Compare commits

...

4 Commits

Author SHA1 Message Date
Test
1bca32a263 fix: note path below title — show only on focus, remove duplicate filename
Two bugs fixed in TitleField:

1. Vault-relative path was always visible for subdirectory notes. Now
   only appears when the title input is focused, hidden on blur.

2. Both bare filename and vault-relative path rendered simultaneously.
   Now the filename hint is suppressed when the path is visible, so
   only one line shows (e.g. `docs/adr/0001-tauri-stack`). Also
   strips the .md extension from the displayed path.

Root-level notes continue to show no path (unchanged).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 19:33:52 +02:00
Test
d5c3e1858e feat: selected type section uses type color — filled icon, colored chip, tinted bg
When a type section is selected in the sidebar, the row now uses the
type's accent color: light-tinted background, filled Phosphor icon,
colored label text, and solid-color badge with white text. Matches
the visual treatment of main sections (Inbox, All Notes). Types with
no custom color fall back to the default muted palette.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 19:06:52 +02:00
Test
ab3de7eecd fix: parse numeric YAML values as numbers so _favorite_index persists
parseScalar() returned all non-boolean values as strings, causing
contentToEntryPatch() to map _favorite_index: 2 → favoriteIndex: null
(typeof "2" !== "number"). Every editor autosave then reset the
in-memory favorite index, breaking drag-to-reorder persistence.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:40:18 +02:00
Test
188cd7af8b feat: add Cmd+D keyboard shortcut to toggle favorite
- Cmd+D toggles favorite on the active note (same as star button)
- Registered in command palette as "Add/Remove from Favorites"
- Label adapts based on current favorite state
- Added test for Cmd+D shortcut

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 17:20:33 +02:00
12 changed files with 117 additions and 21 deletions

View File

@@ -466,6 +466,7 @@ function App() {
noteListFilter,
onSetNoteListFilter: setNoteListFilter,
onOpenInNewWindow: handleOpenInNewWindow,
onToggleFavorite: entryActions.handleToggleFavorite,
})
const activeTab = notes.tabs.find((t) => t.entry.path === notes.activeTabPath) ?? null

View File

