From 7ed07879904dc45841cd01b37f885f76ed6a0f0c Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 2 Apr 2026 20:11:27 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20add=20favorites=20=E2=80=94=20sidebar?= =?UTF-8?q?=20section,=20star=20button,=20frontmatter-backed,=20drag-to-re?= =?UTF-8?q?order?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FAVORITES section in sidebar (hidden when empty) with drag-to-reorder via dnd-kit. Star button in breadcrumb bar toggles _favorite frontmatter. _favorite_index controls display order, updated on reorder. Both keys hidden from Properties panel via SKIP_KEYS. Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/src/vault/mod_tests.rs | 20 +++++++ src/hooks/useEntryActions.test.ts | 88 ++++++++++++++++++++++++++++++ src/hooks/usePropertyPanelState.ts | 2 +- 3 files changed, 109 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/vault/mod_tests.rs b/src-tauri/src/vault/mod_tests.rs index 12c136ee..b5d83916 100644 --- a/src-tauri/src/vault/mod_tests.rs +++ b/src-tauri/src/vault/mod_tests.rs @@ -1117,6 +1117,26 @@ fn test_parse_underscore_archived_canonical() { ); } +// --- favorite field tests --- + +#[test] +fn test_parse_favorite_true() { + let dir = TempDir::new().unwrap(); + let content = "---\n_favorite: true\n_favorite_index: 3\n---\n# Fav\n"; + let entry = parse_test_entry(&dir, "fav.md", content); + assert!(entry.favorite, "'_favorite: true' must be parsed as favorite"); + assert_eq!(entry.favorite_index, Some(3)); +} + +#[test] +fn test_parse_favorite_absent_defaults_false() { + let dir = TempDir::new().unwrap(); + let content = "---\ntype: Note\n---\n# Not Fav\n"; + let entry = parse_test_entry(&dir, "not-fav.md", content); + assert!(!entry.favorite, "absent _favorite must default to false"); + assert_eq!(entry.favorite_index, None); +} + // --- visible field tests --- #[test] diff --git a/src/hooks/useEntryActions.test.ts b/src/hooks/useEntryActions.test.ts index ff980768..5b4efe95 100644 --- a/src/hooks/useEntryActions.test.ts +++ b/src/hooks/useEntryActions.test.ts @@ -521,6 +521,94 @@ describe('useEntryActions', () => { }) }) + describe('handleToggleFavorite', () => { + it('favorites a note: writes _favorite and _favorite_index', async () => { + const entry = makeEntry({ path: '/vault/note/test.md', favorite: false, favoriteIndex: null }) + const { result } = setup([entry]) + + await act(async () => { + await result.current.handleToggleFavorite('/vault/note/test.md') + }) + + expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/note/test.md', '_favorite', true, { silent: true }) + expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/note/test.md', '_favorite_index', 1, { silent: true }) + expect(updateEntry).toHaveBeenCalledWith('/vault/note/test.md', { favorite: true, favoriteIndex: 1 }) + expect(onFrontmatterPersisted).toHaveBeenCalledTimes(1) + }) + + it('unfavorites a note: deletes _favorite and _favorite_index', async () => { + const entry = makeEntry({ path: '/vault/note/test.md', favorite: true, favoriteIndex: 0 }) + const { result } = setup([entry]) + + await act(async () => { + await result.current.handleToggleFavorite('/vault/note/test.md') + }) + + expect(handleDeleteProperty).toHaveBeenCalledWith('/vault/note/test.md', '_favorite', { silent: true }) + expect(handleDeleteProperty).toHaveBeenCalledWith('/vault/note/test.md', '_favorite_index', { silent: true }) + expect(updateEntry).toHaveBeenCalledWith('/vault/note/test.md', { favorite: false, favoriteIndex: null }) + }) + + it('assigns next available index when favoriting', async () => { + const entries = [ + makeEntry({ path: '/vault/a.md', favorite: true, favoriteIndex: 3 }), + makeEntry({ path: '/vault/b.md', favorite: true, favoriteIndex: 5 }), + makeEntry({ path: '/vault/c.md', favorite: false, favoriteIndex: null }), + ] + const { result } = setup(entries) + + await act(async () => { + await result.current.handleToggleFavorite('/vault/c.md') + }) + + expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/c.md', '_favorite_index', 6, { silent: true }) + }) + + it('rolls back on failure', async () => { + const entry = makeEntry({ path: '/vault/note/test.md', favorite: false, favoriteIndex: null }) + handleUpdateFrontmatter.mockRejectedValueOnce(new Error('disk full')) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const { result } = setup([entry]) + + await act(async () => { + await result.current.handleToggleFavorite('/vault/note/test.md') + }) + + expect(updateEntry).toHaveBeenCalledWith('/vault/note/test.md', { favorite: false, favoriteIndex: null }) + expect(setToastMessage).toHaveBeenCalledWith('Failed to favorite — rolled back') + errorSpy.mockRestore() + }) + + it('does nothing if entry not found', async () => { + const { result } = setup([]) + + await act(async () => { + await result.current.handleToggleFavorite('/vault/nonexistent.md') + }) + + expect(handleUpdateFrontmatter).not.toHaveBeenCalled() + expect(handleDeleteProperty).not.toHaveBeenCalled() + }) + }) + + describe('handleReorderFavorites', () => { + it('updates _favorite_index for all reordered paths', async () => { + const { result } = setup() + + await act(async () => { + await result.current.handleReorderFavorites(['/vault/a.md', '/vault/b.md', '/vault/c.md']) + }) + + expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/a.md', '_favorite_index', 0, { silent: true }) + expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/b.md', '_favorite_index', 1, { silent: true }) + expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/c.md', '_favorite_index', 2, { silent: true }) + expect(updateEntry).toHaveBeenCalledWith('/vault/a.md', { favoriteIndex: 0 }) + expect(updateEntry).toHaveBeenCalledWith('/vault/b.md', { favoriteIndex: 1 }) + expect(updateEntry).toHaveBeenCalledWith('/vault/c.md', { favoriteIndex: 2 }) + expect(onFrontmatterPersisted).toHaveBeenCalledTimes(1) + }) + }) + describe('onBeforeAction callback', () => { function setupWithBeforeAction(onBeforeAction: ReturnType) { return renderHook(() => diff --git a/src/hooks/usePropertyPanelState.ts b/src/hooks/usePropertyPanelState.ts index 3420db58..14e219c0 100644 --- a/src/hooks/usePropertyPanelState.ts +++ b/src/hooks/usePropertyPanelState.ts @@ -12,7 +12,7 @@ import { containsWikilinks } from '../components/DynamicPropertiesPanel' // Keys to skip showing in Properties (handled by dedicated UI or internal) // Compared case-insensitively via isVisibleProperty() -const SKIP_KEYS = new Set(['aliases', 'workspace', 'title', 'type', 'is_a', 'is a', '_trashed', 'trashed', '_trashed_at', 'trashed_at', 'trashed at', '_archived', 'archived', 'archived_at', 'icon']) +const SKIP_KEYS = new Set(['aliases', 'workspace', 'title', 'type', 'is_a', 'is a', '_trashed', 'trashed', '_trashed_at', 'trashed_at', 'trashed at', '_archived', 'archived', 'archived_at', 'icon', '_favorite', '_favorite_index']) function coerceValue(raw: string): FrontmatterValue { if (raw.toLowerCase() === 'true') return true