diff --git a/e2e/playwright/flow-tests.spec.ts b/e2e/playwright/flow-tests.spec.ts index 459daa59c..668a1a83c 100644 --- a/e2e/playwright/flow-tests.spec.ts +++ b/e2e/playwright/flow-tests.spec.ts @@ -3,8 +3,8 @@ import { secrets } from './secrets' import { getUtils } from './test-utils' import waitOn from 'wait-on' import { Themes } from '../../src/lib/theme' +import { initialSettings } from '../../src/lib/settings/initialSettings' import { roundOff } from 'lib/utils' -import { platform } from 'node:os' /* debug helper: unfortunately we do rely on exact coord mouse clicks in a few places @@ -516,6 +516,55 @@ test('Auto complete works', async ({ page }) => { |> xLine(5, %) // lin`) }) +// Stored settings validation test +test('Stored settings are validated and fall back to defaults', async ({ + page, + context, +}) => { + // Override beforeEach test setup + // with corrupted settings + await context.addInitScript(async () => { + const storedSettings = JSON.parse( + localStorage.getItem('SETTINGS_PERSIST_KEY') || '{}' + ) + + // Corrupt the settings + storedSettings.baseUnit = 'invalid' + storedSettings.cameraControls = `() => alert('hack the planet')` + storedSettings.defaultDirectory = 123 + storedSettings.defaultProjectName = false + + localStorage.setItem('SETTINGS_PERSIST_KEY', JSON.stringify(storedSettings)) + }) + + await page.setViewportSize({ width: 1200, height: 500 }) + await page.goto('/', { waitUntil: 'domcontentloaded' }) + + // Check the toast appeared + await expect( + page.getByText(`Error validating persisted settings:`, { + exact: false, + }) + ).toBeVisible() + + // Check the settings were reset + const storedSettings = JSON.parse( + await page.evaluate( + () => localStorage.getItem('SETTINGS_PERSIST_KEY') || '{}' + ) + ) + await expect(storedSettings.baseUnit).toBe(initialSettings.baseUnit) + await expect(storedSettings.cameraControls).toBe( + initialSettings.cameraControls + ) + await expect(storedSettings.defaultDirectory).toBe( + initialSettings.defaultDirectory + ) + await expect(storedSettings.defaultProjectName).toBe( + initialSettings.defaultProjectName + ) +}) + // Onboarding tests test('Onboarding redirects and code updating', async ({ page, context }) => { const u = getUtils(page) diff --git a/e2e/playwright/snapshot-tests.spec.ts-snapshots/extrude-on-each-default-plane-should-be-stable-1-Google-Chrome-linux.png b/e2e/playwright/snapshot-tests.spec.ts-snapshots/extrude-on-each-default-plane-should-be-stable-1-Google-Chrome-linux.png index 5bd90e3d9..83c7f0d1a 100644 Binary files a/e2e/playwright/snapshot-tests.spec.ts-snapshots/extrude-on-each-default-plane-should-be-stable-1-Google-Chrome-linux.png and b/e2e/playwright/snapshot-tests.spec.ts-snapshots/extrude-on-each-default-plane-should-be-stable-1-Google-Chrome-linux.png differ diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index b80b0038e..d685fcb92 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -7,6 +7,7 @@ use std::io::Read; use anyhow::Result; use oauth2::TokenResponse; +use std::process::Command; use tauri::{InvokeError, Manager}; const DEFAULT_HOST: &str = "https://api.kittycad.io"; @@ -142,6 +143,25 @@ async fn get_user( Ok(user_info) } +/// Open the selected path in the system file manager. +/// From this GitHub comment: https://github.com/tauri-apps/tauri/issues/4062#issuecomment-1338048169 +/// But with the Linux support removed since we don't need it for now. +#[tauri::command] +fn show_in_folder(path: String) { + #[cfg(target_os = "windows")] + { + Command::new("explorer") + .args(["/select,", &path]) // The comma after select is not a typo + .spawn() + .unwrap(); + } + + #[cfg(target_os = "macos")] + { + Command::new("open").args(["-R", &path]).spawn().unwrap(); + } +} + fn main() { tauri::Builder::default() .setup(|_app| { @@ -158,7 +178,8 @@ fn main() { get_user, login, read_toml, - read_txt_file + read_txt_file, + show_in_folder, ]) .plugin(tauri_plugin_fs_extra::init()) .run(tauri::generate_context!()) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 256cc0255..d46ff92e9 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -23,7 +23,10 @@ "fs": { "scope": [ "$HOME/**/*", - "$APPDATA/**/*" + "$APPCONFIG", + "$APPCONFIG/**/*", + "$DOCUMENT", + "$DOCUMENT/**/*" ], "all": true }, @@ -60,7 +63,7 @@ "icons/icon.icns", "icons/icon.ico" ], - "identifier": "io.kittycad.modeling-app", + "identifier": "dev.zoo.modeling-app", "longDescription": "", "macOS": { "entitlements": null, diff --git a/src/App.tsx b/src/App.tsx index 4ab1df50f..3f092babf 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -33,8 +33,10 @@ import { useModelingContext } from 'hooks/useModelingContext' import { useAbsoluteFilePath } from 'hooks/useAbsoluteFilePath' import { isTauri } from 'lib/isTauri' import { useLspContext } from 'components/LspProvider' +import { useValidateSettings } from 'hooks/useValidateSettings' export function App() { + useValidateSettings() const { project, file } = useLoaderData() as IndexLoaderData const navigate = useNavigate() const filePath = useAbsoluteFilePath() diff --git a/src/Router.tsx b/src/Router.tsx index ae04d4867..faf638d9e 100644 --- a/src/Router.tsx +++ b/src/Router.tsx @@ -12,68 +12,54 @@ import SignIn from './routes/SignIn' import { Auth } from './Auth' import { isTauri } from './lib/isTauri' import Home from './routes/Home' -import { FileEntry, readDir, readTextFile } from '@tauri-apps/api/fs' import makeUrlPathRelative from './lib/makeUrlPathRelative' -import { - initializeProjectDirectory, - isProjectDirectory, - PROJECT_ENTRYPOINT, -} from './lib/tauriFS' -import { metadata } from 'tauri-plugin-fs-extra-api' -import DownloadAppBanner from './components/DownloadAppBanner' -import { WasmErrBanner } from './components/WasmErrBanner' -import { SettingsAuthProvider } from './components/SettingsAuthProvider' -import { settingsMachine } from './machines/settingsMachine' -import { SETTINGS_PERSIST_KEY } from './lib/settings' -import { ContextFrom } from 'xstate' -import CommandBarProvider, { - CommandBar, -} from 'components/CommandBar/CommandBar' +import DownloadAppBanner from 'components/DownloadAppBanner' +import { WasmErrBanner } from 'components/WasmErrBanner' +import { CommandBar } from 'components/CommandBar/CommandBar' import ModelingMachineProvider from 'components/ModelingMachineProvider' -import { KclContextProvider, kclManager } from 'lang/KclSingleton' import FileMachineProvider from 'components/FileMachineProvider' -import { sep } from '@tauri-apps/api/path' import { paths } from 'lib/paths' -import { IndexLoaderData, HomeLoaderData } from 'lib/types' -import { fileSystemManager } from 'lang/std/fileSystemManager' +import { + fileLoader, + homeLoader, + indexLoader, + onboardingRedirectLoader, +} from 'lib/routeLoaders' +import { CommandBarProvider } from 'components/CommandBar/CommandBarProvider' +import SettingsAuthProvider from 'components/SettingsAuthProvider' import LspProvider from 'components/LspProvider' +import { KclContextProvider } from 'lang/KclSingleton' export const BROWSER_FILE_NAME = 'new' -type CreateBrowserRouterArg = Parameters[0] - -const addGlobalContextToElements = ( - routes: CreateBrowserRouterArg -): CreateBrowserRouterArg => - routes.map((route) => - 'element' in route - ? { - ...route, - element: ( - - - {route.element} - - - ), - } - : route - ) - -const router = createBrowserRouter( - addGlobalContextToElements([ - { - path: paths.INDEX, - loader: () => - isTauri() - ? redirect(paths.HOME) - : redirect(paths.FILE + '/' + BROWSER_FILE_NAME), - errorElement: , - }, - { - path: paths.FILE + '/:id', - element: ( +const router = createBrowserRouter([ + { + loader: indexLoader, + id: paths.INDEX, + element: ( + + + + + + + + + ), + children: [ + { + path: paths.INDEX, + loader: () => + isTauri() + ? redirect(paths.HOME) + : redirect(paths.FILE + '/' + BROWSER_FILE_NAME), + errorElement: , + }, + { + loader: fileLoader, + id: paths.FILE, + element: ( @@ -85,155 +71,52 @@ const router = createBrowserRouter( {!isTauri() && import.meta.env.PROD && } - - ), - id: paths.FILE, - loader: async ({ - request, - params, - }): Promise => { - const fetchedStorage = localStorage?.getItem(SETTINGS_PERSIST_KEY) - const persistedSettings = JSON.parse(fetchedStorage || '{}') as Partial< - ContextFrom - > - - const status = persistedSettings.onboardingStatus || '' - const notEnRouteToOnboarding = !request.url.includes( - paths.ONBOARDING.INDEX - ) - // '' is the initial state, 'done' and 'dismissed' are the final states - const hasValidOnboardingStatus = - status.length === 0 || !(status === 'done' || status === 'dismissed') - const shouldRedirectToOnboarding = - notEnRouteToOnboarding && hasValidOnboardingStatus - - if (shouldRedirectToOnboarding) { - return redirect( - makeUrlPathRelative(paths.ONBOARDING.INDEX) + status.slice(1) - ) - } - - const defaultDir = persistedSettings.defaultDirectory || '' - - if (params.id && params.id !== BROWSER_FILE_NAME) { - const decodedId = decodeURIComponent(params.id) - const projectAndFile = decodedId.replace(defaultDir + sep, '') - const firstSlashIndex = projectAndFile.indexOf(sep) - const projectName = projectAndFile.slice(0, firstSlashIndex) - const projectPath = defaultDir + sep + projectName - const currentFileName = projectAndFile.slice(firstSlashIndex + 1) - - if (firstSlashIndex === -1 || !currentFileName) - return redirect( - `${paths.FILE}/${encodeURIComponent( - `${params.id}${sep}${PROJECT_ENTRYPOINT}` - )}` - ) - - // Note that PROJECT_ENTRYPOINT is hardcoded until we support multiple files - const code = await readTextFile(decodedId) - const entrypointMetadata = await metadata( - projectPath + sep + PROJECT_ENTRYPOINT - ) - const children = await readDir(projectPath, { recursive: true }) - kclManager.setCodeAndExecute(code, false) - - // Set the file system manager to the project path - // So that WASM gets an updated path for operations - fileSystemManager.dir = projectPath - - return { - code, - project: { - name: projectName, - path: projectPath, - children, - entrypointMetadata, - }, - file: { - name: currentFileName, - path: params.id, - }, - } - } - - return { - code: '', - } + ), + children: [ + { + path: paths.FILE + '/:id', + loader: onboardingRedirectLoader, + children: [ + { + path: makeUrlPathRelative(paths.SETTINGS), + loader: indexLoader, // very rare someone will load into settings first, but it's possible in the browser + element: , + }, + { + path: makeUrlPathRelative(paths.ONBOARDING.INDEX), + element: , + loader: indexLoader, // very rare someone will load into settings first, but it's possible in the browser + children: onboardingRoutes, + }, + ], + }, + ], }, - children: [ - { - path: makeUrlPathRelative(paths.SETTINGS), - element: , - }, - { - path: makeUrlPathRelative(paths.ONBOARDING.INDEX), - element: , - children: onboardingRoutes, - }, - ], - }, - { - path: paths.HOME, - element: ( - - - - - - ), - loader: async (): Promise => { - if (!isTauri()) { - return redirect(paths.FILE + '/' + BROWSER_FILE_NAME) - } - const fetchedStorage = localStorage?.getItem(SETTINGS_PERSIST_KEY) - const persistedSettings = JSON.parse(fetchedStorage || '{}') as Partial< - ContextFrom - > - const projectDir = await initializeProjectDirectory( - persistedSettings.defaultDirectory || '' - ) - let newDefaultDirectory: string | undefined = undefined - if (projectDir !== persistedSettings.defaultDirectory) { - localStorage.setItem( - SETTINGS_PERSIST_KEY, - JSON.stringify({ - ...persistedSettings, - defaultDirectory: projectDir, - }) - ) - newDefaultDirectory = projectDir - } - const projectsNoMeta = (await readDir(projectDir)).filter( - isProjectDirectory - ) - const projects = await Promise.all( - projectsNoMeta.map(async (p: FileEntry) => ({ - entrypointMetadata: await metadata( - p.path + sep + PROJECT_ENTRYPOINT - ), - ...p, - })) - ) - - return { - projects, - newDefaultDirectory, - } + { + path: paths.HOME, + element: ( + + + + + + ), + id: paths.HOME, + loader: homeLoader, + children: [ + { + path: makeUrlPathRelative(paths.SETTINGS), + element: , + }, + ], }, - children: [ - { - path: makeUrlPathRelative(paths.SETTINGS), - element: , - }, - ], - }, - { - path: paths.SIGN_IN, - element: , - }, - ]) -) + { + path: paths.SIGN_IN, + element: , + }, + ], + }, +]) /** * All routes in the app, used in src/index.tsx diff --git a/src/clientSideScene/sceneInfra.ts b/src/clientSideScene/sceneInfra.ts index b91f972b8..4a65623fc 100644 --- a/src/clientSideScene/sceneInfra.ts +++ b/src/clientSideScene/sceneInfra.ts @@ -24,7 +24,8 @@ import { useModelingContext } from 'hooks/useModelingContext' import * as TWEEN from '@tweenjs/tween.js' import { SourceRange } from 'lang/wasm' import { Axis } from 'lib/selections' -import { BaseUnit, SETTINGS_PERSIST_KEY } from 'lib/settings' +import { type BaseUnit } from 'lib/settings/settingsTypes' +import { SETTINGS_PERSIST_KEY } from 'lib/constants' import { CameraControls } from './CameraControls' type SendType = ReturnType['send'] diff --git a/src/components/CommandBar/CommandBar.tsx b/src/components/CommandBar/CommandBar.tsx index 17fdb7008..bec598108 100644 --- a/src/components/CommandBar/CommandBar.tsx +++ b/src/components/CommandBar/CommandBar.tsx @@ -1,61 +1,14 @@ import { Dialog, Popover, Transition } from '@headlessui/react' -import { Fragment, createContext, useEffect } from 'react' +import { Fragment, useEffect } from 'react' import { useHotkeys } from 'react-hotkeys-hook' import { useCommandsContext } from 'hooks/useCommandsContext' -import { useMachine } from '@xstate/react' -import { commandBarMachine } from 'machines/commandBarMachine' -import { EventFrom, StateFrom } from 'xstate' import CommandBarArgument from './CommandBarArgument' import CommandComboBox from '../CommandComboBox' -import { useLocation } from 'react-router-dom' import CommandBarReview from './CommandBarReview' - -type CommandsContextType = { - commandBarState: StateFrom - commandBarSend: (event: EventFrom) => void -} - -export const CommandsContext = createContext({ - commandBarState: commandBarMachine.initialState, - commandBarSend: () => {}, -}) - -export const CommandBarProvider = ({ - children, -}: { - children: React.ReactNode -}) => { - const { pathname } = useLocation() - const [commandBarState, commandBarSend] = useMachine(commandBarMachine, { - devTools: true, - guards: { - 'Command has no arguments': (context, _event) => { - return ( - !context.selectedCommand?.args || - Object.keys(context.selectedCommand?.args).length === 0 - ) - }, - }, - }) - - // Close the command bar when navigating - useEffect(() => { - commandBarSend({ type: 'Close' }) - }, [pathname]) - - return ( - - {children} - - ) -} +import { useLocation } from 'react-router-dom' export const CommandBar = () => { + const { pathname } = useLocation() const { commandBarState, commandBarSend } = useCommandsContext() const { context: { selectedCommand, currentArgument, commands }, @@ -63,6 +16,12 @@ export const CommandBar = () => { const isSelectionArgument = currentArgument?.inputType === 'selection' const WrapperComponent = isSelectionArgument ? Popover : Dialog + // Close the command bar when navigating + useEffect(() => { + commandBarSend({ type: 'Close' }) + }, [pathname]) + + // Hook up keyboard shortcuts useHotkeys(['mod+k', 'mod+/'], () => { if (commandBarState.context.commands.length === 0) return if (commandBarState.matches('Closed')) { @@ -164,4 +123,4 @@ export const CommandBar = () => { ) } -export default CommandBarProvider +export default CommandBar diff --git a/src/components/CommandBar/CommandBarProvider.tsx b/src/components/CommandBar/CommandBarProvider.tsx new file mode 100644 index 000000000..3baf439fd --- /dev/null +++ b/src/components/CommandBar/CommandBarProvider.tsx @@ -0,0 +1,43 @@ +import { useMachine } from '@xstate/react' +import { commandBarMachine } from 'machines/commandBarMachine' +import { createContext } from 'react' +import { EventFrom, StateFrom } from 'xstate' + +type CommandsContextType = { + commandBarState: StateFrom + commandBarSend: (event: EventFrom) => void +} + +export const CommandsContext = createContext({ + commandBarState: commandBarMachine.initialState, + commandBarSend: () => {}, +}) + +export const CommandBarProvider = ({ + children, +}: { + children: React.ReactNode +}) => { + const [commandBarState, commandBarSend] = useMachine(commandBarMachine, { + devTools: true, + guards: { + 'Command has no arguments': (context, _event) => { + return ( + !context.selectedCommand?.args || + Object.keys(context.selectedCommand?.args).length === 0 + ) + }, + }, + }) + + return ( + + {children} + + ) +} diff --git a/src/components/NetworkHealthIndicator.test.tsx b/src/components/NetworkHealthIndicator.test.tsx index 9761d102a..069bb2501 100644 --- a/src/components/NetworkHealthIndicator.test.tsx +++ b/src/components/NetworkHealthIndicator.test.tsx @@ -1,7 +1,7 @@ import { fireEvent, render, screen } from '@testing-library/react' import { BrowserRouter } from 'react-router-dom' -import { SettingsAuthProvider } from './SettingsAuthProvider' -import CommandBarProvider from './CommandBar/CommandBar' +import { SettingsAuthProviderJest } from './SettingsAuthProvider' +import { CommandBarProvider } from './CommandBar/CommandBarProvider' import { NETWORK_HEALTH_TEXT, NetworkHealthIndicator, @@ -13,7 +13,7 @@ function TestWrap({ children }: { children: React.ReactNode }) { return ( - {children} + {children} ) diff --git a/src/components/ProjectSidebarMenu.test.tsx b/src/components/ProjectSidebarMenu.test.tsx index da3670e3f..c1fc2d8b1 100644 --- a/src/components/ProjectSidebarMenu.test.tsx +++ b/src/components/ProjectSidebarMenu.test.tsx @@ -2,9 +2,9 @@ import { fireEvent, render, screen } from '@testing-library/react' import { BrowserRouter } from 'react-router-dom' import ProjectSidebarMenu from './ProjectSidebarMenu' import { type ProjectWithEntryPointMetadata } from 'lib/types' -import { SettingsAuthProvider } from './SettingsAuthProvider' +import { SettingsAuthProviderJest } from './SettingsAuthProvider' import { APP_NAME } from 'lib/constants' -import { vi } from 'vitest' +import { CommandBarProvider } from './CommandBar/CommandBarProvider' const now = new Date() const projectWellFormed = { @@ -41,9 +41,11 @@ describe('ProjectSidebarMenu tests', () => { test('Renders the project name', () => { render( - - - + + + + + ) @@ -60,9 +62,11 @@ describe('ProjectSidebarMenu tests', () => { test('Renders app name if given no project', () => { render( - - - + + + + + ) @@ -74,9 +78,14 @@ describe('ProjectSidebarMenu tests', () => { test('Renders as a link if set to do so', () => { render( - - - + + + + + ) diff --git a/src/components/SettingsAuthProvider.tsx b/src/components/SettingsAuthProvider.tsx index b1bfba366..4e84ac730 100644 --- a/src/components/SettingsAuthProvider.tsx +++ b/src/components/SettingsAuthProvider.tsx @@ -1,12 +1,15 @@ import { useMachine } from '@xstate/react' -import { useNavigate } from 'react-router-dom' +import { useNavigate, useRouteLoaderData } from 'react-router-dom' import { paths } from 'lib/paths' import { authMachine, TOKEN_PERSIST_KEY } from '../machines/authMachine' import withBaseUrl from '../lib/withBaseURL' -import React, { createContext, useEffect, useRef } from 'react' +import React, { createContext, useEffect } from 'react' import useStateMachineCommands from '../hooks/useStateMachineCommands' import { settingsMachine } from 'machines/settingsMachine' -import { SETTINGS_PERSIST_KEY } from 'lib/settings' +import { + fallbackLoadedSettings, + validateSettings, +} from 'lib/settings/settingsUtils' import { toast } from 'react-hot-toast' import { setThemeClass, Themes } from 'lib/theme' import { @@ -20,6 +23,7 @@ import { isTauri } from 'lib/isTauri' import { settingsCommandBarConfig } from 'lib/commandBarConfigs/settingsCommandConfig' import { authCommandBarConfig } from 'lib/commandBarConfigs/authCommandConfig' import { sceneInfra } from 'clientSideScene/sceneInfra' +import { kclManager } from 'lang/KclSingleton' type MachineContext = { state: StateFrom @@ -27,7 +31,7 @@ type MachineContext = { send: Prop, 'send'> } -type GlobalContext = { +type SettingsAuthContextType = { auth: MachineContext settings: MachineContext } @@ -38,37 +42,66 @@ type GlobalContext = { let settingsStateRef: (typeof settingsMachine)['context'] | undefined export const getSettingsState = () => settingsStateRef -export const SettingsAuthContext = createContext({} as GlobalContext) +export const SettingsAuthContext = createContext({} as SettingsAuthContextType) export const SettingsAuthProvider = ({ children, }: { children: React.ReactNode }) => { - const navigate = useNavigate() + const loadedSettings = useRouteLoaderData(paths.INDEX) as Awaited< + ReturnType + > + return ( + + {children} + + ) +} - // Settings machine setup - const retrievedSettings = useRef( - localStorage?.getItem(SETTINGS_PERSIST_KEY) || '{}' - ) - const persistedSettings = Object.assign( - settingsMachine.initialState.context, - JSON.parse(retrievedSettings.current) as Partial< - (typeof settingsMachine)['context'] - > +// 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 = fallbackLoadedSettings + return ( + + {children} + ) +} + +export const SettingsAuthProviderBase = ({ + children, + loadedSettings, +}: { + children: React.ReactNode + loadedSettings: Awaited> +}) => { + const { settings: initialLoadedContext } = loadedSettings + const navigate = useNavigate() const [settingsState, settingsSend, settingsActor] = useMachine( settingsMachine, { - context: persistedSettings, + context: initialLoadedContext, actions: { + setClientSideSceneUnits: (context, event) => { + const newBaseUnit = + event.type === 'Set Base Unit' + ? event.data.baseUnit + : context.baseUnit + sceneInfra.baseUnit = newBaseUnit + }, toastSuccess: (context, event) => { const truncatedNewValue = 'data' in event && event.data instanceof Object - ? (String( - context[Object.keys(event.data)[0] as keyof typeof context] - ).substring(0, 28) as any) + ? (context[Object.keys(event.data)[0] as keyof typeof context] + .toString() + .substring(0, 28) as any) : undefined toast.success( event.type + @@ -79,6 +112,7 @@ export const SettingsAuthProvider = ({ : '') ) }, + 'Execute AST': () => kclManager.executeAst(), }, } ) @@ -103,7 +137,6 @@ export const SettingsAuthProvider = ({ if (settingsState.context.theme !== 'system') return setThemeClass(e.matches ? Themes.Dark : Themes.Light) } - sceneInfra.baseUnit = settingsState?.context?.baseUnit || 'mm' matcher.addEventListener('change', listener) return () => matcher.removeEventListener('change', listener) diff --git a/src/components/UserSidebarMenu.test.tsx b/src/components/UserSidebarMenu.test.tsx index 66f11ccdd..15b9acaa1 100644 --- a/src/components/UserSidebarMenu.test.tsx +++ b/src/components/UserSidebarMenu.test.tsx @@ -7,8 +7,8 @@ import { createRoutesFromElements, } from 'react-router-dom' import { Models } from '@kittycad/lib' -import { SettingsAuthProvider } from './SettingsAuthProvider' -import CommandBarProvider from './CommandBar/CommandBar' +import { SettingsAuthProviderJest } from './SettingsAuthProvider' +import { CommandBarProvider } from './CommandBar/CommandBarProvider' type User = Models['User_type'] @@ -113,7 +113,7 @@ function TestWrap({ children }: { children: React.ReactNode }) { path="/file/:id" element={ - {children} + {children} } /> diff --git a/src/hooks/useCommandsContext.ts b/src/hooks/useCommandsContext.ts index 393397450..536d00085 100644 --- a/src/hooks/useCommandsContext.ts +++ b/src/hooks/useCommandsContext.ts @@ -1,4 +1,4 @@ -import { CommandsContext } from 'components/CommandBar/CommandBar' +import { CommandsContext } from 'components/CommandBar/CommandBarProvider' import { useContext } from 'react' export const useCommandsContext = () => { diff --git a/src/hooks/useValidateSettings.ts b/src/hooks/useValidateSettings.ts new file mode 100644 index 000000000..98aae9dfc --- /dev/null +++ b/src/hooks/useValidateSettings.ts @@ -0,0 +1,33 @@ +import { validateSettings } from 'lib/settings/settingsUtils' +import { useEffect } from 'react' +import toast from 'react-hot-toast' +import { useRouteLoaderData } from 'react-router-dom' +import { useSettingsAuthContext } from './useSettingsAuthContext' +import { paths } from 'lib/paths' + +// This hook must only be used within a descendant of the SettingsAuthProvider component +// (and, by extension, the Router component). +// Specifically it relies on the Router's indexLoader data and the settingsMachine send function. +// for the settings and validation errors to be available. +export function useValidateSettings() { + const { + settings: { send }, + } = useSettingsAuthContext() + const { settings, errors } = useRouteLoaderData(paths.INDEX) as Awaited< + ReturnType + > + + // If there were validation errors either from local storage or from the file, + // log them to the console and show a toast message to the user. + useEffect(() => { + if (errors.length > 0) { + send('Set All Settings', settings) + const errorMessage = + 'Error validating persisted settings: ' + + errors.join(', ') + + '. Using defaults.' + console.error(errorMessage) + toast.error(errorMessage) + } + }, [errors]) +} diff --git a/src/lib/commandBarConfigs/settingsCommandConfig.ts b/src/lib/commandBarConfigs/settingsCommandConfig.ts index 7061877d1..bdadadf26 100644 --- a/src/lib/commandBarConfigs/settingsCommandConfig.ts +++ b/src/lib/commandBarConfigs/settingsCommandConfig.ts @@ -1,7 +1,12 @@ -import { CommandSetConfig } from '../commandTypes' -import { BaseUnit, Toggle, UnitSystem, baseUnitsUnion } from 'lib/settings' +import { type CommandSetConfig } from '../commandTypes' +import { + type BaseUnit, + type Toggle, + UnitSystem, + baseUnitsUnion, +} from 'lib/settings/settingsTypes' import { settingsMachine } from 'machines/settingsMachine' -import { CameraSystem, cameraSystems } from '../cameraControls' +import { type CameraSystem, cameraSystems } from '../cameraControls' import { Themes } from '../theme' // SETTINGS MACHINE diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 8468266c3..fbed86041 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -1 +1,4 @@ export const APP_NAME = 'Modeling App' +export const DEFAULT_PROJECT_NAME = 'project-$nnn' +export const SETTINGS_PERSIST_KEY = 'SETTINGS_PERSIST_KEY' +export const SETTINGS_FILE_NAME = 'settings.json' diff --git a/src/lib/routeLoaders.ts b/src/lib/routeLoaders.ts new file mode 100644 index 000000000..e122b0f7a --- /dev/null +++ b/src/lib/routeLoaders.ts @@ -0,0 +1,138 @@ +import { ActionFunction, LoaderFunction, redirect } from 'react-router-dom' +import { HomeLoaderData, IndexLoaderData } from './types' +import { isTauri } from './isTauri' +import { paths } from './paths' +import { BROWSER_FILE_NAME } from 'Router' +import { SETTINGS_PERSIST_KEY } from 'lib/constants' +import { loadAndValidateSettings } from './settings/settingsUtils' +import { + getInitialDefaultDir, + getProjectsInDir, + initializeProjectDirectory, + PROJECT_ENTRYPOINT, +} from './tauriFS' +import makeUrlPathRelative from './makeUrlPathRelative' +import { sep } from '@tauri-apps/api/path' +import { readDir, readTextFile } from '@tauri-apps/api/fs' +import { metadata } from 'tauri-plugin-fs-extra-api' +import { kclManager } from 'lang/KclSingleton' +import { fileSystemManager } from 'lang/std/fileSystemManager' + +// The root loader simply resolves the settings and any errors that +// occurred during the settings load +export const indexLoader: LoaderFunction = async (): ReturnType< + typeof loadAndValidateSettings +> => { + return await loadAndValidateSettings() +} + +// Redirect users to the appropriate onboarding page if they haven't completed it +export const onboardingRedirectLoader: ActionFunction = async ({ request }) => { + const { settings } = await loadAndValidateSettings() + const onboardingStatus = settings.onboardingStatus || '' + const notEnRouteToOnboarding = !request.url.includes(paths.ONBOARDING.INDEX) + // '' is the initial state, 'done' and 'dismissed' are the final states + const hasValidOnboardingStatus = + onboardingStatus.length === 0 || + !(onboardingStatus === 'done' || onboardingStatus === 'dismissed') + const shouldRedirectToOnboarding = + notEnRouteToOnboarding && hasValidOnboardingStatus + + if (shouldRedirectToOnboarding) { + return redirect( + makeUrlPathRelative(paths.ONBOARDING.INDEX) + onboardingStatus.slice(1) + ) + } + + return null +} + +export const fileLoader: LoaderFunction = async ({ + params, +}): Promise => { + const { settings } = await loadAndValidateSettings() + + const defaultDir = settings.defaultDirectory || '' + + if (params.id && params.id !== BROWSER_FILE_NAME) { + const decodedId = decodeURIComponent(params.id) + const projectAndFile = decodedId.replace(defaultDir + sep, '') + const firstSlashIndex = projectAndFile.indexOf(sep) + const projectName = projectAndFile.slice(0, firstSlashIndex) + const projectPath = defaultDir + sep + projectName + const currentFileName = projectAndFile.slice(firstSlashIndex + 1) + + if (firstSlashIndex === -1 || !currentFileName) + return redirect( + `${paths.FILE}/${encodeURIComponent( + `${params.id}${sep}${PROJECT_ENTRYPOINT}` + )}` + ) + + // TODO: PROJECT_ENTRYPOINT is hardcoded + // until we support setting a project's entrypoint file + const code = await readTextFile(decodedId) + const entrypointMetadata = await metadata( + projectPath + sep + PROJECT_ENTRYPOINT + ) + const children = await readDir(projectPath, { recursive: true }) + kclManager.setCodeAndExecute(code, false) + + // Set the file system manager to the project path + // So that WASM gets an updated path for operations + fileSystemManager.dir = projectPath + + return { + code, + project: { + name: projectName, + path: projectPath, + children, + entrypointMetadata, + }, + file: { + name: currentFileName, + path: params.id, + }, + } + } + + return { + code: '', + } +} + +// Loads the settings and by extension the projects in the default directory +// and returns them to the Home route, along with any errors that occurred +export const homeLoader: LoaderFunction = async (): Promise< + HomeLoaderData | Response +> => { + if (!isTauri()) { + return redirect(paths.FILE + '/' + BROWSER_FILE_NAME) + } + const { settings } = await loadAndValidateSettings() + const projectDir = await initializeProjectDirectory( + settings.defaultDirectory || (await getInitialDefaultDir()) + ) + + if (projectDir.path) { + if (projectDir.path !== settings.defaultDirectory) { + localStorage.setItem( + SETTINGS_PERSIST_KEY, + JSON.stringify({ + ...settings, + defaultDirectory: projectDir, + }) + ) + } + const projects = await getProjectsInDir(projectDir.path) + + return { + projects, + } + } else { + return { + projects: [], + } + } +} diff --git a/src/lib/settings/initialSettings.ts b/src/lib/settings/initialSettings.ts new file mode 100644 index 000000000..e9c7a27e5 --- /dev/null +++ b/src/lib/settings/initialSettings.ts @@ -0,0 +1,15 @@ +import { DEFAULT_PROJECT_NAME } from 'lib/constants' +import { SettingsMachineContext, UnitSystem } from 'lib/settings/settingsTypes' +import { Themes } from 'lib/theme' + +export const initialSettings: SettingsMachineContext = { + baseUnit: 'mm', + cameraControls: 'KittyCAD', + defaultDirectory: '', + defaultProjectName: DEFAULT_PROJECT_NAME, + onboardingStatus: '', + showDebugPanel: false, + textWrapping: 'On', + theme: Themes.System, + unitSystem: UnitSystem.Metric, +} diff --git a/src/lib/settings.ts b/src/lib/settings/settingsTypes.ts similarity index 52% rename from src/lib/settings.ts rename to src/lib/settings/settingsTypes.ts index 6d18e0e03..54384e877 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings/settingsTypes.ts @@ -1,10 +1,6 @@ import { type Models } from '@kittycad/lib' -import { CameraSystem } from './cameraControls' -import { Themes } from './theme' - -export const DEFAULT_PROJECT_NAME = 'project-$nnn' -export const SETTINGS_PERSIST_KEY = 'SETTINGS_PERSIST_KEY' -export const SETTINGS_FILE_NAME = 'settings.json' +import { type CameraSystem } from '../cameraControls' +import { Themes } from 'lib/theme' export enum UnitSystem { Imperial = 'imperial', @@ -21,6 +17,7 @@ export type BaseUnit = Models['UnitLength_type'] export const baseUnitsUnion = Object.values(baseUnits).flatMap((v) => v) export type Toggle = 'On' | 'Off' +export const toggleAsArray = ['On', 'Off'] as const export type SettingsMachineContext = { baseUnit: BaseUnit @@ -33,15 +30,3 @@ export type SettingsMachineContext = { theme: Themes unitSystem: UnitSystem } - -export const initialSettings: SettingsMachineContext = { - baseUnit: 'mm' as BaseUnit, - cameraControls: 'KittyCAD' as CameraSystem, - defaultDirectory: '', - defaultProjectName: DEFAULT_PROJECT_NAME, - onboardingStatus: '', - showDebugPanel: false, - textWrapping: 'On' as Toggle, - theme: Themes.System, - unitSystem: UnitSystem.Metric, -} diff --git a/src/lib/settings/settingsUtils.ts b/src/lib/settings/settingsUtils.ts new file mode 100644 index 000000000..dafaef761 --- /dev/null +++ b/src/lib/settings/settingsUtils.ts @@ -0,0 +1,88 @@ +import { type CameraSystem, cameraSystems } from '../cameraControls' +import { Themes } from '../theme' +import { isTauri } from '../isTauri' +import { getInitialDefaultDir, readSettingsFile } from '../tauriFS' +import { initialSettings } from 'lib/settings/initialSettings' +import { + type BaseUnit, + baseUnitsUnion, + type Toggle, + type SettingsMachineContext, + toggleAsArray, + UnitSystem, +} from './settingsTypes' +import { SETTINGS_PERSIST_KEY } from '../constants' + +export const fallbackLoadedSettings = { + settings: initialSettings, + errors: [] as (keyof SettingsMachineContext)[], +} + +function isEnumMember>(v: unknown, e: T) { + return Object.values(e).includes(v) +} + +export async function loadAndValidateSettings(): Promise< + ReturnType +> { + const fsSettings = isTauri() ? await readSettingsFile() : {} + const localStorageSettings = JSON.parse( + localStorage?.getItem(SETTINGS_PERSIST_KEY) || '{}' + ) + const mergedSettings = Object.assign({}, localStorageSettings, fsSettings) + + return await validateSettings(mergedSettings) +} + +const settingsValidators: Record< + keyof SettingsMachineContext, + (v: unknown) => boolean +> = { + baseUnit: (v) => baseUnitsUnion.includes(v as BaseUnit), + cameraControls: (v) => cameraSystems.includes(v as CameraSystem), + defaultDirectory: (v) => + typeof v === 'string' && (v.length > 0 || !isTauri()), + defaultProjectName: (v) => typeof v === 'string' && v.length > 0, + onboardingStatus: (v) => typeof v === 'string', + showDebugPanel: (v) => typeof v === 'boolean', + textWrapping: (v) => toggleAsArray.includes(v as Toggle), + theme: (v) => isEnumMember(v, Themes), + unitSystem: (v) => isEnumMember(v, UnitSystem), +} + +function removeInvalidSettingsKeys(s: Record) { + const validKeys = Object.keys(initialSettings) + for (const key in s) { + if (!validKeys.includes(key)) { + console.warn(`Invalid key found in settings: ${key}`) + delete s[key] + } + } + return s +} + +export async function validateSettings(s: Record) { + let settingsNoInvalidKeys = removeInvalidSettingsKeys({ ...s }) + let errors: (keyof SettingsMachineContext)[] = [] + for (const key in settingsNoInvalidKeys) { + const k = key as keyof SettingsMachineContext + if (!settingsValidators[k](settingsNoInvalidKeys[k])) { + delete settingsNoInvalidKeys[k] + errors.push(k) + } + } + + // Here's our chance to insert the fallback defaultDir + const defaultDirectory = isTauri() ? await getInitialDefaultDir() : '' + + const settings = Object.assign( + initialSettings, + { defaultDirectory }, + settingsNoInvalidKeys + ) as SettingsMachineContext + + return { + settings, + errors, + } +} diff --git a/src/lib/tauriFS.ts b/src/lib/tauriFS.ts index efaf8d43c..895685ea3 100644 --- a/src/lib/tauriFS.ts +++ b/src/lib/tauriFS.ts @@ -3,12 +3,16 @@ import { createDir, exists, readDir, + readTextFile, writeTextFile, } from '@tauri-apps/api/fs' -import { documentDir, homeDir, sep } from '@tauri-apps/api/path' +import { appConfigDir, documentDir, homeDir, sep } from '@tauri-apps/api/path' import { isTauri } from './isTauri' import { type ProjectWithEntryPointMetadata } from 'lib/types' import { metadata } from 'tauri-plugin-fs-extra-api' +import { settingsMachine } from 'machines/settingsMachine' +import { ContextFrom } from 'xstate' +import { SETTINGS_FILE_NAME } from 'lib/constants' const PROJECT_FOLDER = 'zoo-modeling-app-projects' export const FILE_EXT = '.kcl' @@ -26,39 +30,100 @@ const RELEVANT_FILE_TYPES = [ 'stl', ] -// Initializes the project directory and returns the path -export async function initializeProjectDirectory(directory: string) { - if (!isTauri()) { - throw new Error( - 'initializeProjectDirectory() can only be called from a Tauri app' - ) +type PathWithPossibleError = { + path: string | null + error: Error | null +} + +export async function getInitialDefaultDir() { + if (!isTauri()) return '' + let dir + try { + dir = await documentDir() + } catch (e) { + dir = `${await homeDir()}Documents/` // for headless Linux (eg. Github Actions) } + return dir + PROJECT_FOLDER +} + +// Initializes the project directory and returns the path +// with any Errors that occurred +export async function initializeProjectDirectory( + directory: string +): Promise { + let returnValue: PathWithPossibleError = { + path: null, + error: null, + } + + if (!isTauri()) return returnValue + if (directory) { - const dirExists = await exists(directory) - if (!dirExists) { - await createDir(directory, { recursive: true }) + returnValue = await testAndCreateDir(directory, returnValue) + } + + // If the directory from settings does not exist or could not be created, + // use the default directory + if (returnValue.path === null) { + const INITIAL_DEFAULT_DIR = await getInitialDefaultDir() + const defaultReturnValue = await testAndCreateDir( + INITIAL_DEFAULT_DIR, + returnValue, + { + exists: 'Error checking default directory.', + create: 'Error creating default directory.', + } + ) + returnValue.path = defaultReturnValue.path + returnValue.error = + returnValue.error === null ? defaultReturnValue.error : returnValue.error + } + + return returnValue +} + +async function testAndCreateDir( + directory: string, + returnValue = { + path: null, + error: null, + } as PathWithPossibleError, + errorMessages = { + exists: + 'Error checking directory at path from saved settings. Using default.', + create: + 'Error creating directory at path from saved settings. Using default.', + } +): Promise { + const dirExists = await exists(directory).catch((e) => { + console.error(`Error checking directory ${directory}. Original error:`, e) + return new Error(errorMessages.exists) + }) + + if (dirExists instanceof Error) { + returnValue.error = dirExists + } else if (dirExists === false) { + const newDirCreated = await createDir(directory, { recursive: true }).catch( + (e) => { + console.error( + `Error creating directory ${directory}. Original error:`, + e + ) + return new Error(errorMessages.create) + } + ) + + if (newDirCreated instanceof Error) { + returnValue.error = newDirCreated + } else { + returnValue.path = directory } - return directory + } else if (dirExists === true) { + returnValue.path = directory } - let docDirectory: string - try { - docDirectory = await documentDir() - } catch (e) { - console.log('error', e) - docDirectory = `${await homeDir()}Documents/` // for headless Linux (eg. Github Actions) - } - - const INITIAL_DEFAULT_DIR = docDirectory + PROJECT_FOLDER - - const defaultDirExists = await exists(INITIAL_DEFAULT_DIR) - - if (!defaultDirExists) { - await createDir(INITIAL_DEFAULT_DIR, { recursive: true }) - } - - return INITIAL_DEFAULT_DIR + return returnValue } export function isProjectDirectory(fileOrDir: Partial) { @@ -309,3 +374,44 @@ function getPaddedIdentifierRegExp() { const escapedIdentifier = escapeRegExpChars(INDEX_IDENTIFIER) return new RegExp(`${escapedIdentifier}(${escapedIdentifier.slice(-1)}*)`) } + +export async function getSettingsFilePath() { + const dir = await appConfigDir() + return dir + SETTINGS_FILE_NAME +} + +export async function writeToSettingsFile( + settings: ContextFrom +) { + return writeTextFile( + await getSettingsFilePath(), + JSON.stringify(settings, null, 2) + ) +} + +export async function readSettingsFile(): Promise | null> { + const dir = await appConfigDir() + const path = dir + SETTINGS_FILE_NAME + const dirExists = await exists(dir) + if (!dirExists) { + await createDir(dir, { recursive: true }) + } + + const settingsExist = dirExists ? await exists(path) : false + + if (!settingsExist) { + console.log(`Settings file does not exist at ${path}`) + await writeToSettingsFile(settingsMachine.initialState.context) + return null + } + + try { + const settings = await readTextFile(path) + return JSON.parse(settings) + } catch (e) { + console.error('Error reading settings file:', e) + return null + } +} diff --git a/src/lib/types.ts b/src/lib/types.ts index ce323c06c..de655fc6d 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -12,5 +12,4 @@ export type ProjectWithEntryPointMetadata = FileEntry & { } export type HomeLoaderData = { projects: ProjectWithEntryPointMetadata[] - newDefaultDirectory?: string } diff --git a/src/machines/commandBarMachine.ts b/src/machines/commandBarMachine.ts index c61f9b030..5e690dd30 100644 --- a/src/machines/commandBarMachine.ts +++ b/src/machines/commandBarMachine.ts @@ -450,6 +450,7 @@ export const commandBarMachine = createMachine( const hasMismatchedDefaultValueType = isRequired && + resolvedDefaultValue !== undefined && typeof argValue !== typeof resolvedDefaultValue && !(argConfig.inputType === 'kcl' || argConfig.skip) const hasInvalidKclValue = diff --git a/src/machines/settingsMachine.ts b/src/machines/settingsMachine.ts index e3497ea5f..332fadf10 100644 --- a/src/machines/settingsMachine.ts +++ b/src/machines/settingsMachine.ts @@ -1,40 +1,41 @@ import { assign, createMachine } from 'xstate' -import { Themes, getSystemTheme, setThemeClass } from '../lib/theme' +import { Themes, getSystemTheme, setThemeClass } from 'lib/theme' import { CameraSystem } from 'lib/cameraControls' +import { isTauri } from 'lib/isTauri' +import { writeToSettingsFile } from 'lib/tauriFS' +import { DEFAULT_PROJECT_NAME, SETTINGS_PERSIST_KEY } from 'lib/constants' import { - BaseUnit, - DEFAULT_PROJECT_NAME, - SETTINGS_PERSIST_KEY, - SettingsMachineContext, - Toggle, UnitSystem, -} from 'lib/settings' - -const kclManagerPromise = import('lang/KclSingleton').then( - (module) => module.kclManager -) + type BaseUnit, + type SettingsMachineContext, + type Toggle, +} from 'lib/settings/settingsTypes' export const settingsMachine = createMachine( { - /** @xstate-layout N4IgpgJg5mDOIC5QGUwBc0EsB2VYDpMIAbMAYlTQAIAVACzAFswBtABgF1FQAHAe1iYsfbNxAAPRAA42+AEwB2KQFYAzGznKAnADZli1QBoQAT2kBGKfm37lOned3nzqgL6vjlLLgJFSFdCoAETAAMwBDAFdiagAFACc+ACswAGNqADlw5nYuJBB+QWFRfMkEABY5fDYa2rra83LjMwQdLWV8BXLyuxlVLU1Ld090bzxCEnJKYLComODMeLS0PniTXLFCoUwRMTK7fC1zNql7NgUjtnKjU0RlBSqpLVUVPVUda60tYZAvHHG-FNAgBVbBCKjIEywNBMDb5LbFPaILqdfRSORsS4qcxXZqIHqyK6qY4XOxsGTKco-P4+Cb+aYAIXCsDAVFBQjhvAE212pWkskUKnUml0+gUNxaqkU+EccnKF1UCnucnMcjcHl+o3+vkmZBofCgUFIMwARpEoFRYuFsGBiJyCtzEXzWrJlGxlKdVFKvfY1XiEBjyvhVOVzBdzu13pYFNStbTAQFqAB5bAmvjheIQf4QtDhNCRWD2hE7EqgfayHTEh7lHQNSxSf1Scz4cpHHFyFVujTKczuDXYPgQOBiGl4TaOktIhAAWg6X3nC4Xp39050sYw2rpYHHRUnztVhPJqmUlIGbEriv9WhrLZ6uibHcqUr7riAA */ + /** @xstate-layout N4IgpgJg5mDOIC5QGUwBc0EsB2VYDpMIAbMAYlTQAIBBY4qyrXWAbQAYBdRUABwHtYmLP2w8QAD0TsANCACe0gL5K5THHkIlylKgCEAhrDBUAqtmEduSEAKEixNqQgCM7AJz52AJgAsAVg8AZgAOEIA2fxd3XzlFBCCXf3xfdlS0kN9vGIiVNXRmTSJSCnQqAGEDAFswACcDCtE0Wv5iNi5xO2FMUXFnWQVlVRB1Fi0S3QARMAAzAwBXYmpJzFqwAGM0flr5K07Bbt6nRH9w-HcXcPcI8PYAdgu0oLiTu+98EPdQ0-8g8N8gu53HkRgUNARijoytM5otqAAFFoAKw21AActUwHsbF0HH1EFkvOxiSTScSXLFBggrsk7r4AuEQuxAd4oiEQaMitpStQAPLYABG-AMtQgGkYaAMaHm7WsfAOeOOCEC+HCiTevlu5JcYReCBCLhSFzc3m8SWJrJcHLBY0hPKoABUwBJqAB1eq8XgabHy+w9Rygfp69jWjDg8ZQ6gOgAWYBqPtsCv9+P17Hw3juIV+Pn87kiGeeVINIXwuf8rPC4WiVZcQVDhQh3N05mEjHksDQcYTuOTSrp+Du5ZC3g8bizbkp8QCaaelwep3YTP8vnr4btDv4UCgpCo0wF8ygVHhBmwYGI3aTR0DiFupfY-giQSC3iflZfepHvnwQV8Lge93cX4qxCO4VGGbB+AgOBxE5eAcUvANJEQABaXwQj1ZCQLvUkmXpFwzStYZYIjfY-SvJDXBHLxa01Stc0yIE7j1NwKW-NUAl8a4-DuZkwKUIA */ id: 'Settings', predictableActionArguments: true, - context: { - baseUnit: 'mm', - cameraControls: 'KittyCAD', - defaultDirectory: '', - defaultProjectName: DEFAULT_PROJECT_NAME, - onboardingStatus: '', - showDebugPanel: false, - textWrapping: 'On', - theme: Themes.System, - unitSystem: UnitSystem.Metric, - } as SettingsMachineContext, + context: {} as SettingsMachineContext, initial: 'idle', states: { idle: { - entry: ['setThemeClass'], + entry: ['setThemeClass', 'setClientSideSceneUnits', 'persistSettings'], on: { + 'Set All Settings': { + actions: [ + assign((context, event) => { + return { + ...context, + ...event.data, + } + }), + 'persistSettings', + 'setThemeClass', + ], + target: 'idle', + internal: true, + }, 'Set Base Unit': { actions: [ assign({ @@ -42,9 +43,8 @@ export const settingsMachine = createMachine( }), 'persistSettings', 'toastSuccess', - async () => { - ;(await kclManagerPromise).executeAst() - }, + 'setClientSideSceneUnits', + 'Execute AST', ], target: 'idle', internal: true, @@ -125,9 +125,7 @@ export const settingsMachine = createMachine( }), 'persistSettings', 'toastSuccess', - async () => { - ;(await kclManagerPromise).executeAst() - }, + 'Execute AST', ], target: 'idle', internal: true, @@ -151,6 +149,7 @@ export const settingsMachine = createMachine( tsTypes: {} as import('./settingsMachine.typegen').Typegen0, schema: { events: {} as + | { type: 'Set All Settings'; data: Partial } | { type: 'Set Base Unit'; data: { baseUnit: BaseUnit } } | { type: 'Set Camera Controls' @@ -174,6 +173,11 @@ export const settingsMachine = createMachine( { actions: { persistSettings: (context) => { + if (isTauri()) { + writeToSettingsFile(context).catch((err) => { + console.error('Error writing settings:', err) + }) + } try { localStorage.setItem(SETTINGS_PERSIST_KEY, JSON.stringify(context)) } catch (e) { diff --git a/src/routes/Home.tsx b/src/routes/Home.tsx index 9f38c8123..94769b716 100644 --- a/src/routes/Home.tsx +++ b/src/routes/Home.tsx @@ -31,21 +31,22 @@ import { import useStateMachineCommands from '../hooks/useStateMachineCommands' import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { useCommandsContext } from 'hooks/useCommandsContext' -import { DEFAULT_PROJECT_NAME } from 'lib/settings' +import { DEFAULT_PROJECT_NAME } from 'lib/constants' import { sep } from '@tauri-apps/api/path' import { homeCommandBarConfig } from 'lib/commandBarConfigs/homeCommandConfig' import { useHotkeys } from 'react-hotkeys-hook' import { isTauri } from 'lib/isTauri' import { kclManager } from 'lang/KclSingleton' import { useLspContext } from 'components/LspProvider' +import { useValidateSettings } from 'hooks/useValidateSettings' // This route only opens in the Tauri desktop context for now, // as defined in Router.tsx, so we can use the Tauri APIs and types. const Home = () => { + useValidateSettings() const { commandBarSend } = useCommandsContext() const navigate = useNavigate() - const { projects: loadedProjects, newDefaultDirectory } = - useLoaderData() as HomeLoaderData + const { projects: loadedProjects } = useLoaderData() as HomeLoaderData const { settings: { context: { defaultDirectory, defaultProjectName }, @@ -54,18 +55,11 @@ const Home = () => { } = useSettingsAuthContext() const { onProjectOpen } = useLspContext() - // Set the default directory if it's been updated - // during the loading of the home page. This is wrapped - // in a single-use effect to avoid a potential infinite loop. + // Cancel all KCL executions while on the home page useEffect(() => { kclManager.cancelAllExecutions() - if (newDefaultDirectory) { - sendToSettings({ - type: 'Set Default Directory', - data: { defaultDirectory: newDefaultDirectory }, - }) - } }, []) + useHotkeys( isTauri() ? 'mod+,' : 'shift+mod+,', () => navigate(paths.HOME + paths.SETTINGS), diff --git a/src/routes/Onboarding/Units.tsx b/src/routes/Onboarding/Units.tsx index d95d35bc5..f14d185ac 100644 --- a/src/routes/Onboarding/Units.tsx +++ b/src/routes/Onboarding/Units.tsx @@ -1,5 +1,9 @@ import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons' -import { BaseUnit, baseUnits, UnitSystem } from 'lib/settings' +import { + type BaseUnit, + baseUnits, + UnitSystem, +} from 'lib/settings/settingsTypes' import { ActionButton } from 'components/ActionButton' import { SettingsSection } from '../Settings' import { Toggle } from 'components/Toggle/Toggle' diff --git a/src/routes/Settings.tsx b/src/routes/Settings.tsx index f4f04be57..b4c6ec219 100644 --- a/src/routes/Settings.tsx +++ b/src/routes/Settings.tsx @@ -2,7 +2,12 @@ import { faArrowRotateBack, faXmark } from '@fortawesome/free-solid-svg-icons' import { ActionButton } from '../components/ActionButton' import { AppHeader } from '../components/AppHeader' import { open } from '@tauri-apps/api/dialog' -import { BaseUnit, DEFAULT_PROJECT_NAME, baseUnits } from 'lib/settings' +import { DEFAULT_PROJECT_NAME, SETTINGS_PERSIST_KEY } from 'lib/constants' +import { + type BaseUnit, + UnitSystem, + baseUnits, +} from 'lib/settings/settingsTypes' import { Toggle } from 'components/Toggle/Toggle' import { useLocation, useNavigate, useRouteLoaderData } from 'react-router-dom' import { useHotkeys } from 'react-hotkeys-hook' @@ -15,17 +20,22 @@ import { cameraSystems, cameraMouseDragGuards, } from 'lib/cameraControls' -import { UnitSystem } from 'lib/settings' import { useDotDotSlash } from 'hooks/useDotDotSlash' import { createNewProject, getNextProjectIndex, getProjectsInDir, + getSettingsFilePath, + initializeProjectDirectory, interpolateProjectNameWithIndex, } from 'lib/tauriFS' +import { initialSettings } from 'lib/settings/initialSettings' import { ONBOARDING_PROJECT_NAME } from './Onboarding' import { sep } from '@tauri-apps/api/path' import { bracket } from 'lib/exampleKcl' +import { isTauri } from 'lib/isTauri' +import { invoke } from '@tauri-apps/api' +import toast from 'react-hot-toast' export const Settings = () => { const APP_VERSION = import.meta.env.PACKAGE_VERSION || 'unknown' @@ -54,8 +64,11 @@ export const Settings = () => { } = useSettingsAuthContext() async function handleDirectorySelection() { + // the `recursive` property added following + // this advice for permissions: https://github.com/tauri-apps/tauri/issues/4851#issuecomment-1210711455 const newDirectory = await open({ directory: true, + recursive: true, defaultPath: defaultDirectory || paths.INDEX, title: 'Choose a new default directory', }) @@ -302,6 +315,59 @@ export const Settings = () => { Replay Onboarding +

