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:
@ -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 (
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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>
|
||||
)
|
||||
|
@ -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>
|
||||
)
|
||||
|
@ -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)
|
@ -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()
|
||||
|
@ -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()
|
||||
|
@ -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>
|
||||
}
|
||||
/>
|
||||
|
@ -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
|
||||
|
Reference in New Issue
Block a user