File based settings (#1361)

* Rename GlobalStateContext to SettingsAuthContext

* Naive initial impl of settings persistence to file system

* Update app identifier in tauri config

* Add "show in folder" tauri command

* Load from and save to file system in Tauri app

* Add documents drive to tauri permission scope

* Add recursive prop to default dir selection dialog

* Add success toast to web restore defaults action

* Add a way to validate read-in settings

* Update imports to use separate settings lib file

* Validate localStorage-loaded settings, combine error message

* Add a e2e test for validation

* Clean up state state bugs

* Reverse validation looping so new users don't error

* update settingsMachine typegen to remove conflicts

* Fmt

* Fix TS errors
This commit is contained in:
Frank Noirot
2024-02-15 14:14:14 -05:00
committed by GitHub
parent d9bcadb062
commit 602e7afef6
32 changed files with 599 additions and 169 deletions

View File

@ -2,7 +2,7 @@ import { Toolbar } from '../Toolbar'
import UserSidebarMenu from './UserSidebarMenu'
import { type IndexLoaderData } from 'lib/types'
import ProjectSidebarMenu from './ProjectSidebarMenu'
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import styles from './AppHeader.module.css'
import { NetworkHealthIndicator } from './NetworkHealthIndicator'
import { useCommandsContext } from 'hooks/useCommandsContext'
@ -25,7 +25,7 @@ export const AppHeader = ({
}: AppHeaderProps) => {
const platform = usePlatform()
const { commandBarSend } = useCommandsContext()
const { auth } = useGlobalStateContext()
const { auth } = useSettingsAuthContext()
const user = auth?.context?.user
return (

View File

@ -6,7 +6,7 @@ import React from 'react'
import { useFormik } from 'formik'
import { Models } from '@kittycad/lib'
import { engineCommandManager } from '../lang/std/engineConnection'
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
type OutputFormat = Models['OutputFormat_type']
type OutputTypeKey = OutputFormat['type']
@ -29,7 +29,7 @@ export const ExportButton = ({ children, className }: ExportButtonProps) => {
context: { baseUnit },
},
},
} = useGlobalStateContext()
} = useSettingsAuthContext()
const defaultType = 'gltf'
const [type, setType] = React.useState<OutputTypeKey>(defaultType)

View File

