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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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<string, string> = {}
|
||||
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(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
@@ -891,12 +884,13 @@ describe('DynamicPropertiesPanel', () => {
|
||||
)
|
||||
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<string, string>
|
||||
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(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
@@ -909,7 +903,7 @@ describe('DynamicPropertiesPanel', () => {
|
||||
})
|
||||
|
||||
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(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
@@ -923,7 +917,7 @@ describe('DynamicPropertiesPanel', () => {
|
||||
})
|
||||
|
||||
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(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
@@ -937,7 +931,7 @@ describe('DynamicPropertiesPanel', () => {
|
||||
})
|
||||
|
||||
it('toggles string boolean from false to true', () => {
|
||||
localStorageMock.setItem('laputa:display-mode-overrides', JSON.stringify({ draft: 'boolean' }))
|
||||
initDisplayModeOverrides({ draft: 'boolean' })
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
@@ -951,7 +945,7 @@ describe('DynamicPropertiesPanel', () => {
|
||||
})
|
||||
|
||||
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(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
@@ -965,7 +959,7 @@ describe('DynamicPropertiesPanel', () => {
|
||||
})
|
||||
|
||||
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(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
|
||||
@@ -2,18 +2,7 @@ import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { Sidebar } from './Sidebar'
|
||||
import type { VaultEntry, SidebarSelection } from '../types'
|
||||
|
||||
// Mock localStorage for section visibility tests
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {}
|
||||
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(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
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(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
|
||||
// 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(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
|
||||
// Top nav items still present
|
||||
|
||||
@@ -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] ?? {}
|
||||
}
|
||||
|
||||
@@ -24,25 +24,33 @@ function toResult(e: VaultEntry, typeEntryMap: Record<string, VaultEntry>): 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
|
||||
|
||||
@@ -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<string> {
|
||||
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<string>) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify([...hidden]))
|
||||
function saveHiddenSections(hidden: Set<string>): void {
|
||||
const arr = [...hidden]
|
||||
updateVaultConfigField('hidden_sections', arr.length > 0 ? arr : null)
|
||||
}
|
||||
|
||||
export function useSectionVisibility() {
|
||||
const [hiddenSections, setHiddenSections] = useState<Set<string>>(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)
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
67
src/hooks/useVaultConfig.ts
Normal file
67
src/hooks/useVaultConfig.ts
Normal file
@@ -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<T>(command: string, tauriArgs: Record<string, unknown>, mockArgs?: Record<string, unknown>): Promise<T> {
|
||||
return isTauri() ? invoke<T>(command, tauriArgs) : mockInvoke<T>(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<void>('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<VaultConfig>('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(<K extends keyof VaultConfig>(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 }
|
||||
}
|
||||
@@ -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<string, string> = {}
|
||||
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')
|
||||
})
|
||||
|
||||
@@ -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<ViewMode>(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'
|
||||
|
||||
@@ -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<string, string> = {}
|
||||
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)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: {},
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
12
src/types.ts
12
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<string, string> | null
|
||||
status_colors: Record<string, string> | null
|
||||
property_display_modes: Record<string, string> | null
|
||||
hidden_sections: string[] | null
|
||||
}
|
||||
|
||||
export type SidebarFilter = 'all' | 'favorites' | 'archived' | 'trash' | 'changes'
|
||||
|
||||
export type SidebarSelection =
|
||||
|
||||
95
src/utils/configMigration.ts
Normal file
95
src/utils/configMigration.ts
Normal file
@@ -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<T>(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<Record<string, string>>(LS_KEYS.tagColors)
|
||||
if (colors && Object.keys(colors).length > 0) result.tag_colors = colors
|
||||
}
|
||||
|
||||
// Status colors
|
||||
if (result.status_colors === null) {
|
||||
const colors = readJson<Record<string, string>>(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<Record<string, string>>(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<string[]>(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
|
||||
}
|
||||
@@ -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<string, PropertyDisplayMode> | null = null
|
||||
|
||||
/** Initialize display mode overrides from vault config (replaces localStorage). */
|
||||
export function initDisplayModeOverrides(overrides: Record<string, string>): void {
|
||||
vaultOverrides = overrides as Record<string, PropertyDisplayMode>
|
||||
}
|
||||
|
||||
export function loadDisplayModeOverrides(): Record<string, PropertyDisplayMode> {
|
||||
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<string, PropertyDisplayMode>
|
||||
}
|
||||
}
|
||||
|
||||
function persistDisplayModeOverrides(overrides: Record<string, PropertyDisplayMode>): void {
|
||||
vaultOverrides = { ...overrides }
|
||||
const snapshot = Object.keys(overrides).length > 0 ? { ...overrides } : null
|
||||
updateVaultConfigField('property_display_modes', snapshot as Record<string, string> | 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(
|
||||
|
||||
@@ -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<string, string> = {}
|
||||
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<string, string>
|
||||
expect(stored).toBeTruthy()
|
||||
expect(stored['Active']).toBe('red')
|
||||
})
|
||||
|
||||
it('getStatusStyle uses override when set', () => {
|
||||
|
||||
@@ -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<string, StatusStyle> = Object.fromEntries(
|
||||
ACCENT_COLORS.map(c => [c.key, { bg: c.cssLight, color: c.css }]),
|
||||
)
|
||||
|
||||
const colorOverrides: Record<string, string> = loadColorOverrides()
|
||||
let colorOverrides: Record<string, string> = loadColorOverrides()
|
||||
|
||||
/** Initialize status color overrides from vault config (replaces localStorage). */
|
||||
export function initStatusColors(overrides: Record<string, string>): void {
|
||||
colorOverrides = { ...overrides }
|
||||
}
|
||||
|
||||
function loadColorOverrides(): Record<string, string> {
|
||||
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 {
|
||||
|
||||
@@ -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<string, string> = {}
|
||||
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<string, string>
|
||||
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<string, string>
|
||||
expect(stored).toEqual({ React: 'blue', Tauri: 'orange' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string, TagStyle> = Object.fromEntries(
|
||||
ACCENT_COLORS.map(c => [c.key, { bg: c.cssLight, color: c.css }]),
|
||||
)
|
||||
|
||||
const colorOverrides: Record<string, string> = loadColorOverrides()
|
||||
let colorOverrides: Record<string, string> = loadColorOverrides()
|
||||
|
||||
/** Initialize tag color overrides from vault config (replaces localStorage). */
|
||||
export function initTagColors(overrides: Record<string, string>): void {
|
||||
colorOverrides = { ...overrides }
|
||||
}
|
||||
|
||||
function loadColorOverrides(): Record<string, string> {
|
||||
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 {
|
||||
|
||||
43
src/utils/vaultConfigStore.ts
Normal file
43
src/utils/vaultConfigStore.ts
Normal file
@@ -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<Listener> = 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<K extends keyof VaultConfig>(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()
|
||||
}
|
||||
Reference in New Issue
Block a user