[Refactor] decouple settingsMachine from React (#5142)

* Remove unnecessary console.log

* Create a global appMachine

* Strip authMachine of side-effects

* Replace react-bound authMachine use with XState actor use

* Fix import goof

* Register auth commands directly!

* Don't provide anything to settingsMachine from React

* Remove unecessary async

* Make it possible to load project settings via a sent event, without React

* Make settingsMachine ready to be an actor

* Remove settingsLoader use

* Replace all useSettingsAuthContext use with direct actor use

* Add logic to clear project settings, fmt

* fmt

* Clear and load project settings from routeLoaders, but using actor

* Remove useRefreshSettings

* Restore use of useToken() that wasn't working for some reason

* Migrate useFileSystemWatcher use to RouteProvider

* Surface wasm_bindgen unavailable error to console

* Remove unnecessary use of Jest settings wrappers

* Replace dynamic import with actor.getSnapshot

* Migrate system theme and theme color watching from useEffects to actors/actions

* Migrate cursor color effect

* Remove unused code that is now in RouteProvider

* Migrate route commands registration further down for now, out of SettingsAuthProvider

* Migrate settings command registration out of SettingsAuthProvider.tsx

* Delete SettingsAuthProvider.tsx!

* Remove unused settingsLoader!

* fmt and remove comments

* Use actor for routeLoader

* Fix project read error due to uninitialized WASM

* Fix user settings load error due to uninitialized WASM

* Move settingsActor into appActor as a spawned child

* Trying to fix unit tests

* Remove unused imports and demo window attachments

* fmt

* Fix testing issues caused by circular dependency

* Add `setThemeColor` to a few actions list it was missing from

* fmt

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Fix "Execute AST" action in browser, where currentProject is `undefined`

* Update commands list when currentProject changes

* Fix `clearProjectSettings`, which was passing along non-settings context

* Fix onboarding test that actually needed the onboarding initially dismissed

* Add scrollIntoView to make this test more reliable

* @lf94's feedback I missed

I got distracted by a million other things last week

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Revert "A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)"

This reverts commit 129226c6ef.

* fmt

* revert bad snapshot

* Fix up camera movement test locator

* Fix test that was flipping the user settings without waiting

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Frank Noirot
2025-02-21 13:47:36 -05:00
committed by GitHub
parent 4d1eaf9381
commit 46b4b01d23
60 changed files with 791 additions and 780 deletions

View File

@ -13,8 +13,8 @@ import {
import * as TOML from '@iarna/toml' import * as TOML from '@iarna/toml'
import { expectPixelColor } from './fixtures/sceneFixture' import { expectPixelColor } from './fixtures/sceneFixture'
// Because onboarding relies on an app setting we need to set it as incompletel // Because our default test settings have the onboardingStatus set to 'dismissed',
// for all these tests. // we must set it to empty for the tests where we want to see the onboarding immediately.
test.describe('Onboarding tests', () => { test.describe('Onboarding tests', () => {
test( test(
@ -22,7 +22,7 @@ test.describe('Onboarding tests', () => {
{ {
appSettings: { appSettings: {
app: { app: {
onboardingStatus: 'incomplete', onboardingStatus: '',
}, },
}, },
cleanProjectDir: true, cleanProjectDir: true,
@ -63,7 +63,7 @@ test.describe('Onboarding tests', () => {
tag: '@electron', tag: '@electron',
appSettings: { appSettings: {
app: { app: {
onboardingStatus: 'incomplete', onboardingStatus: '',
}, },
}, },
cleanProjectDir: true, cleanProjectDir: true,
@ -106,11 +106,6 @@ test.describe('Onboarding tests', () => {
test( test(
'Code resets after confirmation', 'Code resets after confirmation',
{ {
appSettings: {
app: {
onboardingStatus: 'incomplete',
},
},
cleanProjectDir: true, cleanProjectDir: true,
}, },
async ({ context, page, homePage }) => { async ({ context, page, homePage }) => {
@ -158,7 +153,7 @@ test.describe('Onboarding tests', () => {
{ {
appSettings: { appSettings: {
app: { app: {
onboardingStatus: 'incomplete', onboardingStatus: '',
}, },
}, },
}, },
@ -319,7 +314,7 @@ test.describe('Onboarding tests', () => {
{ {
appSettings: { appSettings: {
app: { app: {
onboardingStatus: 'incomplete', onboardingStatus: '',
}, },
}, },
cleanProjectDir: true, cleanProjectDir: true,
@ -392,7 +387,7 @@ test.describe('Onboarding tests', () => {
{ {
appSettings: { appSettings: {
app: { app: {
onboardingStatus: 'incomplete', onboardingStatus: '',
}, },
}, },
cleanProjectDir: true, cleanProjectDir: true,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

View File

@ -358,9 +358,7 @@ test.describe('Testing Camera Movement', { tag: ['@skipWin'] }, () => {
exact: true, exact: true,
}) })
const userSettingsTab = page.getByRole('radio', { name: 'User' }) const userSettingsTab = page.getByRole('radio', { name: 'User' })
const mouseControlsSetting = page const mouseControlsSetting = () => page.locator('#camera-controls').first()
.locator('#mouseControls')
.getByRole('combobox')
const mouseControlSuccesToast = page.getByText( const mouseControlSuccesToast = page.getByText(
'Set mouse controls to "Solidworks"' 'Set mouse controls to "Solidworks"'
) )
@ -390,7 +388,14 @@ test.describe('Testing Camera Movement', { tag: ['@skipWin'] }, () => {
await settingsLink.click() await settingsLink.click()
await expect(settingsDialogHeading).toBeVisible() await expect(settingsDialogHeading).toBeVisible()
await userSettingsTab.click() await userSettingsTab.click()
await mouseControlsSetting.selectOption({ label: 'Solidworks' }) const setting = mouseControlsSetting()
await expect(setting).toBeAttached()
await setting.scrollIntoViewIfNeeded()
await setting.selectOption({ label: 'Solidworks' })
await expect(setting, 'Setting value did not change').toHaveValue(
'Solidworks',
{ timeout: 120_000 }
)
await expect(mouseControlSuccesToast).toBeVisible() await expect(mouseControlSuccesToast).toBeVisible()
await settingsCloseButton.click() await settingsCloseButton.click()
}) })

View File

@ -633,6 +633,7 @@ test.describe('Testing settings', () => {
`Set default unit to "${unitOfMeasure}" as a user default` `Set default unit to "${unitOfMeasure}" as a user default`
) )
await expect(toastMessage).toBeVisible() await expect(toastMessage).toBeVisible()
await expect(toastMessage).not.toBeVisible()
}) })
} }
await changeUnitOfMeasureInUserTab('in') await changeUnitOfMeasureInUserTab('in')

View File

@ -6,14 +6,12 @@ import { useHotkeys } from 'react-hotkeys-hook'
import { useLoaderData, useNavigate } from 'react-router-dom' import { useLoaderData, useNavigate } from 'react-router-dom'
import { type IndexLoaderData } from 'lib/types' import { type IndexLoaderData } from 'lib/types'
import { PATHS } from 'lib/paths' import { PATHS } from 'lib/paths'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { onboardingPaths } from 'routes/Onboarding/paths' import { onboardingPaths } from 'routes/Onboarding/paths'
import { useEngineConnectionSubscriptions } from 'hooks/useEngineConnectionSubscriptions' import { useEngineConnectionSubscriptions } from 'hooks/useEngineConnectionSubscriptions'
import { codeManager, engineCommandManager } from 'lib/singletons' import { codeManager, engineCommandManager } from 'lib/singletons'
import { useAbsoluteFilePath } from 'hooks/useAbsoluteFilePath' import { useAbsoluteFilePath } from 'hooks/useAbsoluteFilePath'
import { isDesktop } from 'lib/isDesktop' import { isDesktop } from 'lib/isDesktop'
import { useLspContext } from 'components/LspProvider' import { useLspContext } from 'components/LspProvider'
import { useRefreshSettings } from 'hooks/useRefreshSettings'
import { ModelingSidebar } from 'components/ModelingSidebar/ModelingSidebar' import { ModelingSidebar } from 'components/ModelingSidebar/ModelingSidebar'
import { LowerRightControls } from 'components/LowerRightControls' import { LowerRightControls } from 'components/LowerRightControls'
import ModalContainer from 'react-modal-promise' import ModalContainer from 'react-modal-promise'
@ -30,6 +28,7 @@ import { useRouteLoaderData } from 'react-router-dom'
import { useEngineCommands } from 'components/EngineCommands' import { useEngineCommands } from 'components/EngineCommands'
import { commandBarActor } from 'machines/commandBarMachine' import { commandBarActor } from 'machines/commandBarMachine'
import { useToken } from 'machines/appMachine' import { useToken } from 'machines/appMachine'
import { useSettings } from 'machines/appMachine'
maybeWriteToDisk() maybeWriteToDisk()
.then(() => {}) .then(() => {})
.catch(() => {}) .catch(() => {})
@ -49,7 +48,6 @@ export function App() {
}) })
}) })
useRefreshSettings(PATHS.FILE + 'SETTINGS')
const navigate = useNavigate() const navigate = useNavigate()
const filePath = useAbsoluteFilePath() const filePath = useAbsoluteFilePath()
const { onProjectOpen } = useLspContext() const { onProjectOpen } = useLspContext()
@ -71,7 +69,7 @@ export function App() {
useHotKeyListener() useHotKeyListener()
const { settings } = useSettingsAuthContext() const settings = useSettings()
const token = useToken() const token = useToken()
const coreDumpManager = useMemo( const coreDumpManager = useMemo(
@ -81,7 +79,7 @@ export function App() {
const { const {
app: { onboardingStatus }, app: { onboardingStatus },
} = settings.context } = settings
useHotkeys('backspace', (e) => { useHotkeys('backspace', (e) => {
e.preventDefault() e.preventDefault()

View File

@ -28,10 +28,8 @@ import {
fileLoader, fileLoader,
homeLoader, homeLoader,
onboardingRedirectLoader, onboardingRedirectLoader,
settingsLoader,
telemetryLoader, telemetryLoader,
} from 'lib/routeLoaders' } from 'lib/routeLoaders'
import SettingsAuthProvider from 'components/SettingsAuthProvider'
import LspProvider from 'components/LspProvider' import LspProvider from 'components/LspProvider'
import { KclContextProvider } from 'lang/KclProvider' import { KclContextProvider } from 'lang/KclProvider'
import { ASK_TO_OPEN_QUERY_PARAM, BROWSER_PROJECT_NAME } from 'lib/constants' import { ASK_TO_OPEN_QUERY_PARAM, BROWSER_PROJECT_NAME } from 'lib/constants'
@ -45,34 +43,28 @@ import { AppStateProvider } from 'AppState'
import { reportRejection } from 'lib/trap' import { reportRejection } from 'lib/trap'
import { RouteProvider } from 'components/RouteProvider' import { RouteProvider } from 'components/RouteProvider'
import { ProjectsContextProvider } from 'components/ProjectsContextProvider' import { ProjectsContextProvider } from 'components/ProjectsContextProvider'
import { OpenInDesktopAppHandler } from 'components/OpenInDesktopAppHandler'
import { useToken } from 'machines/appMachine' import { useToken } from 'machines/appMachine'
import { OpenInDesktopAppHandler } from 'components/OpenInDesktopAppHandler'
const createRouter = isDesktop() ? createHashRouter : createBrowserRouter const createRouter = isDesktop() ? createHashRouter : createBrowserRouter
const router = createRouter([ const router = createRouter([
{ {
loader: settingsLoader,
id: PATHS.INDEX, id: PATHS.INDEX,
// TODO: Re-evaluate if this is true
/* Make sure auth is the outermost provider or else we will have
* inefficient re-renders, use the react profiler to see. */
element: ( element: (
<OpenInDesktopAppHandler> <OpenInDesktopAppHandler>
<RouteProvider> <RouteProvider>
<SettingsAuthProvider> <LspProvider>
<LspProvider> <ProjectsContextProvider>
<ProjectsContextProvider> <KclContextProvider>
<KclContextProvider> <AppStateProvider>
<AppStateProvider> <MachineManagerProvider>
<MachineManagerProvider> <Outlet />
<Outlet /> </MachineManagerProvider>
</MachineManagerProvider> </AppStateProvider>
</AppStateProvider> </KclContextProvider>
</KclContextProvider> </ProjectsContextProvider>
</ProjectsContextProvider> </LspProvider>
</LspProvider>
</SettingsAuthProvider>
</RouteProvider> </RouteProvider>
</OpenInDesktopAppHandler> </OpenInDesktopAppHandler>
), ),
@ -120,7 +112,6 @@ const router = createRouter([
children: [ children: [
{ {
id: PATHS.FILE + 'SETTINGS', id: PATHS.FILE + 'SETTINGS',
loader: settingsLoader,
children: [ children: [
{ {
loader: onboardingRedirectLoader, loader: onboardingRedirectLoader,
@ -166,11 +157,9 @@ const router = createRouter([
index: true, index: true,
element: <></>, element: <></>,
id: PATHS.HOME + 'SETTINGS', id: PATHS.HOME + 'SETTINGS',
loader: settingsLoader,
}, },
{ {
path: makeUrlPathRelative(PATHS.SETTINGS), path: makeUrlPathRelative(PATHS.SETTINGS),
loader: settingsLoader,
element: <Settings />, element: <Settings />,
}, },
{ {

View File

@ -2,7 +2,6 @@ import { useRef, useEffect, useState, useMemo, Fragment } from 'react'
import { useModelingContext } from 'hooks/useModelingContext' import { useModelingContext } from 'hooks/useModelingContext'
import { cameraMouseDragGuards } from 'lib/cameraControls' import { cameraMouseDragGuards } from 'lib/cameraControls'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { ARROWHEAD, DEBUG_SHOW_BOTH_SCENES } from './sceneInfra' import { ARROWHEAD, DEBUG_SHOW_BOTH_SCENES } from './sceneInfra'
import { ReactCameraProperties } from './CameraControls' import { ReactCameraProperties } from './CameraControls'
import { throttle, toSync } from 'lib/utils' import { throttle, toSync } from 'lib/utils'
@ -48,6 +47,7 @@ import { ActionButton } from 'components/ActionButton'
import { err, reportRejection, trap } from 'lib/trap' import { err, reportRejection, trap } from 'lib/trap'
import { Node } from 'wasm-lib/kcl/bindings/Node' import { Node } from 'wasm-lib/kcl/bindings/Node'
import { commandBarActor } from 'machines/commandBarMachine' import { commandBarActor } from 'machines/commandBarMachine'
import { useSettings } from 'machines/appMachine'
function useShouldHideScene(): { hideClient: boolean; hideServer: boolean } { function useShouldHideScene(): { hideClient: boolean; hideServer: boolean } {
const [isCamMoving, setIsCamMoving] = useState(false) const [isCamMoving, setIsCamMoving] = useState(false)
@ -76,8 +76,8 @@ export const ClientSideScene = ({
cameraControls, cameraControls,
}: { }: {
cameraControls: ReturnType< cameraControls: ReturnType<
typeof useSettingsAuthContext typeof useSettings
>['settings']['context']['modeling']['mouseControls']['current'] >['modeling']['mouseControls']['current']
}) => { }) => {
const canvasRef = useRef<HTMLDivElement>(null) const canvasRef = useRef<HTMLDivElement>(null)
const { state, send, context } = useModelingContext() const { state, send, context } = useModelingContext()

View File

@ -1,24 +1,22 @@
import { Switch } from '@headlessui/react' import { Switch } from '@headlessui/react'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { settingsActor, useSettings } from 'machines/appMachine'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
export function CameraProjectionToggle() { export function CameraProjectionToggle() {
const { settings } = useSettingsAuthContext() const settings = useSettings()
const isCameraProjectionPerspective = const isCameraProjectionPerspective =
settings.context.modeling.cameraProjection.current === 'perspective' settings.modeling.cameraProjection.current === 'perspective'
const [checked, setChecked] = useState(isCameraProjectionPerspective) const [checked, setChecked] = useState(isCameraProjectionPerspective)
useEffect(() => { useEffect(() => {
setChecked( setChecked(settings.modeling.cameraProjection.current === 'perspective')
settings.context.modeling.cameraProjection.current === 'perspective' }, [settings.modeling.cameraProjection.current])
)
}, [settings.context.modeling.cameraProjection.current])
return ( return (
<Switch <Switch
checked={checked} checked={checked}
onChange={(newValue) => { onChange={(newValue) => {
settings.send({ settingsActor.send({
type: 'set.modeling.cameraProjection', type: 'set.modeling.cameraProjection',
data: { data: {
level: 'user', level: 'user',

View File

@ -7,7 +7,6 @@ import {
} from '@codemirror/autocomplete' } from '@codemirror/autocomplete'
import { EditorView, keymap, ViewUpdate } from '@codemirror/view' import { EditorView, keymap, ViewUpdate } from '@codemirror/view'
import { CustomIcon } from 'components/CustomIcon' import { CustomIcon } from 'components/CustomIcon'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { CommandArgument, KclCommandValue } from 'lib/commandTypes' import { CommandArgument, KclCommandValue } from 'lib/commandTypes'
import { getSystemTheme } from 'lib/theme' import { getSystemTheme } from 'lib/theme'
import { useCalculateKclExpression } from 'lib/useCalculateKclExpression' import { useCalculateKclExpression } from 'lib/useCalculateKclExpression'
@ -20,6 +19,7 @@ import { createIdentifier, createVariableDeclaration } from 'lang/modifyAst'
import { useCodeMirror } from 'components/ModelingSidebar/ModelingPanes/CodeEditor' import { useCodeMirror } from 'components/ModelingSidebar/ModelingPanes/CodeEditor'
import { useSelector } from '@xstate/react' import { useSelector } from '@xstate/react'
import { commandBarActor, useCommandBarState } from 'machines/commandBarMachine' import { commandBarActor, useCommandBarState } from 'machines/commandBarMachine'
import { useSettings } from 'machines/appMachine'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
const machineContextSelector = (snapshot?: { const machineContextSelector = (snapshot?: {
@ -42,7 +42,7 @@ function CommandBarKclInput({
const previouslySetValue = commandBarState.context.argumentsToSubmit[ const previouslySetValue = commandBarState.context.argumentsToSubmit[
arg.name arg.name
] as KclCommandValue | undefined ] as KclCommandValue | undefined
const { settings } = useSettingsAuthContext() const settings = useSettings()
const argMachineContext = useSelector( const argMachineContext = useSelector(
arg.machineActor, arg.machineActor,
machineContextSelector machineContextSelector
@ -117,9 +117,9 @@ function CommandBarKclInput({
: defaultValue.length, : defaultValue.length,
}, },
theme: theme:
settings.context.app.theme.current === 'system' settings.app.theme.current === 'system'
? getSystemTheme() ? getSystemTheme()
: settings.context.app.theme.current, : settings.app.theme.current,
extensions: [ extensions: [
varMentionsExtension, varMentionsExtension,
EditorView.updateListener.of((vu: ViewUpdate) => { EditorView.updateListener.of((vu: ViewUpdate) => {

View File

@ -1,16 +1,16 @@
import { Dialog } from '@headlessui/react' import { Dialog } from '@headlessui/react'
import { ActionButton } from './ActionButton' import { ActionButton } from './ActionButton'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { useState } from 'react' import { useState } from 'react'
import { useSearchParams } from 'react-router-dom' import { useSearchParams } from 'react-router-dom'
import { CREATE_FILE_URL_PARAM } from 'lib/constants' import { CREATE_FILE_URL_PARAM } from 'lib/constants'
import { useSettings } from 'machines/appMachine'
const DownloadAppBanner = () => { const DownloadAppBanner = () => {
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
const hasCreateFileParam = searchParams.has(CREATE_FILE_URL_PARAM) const hasCreateFileParam = searchParams.has(CREATE_FILE_URL_PARAM)
const { settings } = useSettingsAuthContext() const settings = useSettings()
const [isBannerDismissed, setIsBannerDismissed] = useState( const [isBannerDismissed, setIsBannerDismissed] = useState(
settings.context.app.dismissWebBanner.current || hasCreateFileParam settings.app.dismissWebBanner.current
) )
return ( return (

View File

@ -1,7 +1,7 @@
import { useMachine } from '@xstate/react' import { useMachine } from '@xstate/react'
import { useNavigate, useRouteLoaderData } from 'react-router-dom' import { useLocation, useNavigate, useRouteLoaderData } from 'react-router-dom'
import { type IndexLoaderData } from 'lib/types' import { type IndexLoaderData } from 'lib/types'
import { PATHS } from 'lib/paths' import { BROWSER_PATH, PATHS } from 'lib/paths'
import React, { createContext, useEffect, useMemo } from 'react' import React, { createContext, useEffect, useMemo } from 'react'
import { toast } from 'react-hot-toast' import { toast } from 'react-hot-toast'
import { import {
@ -27,9 +27,10 @@ import {
getKclSamplesManifest, getKclSamplesManifest,
KclSamplesManifestItem, KclSamplesManifestItem,
} from 'lib/getKclSamplesManifest' } from 'lib/getKclSamplesManifest'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { markOnce } from 'lib/performance' import { markOnce } from 'lib/performance'
import { commandBarActor } from 'machines/commandBarMachine' import { commandBarActor } from 'machines/commandBarMachine'
import { settingsActor, useSettings } from 'machines/appMachine'
import { createRouteCommands } from 'lib/commandBarConfigs/routeCommandConfig'
import { useToken } from 'machines/appMachine' import { useToken } from 'machines/appMachine'
type MachineContext<T extends AnyStateMachine> = { type MachineContext<T extends AnyStateMachine> = {
@ -48,14 +49,51 @@ export const FileMachineProvider = ({
children: React.ReactNode children: React.ReactNode
}) => { }) => {
const navigate = useNavigate() const navigate = useNavigate()
const { settings } = useSettingsAuthContext() const location = useLocation()
const token = useToken() const token = useToken()
const settings = useSettings()
const projectData = useRouteLoaderData(PATHS.FILE) as IndexLoaderData const projectData = useRouteLoaderData(PATHS.FILE) as IndexLoaderData
const { project, file } = projectData const { project, file } = projectData
const [kclSamples, setKclSamples] = React.useState<KclSamplesManifestItem[]>( const [kclSamples, setKclSamples] = React.useState<KclSamplesManifestItem[]>(
[] []
) )
// Due to the route provider, i've moved this to the FileMachineProvider instead of CommandBarProvider
// This will register the commands to route to Telemetry, Home, and Settings.
useEffect(() => {
const filePath =
PATHS.FILE + '/' + encodeURIComponent(file?.path || BROWSER_PATH)
const { RouteTelemetryCommand, RouteHomeCommand, RouteSettingsCommand } =
createRouteCommands(navigate, location, filePath)
commandBarActor.send({
type: 'Remove commands',
data: {
commands: [
RouteTelemetryCommand,
RouteHomeCommand,
RouteSettingsCommand,
],
},
})
if (location.pathname === PATHS.HOME) {
commandBarActor.send({
type: 'Add commands',
data: { commands: [RouteTelemetryCommand, RouteSettingsCommand] },
})
} else if (location.pathname.includes(PATHS.FILE)) {
commandBarActor.send({
type: 'Add commands',
data: {
commands: [
RouteTelemetryCommand,
RouteSettingsCommand,
RouteHomeCommand,
],
},
})
}
}, [location])
useEffect(() => { useEffect(() => {
markOnce('code/didLoadFile') markOnce('code/didLoadFile')
async function fetchKclSamples() { async function fetchKclSamples() {
@ -323,7 +361,7 @@ export const FileMachineProvider = ({
authToken: token ?? '', authToken: token ?? '',
projectData, projectData,
settings: { settings: {
defaultUnit: settings?.context?.modeling.defaultUnit.current ?? 'mm', defaultUnit: settings.modeling.defaultUnit.current ?? 'mm',
}, },
specialPropsForSampleCommand: { specialPropsForSampleCommand: {
onSubmit: async (data) => { onSubmit: async (data) => {
@ -345,7 +383,7 @@ export const FileMachineProvider = ({
// Either way, we want to overwrite the defaultUnit project setting // Either way, we want to overwrite the defaultUnit project setting
// with the sample's setting. // with the sample's setting.
if (data.sampleUnits) { if (data.sampleUnits) {
settings.send({ settingsActor.send({
type: 'set.modeling.defaultUnit', type: 'set.modeling.defaultUnit',
data: { data: {
level: 'project', level: 'project',

View File

@ -1,6 +1,5 @@
import { Popover } from '@headlessui/react' import { Popover } from '@headlessui/react'
import Tooltip from './Tooltip' import Tooltip from './Tooltip'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { CustomIcon } from './CustomIcon' import { CustomIcon } from './CustomIcon'
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
import { PATHS } from 'lib/paths' import { PATHS } from 'lib/paths'
@ -9,6 +8,7 @@ import { useAbsoluteFilePath } from 'hooks/useAbsoluteFilePath'
import { useLspContext } from './LspProvider' import { useLspContext } from './LspProvider'
import { openExternalBrowserIfDesktop } from 'lib/openWindow' import { openExternalBrowserIfDesktop } from 'lib/openWindow'
import { reportRejection } from 'lib/trap' import { reportRejection } from 'lib/trap'
import { settingsActor } from 'machines/appMachine'
const HelpMenuDivider = () => ( const HelpMenuDivider = () => (
<div className="h-[1px] bg-chalkboard-110 dark:bg-chalkboard-80" /> <div className="h-[1px] bg-chalkboard-110 dark:bg-chalkboard-80" />
@ -20,7 +20,6 @@ export function HelpMenu(props: React.PropsWithChildren) {
const filePath = useAbsoluteFilePath() const filePath = useAbsoluteFilePath()
const isInProject = location.pathname.includes(PATHS.FILE) const isInProject = location.pathname.includes(PATHS.FILE)
const navigate = useNavigate() const navigate = useNavigate()
const { settings } = useSettingsAuthContext()
return ( return (
<Popover className="relative"> <Popover className="relative">
@ -106,7 +105,7 @@ export function HelpMenu(props: React.PropsWithChildren) {
<HelpMenuItem <HelpMenuItem
as="button" as="button"
onClick={() => { onClick={() => {
settings.send({ settingsActor.send({
type: 'set.app.onboardingStatus', type: 'set.app.onboardingStatus',
data: { data: {
value: '', value: '',

View File

@ -10,7 +10,6 @@ import {
import { TEST, VITE_KC_API_BASE_URL } from 'env' import { TEST, VITE_KC_API_BASE_URL } from 'env'
import { kcl } from 'editor/plugins/lsp/kcl/language' import { kcl } from 'editor/plugins/lsp/kcl/language'
import { copilotPlugin } from 'editor/plugins/lsp/copilot' import { copilotPlugin } from 'editor/plugins/lsp/copilot'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { Extension } from '@codemirror/state' import { Extension } from '@codemirror/state'
import { LanguageSupport } from '@codemirror/language' import { LanguageSupport } from '@codemirror/language'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'

View File

@ -22,7 +22,6 @@ import {
modelingMachineDefaultContext, modelingMachineDefaultContext,
} from 'machines/modelingMachine' } from 'machines/modelingMachine'
import { useSetupEngineManager } from 'hooks/useSetupEngineManager' import { useSetupEngineManager } from 'hooks/useSetupEngineManager'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { import {
isCursorInSketchCommandRange, isCursorInSketchCommandRange,
updateSketchDetailsNodePaths, updateSketchDetailsNodePaths,
@ -110,6 +109,7 @@ import { kclEditorActor } from 'machines/kclEditorMachine'
import { commandBarActor } from 'machines/commandBarMachine' import { commandBarActor } from 'machines/commandBarMachine'
import { useToken } from 'machines/appMachine' import { useToken } from 'machines/appMachine'
import { getNodePathFromSourceRange } from 'lang/queryAstNodePathUtils' import { getNodePathFromSourceRange } from 'lang/queryAstNodePathUtils'
import { useSettings } from 'machines/appMachine'
type MachineContext<T extends AnyStateMachine> = { type MachineContext<T extends AnyStateMachine> = {
state: StateFrom<T> state: StateFrom<T>
@ -131,19 +131,15 @@ export const ModelingMachineProvider = ({
children: React.ReactNode children: React.ReactNode
}) => { }) => {
const { const {
settings: { app: { theme, enableSSAO, allowOrbitInSketchMode },
context: { modeling: {
app: { theme, enableSSAO, allowOrbitInSketchMode }, defaultUnit,
modeling: { cameraProjection,
defaultUnit, highlightEdges,
cameraProjection, showScaleGrid,
highlightEdges, cameraOrbit,
showScaleGrid,
cameraOrbit,
},
},
}, },
} = useSettingsAuthContext() } = useSettings()
const previousAllowOrbitInSketchMode = useRef(allowOrbitInSketchMode.current) const previousAllowOrbitInSketchMode = useRef(allowOrbitInSketchMode.current)
const navigate = useNavigate() const navigate = useNavigate()
const { context, send: fileMachineSend } = useFileContext() const { context, send: fileMachineSend } = useFileContext()

View File

@ -1,12 +1,12 @@
import { ReactNode } from 'react' import { ReactNode } from 'react'
import styles from './ModelingPane.module.css' import styles from './ModelingPane.module.css'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { ActionButton } from 'components/ActionButton' import { ActionButton } from 'components/ActionButton'
import Tooltip from 'components/Tooltip' import Tooltip from 'components/Tooltip'
import { CustomIconName } from 'components/CustomIcon' import { CustomIconName } from 'components/CustomIcon'
import { IconDefinition } from '@fortawesome/free-solid-svg-icons' import { IconDefinition } from '@fortawesome/free-solid-svg-icons'
import { ActionIcon } from 'components/ActionIcon' import { ActionIcon } from 'components/ActionIcon'
import { onboardingPaths } from 'routes/Onboarding/paths' import { onboardingPaths } from 'routes/Onboarding/paths'
import { useSettings } from 'machines/appMachine'
export interface ModelingPaneProps { export interface ModelingPaneProps {
id: string id: string
@ -68,8 +68,8 @@ export const ModelingPane = ({
title, title,
...props ...props
}: ModelingPaneProps) => { }: ModelingPaneProps) => {
const { settings } = useSettingsAuthContext() const settings = useSettings()
const onboardingStatus = settings.context.app.onboardingStatus const onboardingStatus = settings.app.onboardingStatus
const pointerEventsCssClass = const pointerEventsCssClass =
onboardingStatus.current === onboardingPaths.CAMERA onboardingStatus.current === onboardingPaths.CAMERA
? 'pointer-events-none ' ? 'pointer-events-none '

View File

@ -1,5 +1,4 @@
import { TEST } from 'env' import { TEST } from 'env'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { Themes, getSystemTheme } from 'lib/theme' import { Themes, getSystemTheme } from 'lib/theme'
import { useEffect, useMemo, useRef } from 'react' import { useEffect, useMemo, useRef } from 'react'
import { highlightSelectionMatches, searchKeymap } from '@codemirror/search' import { highlightSelectionMatches, searchKeymap } from '@codemirror/search'
@ -51,6 +50,7 @@ import {
} from 'machines/kclEditorMachine' } from 'machines/kclEditorMachine'
import { useSelector } from '@xstate/react' import { useSelector } from '@xstate/react'
import { modelingMachineEvent } from 'editor/manager' import { modelingMachineEvent } from 'editor/manager'
import { useSettings } from 'machines/appMachine'
export const editorShortcutMeta = { export const editorShortcutMeta = {
formatCode: { formatCode: {
@ -63,9 +63,7 @@ export const editorShortcutMeta = {
} }
export const KclEditorPane = () => { export const KclEditorPane = () => {
const { const context = useSettings()
settings: { context },
} = useSettingsAuthContext()
const lastSelectionEvent = useSelector(kclEditorActor, selectionEventSelector) const lastSelectionEvent = useSelector(kclEditorActor, selectionEventSelector)
const editorIsMounted = useSelector(kclEditorActor, editorIsMountedSelector) const editorIsMounted = useSelector(kclEditorActor, editorIsMountedSelector)
const theme = const theme =

View File

@ -1,4 +1,3 @@
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { Resizable } from 're-resizable' import { Resizable } from 're-resizable'
import { import {
MouseEventHandler, MouseEventHandler,
@ -21,6 +20,7 @@ import { MachineManagerContext } from 'components/MachineManagerProvider'
import { onboardingPaths } from 'routes/Onboarding/paths' import { onboardingPaths } from 'routes/Onboarding/paths'
import { SIDEBAR_BUTTON_SUFFIX } from 'lib/constants' import { SIDEBAR_BUTTON_SUFFIX } from 'lib/constants'
import { commandBarActor } from 'machines/commandBarMachine' import { commandBarActor } from 'machines/commandBarMachine'
import { useSettings } from 'machines/appMachine'
interface ModelingSidebarProps { interface ModelingSidebarProps {
paneOpacity: '' | 'opacity-20' | 'opacity-40' paneOpacity: '' | 'opacity-20' | 'opacity-40'
@ -38,23 +38,23 @@ function getPlatformString(): 'web' | 'desktop' {
export function ModelingSidebar({ paneOpacity }: ModelingSidebarProps) { export function ModelingSidebar({ paneOpacity }: ModelingSidebarProps) {
const machineManager = useContext(MachineManagerContext) const machineManager = useContext(MachineManagerContext)
const kclContext = useKclContext() const kclContext = useKclContext()
const { settings } = useSettingsAuthContext() const settings = useSettings()
const onboardingStatus = settings.context.app.onboardingStatus const onboardingStatus = settings.app.onboardingStatus
const { send, context } = useModelingContext() const { send, context } = useModelingContext()
const pointerEventsCssClass = const pointerEventsCssClass =
onboardingStatus.current === onboardingPaths.CAMERA || onboardingStatus.current === onboardingPaths.CAMERA ||
context.store?.openPanes.length === 0 context.store?.openPanes.length === 0
? 'pointer-events-none ' ? 'pointer-events-none '
: 'pointer-events-auto ' : 'pointer-events-auto '
const showDebugPanel = settings.context.modeling.showDebugPanel const showDebugPanel = settings.modeling.showDebugPanel
const paneCallbackProps = useMemo( const paneCallbackProps = useMemo(
() => ({ () => ({
kclContext, kclContext,
settings: settings.context, settings,
platform: getPlatformString(), platform: getPlatformString(),
}), }),
[kclContext.diagnostics, settings.context] [kclContext.diagnostics, settings]
) )
const sidebarActions: SidebarAction[] = [ const sidebarActions: SidebarAction[] = [
@ -144,7 +144,7 @@ export function ModelingSidebar({ paneOpacity }: ModelingSidebarProps) {
}, },
}) })
} }
}, [settings.context]) }, [settings.modeling.showDebugPanel])
const togglePane = useCallback( const togglePane = useCallback(
(newPane: SidebarType) => { (newPane: SidebarType) => {

View File

@ -1,6 +1,5 @@
import { fireEvent, render, screen } from '@testing-library/react' import { fireEvent, render, screen } from '@testing-library/react'
import { BrowserRouter } from 'react-router-dom' import { BrowserRouter } from 'react-router-dom'
import { SettingsAuthProviderJest } from './SettingsAuthProvider'
import { import {
NETWORK_HEALTH_TEXT, NETWORK_HEALTH_TEXT,
NetworkHealthIndicator, NetworkHealthIndicator,
@ -9,11 +8,7 @@ import { NetworkHealthState } from 'hooks/useNetworkStatus'
function TestWrap({ children }: { children: React.ReactNode }) { function TestWrap({ children }: { children: React.ReactNode }) {
// wrap in router and xState context // wrap in router and xState context
return ( return <BrowserRouter>{children}</BrowserRouter>
<BrowserRouter>
<SettingsAuthProviderJest>{children}</SettingsAuthProviderJest>
</BrowserRouter>
)
} }
// Our Playwright tests for this are much more comprehensive. // Our Playwright tests for this are much more comprehensive.

View File

@ -1,7 +1,6 @@
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react'
import { BrowserRouter } from 'react-router-dom' import { BrowserRouter } from 'react-router-dom'
import ProjectSidebarMenu from './ProjectSidebarMenu' import ProjectSidebarMenu from './ProjectSidebarMenu'
import { SettingsAuthProviderJest } from './SettingsAuthProvider'
import { Project } from 'lib/project' import { Project } from 'lib/project'
const now = new Date() const now = new Date()
@ -32,9 +31,7 @@ describe('ProjectSidebarMenu tests', () => {
test('Disables popover menu by default', () => { test('Disables popover menu by default', () => {
render( render(
<BrowserRouter> <BrowserRouter>
<SettingsAuthProviderJest> <ProjectSidebarMenu project={projectWellFormed} />
<ProjectSidebarMenu project={projectWellFormed} />
</SettingsAuthProviderJest>
</BrowserRouter> </BrowserRouter>
) )

View File

@ -18,7 +18,6 @@ import { SnapshotFrom } from 'xstate'
import { commandBarActor } from 'machines/commandBarMachine' import { commandBarActor } from 'machines/commandBarMachine'
import { useSelector } from '@xstate/react' import { useSelector } from '@xstate/react'
import { copyFileShareLink } from 'lib/links' import { copyFileShareLink } from 'lib/links'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { useToken } from 'machines/appMachine' import { useToken } from 'machines/appMachine'
const ProjectSidebarMenu = ({ const ProjectSidebarMenu = ({
@ -103,7 +102,6 @@ function ProjectMenuPopover({
const location = useLocation() const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const filePath = useAbsoluteFilePath() const filePath = useAbsoluteFilePath()
useSettingsAuthContext()
const token = useToken() const token = useToken()
const machineManager = useContext(MachineManagerContext) const machineManager = useContext(MachineManagerContext)
const commands = useSelector(commandBarActor, commandsSelector) const commands = useSelector(commandBarActor, commandsSelector)

View File

@ -20,11 +20,11 @@ import {
getUniqueProjectName, getUniqueProjectName,
getNextFileName, getNextFileName,
} from 'lib/desktopFS' } from 'lib/desktopFS'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import useStateMachineCommands from 'hooks/useStateMachineCommands' import useStateMachineCommands from 'hooks/useStateMachineCommands'
import { projectsCommandBarConfig } from 'lib/commandBarConfigs/projectsCommandConfig' import { projectsCommandBarConfig } from 'lib/commandBarConfigs/projectsCommandConfig'
import { isDesktop } from 'lib/isDesktop' import { isDesktop } from 'lib/isDesktop'
import { commandBarActor } from 'machines/commandBarMachine' import { commandBarActor } from 'machines/commandBarMachine'
import { useSettings } from 'machines/appMachine'
import { import {
CREATE_FILE_URL_PARAM, CREATE_FILE_URL_PARAM,
FILE_EXT, FILE_EXT,
@ -77,9 +77,7 @@ const ProjectsContextWeb = ({ children }: { children: React.ReactNode }) => {
searchParams.delete('units') searchParams.delete('units')
setSearchParams(searchParams) setSearchParams(searchParams)
}, [searchParams, setSearchParams]) }, [searchParams, setSearchParams])
const { const settings = useSettings()
settings: { context: settings },
} = useSettingsAuthContext()
const [state, send, actor] = useMachine( const [state, send, actor] = useMachine(
projectsMachine.provide({ projectsMachine.provide({
@ -183,9 +181,7 @@ const ProjectsContextDesktop = ({
setSearchParams(searchParams) setSearchParams(searchParams)
}, [searchParams, setSearchParams]) }, [searchParams, setSearchParams])
const { onProjectOpen } = useLspContext() const { onProjectOpen } = useLspContext()
const { const settings = useSettings()
settings: { context: settings },
} = useSettingsAuthContext()
const [projectsLoaderTrigger, setProjectsLoaderTrigger] = useState(0) const [projectsLoaderTrigger, setProjectsLoaderTrigger] = useState(0)
const { projectPaths, projectsDir } = useProjectsLoader([ const { projectPaths, projectsDir } = useProjectsLoader([

View File

@ -5,7 +5,6 @@ import { codeManager, engineCommandManager } from 'lib/singletons'
import React, { useMemo } from 'react' import React, { useMemo } from 'react'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import Tooltip from './Tooltip' import Tooltip from './Tooltip'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { reportRejection } from 'lib/trap' import { reportRejection } from 'lib/trap'
import { toSync } from 'lib/utils' import { toSync } from 'lib/utils'
import { useToken } from 'machines/appMachine' import { useToken } from 'machines/appMachine'

View File

@ -1,17 +1,36 @@
import { useEffect, useState, createContext, ReactNode } from 'react' import { useEffect, useState, createContext, ReactNode } from 'react'
import { useNavigation, useLocation } from 'react-router-dom' import {
useNavigation,
useLocation,
useNavigate,
useRouteLoaderData,
} from 'react-router-dom'
import { PATHS } from 'lib/paths' import { PATHS } from 'lib/paths'
import { markOnce } from 'lib/performance' import { markOnce } from 'lib/performance'
import { useAuthNavigation } from 'hooks/useAuthNavigation' import { useAuthNavigation } from 'hooks/useAuthNavigation'
import { useAuthState } from 'machines/appMachine'
import { IndexLoaderData } from 'lib/types'
import { getAppSettingsFilePath } from 'lib/desktop'
import { isDesktop } from 'lib/isDesktop'
import { trap } from 'lib/trap'
import { useFileSystemWatcher } from 'hooks/useFileSystemWatcher'
import { loadAndValidateSettings } from 'lib/settings/settingsUtils'
import { settingsActor } from 'machines/appMachine'
export const RouteProviderContext = createContext({}) export const RouteProviderContext = createContext({})
export function RouteProvider({ children }: { children: ReactNode }) { export function RouteProvider({ children }: { children: ReactNode }) {
useAuthNavigation() useAuthNavigation()
const loadedProject = useRouteLoaderData(PATHS.FILE) as IndexLoaderData
const [first, setFirstState] = useState(true) const [first, setFirstState] = useState(true)
const [settingsPath, setSettingsPath] = useState<string | undefined>(
undefined
)
const navigation = useNavigation() const navigation = useNavigation()
const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const authState = useAuthState()
useEffect(() => { useEffect(() => {
// On initialization, the react-router-dom does not send a 'loading' state event. // On initialization, the react-router-dom does not send a 'loading' state event.
// it sends an idle event first. // it sends an idle event first.
@ -28,6 +47,41 @@ export function RouteProvider({ children }: { children: ReactNode }) {
setFirstState(false) setFirstState(false)
}, [navigation]) }, [navigation])
useEffect(() => {
if (!isDesktop()) return
getAppSettingsFilePath().then(setSettingsPath).catch(trap)
}, [])
useFileSystemWatcher(
async (eventType: string) => {
// If there is a projectPath but it no longer exists it means
// it was exterally removed. If we let the code past this condition
// execute it will recreate the directory due to code in
// loadAndValidateSettings trying to recreate files. I do not
// wish to change the behavior in case anything else uses it.
// Go home.
if (loadedProject?.project?.path) {
if (!window.electron.exists(loadedProject?.project?.path)) {
navigate(PATHS.HOME)
return
}
}
// Only reload if there are changes. Ignore everything else.
if (eventType !== 'change') return
const data = await loadAndValidateSettings(loadedProject?.project?.path)
settingsActor.send({
type: 'Set all settings',
settings: data.settings,
doNotPersist: true,
})
},
[settingsPath, loadedProject?.project?.path].filter(
(x: string | undefined) => x !== undefined
)
)
return ( return (
<RouteProviderContext.Provider value={{}}> <RouteProviderContext.Provider value={{}}>
{children} {children}

View File

@ -1,5 +1,4 @@
import decamelize from 'decamelize' import decamelize from 'decamelize'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { Setting } from 'lib/settings/initialSettings' import { Setting } from 'lib/settings/initialSettings'
import { SetEventTypes, SettingsLevel } from 'lib/settings/settingsTypes' import { SetEventTypes, SettingsLevel } from 'lib/settings/settingsTypes'
import { import {
@ -25,6 +24,8 @@ import { useLspContext } from 'components/LspProvider'
import { toSync } from 'lib/utils' import { toSync } from 'lib/utils'
import { reportRejection } from 'lib/trap' import { reportRejection } from 'lib/trap'
import { openExternalBrowserIfDesktop } from 'lib/openWindow' import { openExternalBrowserIfDesktop } from 'lib/openWindow'
import { settingsActor, useSettings } from 'machines/appMachine'
import { useSelector } from '@xstate/react'
interface AllSettingsFieldsProps { interface AllSettingsFieldsProps {
searchParamTab: SettingsLevel searchParamTab: SettingsLevel
@ -40,9 +41,7 @@ export const AllSettingsFields = forwardRef(
const navigate = useNavigate() const navigate = useNavigate()
const { onProjectOpen } = useLspContext() const { onProjectOpen } = useLspContext()
const dotDotSlash = useDotDotSlash() const dotDotSlash = useDotDotSlash()
const { const context = useSettings()
settings: { send, context, state },
} = useSettingsAuthContext()
const projectPath = useMemo(() => { const projectPath = useMemo(() => {
const filteredPathname = location.pathname const filteredPathname = location.pathname
@ -62,7 +61,7 @@ export const AllSettingsFields = forwardRef(
}, [location.pathname]) }, [location.pathname])
function restartOnboarding() { function restartOnboarding() {
send({ settingsActor.send({
type: `set.app.onboardingStatus`, type: `set.app.onboardingStatus`,
data: { level: 'user', value: '' }, data: { level: 'user', value: '' },
}) })
@ -72,11 +71,14 @@ export const AllSettingsFields = forwardRef(
* A "listener" for the XState to return to "idle" state * A "listener" for the XState to return to "idle" state
* when the user resets the onboarding, using the callback above * when the user resets the onboarding, using the callback above
*/ */
const isSettingsMachineIdle = useSelector(settingsActor, (s) =>
s.matches('idle')
)
useEffect(() => { useEffect(() => {
async function navigateToOnboardingStart() { async function navigateToOnboardingStart() {
if ( if (
state.context.app.onboardingStatus.user === '' && context.app.onboardingStatus.current === '' &&
state.matches('idle') isSettingsMachineIdle
) { ) {
if (isFileSettings) { if (isFileSettings) {
// If we're in a project, first navigate to the onboarding start here // If we're in a project, first navigate to the onboarding start here
@ -91,7 +93,12 @@ export const AllSettingsFields = forwardRef(
} }
// eslint-disable-next-line @typescript-eslint/no-floating-promises // eslint-disable-next-line @typescript-eslint/no-floating-promises
navigateToOnboardingStart() navigateToOnboardingStart()
}, [isFileSettings, navigate, state]) }, [
isFileSettings,
navigate,
isSettingsMachineIdle,
context.app.onboardingStatus.current,
])
return ( return (
<div className="relative overflow-y-auto"> <div className="relative overflow-y-auto">
@ -142,7 +149,7 @@ export const AllSettingsFields = forwardRef(
} }
parentLevel={setting.getParentLevel(searchParamTab)} parentLevel={setting.getParentLevel(searchParamTab)}
onFallback={() => onFallback={() =>
send({ settingsActor.send({
type: `set.${category}.${settingName}`, type: `set.${category}.${settingName}`,
data: { data: {
level: searchParamTab, level: searchParamTab,
@ -218,7 +225,7 @@ export const AllSettingsFields = forwardRef(
<ActionButton <ActionButton
Element="button" Element="button"
onClick={() => { onClick={() => {
send({ settingsActor.send({
type: 'Reset settings', type: 'Reset settings',
level: searchParamTab, level: searchParamTab,
}) })

View File

@ -1,5 +1,4 @@
import { Toggle } from 'components/Toggle/Toggle' import { Toggle } from 'components/Toggle/Toggle'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { Setting } from 'lib/settings/initialSettings' import { Setting } from 'lib/settings/initialSettings'
import { import {
SetEventTypes, SetEventTypes,
@ -7,6 +6,7 @@ import {
WildcardSetEvent, WildcardSetEvent,
} from 'lib/settings/settingsTypes' } from 'lib/settings/settingsTypes'
import { getSettingInputType } from 'lib/settings/settingsUtils' import { getSettingInputType } from 'lib/settings/settingsUtils'
import { settingsActor, useSettings } from 'machines/appMachine'
import { useMemo } from 'react' import { useMemo } from 'react'
import { EventFrom } from 'xstate' import { EventFrom } from 'xstate'
@ -25,9 +25,8 @@ export function SettingsFieldInput({
settingsLevel, settingsLevel,
setting, setting,
}: SettingsFieldInputProps) { }: SettingsFieldInputProps) {
const { const context = useSettings()
settings: { context, send }, const send = settingsActor.send
} = useSettingsAuthContext()
const options = useMemo(() => { const options = useMemo(() => {
return setting.commandConfig && return setting.commandConfig &&
'options' in setting.commandConfig && 'options' in setting.commandConfig &&

View File

@ -2,10 +2,10 @@ import { Combobox } from '@headlessui/react'
import { CustomIcon } from 'components/CustomIcon' import { CustomIcon } from 'components/CustomIcon'
import decamelize from 'decamelize' import decamelize from 'decamelize'
import Fuse from 'fuse.js' import Fuse from 'fuse.js'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { interactionMap } from 'lib/settings/initialKeybindings' import { interactionMap } from 'lib/settings/initialKeybindings'
import { Setting } from 'lib/settings/initialSettings' import { Setting } from 'lib/settings/initialSettings'
import { SettingsLevel } from 'lib/settings/settingsTypes' import { SettingsLevel } from 'lib/settings/settingsTypes'
import { useSettings } from 'machines/appMachine'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { useHotkeys } from 'react-hotkeys-hook' import { useHotkeys } from 'react-hotkeys-hook'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
@ -32,23 +32,22 @@ export function SettingsSearchBar() {
) )
const navigate = useNavigate() const navigate = useNavigate()
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const { settings } = useSettingsAuthContext() const settings = useSettings()
const settingsAsSearchable: SettingsSearchItem[] = useMemo( const settingsAsSearchable: SettingsSearchItem[] = useMemo(
() => [ () => [
...Object.entries(settings.state.context).flatMap( ...Object.entries(settings).flatMap(([category, categorySettings]) =>
([category, categorySettings]) => Object.entries(categorySettings).flatMap(([settingName, setting]) => {
Object.entries(categorySettings).flatMap(([settingName, setting]) => { const s = setting
const s = setting as Setting return (['project', 'user'] satisfies SettingsLevel[])
return (['project', 'user'] satisfies SettingsLevel[]) .filter((l) => s.hideOnLevel !== l)
.filter((l) => s.hideOnLevel !== l) .map((l) => ({
.map((l) => ({ category: decamelize(category, { separator: ' ' }),
category: decamelize(category, { separator: ' ' }), name: settingName,
name: settingName, description: s.description ?? '',
description: s.description ?? '', displayName: decamelize(settingName, { separator: ' ' }),
displayName: decamelize(settingName, { separator: ' ' }), level: l,
level: l as ExtendedSettingsLevel, }))
})) })
})
), ),
...Object.entries(interactionMap).flatMap( ...Object.entries(interactionMap).flatMap(
([category, categoryKeybindings]) => ([category, categoryKeybindings]) =>
@ -61,7 +60,7 @@ export function SettingsSearchBar() {
})) }))
), ),
], ],
[settings.state.context] [settings]
) )
const [searchResults, setSearchResults] = useState(settingsAsSearchable) const [searchResults, setSearchResults] = useState(settingsAsSearchable)

View File

@ -1,8 +1,8 @@
import decamelize from 'decamelize' import decamelize from 'decamelize'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { Setting } from 'lib/settings/initialSettings' import { Setting } from 'lib/settings/initialSettings'
import { SettingsLevel } from 'lib/settings/settingsTypes' import { SettingsLevel } from 'lib/settings/settingsTypes'
import { shouldHideSetting } from 'lib/settings/settingsUtils' import { shouldHideSetting } from 'lib/settings/settingsUtils'
import { useSettings } from 'machines/appMachine'
interface SettingsSectionsListProps { interface SettingsSectionsListProps {
searchParamTab: SettingsLevel searchParamTab: SettingsLevel
@ -13,9 +13,7 @@ export function SettingsSectionsList({
searchParamTab, searchParamTab,
scrollRef, scrollRef,
}: SettingsSectionsListProps) { }: SettingsSectionsListProps) {
const { const context = useSettings()
settings: { context },
} = useSettingsAuthContext()
return ( return (
<div className="flex w-32 flex-col gap-3 pr-2 py-1 border-0 border-r border-r-chalkboard-20 dark:border-r-chalkboard-90"> <div className="flex w-32 flex-col gap-3 pr-2 py-1 border-0 border-r border-r-chalkboard-20 dark:border-r-chalkboard-90">
{Object.entries(context) {Object.entries(context)

View File

@ -1,383 +0,0 @@
import { trap } from 'lib/trap'
import { useMachine, useSelector } from '@xstate/react'
import { useNavigate, useRouteLoaderData, useLocation } from 'react-router-dom'
import { PATHS, BROWSER_PATH } from 'lib/paths'
import React, { createContext, useEffect, useState } from 'react'
import { settingsMachine } from 'machines/settingsMachine'
import { toast } from 'react-hot-toast'
import {
darkModeMatcher,
getOppositeTheme,
setThemeClass,
Themes,
} from 'lib/theme'
import decamelize from 'decamelize'
import { Actor, AnyStateMachine, ContextFrom, Prop, StateFrom } from 'xstate'
import {
kclManager,
sceneInfra,
engineCommandManager,
sceneEntitiesManager,
} from 'lib/singletons'
import { IndexLoaderData } from 'lib/types'
import { settings } from 'lib/settings/initialSettings'
import {
createSettingsCommand,
settingsWithCommandConfigs,
} from 'lib/commandBarConfigs/settingsCommandConfig'
import { Command } from 'lib/commandTypes'
import { BaseUnit } from 'lib/settings/settingsTypes'
import {
saveSettings,
loadAndValidateSettings,
} from 'lib/settings/settingsUtils'
import { reportRejection } from 'lib/trap'
import { getAppSettingsFilePath } from 'lib/desktop'
import { isDesktop } from 'lib/isDesktop'
import { useFileSystemWatcher } from 'hooks/useFileSystemWatcher'
import { codeManager } from 'lib/singletons'
import { createRouteCommands } from 'lib/commandBarConfigs/routeCommandConfig'
import { commandBarActor } from 'machines/commandBarMachine'
type MachineContext<T extends AnyStateMachine> = {
state: StateFrom<T>
context: ContextFrom<T>
send: Prop<Actor<T>, 'send'>
}
type SettingsAuthContextType = {
settings: MachineContext<typeof settingsMachine>
}
/**
* This variable is used to store the last snapshot of the settings context
* for use outside of React, such as in `wasm.ts`. It is updated every time
* the settings machine changes with `useSelector`.
* TODO: when we decouple XState from React, we can just subscribe to the actor directly from `wasm.ts`
*/
export let lastSettingsContextSnapshot:
| ContextFrom<typeof settingsMachine>
| undefined
export const SettingsAuthContext = createContext({} as SettingsAuthContextType)
export const SettingsAuthProvider = ({
children,
}: {
children: React.ReactNode
}) => {
const loadedSettings = useRouteLoaderData(PATHS.INDEX) as typeof settings
const loadedProject = useRouteLoaderData(PATHS.FILE) as IndexLoaderData
return (
<SettingsAuthProviderBase
loadedSettings={loadedSettings}
loadedProject={loadedProject}
>
{children}
</SettingsAuthProviderBase>
)
}
// For use in jest tests we don't want to use the loader data
// and mock the whole Router
export const SettingsAuthProviderJest = ({
children,
}: {
children: React.ReactNode
}) => {
const loadedSettings = settings
return (
<SettingsAuthProviderBase loadedSettings={loadedSettings}>
{children}
</SettingsAuthProviderBase>
)
}
export const SettingsAuthProviderBase = ({
children,
loadedSettings,
loadedProject,
}: {
children: React.ReactNode
loadedSettings: typeof settings
loadedProject?: IndexLoaderData
}) => {
const location = useLocation()
const navigate = useNavigate()
const [settingsPath, setSettingsPath] = useState<string | undefined>(
undefined
)
const [settingsState, settingsSend, settingsActor] = useMachine(
settingsMachine.provide({
actions: {
//TODO: batch all these and if that's difficult to do from tsx,
// make it easy to do
setClientSideSceneUnits: ({ context, event }) => {
const newBaseUnit =
event.type === 'set.modeling.defaultUnit'
? (event.data.value as BaseUnit)
: context.modeling.defaultUnit.current
sceneInfra.baseUnit = newBaseUnit
},
setEngineTheme: ({ context }) => {
engineCommandManager
.setTheme(context.app.theme.current)
.catch(reportRejection)
},
setClientTheme: ({ context }) => {
const opposingTheme = getOppositeTheme(context.app.theme.current)
sceneInfra.theme = opposingTheme
sceneEntitiesManager.updateSegmentBaseColor(opposingTheme)
},
setAllowOrbitInSketchMode: ({ context }) => {
sceneInfra.camControls._setting_allowOrbitInSketchMode =
context.app.allowOrbitInSketchMode.current
// ModelingMachineProvider will do a use effect to trigger the camera engine sync
},
toastSuccess: ({ event }) => {
if (!('data' in event)) return
const eventParts = event.type.replace(/^set./, '').split('.') as [
keyof typeof settings,
string
]
const truncatedNewValue = event.data.value?.toString().slice(0, 28)
const message =
`Set ${decamelize(eventParts[1], { separator: ' ' })}` +
(truncatedNewValue
? ` to "${truncatedNewValue}${
truncatedNewValue.length === 28 ? '...' : ''
}"${
event.data.level === 'project'
? ' for this project'
: ' as a user default'
}`
: '')
toast.success(message, {
duration: message.split(' ').length * 100 + 1500,
id: `${event.type}.success`,
})
},
'Execute AST': ({ context, event }) => {
try {
const relevantSetting = (s: typeof settings) => {
return (
s.modeling?.defaultUnit?.current !==
context.modeling.defaultUnit.current ||
s.modeling.showScaleGrid.current !==
context.modeling.showScaleGrid.current ||
s.modeling?.highlightEdges.current !==
context.modeling.highlightEdges.current
)
}
const allSettingsIncludesUnitChange =
event.type === 'Set all settings' &&
relevantSetting(event.settings)
const resetSettingsIncludesUnitChange =
event.type === 'Reset settings' && relevantSetting(settings)
if (
event.type === 'set.modeling.defaultUnit' ||
event.type === 'set.modeling.showScaleGrid' ||
event.type === 'set.modeling.highlightEdges' ||
allSettingsIncludesUnitChange ||
resetSettingsIncludesUnitChange
) {
// Unit changes requires a re-exec of code
// eslint-disable-next-line @typescript-eslint/no-floating-promises
kclManager.executeCode(true)
} else {
// For any future logging we'd like to do
// console.log(
// 'Not re-executing AST because the settings change did not affect the code interpretation'
// )
}
} catch (e) {
console.error('Error executing AST after settings change', e)
}
},
async persistSettings({ context, event }) {
// Without this, when a user changes the file, it'd
// create a detection loop with the file-system watcher.
if (event.doNotPersist) return
codeManager.writeCausedByAppCheckedInFileTreeFileSystemWatcher = true
return saveSettings(context, loadedProject?.project?.path)
},
},
}),
{ input: loadedSettings }
)
// Any time the actor changes, update the settings state for external use
useSelector(settingsActor, (s) => {
lastSettingsContextSnapshot = s.context
})
useEffect(() => {
if (!isDesktop()) return
getAppSettingsFilePath().then(setSettingsPath).catch(trap)
}, [])
useFileSystemWatcher(
async (eventType: string) => {
// If there is a projectPath but it no longer exists it means
// it was exterally removed. If we let the code past this condition
// execute it will recreate the directory due to code in
// loadAndValidateSettings trying to recreate files. I do not
// wish to change the behavior in case anything else uses it.
// Go home.
if (loadedProject?.project?.path) {
if (!window.electron.exists(loadedProject?.project?.path)) {
navigate(PATHS.HOME)
return
}
}
// Only reload if there are changes. Ignore everything else.
if (eventType !== 'change') return
const data = await loadAndValidateSettings(loadedProject?.project?.path)
settingsSend({
type: 'Set all settings',
settings: data.settings,
doNotPersist: true,
})
},
[settingsPath, loadedProject?.project?.path].filter(
(x: string | undefined) => x !== undefined
)
)
// Add settings commands to the command bar
// They're treated slightly differently than other commands
// Because their state machine doesn't have a meaningful .nextEvents,
// and they are configured statically in initialiSettings
useEffect(() => {
// If the user wants to hide the settings commands
//from the command bar don't add them.
if (settingsState.context.commandBar.includeSettings.current === false)
return
const commands = settingsWithCommandConfigs(settingsState.context)
.map((type) =>
createSettingsCommand({
type,
send: settingsSend,
context: settingsState.context,
actor: settingsActor,
isProjectAvailable: loadedProject !== undefined,
})
)
.filter((c) => c !== null) as Command[]
commandBarActor.send({ type: 'Add commands', data: { commands: commands } })
return () => {
commandBarActor.send({
type: 'Remove commands',
data: { commands },
})
}
}, [
settingsState,
settingsSend,
settingsActor,
commandBarActor.send,
settingsWithCommandConfigs,
])
// Due to the route provider, i've moved this to the SettingsAuthProvider instead of CommandBarProvider
// This will register the commands to route to Telemetry, Home, and Settings.
useEffect(() => {
const filePath =
PATHS.FILE +
'/' +
encodeURIComponent(loadedProject?.file?.path || BROWSER_PATH)
const { RouteTelemetryCommand, RouteHomeCommand, RouteSettingsCommand } =
createRouteCommands(navigate, location, filePath)
commandBarActor.send({
type: 'Remove commands',
data: {
commands: [
RouteTelemetryCommand,
RouteHomeCommand,
RouteSettingsCommand,
],
},
})
if (location.pathname === PATHS.HOME) {
commandBarActor.send({
type: 'Add commands',
data: { commands: [RouteTelemetryCommand, RouteSettingsCommand] },
})
} else if (location.pathname.includes(PATHS.FILE)) {
commandBarActor.send({
type: 'Add commands',
data: {
commands: [
RouteTelemetryCommand,
RouteSettingsCommand,
RouteHomeCommand,
],
},
})
}
}, [location])
// Listen for changes to the system theme and update the app theme accordingly
// This is only done if the theme setting is set to 'system'.
// It can't be done in XState (in an invoked callback, for example)
// because there doesn't seem to be a good way to listen to
// events outside of the machine that also depend on the machine's context
useEffect(() => {
const listener = (e: MediaQueryListEvent) => {
if (settingsState.context.app.theme.current !== 'system') return
setThemeClass(e.matches ? Themes.Dark : Themes.Light)
}
darkModeMatcher?.addEventListener('change', listener)
return () => darkModeMatcher?.removeEventListener('change', listener)
}, [settingsState.context])
/**
* Update the --primary-hue CSS variable
* to match the setting app.themeColor.current
*/
useEffect(() => {
document.documentElement.style.setProperty(
`--primary-hue`,
settingsState.context.app.themeColor.current
)
}, [settingsState.context.app.themeColor.current])
/**
* Update the --cursor-color CSS variable
* based on the setting textEditor.blinkingCursor.current
*/
useEffect(() => {
document.documentElement.style.setProperty(
`--cursor-color`,
settingsState.context.textEditor.blinkingCursor.current
? 'auto'
: 'transparent'
)
}, [settingsState.context.textEditor.blinkingCursor.current])
return (
<SettingsAuthContext.Provider
value={{
settings: {
state: settingsState,
context: settingsState.context,
send: settingsSend,
},
}}
>
{children}
</SettingsAuthContext.Provider>
)
}
export default SettingsAuthProvider

View File

@ -1,6 +1,5 @@
import { MouseEventHandler, useEffect, useRef, useState } from 'react' import { MouseEventHandler, useEffect, useRef, useState } from 'react'
import Loading from './Loading' import Loading from './Loading'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { useModelingContext } from 'hooks/useModelingContext' import { useModelingContext } from 'hooks/useModelingContext'
import { useNetworkContext } from 'hooks/useNetworkContext' import { useNetworkContext } from 'hooks/useNetworkContext'
import { NetworkHealthState } from 'hooks/useNetworkStatus' import { NetworkHealthState } from 'hooks/useNetworkStatus'
@ -20,8 +19,8 @@ import { IndexLoaderData } from 'lib/types'
import { err, reportRejection } from 'lib/trap' import { err, reportRejection } from 'lib/trap'
import { getArtifactOfTypes } from 'lang/std/artifactGraph' import { getArtifactOfTypes } from 'lang/std/artifactGraph'
import { ViewControlContextMenu } from './ViewControlMenu' import { ViewControlContextMenu } from './ViewControlMenu'
import { commandBarActor, useCommandBarState } from 'machines/commandBarMachine' import { useCommandBarState } from 'machines/commandBarMachine'
import { useSelector } from '@xstate/react' import { useSettings } from 'machines/appMachine'
enum StreamState { enum StreamState {
Playing = 'playing', Playing = 'playing',
@ -34,7 +33,7 @@ export const Stream = () => {
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const videoWrapperRef = useRef<HTMLDivElement>(null) const videoWrapperRef = useRef<HTMLDivElement>(null)
const videoRef = useRef<HTMLVideoElement>(null) const videoRef = useRef<HTMLVideoElement>(null)
const { settings } = useSettingsAuthContext() const settings = useSettings()
const { state, send } = useModelingContext() const { state, send } = useModelingContext()
const commandBarState = useCommandBarState() const commandBarState = useCommandBarState()
const { mediaStream } = useAppStream() const { mediaStream } = useAppStream()
@ -42,7 +41,7 @@ export const Stream = () => {
const [streamState, setStreamState] = useState(StreamState.Unset) const [streamState, setStreamState] = useState(StreamState.Unset)
const { file } = useRouteLoaderData(PATHS.FILE) as IndexLoaderData const { file } = useRouteLoaderData(PATHS.FILE) as IndexLoaderData
const IDLE = settings.context.app.streamIdleMode.current const IDLE = settings.app.streamIdleMode.current
const isNetworkOkay = const isNetworkOkay =
overallState === NetworkHealthState.Ok || overallState === NetworkHealthState.Ok ||
@ -336,7 +335,7 @@ export const Stream = () => {
id="video-stream" id="video-stream"
/> />
<ClientSideScene <ClientSideScene
cameraControls={settings.context.modeling.mouseControls.current} cameraControls={settings.modeling.mouseControls.current}
/> />
{(streamState === StreamState.Paused || {(streamState === StreamState.Paused ||
streamState === StreamState.Resuming) && ( streamState === StreamState.Resuming) && (

View File

@ -1,5 +1,5 @@
import { Popover } from '@headlessui/react' import { Popover } from '@headlessui/react'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { settingsActor, useSettings } from 'machines/appMachine'
import { changeKclSettings, unitLengthToUnitLen } from 'lang/wasm' import { changeKclSettings, unitLengthToUnitLen } from 'lang/wasm'
import { baseUnitLabels, baseUnitsUnion } from 'lib/settings/settingsTypes' import { baseUnitLabels, baseUnitsUnion } from 'lib/settings/settingsTypes'
import { codeManager, kclManager } from 'lib/singletons' import { codeManager, kclManager } from 'lib/singletons'
@ -8,24 +8,25 @@ import { useEffect, useState } from 'react'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
export function UnitsMenu() { export function UnitsMenu() {
const { settings } = useSettingsAuthContext() const settings = useSettings()
const [hasPerFileLengthUnit, setHasPerFileLengthUnit] = useState( const [hasPerFileLengthUnit, setHasPerFileLengthUnit] = useState(
Boolean(kclManager.fileSettings.defaultLengthUnit) Boolean(kclManager.fileSettings.defaultLengthUnit)
) )
const [lengthSetting, setLengthSetting] = useState( const [lengthSetting, setLengthSetting] = useState(
kclManager.fileSettings.defaultLengthUnit || kclManager.fileSettings.defaultLengthUnit ||
settings.context.modeling.defaultUnit.current settings.modeling.defaultUnit.current
) )
useEffect(() => { useEffect(() => {
setHasPerFileLengthUnit(Boolean(kclManager.fileSettings.defaultLengthUnit)) setHasPerFileLengthUnit(Boolean(kclManager.fileSettings.defaultLengthUnit))
setLengthSetting( setLengthSetting(
kclManager.fileSettings.defaultLengthUnit || kclManager.fileSettings.defaultLengthUnit ||
settings.context.modeling.defaultUnit.current settings.modeling.defaultUnit.current
) )
}, [ }, [
kclManager.fileSettings.defaultLengthUnit, kclManager.fileSettings.defaultLengthUnit,
settings.context.modeling.defaultUnit.current, settings.modeling.defaultUnit.current,
]) ])
return ( return (
<Popover className="relative pointer-events-auto"> <Popover className="relative pointer-events-auto">
{({ close }) => ( {({ close }) => (
@ -75,7 +76,7 @@ export function UnitsMenu() {
.catch(reportRejection) .catch(reportRejection)
} }
} else { } else {
settings.send({ settingsActor.send({
type: 'set.modeling.defaultUnit', type: 'set.modeling.defaultUnit',
data: { data: {
level: 'project', level: 'project',

View File

@ -7,7 +7,6 @@ import {
createRoutesFromElements, createRoutesFromElements,
} from 'react-router-dom' } from 'react-router-dom'
import { Models } from '@kittycad/lib' import { Models } from '@kittycad/lib'
import { SettingsAuthProviderJest } from './SettingsAuthProvider'
type User = Models['User_type'] type User = Models['User_type']
@ -120,12 +119,7 @@ function TestWrap({ children }: { children: React.ReactNode }) {
// https://reactrouter.com/en/6.16.0/routers/picking-a-router#using-v64-data-apis // https://reactrouter.com/en/6.16.0/routers/picking-a-router#using-v64-data-apis
const router = createMemoryRouter( const router = createMemoryRouter(
createRoutesFromElements( createRoutesFromElements(
<Route <Route path="/file/:id" element={<>{children}</>} />
path="/file/:id"
element={
<SettingsAuthProviderJest>{children}</SettingsAuthProviderJest>
}
/>
), ),
{ {
initialEntries: ['/file/new'], initialEntries: ['/file/new'],

View File

@ -2,10 +2,10 @@ import { base64ToString } from 'lib/base64'
import { CREATE_FILE_URL_PARAM, DEFAULT_FILE_NAME } from 'lib/constants' import { CREATE_FILE_URL_PARAM, DEFAULT_FILE_NAME } from 'lib/constants'
import { useEffect } from 'react' import { useEffect } from 'react'
import { useSearchParams } from 'react-router-dom' import { useSearchParams } from 'react-router-dom'
import { useSettingsAuthContext } from './useSettingsAuthContext'
import { isDesktop } from 'lib/isDesktop' import { isDesktop } from 'lib/isDesktop'
import { FileLinkParams } from 'lib/links' import { FileLinkParams } from 'lib/links'
import { ProjectsCommandSchema } from 'lib/commandBarConfigs/projectsCommandConfig' import { ProjectsCommandSchema } from 'lib/commandBarConfigs/projectsCommandConfig'
import { useSettings } from 'machines/appMachine'
// For initializing the command arguments, we actually want `method` to be undefined // For initializing the command arguments, we actually want `method` to be undefined
// so that we don't skip it in the command palette. // so that we don't skip it in the command palette.
@ -26,7 +26,7 @@ export function useCreateFileLinkQuery(
callback: (args: CreateFileSchemaMethodOptional) => void callback: (args: CreateFileSchemaMethodOptional) => void
) { ) {
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
const { settings } = useSettingsAuthContext() const settings = useSettings()
useEffect(() => { useEffect(() => {
const createFileParam = searchParams.has(CREATE_FILE_URL_PARAM) const createFileParam = searchParams.has(CREATE_FILE_URL_PARAM)
@ -45,7 +45,7 @@ export function useCreateFileLinkQuery(
? params.name.replace('.kcl', '') ? params.name.replace('.kcl', '')
: params.name : params.name
: isDesktop() : isDesktop()
? settings.context.projects.defaultProjectName.current ? settings.projects.defaultProjectName.current
: DEFAULT_FILE_NAME, : DEFAULT_FILE_NAME,
code: params.code || '', code: params.code || '',
method: isDesktop() ? undefined : 'existingProject', method: isDesktop() ? undefined : 'existingProject',

View File

@ -1,32 +0,0 @@
import { useRouteLoaderData } from 'react-router-dom'
import { useSettingsAuthContext } from './useSettingsAuthContext'
import { PATHS } from 'lib/paths'
import { settings } from 'lib/settings/initialSettings'
import { useEffect } from 'react'
/**
* I was dismayed to learn that index route in Router.tsx where we initially load up the settings
* doesn't re-run on subsequent navigations. This hook is a workaround,
* in conjunction with additional uses of settingsLoader further down the router tree.
* @param routeId - The id defined in Router.tsx to load the settings from.
*/
export function useRefreshSettings(routeId: string = PATHS.INDEX) {
const ctx = useSettingsAuthContext()
const routeData = useRouteLoaderData(routeId) as typeof settings
if (!ctx) {
// Intended to stop the world
// eslint-disable-next-line
throw new Error(
'useRefreshSettings must be used within a SettingsAuthProvider'
)
}
useEffect(() => {
ctx.settings.send({
type: 'Set all settings',
settings: routeData,
doNotPersist: true,
})
}, [])
}

View File

@ -1,5 +1,5 @@
import { Themes, getSystemTheme } from 'lib/theme' import { Themes, getSystemTheme } from 'lib/theme'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { useSettings } from 'machines/appMachine'
/** /**
* Resolves the current theme based on the theme setting * Resolves the current theme based on the theme setting
@ -7,10 +7,8 @@ import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
* @returns {Themes.Light | Themes.Dark} * @returns {Themes.Light | Themes.Dark}
*/ */
export function useResolvedTheme() { export function useResolvedTheme() {
const { const settings = useSettings()
settings: { context }, return settings.app.theme.current === Themes.System
} = useSettingsAuthContext()
return context.app.theme.current === Themes.System
? getSystemTheme() ? getSystemTheme()
: context.app.theme.current : settings.app.theme.current
} }

View File

@ -1,6 +0,0 @@
import { SettingsAuthContext } from 'components/SettingsAuthProvider'
import { useContext } from 'react'
export const useSettingsAuthContext = () => {
return useContext(SettingsAuthContext)
}

View File

@ -11,6 +11,9 @@ import { ToastUpdate } from 'components/ToastUpdate'
import { markOnce } from 'lib/performance' import { markOnce } from 'lib/performance'
import { AUTO_UPDATER_TOAST_ID } from 'lib/constants' import { AUTO_UPDATER_TOAST_ID } from 'lib/constants'
import { initializeWindowExceptionHandler } from 'lib/exceptions' import { initializeWindowExceptionHandler } from 'lib/exceptions'
import { initPromise } from 'lang/wasm'
import { appActor } from 'machines/appMachine'
import { reportRejection } from 'lib/trap'
markOnce('code/willAuth') markOnce('code/willAuth')
initializeWindowExceptionHandler() initializeWindowExceptionHandler()
@ -23,6 +26,14 @@ initializeWindowExceptionHandler()
// iframe: false, // iframe: false,
// }) // })
// Don't start the app machine until all these singletons
// are initialized, and the wasm module is loaded.
initPromise
.then(() => {
appActor.start()
})
.catch(reportRejection)
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement) const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement)
root.render( root.render(

View File

@ -1,9 +1,10 @@
import { createContext, useContext, useEffect, useState } from 'react' import { createContext, useContext, useEffect, useState } from 'react'
import { type IndexLoaderData } from 'lib/types' import { type IndexLoaderData } from 'lib/types'
import { useLoaderData } from 'react-router-dom' import { useRouteLoaderData } from 'react-router-dom'
import { codeManager, kclManager } from 'lib/singletons' import { codeManager, kclManager } from 'lib/singletons'
import { Diagnostic } from '@codemirror/lint' import { Diagnostic } from '@codemirror/lint'
import { KCLError } from './errors' import { KCLError } from './errors'
import { PATHS } from 'lib/paths'
const KclContext = createContext({ const KclContext = createContext({
code: codeManager?.code || '', code: codeManager?.code || '',
@ -27,7 +28,9 @@ export function KclContextProvider({
}) { }) {
// If we try to use this component anywhere but under the paths.FILE route it will fail // If we try to use this component anywhere but under the paths.FILE route it will fail
// Because useLoaderData assumes we are on within it's context. // Because useLoaderData assumes we are on within it's context.
const { code: loadedCode } = useLoaderData() as IndexLoaderData const data = useRouteLoaderData(PATHS.FILE) as IndexLoaderData | undefined
const loadedCode = data?.code
// Both the code state and the editor state start off with the same code. // Both the code state and the editor state start off with the same code.
const [code, setCode] = useState(loadedCode || codeManager.code) const [code, setCode] = useState(loadedCode || codeManager.code)

View File

@ -463,11 +463,11 @@ export const executeWithEngine = async (
const jsAppSettings = async () => { const jsAppSettings = async () => {
let jsAppSettings = default_app_settings() let jsAppSettings = default_app_settings()
if (!TEST) { if (!TEST) {
const lastSettingsSnapshot = await import( const settings = await import('machines/appMachine').then((module) =>
'components/SettingsAuthProvider' module.getSettings()
).then((module) => module.lastSettingsContextSnapshot) )
if (lastSettingsSnapshot) { if (settings) {
jsAppSettings = getAllCurrentSettings(lastSettingsSnapshot) jsAppSettings = getAllCurrentSettings(settings)
} }
} }
return jsAppSettings return jsAppSettings

View File

@ -7,20 +7,23 @@ import {
SettingsPaths, SettingsPaths,
SettingsLevel, SettingsLevel,
SettingProps, SettingProps,
SetEventTypes,
} from 'lib/settings/settingsTypes' } from 'lib/settings/settingsTypes'
import { settingsMachine } from 'machines/settingsMachine' import { settingsMachine } from 'machines/settingsMachine'
import { PathValue } from 'lib/types' import { PathValue } from 'lib/types'
import { Actor, AnyStateMachine, ContextFrom } from 'xstate' import { ActorRefFrom, AnyStateMachine } from 'xstate'
import { getPropertyByPath } from 'lib/objectPropertyByPath' import { getPropertyByPath } from 'lib/objectPropertyByPath'
import { buildCommandArgument } from 'lib/createMachineCommand' import { buildCommandArgument } from 'lib/createMachineCommand'
import decamelize from 'decamelize' import decamelize from 'decamelize'
import { isDesktop } from 'lib/isDesktop' import { isDesktop } from 'lib/isDesktop'
import { Setting } from 'lib/settings/initialSettings' import {
createSettings,
Setting,
SettingsType,
} from 'lib/settings/initialSettings'
// An array of the paths to all of the settings that have commandConfigs // An array of the paths to all of the settings that have commandConfigs
export const settingsWithCommandConfigs = ( export const settingsWithCommandConfigs = (s: SettingsType) =>
s: ContextFrom<typeof settingsMachine>
) =>
Object.entries(s).flatMap(([categoryName, categorySettings]) => Object.entries(s).flatMap(([categoryName, categorySettings]) =>
Object.entries(categorySettings) Object.entries(categorySettings)
.filter(([_, setting]) => setting.commandConfig !== undefined) .filter(([_, setting]) => setting.commandConfig !== undefined)
@ -28,7 +31,7 @@ export const settingsWithCommandConfigs = (
) as SettingsPaths[] ) as SettingsPaths[]
const levelArgConfig = <T extends AnyStateMachine = AnyStateMachine>( const levelArgConfig = <T extends AnyStateMachine = AnyStateMachine>(
actor: Actor<T>, actor: ActorRefFrom<T>,
isProjectAvailable: boolean, isProjectAvailable: boolean,
hideOnLevel?: SettingsLevel hideOnLevel?: SettingsLevel
): CommandArgument<SettingsLevel, T> => ({ ): CommandArgument<SettingsLevel, T> => ({
@ -53,23 +56,16 @@ const levelArgConfig = <T extends AnyStateMachine = AnyStateMachine>(
interface CreateSettingsArgs { interface CreateSettingsArgs {
type: SettingsPaths type: SettingsPaths
send: Function actor: ActorRefFrom<typeof settingsMachine>
context: ContextFrom<typeof settingsMachine>
actor: Actor<typeof settingsMachine>
isProjectAvailable: boolean
} }
// Takes a Setting with a commandConfig and creates a Command // Takes a Setting with a commandConfig and creates a Command
// that can be used in the CommandBar component. // that can be used in the CommandBar component.
export function createSettingsCommand({ export function createSettingsCommand({ type, actor }: CreateSettingsArgs) {
type, type S = PathValue<ReturnType<typeof createSettings>, typeof type>
send,
context,
actor,
isProjectAvailable,
}: CreateSettingsArgs) {
type S = PathValue<typeof context, typeof type>
const context = actor.getSnapshot().context
const isProjectAvailable = context.currentProject !== undefined
const settingConfig = getPropertyByPath(context, type) as SettingProps< const settingConfig = getPropertyByPath(context, type) as SettingProps<
S['default'] S['default']
> >
@ -129,10 +125,18 @@ export function createSettingsCommand({
icon: 'settings', icon: 'settings',
needsReview: false, needsReview: false,
onSubmit: (data) => { onSubmit: (data) => {
if (data !== undefined && data !== null) { if (
send({ type: `set.${type}`, data }) data !== undefined &&
data !== null &&
'value' in data &&
'level' in data
) {
// TS would not let me get this to type properly
const coercedData = data as unknown as SetEventTypes['data']
actor.send({ type: `set.${type}`, data: coercedData })
} else { } else {
send({ type }) console.error('Invalid data submitted to settings command', data)
return new Error('Invalid data submitted to settings command', data)
} }
}, },
args: { args: {

View File

@ -4,6 +4,7 @@ import { Project, FileEntry } from 'lib/project'
import { import {
defaultAppSettings, defaultAppSettings,
initPromise,
parseAppSettings, parseAppSettings,
parseProjectSettings, parseProjectSettings,
} from 'lang/wasm' } from 'lang/wasm'
@ -131,11 +132,20 @@ export async function createNewProjectDirectory(
export async function listProjects( export async function listProjects(
configuration?: DeepPartial<Configuration> | Error configuration?: DeepPartial<Configuration> | Error
): Promise<Project[]> { ): Promise<Project[]> {
if (configuration === undefined) { // Make sure we have wasm initialized.
configuration = await readAppSettingsFile() const initializedResult = await initPromise
if (err(initializedResult)) {
return Promise.reject(initializedResult)
} }
if (err(configuration)) return Promise.reject(configuration) if (configuration === undefined) {
configuration = await readAppSettingsFile().catch((e) => {
console.error(e)
return e
})
}
if (err(configuration) || !configuration) return Promise.reject(configuration)
const projectDir = await ensureProjectDirectoryExists(configuration) const projectDir = await ensureProjectDirectoryExists(configuration)
const projects = [] const projects = []
if (!projectDir) return Promise.reject(new Error('projectDir was falsey')) if (!projectDir) return Promise.reject(new Error('projectDir was falsey'))

View File

@ -75,11 +75,11 @@ export async function getProjectMetaByRouteId(
return route return route
} }
export async function parseProjectRoute( export function parseProjectRoute(
configuration: DeepPartial<Configuration>, configuration: DeepPartial<Configuration>,
id: string, id: string,
pathlib: PlatformPath | undefined pathlib: PlatformPath | undefined
): Promise<ProjectRoute> { ): ProjectRoute {
let projectName = null let projectName = null
let projectPath = '' let projectPath = ''
let currentFileName = null let currentFileName = null

View File

@ -13,37 +13,9 @@ import makeUrlPathRelative from './makeUrlPathRelative'
import { codeManager } from 'lib/singletons' import { codeManager } from 'lib/singletons'
import { fileSystemManager } from 'lang/std/fileSystemManager' import { fileSystemManager } from 'lang/std/fileSystemManager'
import { getProjectInfo } from './desktop' import { getProjectInfo } from './desktop'
import { createSettings } from './settings/initialSettings'
import { normalizeLineEndings } from 'lib/codeEditor' import { normalizeLineEndings } from 'lib/codeEditor'
import { OnboardingStatus } from 'wasm-lib/kcl/bindings/OnboardingStatus' import { OnboardingStatus } from 'wasm-lib/kcl/bindings/OnboardingStatus'
import { getSettings, settingsActor } from 'machines/appMachine'
// The root loader simply resolves the settings and any errors that
// occurred during the settings load
export const settingsLoader: LoaderFunction = async ({
params,
}): Promise<
ReturnType<typeof createSettings> | ReturnType<typeof redirect>
> => {
let { settings, configuration } = await loadAndValidateSettings()
// I don't love that we have to read the settings again here,
// but we need to get the project path to load the project settings
if (params.id) {
const projectPathData = await getProjectMetaByRouteId(
params.id,
configuration
)
if (projectPathData) {
const { projectPath } = projectPathData
const { settings: s } = await loadAndValidateSettings(
projectPath || undefined
)
return s
}
}
return settings
}
export const telemetryLoader: LoaderFunction = async ({ export const telemetryLoader: LoaderFunction = async ({
params, params,
@ -53,7 +25,7 @@ export const telemetryLoader: LoaderFunction = async ({
// Redirect users to the appropriate onboarding page if they haven't completed it // Redirect users to the appropriate onboarding page if they haven't completed it
export const onboardingRedirectLoader: ActionFunction = async (args) => { export const onboardingRedirectLoader: ActionFunction = async (args) => {
const { settings } = await loadAndValidateSettings() const settings = getSettings()
const onboardingStatus: OnboardingStatus = const onboardingStatus: OnboardingStatus =
settings.app.onboardingStatus.current || '' settings.app.onboardingStatus.current || ''
const notEnRouteToOnboarding = !args.request.url.includes( const notEnRouteToOnboarding = !args.request.url.includes(
@ -72,7 +44,7 @@ export const onboardingRedirectLoader: ActionFunction = async (args) => {
) )
} }
return settingsLoader(args) return null
} }
export const fileLoader: LoaderFunction = async ( export const fileLoader: LoaderFunction = async (
@ -156,9 +128,17 @@ export const fileLoader: LoaderFunction = async (
? await getProjectInfo(projectPath) ? await getProjectInfo(projectPath)
: null : null
const project = maybeProjectInfo ?? defaultProjectData
// Fire off the event to load the project settings
settingsActor.send({
type: 'load.project',
project,
})
const projectData: IndexLoaderData = { const projectData: IndexLoaderData = {
code, code,
project: maybeProjectInfo ?? defaultProjectData, project,
file: { file: {
name: currentFileName || '', name: currentFileName || '',
path: currentFilePath || '', path: currentFilePath || '',
@ -197,5 +177,8 @@ export const homeLoader: LoaderFunction = async ({
PATHS.FILE + '/%2F' + BROWSER_PROJECT_NAME + (url.search || '') PATHS.FILE + '/%2F' + BROWSER_PROJECT_NAME + (url.search || '')
) )
} }
settingsActor.send({
type: 'clear.project',
})
return {} return {}
} }

View File

@ -554,3 +554,4 @@ export function createSettings() {
} }
export const settings = createSettings() export const settings = createSettings()
export type SettingsType = ReturnType<typeof createSettings>

View File

@ -1,7 +1,3 @@
import { Setting, createSettings, settings } from 'lib/settings/initialSettings'
import { SaveSettingsPayload, SettingsLevel } from './settingsTypes'
import { isDesktop } from 'lib/isDesktop'
import { err } from 'lib/trap'
import { import {
defaultAppSettings, defaultAppSettings,
defaultProjectSettings, defaultProjectSettings,
@ -10,9 +6,8 @@ import {
parseProjectSettings, parseProjectSettings,
tomlStringify, tomlStringify,
} from 'lang/wasm' } from 'lang/wasm'
import { Configuration } from 'wasm-lib/kcl/bindings/Configuration'
import { mouseControlsToCameraSystem } from 'lib/cameraControls' import { mouseControlsToCameraSystem } from 'lib/cameraControls'
import { appThemeToTheme } from 'lib/theme' import { BROWSER_PROJECT_NAME } from 'lib/constants'
import { import {
getInitialDefaultDir, getInitialDefaultDir,
readAppSettingsFile, readAppSettingsFile,
@ -20,9 +15,14 @@ import {
writeAppSettingsFile, writeAppSettingsFile,
writeProjectSettingsFile, writeProjectSettingsFile,
} from 'lib/desktop' } from 'lib/desktop'
import { ProjectConfiguration } from 'wasm-lib/kcl/bindings/ProjectConfiguration' import { isDesktop } from 'lib/isDesktop'
import { BROWSER_PROJECT_NAME } from 'lib/constants' import { Setting, createSettings, settings } from 'lib/settings/initialSettings'
import { appThemeToTheme } from 'lib/theme'
import { err } from 'lib/trap'
import { DeepPartial } from 'lib/types' import { DeepPartial } from 'lib/types'
import { Configuration } from 'wasm-lib/kcl/bindings/Configuration'
import { ProjectConfiguration } from 'wasm-lib/kcl/bindings/ProjectConfiguration'
import { SaveSettingsPayload, SettingsLevel } from './settingsTypes'
/** /**
* Convert from a rust settings struct into the JS settings struct. * Convert from a rust settings struct into the JS settings struct.
@ -312,6 +312,22 @@ export function getAllCurrentSettings(
return currentSettings return currentSettings
} }
export function clearSettingsAtLevel(
allSettings: typeof settings,
level: SettingsLevel
) {
Object.entries(allSettings).forEach(([category, settingsCategory]) => {
const categoryKey = category as keyof typeof settings
Object.entries(settingsCategory).forEach(
([_, settingValue]: [string, Setting]) => {
settingValue[level] = undefined
}
)
})
return allSettings
}
export function setSettingsAtLevel( export function setSettingsAtLevel(
allSettings: typeof settings, allSettings: typeof settings,
level: SettingsLevel, level: SettingsLevel,

View File

@ -1,26 +1,39 @@
import { ActorRefFrom, createActor, setup } from 'xstate' import { ActorRefFrom, assign, createActor, setup, spawnChild } from 'xstate'
import { authMachine } from './authMachine' import { authMachine } from './authMachine'
import { useSelector } from '@xstate/react' import { useSelector } from '@xstate/react'
import { ACTOR_IDS } from './machineConstants' import { ACTOR_IDS } from './machineConstants'
import { settingsMachine } from './settingsMachine'
import { createSettings } from 'lib/settings/initialSettings'
const { AUTH, SETTINGS } = ACTOR_IDS
const appMachineActors = {
[AUTH]: authMachine,
[SETTINGS]: settingsMachine,
} as const
type AppMachineActors = {
[K in keyof typeof appMachineActors]: ActorRefFrom<
(typeof appMachineActors)[K]
>
}
const appMachine = setup({ const appMachine = setup({
actors: { actors: appMachineActors,
[ACTOR_IDS.AUTH]: authMachine,
},
}).createMachine({ }).createMachine({
/** @xstate-layout N4IgpgJg5mDOIC5gF8A0IB2B7CdGgAoBbAQwGMALASwzAEp8QAHLWKgFyqw0YA9EAjACZ0AT0FDkU5EA */ /** @xstate-layout N4IgpgJg5mDOIC5gF8A0IB2B7CdGgAoBbAQwGMALASwzAEp8QAHLWKgFyqw0YA9EAjACZ0AT0FDkU5EA */
id: 'modeling-app', id: 'modeling-app',
invoke: [ entry: [
{ spawnChild(AUTH, { id: AUTH, systemId: AUTH }),
src: ACTOR_IDS.AUTH, spawnChild(SETTINGS, {
systemId: ACTOR_IDS.AUTH, id: SETTINGS,
}, systemId: SETTINGS,
input: createSettings(),
}),
], ],
}) })
export const appActor = createActor(appMachine).start() export const appActor = createActor(appMachine)
export const authActor = appActor.system.get(AUTH) as ActorRefFrom<
export const authActor = appActor.system.get(ACTOR_IDS.AUTH) as ActorRefFrom<
typeof authMachine typeof authMachine
> >
export const useAuthState = () => useSelector(authActor, (state) => state) export const useAuthState = () => useSelector(authActor, (state) => state)
@ -28,3 +41,17 @@ export const useToken = () =>
useSelector(authActor, (state) => state.context.token) useSelector(authActor, (state) => state.context.token)
export const useUser = () => export const useUser = () =>
useSelector(authActor, (state) => state.context.user) useSelector(authActor, (state) => state.context.user)
export const settingsActor = appActor.system.get(SETTINGS) as ActorRefFrom<
typeof settingsMachine
>
export const getSettings = () => {
const { currentProject: _, ...settings } = settingsActor.getSnapshot().context
return settings
}
export const useSettings = () =>
useSelector(settingsActor, (state) => {
// We have to peel everything that isn't settings off
const { currentProject, ...settings } = state.context
return settings
})

View File

@ -80,7 +80,7 @@ export const authMachine = setup({
), ),
}, },
}).createMachine({ }).createMachine({
/** @xstate-layout N4IgpgJg5mDOIC5QAoC2BDAxgCwJYDswBKAOhzEwGsBJAMwBkB7KGCa-AYgkcJIIDdGlMGWwVKAWgA2zVhIIBtAAwBdRKAAOjWLgAuuHupAAPRAGYArAEYSADgu2AnGYBMLpVYBsZz7YA0IACeiG6OJM62tmZKLgDsno5KtvEAvikBaFh4hKTkVHRMLJDsHGAATmWMZSQaUui6tFWoouLSspDy+MpqSCBaOvqGvaYIljb2Tq7uXj7+QYgALFYW4clWy1ZmVgsWsZtpGRg4BMQkMkVsnIUABIwArrrdRv16BvhGI74LJBYW7o5WKJmKILObBUZeEgJP4LTxKMwIhZmBYLA4gTLHHJnWQEKAAeQeXB4IgEQhEGOyp3OUFxBN0CFJmHqb26T16L0G72GiCsSg8PyszkBCViTiUjgC4Jcnhc4SUsQcvgsoL2VjRFJOpGptMJ5Uq1Vq9UaZWaGqx2vw+IeDPwgiZnNZqme2leQ1An1s31+-0BCJBYJCLm+lk8CRl9hRyos6qOlK17QgdI4N0UTvZLs5Hx58NsJARuys0tDSl+AYQthsgNi0TMqt2LjVaPwjAgcCMZuIzoGbyzCAknkliH7Maympa+QYCfYXddXPdixcg4QvKUdk2u2iLkcsXhCRHmKpU7nfQzPe5CAsMpIXi8MvFKM8VliS5c1jzj53W3isNFqPS6NjMcLStXQZ0zc8ohsJI-kcFxXEcR9HAWF9gTzDxbCUXxAQWEsdn3ONsQuOkwLPedl22MIzFg3YP1gl9PG+bYvGsSxlUcRJozSFIgA */ /** @xstate-layout N4IgpgJg5mDOIC5QEECuAXAFgOgMabFwGsBJAMwBkB7KGCEgOwGIIqGxsBLBgNyqI75CRALQAbGnRHcA2gAYAuolAAHKrE7pObZSAAeiAIwAWQ9gBspuQCYAnAGYAHPYCsx+4ccAaEAE9E1q7YcoZyxrYR1m7mcrYAvnE+aFh4BMTk1LSQjExgAE55VHnYKmIAhuhkRQC2qcLikpDSDPJKSCBqGlo67QYI9gDs5tge5o6h5vau7oY+-v3mA9jWco4u5iu21ua2YcYJSRg4Eln0zJkABFQYrbqdmtoMun2GA7YjxuPmLqvGNh5zRCfJaOcyLUzuAYuFyGcwHEDJY6NCAAeQwTEuskUd3UDx6oD6Im2wUcAzkMJ2cjBxlMgIWLmwZLWljecjJTjh8IYVAgcF0iJxXUez0QIgGxhJZIpu2ptL8AWwtje1nCW2iq1shns8MRdXSlGRjEFeKevUQjkcy3sqwGHimbg83nlCF22GMytVUWMMUc8USCKO2BOdCN7Xu3VNBKMKsVFp2hm2vu+1id83slkVrgTxhcW0pNJ1geDkDR6GNEZFCAT1kZZLk9cMLltb0WdPMjewjjC1mzOZCtk5CSAA */
id: ACTOR_IDS.AUTH, id: ACTOR_IDS.AUTH,
initial: 'checkIfLoggedIn', initial: 'checkIfLoggedIn',
context: { context: {

View File

@ -1,3 +1,4 @@
export const ACTOR_IDS = { export const ACTOR_IDS = {
AUTH: 'auth', AUTH: 'auth',
} SETTINGS: 'settings',
} as const

View File

@ -1,6 +1,25 @@
import { assign, setup } from 'xstate' import {
import { Themes, getSystemTheme, setThemeClass } from 'lib/theme' AnyActorRef,
import { createSettings, settings } from 'lib/settings/initialSettings' assign,
enqueueActions,
EventObject,
fromCallback,
fromPromise,
sendTo,
setup,
} from 'xstate'
import {
Themes,
darkModeMatcher,
getOppositeTheme,
getSystemTheme,
setThemeClass,
} from 'lib/theme'
import {
createSettings,
settings,
SettingsType,
} from 'lib/settings/initialSettings'
import { import {
BaseUnit, BaseUnit,
SetEventTypes, SetEventTypes,
@ -9,16 +28,39 @@ import {
WildcardSetEvent, WildcardSetEvent,
} from 'lib/settings/settingsTypes' } from 'lib/settings/settingsTypes'
import { import {
clearSettingsAtLevel,
configurationToSettingsPayload, configurationToSettingsPayload,
loadAndValidateSettings,
projectConfigurationToSettingsPayload, projectConfigurationToSettingsPayload,
saveSettings,
setSettingsAtLevel, setSettingsAtLevel,
} from 'lib/settings/settingsUtils' } from 'lib/settings/settingsUtils'
import { sceneInfra } from 'lib/singletons' import {
codeManager,
engineCommandManager,
kclManager,
sceneEntitiesManager,
sceneInfra,
} from 'lib/singletons'
import toast from 'react-hot-toast'
import decamelize from 'decamelize'
import { reportRejection } from 'lib/trap'
import { Project } from 'lib/project'
import {
createSettingsCommand,
settingsWithCommandConfigs,
} from 'lib/commandBarConfigs/settingsCommandConfig'
import { Command } from 'lib/commandTypes'
import { commandBarActor } from './commandBarMachine'
type SettingsMachineContext = SettingsType & {
currentProject?: Project
}
export const settingsMachine = setup({ export const settingsMachine = setup({
types: { types: {
context: {} as ReturnType<typeof createSettings>, context: {} as SettingsMachineContext,
input: {} as ReturnType<typeof createSettings>, input: {} as SettingsMachineContext,
events: {} as ( events: {} as (
| WildcardSetEvent<SettingsPaths> | WildcardSetEvent<SettingsPaths>
| SetEventTypes | SetEventTypes
@ -35,16 +77,219 @@ export const settingsMachine = setup({
level: SettingsLevel level: SettingsLevel
} }
| { type: 'Set all settings'; settings: typeof settings } | { type: 'Set all settings'; settings: typeof settings }
| { type: 'load.project'; project?: Project }
| { type: 'clear.project' }
) & { doNotPersist?: boolean }, ) & { doNotPersist?: boolean },
}, },
actors: {
persistSettings: fromPromise<
void,
{ doNotPersist: boolean; context: SettingsMachineContext }
>(async ({ input }) => {
// Without this, when a user changes the file, it'd
// create a detection loop with the file-system watcher.
if (input.doNotPersist) return
codeManager.writeCausedByAppCheckedInFileTreeFileSystemWatcher = true
const { currentProject, ...settings } = input.context
return saveSettings(settings, currentProject?.path)
}),
loadUserSettings: fromPromise<SettingsMachineContext, void>(async () => {
const { settings } = await loadAndValidateSettings()
return settings
}),
loadProjectSettings: fromPromise<
SettingsMachineContext,
{ project?: Project }
>(async ({ input }) => {
const { settings } = await loadAndValidateSettings(input.project?.path)
return settings
}),
watchSystemTheme: fromCallback<{
type: 'update.themeWatcher'
theme: Themes
}>(({ receive }) => {
const listener = (e: MediaQueryListEvent) => {
setThemeClass(e.matches ? Themes.Dark : Themes.Light)
}
receive((event) => {
if (event.type !== 'update.themeWatcher') {
return
} else {
if (event.theme === Themes.System) {
darkModeMatcher?.addEventListener('change', listener)
} else {
darkModeMatcher?.removeEventListener('change', listener)
}
}
})
return () => darkModeMatcher?.removeEventListener('change', listener)
}),
registerCommands: fromCallback<
{ type: 'update' },
{ settings: SettingsType; actor: AnyActorRef }
>(({ input, receive }) => {
// If the user wants to hide the settings commands
//from the command bar don't add them.
if (settings.commandBar.includeSettings.current === false) return
let commands: Command[] = []
const updateCommands = () =>
settingsWithCommandConfigs(input.settings)
.map((type) =>
createSettingsCommand({
type,
actor: input.actor,
})
)
.filter((c) => c !== null) as Command[]
const addCommands = () =>
commandBarActor.send({
type: 'Add commands',
data: { commands: commands },
})
const removeCommands = () =>
commandBarActor.send({
type: 'Remove commands',
data: { commands: commands },
})
receive((event) => {
if (event.type !== 'update') return
removeCommands()
commands = updateCommands()
addCommands()
})
commands = updateCommands()
addCommands()
return () => {
removeCommands()
}
}),
},
actions: { actions: {
setEngineTheme: () => {}, setClientSideSceneUnits: ({ context, event }) => {
setClientTheme: () => {}, const newBaseUnit =
'Execute AST': () => {}, event.type === 'set.modeling.defaultUnit'
toastSuccess: () => {}, ? (event.data.value as BaseUnit)
setClientSideSceneUnits: () => {}, : context.modeling.defaultUnit.current
setAllowOrbitInSketchMode: () => {}, if (!sceneInfra) return
persistSettings: () => {}, sceneInfra.baseUnit = newBaseUnit
},
setEngineTheme: ({ context }) => {
if (engineCommandManager && context.app.theme.current) {
engineCommandManager
.setTheme(context.app.theme.current)
.catch(reportRejection)
}
},
setClientTheme: ({ context }) => {
if (!sceneInfra || !sceneEntitiesManager) return
const opposingTheme = getOppositeTheme(context.app.theme.current)
sceneInfra.theme = opposingTheme
sceneEntitiesManager.updateSegmentBaseColor(opposingTheme)
},
setAllowOrbitInSketchMode: ({ context }) => {
if (!sceneInfra.camControls) return
sceneInfra.camControls._setting_allowOrbitInSketchMode =
context.app.allowOrbitInSketchMode.current
// ModelingMachineProvider will do a use effect to trigger the camera engine sync
},
toastSuccess: ({ event }) => {
if (!('data' in event)) return
const eventParts = event.type.replace(/^set./, '').split('.') as [
keyof typeof settings,
string
]
const truncatedNewValue = event.data.value?.toString().slice(0, 28)
const message =
`Set ${decamelize(eventParts[1], { separator: ' ' })}` +
(truncatedNewValue
? ` to "${truncatedNewValue}${
truncatedNewValue.length === 28 ? '...' : ''
}"${
event.data.level === 'project'
? ' for this project'
: ' as a user default'
}`
: '')
toast.success(message, {
duration: message.split(' ').length * 100 + 1500,
id: `${event.type}.success`,
})
},
'Execute AST': ({ context, event }) => {
try {
const relevantSetting = (s: typeof settings) => {
return (
s.modeling?.defaultUnit?.current !==
context.modeling.defaultUnit.current ||
s.modeling.showScaleGrid.current !==
context.modeling.showScaleGrid.current ||
s.modeling?.highlightEdges.current !==
context.modeling.highlightEdges.current
)
}
const allSettingsIncludesUnitChange =
event.type === 'Set all settings' &&
relevantSetting(event.settings || context)
const resetSettingsIncludesUnitChange =
event.type === 'Reset settings' && relevantSetting(settings)
const shouldExecute =
kclManager !== undefined &&
(event.type === 'set.modeling.defaultUnit' ||
event.type === 'set.modeling.showScaleGrid' ||
event.type === 'set.modeling.highlightEdges' ||
allSettingsIncludesUnitChange ||
resetSettingsIncludesUnitChange)
if (shouldExecute) {
// Unit changes requires a re-exec of code
kclManager.executeCode(true).catch(reportRejection)
} else {
// For any future logging we'd like to do
// console.log(
// 'Not re-executing AST because the settings change did not affect the code interpretation'
// )
}
} catch (e) {
console.error('Error executing AST after settings change', e)
}
},
setThemeColor: ({ context }) => {
document.documentElement.style.setProperty(
`--primary-hue`,
context.app.themeColor.current
)
},
/**
* Update the --cursor-color CSS variable
* based on the setting textEditor.blinkingCursor.current
*/
setCursorColor: ({ context }) => {
document.documentElement.style.setProperty(
`--cursor-color`,
context.textEditor.blinkingCursor.current ? 'auto' : 'transparent'
)
},
/** Unload the project-level setting values from memory */
clearProjectSettings: assign(({ context }) => {
// Peel off all non-settings context
const { currentProject: _, ...settings } = context
const newSettings = clearSettingsAtLevel(settings, 'project')
return newSettings
}),
/** Unload the current project's info from memory */
clearCurrentProject: assign(({ context }) => {
return { ...context, currentProject: undefined }
}),
resetSettings: assign(({ context, event }) => { resetSettings: assign(({ context, event }) => {
if (!('level' in event)) return {} if (!('level' in event)) return {}
@ -59,9 +304,10 @@ export const settingsMachine = setup({
return newSettings return newSettings
}), }),
setAllSettings: assign(({ event }) => { setAllSettings: assign(({ event, context }) => {
if (!('settings' in event)) return {} if ('settings' in event) return event.settings
return event.settings else if ('output' in event) return event.output || context
else return context
}), }),
setSettingAtLevel: assign(({ context, event }) => { setSettingAtLevel: assign(({ context, event }) => {
if (!('data' in event)) return {} if (!('data' in event)) return {}
@ -94,25 +340,55 @@ export const settingsMachine = setup({
const newCurrentProjection = context.modeling.cameraProjection.current const newCurrentProjection = context.modeling.cameraProjection.current
sceneInfra.camControls.setEngineCameraProjection(newCurrentProjection) sceneInfra.camControls.setEngineCameraProjection(newCurrentProjection)
}, },
sendThemeToWatcher: sendTo('watchSystemTheme', ({ context }) => ({
type: 'update.themeWatcher',
theme: context.app.theme.current,
})),
}, },
}).createMachine({ }).createMachine({
/** @xstate-layout N4IgpgJg5mDOIC5QGUwBc0EsB2VYDpMIAbMAYlnXwEMAHW-Ae2wCNHqAnCHKZNatAFdYAbQAMAXUShajWJizNpIAB6IALAFYAnPgBMARgDsBsQDY969QGYjmzQBoQAT0SnrADnwePY61r0PAwNtMyMAX3CnVAweAiJSCio6BjQACzAAWzAAYUZiRg5xKSQQWXlFbGU1BD1PfFtfE3UzTUNNaydXBCD1b209PTEPTTMtdQNNSOj0LFx4knJKNHxMxggwYh58DYAzakFiNABVbAVi5XKFTCVSmusxPXx7bRt1DzMxI3UjD3UutwhAz4MyeHxiV5+AYRKIgGJzPCERZJFYpfDpLJgC6lK6VaqIExPMwWGwdGxBPRmAE9PSafCPMQ-EzWbQ6ELTOGzOJIxLLVbrTbbNKYKBpLaitAAUWgcGxMjk11uoBqVmBH0ZLKCrVs-xciCCwLCvhCjyMFhGHPh3IS5AASnB0AACZYI0SSS4KvF3AlafADRl1YZ2IxiRx6hBtIzPb7abQ+DxGaxmYKWrnzHnkGKO6jEYjOtN4OVlT03KrehAtOnm7Qaup6Ixm6mR6OaR4dAwjM1mVOxdM2lH8jZbXD4WBpRgAd2QAGMc2AAOIcIhF3Gl-EIRPA6yGcyh4whSnU0xGJ5GAat0OfFowma9xH9gBUK5LStUiECdMmfx+mg8hmNTY-PgMYQpoZoxh41g9q6+C0GAHDyLACL5nesBkBAzBgIQ2AAG6MAA1lhcEIZgSFWvMz4VGu5YALTbtYwEnj8HhxnooT1mG3QhmY-TmJ82gGCyjzaJEsLYAK8ClOReAelRr41HRJiMZYvysexdjUuohh+poBiGDuXzGKy0HWossmKmWyqIDR3zAZWLSahM2jWJ04YjDxHbDMmmhaYE3wmemxGIchLpxOZXpWQgNEjMB1h6WEYHqK8ZgJk2EL6N8wR1Cy-gJqJ4RAA */ /** @xstate-layout N4IgpgJg5mDOIC5QGUwBc0EsB2VYDpMIAbMAYlnXwEMAHW-Ae2wCNHqAnCHKZNatAFdYAbQAMAXUShajWJizNpIAB6IAzAA4x+AIyaAbJoCsAFl1njAJmOaANCACeiXQHZ1+a7bdWDATnUxawBfYIdUDB4CIlIKKjoGNAALMABbMABhRmJGDnEpJBBZeUVsZTUELR19IzMLUy97J0RfTXxDBr8DAxtdMSs-UPD0LFxoknJKNHxUxggwYh58eYAzakFiNABVbAV85WKFTCVCiqq9QxNzSxsm50qDU3wrMXV1V2M-bT8xV6GQCKjPCECZxaYJfDJNJgfaFQ6lcoabQXWrXBq3Bz3YzqPz4AyvL7qYw1TS6Az-QFREGxKY0ej4WBoDhgaipACSEwAsnMYZIDnIjidQGdkSS6jdbJjEK5zHi-PouqYiQZjBSRlSYpN4vTqMQcgB3ADyHBYCjZ2GQAGt0ABjJLc+awmQChGnJHVS7i9GS5oIQweVxueVWVz4mW6NWRMbUrXTWbzRa4fA21lgDjUAAKHEYACswDbSk6ii7jmU3ZVRZ60Y0pQg+rorPglQ2rKZ-FY3DLI0DxjSqPGFkskpgoElFqO0ABRaBwIvw0uIise1H1Gu+3Rk3R6Uydaz47qqsIA9XRzVkABKcHQAAIpj25yWhap3SirquMevAriTK4urYBhYViaN2GqghE166sQt4nngD4lAu5Zkni1yaKG2i6OoBgblYtY7sYniGNYmjqKYmjyropggaeoK0gOiZQAySSMPqyApqQADiHBEHBgplsKL5itWH73BYKr4OoLzmBhHahlRwJnjk1AQPgtDZnmBY8a6-EIKYVgePopEfKRmhAQ2xi1m8FyuCGnxmHhJFyb25AAFSaQh2nnIJ74+iJgZPK87ykmR-SmK4wFHpS0a0Gm8iMjw0FRngZAQMwYCENgABujDWvgkXAtFHCxUCCU9ggOBZSmhaSG5T4VKFuIkUEO7uPKfihaYtb6JZQR+J8Sq-iR4XDIlBAFUV8V3lEZBptmHAqcQAgrLkqS5TBo0xZgcW4CVURlZljCVaW+Q1Xxz46ciRhiFhBjvEEPmIKSVk2b1O4NA5EVrfgincLgWyUBwyWpelWU5XlBDfTwf1pntFUCEd1V8nCj6nWczyfPiYgfL4xhhZoqGdR8OhXT0xJiL1HxaI5X3sD9UBQwDM25PNi3LatI3U0pkP-TDB1w8wx2I868G1RomEEURGHWZjrydV0Hjym2bw3bY2LqFTEO4Fmub5mggPYGl5XZWlYMc7TWvqWgPOHfzCMFELvGLn035dGIPgYW1ui9bLv7PK8LxGGRFG6erNM8ObOvTRws3M2gS0cCtJsa1A4cFlbfPYALdvFsLKMaMS+BiKYfg2NigZk+85m+h2pg6L+MpthhKtEqER7YDy8CFGD-I54uAC06ie88ZL4i8FFfmSta92YBe-L8fhhaGLyYVTmrdw75ahbWqEGPgfjyj+PR762EYfezY2bcVk1jGvWlnWRTxB8YWEysY6O6Fve94vUB5fDXxIh5zX6-0b7uTOr3EMW4OzdH6K7JUZMJ7rh3I2X+PRpZvE+K4ABZs1I6xASLBAYhawdj6M8CSG4F64zVi3IAA */
id: 'Settings', initial: 'loadingUser',
initial: 'idle',
context: ({ input }) => { context: ({ input }) => {
return { return {
...createSettings(), ...createSettings(),
...input, ...input,
} }
}, },
invoke: [
{
src: 'watchSystemTheme',
id: 'watchSystemTheme',
},
{
src: 'registerCommands',
id: 'registerCommands',
// Peel off the non-settings context
input: ({ context: { currentProject, ...settings }, self }) => ({
settings,
actor: self,
}),
},
],
states: { states: {
idle: { idle: {
entry: ['setThemeClass', 'setClientSideSceneUnits'], entry: ['setThemeClass', 'setClientSideSceneUnits', 'sendThemeToWatcher'],
on: { on: {
'*': { '*': {
target: 'persisting settings', target: 'persisting settings',
actions: ['setSettingAtLevel', 'toastSuccess'], actions: [
'setSettingAtLevel',
'toastSuccess',
enqueueActions(({ enqueue, check }) => {
if (
check(
({ event }) => event.type === 'set.textEditor.blinkingCursor'
)
) {
enqueue('setCursorColor')
}
}),
],
}, },
'set.app.onboardingStatus': { 'set.app.onboardingStatus': {
@ -126,7 +402,7 @@ export const settingsMachine = setup({
target: 'persisting settings', target: 'persisting settings',
// No toast // No toast
actions: ['setSettingAtLevel'], actions: ['setSettingAtLevel', 'setThemeColor'],
}, },
'set.modeling.defaultUnit': { 'set.modeling.defaultUnit': {
@ -149,6 +425,7 @@ export const settingsMachine = setup({
'setThemeClass', 'setThemeClass',
'setEngineTheme', 'setEngineTheme',
'setClientTheme', 'setClientTheme',
'sendThemeToWatcher',
], ],
}, },
@ -191,9 +468,11 @@ export const settingsMachine = setup({
'setThemeClass', 'setThemeClass',
'setEngineTheme', 'setEngineTheme',
'setClientSideSceneUnits', 'setClientSideSceneUnits',
'setThemeColor',
'Execute AST', 'Execute AST',
'setClientTheme', 'setClientTheme',
'setAllowOrbitInSketchMode', 'setAllowOrbitInSketchMode',
'sendThemeToWatcher',
], ],
}, },
@ -203,9 +482,11 @@ export const settingsMachine = setup({
'setThemeClass', 'setThemeClass',
'setEngineTheme', 'setEngineTheme',
'setClientSideSceneUnits', 'setClientSideSceneUnits',
'setThemeColor',
'Execute AST', 'Execute AST',
'setClientTheme', 'setClientTheme',
'setAllowOrbitInSketchMode', 'setAllowOrbitInSketchMode',
'sendThemeToWatcher',
], ],
}, },
@ -213,12 +494,85 @@ export const settingsMachine = setup({
target: 'persisting settings', target: 'persisting settings',
actions: ['setSettingAtLevel', 'toastSuccess', 'Execute AST'], actions: ['setSettingAtLevel', 'toastSuccess', 'Execute AST'],
}, },
'load.project': {
target: 'loadingProject',
},
'clear.project': {
target: 'idle',
reenter: true,
actions: [
'clearProjectSettings',
'clearCurrentProject',
'setThemeColor',
sendTo('registerCommands', { type: 'update' }),
],
},
}, },
}, },
'persisting settings': { 'persisting settings': {
entry: ['persistSettings'], invoke: {
always: 'idle', src: 'persistSettings',
onDone: {
target: 'idle',
},
onError: {
target: 'idle',
actions: () => {
console.error('Error persisting settings')
},
},
input: ({ context, event }) => {
return {
doNotPersist: event.doNotPersist ?? false,
context,
}
},
},
},
loadingUser: {
invoke: {
src: 'loadUserSettings',
onDone: {
target: 'idle',
actions: 'setAllSettings',
},
onError: {
target: 'idle',
actions: ({ event }) => {
console.error('Error loading user settings', event)
},
},
},
},
loadingProject: {
entry: [
assign({
currentProject: ({ event }) =>
event.type === 'load.project' ? event.project : undefined,
}),
],
invoke: {
src: 'loadProjectSettings',
onDone: {
target: 'idle',
actions: [
'setAllSettings',
'setThemeColor',
'Execute AST',
sendTo('registerCommands', { type: 'update' }),
],
},
onError: 'idle',
input: ({ event }) => {
return {
project: event.type === 'load.project' ? event.project : undefined,
}
},
},
}, },
}, },
}) })

View File

@ -12,11 +12,9 @@ import {
getSortFunction, getSortFunction,
getSortIcon, getSortIcon,
} from '../lib/sorting' } from '../lib/sorting'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { useHotkeys } from 'react-hotkeys-hook' import { useHotkeys } from 'react-hotkeys-hook'
import { isDesktop } from 'lib/isDesktop' import { isDesktop } from 'lib/isDesktop'
import { kclManager } from 'lib/singletons' import { kclManager } from 'lib/singletons'
import { useRefreshSettings } from 'hooks/useRefreshSettings'
import { LowerRightControls } from 'components/LowerRightControls' import { LowerRightControls } from 'components/LowerRightControls'
import { ProjectSearchBar, useProjectSearch } from 'components/ProjectSearchBar' import { ProjectSearchBar, useProjectSearch } from 'components/ProjectSearchBar'
import { Project } from 'lib/project' import { Project } from 'lib/project'
@ -26,6 +24,7 @@ import { useProjectsLoader } from 'hooks/useProjectsLoader'
import { useProjectsContext } from 'hooks/useProjectsContext' import { useProjectsContext } from 'hooks/useProjectsContext'
import { commandBarActor } from 'machines/commandBarMachine' import { commandBarActor } from 'machines/commandBarMachine'
import { useCreateFileLinkQuery } from 'hooks/useCreateFileLinkQueryWatcher' import { useCreateFileLinkQuery } from 'hooks/useCreateFileLinkQueryWatcher'
import { useSettings } from 'machines/appMachine'
// This route only opens in the desktop context for now, // This route only opens in the desktop context for now,
// as defined in Router.tsx, so we can use the desktop APIs and types. // as defined in Router.tsx, so we can use the desktop APIs and types.
@ -46,11 +45,8 @@ const Home = () => {
}) })
}) })
useRefreshSettings(PATHS.HOME + 'SETTINGS')
const navigate = useNavigate() const navigate = useNavigate()
const { const settings = useSettings()
settings: { context: settings },
} = useSettingsAuthContext()
// Cancel all KCL executions while on the home page // Cancel all KCL executions while on the home page
useEffect(() => { useEffect(() => {

View File

@ -1,26 +1,19 @@
import { OnboardingButtons, useDismiss, useNextClick } from '.' import { OnboardingButtons, useDismiss, useNextClick } from '.'
import { onboardingPaths } from 'routes/Onboarding/paths' import { onboardingPaths } from 'routes/Onboarding/paths'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { import {
CameraSystem, CameraSystem,
cameraMouseDragGuards, cameraMouseDragGuards,
cameraSystems, cameraSystems,
} from 'lib/cameraControls' } from 'lib/cameraControls'
import { SettingsSection } from 'components/Settings/SettingsSection' import { SettingsSection } from 'components/Settings/SettingsSection'
import { settingsActor, useSettings } from 'machines/appMachine'
export default function Units() { export default function Units() {
const dismiss = useDismiss() const dismiss = useDismiss()
const next = useNextClick(onboardingPaths.STREAMING) const next = useNextClick(onboardingPaths.STREAMING)
const { const {
settings: { modeling: { mouseControls },
send, } = useSettings()
state: {
context: {
modeling: { mouseControls },
},
},
},
} = useSettingsAuthContext()
return ( return (
<div className="fixed inset-0 z-50 grid items-end justify-start px-4 pointer-events-none"> <div className="fixed inset-0 z-50 grid items-end justify-start px-4 pointer-events-none">
@ -40,7 +33,7 @@ export default function Units() {
className="block w-full px-3 py-1 bg-transparent border border-chalkboard-30" className="block w-full px-3 py-1 bg-transparent border border-chalkboard-30"
value={mouseControls.current} value={mouseControls.current}
onChange={(e) => { onChange={(e) => {
send({ settingsActor.send({
type: 'set.modeling.mouseControls', type: 'set.modeling.mouseControls',
data: { data: {
level: 'user', level: 'user',

View File

@ -1,6 +1,5 @@
import { OnboardingButtons, useDemoCode } from '.' import { OnboardingButtons, useDemoCode } from '.'
import { onboardingPaths } from 'routes/Onboarding/paths' import { onboardingPaths } from 'routes/Onboarding/paths'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { Themes, getSystemTheme } from 'lib/theme' import { Themes, getSystemTheme } from 'lib/theme'
import { bracket } from 'lib/exampleKcl' import { bracket } from 'lib/exampleKcl'
import { createAndOpenNewTutorialProject } from 'lib/desktopFS' import { createAndOpenNewTutorialProject } from 'lib/desktopFS'
@ -14,6 +13,7 @@ import { PATHS } from 'lib/paths'
import { useFileContext } from 'hooks/useFileContext' import { useFileContext } from 'hooks/useFileContext'
import { useLspContext } from 'components/LspProvider' import { useLspContext } from 'components/LspProvider'
import { reportRejection } from 'lib/trap' import { reportRejection } from 'lib/trap'
import { useSettings } from 'machines/appMachine'
/** /**
* Show either a welcome screen or a warning screen * Show either a welcome screen or a warning screen
@ -120,14 +120,8 @@ function OnboardingIntroductionInner() {
useDemoCode() useDemoCode()
const { const {
settings: { app: { theme },
state: { } = useSettings()
context: {
app: { theme },
},
},
},
} = useSettingsAuthContext()
const getLogoTheme = () => const getLogoTheme = () =>
theme.current === Themes.Light || theme.current === Themes.Light ||
(theme.current === Themes.System && getSystemTheme() === Themes.Light) (theme.current === Themes.System && getSystemTheme() === Themes.Light)

View File

@ -1,21 +1,17 @@
import { OnboardingButtons, useDemoCode } from '.' import { OnboardingButtons, useDemoCode } from '.'
import { onboardingPaths } from 'routes/Onboarding/paths' import { onboardingPaths } from 'routes/Onboarding/paths'
import { Themes, getSystemTheme } from 'lib/theme' import { Themes, getSystemTheme } from 'lib/theme'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { bracketThicknessCalculationLine } from 'lib/exampleKcl' import { bracketThicknessCalculationLine } from 'lib/exampleKcl'
import { isDesktop } from 'lib/isDesktop' import { isDesktop } from 'lib/isDesktop'
import { useSettings } from 'machines/appMachine'
export default function OnboardingParametricModeling() { export default function OnboardingParametricModeling() {
useDemoCode() useDemoCode()
const { const {
settings: { app: {
context: { theme: { current: theme },
app: {
theme: { current: theme },
},
},
}, },
} = useSettingsAuthContext() } = useSettings()
const getImageTheme = () => const getImageTheme = () =>
theme === Themes.Light || theme === Themes.Light ||
(theme === Themes.System && getSystemTheme() === Themes.Light) (theme === Themes.System && getSystemTheme() === Themes.Light)

View File

@ -4,19 +4,14 @@ import { ActionButton } from 'components/ActionButton'
import { SettingsSection } from 'components/Settings/SettingsSection' import { SettingsSection } from 'components/Settings/SettingsSection'
import { useDismiss, useNextClick } from '.' import { useDismiss, useNextClick } from '.'
import { onboardingPaths } from 'routes/Onboarding/paths' import { onboardingPaths } from 'routes/Onboarding/paths'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { settingsActor, useSettings } from 'machines/appMachine'
export default function Units() { export default function Units() {
const dismiss = useDismiss() const dismiss = useDismiss()
const next = useNextClick(onboardingPaths.CAMERA) const next = useNextClick(onboardingPaths.CAMERA)
const { const {
settings: { modeling: { defaultUnit },
send, } = useSettings()
context: {
modeling: { defaultUnit },
},
},
} = useSettingsAuthContext()
return ( return (
<div className="fixed grid place-content-center inset-0 bg-chalkboard-110/50 z-50"> <div className="fixed grid place-content-center inset-0 bg-chalkboard-110/50 z-50">
@ -31,7 +26,7 @@ export default function Units() {
className="block w-full px-3 py-1 border border-chalkboard-30 bg-transparent" className="block w-full px-3 py-1 border border-chalkboard-30 bg-transparent"
value={defaultUnit.user} value={defaultUnit.user}
onChange={(e) => { onChange={(e) => {
send({ settingsActor.send({
type: 'set.modeling.defaultUnit', type: 'set.modeling.defaultUnit',
data: { data: {
level: 'user', level: 'user',

View File

@ -5,7 +5,6 @@ import Camera from './Camera'
import Sketching from './Sketching' import Sketching from './Sketching'
import { useCallback, useEffect } from 'react' import { useCallback, useEffect } from 'react'
import makeUrlPathRelative from '../../lib/makeUrlPathRelative' import makeUrlPathRelative from '../../lib/makeUrlPathRelative'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import Streaming from './Streaming' import Streaming from './Streaming'
import CodeEditor from './CodeEditor' import CodeEditor from './CodeEditor'
import ParametricModeling from './ParametricModeling' import ParametricModeling from './ParametricModeling'
@ -26,9 +25,10 @@ import { reportRejection } from 'lib/trap'
import { useNetworkContext } from 'hooks/useNetworkContext' import { useNetworkContext } from 'hooks/useNetworkContext'
import { NetworkHealthState } from 'hooks/useNetworkStatus' import { NetworkHealthState } from 'hooks/useNetworkStatus'
import { EngineConnectionStateType } from 'lang/std/engineConnection' import { EngineConnectionStateType } from 'lang/std/engineConnection'
import { settingsActor, useSettings } from 'machines/appMachine'
import { useSelector } from '@xstate/react'
import { CustomIcon } from 'components/CustomIcon' import { CustomIcon } from 'components/CustomIcon'
import Tooltip from 'components/Tooltip' import Tooltip from 'components/Tooltip'
import { commandBarActor } from 'machines/commandBarMachine'
export const kbdClasses = export const kbdClasses =
'py-0.5 px-1 text-sm rounded bg-chalkboard-10 dark:bg-chalkboard-100 border border-chalkboard-50 border-b-2' 'py-0.5 px-1 text-sm rounded bg-chalkboard-10 dark:bg-chalkboard-100 border border-chalkboard-50 border-b-2'
@ -112,25 +112,24 @@ export function useDemoCode() {
export function useNextClick(newStatus: string) { export function useNextClick(newStatus: string) {
const filePath = useAbsoluteFilePath() const filePath = useAbsoluteFilePath()
const {
settings: { send },
} = useSettingsAuthContext()
const navigate = useNavigate() const navigate = useNavigate()
return useCallback(() => { return useCallback(() => {
send({ settingsActor.send({
type: 'set.app.onboardingStatus', type: 'set.app.onboardingStatus',
data: { level: 'user', value: newStatus }, data: { level: 'user', value: newStatus },
}) })
navigate(filePath + PATHS.ONBOARDING.INDEX.slice(0, -1) + newStatus) navigate(filePath + PATHS.ONBOARDING.INDEX.slice(0, -1) + newStatus)
}, [filePath, newStatus, send, navigate]) }, [filePath, newStatus, settingsActor.send, navigate])
} }
export function useDismiss() { export function useDismiss() {
const filePath = useAbsoluteFilePath() const filePath = useAbsoluteFilePath()
const { const settings = useSettings()
settings: { state, send }, const send = settingsActor.send
} = useSettingsAuthContext() const isSettingsActorIdle = useSelector(settingsActor, (s) =>
s.matches('idle')
)
const navigate = useNavigate() const navigate = useNavigate()
const settingsCallback = useCallback(() => { const settingsCallback = useCallback(() => {
@ -146,12 +145,17 @@ export function useDismiss() {
*/ */
useEffect(() => { useEffect(() => {
if ( if (
state.context.app.onboardingStatus.user === 'dismissed' && settings.app.onboardingStatus.current === 'dismissed' &&
state.matches('idle') isSettingsActorIdle
) { ) {
navigate(filePath) navigate(filePath)
} }
}, [filePath, navigate, state]) }, [
filePath,
navigate,
isSettingsActorIdle,
settings.app.onboardingStatus.current,
])
return settingsCallback return settingsCallback
} }

View File

@ -3,7 +3,6 @@ import { isDesktop } from '../lib/isDesktop'
import { VITE_KC_SITE_BASE_URL, VITE_KC_API_BASE_URL } from '../env' import { VITE_KC_SITE_BASE_URL, VITE_KC_API_BASE_URL } from '../env'
import { Themes, getSystemTheme } from '../lib/theme' import { Themes, getSystemTheme } from '../lib/theme'
import { PATHS } from 'lib/paths' import { PATHS } from 'lib/paths'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { APP_NAME } from 'lib/constants' import { APP_NAME } from 'lib/constants'
import { CSSProperties, useCallback, useState } from 'react' import { CSSProperties, useCallback, useState } from 'react'
import { Logo } from 'components/Logo' import { Logo } from 'components/Logo'
@ -15,6 +14,7 @@ import { toSync } from 'lib/utils'
import { reportRejection } from 'lib/trap' import { reportRejection } from 'lib/trap'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { authActor } from 'machines/appMachine' import { authActor } from 'machines/appMachine'
import { useSettings } from 'machines/appMachine'
const subtleBorder = const subtleBorder =
'border border-solid border-chalkboard-30 dark:border-chalkboard-80' 'border border-solid border-chalkboard-30 dark:border-chalkboard-80'
@ -23,14 +23,8 @@ const cardArea = `${subtleBorder} rounded-lg px-6 py-3 text-chalkboard-70 dark:t
const SignIn = () => { const SignIn = () => {
const [userCode, setUserCode] = useState('') const [userCode, setUserCode] = useState('')
const { const {
settings: { app: { theme },
state: { } = useSettings()
context: {
app: { theme },
},
},
},
} = useSettingsAuthContext()
const signInUrl = `${VITE_KC_SITE_BASE_URL}${ const signInUrl = `${VITE_KC_SITE_BASE_URL}${
PATHS.SIGN_IN PATHS.SIGN_IN
}?callbackUrl=${encodeURIComponent( }?callbackUrl=${encodeURIComponent(