File based settings (#1679)

* Rename GlobalStateContext to SettingsAuthContext

* Naive initial impl of settings persistence to file system

* Update app identifier in tauri config

* Add "show in folder" tauri command

* Load from and save to file system in Tauri app

* Add documents drive to tauri permission scope

* Add recursive prop to default dir selection dialog

* Add success toast to web restore defaults action

* Add a way to validate read-in settings

* Update imports to use separate settings lib file

* Validate localStorage-loaded settings, combine error message

* Add a e2e test for validation

* Clean up state state bugs

* Reverse validation looping so new users don't error

* update settingsMachine typegen to remove conflicts

* Fmt

* Fix TS errors

* Fix import paths, etc post-merge

* Make default length units `mm` and 'metric'

* Rename to SettingsAuth*

* cargo fmt

* Revert Tauri config identifier change

* Update clientSideInfra's baseUnits from settings

* Break apart CommandBar and CommandBarProvider

* Bugfix: don't validate defaultValue when it's not configured

* Allow some TauriFS functions to no-op from browser

* Sidestep circular deps by loading context and kclManager only from React-land

* Update broken import paths

* Separate loaders from Router, load settings on every route

* Break apart settings types, utils, and constants

* Fix Jest tests by decoupling reliance on useLoaderData from SettingsAuthProvider

* Fix up Router loader data with "layout routes"
https://reactrouter.com/en/main/route/route#layout-routes

* Move settings validation and toast to custom hook so the toast renders

* fmt

* Use forks for Vitest
https://vitest.dev/guide/common-errors.html#failed-to-terminate-worker

* $APPCONFIG !== $APPDATA only on Linux
+ change the identifier back since it really doesn't seem to affect app signing

* Debugging on Linux

* Better directory validation, fix reset settings button

* A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu)

* defaultDirectory can be empty in browser

* fmt

* A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu)

* re-trigger CI

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Frank Noirot
2024-03-14 15:56:45 -04:00
committed by GitHub
parent 13cd3e179b
commit f40cdabfdf
30 changed files with 842 additions and 389 deletions

View File

