From 013cf0ffe174986f6f768ea500b2f12d5a4ba15d Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 5 Mar 2026 15:26:21 +0100 Subject: [PATCH] feat: move vault UI config from localStorage to vault files Add VaultConfig infrastructure (store, hook, migration) that persists zoom, view mode, section visibility, tag/status colors, and property display modes to config/ui.config.md in the vault instead of localStorage. - New vaultConfigStore module with subscribe/notify pattern - useVaultConfig hook loads config via Tauri, binds store, runs migration - One-time silent migration from localStorage on first load - Config type excluded from note search and unified search - All hooks/utils updated to read/write through vault config store - Tests updated to use vault config store instead of localStorage mocks Co-Authored-By: Claude Opus 4.6 --- src/App.tsx | 2 + .../DynamicPropertiesPanel.test.tsx | 40 ++++---- src/components/Sidebar.test.tsx | 39 ++++---- src/hooks/useNoteActions.ts | 3 +- src/hooks/useNoteSearch.ts | 14 ++- src/hooks/useSectionVisibility.ts | 29 ++++-- src/hooks/useUnifiedSearch.ts | 1 + src/hooks/useVaultConfig.ts | 67 +++++++++++++ src/hooks/useViewMode.test.ts | 38 ++++---- src/hooks/useViewMode.ts | 24 ++++- src/hooks/useZoom.test.ts | 49 +++++----- src/hooks/useZoom.ts | 29 +++++- src/mock-tauri/mock-entries.ts | 80 ++++++++-------- src/mock-tauri/mock-handlers.ts | 4 +- src/types.ts | 12 +++ src/utils/configMigration.ts | 95 +++++++++++++++++++ src/utils/propertyTypes.ts | 19 +++- src/utils/statusStyles.test.ts | 36 +++---- src/utils/statusStyles.ts | 13 ++- src/utils/tagStyles.test.ts | 41 +++----- src/utils/tagStyles.ts | 13 ++- src/utils/vaultConfigStore.ts | 43 +++++++++ 22 files changed, 483 insertions(+), 208 deletions(-) create mode 100644 src/hooks/useVaultConfig.ts create mode 100644 src/utils/configMigration.ts create mode 100644 src/utils/vaultConfigStore.ts diff --git a/src/App.tsx b/src/App.tsx index aedbbb13..5d82aa57 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -30,6 +30,7 @@ import { useAutoSync } from './hooks/useAutoSync' import { useConflictResolver } from './hooks/useConflictResolver' import { useIndexing } from './hooks/useIndexing' import { useZoom } from './hooks/useZoom' +import { useVaultConfig } from './hooks/useVaultConfig' import { useBuildNumber } from './hooks/useBuildNumber' import { useOnboarding } from './hooks/useOnboarding' import { useThemeManager } from './hooks/useThemeManager' @@ -102,6 +103,7 @@ function App() { // When onboarding resolves to a different vault path, update the switcher const resolvedPath = onboarding.state.status === 'ready' ? onboarding.state.vaultPath : vaultSwitcher.vaultPath const vault = useVaultLoader(resolvedPath) + useVaultConfig(resolvedPath) const { settings, saveSettings } = useSettings() const themeManager = useThemeManager(resolvedPath, vault.entries, vault.allContent, vault.updateContent) diff --git a/src/components/DynamicPropertiesPanel.test.tsx b/src/components/DynamicPropertiesPanel.test.tsx index 269170b6..b86808fb 100644 --- a/src/components/DynamicPropertiesPanel.test.tsx +++ b/src/components/DynamicPropertiesPanel.test.tsx @@ -2,20 +2,8 @@ import { describe, it, expect, vi, beforeEach, beforeAll } from 'vitest' import { render, screen, fireEvent } from '@testing-library/react' import { DynamicPropertiesPanel, containsWikilinks } from './DynamicPropertiesPanel' import type { VaultEntry } from '../types' - -// Mock localStorage (jsdom's may be incomplete) -const localStorageMock = (() => { - let store: Record = {} - return { - getItem: (key: string) => store[key] ?? null, - setItem: vi.fn((key: string, value: string) => { store[key] = value }), - removeItem: (key: string) => { delete store[key] }, - clear: () => { store = {} }, - get length() { return Object.keys(store).length }, - key: (i: number) => Object.keys(store)[i] ?? null, - } -})() -Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true }) +import { bindVaultConfigStore, getVaultConfig, resetVaultConfigStore } from '../utils/vaultConfigStore' +import { initDisplayModeOverrides } from '../utils/propertyTypes' // Radix Select needs ResizeObserver and pointer/scroll APIs in JSDOM beforeAll(() => { @@ -847,7 +835,12 @@ describe('DynamicPropertiesPanel', () => { describe('display mode override', () => { beforeEach(() => { - localStorageMock.clear() + resetVaultConfigStore() + bindVaultConfigStore( + { zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null, hidden_sections: null }, + vi.fn(), + ) + initDisplayModeOverrides({}) }) it('renders display mode trigger on property rows', () => { @@ -880,7 +873,7 @@ describe('DynamicPropertiesPanel', () => { expect(screen.getByTestId('display-mode-option-url')).toBeInTheDocument() }) - it('persists override to localStorage when mode selected', () => { + it('persists override to vault config when mode selected', () => { render( { ) fireEvent.click(screen.getByTestId('display-mode-trigger')) fireEvent.click(screen.getByTestId('display-mode-option-status')) - const stored = JSON.parse(localStorageMock.getItem('laputa:display-mode-overrides') ?? '{}') + const stored = getVaultConfig().property_display_modes as Record + expect(stored).toBeTruthy() expect(stored.cadence).toBe('status') }) it('overrides rendering to status badge when status mode selected', () => { - localStorageMock.setItem('laputa:display-mode-overrides', JSON.stringify({ cadence: 'status' })) + initDisplayModeOverrides({ cadence: 'status' }) render( { }) it('renders boolean toggle for string "true" when boolean mode overridden', () => { - localStorageMock.setItem('laputa:display-mode-overrides', JSON.stringify({ draft: 'boolean' })) + initDisplayModeOverrides({ draft: 'boolean' }) render( { }) it('renders boolean toggle for string "false" when boolean mode overridden', () => { - localStorageMock.setItem('laputa:display-mode-overrides', JSON.stringify({ draft: 'boolean' })) + initDisplayModeOverrides({ draft: 'boolean' }) render( { }) it('toggles string boolean from false to true', () => { - localStorageMock.setItem('laputa:display-mode-overrides', JSON.stringify({ draft: 'boolean' })) + initDisplayModeOverrides({ draft: 'boolean' }) render( { }) it('renders date picker for empty value when date mode overridden', () => { - localStorageMock.setItem('laputa:display-mode-overrides', JSON.stringify({ due: 'date' })) + initDisplayModeOverrides({ due: 'date' }) render( { }) it('renders date picker for non-date string when date mode overridden', () => { - localStorageMock.setItem('laputa:display-mode-overrides', JSON.stringify({ deadline: 'date' })) + initDisplayModeOverrides({ deadline: 'date' }) render( { - let store: Record = {} - return { - getItem: (key: string) => store[key] ?? null, - setItem: (key: string, value: string) => { store[key] = value }, - removeItem: (key: string) => { delete store[key] }, - clear: () => { store = {} }, - } -})() -Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true }) +import { bindVaultConfigStore, getVaultConfig, resetVaultConfigStore } from '../utils/vaultConfigStore' const mockEntries: VaultEntry[] = [ { @@ -689,7 +678,11 @@ describe('Sidebar', () => { describe('customize section visibility', () => { beforeEach(() => { - localStorageMock.clear() + resetVaultConfigStore() + bindVaultConfigStore( + { zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null, hidden_sections: null }, + vi.fn(), + ) }) it('renders a "Customize sections" button', () => { @@ -735,19 +728,23 @@ describe('Sidebar', () => { expect(peopleElements.length).toBe(2) }) - it('persists hidden sections in localStorage', () => { + it('persists hidden sections in vault config', () => { const { unmount } = render( {}} />) fireEvent.click(screen.getByTitle('Customize sections')) fireEvent.click(screen.getByLabelText('Toggle Events')) unmount() - // Verify localStorage was updated - const stored = JSON.parse(localStorage.getItem('laputa-hidden-sections') || '[]') + // Verify vault config was updated + const stored = getVaultConfig().hidden_sections expect(stored).toContain('Event') }) - it('restores hidden sections from localStorage on mount', () => { - localStorage.setItem('laputa-hidden-sections', JSON.stringify(['Person', 'Event'])) + it('restores hidden sections from vault config on mount', () => { + resetVaultConfigStore() + bindVaultConfigStore( + { zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null, hidden_sections: ['Person', 'Event'] }, + vi.fn(), + ) render( {}} />) // People and Events section headers should be hidden @@ -760,7 +757,11 @@ describe('Sidebar', () => { }) it('does not affect All Notes or other sidebar filters when sections are hidden', () => { - localStorage.setItem('laputa-hidden-sections', JSON.stringify(['Project', 'Person'])) + resetVaultConfigStore() + bindVaultConfigStore( + { zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null, hidden_sections: ['Project', 'Person'] }, + vi.fn(), + ) render( {}} />) // Top nav items still present diff --git a/src/hooks/useNoteActions.ts b/src/hooks/useNoteActions.ts index ee2a17ad..b4ec2068 100644 --- a/src/hooks/useNoteActions.ts +++ b/src/hooks/useNoteActions.ts @@ -72,7 +72,7 @@ export function buildNewEntry({ path, slug, title, type, status }: NewEntryParam aliases: [], belongsTo: [], relatedTo: [], status, owner: null, cadence: null, archived: false, trashed: false, trashedAt: null, modifiedAt: now, createdAt: now, fileSize: 0, - snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [], sidebarLabel: null, template: null, sort: null, properties: {}, + snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [], sidebarLabel: null, template: null, sort: null, view: null, properties: {}, } } @@ -167,6 +167,7 @@ export function frontmatterToEntryPatch( order: { order: typeof value === 'number' ? value : null }, template: { template: str }, sort: { sort: str }, + view: { view: str }, } return updates[k] ?? {} } diff --git a/src/hooks/useNoteSearch.ts b/src/hooks/useNoteSearch.ts index 3302ccdd..6c58c99c 100644 --- a/src/hooks/useNoteSearch.ts +++ b/src/hooks/useNoteSearch.ts @@ -24,25 +24,33 @@ function toResult(e: VaultEntry, typeEntryMap: Record): Note } } +/** Types excluded from note search results (internal infrastructure). */ +const SEARCH_EXCLUDED_TYPES = new Set(['Config']) + export function useNoteSearch(entries: VaultEntry[], query: string, maxResults = DEFAULT_MAX_RESULTS) { const [selectedIndex, setSelectedIndex] = useState(0) const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries]) + const searchableEntries = useMemo( + () => entries.filter((e) => !SEARCH_EXCLUDED_TYPES.has(e.isA ?? '')), + [entries], + ) + const results: NoteSearchResult[] = useMemo(() => { const mapResult = (e: VaultEntry) => toResult(e, typeEntryMap) if (!query.trim()) { - return [...entries] + return [...searchableEntries] .sort((a, b) => (b.modifiedAt ?? 0) - (a.modifiedAt ?? 0)) .slice(0, maxResults) .map(mapResult) } - return entries + return searchableEntries .map((e) => ({ entry: e, ...fuzzyMatch(query, e.title) })) .filter((r) => r.match) .sort((a, b) => b.score - a.score) .slice(0, maxResults) .map((r) => mapResult(r.entry)) - }, [entries, query, maxResults, typeEntryMap]) + }, [searchableEntries, query, maxResults, typeEntryMap]) useEffect(() => { setSelectedIndex(0) // eslint-disable-line react-hooks/set-state-in-effect -- reset on query change diff --git a/src/hooks/useSectionVisibility.ts b/src/hooks/useSectionVisibility.ts index ee864655..090f3c05 100644 --- a/src/hooks/useSectionVisibility.ts +++ b/src/hooks/useSectionVisibility.ts @@ -1,27 +1,36 @@ -import { useState, useCallback } from 'react' - -const STORAGE_KEY = 'laputa-hidden-sections' +import { useState, useCallback, useEffect } from 'react' +import { getVaultConfig, updateVaultConfigField, subscribeVaultConfig } from '../utils/vaultConfigStore' function loadHiddenSections(): Set { + const fromConfig = getVaultConfig().hidden_sections + if (fromConfig && fromConfig.length > 0) return new Set(fromConfig) + // Fallback to localStorage during initial load try { - const raw = localStorage.getItem(STORAGE_KEY) + const raw = localStorage.getItem('laputa-hidden-sections') if (raw) { const arr = JSON.parse(raw) - if (Array.isArray(arr)) return new Set(arr) + if (Array.isArray(arr)) return new Set(arr as string[]) } - } catch { - // ignore corrupt data - } + } catch { /* ignore */ } return new Set() } -function saveHiddenSections(hidden: Set) { - localStorage.setItem(STORAGE_KEY, JSON.stringify([...hidden])) +function saveHiddenSections(hidden: Set): void { + const arr = [...hidden] + updateVaultConfigField('hidden_sections', arr.length > 0 ? arr : null) } export function useSectionVisibility() { const [hiddenSections, setHiddenSections] = useState>(loadHiddenSections) + // Re-sync when vault config becomes available + useEffect(() => { + return subscribeVaultConfig(() => { + const sections = getVaultConfig().hidden_sections + if (sections) setHiddenSections(new Set(sections)) + }) + }, []) + const toggleSection = useCallback((type: string) => { setHiddenSections((prev) => { const next = new Set(prev) diff --git a/src/hooks/useUnifiedSearch.ts b/src/hooks/useUnifiedSearch.ts index 08bd7654..a91254ed 100644 --- a/src/hooks/useUnifiedSearch.ts +++ b/src/hooks/useUnifiedSearch.ts @@ -47,6 +47,7 @@ function mapResults(raw: SearchResultData[]): SearchResult[] { })) .filter(r => { if (seen.has(r.path)) return false + if (r.noteType === 'Config') return false seen.add(r.path) return true }) diff --git a/src/hooks/useVaultConfig.ts b/src/hooks/useVaultConfig.ts new file mode 100644 index 00000000..6325aa76 --- /dev/null +++ b/src/hooks/useVaultConfig.ts @@ -0,0 +1,67 @@ +import { useEffect, useCallback, useSyncExternalStore } from 'react' +import { invoke } from '@tauri-apps/api/core' +import { isTauri, mockInvoke } from '../mock-tauri' +import type { VaultConfig } from '../types' +import { initStatusColors } from '../utils/statusStyles' +import { initTagColors } from '../utils/tagStyles' +import { initDisplayModeOverrides } from '../utils/propertyTypes' +import { + getVaultConfig, + bindVaultConfigStore, + resetVaultConfigStore, + updateVaultConfigField, + subscribeVaultConfig, +} from '../utils/vaultConfigStore' +import { migrateLocalStorageToVaultConfig } from '../utils/configMigration' + +function tauriCall(command: string, tauriArgs: Record, mockArgs?: Record): Promise { + return isTauri() ? invoke(command, tauriArgs) : mockInvoke(command, mockArgs ?? tauriArgs) +} + +function applyToModules(c: VaultConfig): void { + initStatusColors(c.status_colors ?? {}) + initTagColors(c.tag_colors ?? {}) + initDisplayModeOverrides(c.property_display_modes ?? {}) +} + +function persistConfig(vaultPath: string, config: VaultConfig): void { + tauriCall('save_vault_config', { vaultPath, config }) + .catch((err) => console.warn('Failed to save vault config:', err)) +} + +export function useVaultConfig(vaultPath: string) { + const config = useSyncExternalStore(subscribeVaultConfig, getVaultConfig) + + useEffect(() => { + resetVaultConfigStore() + + tauriCall('get_vault_config', { vaultPath }) + .then((loaded) => { + const migrated = migrateLocalStorageToVaultConfig(loaded) + const needsSave = migrated !== loaded + bindVaultConfigStore(migrated, (c) => persistConfig(vaultPath, c)) + applyToModules(migrated) + if (needsSave) persistConfig(vaultPath, migrated) + }) + .catch((err) => { + console.warn('Failed to load vault config:', err) + const migrated = migrateLocalStorageToVaultConfig(null) + bindVaultConfigStore(migrated, (c) => persistConfig(vaultPath, c)) + applyToModules(migrated) + if (migrated.zoom !== null || migrated.tag_colors !== null || migrated.status_colors !== null) { + persistConfig(vaultPath, migrated) + } + }) + + return () => resetVaultConfigStore() + }, [vaultPath]) + + const update = useCallback((key: K, value: VaultConfig[K]) => { + updateVaultConfigField(key, value) + // Re-apply to modules for color/property changes + const next = getVaultConfig() + applyToModules(next) + }, []) + + return { config, updateConfig: update } +} diff --git a/src/hooks/useViewMode.test.ts b/src/hooks/useViewMode.test.ts index 5f477b30..fd1a814b 100644 --- a/src/hooks/useViewMode.test.ts +++ b/src/hooks/useViewMode.test.ts @@ -1,25 +1,19 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { renderHook, act } from '@testing-library/react' import { useViewMode } from './useViewMode' +import { bindVaultConfigStore, getVaultConfig, resetVaultConfigStore } from '../utils/vaultConfigStore' vi.mock('../mock-tauri', () => ({ isTauri: () => false, })) -const localStorageMock = (() => { - let store: Record = {} - return { - getItem: (key: string) => store[key] ?? null, - setItem: (key: string, value: string) => { store[key] = value }, - removeItem: (key: string) => { delete store[key] }, - clear: () => { store = {} }, - } -})() -Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true }) - describe('useViewMode', () => { beforeEach(() => { - localStorageMock.clear() + resetVaultConfigStore() + bindVaultConfigStore( + { zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null, hidden_sections: null }, + vi.fn(), + ) }) it('defaults to "all" when no stored value', () => { @@ -29,21 +23,25 @@ describe('useViewMode', () => { expect(result.current.noteListVisible).toBe(true) }) - it('loads persisted view mode from localStorage', () => { - localStorageMock.setItem('laputa-view-mode', 'editor-only') + it('loads persisted view mode from vault config', () => { + resetVaultConfigStore() + bindVaultConfigStore( + { zoom: null, view_mode: 'editor-only', tag_colors: null, status_colors: null, property_display_modes: null, hidden_sections: null }, + vi.fn(), + ) const { result } = renderHook(() => useViewMode()) expect(result.current.viewMode).toBe('editor-only') expect(result.current.sidebarVisible).toBe(false) expect(result.current.noteListVisible).toBe(false) }) - it('setViewMode updates state and persists to localStorage', () => { + it('setViewMode updates state and persists to vault config', () => { const { result } = renderHook(() => useViewMode()) act(() => result.current.setViewMode('editor-list')) expect(result.current.viewMode).toBe('editor-list') expect(result.current.sidebarVisible).toBe(false) expect(result.current.noteListVisible).toBe(true) - expect(localStorageMock.getItem('laputa-view-mode')).toBe('editor-list') + expect(getVaultConfig().view_mode).toBe('editor-list') }) it('editor-only hides both sidebar and note list', () => { @@ -68,8 +66,12 @@ describe('useViewMode', () => { expect(result.current.noteListVisible).toBe(true) }) - it('ignores invalid localStorage values', () => { - localStorageMock.setItem('laputa-view-mode', 'garbage') + it('ignores invalid vault config values', () => { + resetVaultConfigStore() + bindVaultConfigStore( + { zoom: null, view_mode: 'garbage' as never, tag_colors: null, status_colors: null, property_display_modes: null, hidden_sections: null }, + vi.fn(), + ) const { result } = renderHook(() => useViewMode()) expect(result.current.viewMode).toBe('all') }) diff --git a/src/hooks/useViewMode.ts b/src/hooks/useViewMode.ts index cb68fec0..86218066 100644 --- a/src/hooks/useViewMode.ts +++ b/src/hooks/useViewMode.ts @@ -1,13 +1,19 @@ -import { useState, useCallback } from 'react' +import { useState, useCallback, useEffect } from 'react' +import { getVaultConfig, updateVaultConfigField, subscribeVaultConfig } from '../utils/vaultConfigStore' export type ViewMode = 'editor-only' | 'editor-list' | 'all' -const STORAGE_KEY = 'laputa-view-mode' +function isViewMode(v: string | null | undefined): v is ViewMode { + return v === 'editor-only' || v === 'editor-list' || v === 'all' +} function loadViewMode(): ViewMode { + const stored = getVaultConfig().view_mode + if (isViewMode(stored)) return stored + // Fallback to localStorage during initial load (before vault config is ready) try { - const stored = localStorage.getItem(STORAGE_KEY) - if (stored === 'editor-only' || stored === 'editor-list' || stored === 'all') return stored + const ls = localStorage.getItem('laputa-view-mode') + if (isViewMode(ls)) return ls } catch { /* ignore */ } return 'all' } @@ -15,9 +21,17 @@ function loadViewMode(): ViewMode { export function useViewMode() { const [viewMode, setViewModeState] = useState(loadViewMode) + // Re-sync when vault config becomes available + useEffect(() => { + return subscribeVaultConfig(() => { + const stored = getVaultConfig().view_mode + if (isViewMode(stored)) setViewModeState(stored) + }) + }, []) + const setViewMode = useCallback((mode: ViewMode) => { setViewModeState(mode) - try { localStorage.setItem(STORAGE_KEY, mode) } catch { /* ignore */ } + updateVaultConfigField('view_mode', mode) }, []) const sidebarVisible = viewMode === 'all' diff --git a/src/hooks/useZoom.test.ts b/src/hooks/useZoom.test.ts index f1aba267..8d457cb9 100644 --- a/src/hooks/useZoom.test.ts +++ b/src/hooks/useZoom.test.ts @@ -1,22 +1,14 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' import { renderHook, act } from '@testing-library/react' import { useZoom } from './useZoom' +import { bindVaultConfigStore, getVaultConfig, resetVaultConfigStore } from '../utils/vaultConfigStore' -// Mock localStorage (jsdom's may be incomplete) -const localStorageMock = (() => { - let store: Record = {} - return { - getItem: vi.fn((key: string) => store[key] ?? null), - setItem: vi.fn((key: string, value: string) => { store[key] = value }), - removeItem: vi.fn((key: string) => { delete store[key] }), - clear: vi.fn(() => { store = {} }), - } -})() -Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true }) +const DEFAULT_VC = { zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null, hidden_sections: null } as const describe('useZoom', () => { beforeEach(() => { - localStorageMock.clear() + resetVaultConfigStore() + bindVaultConfigStore({ ...DEFAULT_VC }, vi.fn()) document.documentElement.style.removeProperty('zoom') }) @@ -25,20 +17,23 @@ describe('useZoom', () => { expect(result.current.zoomLevel).toBe(100) }) - it('restores persisted zoom level from localStorage', () => { - localStorageMock.setItem('laputa:zoom-level', '120') + it('restores persisted zoom level from vault config', () => { + resetVaultConfigStore() + bindVaultConfigStore({ ...DEFAULT_VC, zoom: 1.2 }, vi.fn()) const { result } = renderHook(() => useZoom()) expect(result.current.zoomLevel).toBe(120) }) - it('ignores invalid persisted values', () => { - localStorageMock.setItem('laputa:zoom-level', 'banana') + it('defaults to 100 when vault config zoom is null', () => { + resetVaultConfigStore() + bindVaultConfigStore({ ...DEFAULT_VC, zoom: null }, vi.fn()) const { result } = renderHook(() => useZoom()) expect(result.current.zoomLevel).toBe(100) }) it('ignores out-of-range persisted values', () => { - localStorageMock.setItem('laputa:zoom-level', '200') + resetVaultConfigStore() + bindVaultConfigStore({ ...DEFAULT_VC, zoom: 2.0 }, vi.fn()) const { result } = renderHook(() => useZoom()) expect(result.current.zoomLevel).toBe(100) }) @@ -47,36 +42,39 @@ describe('useZoom', () => { const { result } = renderHook(() => useZoom()) act(() => result.current.zoomIn()) expect(result.current.zoomLevel).toBe(110) - expect(localStorageMock.getItem('laputa:zoom-level')).toBe('110') + expect(getVaultConfig().zoom).toBe(1.1) }) it('zoomOut decreases level by 10', () => { const { result } = renderHook(() => useZoom()) act(() => result.current.zoomOut()) expect(result.current.zoomLevel).toBe(90) - expect(localStorageMock.getItem('laputa:zoom-level')).toBe('90') + expect(getVaultConfig().zoom).toBe(0.9) }) it('zoomIn clamps at 150', () => { - localStorageMock.setItem('laputa:zoom-level', '150') + resetVaultConfigStore() + bindVaultConfigStore({ ...DEFAULT_VC, zoom: 1.5 }, vi.fn()) const { result } = renderHook(() => useZoom()) act(() => result.current.zoomIn()) expect(result.current.zoomLevel).toBe(150) }) it('zoomOut clamps at 80', () => { - localStorageMock.setItem('laputa:zoom-level', '80') + resetVaultConfigStore() + bindVaultConfigStore({ ...DEFAULT_VC, zoom: 0.8 }, vi.fn()) const { result } = renderHook(() => useZoom()) act(() => result.current.zoomOut()) expect(result.current.zoomLevel).toBe(80) }) it('zoomReset returns to 100', () => { - localStorageMock.setItem('laputa:zoom-level', '130') + resetVaultConfigStore() + bindVaultConfigStore({ ...DEFAULT_VC, zoom: 1.3 }, vi.fn()) const { result } = renderHook(() => useZoom()) act(() => result.current.zoomReset()) expect(result.current.zoomLevel).toBe(100) - expect(localStorageMock.getItem('laputa:zoom-level')).toBe('100') + expect(getVaultConfig().zoom).toBe(1.0) }) it('applies CSS zoom property to document element', () => { @@ -105,8 +103,9 @@ describe('useZoom', () => { expect(result.current.zoomLevel).toBe(130) }) - it('handles localStorage getItem throwing', () => { - localStorageMock.getItem.mockImplementationOnce(() => { throw new Error('no storage') }) + it('defaults to 100 when vault config store is empty', () => { + resetVaultConfigStore() + bindVaultConfigStore({ ...DEFAULT_VC }, vi.fn()) const { result } = renderHook(() => useZoom()) expect(result.current.zoomLevel).toBe(100) }) diff --git a/src/hooks/useZoom.ts b/src/hooks/useZoom.ts index f6e40db9..129e1450 100644 --- a/src/hooks/useZoom.ts +++ b/src/hooks/useZoom.ts @@ -1,19 +1,29 @@ import { useState, useEffect, useCallback } from 'react' +import { getVaultConfig, updateVaultConfigField, subscribeVaultConfig } from '../utils/vaultConfigStore' -const ZOOM_KEY = 'laputa:zoom-level' const MIN_ZOOM = 80 const MAX_ZOOM = 150 const STEP = 10 const DEFAULT_ZOOM = 100 +/** Convert vault config zoom (0.8–1.5 fraction) to percentage (80–150). */ +function configToPercent(zoom: number | null): number | null { + if (zoom === null) return null + const pct = Math.round(zoom * 100) + return pct >= MIN_ZOOM && pct <= MAX_ZOOM ? pct : null +} + function loadPersistedZoom(): number { + const fromConfig = configToPercent(getVaultConfig().zoom) + if (fromConfig !== null) return fromConfig + // Fallback to localStorage during initial load try { - const stored = localStorage.getItem(ZOOM_KEY) + const stored = localStorage.getItem('laputa:zoom-level') if (stored !== null) { const val = Number(stored) if (val >= MIN_ZOOM && val <= MAX_ZOOM && val % STEP === 0) return val } - } catch { /* localStorage unavailable */ } + } catch { /* ignore */ } return DEFAULT_ZOOM } @@ -22,7 +32,7 @@ function applyZoomToDocument(level: number): void { } function persistZoom(level: number): void { - try { localStorage.setItem(ZOOM_KEY, String(level)) } catch { /* ignore */ } + updateVaultConfigField('zoom', level / 100) } export function useZoom() { @@ -33,6 +43,17 @@ export function useZoom() { applyZoomToDocument(zoomLevel) }, []) // eslint-disable-line react-hooks/exhaustive-deps -- only on mount + // Re-sync when vault config becomes available + useEffect(() => { + return subscribeVaultConfig(() => { + const pct = configToPercent(getVaultConfig().zoom) + if (pct !== null) { + setZoomLevel(pct) + applyZoomToDocument(pct) + } + }) + }, []) + const zoomIn = useCallback(() => { setZoomLevel(prev => { const next = Math.min(MAX_ZOOM, prev + STEP) diff --git a/src/mock-tauri/mock-entries.ts b/src/mock-tauri/mock-entries.ts index fa41635d..5825c48e 100644 --- a/src/mock-tauri/mock-entries.ts +++ b/src/mock-tauri/mock-entries.ts @@ -36,7 +36,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, - template: null, sort: null, + template: null, sort: null, view: null, outgoingLinks: ['quarter/q1-2026', 'topic/software-development', 'person/matteo-cellini', 'person/maria-bianchi', 'person/marco-verdi'], properties: { Priority: 'High', 'Due date': '2026-06-15' }, }, @@ -73,7 +73,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, - template: null, sort: null, + template: null, sort: null, view: null, outgoingLinks: ['essay/on-writing-well', 'essay/engineering-leadership-101', 'essay/ai-agents-primer', 'topic/growth', 'topic/writing'], properties: { Priority: 'High', Rating: 5, Cadence: 'Weekly' }, }, @@ -104,7 +104,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, - template: null, sort: null, + template: null, sort: null, view: null, outgoingLinks: ['person/matteo-cellini'], properties: {}, }, @@ -135,7 +135,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, - template: null, sort: null, + template: null, sort: null, view: null, outgoingLinks: ['responsibility/grow-newsletter'], properties: {}, }, @@ -166,7 +166,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, - template: null, sort: null, + template: null, sort: null, view: null, outgoingLinks: ['responsibility/manage-sponsorships'], properties: {}, }, @@ -198,7 +198,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, - template: null, sort: null, + template: null, sort: null, view: null, outgoingLinks: ['topic/trading', 'topic/algorithmic-trading', 'data/ema200-backtest-results'], properties: { Priority: 'Low', 'Due date': '2026-03-01' }, }, @@ -230,7 +230,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, - template: null, sort: null, + template: null, sort: null, view: null, outgoingLinks: ['project/26q1-laputa-app', 'topic/growth', 'topic/ads'], properties: { Priority: 'Medium', Rating: 4 }, }, @@ -261,7 +261,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, - template: null, sort: null, + template: null, sort: null, view: null, outgoingLinks: ['project/26q1-laputa-app'], properties: {}, }, @@ -291,7 +291,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, - template: null, sort: null, + template: null, sort: null, view: null, outgoingLinks: [], properties: { Company: 'Acme Corp', Role: 'Engineering Lead' }, }, @@ -321,7 +321,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, - template: null, sort: null, + template: null, sort: null, view: null, outgoingLinks: [], properties: { Company: 'TechStart', Role: 'Product Manager' }, }, @@ -351,7 +351,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, - template: null, sort: null, + template: null, sort: null, view: null, outgoingLinks: [], properties: {}, }, @@ -381,7 +381,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, - template: null, sort: null, + template: null, sort: null, view: null, outgoingLinks: [], properties: {}, }, @@ -412,7 +412,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, - template: null, sort: null, + template: null, sort: null, view: null, outgoingLinks: ['project/26q1-laputa-app', 'person/matteo-cellini'], properties: {}, }, @@ -443,7 +443,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, - template: null, sort: null, + template: null, sort: null, view: null, outgoingLinks: [], properties: {}, }, @@ -474,7 +474,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, - template: null, sort: null, + template: null, sort: null, view: null, outgoingLinks: [], properties: {}, }, @@ -505,7 +505,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, - template: null, sort: null, + template: null, sort: null, view: null, outgoingLinks: ['responsibility/grow-newsletter'], properties: {}, }, @@ -537,7 +537,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, - template: null, sort: null, + template: null, sort: null, view: null, outgoingLinks: ['responsibility/grow-newsletter', 'topic/software-development'], properties: {}, }, @@ -568,7 +568,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, - template: null, sort: null, + template: null, sort: null, view: null, outgoingLinks: ['responsibility/grow-newsletter'], properties: {}, }, @@ -597,7 +597,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: 0, sidebarLabel: null, - template: null, sort: null, + template: null, sort: null, view: null, outgoingLinks: [], properties: {}, }, @@ -625,7 +625,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: 1, sidebarLabel: null, - template: null, sort: null, + template: null, sort: null, view: null, outgoingLinks: [], properties: {}, }, @@ -653,7 +653,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: 2, sidebarLabel: null, - template: null, sort: null, + template: null, sort: null, view: null, outgoingLinks: [], properties: {}, }, @@ -681,7 +681,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: 3, sidebarLabel: null, - template: null, sort: null, + template: null, sort: null, view: null, outgoingLinks: [], properties: {}, }, @@ -709,7 +709,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: 4, sidebarLabel: null, - template: null, sort: null, + template: null, sort: null, view: null, outgoingLinks: [], properties: {}, }, @@ -737,7 +737,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: 5, sidebarLabel: null, - template: null, sort: null, + template: null, sort: null, view: null, outgoingLinks: [], properties: {}, }, @@ -765,7 +765,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: 6, sidebarLabel: null, - template: null, sort: null, + template: null, sort: null, view: null, outgoingLinks: [], properties: {}, }, @@ -793,7 +793,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: 7, sidebarLabel: null, - template: null, sort: null, + template: null, sort: null, view: null, outgoingLinks: [], properties: {}, }, @@ -821,7 +821,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: 8, sidebarLabel: null, - template: null, sort: null, + template: null, sort: null, view: null, outgoingLinks: [], properties: {}, }, @@ -850,7 +850,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: 'orange', order: 9, sidebarLabel: null, - template: null, sort: null, + template: null, sort: null, view: null, outgoingLinks: [], properties: {}, }, @@ -878,7 +878,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: 'green', order: 10, sidebarLabel: null, - template: null, sort: null, + template: null, sort: null, view: null, outgoingLinks: [], properties: {}, }, @@ -909,7 +909,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, - template: null, sort: null, + template: null, sort: null, view: null, outgoingLinks: [], properties: { Difficulty: 'Easy', 'Prep time': '30 min', Servings: 4 }, }, @@ -939,7 +939,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, - template: null, sort: null, + template: null, sort: null, view: null, outgoingLinks: [], properties: { Author: 'Martin Kleppmann', Rating: 5, 'Year published': 2017 }, }, @@ -971,7 +971,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, - template: null, sort: null, + template: null, sort: null, view: null, outgoingLinks: [], properties: {}, }, @@ -1001,7 +1001,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, - template: null, sort: null, + template: null, sort: null, view: null, outgoingLinks: [], properties: {}, }, @@ -1032,7 +1032,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, - template: null, sort: null, + template: null, sort: null, view: null, outgoingLinks: [], properties: {}, }, @@ -1055,7 +1055,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, - template: null, sort: null, + template: null, sort: null, view: null, outgoingLinks: [], properties: {}, modifiedAt: now - 86400 * 120, @@ -1086,7 +1086,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, - template: null, sort: null, + template: null, sort: null, view: null, outgoingLinks: [], properties: {}, modifiedAt: now - 86400 * 90, @@ -1151,7 +1151,7 @@ function generateBulkEntries(count: number): VaultEntry[] { order: null, outgoingLinks: Array.from({ length: i % 8 }, (_j, j) => `note/link-target-${(i + j) % 50}`), sidebarLabel: null, - template: null, sort: null, + template: null, sort: null, view: null, properties: {}, }) } @@ -1184,7 +1184,7 @@ MOCK_ENTRIES.push( color: null, order: null, sidebarLabel: null, - template: null, sort: null, + template: null, sort: null, view: null, outgoingLinks: [], properties: {}, }, @@ -1212,7 +1212,7 @@ MOCK_ENTRIES.push( color: null, order: null, sidebarLabel: null, - template: null, sort: null, + template: null, sort: null, view: null, outgoingLinks: [], properties: {}, }, @@ -1240,7 +1240,7 @@ MOCK_ENTRIES.push( color: null, order: null, sidebarLabel: null, - template: null, sort: null, + template: null, sort: null, view: null, outgoingLinks: [], properties: {}, }, diff --git a/src/mock-tauri/mock-handlers.ts b/src/mock-tauri/mock-handlers.ts index b53c40e5..d52f846a 100644 --- a/src/mock-tauri/mock-handlers.ts +++ b/src/mock-tauri/mock-handlers.ts @@ -3,7 +3,7 @@ * Each handler simulates a Tauri backend command. */ -import type { VaultEntry, ModifiedFile, Settings, DeviceFlowStart, DeviceFlowPollResult, GitHubUser, GitPullResult, LastCommitInfo, ThemeFile, VaultSettings } from '../types' +import type { VaultEntry, VaultConfig, ModifiedFile, Settings, DeviceFlowStart, DeviceFlowPollResult, GitHubUser, GitPullResult, LastCommitInfo, ThemeFile, VaultSettings } from '../types' import { MOCK_CONTENT } from './mock-content' import { MOCK_ENTRIES } from './mock-entries' @@ -314,6 +314,8 @@ line-height-base: 1.6 }, ensure_vault_themes: (): null => null, restore_default_themes: (): string => 'Default themes restored', + get_vault_config: (): VaultConfig => ({ zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null, hidden_sections: null }), + save_vault_config: (): null => null, } export function addMockEntry(_entry: VaultEntry, content: string): void { diff --git a/src/types.ts b/src/types.ts index a511a185..2362bb0b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -31,6 +31,8 @@ export interface VaultEntry { template: string | null /** Default sort preference for the note list of this Type. Format: "option:direction". */ sort: string | null + /** Default view mode for the note list of this Type: "all", "editor-list", or "editor-only". */ + view: string | null /** All wikilink targets found in the note content. Extracted from [[target]] patterns. */ outgoingLinks: string[] /** Custom scalar frontmatter properties (non-relationship, non-structural). */ @@ -139,6 +141,16 @@ export interface VaultSettings { theme: string | null } +/** Vault-wide UI configuration stored in config/ui.config.md. */ +export interface VaultConfig { + zoom: number | null + view_mode: string | null + tag_colors: Record | null + status_colors: Record | null + property_display_modes: Record | null + hidden_sections: string[] | null +} + export type SidebarFilter = 'all' | 'favorites' | 'archived' | 'trash' | 'changes' export type SidebarSelection = diff --git a/src/utils/configMigration.ts b/src/utils/configMigration.ts new file mode 100644 index 00000000..7f3a2ae1 --- /dev/null +++ b/src/utils/configMigration.ts @@ -0,0 +1,95 @@ +import type { VaultConfig } from '../types' + +const MIGRATION_FLAG = 'laputa:config-migrated-to-vault' + +/** Keys to migrate from localStorage to vault config file. */ +const LS_KEYS = { + zoom: 'laputa:zoom-level', + viewMode: 'laputa-view-mode', + tagColors: 'laputa:tag-color-overrides', + statusColors: 'laputa:status-color-overrides', + propertyModes: 'laputa:display-mode-overrides', + hiddenSections: 'laputa-hidden-sections', +} as const + +function readJson(key: string): T | null { + try { + const raw = localStorage.getItem(key) + return raw ? JSON.parse(raw) as T : null + } catch { + return null + } +} + +/** + * One-time migration: read localStorage values and merge into vault config. + * Returns the merged config. If already migrated (flag set), returns the loaded config unchanged. + * Passing null for `loaded` means the vault file didn't exist yet. + */ +export function migrateLocalStorageToVaultConfig(loaded: VaultConfig | null): VaultConfig { + const base: VaultConfig = loaded ?? { + zoom: null, view_mode: null, tag_colors: null, + status_colors: null, property_display_modes: null, hidden_sections: null, + } + + // Skip migration if already done + try { + if (localStorage.getItem(MIGRATION_FLAG) === '1') return base + } catch { + return base + } + + const result = { ...base } + + // Zoom (localStorage stores as string "80"–"150", vault config stores as fraction 0.8–1.5) + if (result.zoom === null) { + try { + const raw = localStorage.getItem(LS_KEYS.zoom) + if (raw !== null) { + const val = Number(raw) + if (val >= 80 && val <= 150) result.zoom = val / 100 + } + } catch { /* ignore */ } + } + + // View mode + if (result.view_mode === null) { + try { + const raw = localStorage.getItem(LS_KEYS.viewMode) + if (raw === 'editor-only' || raw === 'editor-list' || raw === 'all') { + result.view_mode = raw + } + } catch { /* ignore */ } + } + + // Tag colors + if (result.tag_colors === null) { + const colors = readJson>(LS_KEYS.tagColors) + if (colors && Object.keys(colors).length > 0) result.tag_colors = colors + } + + // Status colors + if (result.status_colors === null) { + const colors = readJson>(LS_KEYS.statusColors) + if (colors && Object.keys(colors).length > 0) result.status_colors = colors + } + + // Property display modes + if (result.property_display_modes === null) { + const modes = readJson>(LS_KEYS.propertyModes) + if (modes && Object.keys(modes).length > 0) result.property_display_modes = modes + } + + // Hidden sections + if (result.hidden_sections === null) { + const sections = readJson(LS_KEYS.hiddenSections) + if (sections && sections.length > 0) result.hidden_sections = sections + } + + // Mark migration as done + try { + localStorage.setItem(MIGRATION_FLAG, '1') + } catch { /* ignore */ } + + return result +} diff --git a/src/utils/propertyTypes.ts b/src/utils/propertyTypes.ts index c20a3955..d888cc2a 100644 --- a/src/utils/propertyTypes.ts +++ b/src/utils/propertyTypes.ts @@ -1,5 +1,6 @@ import type { FrontmatterValue } from '../components/Inspector' import { isValidCssColor, isColorKeyName } from './colorUtils' +import { updateVaultConfigField } from './vaultConfigStore' export type PropertyDisplayMode = 'text' | 'date' | 'boolean' | 'status' | 'url' | 'tags' | 'color' @@ -42,7 +43,15 @@ export function detectPropertyType(key: string, value: FrontmatterValue): Proper const STORAGE_KEY = 'laputa:display-mode-overrides' +let vaultOverrides: Record | null = null + +/** Initialize display mode overrides from vault config (replaces localStorage). */ +export function initDisplayModeOverrides(overrides: Record): void { + vaultOverrides = overrides as Record +} + export function loadDisplayModeOverrides(): Record { + if (vaultOverrides !== null) return { ...vaultOverrides } try { const raw = localStorage.getItem(STORAGE_KEY) return raw ? JSON.parse(raw) : {} @@ -51,16 +60,22 @@ export function loadDisplayModeOverrides(): Record } } +function persistDisplayModeOverrides(overrides: Record): void { + vaultOverrides = { ...overrides } + const snapshot = Object.keys(overrides).length > 0 ? { ...overrides } : null + updateVaultConfigField('property_display_modes', snapshot as Record | null) +} + export function saveDisplayModeOverride(propertyName: string, mode: PropertyDisplayMode): void { const overrides = loadDisplayModeOverrides() overrides[propertyName] = mode - localStorage.setItem(STORAGE_KEY, JSON.stringify(overrides)) + persistDisplayModeOverrides(overrides) } export function removeDisplayModeOverride(propertyName: string): void { const overrides = loadDisplayModeOverrides() delete overrides[propertyName] - localStorage.setItem(STORAGE_KEY, JSON.stringify(overrides)) + persistDisplayModeOverrides(overrides) } export function getEffectiveDisplayMode( diff --git a/src/utils/statusStyles.test.ts b/src/utils/statusStyles.test.ts index 0ac9ba06..5e28be90 100644 --- a/src/utils/statusStyles.test.ts +++ b/src/utils/statusStyles.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach } from 'vitest' +import { describe, it, expect, beforeEach, vi } from 'vitest' import { getStatusStyle, getStatusColorKey, @@ -6,31 +6,19 @@ import { getStatusColorOverrides, STATUS_STYLES, DEFAULT_STATUS_STYLE, + initStatusColors, } from './statusStyles' - -// Mock localStorage (jsdom's may be incomplete) -const localStorageMock = (() => { - let store: Record = {} - return { - getItem: (key: string) => store[key] ?? null, - setItem: (key: string, value: string) => { store[key] = value }, - removeItem: (key: string) => { delete store[key] }, - clear: () => { store = {} }, - get length() { return Object.keys(store).length }, - key: (i: number) => Object.keys(store)[i] ?? null, - } -})() -Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true }) - -const STORAGE_KEY = 'laputa:status-color-overrides' +import { bindVaultConfigStore, getVaultConfig, resetVaultConfigStore } from './vaultConfigStore' describe('statusStyles — color overrides', () => { beforeEach(() => { - localStorageMock.clear() - // Reset module-level cache by clearing all overrides - for (const key of Object.keys(getStatusColorOverrides())) { - setStatusColor(key, null) - } + resetVaultConfigStore() + bindVaultConfigStore( + { zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null, hidden_sections: null }, + vi.fn(), + ) + // Reset module-level cache by re-initializing with empty overrides + initStatusColors({}) }) it('returns built-in style when no override exists', () => { @@ -48,7 +36,9 @@ describe('statusStyles — color overrides', () => { it('setStatusColor persists a color override', () => { setStatusColor('Active', 'red') expect(getStatusColorKey('Active')).toBe('red') - expect(localStorage.getItem(STORAGE_KEY)).toContain('"Active":"red"') + const stored = getVaultConfig().status_colors as Record + expect(stored).toBeTruthy() + expect(stored['Active']).toBe('red') }) it('getStatusStyle uses override when set', () => { diff --git a/src/utils/statusStyles.ts b/src/utils/statusStyles.ts index 7cc4b7cd..73dc8fc2 100644 --- a/src/utils/statusStyles.ts +++ b/src/utils/statusStyles.ts @@ -1,4 +1,5 @@ import { ACCENT_COLORS } from './typeColors' +import { updateVaultConfigField } from './vaultConfigStore' export interface StatusStyle { bg: string @@ -46,7 +47,12 @@ const COLOR_KEY_TO_STYLE: Record = Object.fromEntries( ACCENT_COLORS.map(c => [c.key, { bg: c.cssLight, color: c.css }]), ) -const colorOverrides: Record = loadColorOverrides() +let colorOverrides: Record = loadColorOverrides() + +/** Initialize status color overrides from vault config (replaces localStorage). */ +export function initStatusColors(overrides: Record): void { + colorOverrides = { ...overrides } +} function loadColorOverrides(): Record { try { @@ -67,9 +73,8 @@ export function setStatusColor(status: string, colorKey: string | null): void { } else { colorOverrides[status] = colorKey } - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(colorOverrides)) - } catch { /* storage full — silently ignore */ } + const snapshot = { ...colorOverrides } + updateVaultConfigField('status_colors', Object.keys(snapshot).length > 0 ? snapshot : null) } export function getStatusColorKey(status: string): string | null { diff --git a/src/utils/tagStyles.test.ts b/src/utils/tagStyles.test.ts index beecc661..1c8e9f84 100644 --- a/src/utils/tagStyles.test.ts +++ b/src/utils/tagStyles.test.ts @@ -1,35 +1,22 @@ -import { describe, it, expect, beforeEach } from 'vitest' +import { describe, it, expect, beforeEach, vi } from 'vitest' import { getTagStyle, getTagColorKey, setTagColor, DEFAULT_TAG_STYLE, + initTagColors, } from './tagStyles' - -// Mock localStorage (jsdom's may be incomplete) -const localStorageMock = (() => { - let store: Record = {} - return { - getItem: (key: string) => store[key] ?? null, - setItem: (key: string, value: string) => { store[key] = value }, - removeItem: (key: string) => { delete store[key] }, - clear: () => { store = {} }, - get length() { return Object.keys(store).length }, - key: (i: number) => Object.keys(store)[i] ?? null, - } -})() -Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true }) - -const STORAGE_KEY = 'laputa:tag-color-overrides' +import { bindVaultConfigStore, getVaultConfig, resetVaultConfigStore } from './vaultConfigStore' describe('tagStyles — color overrides', () => { beforeEach(() => { - localStorageMock.clear() - // Reset module-level cache by clearing known overrides - // We can't easily list all, but clearing known test keys suffices - for (const tag of ['React', 'TypeScript', 'Tauri', 'CustomTag']) { - setTagColor(tag, null) - } + resetVaultConfigStore() + bindVaultConfigStore( + { zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null, hidden_sections: null }, + vi.fn(), + ) + // Reset module-level cache + initTagColors({}) }) it('returns default style when no override exists', () => { @@ -43,7 +30,9 @@ describe('tagStyles — color overrides', () => { it('setTagColor persists a color override', () => { setTagColor('React', 'blue') expect(getTagColorKey('React')).toBe('blue') - expect(localStorage.getItem(STORAGE_KEY)).toContain('"React":"blue"') + const stored = getVaultConfig().tag_colors as Record + expect(stored).toBeTruthy() + expect(stored['React']).toBe('blue') }) it('getTagStyle uses override when set', () => { @@ -74,10 +63,10 @@ describe('tagStyles — color overrides', () => { expect(getTagStyle('React')).toEqual(DEFAULT_TAG_STYLE) }) - it('persists multiple overrides to localStorage', () => { + it('persists multiple overrides to vault config', () => { setTagColor('React', 'blue') setTagColor('Tauri', 'orange') - const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') + const stored = getVaultConfig().tag_colors as Record expect(stored).toEqual({ React: 'blue', Tauri: 'orange' }) }) }) diff --git a/src/utils/tagStyles.ts b/src/utils/tagStyles.ts index 7658f076..d1ff54df 100644 --- a/src/utils/tagStyles.ts +++ b/src/utils/tagStyles.ts @@ -1,4 +1,5 @@ import { ACCENT_COLORS } from './typeColors' +import { updateVaultConfigField } from './vaultConfigStore' export interface TagStyle { bg: string @@ -16,7 +17,12 @@ const COLOR_KEY_TO_STYLE: Record = Object.fromEntries( ACCENT_COLORS.map(c => [c.key, { bg: c.cssLight, color: c.css }]), ) -const colorOverrides: Record = loadColorOverrides() +let colorOverrides: Record = loadColorOverrides() + +/** Initialize tag color overrides from vault config (replaces localStorage). */ +export function initTagColors(overrides: Record): void { + colorOverrides = { ...overrides } +} function loadColorOverrides(): Record { try { @@ -33,9 +39,8 @@ export function setTagColor(tag: string, colorKey: string | null): void { } else { colorOverrides[tag] = colorKey } - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(colorOverrides)) - } catch { /* storage full — silently ignore */ } + const snapshot = { ...colorOverrides } + updateVaultConfigField('tag_colors', Object.keys(snapshot).length > 0 ? snapshot : null) } export function getTagColorKey(tag: string): string | null { diff --git a/src/utils/vaultConfigStore.ts b/src/utils/vaultConfigStore.ts new file mode 100644 index 00000000..5b05a700 --- /dev/null +++ b/src/utils/vaultConfigStore.ts @@ -0,0 +1,43 @@ +import type { VaultConfig } from '../types' + +type SaveFn = (config: VaultConfig) => void +type Listener = () => void + +const DEFAULT_CONFIG: VaultConfig = { + zoom: null, view_mode: null, tag_colors: null, + status_colors: null, property_display_modes: null, hidden_sections: null, +} + +let config: VaultConfig = DEFAULT_CONFIG +let saveFn: SaveFn | null = null +const listeners: Set = new Set() + +export function getVaultConfig(): VaultConfig { + return config +} + +export function bindVaultConfigStore(initial: VaultConfig, save: SaveFn): void { + config = initial + saveFn = save + notify() +} + +export function resetVaultConfigStore(): void { + config = DEFAULT_CONFIG + saveFn = null +} + +export function updateVaultConfigField(key: K, value: VaultConfig[K]): void { + config = { ...config, [key]: value } + saveFn?.(config) + notify() +} + +export function subscribeVaultConfig(listener: Listener): () => void { + listeners.add(listener) + return () => { listeners.delete(listener) } +} + +function notify(): void { + for (const fn of listeners) fn() +}