{noteStatus !== 'clean' && }
+ {entry.icon && isEmoji(entry.icon) && {entry.icon}}
{entry.title}
diff --git a/src/components/SearchPanel.tsx b/src/components/SearchPanel.tsx
index 29ede4a3..488747be 100644
--- a/src/components/SearchPanel.tsx
+++ b/src/components/SearchPanel.tsx
@@ -6,6 +6,7 @@ import { useUnifiedSearch } from '../hooks/useUnifiedSearch'
import { getTypeColor, buildTypeEntryMap } from '../utils/typeColors'
import { formatSearchSubtitle } from '../utils/noteListHelpers'
import { getTypeIcon } from './NoteItem'
+import { isEmoji } from '../utils/emoji'
interface SearchPanelProps {
open: boolean
@@ -210,7 +211,10 @@ function SearchContent({
>
- {result.title}
+
+ {entry?.icon && isEmoji(entry.icon) && {entry.icon}}
+ {result.title}
+
{noteType && (
{noteType}
)}
diff --git a/src/hooks/useAppCommands.ts b/src/hooks/useAppCommands.ts
index 02c1ce03..bcde476b 100644
--- a/src/hooks/useAppCommands.ts
+++ b/src/hooks/useAppCommands.ts
@@ -71,6 +71,9 @@ interface AppCommandsConfig {
onReindexVault?: () => void
onReloadVault?: () => void
onRepairVault?: () => void
+ onSetNoteIcon?: () => void
+ onRemoveNoteIcon?: () => void
+ activeNoteHasIcon?: boolean
}
/** Sets up keyboard shortcuts, command registry, menu events, and keyboard navigation. */
@@ -217,6 +220,9 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onReindexVault: config.onReindexVault,
onReloadVault: config.onReloadVault,
onRepairVault: config.onRepairVault,
+ onSetNoteIcon: config.onSetNoteIcon,
+ onRemoveNoteIcon: config.onRemoveNoteIcon,
+ activeNoteHasIcon: config.activeNoteHasIcon,
})
useKeyboardNavigation({
diff --git a/src/hooks/useCommandRegistry.test.ts b/src/hooks/useCommandRegistry.test.ts
index 00ca2dbe..d047a5cd 100644
--- a/src/hooks/useCommandRegistry.test.ts
+++ b/src/hooks/useCommandRegistry.test.ts
@@ -146,6 +146,51 @@ describe('useCommandRegistry', () => {
rerender(makeConfig())
expect(findCommand(result.current, 'resolve-conflicts')!.enabled).toBe(true)
})
+
+ it('includes set-note-icon command in Note group', () => {
+ const config = makeConfig({ onSetNoteIcon: vi.fn() })
+ const { result } = renderHook(() => useCommandRegistry(config))
+ const cmd = findCommand(result.current, 'set-note-icon')
+ expect(cmd).toBeDefined()
+ expect(cmd!.group).toBe('Note')
+ expect(cmd!.label).toBe('Set Note Icon')
+ })
+
+ it('set-note-icon is enabled when active note and callback exist', () => {
+ const config = makeConfig({ onSetNoteIcon: vi.fn() })
+ const { result } = renderHook(() => useCommandRegistry(config))
+ const cmd = findCommand(result.current, 'set-note-icon')
+ expect(cmd!.enabled).toBe(true)
+ })
+
+ it('set-note-icon is disabled when no active note', () => {
+ const config = makeConfig({ activeTabPath: null, onSetNoteIcon: vi.fn() })
+ const { result } = renderHook(() => useCommandRegistry(config))
+ const cmd = findCommand(result.current, 'set-note-icon')
+ expect(cmd!.enabled).toBe(false)
+ })
+
+ it('remove-note-icon is enabled when active note has icon', () => {
+ const config = makeConfig({ onRemoveNoteIcon: vi.fn(), activeNoteHasIcon: true })
+ const { result } = renderHook(() => useCommandRegistry(config))
+ const cmd = findCommand(result.current, 'remove-note-icon')
+ expect(cmd!.enabled).toBe(true)
+ })
+
+ it('remove-note-icon is disabled when active note has no icon', () => {
+ const config = makeConfig({ onRemoveNoteIcon: vi.fn(), activeNoteHasIcon: false })
+ const { result } = renderHook(() => useCommandRegistry(config))
+ const cmd = findCommand(result.current, 'remove-note-icon')
+ expect(cmd!.enabled).toBe(false)
+ })
+
+ it('set-note-icon executes callback', () => {
+ const onSetNoteIcon = vi.fn()
+ const config = makeConfig({ onSetNoteIcon })
+ const { result } = renderHook(() => useCommandRegistry(config))
+ findCommand(result.current, 'set-note-icon')!.execute()
+ expect(onSetNoteIcon).toHaveBeenCalled()
+ })
})
describe('pluralizeType', () => {
diff --git a/src/hooks/useCommandRegistry.ts b/src/hooks/useCommandRegistry.ts
index 4352abf0..3968eec6 100644
--- a/src/hooks/useCommandRegistry.ts
+++ b/src/hooks/useCommandRegistry.ts
@@ -18,6 +18,8 @@ interface CommandRegistryConfig {
activeTabPath: string | null
entries: VaultEntry[]
modifiedCount: number
+ /** Whether the active note has an emoji icon set. */
+ activeNoteHasIcon?: boolean
mcpStatus?: string
onInstallMcp?: () => void
onEmptyTrash?: () => void
@@ -25,6 +27,8 @@ interface CommandRegistryConfig {
onReindexVault?: () => void
onReloadVault?: () => void
onRepairVault?: () => void
+ onSetNoteIcon?: () => void
+ onRemoveNoteIcon?: () => void
onQuickOpen: () => void
onCreateNote: () => void
@@ -205,6 +209,9 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
onReindexVault,
onReloadVault,
onRepairVault,
+ onSetNoteIcon,
+ onRemoveNoteIcon,
+ activeNoteHasIcon,
} = config
const hasActiveNote = activeTabPath !== null
@@ -247,6 +254,18 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
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?.(),
+ },
// Git
{ id: 'commit-push', label: 'Commit & Push', group: 'Git', keywords: ['git', 'save', 'sync'], enabled: modifiedCount > 0, execute: onCommitPush },
@@ -290,5 +309,6 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
mcpStatus, onInstallMcp,
onEmptyTrash, trashedCount,
onReindexVault, onReloadVault, onRepairVault,
+ onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon,
])
}
diff --git a/src/hooks/useNoteSearch.ts b/src/hooks/useNoteSearch.ts
index 03a5644c..fee375f2 100644
--- a/src/hooks/useNoteSearch.ts
+++ b/src/hooks/useNoteSearch.ts
@@ -4,6 +4,7 @@ import { fuzzyMatch, bestSearchRank } from '../utils/fuzzyMatch'
import { getTypeColor, getTypeLightColor, buildTypeEntryMap } from '../utils/typeColors'
import { getTypeIcon } from '../components/NoteItem'
import type { NoteSearchResultItem } from '../components/NoteSearchList'
+import { isEmoji } from '../utils/emoji'
const DEFAULT_MAX_RESULTS = 20
@@ -14,9 +15,10 @@ export interface NoteSearchResult extends NoteSearchResultItem {
function toResult(e: VaultEntry, typeEntryMap: Record): NoteSearchResult {
const noteType = e.isA || undefined
const te = typeEntryMap[e.isA ?? '']
+ const emojiPrefix = e.icon && isEmoji(e.icon) ? `${e.icon} ` : ''
return {
entry: e,
- title: e.title,
+ title: `${emojiPrefix}${e.title}`,
noteType,
typeColor: noteType ? getTypeColor(e.isA, te?.color) : undefined,
typeLightColor: noteType ? getTypeLightColor(e.isA, te?.color) : undefined,
diff --git a/src/utils/__tests__/emoji.test.ts b/src/utils/__tests__/emoji.test.ts
new file mode 100644
index 00000000..91db78a9
--- /dev/null
+++ b/src/utils/__tests__/emoji.test.ts
@@ -0,0 +1,39 @@
+import { describe, it, expect } from 'vitest'
+import { isEmoji } from '../emoji'
+
+describe('isEmoji', () => {
+ it('returns true for common emoji', () => {
+ expect(isEmoji('๐ฏ')).toBe(true)
+ expect(isEmoji('๐ฅ')).toBe(true)
+ expect(isEmoji('๐')).toBe(true)
+ expect(isEmoji('โค๏ธ')).toBe(true)
+ expect(isEmoji('โจ')).toBe(true)
+ })
+
+ it('returns false for Phosphor icon names', () => {
+ expect(isEmoji('cooking-pot')).toBe(false)
+ expect(isEmoji('file-text')).toBe(false)
+ expect(isEmoji('rocket')).toBe(false)
+ expect(isEmoji('star')).toBe(false)
+ })
+
+ it('returns false for empty string', () => {
+ expect(isEmoji('')).toBe(false)
+ })
+
+ it('returns false for regular text', () => {
+ expect(isEmoji('hello')).toBe(false)
+ expect(isEmoji('ABC')).toBe(false)
+ expect(isEmoji('123')).toBe(false)
+ })
+
+ it('handles compound emoji (ZWJ sequences)', () => {
+ expect(isEmoji('๐จโ๐ป')).toBe(true)
+ expect(isEmoji('๐งโ๐ฌ')).toBe(true)
+ })
+
+ it('returns false for multi-emoji strings', () => {
+ expect(isEmoji('๐ฅ๐')).toBe(false)
+ expect(isEmoji('hi ๐ฏ')).toBe(false)
+ })
+})
diff --git a/src/utils/emoji.ts b/src/utils/emoji.ts
new file mode 100644
index 00000000..11edc77b
--- /dev/null
+++ b/src/utils/emoji.ts
@@ -0,0 +1,53 @@
+/**
+ * Detects whether a string is a single emoji (as opposed to a Phosphor icon name).
+ * Used to differentiate emoji note icons from kebab-case Phosphor icon names.
+ */
+export function isEmoji(value: string): boolean {
+ if (!value) return false
+ // Phosphor icon names are always lowercase ASCII with hyphens
+ if (/^[a-z][a-z0-9-]*$/.test(value)) return false
+ // Match a single emoji (including compound emoji with ZWJ, skin tones, variation selectors, flags)
+ // Uses Unicode segmentation: a single emoji can be base + modifiers/ZWJ sequences
+ const emojiRegex = /^(\p{Emoji_Presentation}|\p{Emoji}\ufe0f)(\u200d(\p{Emoji_Presentation}|\p{Emoji}\ufe0f)|\p{Emoji_Modifier})*$/u
+ return emojiRegex.test(value)
+}
+
+/** Curated emoji categories for the note icon picker. */
+export const EMOJI_CATEGORIES: { name: string; emojis: string[] }[] = [
+ {
+ name: 'Smileys',
+ emojis: ['๐', '๐', '๐', '๐', '๐', '๐ฅน', '๐
', '๐คฃ', '๐', '๐', '๐', '๐', '๐', '๐ฅฐ', '๐', '๐คฉ', '๐', '๐', '๐ค', '๐ง', '๐ค', '๐ค', '๐ซก', '๐คซ', '๐ซ ', '๐ถ', '๐', '๐ฌ', '๐', '๐ด'],
+ },
+ {
+ name: 'Hands & People',
+ emojis: ['๐', '๐ค', 'โ', '๐๏ธ', '๐', '๐ค', 'โ๏ธ', '๐ค', '๐ซฐ', '๐ค', '๐', '๐', '๐', '๐', '๐ซถ', '๐', '๐ช', '๐ง ', '๐', '๐๏ธ', '๐ค', '๐งโ๐ป', '๐งโ๐จ', '๐งโ๐ฌ', '๐งโ๐', '๐งโ๐ซ', '๐งโโ๏ธ', '๐งโ๐ณ', '๐', '๐ง'],
+ },
+ {
+ name: 'Nature',
+ emojis: ['๐ฑ', '๐ฟ', '๐', '๐ต', '๐ฒ', '๐ณ', '๐ด', '๐ธ', '๐บ', '๐ป', '๐น', '๐', '๐', '๐', '๐', '๐พ', '๐', '๐ฆ', '๐', '๐', '๐ฆ', '๐ฆ
', '๐บ', '๐ฆ', '๐ป', '๐ผ', '๐จ', '๐ฏ', '๐ฆ', '๐ธ'],
+ },
+ {
+ name: 'Food & Drink',
+ emojis: ['๐', '๐', '๐', '๐', '๐', '๐', '๐', '๐', '๐ซ', '๐', '๐', '๐ฅ', '๐
', '๐ฅ', '๐ฝ', '๐ฅ', '๐ง
', '๐', '๐ฅ', '๐ง', '๐ฐ', '๐', '๐ช', 'โ', '๐ต', '๐ง', '๐ฅค', '๐บ', '๐ท', '๐ฅ'],
+ },
+ {
+ name: 'Activities',
+ emojis: ['โฝ', '๐', '๐', 'โพ', '๐พ', '๐', '๐ฑ', '๐', '๐ฎ', '๐น๏ธ', '๐ฒ', '๐งฉ', 'โ๏ธ', '๐ฏ', '๐ณ', '๐ธ', '๐น', '๐บ', '๐จ', '๐๏ธ', '๐ท', '๐ฌ', '๐ญ', '๐ค', '๐ง', '๐', '๐', 'โ๏ธ', '๐๏ธ', '๐'],
+ },
+ {
+ name: 'Travel & Places',
+ emojis: ['๐ ', '๐ก', '๐ข', '๐๏ธ', '๐ฐ', '๐๏ธ', 'โช', '๐', '๐ผ', '๐ฝ', 'โฒ', '๐ช', '๐', 'โ๏ธ', '๐', '๐', '๐ฒ', 'โต', '๐๏ธ', '๐', '๐๏ธ', '๐๏ธ', '๐บ๏ธ', '๐', '๐', '๐', '๐งญ', 'โบ', '๐ก', '๐ข'],
+ },
+ {
+ name: 'Objects',
+ emojis: ['๐ก', '๐ฆ', '๐ฏ๏ธ', '๐ฑ', '๐ป', 'โจ๏ธ', '๐ฅ๏ธ', '๐จ๏ธ', '๐ธ', '๐ญ', '๐ฌ', '๐งช', '๐', '๐ฉบ', '๐', '๐๏ธ', '๐', '๐', '๐งฒ', 'โ๏ธ', '๐ง', '๐จ', 'โ๏ธ', '๐ช', '๐งฐ', '๐ฆ', '๐ฎ', 'โ๏ธ', '๐ฉ', '๐ท๏ธ'],
+ },
+ {
+ name: 'Symbols',
+ emojis: ['โค๏ธ', '๐งก', '๐', '๐', '๐', '๐', '๐ค', '๐ค', '๐', 'โฃ๏ธ', '๐', '๐', 'โญ', '๐', 'โจ', '๐ซ', '๐ฅ', '๐ฅ', '๐', '๐', '๐', '๐ฅ', '๐ฅ', '๐ฅ', '๐
', '๐๏ธ', '๐', '๐', '๐ฉ', '๐'],
+ },
+ {
+ name: 'Flags & Signs',
+ emojis: ['โ
', 'โ', 'โญ', 'โ', 'โ', '๐ฏ', '๐ด', '๐ ', '๐ก', '๐ข', '๐ต', '๐ฃ', 'โซ', 'โช', '๐ค', '๐ถ', '๐ท', '๐ธ', '๐น', 'โถ๏ธ', 'โธ๏ธ', 'โน๏ธ', 'โบ๏ธ', 'โญ๏ธ', '๐', '๐', '๐', '๐', 'โก๏ธ', 'โฌ
๏ธ'],
+ },
+]
diff --git a/tests/smoke/note-icon.spec.ts b/tests/smoke/note-icon.spec.ts
new file mode 100644
index 00000000..d0b209a9
--- /dev/null
+++ b/tests/smoke/note-icon.spec.ts
@@ -0,0 +1,165 @@
+import { test, expect } from '@playwright/test'
+import { openCommandPalette, findCommand, executeCommand, sendShortcut } from './helpers'
+
+test.describe('Note icon emoji picker', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/')
+ await page.waitForLoadState('networkidle')
+ await page.waitForTimeout(2500)
+ })
+
+ test('emoji picker opens from Add Icon button and selects emoji', async ({ page }) => {
+ // Open a note by clicking the first item in the note list
+ const noteItem = page.locator('[data-testid="note-list-container"] .cursor-pointer').first()
+ await noteItem.waitFor({ timeout: 5000 })
+ await noteItem.click()
+ await page.waitForTimeout(500)
+
+ // The note icon area should be visible
+ const iconArea = page.locator('[data-testid="note-icon-area"]')
+ await expect(iconArea).toBeVisible({ timeout: 3000 })
+
+ // Hover to reveal the "Add icon" button and click it
+ await iconArea.hover()
+ const addButton = page.locator('[data-testid="note-icon-add"]')
+ await expect(addButton).toBeVisible()
+ await addButton.click()
+
+ // Emoji picker should appear
+ const picker = page.locator('[data-testid="emoji-picker"]')
+ await expect(picker).toBeVisible({ timeout: 2000 })
+
+ // Click the first emoji
+ const emojiOption = picker.locator('[data-testid="emoji-option"]').first()
+ await emojiOption.click()
+
+ // Picker should close
+ await expect(picker).not.toBeVisible()
+
+ // The icon should now be displayed
+ const iconDisplay = page.locator('[data-testid="note-icon-display"]')
+ await expect(iconDisplay).toBeVisible({ timeout: 2000 })
+ })
+
+ test('emoji icon can be removed via menu', async ({ page }) => {
+ // Open a note
+ const noteItem = page.locator('[data-testid="note-list-container"] .cursor-pointer').first()
+ await noteItem.waitFor({ timeout: 5000 })
+ await noteItem.click()
+ await page.waitForTimeout(500)
+
+ // Add an icon first
+ const iconArea = page.locator('[data-testid="note-icon-area"]')
+ await iconArea.hover()
+ await page.locator('[data-testid="note-icon-add"]').click()
+ await page.locator('[data-testid="emoji-option"]').first().click()
+ await page.waitForTimeout(300)
+
+ // Click the displayed icon to show the menu
+ const iconDisplay = page.locator('[data-testid="note-icon-display"]')
+ await iconDisplay.click()
+
+ // Menu should show Remove option
+ const removeButton = page.locator('[data-testid="note-icon-remove"]')
+ await expect(removeButton).toBeVisible()
+ await removeButton.click()
+
+ // Icon should be removed, add button returns on hover
+ await expect(iconDisplay).not.toBeVisible()
+ await iconArea.hover()
+ await expect(page.locator('[data-testid="note-icon-add"]')).toBeVisible()
+ })
+
+ test('Cmd+K shows Set Note Icon command', async ({ page }) => {
+ // Open a note first
+ const noteItem = page.locator('[data-testid="note-list-container"] .cursor-pointer').first()
+ await noteItem.waitFor({ timeout: 5000 })
+ await noteItem.click()
+ await page.waitForTimeout(500)
+
+ // Open command palette and search for the icon command
+ await openCommandPalette(page)
+ const found = await findCommand(page, 'Set Note Icon')
+ expect(found).toBe(true)
+ })
+
+ test('Set Note Icon command opens emoji picker', async ({ page }) => {
+ // Open a note first
+ const noteItem = page.locator('[data-testid="note-list-container"] .cursor-pointer').first()
+ await noteItem.waitFor({ timeout: 5000 })
+ await noteItem.click()
+ await page.waitForTimeout(500)
+
+ // Use command palette to open emoji picker
+ await openCommandPalette(page)
+ await executeCommand(page, 'Set Note Icon')
+ await page.waitForTimeout(300)
+
+ // Emoji picker should be visible
+ const picker = page.locator('[data-testid="emoji-picker"]')
+ await expect(picker).toBeVisible({ timeout: 2000 })
+
+ // Select an emoji
+ await picker.locator('[data-testid="emoji-option"]').first().click()
+
+ // Icon should be displayed
+ const iconDisplay = page.locator('[data-testid="note-icon-display"]')
+ await expect(iconDisplay).toBeVisible({ timeout: 2000 })
+ })
+
+ test('Escape closes the emoji picker', async ({ page }) => {
+ // Open a note
+ const noteItem = page.locator('[data-testid="note-list-container"] .cursor-pointer').first()
+ await noteItem.waitFor({ timeout: 5000 })
+ await noteItem.click()
+ await page.waitForTimeout(500)
+
+ // Open picker
+ const iconArea = page.locator('[data-testid="note-icon-area"]')
+ await iconArea.hover()
+ await page.locator('[data-testid="note-icon-add"]').click()
+
+ const picker = page.locator('[data-testid="emoji-picker"]')
+ await expect(picker).toBeVisible()
+
+ // Press Escape to close
+ await page.keyboard.press('Escape')
+ await expect(picker).not.toBeVisible()
+ })
+
+ test('emoji icon is shown in Quick Open (Cmd+P) results', async ({ page }) => {
+ // Open a note and set an icon
+ const noteItem = page.locator('[data-testid="note-list-container"] .cursor-pointer').first()
+ await noteItem.waitFor({ timeout: 5000 })
+ // Get the note title for later search
+ const noteTitle = await noteItem.locator('.font-medium, .text-sm').first().textContent()
+ await noteItem.click()
+ await page.waitForTimeout(500)
+
+ // Set an icon
+ const iconArea = page.locator('[data-testid="note-icon-area"]')
+ await iconArea.hover()
+ await page.locator('[data-testid="note-icon-add"]').click()
+ const firstEmoji = page.locator('[data-testid="emoji-option"]').first()
+ const emojiText = await firstEmoji.textContent()
+ await firstEmoji.click()
+ await page.waitForTimeout(500)
+
+ // Open Quick Open and search for the note
+ await page.locator('body').click()
+ await sendShortcut(page, 'p', ['Control'])
+ const searchInput = page.locator('input[placeholder="Search notes..."]')
+ await expect(searchInput).toBeVisible()
+
+ if (noteTitle && emojiText) {
+ await searchInput.fill(noteTitle.trim().substring(0, 10))
+ await page.waitForTimeout(300)
+
+ // Verify the emoji appears in the search results
+ const results = page.locator('.py-1 .cursor-pointer .truncate')
+ const resultTexts = await results.allTextContents()
+ const hasEmoji = resultTexts.some(t => t.includes(emojiText))
+ expect(hasEmoji).toBe(true)
+ }
+ })
+})