diff --git a/e2e/playwright/onboarding-tests.spec.ts b/e2e/playwright/onboarding-tests.spec.ts index 804b90c44..33bfa9234 100644 --- a/e2e/playwright/onboarding-tests.spec.ts +++ b/e2e/playwright/onboarding-tests.spec.ts @@ -13,8 +13,8 @@ import { import * as TOML from '@iarna/toml' import { expectPixelColor } from './fixtures/sceneFixture' -// Because onboarding relies on an app setting we need to set it as incompletel -// for all these tests. +// Because our default test settings have the onboardingStatus set to 'dismissed', +// we must set it to empty for the tests where we want to see the onboarding immediately. test.describe('Onboarding tests', () => { test( @@ -22,7 +22,7 @@ test.describe('Onboarding tests', () => { { appSettings: { app: { - onboardingStatus: 'incomplete', + onboardingStatus: '', }, }, cleanProjectDir: true, @@ -63,7 +63,7 @@ test.describe('Onboarding tests', () => { tag: '@electron', appSettings: { app: { - onboardingStatus: 'incomplete', + onboardingStatus: '', }, }, cleanProjectDir: true, @@ -106,11 +106,6 @@ test.describe('Onboarding tests', () => { test( 'Code resets after confirmation', { - appSettings: { - app: { - onboardingStatus: 'incomplete', - }, - }, cleanProjectDir: true, }, async ({ context, page, homePage }) => { @@ -158,7 +153,7 @@ test.describe('Onboarding tests', () => { { appSettings: { app: { - onboardingStatus: 'incomplete', + onboardingStatus: '', }, }, }, @@ -319,7 +314,7 @@ test.describe('Onboarding tests', () => { { appSettings: { app: { - onboardingStatus: 'incomplete', + onboardingStatus: '', }, }, cleanProjectDir: true, @@ -392,7 +387,7 @@ test.describe('Onboarding tests', () => { { appSettings: { app: { - onboardingStatus: 'incomplete', + onboardingStatus: '', }, }, cleanProjectDir: true, diff --git a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Grid-visibility-Grid-turned-off-to-on-via-command-bar-1-Google-Chrome-linux.png b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Grid-visibility-Grid-turned-off-to-on-via-command-bar-1-Google-Chrome-linux.png index 1c9a2d5df..2f31ab10b 100644 Binary files a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Grid-visibility-Grid-turned-off-to-on-via-command-bar-1-Google-Chrome-linux.png and b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Grid-visibility-Grid-turned-off-to-on-via-command-bar-1-Google-Chrome-linux.png differ diff --git a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Sketch-on-face-with-none-z-up-1-Google-Chrome-linux.png b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Sketch-on-face-with-none-z-up-1-Google-Chrome-linux.png index d635f9055..e244fafc6 100644 Binary files a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Sketch-on-face-with-none-z-up-1-Google-Chrome-linux.png and b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Sketch-on-face-with-none-z-up-1-Google-Chrome-linux.png differ diff --git a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Zoom-to-fit-on-load---solid-2d-1-Google-Chrome-linux.png b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Zoom-to-fit-on-load---solid-2d-1-Google-Chrome-linux.png index 01b92cd8e..a6cab619a 100644 Binary files a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Zoom-to-fit-on-load---solid-2d-1-Google-Chrome-linux.png and b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Zoom-to-fit-on-load---solid-2d-1-Google-Chrome-linux.png differ diff --git a/e2e/playwright/snapshot-tests.spec.ts-snapshots/code-color-goober-code-color-goober-opening-window-1-Google-Chrome-linux.png b/e2e/playwright/snapshot-tests.spec.ts-snapshots/code-color-goober-code-color-goober-opening-window-1-Google-Chrome-linux.png index bc6c75db2..85f82a3f7 100644 Binary files a/e2e/playwright/snapshot-tests.spec.ts-snapshots/code-color-goober-code-color-goober-opening-window-1-Google-Chrome-linux.png and b/e2e/playwright/snapshot-tests.spec.ts-snapshots/code-color-goober-code-color-goober-opening-window-1-Google-Chrome-linux.png differ diff --git a/e2e/playwright/snapshot-tests.spec.ts-snapshots/extrude-on-default-planes-should-be-stable--XZ-1-Google-Chrome-linux.png b/e2e/playwright/snapshot-tests.spec.ts-snapshots/extrude-on-default-planes-should-be-stable--XZ-1-Google-Chrome-linux.png index b886f4bca..aaf889c10 100644 Binary files a/e2e/playwright/snapshot-tests.spec.ts-snapshots/extrude-on-default-planes-should-be-stable--XZ-1-Google-Chrome-linux.png and b/e2e/playwright/snapshot-tests.spec.ts-snapshots/extrude-on-default-planes-should-be-stable--XZ-1-Google-Chrome-linux.png differ diff --git a/e2e/playwright/snapshot-tests.spec.ts-snapshots/extrude-on-default-planes-should-be-stable--YZ-1-Google-Chrome-linux.png b/e2e/playwright/snapshot-tests.spec.ts-snapshots/extrude-on-default-planes-should-be-stable--YZ-1-Google-Chrome-linux.png index ec3faefb4..57aece587 100644 Binary files a/e2e/playwright/snapshot-tests.spec.ts-snapshots/extrude-on-default-planes-should-be-stable--YZ-1-Google-Chrome-linux.png and b/e2e/playwright/snapshot-tests.spec.ts-snapshots/extrude-on-default-planes-should-be-stable--YZ-1-Google-Chrome-linux.png differ diff --git a/e2e/playwright/testing-camera-movement.spec.ts b/e2e/playwright/testing-camera-movement.spec.ts index bd2ce90b7..ac6f6f490 100644 --- a/e2e/playwright/testing-camera-movement.spec.ts +++ b/e2e/playwright/testing-camera-movement.spec.ts @@ -358,9 +358,7 @@ test.describe('Testing Camera Movement', { tag: ['@skipWin'] }, () => { exact: true, }) const userSettingsTab = page.getByRole('radio', { name: 'User' }) - const mouseControlsSetting = page - .locator('#mouseControls') - .getByRole('combobox') + const mouseControlsSetting = () => page.locator('#camera-controls').first() const mouseControlSuccesToast = page.getByText( 'Set mouse controls to "Solidworks"' ) @@ -390,7 +388,14 @@ test.describe('Testing Camera Movement', { tag: ['@skipWin'] }, () => { await settingsLink.click() await expect(settingsDialogHeading).toBeVisible() 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 settingsCloseButton.click() }) diff --git a/e2e/playwright/testing-settings.spec.ts b/e2e/playwright/testing-settings.spec.ts index 10d8245d7..4fd6a6ea8 100644 --- a/e2e/playwright/testing-settings.spec.ts +++ b/e2e/playwright/testing-settings.spec.ts @@ -633,6 +633,7 @@ test.describe('Testing settings', () => { `Set default unit to "${unitOfMeasure}" as a user default` ) await expect(toastMessage).toBeVisible() + await expect(toastMessage).not.toBeVisible() }) } await changeUnitOfMeasureInUserTab('in') diff --git a/src/App.tsx b/src/App.tsx index 69eeeda31..8c2795579 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,14 +6,12 @@ import { useHotkeys } from 'react-hotkeys-hook' import { useLoaderData, useNavigate } from 'react-router-dom' import { type IndexLoaderData } from 'lib/types' import { PATHS } from 'lib/paths' -import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { onboardingPaths } from 'routes/Onboarding/paths' import { useEngineConnectionSubscriptions } from 'hooks/useEngineConnectionSubscriptions' import { codeManager, engineCommandManager } from 'lib/singletons' import { useAbsoluteFilePath } from 'hooks/useAbsoluteFilePath' import { isDesktop } from 'lib/isDesktop' import { useLspContext } from 'components/LspProvider' -import { useRefreshSettings } from 'hooks/useRefreshSettings' import { ModelingSidebar } from 'components/ModelingSidebar/ModelingSidebar' import { LowerRightControls } from 'components/LowerRightControls' import ModalContainer from 'react-modal-promise' @@ -30,6 +28,7 @@ import { useRouteLoaderData } from 'react-router-dom' import { useEngineCommands } from 'components/EngineCommands' import { commandBarActor } from 'machines/commandBarMachine' import { useToken } from 'machines/appMachine' +import { useSettings } from 'machines/appMachine' maybeWriteToDisk() .then(() => {}) .catch(() => {}) @@ -49,7 +48,6 @@ export function App() { }) }) - useRefreshSettings(PATHS.FILE + 'SETTINGS') const navigate = useNavigate() const filePath = useAbsoluteFilePath() const { onProjectOpen } = useLspContext() @@ -71,7 +69,7 @@ export function App() { useHotKeyListener() - const { settings } = useSettingsAuthContext() + const settings = useSettings() const token = useToken() const coreDumpManager = useMemo( @@ -81,7 +79,7 @@ export function App() { const { app: { onboardingStatus }, - } = settings.context + } = settings useHotkeys('backspace', (e) => { e.preventDefault() diff --git a/src/Router.tsx b/src/Router.tsx index 7f78328b5..a341565cc 100644 --- a/src/Router.tsx +++ b/src/Router.tsx @@ -28,10 +28,8 @@ import { fileLoader, homeLoader, onboardingRedirectLoader, - settingsLoader, telemetryLoader, } from 'lib/routeLoaders' -import SettingsAuthProvider from 'components/SettingsAuthProvider' import LspProvider from 'components/LspProvider' import { KclContextProvider } from 'lang/KclProvider' 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 { RouteProvider } from 'components/RouteProvider' import { ProjectsContextProvider } from 'components/ProjectsContextProvider' -import { OpenInDesktopAppHandler } from 'components/OpenInDesktopAppHandler' import { useToken } from 'machines/appMachine' +import { OpenInDesktopAppHandler } from 'components/OpenInDesktopAppHandler' const createRouter = isDesktop() ? createHashRouter : createBrowserRouter const router = createRouter([ { - loader: settingsLoader, 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: ( - - - - - - - - - - - - - + + + + + + + + + + + ), @@ -120,7 +112,6 @@ const router = createRouter([ children: [ { id: PATHS.FILE + 'SETTINGS', - loader: settingsLoader, children: [ { loader: onboardingRedirectLoader, @@ -166,11 +157,9 @@ const router = createRouter([ index: true, element: <>, id: PATHS.HOME + 'SETTINGS', - loader: settingsLoader, }, { path: makeUrlPathRelative(PATHS.SETTINGS), - loader: settingsLoader, element: , }, { diff --git a/src/clientSideScene/ClientSideSceneComp.tsx b/src/clientSideScene/ClientSideSceneComp.tsx index 7933b9266..275666330 100644 --- a/src/clientSideScene/ClientSideSceneComp.tsx +++ b/src/clientSideScene/ClientSideSceneComp.tsx @@ -2,7 +2,6 @@ import { useRef, useEffect, useState, useMemo, Fragment } from 'react' import { useModelingContext } from 'hooks/useModelingContext' import { cameraMouseDragGuards } from 'lib/cameraControls' -import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { ARROWHEAD, DEBUG_SHOW_BOTH_SCENES } from './sceneInfra' import { ReactCameraProperties } from './CameraControls' import { throttle, toSync } from 'lib/utils' @@ -48,6 +47,7 @@ import { ActionButton } from 'components/ActionButton' import { err, reportRejection, trap } from 'lib/trap' import { Node } from 'wasm-lib/kcl/bindings/Node' import { commandBarActor } from 'machines/commandBarMachine' +import { useSettings } from 'machines/appMachine' function useShouldHideScene(): { hideClient: boolean; hideServer: boolean } { const [isCamMoving, setIsCamMoving] = useState(false) @@ -76,8 +76,8 @@ export const ClientSideScene = ({ cameraControls, }: { cameraControls: ReturnType< - typeof useSettingsAuthContext - >['settings']['context']['modeling']['mouseControls']['current'] + typeof useSettings + >['modeling']['mouseControls']['current'] }) => { const canvasRef = useRef(null) const { state, send, context } = useModelingContext() diff --git a/src/components/CameraProjectionToggle.tsx b/src/components/CameraProjectionToggle.tsx index 75914ae13..907351dea 100644 --- a/src/components/CameraProjectionToggle.tsx +++ b/src/components/CameraProjectionToggle.tsx @@ -1,24 +1,22 @@ import { Switch } from '@headlessui/react' -import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' +import { settingsActor, useSettings } from 'machines/appMachine' import { useEffect, useState } from 'react' export function CameraProjectionToggle() { - const { settings } = useSettingsAuthContext() + const settings = useSettings() const isCameraProjectionPerspective = - settings.context.modeling.cameraProjection.current === 'perspective' + settings.modeling.cameraProjection.current === 'perspective' const [checked, setChecked] = useState(isCameraProjectionPerspective) useEffect(() => { - setChecked( - settings.context.modeling.cameraProjection.current === 'perspective' - ) - }, [settings.context.modeling.cameraProjection.current]) + setChecked(settings.modeling.cameraProjection.current === 'perspective') + }, [settings.modeling.cameraProjection.current]) return ( { - settings.send({ + settingsActor.send({ type: 'set.modeling.cameraProjection', data: { level: 'user', diff --git a/src/components/CommandBar/CommandBarKclInput.tsx b/src/components/CommandBar/CommandBarKclInput.tsx index e672c0fd4..111b2a976 100644 --- a/src/components/CommandBar/CommandBarKclInput.tsx +++ b/src/components/CommandBar/CommandBarKclInput.tsx @@ -7,7 +7,6 @@ import { } from '@codemirror/autocomplete' import { EditorView, keymap, ViewUpdate } from '@codemirror/view' import { CustomIcon } from 'components/CustomIcon' -import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { CommandArgument, KclCommandValue } from 'lib/commandTypes' import { getSystemTheme } from 'lib/theme' import { useCalculateKclExpression } from 'lib/useCalculateKclExpression' @@ -20,6 +19,7 @@ import { createIdentifier, createVariableDeclaration } from 'lang/modifyAst' import { useCodeMirror } from 'components/ModelingSidebar/ModelingPanes/CodeEditor' import { useSelector } from '@xstate/react' import { commandBarActor, useCommandBarState } from 'machines/commandBarMachine' +import { useSettings } from 'machines/appMachine' import toast from 'react-hot-toast' const machineContextSelector = (snapshot?: { @@ -42,7 +42,7 @@ function CommandBarKclInput({ const previouslySetValue = commandBarState.context.argumentsToSubmit[ arg.name ] as KclCommandValue | undefined - const { settings } = useSettingsAuthContext() + const settings = useSettings() const argMachineContext = useSelector( arg.machineActor, machineContextSelector @@ -117,9 +117,9 @@ function CommandBarKclInput({ : defaultValue.length, }, theme: - settings.context.app.theme.current === 'system' + settings.app.theme.current === 'system' ? getSystemTheme() - : settings.context.app.theme.current, + : settings.app.theme.current, extensions: [ varMentionsExtension, EditorView.updateListener.of((vu: ViewUpdate) => { diff --git a/src/components/DownloadAppBanner.tsx b/src/components/DownloadAppBanner.tsx index 3c2391611..4bde8f0fa 100644 --- a/src/components/DownloadAppBanner.tsx +++ b/src/components/DownloadAppBanner.tsx @@ -1,16 +1,16 @@ import { Dialog } from '@headlessui/react' import { ActionButton } from './ActionButton' -import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { useState } from 'react' import { useSearchParams } from 'react-router-dom' import { CREATE_FILE_URL_PARAM } from 'lib/constants' +import { useSettings } from 'machines/appMachine' const DownloadAppBanner = () => { const [searchParams] = useSearchParams() const hasCreateFileParam = searchParams.has(CREATE_FILE_URL_PARAM) - const { settings } = useSettingsAuthContext() + const settings = useSettings() const [isBannerDismissed, setIsBannerDismissed] = useState( - settings.context.app.dismissWebBanner.current || hasCreateFileParam + settings.app.dismissWebBanner.current ) return ( diff --git a/src/components/FileMachineProvider.tsx b/src/components/FileMachineProvider.tsx index c4973e903..bce232ff5 100644 --- a/src/components/FileMachineProvider.tsx +++ b/src/components/FileMachineProvider.tsx @@ -1,7 +1,7 @@ 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 { PATHS } from 'lib/paths' +import { BROWSER_PATH, PATHS } from 'lib/paths' import React, { createContext, useEffect, useMemo } from 'react' import { toast } from 'react-hot-toast' import { @@ -27,9 +27,10 @@ import { getKclSamplesManifest, KclSamplesManifestItem, } from 'lib/getKclSamplesManifest' -import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { markOnce } from 'lib/performance' import { commandBarActor } from 'machines/commandBarMachine' +import { settingsActor, useSettings } from 'machines/appMachine' +import { createRouteCommands } from 'lib/commandBarConfigs/routeCommandConfig' import { useToken } from 'machines/appMachine' type MachineContext = { @@ -48,14 +49,51 @@ export const FileMachineProvider = ({ children: React.ReactNode }) => { const navigate = useNavigate() - const { settings } = useSettingsAuthContext() + const location = useLocation() const token = useToken() + const settings = useSettings() const projectData = useRouteLoaderData(PATHS.FILE) as IndexLoaderData const { project, file } = projectData const [kclSamples, setKclSamples] = React.useState( [] ) + // 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(() => { markOnce('code/didLoadFile') async function fetchKclSamples() { @@ -323,7 +361,7 @@ export const FileMachineProvider = ({ authToken: token ?? '', projectData, settings: { - defaultUnit: settings?.context?.modeling.defaultUnit.current ?? 'mm', + defaultUnit: settings.modeling.defaultUnit.current ?? 'mm', }, specialPropsForSampleCommand: { onSubmit: async (data) => { @@ -345,7 +383,7 @@ export const FileMachineProvider = ({ // Either way, we want to overwrite the defaultUnit project setting // with the sample's setting. if (data.sampleUnits) { - settings.send({ + settingsActor.send({ type: 'set.modeling.defaultUnit', data: { level: 'project', diff --git a/src/components/HelpMenu.tsx b/src/components/HelpMenu.tsx index d1eb8994e..7363dddd1 100644 --- a/src/components/HelpMenu.tsx +++ b/src/components/HelpMenu.tsx @@ -1,6 +1,5 @@ import { Popover } from '@headlessui/react' import Tooltip from './Tooltip' -import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { CustomIcon } from './CustomIcon' import { useLocation, useNavigate } from 'react-router-dom' import { PATHS } from 'lib/paths' @@ -9,6 +8,7 @@ import { useAbsoluteFilePath } from 'hooks/useAbsoluteFilePath' import { useLspContext } from './LspProvider' import { openExternalBrowserIfDesktop } from 'lib/openWindow' import { reportRejection } from 'lib/trap' +import { settingsActor } from 'machines/appMachine' const HelpMenuDivider = () => (
@@ -20,7 +20,6 @@ export function HelpMenu(props: React.PropsWithChildren) { const filePath = useAbsoluteFilePath() const isInProject = location.pathname.includes(PATHS.FILE) const navigate = useNavigate() - const { settings } = useSettingsAuthContext() return ( @@ -106,7 +105,7 @@ export function HelpMenu(props: React.PropsWithChildren) { { - settings.send({ + settingsActor.send({ type: 'set.app.onboardingStatus', data: { value: '', diff --git a/src/components/LspProvider.tsx b/src/components/LspProvider.tsx index 422e56afc..71f158eef 100644 --- a/src/components/LspProvider.tsx +++ b/src/components/LspProvider.tsx @@ -10,7 +10,6 @@ import { import { TEST, VITE_KC_API_BASE_URL } from 'env' import { kcl } from 'editor/plugins/lsp/kcl/language' import { copilotPlugin } from 'editor/plugins/lsp/copilot' -import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { Extension } from '@codemirror/state' import { LanguageSupport } from '@codemirror/language' import { useNavigate } from 'react-router-dom' diff --git a/src/components/ModelingMachineProvider.tsx b/src/components/ModelingMachineProvider.tsx index 53df7f375..c73a08113 100644 --- a/src/components/ModelingMachineProvider.tsx +++ b/src/components/ModelingMachineProvider.tsx @@ -22,7 +22,6 @@ import { modelingMachineDefaultContext, } from 'machines/modelingMachine' import { useSetupEngineManager } from 'hooks/useSetupEngineManager' -import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { isCursorInSketchCommandRange, updateSketchDetailsNodePaths, @@ -110,6 +109,7 @@ import { kclEditorActor } from 'machines/kclEditorMachine' import { commandBarActor } from 'machines/commandBarMachine' import { useToken } from 'machines/appMachine' import { getNodePathFromSourceRange } from 'lang/queryAstNodePathUtils' +import { useSettings } from 'machines/appMachine' type MachineContext = { state: StateFrom @@ -131,19 +131,15 @@ export const ModelingMachineProvider = ({ children: React.ReactNode }) => { const { - settings: { - context: { - app: { theme, enableSSAO, allowOrbitInSketchMode }, - modeling: { - defaultUnit, - cameraProjection, - highlightEdges, - showScaleGrid, - cameraOrbit, - }, - }, + app: { theme, enableSSAO, allowOrbitInSketchMode }, + modeling: { + defaultUnit, + cameraProjection, + highlightEdges, + showScaleGrid, + cameraOrbit, }, - } = useSettingsAuthContext() + } = useSettings() const previousAllowOrbitInSketchMode = useRef(allowOrbitInSketchMode.current) const navigate = useNavigate() const { context, send: fileMachineSend } = useFileContext() diff --git a/src/components/ModelingSidebar/ModelingPane.tsx b/src/components/ModelingSidebar/ModelingPane.tsx index f9b36fb86..748e5ddbe 100644 --- a/src/components/ModelingSidebar/ModelingPane.tsx +++ b/src/components/ModelingSidebar/ModelingPane.tsx @@ -1,12 +1,12 @@ import { ReactNode } from 'react' import styles from './ModelingPane.module.css' -import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { ActionButton } from 'components/ActionButton' import Tooltip from 'components/Tooltip' import { CustomIconName } from 'components/CustomIcon' import { IconDefinition } from '@fortawesome/free-solid-svg-icons' import { ActionIcon } from 'components/ActionIcon' import { onboardingPaths } from 'routes/Onboarding/paths' +import { useSettings } from 'machines/appMachine' export interface ModelingPaneProps { id: string @@ -68,8 +68,8 @@ export const ModelingPane = ({ title, ...props }: ModelingPaneProps) => { - const { settings } = useSettingsAuthContext() - const onboardingStatus = settings.context.app.onboardingStatus + const settings = useSettings() + const onboardingStatus = settings.app.onboardingStatus const pointerEventsCssClass = onboardingStatus.current === onboardingPaths.CAMERA ? 'pointer-events-none ' diff --git a/src/components/ModelingSidebar/ModelingPanes/KclEditorPane.tsx b/src/components/ModelingSidebar/ModelingPanes/KclEditorPane.tsx index 5925aeb4b..b4fa69b1f 100644 --- a/src/components/ModelingSidebar/ModelingPanes/KclEditorPane.tsx +++ b/src/components/ModelingSidebar/ModelingPanes/KclEditorPane.tsx @@ -1,5 +1,4 @@ import { TEST } from 'env' -import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { Themes, getSystemTheme } from 'lib/theme' import { useEffect, useMemo, useRef } from 'react' import { highlightSelectionMatches, searchKeymap } from '@codemirror/search' @@ -51,6 +50,7 @@ import { } from 'machines/kclEditorMachine' import { useSelector } from '@xstate/react' import { modelingMachineEvent } from 'editor/manager' +import { useSettings } from 'machines/appMachine' export const editorShortcutMeta = { formatCode: { @@ -63,9 +63,7 @@ export const editorShortcutMeta = { } export const KclEditorPane = () => { - const { - settings: { context }, - } = useSettingsAuthContext() + const context = useSettings() const lastSelectionEvent = useSelector(kclEditorActor, selectionEventSelector) const editorIsMounted = useSelector(kclEditorActor, editorIsMountedSelector) const theme = diff --git a/src/components/ModelingSidebar/ModelingSidebar.tsx b/src/components/ModelingSidebar/ModelingSidebar.tsx index b6bef1ea1..b1ffe3f3e 100644 --- a/src/components/ModelingSidebar/ModelingSidebar.tsx +++ b/src/components/ModelingSidebar/ModelingSidebar.tsx @@ -1,4 +1,3 @@ -import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { Resizable } from 're-resizable' import { MouseEventHandler, @@ -21,6 +20,7 @@ import { MachineManagerContext } from 'components/MachineManagerProvider' import { onboardingPaths } from 'routes/Onboarding/paths' import { SIDEBAR_BUTTON_SUFFIX } from 'lib/constants' import { commandBarActor } from 'machines/commandBarMachine' +import { useSettings } from 'machines/appMachine' interface ModelingSidebarProps { paneOpacity: '' | 'opacity-20' | 'opacity-40' @@ -38,23 +38,23 @@ function getPlatformString(): 'web' | 'desktop' { export function ModelingSidebar({ paneOpacity }: ModelingSidebarProps) { const machineManager = useContext(MachineManagerContext) const kclContext = useKclContext() - const { settings } = useSettingsAuthContext() - const onboardingStatus = settings.context.app.onboardingStatus + const settings = useSettings() + const onboardingStatus = settings.app.onboardingStatus const { send, context } = useModelingContext() const pointerEventsCssClass = onboardingStatus.current === onboardingPaths.CAMERA || context.store?.openPanes.length === 0 ? 'pointer-events-none ' : 'pointer-events-auto ' - const showDebugPanel = settings.context.modeling.showDebugPanel + const showDebugPanel = settings.modeling.showDebugPanel const paneCallbackProps = useMemo( () => ({ kclContext, - settings: settings.context, + settings, platform: getPlatformString(), }), - [kclContext.diagnostics, settings.context] + [kclContext.diagnostics, settings] ) const sidebarActions: SidebarAction[] = [ @@ -144,7 +144,7 @@ export function ModelingSidebar({ paneOpacity }: ModelingSidebarProps) { }, }) } - }, [settings.context]) + }, [settings.modeling.showDebugPanel]) const togglePane = useCallback( (newPane: SidebarType) => { diff --git a/src/components/NetworkHealthIndicator.test.tsx b/src/components/NetworkHealthIndicator.test.tsx index ccee054bb..84e2ef978 100644 --- a/src/components/NetworkHealthIndicator.test.tsx +++ b/src/components/NetworkHealthIndicator.test.tsx @@ -1,6 +1,5 @@ import { fireEvent, render, screen } from '@testing-library/react' import { BrowserRouter } from 'react-router-dom' -import { SettingsAuthProviderJest } from './SettingsAuthProvider' import { NETWORK_HEALTH_TEXT, NetworkHealthIndicator, @@ -9,11 +8,7 @@ import { NetworkHealthState } from 'hooks/useNetworkStatus' function TestWrap({ children }: { children: React.ReactNode }) { // wrap in router and xState context - return ( - - {children} - - ) + return {children} } // Our Playwright tests for this are much more comprehensive. diff --git a/src/components/ProjectSidebarMenu.test.tsx b/src/components/ProjectSidebarMenu.test.tsx index 3cf0a78be..13d47829e 100644 --- a/src/components/ProjectSidebarMenu.test.tsx +++ b/src/components/ProjectSidebarMenu.test.tsx @@ -1,7 +1,6 @@ import { render, screen } from '@testing-library/react' import { BrowserRouter } from 'react-router-dom' import ProjectSidebarMenu from './ProjectSidebarMenu' -import { SettingsAuthProviderJest } from './SettingsAuthProvider' import { Project } from 'lib/project' const now = new Date() @@ -32,9 +31,7 @@ describe('ProjectSidebarMenu tests', () => { test('Disables popover menu by default', () => { render( - - - + ) diff --git a/src/components/ProjectSidebarMenu.tsx b/src/components/ProjectSidebarMenu.tsx index 639b5f668..5e125a829 100644 --- a/src/components/ProjectSidebarMenu.tsx +++ b/src/components/ProjectSidebarMenu.tsx @@ -18,7 +18,6 @@ import { SnapshotFrom } from 'xstate' import { commandBarActor } from 'machines/commandBarMachine' import { useSelector } from '@xstate/react' import { copyFileShareLink } from 'lib/links' -import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { useToken } from 'machines/appMachine' const ProjectSidebarMenu = ({ @@ -103,7 +102,6 @@ function ProjectMenuPopover({ const location = useLocation() const navigate = useNavigate() const filePath = useAbsoluteFilePath() - useSettingsAuthContext() const token = useToken() const machineManager = useContext(MachineManagerContext) const commands = useSelector(commandBarActor, commandsSelector) diff --git a/src/components/ProjectsContextProvider.tsx b/src/components/ProjectsContextProvider.tsx index 8603031d3..58765efd0 100644 --- a/src/components/ProjectsContextProvider.tsx +++ b/src/components/ProjectsContextProvider.tsx @@ -20,11 +20,11 @@ import { getUniqueProjectName, getNextFileName, } from 'lib/desktopFS' -import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import useStateMachineCommands from 'hooks/useStateMachineCommands' import { projectsCommandBarConfig } from 'lib/commandBarConfigs/projectsCommandConfig' import { isDesktop } from 'lib/isDesktop' import { commandBarActor } from 'machines/commandBarMachine' +import { useSettings } from 'machines/appMachine' import { CREATE_FILE_URL_PARAM, FILE_EXT, @@ -77,9 +77,7 @@ const ProjectsContextWeb = ({ children }: { children: React.ReactNode }) => { searchParams.delete('units') setSearchParams(searchParams) }, [searchParams, setSearchParams]) - const { - settings: { context: settings }, - } = useSettingsAuthContext() + const settings = useSettings() const [state, send, actor] = useMachine( projectsMachine.provide({ @@ -183,9 +181,7 @@ const ProjectsContextDesktop = ({ setSearchParams(searchParams) }, [searchParams, setSearchParams]) const { onProjectOpen } = useLspContext() - const { - settings: { context: settings }, - } = useSettingsAuthContext() + const settings = useSettings() const [projectsLoaderTrigger, setProjectsLoaderTrigger] = useState(0) const { projectPaths, projectsDir } = useProjectsLoader([ diff --git a/src/components/RefreshButton.tsx b/src/components/RefreshButton.tsx index c6cfeaf13..e4772838e 100644 --- a/src/components/RefreshButton.tsx +++ b/src/components/RefreshButton.tsx @@ -5,7 +5,6 @@ import { codeManager, engineCommandManager } from 'lib/singletons' import React, { useMemo } from 'react' import toast from 'react-hot-toast' import Tooltip from './Tooltip' -import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { reportRejection } from 'lib/trap' import { toSync } from 'lib/utils' import { useToken } from 'machines/appMachine' diff --git a/src/components/RouteProvider.tsx b/src/components/RouteProvider.tsx index f50d4996c..f306c5755 100644 --- a/src/components/RouteProvider.tsx +++ b/src/components/RouteProvider.tsx @@ -1,17 +1,36 @@ 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 { markOnce } from 'lib/performance' 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 function RouteProvider({ children }: { children: ReactNode }) { useAuthNavigation() + const loadedProject = useRouteLoaderData(PATHS.FILE) as IndexLoaderData const [first, setFirstState] = useState(true) + const [settingsPath, setSettingsPath] = useState( + undefined + ) const navigation = useNavigation() + const navigate = useNavigate() const location = useLocation() + const authState = useAuthState() useEffect(() => { // On initialization, the react-router-dom does not send a 'loading' state event. // it sends an idle event first. @@ -28,6 +47,41 @@ export function RouteProvider({ children }: { children: ReactNode }) { setFirstState(false) }, [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 ( {children} diff --git a/src/components/Settings/AllSettingsFields.tsx b/src/components/Settings/AllSettingsFields.tsx index 6a1fa7644..b4b5c8b9b 100644 --- a/src/components/Settings/AllSettingsFields.tsx +++ b/src/components/Settings/AllSettingsFields.tsx @@ -1,5 +1,4 @@ import decamelize from 'decamelize' -import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { Setting } from 'lib/settings/initialSettings' import { SetEventTypes, SettingsLevel } from 'lib/settings/settingsTypes' import { @@ -25,6 +24,8 @@ import { useLspContext } from 'components/LspProvider' import { toSync } from 'lib/utils' import { reportRejection } from 'lib/trap' import { openExternalBrowserIfDesktop } from 'lib/openWindow' +import { settingsActor, useSettings } from 'machines/appMachine' +import { useSelector } from '@xstate/react' interface AllSettingsFieldsProps { searchParamTab: SettingsLevel @@ -40,9 +41,7 @@ export const AllSettingsFields = forwardRef( const navigate = useNavigate() const { onProjectOpen } = useLspContext() const dotDotSlash = useDotDotSlash() - const { - settings: { send, context, state }, - } = useSettingsAuthContext() + const context = useSettings() const projectPath = useMemo(() => { const filteredPathname = location.pathname @@ -62,7 +61,7 @@ export const AllSettingsFields = forwardRef( }, [location.pathname]) function restartOnboarding() { - send({ + settingsActor.send({ type: `set.app.onboardingStatus`, data: { level: 'user', value: '' }, }) @@ -72,11 +71,14 @@ export const AllSettingsFields = forwardRef( * A "listener" for the XState to return to "idle" state * when the user resets the onboarding, using the callback above */ + const isSettingsMachineIdle = useSelector(settingsActor, (s) => + s.matches('idle') + ) useEffect(() => { async function navigateToOnboardingStart() { if ( - state.context.app.onboardingStatus.user === '' && - state.matches('idle') + context.app.onboardingStatus.current === '' && + isSettingsMachineIdle ) { if (isFileSettings) { // 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 navigateToOnboardingStart() - }, [isFileSettings, navigate, state]) + }, [ + isFileSettings, + navigate, + isSettingsMachineIdle, + context.app.onboardingStatus.current, + ]) return (
@@ -142,7 +149,7 @@ export const AllSettingsFields = forwardRef( } parentLevel={setting.getParentLevel(searchParamTab)} onFallback={() => - send({ + settingsActor.send({ type: `set.${category}.${settingName}`, data: { level: searchParamTab, @@ -218,7 +225,7 @@ export const AllSettingsFields = forwardRef( { - send({ + settingsActor.send({ type: 'Reset settings', level: searchParamTab, }) diff --git a/src/components/Settings/SettingsFieldInput.tsx b/src/components/Settings/SettingsFieldInput.tsx index 8b1aa2638..83ae6166c 100644 --- a/src/components/Settings/SettingsFieldInput.tsx +++ b/src/components/Settings/SettingsFieldInput.tsx @@ -1,5 +1,4 @@ import { Toggle } from 'components/Toggle/Toggle' -import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { Setting } from 'lib/settings/initialSettings' import { SetEventTypes, @@ -7,6 +6,7 @@ import { WildcardSetEvent, } from 'lib/settings/settingsTypes' import { getSettingInputType } from 'lib/settings/settingsUtils' +import { settingsActor, useSettings } from 'machines/appMachine' import { useMemo } from 'react' import { EventFrom } from 'xstate' @@ -25,9 +25,8 @@ export function SettingsFieldInput({ settingsLevel, setting, }: SettingsFieldInputProps) { - const { - settings: { context, send }, - } = useSettingsAuthContext() + const context = useSettings() + const send = settingsActor.send const options = useMemo(() => { return setting.commandConfig && 'options' in setting.commandConfig && diff --git a/src/components/Settings/SettingsSearchBar.tsx b/src/components/Settings/SettingsSearchBar.tsx index f317cdb1b..da5307c12 100644 --- a/src/components/Settings/SettingsSearchBar.tsx +++ b/src/components/Settings/SettingsSearchBar.tsx @@ -2,10 +2,10 @@ import { Combobox } from '@headlessui/react' import { CustomIcon } from 'components/CustomIcon' import decamelize from 'decamelize' import Fuse from 'fuse.js' -import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { interactionMap } from 'lib/settings/initialKeybindings' import { Setting } from 'lib/settings/initialSettings' import { SettingsLevel } from 'lib/settings/settingsTypes' +import { useSettings } from 'machines/appMachine' import { useEffect, useMemo, useRef, useState } from 'react' import { useHotkeys } from 'react-hotkeys-hook' import { useNavigate } from 'react-router-dom' @@ -32,23 +32,22 @@ export function SettingsSearchBar() { ) const navigate = useNavigate() const [query, setQuery] = useState('') - const { settings } = useSettingsAuthContext() + const settings = useSettings() const settingsAsSearchable: SettingsSearchItem[] = useMemo( () => [ - ...Object.entries(settings.state.context).flatMap( - ([category, categorySettings]) => - Object.entries(categorySettings).flatMap(([settingName, setting]) => { - const s = setting as Setting - return (['project', 'user'] satisfies SettingsLevel[]) - .filter((l) => s.hideOnLevel !== l) - .map((l) => ({ - category: decamelize(category, { separator: ' ' }), - name: settingName, - description: s.description ?? '', - displayName: decamelize(settingName, { separator: ' ' }), - level: l as ExtendedSettingsLevel, - })) - }) + ...Object.entries(settings).flatMap(([category, categorySettings]) => + Object.entries(categorySettings).flatMap(([settingName, setting]) => { + const s = setting + return (['project', 'user'] satisfies SettingsLevel[]) + .filter((l) => s.hideOnLevel !== l) + .map((l) => ({ + category: decamelize(category, { separator: ' ' }), + name: settingName, + description: s.description ?? '', + displayName: decamelize(settingName, { separator: ' ' }), + level: l, + })) + }) ), ...Object.entries(interactionMap).flatMap( ([category, categoryKeybindings]) => @@ -61,7 +60,7 @@ export function SettingsSearchBar() { })) ), ], - [settings.state.context] + [settings] ) const [searchResults, setSearchResults] = useState(settingsAsSearchable) diff --git a/src/components/Settings/SettingsSectionsList.tsx b/src/components/Settings/SettingsSectionsList.tsx index 581525ccd..2ce5d750c 100644 --- a/src/components/Settings/SettingsSectionsList.tsx +++ b/src/components/Settings/SettingsSectionsList.tsx @@ -1,8 +1,8 @@ import decamelize from 'decamelize' -import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { Setting } from 'lib/settings/initialSettings' import { SettingsLevel } from 'lib/settings/settingsTypes' import { shouldHideSetting } from 'lib/settings/settingsUtils' +import { useSettings } from 'machines/appMachine' interface SettingsSectionsListProps { searchParamTab: SettingsLevel @@ -13,9 +13,7 @@ export function SettingsSectionsList({ searchParamTab, scrollRef, }: SettingsSectionsListProps) { - const { - settings: { context }, - } = useSettingsAuthContext() + const context = useSettings() return (
{Object.entries(context) diff --git a/src/components/SettingsAuthProvider.tsx b/src/components/SettingsAuthProvider.tsx deleted file mode 100644 index 8dcae757c..000000000 --- a/src/components/SettingsAuthProvider.tsx +++ /dev/null @@ -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 = { - state: StateFrom - context: ContextFrom - send: Prop, 'send'> -} - -type SettingsAuthContextType = { - settings: MachineContext -} - -/** - * 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 - | 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 ( - - {children} - - ) -} - -// 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 ( - - {children} - - ) -} - -export const SettingsAuthProviderBase = ({ - children, - loadedSettings, - loadedProject, -}: { - children: React.ReactNode - loadedSettings: typeof settings - loadedProject?: IndexLoaderData -}) => { - const location = useLocation() - const navigate = useNavigate() - const [settingsPath, setSettingsPath] = useState( - 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 ( - - {children} - - ) -} - -export default SettingsAuthProvider diff --git a/src/components/Stream.tsx b/src/components/Stream.tsx index b465c847c..8ec2d1fbe 100644 --- a/src/components/Stream.tsx +++ b/src/components/Stream.tsx @@ -1,6 +1,5 @@ import { MouseEventHandler, useEffect, useRef, useState } from 'react' import Loading from './Loading' -import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { useModelingContext } from 'hooks/useModelingContext' import { useNetworkContext } from 'hooks/useNetworkContext' import { NetworkHealthState } from 'hooks/useNetworkStatus' @@ -20,8 +19,8 @@ import { IndexLoaderData } from 'lib/types' import { err, reportRejection } from 'lib/trap' import { getArtifactOfTypes } from 'lang/std/artifactGraph' import { ViewControlContextMenu } from './ViewControlMenu' -import { commandBarActor, useCommandBarState } from 'machines/commandBarMachine' -import { useSelector } from '@xstate/react' +import { useCommandBarState } from 'machines/commandBarMachine' +import { useSettings } from 'machines/appMachine' enum StreamState { Playing = 'playing', @@ -34,7 +33,7 @@ export const Stream = () => { const [isLoading, setIsLoading] = useState(true) const videoWrapperRef = useRef(null) const videoRef = useRef(null) - const { settings } = useSettingsAuthContext() + const settings = useSettings() const { state, send } = useModelingContext() const commandBarState = useCommandBarState() const { mediaStream } = useAppStream() @@ -42,7 +41,7 @@ export const Stream = () => { const [streamState, setStreamState] = useState(StreamState.Unset) const { file } = useRouteLoaderData(PATHS.FILE) as IndexLoaderData - const IDLE = settings.context.app.streamIdleMode.current + const IDLE = settings.app.streamIdleMode.current const isNetworkOkay = overallState === NetworkHealthState.Ok || @@ -336,7 +335,7 @@ export const Stream = () => { id="video-stream" /> {(streamState === StreamState.Paused || streamState === StreamState.Resuming) && ( diff --git a/src/components/UnitsMenu.tsx b/src/components/UnitsMenu.tsx index 74ed63471..b65db3d0a 100644 --- a/src/components/UnitsMenu.tsx +++ b/src/components/UnitsMenu.tsx @@ -1,5 +1,5 @@ import { Popover } from '@headlessui/react' -import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' +import { settingsActor, useSettings } from 'machines/appMachine' import { changeKclSettings, unitLengthToUnitLen } from 'lang/wasm' import { baseUnitLabels, baseUnitsUnion } from 'lib/settings/settingsTypes' import { codeManager, kclManager } from 'lib/singletons' @@ -8,24 +8,25 @@ import { useEffect, useState } from 'react' import toast from 'react-hot-toast' export function UnitsMenu() { - const { settings } = useSettingsAuthContext() + const settings = useSettings() const [hasPerFileLengthUnit, setHasPerFileLengthUnit] = useState( Boolean(kclManager.fileSettings.defaultLengthUnit) ) const [lengthSetting, setLengthSetting] = useState( kclManager.fileSettings.defaultLengthUnit || - settings.context.modeling.defaultUnit.current + settings.modeling.defaultUnit.current ) useEffect(() => { setHasPerFileLengthUnit(Boolean(kclManager.fileSettings.defaultLengthUnit)) setLengthSetting( kclManager.fileSettings.defaultLengthUnit || - settings.context.modeling.defaultUnit.current + settings.modeling.defaultUnit.current ) }, [ kclManager.fileSettings.defaultLengthUnit, - settings.context.modeling.defaultUnit.current, + settings.modeling.defaultUnit.current, ]) + return ( {({ close }) => ( @@ -75,7 +76,7 @@ export function UnitsMenu() { .catch(reportRejection) } } else { - settings.send({ + settingsActor.send({ type: 'set.modeling.defaultUnit', data: { level: 'project', diff --git a/src/components/UserSidebarMenu.test.tsx b/src/components/UserSidebarMenu.test.tsx index 2e9fc1694..afa3ebd94 100644 --- a/src/components/UserSidebarMenu.test.tsx +++ b/src/components/UserSidebarMenu.test.tsx @@ -7,7 +7,6 @@ import { createRoutesFromElements, } from 'react-router-dom' import { Models } from '@kittycad/lib' -import { SettingsAuthProviderJest } from './SettingsAuthProvider' 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 const router = createMemoryRouter( createRoutesFromElements( - {children} - } - /> + {children}} /> ), { initialEntries: ['/file/new'], diff --git a/src/hooks/useCreateFileLinkQueryWatcher.ts b/src/hooks/useCreateFileLinkQueryWatcher.ts index 35b7a95ec..04237aadf 100644 --- a/src/hooks/useCreateFileLinkQueryWatcher.ts +++ b/src/hooks/useCreateFileLinkQueryWatcher.ts @@ -2,10 +2,10 @@ import { base64ToString } from 'lib/base64' import { CREATE_FILE_URL_PARAM, DEFAULT_FILE_NAME } from 'lib/constants' import { useEffect } from 'react' import { useSearchParams } from 'react-router-dom' -import { useSettingsAuthContext } from './useSettingsAuthContext' import { isDesktop } from 'lib/isDesktop' import { FileLinkParams } from 'lib/links' import { ProjectsCommandSchema } from 'lib/commandBarConfigs/projectsCommandConfig' +import { useSettings } from 'machines/appMachine' // For initializing the command arguments, we actually want `method` to be undefined // so that we don't skip it in the command palette. @@ -26,7 +26,7 @@ export function useCreateFileLinkQuery( callback: (args: CreateFileSchemaMethodOptional) => void ) { const [searchParams] = useSearchParams() - const { settings } = useSettingsAuthContext() + const settings = useSettings() useEffect(() => { const createFileParam = searchParams.has(CREATE_FILE_URL_PARAM) @@ -45,7 +45,7 @@ export function useCreateFileLinkQuery( ? params.name.replace('.kcl', '') : params.name : isDesktop() - ? settings.context.projects.defaultProjectName.current + ? settings.projects.defaultProjectName.current : DEFAULT_FILE_NAME, code: params.code || '', method: isDesktop() ? undefined : 'existingProject', diff --git a/src/hooks/useRefreshSettings.ts b/src/hooks/useRefreshSettings.ts deleted file mode 100644 index da7c440d2..000000000 --- a/src/hooks/useRefreshSettings.ts +++ /dev/null @@ -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, - }) - }, []) -} diff --git a/src/hooks/useResolvedTheme.ts b/src/hooks/useResolvedTheme.ts index 21d376136..325dcf0f4 100644 --- a/src/hooks/useResolvedTheme.ts +++ b/src/hooks/useResolvedTheme.ts @@ -1,5 +1,5 @@ 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 @@ -7,10 +7,8 @@ import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' * @returns {Themes.Light | Themes.Dark} */ export function useResolvedTheme() { - const { - settings: { context }, - } = useSettingsAuthContext() - return context.app.theme.current === Themes.System + const settings = useSettings() + return settings.app.theme.current === Themes.System ? getSystemTheme() - : context.app.theme.current + : settings.app.theme.current } diff --git a/src/hooks/useSettingsAuthContext.ts b/src/hooks/useSettingsAuthContext.ts deleted file mode 100644 index 9153c6e39..000000000 --- a/src/hooks/useSettingsAuthContext.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { SettingsAuthContext } from 'components/SettingsAuthProvider' -import { useContext } from 'react' - -export const useSettingsAuthContext = () => { - return useContext(SettingsAuthContext) -} diff --git a/src/index.tsx b/src/index.tsx index 907961a59..b7abe9b9f 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -11,6 +11,9 @@ import { ToastUpdate } from 'components/ToastUpdate' import { markOnce } from 'lib/performance' import { AUTO_UPDATER_TOAST_ID } from 'lib/constants' import { initializeWindowExceptionHandler } from 'lib/exceptions' +import { initPromise } from 'lang/wasm' +import { appActor } from 'machines/appMachine' +import { reportRejection } from 'lib/trap' markOnce('code/willAuth') initializeWindowExceptionHandler() @@ -23,6 +26,14 @@ initializeWindowExceptionHandler() // 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) root.render( diff --git a/src/lang/KclProvider.tsx b/src/lang/KclProvider.tsx index 510dca9a0..61dc05f39 100644 --- a/src/lang/KclProvider.tsx +++ b/src/lang/KclProvider.tsx @@ -1,9 +1,10 @@ import { createContext, useContext, useEffect, useState } from 'react' 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 { Diagnostic } from '@codemirror/lint' import { KCLError } from './errors' +import { PATHS } from 'lib/paths' const KclContext = createContext({ 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 // 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. const [code, setCode] = useState(loadedCode || codeManager.code) diff --git a/src/lang/wasm.ts b/src/lang/wasm.ts index d23754670..4e40569e2 100644 --- a/src/lang/wasm.ts +++ b/src/lang/wasm.ts @@ -463,11 +463,11 @@ export const executeWithEngine = async ( const jsAppSettings = async () => { let jsAppSettings = default_app_settings() if (!TEST) { - const lastSettingsSnapshot = await import( - 'components/SettingsAuthProvider' - ).then((module) => module.lastSettingsContextSnapshot) - if (lastSettingsSnapshot) { - jsAppSettings = getAllCurrentSettings(lastSettingsSnapshot) + const settings = await import('machines/appMachine').then((module) => + module.getSettings() + ) + if (settings) { + jsAppSettings = getAllCurrentSettings(settings) } } return jsAppSettings diff --git a/src/lib/commandBarConfigs/settingsCommandConfig.ts b/src/lib/commandBarConfigs/settingsCommandConfig.ts index cc691b439..a79b4a29f 100644 --- a/src/lib/commandBarConfigs/settingsCommandConfig.ts +++ b/src/lib/commandBarConfigs/settingsCommandConfig.ts @@ -7,20 +7,23 @@ import { SettingsPaths, SettingsLevel, SettingProps, + SetEventTypes, } from 'lib/settings/settingsTypes' import { settingsMachine } from 'machines/settingsMachine' import { PathValue } from 'lib/types' -import { Actor, AnyStateMachine, ContextFrom } from 'xstate' +import { ActorRefFrom, AnyStateMachine } from 'xstate' import { getPropertyByPath } from 'lib/objectPropertyByPath' import { buildCommandArgument } from 'lib/createMachineCommand' import decamelize from 'decamelize' 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 -export const settingsWithCommandConfigs = ( - s: ContextFrom -) => +export const settingsWithCommandConfigs = (s: SettingsType) => Object.entries(s).flatMap(([categoryName, categorySettings]) => Object.entries(categorySettings) .filter(([_, setting]) => setting.commandConfig !== undefined) @@ -28,7 +31,7 @@ export const settingsWithCommandConfigs = ( ) as SettingsPaths[] const levelArgConfig = ( - actor: Actor, + actor: ActorRefFrom, isProjectAvailable: boolean, hideOnLevel?: SettingsLevel ): CommandArgument => ({ @@ -53,23 +56,16 @@ const levelArgConfig = ( interface CreateSettingsArgs { type: SettingsPaths - send: Function - context: ContextFrom - actor: Actor - isProjectAvailable: boolean + actor: ActorRefFrom } // Takes a Setting with a commandConfig and creates a Command // that can be used in the CommandBar component. -export function createSettingsCommand({ - type, - send, - context, - actor, - isProjectAvailable, -}: CreateSettingsArgs) { - type S = PathValue +export function createSettingsCommand({ type, actor }: CreateSettingsArgs) { + type S = PathValue, typeof type> + const context = actor.getSnapshot().context + const isProjectAvailable = context.currentProject !== undefined const settingConfig = getPropertyByPath(context, type) as SettingProps< S['default'] > @@ -129,10 +125,18 @@ export function createSettingsCommand({ icon: 'settings', needsReview: false, onSubmit: (data) => { - if (data !== undefined && data !== null) { - send({ type: `set.${type}`, data }) + if ( + 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 { - send({ type }) + console.error('Invalid data submitted to settings command', data) + return new Error('Invalid data submitted to settings command', data) } }, args: { diff --git a/src/lib/desktop.ts b/src/lib/desktop.ts index 7a55b0b21..5ad54d9f5 100644 --- a/src/lib/desktop.ts +++ b/src/lib/desktop.ts @@ -4,6 +4,7 @@ import { Project, FileEntry } from 'lib/project' import { defaultAppSettings, + initPromise, parseAppSettings, parseProjectSettings, } from 'lang/wasm' @@ -131,11 +132,20 @@ export async function createNewProjectDirectory( export async function listProjects( configuration?: DeepPartial | Error ): Promise { - if (configuration === undefined) { - configuration = await readAppSettingsFile() + // Make sure we have wasm initialized. + 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 projects = [] if (!projectDir) return Promise.reject(new Error('projectDir was falsey')) diff --git a/src/lib/paths.ts b/src/lib/paths.ts index 952b72fd6..9971ede0f 100644 --- a/src/lib/paths.ts +++ b/src/lib/paths.ts @@ -75,11 +75,11 @@ export async function getProjectMetaByRouteId( return route } -export async function parseProjectRoute( +export function parseProjectRoute( configuration: DeepPartial, id: string, pathlib: PlatformPath | undefined -): Promise { +): ProjectRoute { let projectName = null let projectPath = '' let currentFileName = null diff --git a/src/lib/routeLoaders.ts b/src/lib/routeLoaders.ts index 550c20c8f..9dd99ac5d 100644 --- a/src/lib/routeLoaders.ts +++ b/src/lib/routeLoaders.ts @@ -13,37 +13,9 @@ import makeUrlPathRelative from './makeUrlPathRelative' import { codeManager } from 'lib/singletons' import { fileSystemManager } from 'lang/std/fileSystemManager' import { getProjectInfo } from './desktop' -import { createSettings } from './settings/initialSettings' import { normalizeLineEndings } from 'lib/codeEditor' import { OnboardingStatus } from 'wasm-lib/kcl/bindings/OnboardingStatus' - -// The root loader simply resolves the settings and any errors that -// occurred during the settings load -export const settingsLoader: LoaderFunction = async ({ - params, -}): Promise< - ReturnType | ReturnType -> => { - 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 -} +import { getSettings, settingsActor } from 'machines/appMachine' export const telemetryLoader: LoaderFunction = async ({ params, @@ -53,7 +25,7 @@ export const telemetryLoader: LoaderFunction = async ({ // Redirect users to the appropriate onboarding page if they haven't completed it export const onboardingRedirectLoader: ActionFunction = async (args) => { - const { settings } = await loadAndValidateSettings() + const settings = getSettings() const onboardingStatus: OnboardingStatus = settings.app.onboardingStatus.current || '' 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 ( @@ -156,9 +128,17 @@ export const fileLoader: LoaderFunction = async ( ? await getProjectInfo(projectPath) : null + const project = maybeProjectInfo ?? defaultProjectData + + // Fire off the event to load the project settings + settingsActor.send({ + type: 'load.project', + project, + }) + const projectData: IndexLoaderData = { code, - project: maybeProjectInfo ?? defaultProjectData, + project, file: { name: currentFileName || '', path: currentFilePath || '', @@ -197,5 +177,8 @@ export const homeLoader: LoaderFunction = async ({ PATHS.FILE + '/%2F' + BROWSER_PROJECT_NAME + (url.search || '') ) } + settingsActor.send({ + type: 'clear.project', + }) return {} } diff --git a/src/lib/settings/initialSettings.tsx b/src/lib/settings/initialSettings.tsx index f354e1091..c8f9f283e 100644 --- a/src/lib/settings/initialSettings.tsx +++ b/src/lib/settings/initialSettings.tsx @@ -554,3 +554,4 @@ export function createSettings() { } export const settings = createSettings() +export type SettingsType = ReturnType diff --git a/src/lib/settings/settingsUtils.ts b/src/lib/settings/settingsUtils.ts index b847a7afc..35f1c765e 100644 --- a/src/lib/settings/settingsUtils.ts +++ b/src/lib/settings/settingsUtils.ts @@ -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 { defaultAppSettings, defaultProjectSettings, @@ -10,9 +6,8 @@ import { parseProjectSettings, tomlStringify, } from 'lang/wasm' -import { Configuration } from 'wasm-lib/kcl/bindings/Configuration' import { mouseControlsToCameraSystem } from 'lib/cameraControls' -import { appThemeToTheme } from 'lib/theme' +import { BROWSER_PROJECT_NAME } from 'lib/constants' import { getInitialDefaultDir, readAppSettingsFile, @@ -20,9 +15,14 @@ import { writeAppSettingsFile, writeProjectSettingsFile, } from 'lib/desktop' -import { ProjectConfiguration } from 'wasm-lib/kcl/bindings/ProjectConfiguration' -import { BROWSER_PROJECT_NAME } from 'lib/constants' +import { isDesktop } from 'lib/isDesktop' +import { Setting, createSettings, settings } from 'lib/settings/initialSettings' +import { appThemeToTheme } from 'lib/theme' +import { err } from 'lib/trap' 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. @@ -312,6 +312,22 @@ export function getAllCurrentSettings( 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( allSettings: typeof settings, level: SettingsLevel, diff --git a/src/machines/appMachine.ts b/src/machines/appMachine.ts index fd2e757db..745d0a2c3 100644 --- a/src/machines/appMachine.ts +++ b/src/machines/appMachine.ts @@ -1,26 +1,39 @@ -import { ActorRefFrom, createActor, setup } from 'xstate' +import { ActorRefFrom, assign, createActor, setup, spawnChild } from 'xstate' import { authMachine } from './authMachine' import { useSelector } from '@xstate/react' 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({ - actors: { - [ACTOR_IDS.AUTH]: authMachine, - }, + actors: appMachineActors, }).createMachine({ /** @xstate-layout N4IgpgJg5mDOIC5gF8A0IB2B7CdGgAoBbAQwGMALASwzAEp8QAHLWKgFyqw0YA9EAjACZ0AT0FDkU5EA */ id: 'modeling-app', - invoke: [ - { - src: ACTOR_IDS.AUTH, - systemId: ACTOR_IDS.AUTH, - }, + entry: [ + spawnChild(AUTH, { id: AUTH, systemId: AUTH }), + spawnChild(SETTINGS, { + id: SETTINGS, + systemId: SETTINGS, + input: createSettings(), + }), ], }) -export const appActor = createActor(appMachine).start() - -export const authActor = appActor.system.get(ACTOR_IDS.AUTH) as ActorRefFrom< +export const appActor = createActor(appMachine) +export const authActor = appActor.system.get(AUTH) as ActorRefFrom< typeof authMachine > export const useAuthState = () => useSelector(authActor, (state) => state) @@ -28,3 +41,17 @@ export const useToken = () => useSelector(authActor, (state) => state.context.token) export const useUser = () => 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 + }) diff --git a/src/machines/authMachine.ts b/src/machines/authMachine.ts index 3ea7a055f..8e76ba838 100644 --- a/src/machines/authMachine.ts +++ b/src/machines/authMachine.ts @@ -80,7 +80,7 @@ export const authMachine = setup({ ), }, }).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, initial: 'checkIfLoggedIn', context: { diff --git a/src/machines/machineConstants.ts b/src/machines/machineConstants.ts index a1a18ce4f..782d57964 100644 --- a/src/machines/machineConstants.ts +++ b/src/machines/machineConstants.ts @@ -1,3 +1,4 @@ export const ACTOR_IDS = { AUTH: 'auth', -} + SETTINGS: 'settings', +} as const diff --git a/src/machines/settingsMachine.ts b/src/machines/settingsMachine.ts index 690fba1e0..244143374 100644 --- a/src/machines/settingsMachine.ts +++ b/src/machines/settingsMachine.ts @@ -1,6 +1,25 @@ -import { assign, setup } from 'xstate' -import { Themes, getSystemTheme, setThemeClass } from 'lib/theme' -import { createSettings, settings } from 'lib/settings/initialSettings' +import { + AnyActorRef, + 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 { BaseUnit, SetEventTypes, @@ -9,16 +28,39 @@ import { WildcardSetEvent, } from 'lib/settings/settingsTypes' import { + clearSettingsAtLevel, configurationToSettingsPayload, + loadAndValidateSettings, projectConfigurationToSettingsPayload, + saveSettings, setSettingsAtLevel, } 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({ types: { - context: {} as ReturnType, - input: {} as ReturnType, + context: {} as SettingsMachineContext, + input: {} as SettingsMachineContext, events: {} as ( | WildcardSetEvent | SetEventTypes @@ -35,16 +77,219 @@ export const settingsMachine = setup({ level: SettingsLevel } | { type: 'Set all settings'; settings: typeof settings } + | { type: 'load.project'; project?: Project } + | { type: 'clear.project' } ) & { 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(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: { - setEngineTheme: () => {}, - setClientTheme: () => {}, - 'Execute AST': () => {}, - toastSuccess: () => {}, - setClientSideSceneUnits: () => {}, - setAllowOrbitInSketchMode: () => {}, - persistSettings: () => {}, + setClientSideSceneUnits: ({ context, event }) => { + const newBaseUnit = + event.type === 'set.modeling.defaultUnit' + ? (event.data.value as BaseUnit) + : context.modeling.defaultUnit.current + if (!sceneInfra) return + 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 }) => { if (!('level' in event)) return {} @@ -59,9 +304,10 @@ export const settingsMachine = setup({ return newSettings }), - setAllSettings: assign(({ event }) => { - if (!('settings' in event)) return {} - return event.settings + setAllSettings: assign(({ event, context }) => { + if ('settings' in event) return event.settings + else if ('output' in event) return event.output || context + else return context }), setSettingAtLevel: assign(({ context, event }) => { if (!('data' in event)) return {} @@ -94,25 +340,55 @@ export const settingsMachine = setup({ const newCurrentProjection = context.modeling.cameraProjection.current sceneInfra.camControls.setEngineCameraProjection(newCurrentProjection) }, + sendThemeToWatcher: sendTo('watchSystemTheme', ({ context }) => ({ + type: 'update.themeWatcher', + theme: context.app.theme.current, + })), }, }).createMachine({ - /** @xstate-layout N4IgpgJg5mDOIC5QGUwBc0EsB2VYDpMIAbMAYlnXwEMAHW-Ae2wCNHqAnCHKZNatAFdYAbQAMAXUShajWJizNpIAB6IALAFYAnPgBMARgDsBsQDY969QGYjmzQBoQAT0SnrADnwePY61r0PAwNtMyMAX3CnVAweAiJSCio6BjQACzAAWzAAYUZiRg5xKSQQWXlFbGU1BD1PfFtfE3UzTUNNaydXBCD1b209PTEPTTMtdQNNSOj0LFx4knJKNHxMxggwYh58DYAzakFiNABVbAVi5XKFTCVSmusxPXx7bRt1DzMxI3UjD3UutwhAz4MyeHxiV5+AYRKIgGJzPCERZJFYpfDpLJgC6lK6VaqIExPMwWGwdGxBPRmAE9PSafCPMQ-EzWbQ6ELTOGzOJIxLLVbrTbbNKYKBpLaitAAUWgcGxMjk11uoBqVmBH0ZLKCrVs-xciCCwLCvhCjyMFhGHPh3IS5AASnB0AACZYI0SSS4KvF3AlafADRl1YZ2IxiRx6hBtIzPb7abQ+DxGaxmYKWrnzHnkGKO6jEYjOtN4OVlT03KrehAtOnm7Qaup6Ixm6mR6OaR4dAwjM1mVOxdM2lH8jZbXD4WBpRgAd2QAGMc2AAOIcIhF3Gl-EIRPA6yGcyh4whSnU0xGJ5GAat0OfFowma9xH9gBUK5LStUiECdMmfx+mg8hmNTY-PgMYQpoZoxh41g9q6+C0GAHDyLACL5nesBkBAzBgIQ2AAG6MAA1lhcEIZgSFWvMz4VGu5YALTbtYwEnj8HhxnooT1mG3QhmY-TmJ82gGCyjzaJEsLYAK8ClOReAelRr41HRJiMZYvysexdjUuohh+poBiGDuXzGKy0HWossmKmWyqIDR3zAZWLSahM2jWJ04YjDxHbDMmmhaYE3wmemxGIchLpxOZXpWQgNEjMB1h6WEYHqK8ZgJk2EL6N8wR1Cy-gJqJ4RAA */ - id: 'Settings', - initial: 'idle', + /** @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 */ + initial: 'loadingUser', context: ({ input }) => { return { ...createSettings(), ...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: { idle: { - entry: ['setThemeClass', 'setClientSideSceneUnits'], + entry: ['setThemeClass', 'setClientSideSceneUnits', 'sendThemeToWatcher'], on: { '*': { 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': { @@ -126,7 +402,7 @@ export const settingsMachine = setup({ target: 'persisting settings', // No toast - actions: ['setSettingAtLevel'], + actions: ['setSettingAtLevel', 'setThemeColor'], }, 'set.modeling.defaultUnit': { @@ -149,6 +425,7 @@ export const settingsMachine = setup({ 'setThemeClass', 'setEngineTheme', 'setClientTheme', + 'sendThemeToWatcher', ], }, @@ -191,9 +468,11 @@ export const settingsMachine = setup({ 'setThemeClass', 'setEngineTheme', 'setClientSideSceneUnits', + 'setThemeColor', 'Execute AST', 'setClientTheme', 'setAllowOrbitInSketchMode', + 'sendThemeToWatcher', ], }, @@ -203,9 +482,11 @@ export const settingsMachine = setup({ 'setThemeClass', 'setEngineTheme', 'setClientSideSceneUnits', + 'setThemeColor', 'Execute AST', 'setClientTheme', 'setAllowOrbitInSketchMode', + 'sendThemeToWatcher', ], }, @@ -213,12 +494,85 @@ export const settingsMachine = setup({ target: 'persisting settings', 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': { - entry: ['persistSettings'], - always: 'idle', + invoke: { + 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, + } + }, + }, }, }, }) diff --git a/src/routes/Home.tsx b/src/routes/Home.tsx index 20ddc6819..e50ae1486 100644 --- a/src/routes/Home.tsx +++ b/src/routes/Home.tsx @@ -12,11 +12,9 @@ import { getSortFunction, getSortIcon, } from '../lib/sorting' -import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { useHotkeys } from 'react-hotkeys-hook' import { isDesktop } from 'lib/isDesktop' import { kclManager } from 'lib/singletons' -import { useRefreshSettings } from 'hooks/useRefreshSettings' import { LowerRightControls } from 'components/LowerRightControls' import { ProjectSearchBar, useProjectSearch } from 'components/ProjectSearchBar' import { Project } from 'lib/project' @@ -26,6 +24,7 @@ import { useProjectsLoader } from 'hooks/useProjectsLoader' import { useProjectsContext } from 'hooks/useProjectsContext' import { commandBarActor } from 'machines/commandBarMachine' import { useCreateFileLinkQuery } from 'hooks/useCreateFileLinkQueryWatcher' +import { useSettings } from 'machines/appMachine' // This route only opens in the desktop context for now, // 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 { - settings: { context: settings }, - } = useSettingsAuthContext() + const settings = useSettings() // Cancel all KCL executions while on the home page useEffect(() => { diff --git a/src/routes/Onboarding/Camera.tsx b/src/routes/Onboarding/Camera.tsx index 476adb8f4..6fb78b9fd 100644 --- a/src/routes/Onboarding/Camera.tsx +++ b/src/routes/Onboarding/Camera.tsx @@ -1,26 +1,19 @@ import { OnboardingButtons, useDismiss, useNextClick } from '.' import { onboardingPaths } from 'routes/Onboarding/paths' -import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { CameraSystem, cameraMouseDragGuards, cameraSystems, } from 'lib/cameraControls' import { SettingsSection } from 'components/Settings/SettingsSection' +import { settingsActor, useSettings } from 'machines/appMachine' export default function Units() { const dismiss = useDismiss() const next = useNextClick(onboardingPaths.STREAMING) const { - settings: { - send, - state: { - context: { - modeling: { mouseControls }, - }, - }, - }, - } = useSettingsAuthContext() + modeling: { mouseControls }, + } = useSettings() return (
@@ -40,7 +33,7 @@ export default function Units() { className="block w-full px-3 py-1 bg-transparent border border-chalkboard-30" value={mouseControls.current} onChange={(e) => { - send({ + settingsActor.send({ type: 'set.modeling.mouseControls', data: { level: 'user', diff --git a/src/routes/Onboarding/Introduction.tsx b/src/routes/Onboarding/Introduction.tsx index ada5d8ab3..b4a9e052e 100644 --- a/src/routes/Onboarding/Introduction.tsx +++ b/src/routes/Onboarding/Introduction.tsx @@ -1,6 +1,5 @@ import { OnboardingButtons, useDemoCode } from '.' import { onboardingPaths } from 'routes/Onboarding/paths' -import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { Themes, getSystemTheme } from 'lib/theme' import { bracket } from 'lib/exampleKcl' import { createAndOpenNewTutorialProject } from 'lib/desktopFS' @@ -14,6 +13,7 @@ import { PATHS } from 'lib/paths' import { useFileContext } from 'hooks/useFileContext' import { useLspContext } from 'components/LspProvider' import { reportRejection } from 'lib/trap' +import { useSettings } from 'machines/appMachine' /** * Show either a welcome screen or a warning screen @@ -120,14 +120,8 @@ function OnboardingIntroductionInner() { useDemoCode() const { - settings: { - state: { - context: { - app: { theme }, - }, - }, - }, - } = useSettingsAuthContext() + app: { theme }, + } = useSettings() const getLogoTheme = () => theme.current === Themes.Light || (theme.current === Themes.System && getSystemTheme() === Themes.Light) diff --git a/src/routes/Onboarding/ParametricModeling.tsx b/src/routes/Onboarding/ParametricModeling.tsx index 0fa8428ae..ae13f1ef1 100644 --- a/src/routes/Onboarding/ParametricModeling.tsx +++ b/src/routes/Onboarding/ParametricModeling.tsx @@ -1,21 +1,17 @@ import { OnboardingButtons, useDemoCode } from '.' import { onboardingPaths } from 'routes/Onboarding/paths' import { Themes, getSystemTheme } from 'lib/theme' -import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { bracketThicknessCalculationLine } from 'lib/exampleKcl' import { isDesktop } from 'lib/isDesktop' +import { useSettings } from 'machines/appMachine' export default function OnboardingParametricModeling() { useDemoCode() const { - settings: { - context: { - app: { - theme: { current: theme }, - }, - }, + app: { + theme: { current: theme }, }, - } = useSettingsAuthContext() + } = useSettings() const getImageTheme = () => theme === Themes.Light || (theme === Themes.System && getSystemTheme() === Themes.Light) diff --git a/src/routes/Onboarding/Units.tsx b/src/routes/Onboarding/Units.tsx index ad469b983..7ec94ac34 100644 --- a/src/routes/Onboarding/Units.tsx +++ b/src/routes/Onboarding/Units.tsx @@ -4,19 +4,14 @@ import { ActionButton } from 'components/ActionButton' import { SettingsSection } from 'components/Settings/SettingsSection' import { useDismiss, useNextClick } from '.' import { onboardingPaths } from 'routes/Onboarding/paths' -import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' +import { settingsActor, useSettings } from 'machines/appMachine' export default function Units() { const dismiss = useDismiss() const next = useNextClick(onboardingPaths.CAMERA) const { - settings: { - send, - context: { - modeling: { defaultUnit }, - }, - }, - } = useSettingsAuthContext() + modeling: { defaultUnit }, + } = useSettings() return (
@@ -31,7 +26,7 @@ export default function Units() { className="block w-full px-3 py-1 border border-chalkboard-30 bg-transparent" value={defaultUnit.user} onChange={(e) => { - send({ + settingsActor.send({ type: 'set.modeling.defaultUnit', data: { level: 'user', diff --git a/src/routes/Onboarding/index.tsx b/src/routes/Onboarding/index.tsx index 16d4f32e8..3d2daef9c 100644 --- a/src/routes/Onboarding/index.tsx +++ b/src/routes/Onboarding/index.tsx @@ -5,7 +5,6 @@ import Camera from './Camera' import Sketching from './Sketching' import { useCallback, useEffect } from 'react' import makeUrlPathRelative from '../../lib/makeUrlPathRelative' -import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import Streaming from './Streaming' import CodeEditor from './CodeEditor' import ParametricModeling from './ParametricModeling' @@ -26,9 +25,10 @@ import { reportRejection } from 'lib/trap' import { useNetworkContext } from 'hooks/useNetworkContext' import { NetworkHealthState } from 'hooks/useNetworkStatus' import { EngineConnectionStateType } from 'lang/std/engineConnection' +import { settingsActor, useSettings } from 'machines/appMachine' +import { useSelector } from '@xstate/react' import { CustomIcon } from 'components/CustomIcon' import Tooltip from 'components/Tooltip' -import { commandBarActor } from 'machines/commandBarMachine' 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' @@ -112,25 +112,24 @@ export function useDemoCode() { export function useNextClick(newStatus: string) { const filePath = useAbsoluteFilePath() - const { - settings: { send }, - } = useSettingsAuthContext() const navigate = useNavigate() return useCallback(() => { - send({ + settingsActor.send({ type: 'set.app.onboardingStatus', data: { level: 'user', value: newStatus }, }) navigate(filePath + PATHS.ONBOARDING.INDEX.slice(0, -1) + newStatus) - }, [filePath, newStatus, send, navigate]) + }, [filePath, newStatus, settingsActor.send, navigate]) } export function useDismiss() { const filePath = useAbsoluteFilePath() - const { - settings: { state, send }, - } = useSettingsAuthContext() + const settings = useSettings() + const send = settingsActor.send + const isSettingsActorIdle = useSelector(settingsActor, (s) => + s.matches('idle') + ) const navigate = useNavigate() const settingsCallback = useCallback(() => { @@ -146,12 +145,17 @@ export function useDismiss() { */ useEffect(() => { if ( - state.context.app.onboardingStatus.user === 'dismissed' && - state.matches('idle') + settings.app.onboardingStatus.current === 'dismissed' && + isSettingsActorIdle ) { navigate(filePath) } - }, [filePath, navigate, state]) + }, [ + filePath, + navigate, + isSettingsActorIdle, + settings.app.onboardingStatus.current, + ]) return settingsCallback } diff --git a/src/routes/SignIn.tsx b/src/routes/SignIn.tsx index f78064c90..3772aaf7a 100644 --- a/src/routes/SignIn.tsx +++ b/src/routes/SignIn.tsx @@ -3,7 +3,6 @@ import { isDesktop } from '../lib/isDesktop' import { VITE_KC_SITE_BASE_URL, VITE_KC_API_BASE_URL } from '../env' import { Themes, getSystemTheme } from '../lib/theme' import { PATHS } from 'lib/paths' -import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { APP_NAME } from 'lib/constants' import { CSSProperties, useCallback, useState } from 'react' import { Logo } from 'components/Logo' @@ -15,6 +14,7 @@ import { toSync } from 'lib/utils' import { reportRejection } from 'lib/trap' import toast from 'react-hot-toast' import { authActor } from 'machines/appMachine' +import { useSettings } from 'machines/appMachine' const subtleBorder = '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 [userCode, setUserCode] = useState('') const { - settings: { - state: { - context: { - app: { theme }, - }, - }, - }, - } = useSettingsAuthContext() + app: { theme }, + } = useSettings() const signInUrl = `${VITE_KC_SITE_BASE_URL}${ PATHS.SIGN_IN }?callbackUrl=${encodeURIComponent(