Files
peardata/client/settings.js
T
Raven Scott 79971cc345
Release rolling / release (push) Canceled after 6m30s
CI / test (push) Canceled after 6m32s
Ask Mode and No More Auto Driven AI Confirm
2026-07-30 16:51:58 -04:00

224 lines
5.9 KiB
JavaScript

/**
* Desktop UI preferences — PearDock-style cache + FOUC localStorage mirror.
*
* Primary: ~/.config/peardata/cache/settings.json
* Mirror: localStorage peardata.settings.v1
*/
import fs from 'fs'
import path from 'path'
import { getPeardataCacheDir, getPeardataHome } from './paths.js'
import { atomicWriteJson } from './jsonCache.js'
export const SETTINGS_CACHE_VERSION = 1
export const SETTINGS_LOCALSTORAGE_KEY = 'peardata.settings.v1'
/** @typedef {{
* theme: 'dark'|'light',
* accent: string,
* density: 'comfortable'|'compact'|'spacious',
* sidebarCollapsed: boolean,
* notifyDesktop: boolean,
* comparePeers: boolean,
* chartPoints: number,
* defaultExplore: string,
* reduceMotion: boolean,
* metricsCardHeight: number,
* metricsDimSort: 'name'|'value',
* metricsCollapsed: string[],
* metricsChartTypes: Record<string, string>,
* metricsPinned: string[],
* metricsGroup: 'average'|'min'|'max'|'sum',
* metricsForcePlay: boolean,
* metricsFiltersOpen: boolean,
* reconnectMaxAttempts: number,
* autoRestorePeers: boolean,
* qvacOnboarded?: boolean,
* qvacProfile?: string,
* qvacMode?: string,
* qvacCacheDir?: string,
* qvacRag?: boolean,
* qvacIdleUnloadMin?: number,
* qvacSubAgents?: boolean,
* qvacMaxSubAgents?: number,
* qvacToolDepth?: 'auto'|'core'|'deep',
* qvacAutoNavigate?: 'off'|'ask'|'on',
* customDashboards?: Array<{
* id: string,
* name: string,
* description?: string,
* tiles: Array<{ id: string, chart: string, mode?: string, title?: string }>,
* createdAt?: number,
* updatedAt?: number,
* }>,
* activeDashboardId?: string|null,
* }} UiSettings */
/** @returns {UiSettings} */
export function defaultSettings() {
return {
theme: 'dark',
accent: 'teal',
density: 'comfortable',
sidebarCollapsed: false,
notifyDesktop: true,
comparePeers: false,
chartPoints: 90,
defaultExplore: 'system.io',
reduceMotion: false,
metricsCardHeight: 160,
metricsDimSort: 'name',
metricsCollapsed: [],
metricsChartTypes: {},
metricsPinned: [],
metricsGroup: 'average',
metricsForcePlay: false,
metricsFiltersOpen: false,
reconnectMaxAttempts: 20,
autoRestorePeers: true,
qvacOnboarded: false,
qvacProfile: 'recommended',
qvacMode: '',
qvacCacheDir: '',
qvacRag: true,
qvacIdleUnloadMin: 30,
qvacSubAgents: true,
qvacMaxSubAgents: 3,
qvacToolDepth: 'auto',
/** off = never switch tabs; ask = confirm each time; on = always when tools request */
qvacAutoNavigate: 'ask',
customDashboards: [],
activeDashboardId: null,
}
}
export function getSettingsCachePath() {
return path.join(getPeardataCacheDir(), 'settings.json')
}
/** @deprecated use getSettingsCachePath */
export function settingsPath() {
return getSettingsCachePath()
}
function readSettingsFile(file) {
try {
if (!fs.existsSync(file)) return null
const raw = JSON.parse(fs.readFileSync(file, 'utf8'))
return raw?.settings || raw || null
} catch {
return null
}
}
function migrateLegacySettings() {
const candidates = [
path.join(getPeardataHome(), 'ui-settings.json'),
path.join(getPeardataHome(), 'cache', 'ui-settings.json'),
]
for (const file of candidates) {
const s = readSettingsFile(file)
if (s && typeof s === 'object') return s
}
try {
if (typeof localStorage !== 'undefined') {
const boot = localStorage.getItem('peardata-ui-boot')
if (boot) {
const parsed = JSON.parse(boot)
if (parsed?.theme) return { theme: parsed.theme }
}
}
} catch {
// ignore
}
return null
}
function readLocalStorageMirror() {
try {
if (typeof localStorage === 'undefined') return null
const mir = localStorage.getItem(SETTINGS_LOCALSTORAGE_KEY)
if (!mir) return null
const parsed = JSON.parse(mir)
return parsed && typeof parsed === 'object' ? parsed : null
} catch {
return null
}
}
/** Side-effect free merge of defaults + disk/legacy/LS. Never writes. */
function peekSettings() {
const defaults = defaultSettings()
const fromFile =
readSettingsFile(getSettingsCachePath()) ||
migrateLegacySettings() ||
readLocalStorageMirror()
return { ...defaults, ...(fromFile || {}) }
}
/**
* Persist settings envelope to disk + LS mirror. Does not call loadSettings.
* @param {UiSettings} settings
*/
function persistSettings(settings) {
const payload = {
version: SETTINGS_CACHE_VERSION,
updatedAt: new Date().toISOString(),
settings,
}
try {
atomicWriteJson(getSettingsCachePath(), payload)
} catch {
// ignore persistence failures
}
try {
if (typeof localStorage !== 'undefined') {
localStorage.setItem(SETTINGS_LOCALSTORAGE_KEY, JSON.stringify(settings))
localStorage.setItem(
'peardata-ui-boot',
JSON.stringify({ theme: settings.theme })
)
}
} catch {
// ignore
}
}
export function loadSettings() {
const defaults = defaultSettings()
const cached = readSettingsFile(getSettingsCachePath())
if (cached && typeof cached === 'object') {
return { ...defaults, ...cached }
}
const legacy = migrateLegacySettings()
if (legacy) {
const merged = { ...defaults, ...legacy }
persistSettings(merged)
return merged
}
const mir = readLocalStorageMirror()
return { ...defaults, ...(mir || {}) }
}
/** @param {Partial<UiSettings>} patch */
export function saveSettings(patch) {
const next = { ...peekSettings(), ...patch }
persistSettings(next)
return next
}
/**
* @param {UiSettings} s
*/
export function applySettingsToDom(s) {
const html = document.documentElement
const body = document.body
html.dataset.theme = s.theme
body.dataset.theme = s.theme
body.dataset.accent = s.accent || 'teal'
body.dataset.density = s.density || 'comfortable'
body.dataset.reduceMotion = s.reduceMotion ? '1' : '0'
body.classList.toggle('sidebar-collapsed', Boolean(s.sidebarCollapsed))
}