@ -3,8 +3,8 @@ import { secrets } from './secrets'
import { getUtils } from './test-utils' import { getUtils } from './test-utils'
import waitOn from 'wait-on' import waitOn from 'wait-on'
import { Themes } from '../../src/lib/theme' import { Themes } from '../../src/lib/theme'
import { initialSettings } from '../../src/lib/settings/initialSettings'
import { roundOff } from 'lib/utils' 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 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`) |> 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 // Onboarding tests
test('Onboarding redirects and code updating', async ({ page, context }) => { test('Onboarding redirects and code updating', async ({ page, context }) => {
const u = getUtils(page) const u = getUtils(page)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 54 KiB

View File

@ -7,6 +7,7 @@ use std::io::Read;
use anyhow::Result; use anyhow::Result;
use oauth2::TokenResponse; use oauth2::TokenResponse;
use std::process::Command;
use tauri::{InvokeError, Manager}; use tauri::{InvokeError, Manager};
const DEFAULT_HOST: &str = "https://api.kittycad.io"; const DEFAULT_HOST: &str = "https://api.kittycad.io";
@ -142,6 +143,25 @@ async fn get_user(
Ok(user_info) 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() { fn main() {
tauri::Builder::default() tauri::Builder::default()
.setup(|_app| { .setup(|_app| {
@ -158,7 +178,8 @@ fn main() {
get_user, get_user,
login, login,
read_toml, read_toml,
read_txt_file read_txt_file,
show_in_folder,
]) ])
.plugin(tauri_plugin_fs_extra::init()) .plugin(tauri_plugin_fs_extra::init())
.run(tauri::generate_context!()) .run(tauri::generate_context!())

View File

@ -23,7 +23,10 @@
"fs": { "fs": {
"scope": [ "scope": [
"$HOME/**/*", "$HOME/**/*",
"$APPDATA/**/*" "$APPCONFIG",
"$APPCONFIG/**/*",
"$DOCUMENT",
"$DOCUMENT/**/*"
], ],
"all": true "all": true
}, },
@ -60,7 +63,7 @@
"icons/icon.icns", "icons/icon.icns",
"icons/icon.ico" "icons/icon.ico"
], ],
"identifier": "io.kittycad.modeling-app", "identifier": "dev.zoo.modeling-app",
"longDescription": "", "longDescription": "",
"macOS": { "macOS": {
"entitlements": null, "entitlements": null,

View File

@ -33,8 +33,10 @@ import { useModelingContext } from 'hooks/useModelingContext'
import { useAbsoluteFilePath } from 'hooks/useAbsoluteFilePath' import { useAbsoluteFilePath } from 'hooks/useAbsoluteFilePath'
import { isTauri } from 'lib/isTauri' import { isTauri } from 'lib/isTauri'
import { useLspContext } from 'components/LspProvider' import { useLspContext } from 'components/LspProvider'
import { useValidateSettings } from 'hooks/useValidateSettings'
export function App() { export function App() {
useValidateSettings()
const { project, file } = useLoaderData() as IndexLoaderData const { project, file } = useLoaderData() as IndexLoaderData
const navigate = useNavigate() const navigate = useNavigate()
const filePath = useAbsoluteFilePath() const filePath = useAbsoluteFilePath()

View File

@ -12,56 +12,42 @@ import SignIn from './routes/SignIn'
import { Auth } from './Auth' import { Auth } from './Auth'
import { isTauri } from './lib/isTauri' import { isTauri } from './lib/isTauri'
import Home from './routes/Home' import Home from './routes/Home'
import { FileEntry, readDir, readTextFile } from '@tauri-apps/api/fs'
import makeUrlPathRelative from './lib/makeUrlPathRelative' import makeUrlPathRelative from './lib/makeUrlPathRelative'
import { import DownloadAppBanner from 'components/DownloadAppBanner'
initializeProjectDirectory, import { WasmErrBanner } from 'components/WasmErrBanner'
isProjectDirectory, import { CommandBar } from 'components/CommandBar/CommandBar'
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 ModelingMachineProvider from 'components/ModelingMachineProvider' import ModelingMachineProvider from 'components/ModelingMachineProvider'
import { KclContextProvider, kclManager } from 'lang/KclSingleton'
import FileMachineProvider from 'components/FileMachineProvider' import FileMachineProvider from 'components/FileMachineProvider'
import { sep } from '@tauri-apps/api/path'
import { paths } from 'lib/paths' import { paths } from 'lib/paths'
import { IndexLoaderData, HomeLoaderData } from 'lib/types' import {
import { fileSystemManager } from 'lang/std/fileSystemManager' fileLoader,
homeLoader,
indexLoader,
onboardingRedirectLoader,
} from 'lib/routeLoaders'
import { CommandBarProvider } from 'components/CommandBar/CommandBarProvider'
import SettingsAuthProvider from 'components/SettingsAuthProvider'
import LspProvider from 'components/LspProvider' import LspProvider from 'components/LspProvider'
import { KclContextProvider } from 'lang/KclSingleton'
export const BROWSER_FILE_NAME = 'new' export const BROWSER_FILE_NAME = 'new'
type CreateBrowserRouterArg = Parameters<typeof createBrowserRouter>[0] const router = createBrowserRouter([
{
const addGlobalContextToElements = ( loader: indexLoader,
routes: CreateBrowserRouterArg id: paths.INDEX,
): CreateBrowserRouterArg =>
routes.map((route) =>
'element' in route
? {
...route,
element: ( element: (
<CommandBarProvider> <CommandBarProvider>
<KclContextProvider>
<SettingsAuthProvider> <SettingsAuthProvider>
<LspProvider>{route.element}</LspProvider> <LspProvider>
<Outlet />
</LspProvider>
</SettingsAuthProvider> </SettingsAuthProvider>
</KclContextProvider>
</CommandBarProvider> </CommandBarProvider>
), ),
} children: [
: route
)
const router = createBrowserRouter(
addGlobalContextToElements([
{ {
path: paths.INDEX, path: paths.INDEX,
loader: () => loader: () =>
@ -71,9 +57,9 @@ const router = createBrowserRouter(
errorElement: <ErrorPage />, errorElement: <ErrorPage />,
}, },
{ {
path: paths.FILE + '/:id', loader: fileLoader,
id: paths.FILE,
element: ( element: (
<KclContextProvider>
<Auth> <Auth>
<FileMachineProvider> <FileMachineProvider>
<ModelingMachineProvider> <ModelingMachineProvider>
@ -85,94 +71,27 @@ const router = createBrowserRouter(
</FileMachineProvider> </FileMachineProvider>
{!isTauri() && import.meta.env.PROD && <DownloadAppBanner />} {!isTauri() && import.meta.env.PROD && <DownloadAppBanner />}
</Auth> </Auth>
</KclContextProvider>
), ),
id: paths.FILE, children: [
loader: async ({ {
request, path: paths.FILE + '/:id',
params, loader: onboardingRedirectLoader,
}): Promise<IndexLoaderData | Response> => {
const fetchedStorage = localStorage?.getItem(SETTINGS_PERSIST_KEY)
const persistedSettings = JSON.parse(fetchedStorage || '{}') as Partial<
ContextFrom<typeof settingsMachine>
>
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: [ children: [
{ {
path: makeUrlPathRelative(paths.SETTINGS), path: makeUrlPathRelative(paths.SETTINGS),
loader: indexLoader, // very rare someone will load into settings first, but it's possible in the browser
element: <Settings />, element: <Settings />,
}, },
{ {
path: makeUrlPathRelative(paths.ONBOARDING.INDEX), path: makeUrlPathRelative(paths.ONBOARDING.INDEX),
element: <Onboarding />, element: <Onboarding />,
loader: indexLoader, // very rare someone will load into settings first, but it's possible in the browser
children: onboardingRoutes, children: onboardingRoutes,
}, },
], ],
}, },
],
},
{ {
path: paths.HOME, path: paths.HOME,
element: ( element: (
@ -182,45 +101,8 @@ const router = createBrowserRouter(
<CommandBar /> <CommandBar />
</Auth> </Auth>
), ),
loader: async (): Promise<HomeLoaderData | Response> => { id: paths.HOME,
if (!isTauri()) { loader: homeLoader,
return redirect(paths.FILE + '/' + BROWSER_FILE_NAME)
}
const fetchedStorage = localStorage?.getItem(SETTINGS_PERSIST_KEY)
const persistedSettings = JSON.parse(fetchedStorage || '{}') as Partial<
ContextFrom<typeof settingsMachine>
>
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,
}
},
children: [ children: [
{ {
path: makeUrlPathRelative(paths.SETTINGS), path: makeUrlPathRelative(paths.SETTINGS),
@ -232,8 +114,9 @@ const router = createBrowserRouter(
path: paths.SIGN_IN, path: paths.SIGN_IN,
element: <SignIn />, element: <SignIn />,
}, },
],
},
]) ])
)
/** /**
* All routes in the app, used in src/index.tsx * All routes in the app, used in src/index.tsx

View File

@ -24,7 +24,8 @@ import { useModelingContext } from 'hooks/useModelingContext'
import * as TWEEN from '@tweenjs/tween.js' import * as TWEEN from '@tweenjs/tween.js'
import { SourceRange } from 'lang/wasm' import { SourceRange } from 'lang/wasm'
import { Axis } from 'lib/selections' 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' import { CameraControls } from './CameraControls'
type SendType = ReturnType<typeof useModelingContext>['send'] type SendType = ReturnType<typeof useModelingContext>['send']

View File

@ -1,61 +1,14 @@
import { Dialog, Popover, Transition } from '@headlessui/react' 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 { useHotkeys } from 'react-hotkeys-hook'
import { useCommandsContext } from 'hooks/useCommandsContext' 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 CommandBarArgument from './CommandBarArgument'
import CommandComboBox from '../CommandComboBox' import CommandComboBox from '../CommandComboBox'
import { useLocation } from 'react-router-dom'
import CommandBarReview from './CommandBarReview' import CommandBarReview from './CommandBarReview'
import { useLocation } from 'react-router-dom'
type CommandsContextType = {
commandBarState: StateFrom<typeof commandBarMachine>
commandBarSend: (event: EventFrom<typeof commandBarMachine>) => void
}
export const CommandsContext = createContext<CommandsContextType>({
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 (
<CommandsContext.Provider
value={{
commandBarState,
commandBarSend,
}}
>
{children}
</CommandsContext.Provider>
)
}
export const CommandBar = () => { export const CommandBar = () => {
const { pathname } = useLocation()
const { commandBarState, commandBarSend } = useCommandsContext() const { commandBarState, commandBarSend } = useCommandsContext()
const { const {
context: { selectedCommand, currentArgument, commands }, context: { selectedCommand, currentArgument, commands },
@ -63,6 +16,12 @@ export const CommandBar = () => {
const isSelectionArgument = currentArgument?.inputType === 'selection' const isSelectionArgument = currentArgument?.inputType === 'selection'
const WrapperComponent = isSelectionArgument ? Popover : Dialog const WrapperComponent = isSelectionArgument ? Popover : Dialog
// Close the command bar when navigating
useEffect(() => {
commandBarSend({ type: 'Close' })
}, [pathname])
// Hook up keyboard shortcuts
useHotkeys(['mod+k', 'mod+/'], () => { useHotkeys(['mod+k', 'mod+/'], () => {
if (commandBarState.context.commands.length === 0) return if (commandBarState.context.commands.length === 0) return
if (commandBarState.matches('Closed')) { if (commandBarState.matches('Closed')) {
@ -164,4 +123,4 @@ export const CommandBar = () => {
) )
} }
export default CommandBarProvider export default CommandBar

View File

@ -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<typeof commandBarMachine>
commandBarSend: (event: EventFrom<typeof commandBarMachine>) => void
}
export const CommandsContext = createContext<CommandsContextType>({
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 (
<CommandsContext.Provider
value={{
commandBarState,
commandBarSend,
}}
>
{children}
</CommandsContext.Provider>
)
}

View File

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

View File

@ -2,9 +2,9 @@ import { fireEvent, render, screen } from '@testing-library/react'
import { BrowserRouter } from 'react-router-dom' import { BrowserRouter } from 'react-router-dom'
import ProjectSidebarMenu from './ProjectSidebarMenu' import ProjectSidebarMenu from './ProjectSidebarMenu'
import { type ProjectWithEntryPointMetadata } from 'lib/types' import { type ProjectWithEntryPointMetadata } from 'lib/types'
import { SettingsAuthProvider } from './SettingsAuthProvider' import { SettingsAuthProviderJest } from './SettingsAuthProvider'
import { APP_NAME } from 'lib/constants' import { APP_NAME } from 'lib/constants'
import { vi } from 'vitest' import { CommandBarProvider } from './CommandBar/CommandBarProvider'
const now = new Date() const now = new Date()
const projectWellFormed = { const projectWellFormed = {
@ -41,9 +41,11 @@ describe('ProjectSidebarMenu tests', () => {
test('Renders the project name', () => { test('Renders the project name', () => {
render( render(
<BrowserRouter> <BrowserRouter>
<SettingsAuthProvider> <CommandBarProvider>
<SettingsAuthProviderJest>
<ProjectSidebarMenu project={projectWellFormed} /> <ProjectSidebarMenu project={projectWellFormed} />
</SettingsAuthProvider> </SettingsAuthProviderJest>
</CommandBarProvider>
</BrowserRouter> </BrowserRouter>
) )
@ -60,9 +62,11 @@ describe('ProjectSidebarMenu tests', () => {
test('Renders app name if given no project', () => { test('Renders app name if given no project', () => {
render( render(
<BrowserRouter> <BrowserRouter>
<SettingsAuthProvider> <CommandBarProvider>
<SettingsAuthProviderJest>
<ProjectSidebarMenu /> <ProjectSidebarMenu />
</SettingsAuthProvider> </SettingsAuthProviderJest>
</CommandBarProvider>
</BrowserRouter> </BrowserRouter>
) )
@ -74,9 +78,14 @@ describe('ProjectSidebarMenu tests', () => {
test('Renders as a link if set to do so', () => { test('Renders as a link if set to do so', () => {
render( render(
<BrowserRouter> <BrowserRouter>
<SettingsAuthProvider> <CommandBarProvider>
<ProjectSidebarMenu project={projectWellFormed} renderAsLink={true} /> <SettingsAuthProviderJest>
</SettingsAuthProvider> <ProjectSidebarMenu
project={projectWellFormed}
renderAsLink={true}
/>
</SettingsAuthProviderJest>
</CommandBarProvider>
</BrowserRouter> </BrowserRouter>
) )

View File

@ -1,12 +1,15 @@
import { useMachine } from '@xstate/react' import { useMachine } from '@xstate/react'
import { useNavigate } from 'react-router-dom' import { useNavigate, useRouteLoaderData } from 'react-router-dom'
import { paths } from 'lib/paths' import { paths } from 'lib/paths'
import { authMachine, TOKEN_PERSIST_KEY } from '../machines/authMachine' import { authMachine, TOKEN_PERSIST_KEY } from '../machines/authMachine'
import withBaseUrl from '../lib/withBaseURL' import withBaseUrl from '../lib/withBaseURL'
import React, { createContext, useEffect, useRef } from 'react' import React, { createContext, useEffect } from 'react'
import useStateMachineCommands from '../hooks/useStateMachineCommands' import useStateMachineCommands from '../hooks/useStateMachineCommands'
import { settingsMachine } from 'machines/settingsMachine' 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 { toast } from 'react-hot-toast'
import { setThemeClass, Themes } from 'lib/theme' import { setThemeClass, Themes } from 'lib/theme'
import { import {
@ -20,6 +23,7 @@ import { isTauri } from 'lib/isTauri'
import { settingsCommandBarConfig } from 'lib/commandBarConfigs/settingsCommandConfig' import { settingsCommandBarConfig } from 'lib/commandBarConfigs/settingsCommandConfig'
import { authCommandBarConfig } from 'lib/commandBarConfigs/authCommandConfig' import { authCommandBarConfig } from 'lib/commandBarConfigs/authCommandConfig'
import { sceneInfra } from 'clientSideScene/sceneInfra' import { sceneInfra } from 'clientSideScene/sceneInfra'
import { kclManager } from 'lang/KclSingleton'
type MachineContext<T extends AnyStateMachine> = { type MachineContext<T extends AnyStateMachine> = {
state: StateFrom<T> state: StateFrom<T>
@ -27,7 +31,7 @@ type MachineContext<T extends AnyStateMachine> = {
send: Prop<InterpreterFrom<T>, 'send'> send: Prop<InterpreterFrom<T>, 'send'>
} }
type GlobalContext = { type SettingsAuthContextType = {
auth: MachineContext<typeof authMachine> auth: MachineContext<typeof authMachine>
settings: MachineContext<typeof settingsMachine> settings: MachineContext<typeof settingsMachine>
} }
@ -38,37 +42,66 @@ type GlobalContext = {
let settingsStateRef: (typeof settingsMachine)['context'] | undefined let settingsStateRef: (typeof settingsMachine)['context'] | undefined
export const getSettingsState = () => settingsStateRef export const getSettingsState = () => settingsStateRef
export const SettingsAuthContext = createContext({} as GlobalContext) export const SettingsAuthContext = createContext({} as SettingsAuthContextType)
export const SettingsAuthProvider = ({ export const SettingsAuthProvider = ({
children, children,
}: { }: {
children: React.ReactNode children: React.ReactNode
}) => { }) => {
const navigate = useNavigate() const loadedSettings = useRouteLoaderData(paths.INDEX) as Awaited<
ReturnType<typeof validateSettings>
// 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']
> >
return (
<SettingsAuthProviderBase loadedSettings={loadedSettings}>
{children}
</SettingsAuthProviderBase>
) )
}
// For use in jest tests we don't want to use the loader data
// and mock the whole Router
export const SettingsAuthProviderJest = ({
children,
}: {
children: React.ReactNode
}) => {
const loadedSettings = fallbackLoadedSettings
return (
<SettingsAuthProviderBase loadedSettings={loadedSettings}>
{children}
</SettingsAuthProviderBase>
)
}
export const SettingsAuthProviderBase = ({
children,
loadedSettings,
}: {
children: React.ReactNode
loadedSettings: Awaited<ReturnType<typeof validateSettings>>
}) => {
const { settings: initialLoadedContext } = loadedSettings
const navigate = useNavigate()
const [settingsState, settingsSend, settingsActor] = useMachine( const [settingsState, settingsSend, settingsActor] = useMachine(
settingsMachine, settingsMachine,
{ {
context: persistedSettings, context: initialLoadedContext,
actions: { actions: {
setClientSideSceneUnits: (context, event) => {
const newBaseUnit =
event.type === 'Set Base Unit'
? event.data.baseUnit
: context.baseUnit
sceneInfra.baseUnit = newBaseUnit
},
toastSuccess: (context, event) => { toastSuccess: (context, event) => {
const truncatedNewValue = const truncatedNewValue =
'data' in event && event.data instanceof Object 'data' in event && event.data instanceof Object
? (String( ? (context[Object.keys(event.data)[0] as keyof typeof context]
context[Object.keys(event.data)[0] as keyof typeof context] .toString()
).substring(0, 28) as any) .substring(0, 28) as any)
: undefined : undefined
toast.success( toast.success(
event.type + 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 if (settingsState.context.theme !== 'system') return
setThemeClass(e.matches ? Themes.Dark : Themes.Light) setThemeClass(e.matches ? Themes.Dark : Themes.Light)
} }
sceneInfra.baseUnit = settingsState?.context?.baseUnit || 'mm'
matcher.addEventListener('change', listener) matcher.addEventListener('change', listener)
return () => matcher.removeEventListener('change', listener) return () => matcher.removeEventListener('change', listener)

View File

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

View File

@ -1,4 +1,4 @@
import { CommandsContext } from 'components/CommandBar/CommandBar' import { CommandsContext } from 'components/CommandBar/CommandBarProvider'
import { useContext } from 'react' import { useContext } from 'react'
export const useCommandsContext = () => { export const useCommandsContext = () => {

View File

@ -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<typeof validateSettings>
>
// 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])
}

View File

@ -1,7 +1,12 @@
import { CommandSetConfig } from '../commandTypes' import { type CommandSetConfig } from '../commandTypes'
import { BaseUnit, Toggle, UnitSystem, baseUnitsUnion } from 'lib/settings' import {
type BaseUnit,
type Toggle,
UnitSystem,
baseUnitsUnion,
} from 'lib/settings/settingsTypes'
import { settingsMachine } from 'machines/settingsMachine' import { settingsMachine } from 'machines/settingsMachine'
import { CameraSystem, cameraSystems } from '../cameraControls' import { type CameraSystem, cameraSystems } from '../cameraControls'
import { Themes } from '../theme' import { Themes } from '../theme'
// SETTINGS MACHINE // SETTINGS MACHINE

View File

@ -1 +1,4 @@
export const APP_NAME = 'Modeling App' 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'

138
src/lib/routeLoaders.ts Normal file
View File

@ -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<IndexLoaderData | Response> => {
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: [],
}
}
}

View File

@ -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,
}

View File

@ -1,10 +1,6 @@
import { type Models } from '@kittycad/lib' import { type Models } from '@kittycad/lib'
import { CameraSystem } from './cameraControls' import { type CameraSystem } from '../cameraControls'
import { Themes } from './theme' import { Themes } from 'lib/theme'
export const DEFAULT_PROJECT_NAME = 'project-$nnn'
export const SETTINGS_PERSIST_KEY = 'SETTINGS_PERSIST_KEY'
export const SETTINGS_FILE_NAME = 'settings.json'
export enum UnitSystem { export enum UnitSystem {
Imperial = 'imperial', Imperial = 'imperial',
@ -21,6 +17,7 @@ export type BaseUnit = Models['UnitLength_type']
export const baseUnitsUnion = Object.values(baseUnits).flatMap((v) => v) export const baseUnitsUnion = Object.values(baseUnits).flatMap((v) => v)
export type Toggle = 'On' | 'Off' export type Toggle = 'On' | 'Off'
export const toggleAsArray = ['On', 'Off'] as const
export type SettingsMachineContext = { export type SettingsMachineContext = {
baseUnit: BaseUnit baseUnit: BaseUnit
@ -33,15 +30,3 @@ export type SettingsMachineContext = {
theme: Themes theme: Themes
unitSystem: UnitSystem 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,
}

View File

@ -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<T extends Record<string, unknown>>(v: unknown, e: T) {
return Object.values(e).includes(v)
}
export async function loadAndValidateSettings(): Promise<
ReturnType<typeof validateSettings>
> {
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<string, unknown>) {
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<string, unknown>) {
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,
}
}

View File

@ -3,12 +3,16 @@ import {
createDir, createDir,
exists, exists,
readDir, readDir,
readTextFile,
writeTextFile, writeTextFile,
} from '@tauri-apps/api/fs' } 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 { isTauri } from './isTauri'
import { type ProjectWithEntryPointMetadata } from 'lib/types' import { type ProjectWithEntryPointMetadata } from 'lib/types'
import { metadata } from 'tauri-plugin-fs-extra-api' 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' const PROJECT_FOLDER = 'zoo-modeling-app-projects'
export const FILE_EXT = '.kcl' export const FILE_EXT = '.kcl'
@ -26,39 +30,100 @@ const RELEVANT_FILE_TYPES = [
'stl', 'stl',
] ]
// Initializes the project directory and returns the path type PathWithPossibleError = {
export async function initializeProjectDirectory(directory: string) { path: string | null
if (!isTauri()) { error: Error | null
throw new Error(
'initializeProjectDirectory() can only be called from a Tauri app'
)
} }
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<PathWithPossibleError> {
let returnValue: PathWithPossibleError = {
path: null,
error: null,
}
if (!isTauri()) return returnValue
if (directory) { if (directory) {
const dirExists = await exists(directory) returnValue = await testAndCreateDir(directory, returnValue)
if (!dirExists) {
await createDir(directory, { recursive: true })
}
return directory
} }
let docDirectory: string // If the directory from settings does not exist or could not be created,
try { // use the default directory
docDirectory = await documentDir() if (returnValue.path === null) {
} catch (e) { const INITIAL_DEFAULT_DIR = await getInitialDefaultDir()
console.log('error', e) const defaultReturnValue = await testAndCreateDir(
docDirectory = `${await homeDir()}Documents/` // for headless Linux (eg. Github Actions) 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
} }
const INITIAL_DEFAULT_DIR = docDirectory + PROJECT_FOLDER return returnValue
const defaultDirExists = await exists(INITIAL_DEFAULT_DIR)
if (!defaultDirExists) {
await createDir(INITIAL_DEFAULT_DIR, { recursive: true })
} }
return INITIAL_DEFAULT_DIR 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<PathWithPossibleError> {
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
}
} else if (dirExists === true) {
returnValue.path = directory
}
return returnValue
} }
export function isProjectDirectory(fileOrDir: Partial<FileEntry>) { export function isProjectDirectory(fileOrDir: Partial<FileEntry>) {
@ -309,3 +374,44 @@ function getPaddedIdentifierRegExp() {
const escapedIdentifier = escapeRegExpChars(INDEX_IDENTIFIER) const escapedIdentifier = escapeRegExpChars(INDEX_IDENTIFIER)
return new RegExp(`${escapedIdentifier}(${escapedIdentifier.slice(-1)}*)`) 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<typeof settingsMachine>
) {
return writeTextFile(
await getSettingsFilePath(),
JSON.stringify(settings, null, 2)
)
}
export async function readSettingsFile(): Promise<ContextFrom<
typeof settingsMachine
> | 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
}
}

View File

@ -12,5 +12,4 @@ export type ProjectWithEntryPointMetadata = FileEntry & {
} }
export type HomeLoaderData = { export type HomeLoaderData = {
projects: ProjectWithEntryPointMetadata[] projects: ProjectWithEntryPointMetadata[]
newDefaultDirectory?: string
} }

View File

@ -450,6 +450,7 @@ export const commandBarMachine = createMachine(
const hasMismatchedDefaultValueType = const hasMismatchedDefaultValueType =
isRequired && isRequired &&
resolvedDefaultValue !== undefined &&
typeof argValue !== typeof resolvedDefaultValue && typeof argValue !== typeof resolvedDefaultValue &&
!(argConfig.inputType === 'kcl' || argConfig.skip) !(argConfig.inputType === 'kcl' || argConfig.skip)
const hasInvalidKclValue = const hasInvalidKclValue =

View File

@ -1,40 +1,41 @@
import { assign, createMachine } from 'xstate' 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 { 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 { import {
BaseUnit,
DEFAULT_PROJECT_NAME,
SETTINGS_PERSIST_KEY,
SettingsMachineContext,
Toggle,
UnitSystem, UnitSystem,
} from 'lib/settings' type BaseUnit,
type SettingsMachineContext,
const kclManagerPromise = import('lang/KclSingleton').then( type Toggle,
(module) => module.kclManager } from 'lib/settings/settingsTypes'
)
export const settingsMachine = createMachine( 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', id: 'Settings',
predictableActionArguments: true, predictableActionArguments: true,
context: { context: {} as SettingsMachineContext,
baseUnit: 'mm',
cameraControls: 'KittyCAD',
defaultDirectory: '',
defaultProjectName: DEFAULT_PROJECT_NAME,
onboardingStatus: '',
showDebugPanel: false,
textWrapping: 'On',
theme: Themes.System,
unitSystem: UnitSystem.Metric,
} as SettingsMachineContext,
initial: 'idle', initial: 'idle',
states: { states: {
idle: { idle: {
entry: ['setThemeClass'], entry: ['setThemeClass', 'setClientSideSceneUnits', 'persistSettings'],
on: { on: {
'Set All Settings': {
actions: [
assign((context, event) => {
return {
...context,
...event.data,
}
}),
'persistSettings',
'setThemeClass',
],
target: 'idle',
internal: true,
},
'Set Base Unit': { 'Set Base Unit': {
actions: [ actions: [
assign({ assign({
@ -42,9 +43,8 @@ export const settingsMachine = createMachine(
}), }),
'persistSettings', 'persistSettings',
'toastSuccess', 'toastSuccess',
async () => { 'setClientSideSceneUnits',
;(await kclManagerPromise).executeAst() 'Execute AST',
},
], ],
target: 'idle', target: 'idle',
internal: true, internal: true,
@ -125,9 +125,7 @@ export const settingsMachine = createMachine(
}), }),
'persistSettings', 'persistSettings',
'toastSuccess', 'toastSuccess',
async () => { 'Execute AST',
;(await kclManagerPromise).executeAst()
},
], ],
target: 'idle', target: 'idle',
internal: true, internal: true,
@ -151,6 +149,7 @@ export const settingsMachine = createMachine(
tsTypes: {} as import('./settingsMachine.typegen').Typegen0, tsTypes: {} as import('./settingsMachine.typegen').Typegen0,
schema: { schema: {
events: {} as events: {} as
| { type: 'Set All Settings'; data: Partial<SettingsMachineContext> }
| { type: 'Set Base Unit'; data: { baseUnit: BaseUnit } } | { type: 'Set Base Unit'; data: { baseUnit: BaseUnit } }
| { | {
type: 'Set Camera Controls' type: 'Set Camera Controls'
@ -174,6 +173,11 @@ export const settingsMachine = createMachine(
{ {
actions: { actions: {
persistSettings: (context) => { persistSettings: (context) => {
if (isTauri()) {
writeToSettingsFile(context).catch((err) => {
console.error('Error writing settings:', err)
})
}
try { try {
localStorage.setItem(SETTINGS_PERSIST_KEY, JSON.stringify(context)) localStorage.setItem(SETTINGS_PERSIST_KEY, JSON.stringify(context))
} catch (e) { } catch (e) {

View File

@ -31,21 +31,22 @@ import {
import useStateMachineCommands from '../hooks/useStateMachineCommands' import useStateMachineCommands from '../hooks/useStateMachineCommands'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { useCommandsContext } from 'hooks/useCommandsContext' 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 { sep } from '@tauri-apps/api/path'
import { homeCommandBarConfig } from 'lib/commandBarConfigs/homeCommandConfig' import { homeCommandBarConfig } from 'lib/commandBarConfigs/homeCommandConfig'
import { useHotkeys } from 'react-hotkeys-hook' import { useHotkeys } from 'react-hotkeys-hook'
import { isTauri } from 'lib/isTauri' import { isTauri } from 'lib/isTauri'
import { kclManager } from 'lang/KclSingleton' import { kclManager } from 'lang/KclSingleton'
import { useLspContext } from 'components/LspProvider' import { useLspContext } from 'components/LspProvider'
import { useValidateSettings } from 'hooks/useValidateSettings'
// This route only opens in the Tauri desktop context for now, // 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. // as defined in Router.tsx, so we can use the Tauri APIs and types.
const Home = () => { const Home = () => {
useValidateSettings()
const { commandBarSend } = useCommandsContext() const { commandBarSend } = useCommandsContext()
const navigate = useNavigate() const navigate = useNavigate()
const { projects: loadedProjects, newDefaultDirectory } = const { projects: loadedProjects } = useLoaderData() as HomeLoaderData
useLoaderData() as HomeLoaderData
const { const {
settings: { settings: {
context: { defaultDirectory, defaultProjectName }, context: { defaultDirectory, defaultProjectName },
@ -54,18 +55,11 @@ const Home = () => {
} = useSettingsAuthContext() } = useSettingsAuthContext()
const { onProjectOpen } = useLspContext() const { onProjectOpen } = useLspContext()
// Set the default directory if it's been updated // Cancel all KCL executions while on the home page
// during the loading of the home page. This is wrapped
// in a single-use effect to avoid a potential infinite loop.
useEffect(() => { useEffect(() => {
kclManager.cancelAllExecutions() kclManager.cancelAllExecutions()
if (newDefaultDirectory) {
sendToSettings({
type: 'Set Default Directory',
data: { defaultDirectory: newDefaultDirectory },
})
}
}, []) }, [])
useHotkeys( useHotkeys(
isTauri() ? 'mod+,' : 'shift+mod+,', isTauri() ? 'mod+,' : 'shift+mod+,',
() => navigate(paths.HOME + paths.SETTINGS), () => navigate(paths.HOME + paths.SETTINGS),

View File

@ -1,5 +1,9 @@
import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons' 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 { ActionButton } from 'components/ActionButton'
import { SettingsSection } from '../Settings' import { SettingsSection } from '../Settings'
import { Toggle } from 'components/Toggle/Toggle' import { Toggle } from 'components/Toggle/Toggle'

View File

@ -2,7 +2,12 @@ import { faArrowRotateBack, faXmark } from '@fortawesome/free-solid-svg-icons'
import { ActionButton } from '../components/ActionButton' import { ActionButton } from '../components/ActionButton'
import { AppHeader } from '../components/AppHeader' import { AppHeader } from '../components/AppHeader'
import { open } from '@tauri-apps/api/dialog' 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 { Toggle } from 'components/Toggle/Toggle'
import { useLocation, useNavigate, useRouteLoaderData } from 'react-router-dom' import { useLocation, useNavigate, useRouteLoaderData } from 'react-router-dom'
import { useHotkeys } from 'react-hotkeys-hook' import { useHotkeys } from 'react-hotkeys-hook'
@ -15,17 +20,22 @@ import {
cameraSystems, cameraSystems,
cameraMouseDragGuards, cameraMouseDragGuards,
} from 'lib/cameraControls' } from 'lib/cameraControls'
import { UnitSystem } from 'lib/settings'
import { useDotDotSlash } from 'hooks/useDotDotSlash' import { useDotDotSlash } from 'hooks/useDotDotSlash'
import { import {
createNewProject, createNewProject,
getNextProjectIndex, getNextProjectIndex,
getProjectsInDir, getProjectsInDir,
getSettingsFilePath,
initializeProjectDirectory,
interpolateProjectNameWithIndex, interpolateProjectNameWithIndex,
} from 'lib/tauriFS' } from 'lib/tauriFS'
import { initialSettings } from 'lib/settings/initialSettings'
import { ONBOARDING_PROJECT_NAME } from './Onboarding' import { ONBOARDING_PROJECT_NAME } from './Onboarding'
import { sep } from '@tauri-apps/api/path' import { sep } from '@tauri-apps/api/path'
import { bracket } from 'lib/exampleKcl' 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 = () => { export const Settings = () => {
const APP_VERSION = import.meta.env.PACKAGE_VERSION || 'unknown' const APP_VERSION = import.meta.env.PACKAGE_VERSION || 'unknown'
@ -54,8 +64,11 @@ export const Settings = () => {
} = useSettingsAuthContext() } = useSettingsAuthContext()
async function handleDirectorySelection() { 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({ const newDirectory = await open({
directory: true, directory: true,
recursive: true,
defaultPath: defaultDirectory || paths.INDEX, defaultPath: defaultDirectory || paths.INDEX,
title: 'Choose a new default directory', title: 'Choose a new default directory',
}) })
@ -302,6 +315,59 @@ export const Settings = () => {
Replay Onboarding Replay Onboarding
</ActionButton> </ActionButton>
</SettingsSection> </SettingsSection>
<p className="font-mono my-6 leading-loose">
Your settings are saved in{' '}
{isTauri()
? 'a file in the app data folder for your OS.'
: "your browser's local storage."}{' '}
{isTauri() ? (
<span className="flex gap-4 flex-wrap items-center">
<button
onClick={async () =>
void invoke('show_in_folder', {
path: await getSettingsFilePath(),
})
}
className="text-base"
>
Show settings.json in folder
</button>
<button
onClick={async () => {
// We have to re-call initializeProjectDirectory
// since we can't set that in the settings machine's
// initial context due to it being async
send({
type: 'Set All Settings',
data: {
...initialSettings,
defaultDirectory:
(await initializeProjectDirectory('')).path ?? '',
},
})
toast.success('Settings restored to default')
}}
className="text-base"
>
Restore default settings
</button>
</span>
) : (
<button
onClick={() => {
localStorage.removeItem(SETTINGS_PERSIST_KEY)
send({
type: 'Set All Settings',
data: initialSettings,
})
toast.success('Settings restored to default')
}}
className="text-base"
>
Restore default settings
</button>
)}
</p>
<p className="mt-24 text-sm font-mono"> <p className="mt-24 text-sm font-mono">
{/* This uses a Vite plugin, set in vite.config.ts {/* This uses a Vite plugin, set in vite.config.ts
to inject the version from package.json */} to inject the version from package.json */}

View File

@ -6,8 +6,10 @@ import { Themes, getSystemTheme } from '../lib/theme'
import { paths } from 'lib/paths' import { paths } from 'lib/paths'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { APP_NAME } from 'lib/constants' import { APP_NAME } from 'lib/constants'
import { useValidateSettings } from 'hooks/useValidateSettings'
const SignIn = () => { const SignIn = () => {
useValidateSettings()
const getLogoTheme = () => const getLogoTheme = () =>
theme === Themes.Light || theme === Themes.Light ||
(theme === Themes.System && getSystemTheme() === Themes.Light) (theme === Themes.System && getSystemTheme() === Themes.Light)

View File

@ -17,6 +17,13 @@ const config = defineConfig({
}, },
test: { test: {
globals: true, globals: true,
pool: 'forks',
poolOptions: {
forks: {
maxForks: 2,
minForks: 1,
}
},
setupFiles: 'src/setupTests.ts', setupFiles: 'src/setupTests.ts',
environment: 'happy-dom', environment: 'happy-dom',
coverage: { coverage: {