+ Your settings are saved in{' '} + {isTauri() + ? 'a file in the app data folder for your OS.' + : "your browser's local storage."}{' '} + {isTauri() ? ( + + + + + ) : ( + + )} +

{/* This uses a Vite plugin, set in vite.config.ts to inject the version from package.json */} diff --git a/src/routes/SignIn.tsx b/src/routes/SignIn.tsx index ef6fb2eb5..97c3580c7 100644 --- a/src/routes/SignIn.tsx +++ b/src/routes/SignIn.tsx @@ -6,8 +6,10 @@ import { Themes, getSystemTheme } from '../lib/theme' import { paths } from 'lib/paths' import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { APP_NAME } from 'lib/constants' +import { useValidateSettings } from 'hooks/useValidateSettings' const SignIn = () => { + useValidateSettings() const getLogoTheme = () => theme === Themes.Light || (theme === Themes.System && getSystemTheme() === Themes.Light) diff --git a/vite.config.ts b/vite.config.ts index 4b0f719f6..bb568f8bb 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -17,6 +17,13 @@ const config = defineConfig({ }, test: { globals: true, + pool: 'forks', + poolOptions: { + forks: { + maxForks: 2, + minForks: 1, + } + }, setupFiles: 'src/setupTests.ts', environment: 'happy-dom', coverage: {