@@ -1,7 +1,7 @@
import { type ComponentType, useState, useEffect, useRef } from 'react'
import type { SidebarSelection } from '../types'
import { cn } from '@/lib/utils'
import { getTypeColor } from '../utils/typeColors'
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
import { type IconProps } from '@phosphor-icons/react'
export interface SectionGroup {
@@ -94,11 +94,13 @@ export function SectionContent({
}: SectionContentProps) {
const { label, type, Icon, customColor } = group
const sectionColor = getTypeColor(type, customColor)
const sectionLightColor = getTypeLightColor(type, customColor)
return (
<SectionHeader
label={label} type={type} Icon={Icon}
sectionColor={sectionColor}
sectionLightColor={sectionLightColor}
itemCount={itemCount}
isActive={isSelectionActive(selection, { kind: 'sectionGroup', type })}
onSelect={() => onSelect({ kind: 'sectionGroup', type })}
@@ -142,9 +144,9 @@ function InlineRenameInput({ initialValue, onSubmit, onCancel }: {
)
}
function SectionHeader({ label, type, Icon, sectionColor, itemCount, isActive, onSelect, onContextMenu, dragHandleProps, isRenaming, renameInitialValue, onRenameSubmit, onRenameCancel }: {
function SectionHeader({ label, type, Icon, sectionColor, sectionLightColor, itemCount, isActive, onSelect, onContextMenu, dragHandleProps, isRenaming, renameInitialValue, onRenameSubmit, onRenameCancel }: {
label: string; type: string; Icon: ComponentType<IconProps>
sectionColor: string; itemCount: number; isActive: boolean
sectionColor: string; sectionLightColor: string; itemCount: number; isActive: boolean
onSelect: () => void; onContextMenu: (e: React.MouseEvent) => void
dragHandleProps?: Record<string, unknown>
isRenaming?: boolean; renameInitialValue?: string
@@ -152,14 +154,14 @@ function SectionHeader({ label, type, Icon, sectionColor, itemCount, isActive, o
}) {
return (
<div
className={cn("group/section flex cursor-pointer select-none items-center justify-between rounded transition-colors", isActive ? "bg-secondary" : "hover:bg-accent")}
style={{ padding: '6px 8px 6px 16px', borderRadius: 4, gap: 4 }}
className={cn("group/section flex cursor-pointer select-none items-center justify-between rounded transition-colors", !isActive && "hover:bg-accent")}
style={{ padding: '6px 8px 6px 16px', borderRadius: 4, gap: 4, ...(isActive ? { background: sectionLightColor } : {}) }}
{...dragHandleProps}
onClick={() => { if (!isRenaming) onSelect() }}
onContextMenu={isRenaming ? undefined : onContextMenu}
>
<div className="flex min-w-0 flex-1 items-center" style={{ gap: 4 }}>
<Icon size={16} style={{ color: sectionColor, flexShrink: 0 }} />
<Icon size={16} weight={isActive ? 'fill' : 'regular'} style={{ color: sectionColor, flexShrink: 0 }} />
{isRenaming && onRenameSubmit && onRenameCancel ? (
<InlineRenameInput
key={`rename-${type}`}
@@ -168,11 +170,14 @@ function SectionHeader({ label, type, Icon, sectionColor, itemCount, isActive, o
onCancel={onRenameCancel}
/>
) : (
<span className="text-[13px] font-medium text-foreground" style={{ marginLeft: 4 }}>{label}</span>
<span className="text-[13px] font-medium" style={{ marginLeft: 4, color: isActive ? sectionColor : undefined }}>{label}</span>
)}
</div>
{itemCount > 0 && (
<span className="flex items-center justify-center text-muted-foreground" style={{ height: 20, borderRadius: 9999, padding: '0 6px', fontSize: 10, background: 'var(--muted)' }}>
<span
className={cn("flex items-center justify-center", !isActive && "text-muted-foreground")}
style={{ height: 20, borderRadius: 9999, padding: '0 6px', fontSize: 10, ...(isActive ? { background: sectionColor, color: 'white' } : { background: 'var(--muted)' }) }}
>
{itemCount}
</span>
)}

View File

@@ -117,18 +117,35 @@ describe('TitleField', () => {
expect(input).toHaveValue('Untitled note')
})
it('shows vault-relative path for notes in subdirectories', () => {
it('shows vault-relative path (without .md) only when title is focused', () => {
render(<TitleField title="ADR" filename="0001-tauri-stack.md" notePath="/Users/luca/Laputa/docs/adr/0001-tauri-stack.md" vaultPath="/Users/luca/Laputa" onTitleChange={() => {}} />)
expect(screen.getByTestId('title-field-path')).toHaveTextContent('docs/adr/0001-tauri-stack.md')
// Path hidden by default
expect(screen.queryByTestId('title-field-path')).not.toBeInTheDocument()
// Focus title → path appears
fireEvent.focus(screen.getByTestId('title-field-input'))
expect(screen.getByTestId('title-field-path')).toHaveTextContent('docs/adr/0001-tauri-stack')
// No bare filename shown when path is visible
expect(screen.queryByTestId('title-field-filename')).not.toBeInTheDocument()
})
it('hides path for notes at vault root', () => {
it('hides path on blur', () => {
render(<TitleField title="ADR" filename="0001-tauri-stack.md" notePath="/Users/luca/Laputa/docs/adr/0001-tauri-stack.md" vaultPath="/Users/luca/Laputa" onTitleChange={() => {}} />)
const input = screen.getByTestId('title-field-input')
fireEvent.focus(input)
expect(screen.getByTestId('title-field-path')).toBeInTheDocument()
fireEvent.blur(input)
expect(screen.queryByTestId('title-field-path')).not.toBeInTheDocument()
})
it('hides path for notes at vault root even when focused', () => {
render(<TitleField title="Root Note" filename="root-note.md" notePath="/Users/luca/Laputa/root-note.md" vaultPath="/Users/luca/Laputa" onTitleChange={() => {}} />)
fireEvent.focus(screen.getByTestId('title-field-input'))
expect(screen.queryByTestId('title-field-path')).not.toBeInTheDocument()
})
it('hides path when vaultPath is not provided', () => {
render(<TitleField title="Note" filename="note.md" onTitleChange={() => {}} />)
fireEvent.focus(screen.getByTestId('title-field-input'))
expect(screen.queryByTestId('title-field-path')).not.toBeInTheDocument()
})
})

View File

@@ -63,6 +63,7 @@ function useOptimisticTitle(title: string, onTitleChange: (t: string) => void) {
*/
export function TitleField({ title, filename, editable = true, notePath, vaultPath, onTitleChange }: TitleFieldProps) {
const inputRef = useRef<HTMLInputElement>(null)
const [isFocused, setIsFocused] = useState(false)
const { value, isEditing, handleFocus, commitTitle, revert, setEdit } =
useOptimisticTitle(title, onTitleChange)
@@ -91,13 +92,17 @@ export function TitleField({ title, filename, editable = true, notePath, vaultPa
const expectedSlug = slugify(value.trim() || title)
const currentStem = filename.replace(/\.md$/, '')
const showFilename = isEditing || currentStem !== expectedSlug
// Compute vault-relative path (only for notes in subdirectories)
const relativePath = notePath && vaultPath
? notePath.replace(vaultPath + '/', '')
? notePath.replace(vaultPath + '/', '').replace(/\.md$/, '')
: null
const showRelativePath = relativePath && relativePath.includes('/')
const isSubdirectory = relativePath != null && relativePath.includes('/')
// Show path only when title is focused and note is in a subdirectory
const showRelativePath = isFocused && isSubdirectory
// Show filename hint when slug differs, but suppress when path is already visible
const showFilename = !showRelativePath && (isEditing || currentStem !== expectedSlug)
return (
<div className="title-field" data-testid="title-field">
@@ -106,8 +111,8 @@ export function TitleField({ title, filename, editable = true, notePath, vaultPa
className="title-field__input"
value={value}
onChange={e => setEdit(e.target.value)}
onFocus={handleFocus}
onBlur={commitTitle}
onFocus={() => { setIsFocused(true); handleFocus() }}
onBlur={() => { setIsFocused(false); commitTitle() }}
onKeyDown={handleKeyDown}
disabled={!editable}
placeholder="Untitled"

View File

@@ -19,6 +19,8 @@ interface NoteCommandsConfig {
onSetNoteIcon?: () => void
onRemoveNoteIcon?: () => void
onOpenInNewWindow?: () => void
onToggleFavorite?: (path: string) => void
isFavorite?: boolean
}
export function buildNoteCommands(config: NoteCommandsConfig): CommandAction[] {
@@ -28,7 +30,7 @@ export function buildNoteCommands(config: NoteCommandsConfig): CommandAction[] {
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
onEmptyTrash, trashedCount,
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon,
onOpenInNewWindow,
onOpenInNewWindow, onToggleFavorite, isFavorite,
} = config
return [
@@ -47,6 +49,12 @@ export function buildNoteCommands(config: NoteCommandsConfig): CommandAction[] {
keywords: ['archive'], enabled: hasActiveNote,
execute: () => { if (activeTabPath) (isArchived ? onUnarchiveNote : onArchiveNote)(activeTabPath) },
},
{
id: 'toggle-favorite', label: isFavorite ? 'Remove from Favorites' : 'Add to Favorites', group: 'Note', shortcut: '⌘D',
keywords: ['favorite', 'star', 'bookmark', 'pin'],
enabled: hasActiveNote && !!onToggleFavorite,
execute: () => { if (activeTabPath) onToggleFavorite?.(activeTabPath) },
},
{
id: 'set-note-icon', label: 'Set Note Icon', group: 'Note',
keywords: ['icon', 'emoji', 'set', 'add', 'change', 'picker'],

View File

@@ -65,6 +65,7 @@ interface AppCommandsConfig {
noteListFilter?: NoteListFilter
onSetNoteListFilter?: (filter: NoteListFilter) => void
onOpenInNewWindow?: () => void
onToggleFavorite?: (path: string) => void
}
/** Sets up keyboard shortcuts, command registry, menu events, and keyboard navigation. */
@@ -112,6 +113,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onToggleAIChat: config.onToggleAIChat,
onToggleRawEditor: config.onToggleRawEditor,
onToggleInspector: config.onToggleInspector,
onToggleFavorite: config.onToggleFavorite,
onOpenInNewWindow: config.onOpenInNewWindow,
activeTabPathRef: config.activeTabPathRef,
})

View File

@@ -79,6 +79,14 @@ describe('useAppKeyboard', () => {
expect(actions.onCreateNote).toHaveBeenCalled()
})
it('Cmd+D triggers toggle favorite on active note', () => {
const actions = makeActions()
actions.onToggleFavorite = vi.fn()
renderHook(() => useAppKeyboard(actions))
fireKey('d', { metaKey: true })
expect(actions.onToggleFavorite).toHaveBeenCalledWith('/vault/test.md')
})
it('Cmd+J triggers open daily note', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))

View File

@@ -20,6 +20,7 @@ interface KeyboardActions {
onToggleAIChat?: () => void
onToggleRawEditor?: () => void
onToggleInspector?: () => void
onToggleFavorite?: (path: string) => void
onOpenInNewWindow?: () => void
activeTabPathRef: React.MutableRefObject<string | null>
}
@@ -65,7 +66,7 @@ function handleCmdKey(e: KeyboardEvent, keyMap: Record<string, ShortcutHandler>)
export function useAppKeyboard({
onQuickOpen, onCommandPalette, onSearch, onCreateNote, onOpenDailyNote, onSave, onOpenSettings, onTrashNote, onArchiveNote,
onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, onToggleAIChat, onToggleRawEditor, onToggleInspector, onOpenInNewWindow, activeTabPathRef,
onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, onToggleAIChat, onToggleRawEditor, onToggleInspector, onToggleFavorite, onOpenInNewWindow, activeTabPathRef,
}: KeyboardActions) {
useEffect(() => {
const withActiveTab = (fn: (path: string) => void): ShortcutHandler => () => {
@@ -80,6 +81,7 @@ export function useAppKeyboard({
j: onOpenDailyNote,
s: onSave,
',': onOpenSettings,
d: withActiveTab((path) => onToggleFavorite?.(path)),
e: withActiveTab(onArchiveNote),
Backspace: withActiveTab(onTrashNote),
Delete: withActiveTab(onTrashNote),

View File

@@ -30,6 +30,7 @@ interface CommandRegistryConfig {
onSetNoteIcon?: () => void
onRemoveNoteIcon?: () => void
onOpenInNewWindow?: () => void
onToggleFavorite?: (path: string) => void
onQuickOpen: () => void
onCreateNote: () => void
onCreateNoteOfType: (type: string) => void
@@ -85,7 +86,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
mcpStatus, onInstallMcp, onEmptyTrash, trashedCount,
onReloadVault, onRepairVault,
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon,
onOpenInNewWindow,
onOpenInNewWindow, onToggleFavorite,
selection, noteListFilter, onSetNoteListFilter,
} = config
@@ -97,6 +98,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
)
const isArchived = activeEntry?.archived ?? false
const isTrashed = activeEntry?.trashed ?? false
const isFavorite = activeEntry?.favorite ?? false
const isSectionGroup = selection?.kind === 'sectionGroup'
const vaultTypes = useMemo(() => extractVaultTypes(entries), [entries])
@@ -107,7 +109,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
hasActiveNote, activeTabPath, isArchived, isTrashed,
onCreateNote, onCreateType, onOpenDailyNote, onSave,
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
onEmptyTrash, trashedCount, onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, onOpenInNewWindow,
onEmptyTrash, trashedCount, onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, onOpenInNewWindow, onToggleFavorite, isFavorite,
}),
...buildGitCommands({ modifiedCount, onCommitPush, onPull, onResolveConflicts, onSelect }),
...buildViewCommands({
@@ -136,6 +138,6 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
onReloadVault, onRepairVault,
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon,
isSectionGroup, noteListFilter, onSetNoteListFilter,
onOpenInNewWindow,
onOpenInNewWindow, onToggleFavorite, isFavorite,
])
}

View File

@@ -468,6 +468,24 @@ describe('contentToEntryPatch', () => {
const content = '---\ntype: Note\ncustom: value\n---\n'
expect(contentToEntryPatch(content)).toEqual({ isA: 'Note' })
})
it('preserves _favorite_index as a number (not null)', () => {
const content = '---\n_favorite: true\n_favorite_index: 2\n---\nBody'
const patch = contentToEntryPatch(content)
expect(patch.favorite).toBe(true)
expect(patch.favoriteIndex).toBe(2)
})
it('preserves _favorite_index: 0 as number 0', () => {
const content = '---\n_favorite: true\n_favorite_index: 0\n---\nBody'
const patch = contentToEntryPatch(content)
expect(patch.favoriteIndex).toBe(0)
})
it('preserves order as a number', () => {
const content = '---\ntype: Type\norder: 3\n---\n'
expect(contentToEntryPatch(content)).toEqual({ isA: 'Type', order: 3 })
})
})
describe('todayDateString', () => {

View File

@@ -2,6 +2,33 @@ import { describe, it, expect } from 'vitest'
import { parseFrontmatter, detectFrontmatterState } from './frontmatter'
describe('parseFrontmatter', () => {
describe('numeric values', () => {
it('parses integer values as numbers', () => {
const fm = parseFrontmatter('---\n_favorite_index: 2\n---\nBody')
expect(fm['_favorite_index']).toBe(2)
})
it('parses zero as number 0', () => {
const fm = parseFrontmatter('---\n_favorite_index: 0\n---\nBody')
expect(fm['_favorite_index']).toBe(0)
})
it('parses float values as numbers', () => {
const fm = parseFrontmatter('---\norder: 3.5\n---\nBody')
expect(fm['order']).toBe(3.5)
})
it('parses negative numbers', () => {
const fm = parseFrontmatter('---\norder: -1\n---\nBody')
expect(fm['order']).toBe(-1)
})
it('does not parse quoted numbers as numbers', () => {
const fm = parseFrontmatter('---\nversion: "42"\n---\nBody')
expect(fm['version']).toBe('42')
})
})
describe('boolean-like Yes/No values', () => {
it('parses Archived: Yes as true', () => {
const fm = parseFrontmatter('---\nArchived: Yes\n---\nBody')

View File

@@ -26,6 +26,7 @@ function parseScalar(value: string): FrontmatterValue {
const lower = clean.toLowerCase()
if (lower === 'true' || lower === 'yes') return true
if (lower === 'false' || lower === 'no') return false
if (clean === value && /^-?\d+(\.\d+)?$/.test(clean)) return Number(clean)
return clean
}