Rearchitect settings system to be scoped (#1956)
* BROKEN: start of scopes for each setting * Clean up later: mostly-functional scoped settings! Broken command bar, unimplemented generated settings components * Working persisted project settings in-folder * Start working toward automatic commands and settings UI * Relatively stable, settings-menu-editable * Settings persistence tweaks after merge * Custom settings UI working properly, cleaner types * Allow boolean command types, create Settings UI for them * Add support for option and string Settings input types * Proof of concept settings from command bar * Add all settings to command bar * Allow settings to be hidden on a level * Better command titles for settings * Hide the settings the settings from the commands bar * Derive command defaultValue from *current* settingsMachine context * Fix generated settings UI for 'options' type settings * Pretty settings modal 💅 * Allow for rollback to parent level setting * fmt * Fix tsc errors not related to loading from localStorage * Better setting descriptions, better buttons * Make displayName searchable in command bar * Consolidate constants, get working in browser * Start fixing tests, better types for saved settings payloads * Fix playwright tests * Add a test for the settings modal * Add AtLeast to codespell ignore list * Goofed merge of codespellrc * Try fixing linux E2E tests * Make codespellrc word lowercase * fmt * Fix data-testid in Tauri test * Don't set text settings if nothing changed * Turn off unimplemented settings * Allow for multiple "execution-done" messages to have appeared in snapshot tests * Try fixing up snapshot tests * Switch from .json to .toml settings file format * Use a different method for overriding the default units * Try to force using the new common storage state in snapshot tests * Update tests to use TOML * fmt and remove console logs * Restore units to export * tsc errors, make snapshot tests use TOML * Ensure that snapshot tests use the basicStorageState * Re-organize use of test.use() * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu) * Update snapshots one more time since lighting changed * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu) * Fix broken "Show in folder" for project-level settings * Fire all relevant actions after settings reset * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu) * Properly reset the default directory * Hide settings by platform * Actually honor showDebugPanel * Unify settings hiding logic * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu) * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu) * fix first extrusion snapshot * another attempt to fix extrustion snapshot * Rerun test suite * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu) * trigger CI * more extrusion stuff * Replace resetSettings console log with comment --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Kurt Hutten Irev-Dev <k.hutten@protonmail.ch>
This commit is contained in:
@ -1,88 +1,172 @@
|
||||
import { type CameraSystem, cameraSystems } from '../cameraControls'
|
||||
import { Themes } from '../theme'
|
||||
import { isTauri } from '../isTauri'
|
||||
import { getInitialDefaultDir, readSettingsFile } from '../tauriFS'
|
||||
import { initialSettings } from 'lib/settings/initialSettings'
|
||||
import {
|
||||
type BaseUnit,
|
||||
baseUnitsUnion,
|
||||
type Toggle,
|
||||
type SettingsMachineContext,
|
||||
toggleAsArray,
|
||||
UnitSystem,
|
||||
} from './settingsTypes'
|
||||
import { SETTINGS_PERSIST_KEY } from '../constants'
|
||||
getInitialDefaultDir,
|
||||
getSettingsFilePaths,
|
||||
readSettingsFile,
|
||||
} from '../tauriFS'
|
||||
import { Setting, createSettings, settings } from 'lib/settings/initialSettings'
|
||||
import { SaveSettingsPayload, SettingsLevel } from './settingsTypes'
|
||||
import { isTauri } from 'lib/isTauri'
|
||||
import { removeFile, writeTextFile } from '@tauri-apps/api/fs'
|
||||
import { exists } from 'tauri-plugin-fs-extra-api'
|
||||
import * as TOML from '@iarna/toml'
|
||||
|
||||
export const fallbackLoadedSettings = {
|
||||
settings: initialSettings,
|
||||
errors: [] as (keyof SettingsMachineContext)[],
|
||||
/**
|
||||
* We expect the settings to be stored in a TOML file
|
||||
* or TOML-formatted string in localStorage
|
||||
* under a top-level [settings] key.
|
||||
* @param path
|
||||
* @returns
|
||||
*/
|
||||
function getSettingsFromStorage(path: string) {
|
||||
return isTauri()
|
||||
? readSettingsFile(path)
|
||||
: (TOML.parse(localStorage.getItem(path) ?? '')
|
||||
.settings as Partial<SaveSettingsPayload>)
|
||||
}
|
||||
|
||||
function isEnumMember<T extends Record<string, unknown>>(v: unknown, e: T) {
|
||||
return Object.values(e).includes(v)
|
||||
export async function loadAndValidateSettings(projectPath?: string) {
|
||||
const settings = createSettings()
|
||||
settings.app.projectDirectory.default = await getInitialDefaultDir()
|
||||
// First, get the settings data at the user and project level
|
||||
const settingsFilePaths = await getSettingsFilePaths(projectPath)
|
||||
|
||||
// Load the settings from the files
|
||||
if (settingsFilePaths.user) {
|
||||
const userSettings = await getSettingsFromStorage(settingsFilePaths.user)
|
||||
if (userSettings) {
|
||||
setSettingsAtLevel(settings, 'user', userSettings)
|
||||
}
|
||||
}
|
||||
|
||||
// Load the project settings if they exist
|
||||
if (settingsFilePaths.project) {
|
||||
const projectSettings = await getSettingsFromStorage(
|
||||
settingsFilePaths.project
|
||||
)
|
||||
if (projectSettings) {
|
||||
setSettingsAtLevel(settings, 'project', projectSettings)
|
||||
}
|
||||
}
|
||||
|
||||
// Return the settings object
|
||||
return settings
|
||||
}
|
||||
|
||||
export async function loadAndValidateSettings(): Promise<
|
||||
ReturnType<typeof validateSettings>
|
||||
> {
|
||||
const fsSettings = isTauri() ? await readSettingsFile() : {}
|
||||
const localStorageSettings = JSON.parse(
|
||||
localStorage?.getItem(SETTINGS_PERSIST_KEY) || '{}'
|
||||
export async function saveSettings(
|
||||
allSettings: typeof settings,
|
||||
projectPath?: string
|
||||
) {
|
||||
const settingsFilePaths = await getSettingsFilePaths(projectPath)
|
||||
|
||||
if (settingsFilePaths.user) {
|
||||
const changedSettings = getChangedSettingsAtLevel(allSettings, 'user')
|
||||
|
||||
await writeOrClearPersistedSettings(settingsFilePaths.user, changedSettings)
|
||||
}
|
||||
|
||||
if (settingsFilePaths.project) {
|
||||
const changedSettings = getChangedSettingsAtLevel(allSettings, 'project')
|
||||
|
||||
await writeOrClearPersistedSettings(
|
||||
settingsFilePaths.project,
|
||||
changedSettings
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function writeOrClearPersistedSettings(
|
||||
settingsFilePath: string,
|
||||
changedSettings: Partial<SaveSettingsPayload>
|
||||
) {
|
||||
if (changedSettings && Object.keys(changedSettings).length) {
|
||||
if (isTauri()) {
|
||||
await writeTextFile(
|
||||
settingsFilePath,
|
||||
TOML.stringify({ settings: changedSettings })
|
||||
)
|
||||
}
|
||||
localStorage.setItem(
|
||||
settingsFilePath,
|
||||
TOML.stringify({ settings: changedSettings })
|
||||
)
|
||||
} else {
|
||||
if (isTauri() && (await exists(settingsFilePath))) {
|
||||
await removeFile(settingsFilePath)
|
||||
}
|
||||
localStorage.removeItem(settingsFilePath)
|
||||
}
|
||||
}
|
||||
|
||||
export function getChangedSettingsAtLevel(
|
||||
allSettings: typeof settings,
|
||||
level: SettingsLevel
|
||||
): Partial<SaveSettingsPayload> {
|
||||
const changedSettings = {} as Record<
|
||||
keyof typeof settings,
|
||||
Record<string, unknown>
|
||||
>
|
||||
Object.entries(allSettings).forEach(([category, settingsCategory]) => {
|
||||
const categoryKey = category as keyof typeof settings
|
||||
Object.entries(settingsCategory).forEach(
|
||||
([setting, settingValue]: [string, Setting]) => {
|
||||
// If setting is different its ancestors' non-undefined values,
|
||||
// then it has been changed from the default
|
||||
if (
|
||||
settingValue[level] !== undefined &&
|
||||
((level === 'project' &&
|
||||
(settingValue.user !== undefined
|
||||
? settingValue.project !== settingValue.user
|
||||
: settingValue.project !== settingValue.default)) ||
|
||||
(level === 'user' && settingValue.user !== settingValue.default))
|
||||
) {
|
||||
if (!changedSettings[categoryKey]) {
|
||||
changedSettings[categoryKey] = {}
|
||||
}
|
||||
changedSettings[categoryKey][setting] = settingValue[level]
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
return changedSettings
|
||||
}
|
||||
|
||||
export function setSettingsAtLevel(
|
||||
allSettings: typeof settings,
|
||||
level: SettingsLevel,
|
||||
newSettings: Partial<SaveSettingsPayload>
|
||||
) {
|
||||
Object.entries(newSettings).forEach(([category, settingsCategory]) => {
|
||||
const categoryKey = category as keyof typeof settings
|
||||
if (!allSettings[categoryKey]) return // ignore unrecognized categories
|
||||
Object.entries(settingsCategory).forEach(
|
||||
([settingKey, settingValue]: [string, Setting]) => {
|
||||
// TODO: How do you get a valid type for allSettings[categoryKey][settingKey]?
|
||||
// it seems to always collapses to `never`, which is not correct
|
||||
// @ts-ignore
|
||||
if (!allSettings[categoryKey][settingKey]) return // ignore unrecognized settings
|
||||
// @ts-ignore
|
||||
allSettings[categoryKey][settingKey][level] = settingValue as unknown
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
return allSettings
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the setting should be hidden
|
||||
* based on its config, the current settings level,
|
||||
* and the current platform.
|
||||
*/
|
||||
export function shouldHideSetting(
|
||||
setting: Setting<unknown>,
|
||||
settingsLevel: SettingsLevel
|
||||
) {
|
||||
return (
|
||||
setting.hideOnLevel === settingsLevel ||
|
||||
(setting.hideOnPlatform && isTauri()
|
||||
? setting.hideOnPlatform === 'desktop'
|
||||
: setting.hideOnPlatform === 'web')
|
||||
)
|
||||
const mergedSettings = Object.assign({}, localStorageSettings, fsSettings)
|
||||
|
||||
return await validateSettings(mergedSettings)
|
||||
}
|
||||
|
||||
const settingsValidators: Record<
|
||||
keyof SettingsMachineContext,
|
||||
(v: unknown) => boolean
|
||||
> = {
|
||||
baseUnit: (v) => baseUnitsUnion.includes(v as BaseUnit),
|
||||
cameraControls: (v) => cameraSystems.includes(v as CameraSystem),
|
||||
defaultDirectory: (v) =>
|
||||
typeof v === 'string' && (v.length > 0 || !isTauri()),
|
||||
defaultProjectName: (v) => typeof v === 'string' && v.length > 0,
|
||||
onboardingStatus: (v) => typeof v === 'string',
|
||||
showDebugPanel: (v) => typeof v === 'boolean',
|
||||
textWrapping: (v) => toggleAsArray.includes(v as Toggle),
|
||||
theme: (v) => isEnumMember(v, Themes),
|
||||
unitSystem: (v) => isEnumMember(v, UnitSystem),
|
||||
}
|
||||
|
||||
function removeInvalidSettingsKeys(s: Record<string, unknown>) {
|
||||
const validKeys = Object.keys(initialSettings)
|
||||
for (const key in s) {
|
||||
if (!validKeys.includes(key)) {
|
||||
console.warn(`Invalid key found in settings: ${key}`)
|
||||
delete s[key]
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
export async function validateSettings(s: Record<string, unknown>) {
|
||||
let settingsNoInvalidKeys = removeInvalidSettingsKeys({ ...s })
|
||||
let errors: (keyof SettingsMachineContext)[] = []
|
||||
for (const key in settingsNoInvalidKeys) {
|
||||
const k = key as keyof SettingsMachineContext
|
||||
if (!settingsValidators[k](settingsNoInvalidKeys[k])) {
|
||||
delete settingsNoInvalidKeys[k]
|
||||
errors.push(k)
|
||||
}
|
||||
}
|
||||
|
||||
// Here's our chance to insert the fallback defaultDir
|
||||
const defaultDirectory = isTauri() ? await getInitialDefaultDir() : ''
|
||||
|
||||
const settings = Object.assign(
|
||||
initialSettings,
|
||||
{ defaultDirectory },
|
||||
settingsNoInvalidKeys
|
||||
) as SettingsMachineContext
|
||||
|
||||
return {
|
||||
settings,
|
||||
errors,
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user