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:
Test
2026-03-05 15:26:21 +01:00
parent 418ea8a7a8
commit 013cf0ffe1
22 changed files with 483 additions and 208 deletions

View 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.81.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
}

View File

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

View File

@@ -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', () => {

View File

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

View File

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

View File

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

View 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()
}