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:
@ -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 |
@ -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!())
|
||||||
|
@ -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,
|
||||||
|
@ -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()
|
||||||
|
189
src/Router.tsx
189
src/Router.tsx
@ -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
|
||||||
|
@ -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']
|
||||||
|
@ -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
|
||||||
|
43
src/components/CommandBar/CommandBarProvider.tsx
Normal file
43
src/components/CommandBar/CommandBarProvider.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
@ -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>
|
||||||
)
|
)
|
||||||
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
@ -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>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
@ -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 = () => {
|
||||||
|
33
src/hooks/useValidateSettings.ts
Normal file
33
src/hooks/useValidateSettings.ts
Normal 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])
|
||||||
|
}
|
@ -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
|
||||||
|
@ -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
138
src/lib/routeLoaders.ts
Normal 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: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
15
src/lib/settings/initialSettings.ts
Normal file
15
src/lib/settings/initialSettings.ts
Normal 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,
|
||||||
|
}
|
@ -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,
|
|
||||||
}
|
|
88
src/lib/settings/settingsUtils.ts
Normal file
88
src/lib/settings/settingsUtils.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -12,5 +12,4 @@ export type ProjectWithEntryPointMetadata = FileEntry & {
|
|||||||
}
|
}
|
||||||
export type HomeLoaderData = {
|
export type HomeLoaderData = {
|
||||||
projects: ProjectWithEntryPointMetadata[]
|
projects: ProjectWithEntryPointMetadata[]
|
||||||
newDefaultDirectory?: string
|
|
||||||
}
|
}
|
||||||
|
@ -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 =
|
||||||
|
@ -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) {
|
||||||
|
@ -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),
|
||||||
|
@ -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'
|
||||||
|
@ -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 */}
|
||||||
|
@ -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)
|
||||||
|
@ -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: {
|
||||||
|
Reference in New Issue
Block a user