Compare commits

...

3 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
6 changed files with 89 additions and 16 deletions

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

@@ -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
}