@ -10,7 +10,7 @@ import {
} from 'xstate'
import { SetSelections, modelingMachine } from 'machines/modelingMachine'
import { useSetupEngineManager } from 'hooks/useSetupEngineManager'
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { isCursorInSketchCommandRange } from 'lang/util'
import { engineCommandManager } from 'lang/std/engineConnection'
import { kclManager, useKclContext } from 'lang/KclSingleton'
@ -53,7 +53,7 @@ export const ModelingMachineProvider = ({
}: {
children: React.ReactNode
}) => {
const { auth } = useGlobalStateContext()
const { auth } = useSettingsAuthContext()
const { code } = useKclContext()
const token = auth?.context?.token
const streamRef = useRef<HTMLDivElement>(null)

View File

@ -1,6 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { BrowserRouter } from 'react-router-dom'
import { GlobalStateProvider } from './GlobalStateProvider'
import { SettingsAuthStateProvider } from './SettingsAuthStateProvider'
import CommandBarProvider from './CommandBar/CommandBar'
import {
NETWORK_HEALTH_TEXT,
@ -13,7 +13,7 @@ function TestWrap({ children }: { children: React.ReactNode }) {
return (
<BrowserRouter>
<CommandBarProvider>
<GlobalStateProvider>{children}</GlobalStateProvider>
<SettingsAuthStateProvider>{children}</SettingsAuthStateProvider>
</CommandBarProvider>
</BrowserRouter>
)

View File

@ -2,7 +2,7 @@ import { fireEvent, render, screen } from '@testing-library/react'
import { BrowserRouter } from 'react-router-dom'
import ProjectSidebarMenu from './ProjectSidebarMenu'
import { type ProjectWithEntryPointMetadata } from 'lib/types'
import { GlobalStateProvider } from './GlobalStateProvider'
import { SettingsAuthStateProvider } from './SettingsAuthStateProvider'
import CommandBarProvider from './CommandBar/CommandBar'
import { APP_NAME } from 'lib/constants'
@ -42,9 +42,9 @@ describe('ProjectSidebarMenu tests', () => {
render(
<BrowserRouter>
<CommandBarProvider>
<GlobalStateProvider>
<SettingsAuthStateProvider>
<ProjectSidebarMenu project={projectWellFormed} />
</GlobalStateProvider>
</SettingsAuthStateProvider>
</CommandBarProvider>
</BrowserRouter>
)
@ -63,9 +63,9 @@ describe('ProjectSidebarMenu tests', () => {
render(
<BrowserRouter>
<CommandBarProvider>
<GlobalStateProvider>
<SettingsAuthStateProvider>
<ProjectSidebarMenu />
</GlobalStateProvider>
</SettingsAuthStateProvider>
</CommandBarProvider>
</BrowserRouter>
)
@ -79,12 +79,12 @@ describe('ProjectSidebarMenu tests', () => {
render(
<BrowserRouter>
<CommandBarProvider>
<GlobalStateProvider>
<SettingsAuthStateProvider>
<ProjectSidebarMenu
project={projectWellFormed}
renderAsLink={true}
/>
</GlobalStateProvider>
</SettingsAuthStateProvider>
</CommandBarProvider>
</BrowserRouter>
)

View File

@ -5,7 +5,12 @@ import { authMachine, TOKEN_PERSIST_KEY } from '../machines/authMachine'
import withBaseUrl from '../lib/withBaseURL'
import React, { createContext, useEffect, useRef } from 'react'
import useStateMachineCommands from '../hooks/useStateMachineCommands'
import { SETTINGS_PERSIST_KEY, settingsMachine } from 'machines/settingsMachine'
import { settingsMachine } from 'machines/settingsMachine'
import {
initialSettings,
SETTINGS_PERSIST_KEY,
validateSettings,
} from 'lib/settings'
import { toast } from 'react-hot-toast'
import { setThemeClass, Themes } from 'lib/theme'
import {
@ -18,6 +23,7 @@ import {
import { isTauri } from 'lib/isTauri'
import { settingsCommandBarConfig } from 'lib/commandBarConfigs/settingsCommandConfig'
import { authCommandBarConfig } from 'lib/commandBarConfigs/authCommandConfig'
import { initializeProjectDirectory, readSettingsFile } from 'lib/tauriFS'
type MachineContext<T extends AnyStateMachine> = {
state: StateFrom<T>
@ -25,14 +31,14 @@ type MachineContext<T extends AnyStateMachine> = {
send: Prop<InterpreterFrom<T>, 'send'>
}
type GlobalContext = {
type SettingsAuthContext = {
auth: MachineContext<typeof authMachine>
settings: MachineContext<typeof settingsMachine>
}
export const GlobalStateContext = createContext({} as GlobalContext)
export const SettingsAuthStateContext = createContext({} as SettingsAuthContext)
export const GlobalStateProvider = ({
export const SettingsAuthStateProvider = ({
children,
}: {
children: React.ReactNode
@ -40,14 +46,17 @@ export const GlobalStateProvider = ({
const navigate = useNavigate()
// Settings machine setup
// Load settings from local storage
// and validate them
const retrievedSettings = useRef(
localStorage?.getItem(SETTINGS_PERSIST_KEY) || '{}'
validateSettings(
JSON.parse(localStorage?.getItem(SETTINGS_PERSIST_KEY) || '{}')
)
)
const persistedSettings = Object.assign(
settingsMachine.initialState.context,
JSON.parse(retrievedSettings.current) as Partial<
(typeof settingsMachine)['context']
>
{},
initialSettings,
retrievedSettings.current.settings
)
const [settingsState, settingsSend] = useMachine(settingsMachine, {
@ -72,6 +81,75 @@ export const GlobalStateProvider = ({
},
})
// If the app is running in the Tauri context,
// try to read the settings from a file
// after doing some validation on them
useEffect(() => {
async function getFileBasedSettings() {
if (isTauri()) {
const newSettings = await readSettingsFile()
if (newSettings) {
if (newSettings.defaultDirectory) {
const newDefaultDirectory = await initializeProjectDirectory(
newSettings.defaultDirectory || ''
)
if (newDefaultDirectory.error !== null) {
toast.error(newDefaultDirectory.error.message)
}
if (newDefaultDirectory.path !== null) {
newSettings.defaultDirectory = newDefaultDirectory.path
}
}
const { settings: validatedSettings, errors: validationErrors } =
validateSettings(newSettings)
retrievedSettings.current = Object.assign(
{},
initialSettings,
retrievedSettings.current,
validatedSettings
)
settingsSend({
type: 'Set All Settings',
data: validatedSettings,
})
return validationErrors
}
} else {
// If the app is not running in the Tauri context,
// just use the settings from local storage
// after they've been validated to ensure they are correct.
settingsSend({
type: 'Set All Settings',
data: retrievedSettings.current.settings,
})
}
return []
}
// If there were validation errors either from local storage or from the file,
// log them to the console and show a toast message to the user.
void getFileBasedSettings().then((validationErrors: string[]) => {
const combinedErrors = new Set([
...retrievedSettings.current.errors,
...validationErrors,
])
if (combinedErrors.size > 0) {
const errorMessage =
'Error validating persisted settings: ' +
Array.from(combinedErrors).join(', ') +
'. Using defaults.'
console.error(errorMessage)
toast.error(errorMessage)
}
})
}, [settingsSend])
useStateMachineCommands({
machineId: 'settings',
state: settingsState,
@ -119,7 +197,7 @@ export const GlobalStateProvider = ({
})
return (
<GlobalStateContext.Provider
<SettingsAuthStateContext.Provider
value={{
auth: {
state: authState,
@ -134,11 +212,11 @@ export const GlobalStateProvider = ({
}}
>
{children}
</GlobalStateContext.Provider>
</SettingsAuthStateContext.Provider>
)
}
export default GlobalStateProvider
export default SettingsAuthStateProvider
export function logout() {
localStorage.removeItem(TOKEN_PERSIST_KEY)

View File

@ -10,7 +10,7 @@ import { useStore } from '../useStore'
import { getNormalisedCoordinates, throttle } from '../lib/utils'
import Loading from './Loading'
import { cameraMouseDragGuards } from 'lib/cameraControls'
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { Models } from '@kittycad/lib'
import { engineCommandManager } from '../lang/std/engineConnection'
import { useModelingContext } from 'hooks/useModelingContext'
@ -34,7 +34,7 @@ export const Stream = ({ className = '' }: { className?: string }) => {
setDidDragInStream: s.setDidDragInStream,
streamDimensions: s.streamDimensions,
}))
const { settings } = useGlobalStateContext()
const { settings } = useSettingsAuthContext()
const cameraControls = settings?.context?.cameraControls
const { state } = useModelingContext()
const { isExecuting } = useKclContext()

View File

@ -8,7 +8,7 @@ import Server from '../editor/lsp/server'
import Client from '../editor/lsp/client'
import { TEST } from 'env'
import { useCommandsContext } from 'hooks/useCommandsContext'
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { useConvertToVariable } from 'hooks/useToolbarGuards'
import { Themes } from 'lib/theme'
import { useMemo, useRef } from 'react'
@ -67,7 +67,7 @@ export const TextEditor = ({
} = useModelingContext()
const { settings: { context: { textWrapping } = {} } = {} } =
useGlobalStateContext()
useSettingsAuthContext()
const { commandBarSend } = useCommandsContext()
const { enable: convertEnabled, handleClick: convertCallback } =
useConvertToVariable()

View File

@ -7,7 +7,7 @@ import {
createRoutesFromElements,
} from 'react-router-dom'
import { Models } from '@kittycad/lib'
import { GlobalStateProvider } from './GlobalStateProvider'
import { SettingsAuthStateProvider } from './SettingsAuthStateProvider'
import CommandBarProvider from './CommandBar/CommandBar'
type User = Models['User_type']
@ -107,7 +107,7 @@ function TestWrap({ children }: { children: React.ReactNode }) {
path="/file/:id"
element={
<CommandBarProvider>
<GlobalStateProvider>{children}</GlobalStateProvider>
<SettingsAuthStateProvider>{children}</SettingsAuthStateProvider>
</CommandBarProvider>
}
/>

View File

@ -6,7 +6,7 @@ import { useLocation, useNavigate } from 'react-router-dom'
import { Fragment, useState } from 'react'
import { paths } from 'lib/paths'
import { Models } from '@kittycad/lib'
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { useAbsoluteFilePath } from 'hooks/useAbsoluteFilePath'
type User = Models['User_type']
@ -17,7 +17,7 @@ const UserSidebarMenu = ({ user }: { user?: User }) => {
const displayedName = getDisplayName(user)
const [imageLoadFailed, setImageLoadFailed] = useState(false)
const navigate = useNavigate()
const send = useGlobalStateContext()?.auth?.send
const send = useSettingsAuthContext()?.auth?.send
// Fallback logic for displaying user's "name":
// 1. user.name