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 waitOn from 'wait-on'
|
||||
import { Themes } from '../../src/lib/theme'
|
||||
import { initialSettings } from '../../src/lib/settings/initialSettings'
|
||||
import { roundOff } from 'lib/utils'
|
||||
import { platform } from 'node:os'
|
||||
|
||||
/*
|
||||
debug helper: unfortunately we do rely on exact coord mouse clicks in a few places
|
||||
@ -516,6 +516,55 @@ test('Auto complete works', async ({ page }) => {
|
||||
|> xLine(5, %) // lin`)
|
||||
})
|
||||
|
||||
// Stored settings validation test
|
||||
test('Stored settings are validated and fall back to defaults', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
// Override beforeEach test setup
|
||||
// with corrupted settings
|
||||
await context.addInitScript(async () => {
|
||||
const storedSettings = JSON.parse(
|
||||
localStorage.getItem('SETTINGS_PERSIST_KEY') || '{}'
|
||||
)
|
||||
|
||||
// Corrupt the settings
|
||||
storedSettings.baseUnit = 'invalid'
|
||||
storedSettings.cameraControls = `() => alert('hack the planet')`
|
||||
storedSettings.defaultDirectory = 123
|
||||
storedSettings.defaultProjectName = false
|
||||
|
||||
localStorage.setItem('SETTINGS_PERSIST_KEY', JSON.stringify(storedSettings))
|
||||
})
|
||||
|
||||
await page.setViewportSize({ width: 1200, height: 500 })
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' })
|
||||
|
||||
// Check the toast appeared
|
||||
await expect(
|
||||
page.getByText(`Error validating persisted settings:`, {
|
||||
exact: false,
|
||||
})
|
||||
).toBeVisible()
|
||||
|
||||
// Check the settings were reset
|
||||
const storedSettings = JSON.parse(
|
||||
await page.evaluate(
|
||||
() => localStorage.getItem('SETTINGS_PERSIST_KEY') || '{}'
|
||||
)
|
||||
)
|
||||
await expect(storedSettings.baseUnit).toBe(initialSettings.baseUnit)
|
||||
await expect(storedSettings.cameraControls).toBe(
|
||||
initialSettings.cameraControls
|
||||
)
|
||||
await expect(storedSettings.defaultDirectory).toBe(
|
||||
initialSettings.defaultDirectory
|
||||
)
|
||||
await expect(storedSettings.defaultProjectName).toBe(
|
||||
initialSettings.defaultProjectName
|
||||
)
|
||||
})
|
||||
|
||||
// Onboarding tests
|
||||
test('Onboarding redirects and code updating', async ({ page, context }) => {
|
||||
const u = getUtils(page)
|
||||
|
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 oauth2::TokenResponse;
|
||||
use std::process::Command;
|
||||
use tauri::{InvokeError, Manager};
|
||||
const DEFAULT_HOST: &str = "https://api.kittycad.io";
|
||||
|
||||
@ -142,6 +143,25 @@ async fn get_user(
|
||||
Ok(user_info)
|
||||
}
|
||||
|
||||
/// Open the selected path in the system file manager.
|
||||
/// From this GitHub comment: https://github.com/tauri-apps/tauri/issues/4062#issuecomment-1338048169
|
||||
/// But with the Linux support removed since we don't need it for now.
|
||||
#[tauri::command]
|
||||
fn show_in_folder(path: String) {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
Command::new("explorer")
|
||||
.args(["/select,", &path]) // The comma after select is not a typo
|
||||
.spawn()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
Command::new("open").args(["-R", &path]).spawn().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
tauri::Builder::default()
|
||||
.setup(|_app| {
|
||||
@ -158,7 +178,8 @@ fn main() {
|
||||
get_user,
|
||||
login,
|
||||
read_toml,
|
||||
read_txt_file
|
||||
read_txt_file,
|
||||
show_in_folder,
|
||||
])
|
||||
.plugin(tauri_plugin_fs_extra::init())
|
||||
.run(tauri::generate_context!())
|
||||
|
@ -23,7 +23,10 @@
|
||||
"fs": {
|
||||
"scope": [
|
||||
"$HOME/**/*",
|
||||
"$APPDATA/**/*"
|
||||
"$APPCONFIG",
|
||||
"$APPCONFIG/**/*",
|
||||
"$DOCUMENT",
|
||||
"$DOCUMENT/**/*"
|
||||
],
|
||||
"all": true
|
||||
},
|
||||
@ -60,7 +63,7 @@
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"identifier": "io.kittycad.modeling-app",
|
||||
"identifier": "dev.zoo.modeling-app",
|
||||
"longDescription": "",
|
||||
"macOS": {
|
||||
"entitlements": null,
|
||||
|
@ -33,8 +33,10 @@ import { useModelingContext } from 'hooks/useModelingContext'
|
||||
import { useAbsoluteFilePath } from 'hooks/useAbsoluteFilePath'
|
||||
import { isTauri } from 'lib/isTauri'
|
||||
import { useLspContext } from 'components/LspProvider'
|
||||
import { useValidateSettings } from 'hooks/useValidateSettings'
|
||||
|
||||
export function App() {
|
||||
useValidateSettings()
|
||||
const { project, file } = useLoaderData() as IndexLoaderData
|
||||
const navigate = useNavigate()
|
||||
const filePath = useAbsoluteFilePath()
|
||||
|
283
src/Router.tsx
283
src/Router.tsx
@ -12,68 +12,54 @@ import SignIn from './routes/SignIn'
|
||||
import { Auth } from './Auth'
|
||||
import { isTauri } from './lib/isTauri'
|
||||
import Home from './routes/Home'
|
||||
import { FileEntry, readDir, readTextFile } from '@tauri-apps/api/fs'
|
||||
import makeUrlPathRelative from './lib/makeUrlPathRelative'
|
||||
import {
|
||||
initializeProjectDirectory,
|
||||
isProjectDirectory,
|
||||
PROJECT_ENTRYPOINT,
|
||||
} from './lib/tauriFS'
|
||||
import { metadata } from 'tauri-plugin-fs-extra-api'
|
||||
import DownloadAppBanner from './components/DownloadAppBanner'
|
||||
import { WasmErrBanner } from './components/WasmErrBanner'
|
||||
import { SettingsAuthProvider } from './components/SettingsAuthProvider'
|
||||
import { settingsMachine } from './machines/settingsMachine'
|
||||
import { SETTINGS_PERSIST_KEY } from './lib/settings'
|
||||
import { ContextFrom } from 'xstate'
|
||||
import CommandBarProvider, {
|
||||
CommandBar,
|
||||
} from 'components/CommandBar/CommandBar'
|
||||
import DownloadAppBanner from 'components/DownloadAppBanner'
|
||||
import { WasmErrBanner } from 'components/WasmErrBanner'
|
||||
import { CommandBar } from 'components/CommandBar/CommandBar'
|
||||
import ModelingMachineProvider from 'components/ModelingMachineProvider'
|
||||
import { KclContextProvider, kclManager } from 'lang/KclSingleton'
|
||||
import FileMachineProvider from 'components/FileMachineProvider'
|
||||
import { sep } from '@tauri-apps/api/path'
|
||||
import { paths } from 'lib/paths'
|
||||
import { IndexLoaderData, HomeLoaderData } from 'lib/types'
|
||||
import { fileSystemManager } from 'lang/std/fileSystemManager'
|
||||
import {
|
||||
fileLoader,
|
||||
homeLoader,
|
||||
indexLoader,
|
||||
onboardingRedirectLoader,
|
||||
} from 'lib/routeLoaders'
|
||||
import { CommandBarProvider } from 'components/CommandBar/CommandBarProvider'
|
||||
import SettingsAuthProvider from 'components/SettingsAuthProvider'
|
||||
import LspProvider from 'components/LspProvider'
|
||||
import { KclContextProvider } from 'lang/KclSingleton'
|
||||
|
||||
export const BROWSER_FILE_NAME = 'new'
|
||||
|
||||
type CreateBrowserRouterArg = Parameters<typeof createBrowserRouter>[0]
|
||||
|
||||
const addGlobalContextToElements = (
|
||||
routes: CreateBrowserRouterArg
|
||||
): CreateBrowserRouterArg =>
|
||||
routes.map((route) =>
|
||||
'element' in route
|
||||
? {
|
||||
...route,
|
||||
element: (
|
||||
<CommandBarProvider>
|
||||
<SettingsAuthProvider>
|
||||
<LspProvider>{route.element}</LspProvider>
|
||||
</SettingsAuthProvider>
|
||||
</CommandBarProvider>
|
||||
),
|
||||
}
|
||||
: route
|
||||
)
|
||||
|
||||
const router = createBrowserRouter(
|
||||
addGlobalContextToElements([
|
||||
{
|
||||
path: paths.INDEX,
|
||||
loader: () =>
|
||||
isTauri()
|
||||
? redirect(paths.HOME)
|
||||
: redirect(paths.FILE + '/' + BROWSER_FILE_NAME),
|
||||
errorElement: <ErrorPage />,
|
||||
},
|
||||
{
|
||||
path: paths.FILE + '/:id',
|
||||
element: (
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
loader: indexLoader,
|
||||
id: paths.INDEX,
|
||||
element: (
|
||||
<CommandBarProvider>
|
||||
<KclContextProvider>
|
||||
<SettingsAuthProvider>
|
||||
<LspProvider>
|
||||
<Outlet />
|
||||
</LspProvider>
|
||||
</SettingsAuthProvider>
|
||||
</KclContextProvider>
|
||||
</CommandBarProvider>
|
||||
),
|
||||
children: [
|
||||
{
|
||||
path: paths.INDEX,
|
||||
loader: () =>
|
||||
isTauri()
|
||||
? redirect(paths.HOME)
|
||||
: redirect(paths.FILE + '/' + BROWSER_FILE_NAME),
|
||||
errorElement: <ErrorPage />,
|
||||
},
|
||||
{
|
||||
loader: fileLoader,
|
||||
id: paths.FILE,
|
||||
element: (
|
||||
<Auth>
|
||||
<FileMachineProvider>
|
||||
<ModelingMachineProvider>
|
||||
@ -85,155 +71,52 @@ const router = createBrowserRouter(
|
||||
</FileMachineProvider>
|
||||
{!isTauri() && import.meta.env.PROD && <DownloadAppBanner />}
|
||||
</Auth>
|
||||
</KclContextProvider>
|
||||
),
|
||||
id: paths.FILE,
|
||||
loader: async ({
|
||||
request,
|
||||
params,
|
||||
}): 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: [
|
||||
{
|
||||
path: paths.FILE + '/:id',
|
||||
loader: onboardingRedirectLoader,
|
||||
children: [
|
||||
{
|
||||
path: makeUrlPathRelative(paths.SETTINGS),
|
||||
loader: indexLoader, // very rare someone will load into settings first, but it's possible in the browser
|
||||
element: <Settings />,
|
||||
},
|
||||
{
|
||||
path: makeUrlPathRelative(paths.ONBOARDING.INDEX),
|
||||
element: <Onboarding />,
|
||||
loader: indexLoader, // very rare someone will load into settings first, but it's possible in the browser
|
||||
children: onboardingRoutes,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: makeUrlPathRelative(paths.SETTINGS),
|
||||
element: <Settings />,
|
||||
},
|
||||
{
|
||||
path: makeUrlPathRelative(paths.ONBOARDING.INDEX),
|
||||
element: <Onboarding />,
|
||||
children: onboardingRoutes,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: paths.HOME,
|
||||
element: (
|
||||
<Auth>
|
||||
<Outlet />
|
||||
<Home />
|
||||
<CommandBar />
|
||||
</Auth>
|
||||
),
|
||||
loader: async (): Promise<HomeLoaderData | Response> => {
|
||||
if (!isTauri()) {
|
||||
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,
|
||||
}
|
||||
{
|
||||
path: paths.HOME,
|
||||
element: (
|
||||
<Auth>
|
||||
<Outlet />
|
||||
<Home />
|
||||
<CommandBar />
|
||||
</Auth>
|
||||
),
|
||||
id: paths.HOME,
|
||||
loader: homeLoader,
|
||||
children: [
|
||||
{
|
||||
path: makeUrlPathRelative(paths.SETTINGS),
|
||||
element: <Settings />,
|
||||
},
|
||||
],
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: makeUrlPathRelative(paths.SETTINGS),
|
||||
element: <Settings />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: paths.SIGN_IN,
|
||||
element: <SignIn />,
|
||||
},
|
||||
])
|
||||
)
|
||||
{
|
||||
path: paths.SIGN_IN,
|
||||
element: <SignIn />,
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
/**
|
||||
* 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 { SourceRange } from 'lang/wasm'
|
||||
import { Axis } from 'lib/selections'
|
||||
import { BaseUnit, SETTINGS_PERSIST_KEY } from 'lib/settings'
|
||||
import { type BaseUnit } from 'lib/settings/settingsTypes'
|
||||
import { SETTINGS_PERSIST_KEY } from 'lib/constants'
|
||||
import { CameraControls } from './CameraControls'
|
||||
|
||||
type SendType = ReturnType<typeof useModelingContext>['send']
|
||||
|
@ -1,61 +1,14 @@
|
||||
import { Dialog, Popover, Transition } from '@headlessui/react'
|
||||
import { Fragment, createContext, useEffect } from 'react'
|
||||
import { Fragment, useEffect } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { useCommandsContext } from 'hooks/useCommandsContext'
|
||||
import { useMachine } from '@xstate/react'
|
||||
import { commandBarMachine } from 'machines/commandBarMachine'
|
||||
import { EventFrom, StateFrom } from 'xstate'
|
||||
import CommandBarArgument from './CommandBarArgument'
|
||||
import CommandComboBox from '../CommandComboBox'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import CommandBarReview from './CommandBarReview'
|
||||
|
||||
type CommandsContextType = {
|
||||
commandBarState: StateFrom<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>
|
||||
)
|
||||
}
|
||||
import { useLocation } from 'react-router-dom'
|
||||
|
||||
export const CommandBar = () => {
|
||||
const { pathname } = useLocation()
|
||||
const { commandBarState, commandBarSend } = useCommandsContext()
|
||||
const {
|
||||
context: { selectedCommand, currentArgument, commands },
|
||||
@ -63,6 +16,12 @@ export const CommandBar = () => {
|
||||
const isSelectionArgument = currentArgument?.inputType === 'selection'
|
||||
const WrapperComponent = isSelectionArgument ? Popover : Dialog
|
||||
|
||||
// Close the command bar when navigating
|
||||
useEffect(() => {
|
||||
commandBarSend({ type: 'Close' })
|
||||
}, [pathname])
|
||||
|
||||
// Hook up keyboard shortcuts
|
||||
useHotkeys(['mod+k', 'mod+/'], () => {
|
||||
if (commandBarState.context.commands.length === 0) return
|
||||
if (commandBarState.matches('Closed')) {
|
||||
@ -164,4 +123,4 @@ export const CommandBar = () => {
|
||||
)
|
||||
}
|
||||
|
||||
export default CommandBarProvider
|
||||
export default CommandBar
|
||||
|
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 { BrowserRouter } from 'react-router-dom'
|
||||
import { SettingsAuthProvider } from './SettingsAuthProvider'
|
||||
import CommandBarProvider from './CommandBar/CommandBar'
|
||||
import { SettingsAuthProviderJest } from './SettingsAuthProvider'
|
||||
import { CommandBarProvider } from './CommandBar/CommandBarProvider'
|
||||
import {
|
||||
NETWORK_HEALTH_TEXT,
|
||||
NetworkHealthIndicator,
|
||||
@ -13,7 +13,7 @@ function TestWrap({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<CommandBarProvider>
|
||||
<SettingsAuthProvider>{children}</SettingsAuthProvider>
|
||||
<SettingsAuthProviderJest>{children}</SettingsAuthProviderJest>
|
||||
</CommandBarProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
|
@ -2,9 +2,9 @@ import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import ProjectSidebarMenu from './ProjectSidebarMenu'
|
||||
import { type ProjectWithEntryPointMetadata } from 'lib/types'
|
||||
import { SettingsAuthProvider } from './SettingsAuthProvider'
|
||||
import { SettingsAuthProviderJest } from './SettingsAuthProvider'
|
||||
import { APP_NAME } from 'lib/constants'
|
||||
import { vi } from 'vitest'
|
||||
import { CommandBarProvider } from './CommandBar/CommandBarProvider'
|
||||
|
||||
const now = new Date()
|
||||
const projectWellFormed = {
|
||||
@ -41,9 +41,11 @@ describe('ProjectSidebarMenu tests', () => {
|
||||
test('Renders the project name', () => {
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<SettingsAuthProvider>
|
||||
<ProjectSidebarMenu project={projectWellFormed} />
|
||||
</SettingsAuthProvider>
|
||||
<CommandBarProvider>
|
||||
<SettingsAuthProviderJest>
|
||||
<ProjectSidebarMenu project={projectWellFormed} />
|
||||
</SettingsAuthProviderJest>
|
||||
</CommandBarProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
|
||||
@ -60,9 +62,11 @@ describe('ProjectSidebarMenu tests', () => {
|
||||
test('Renders app name if given no project', () => {
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<SettingsAuthProvider>
|
||||
<ProjectSidebarMenu />
|
||||
</SettingsAuthProvider>
|
||||
<CommandBarProvider>
|
||||
<SettingsAuthProviderJest>
|
||||
<ProjectSidebarMenu />
|
||||
</SettingsAuthProviderJest>
|
||||
</CommandBarProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
|
||||
@ -74,9 +78,14 @@ describe('ProjectSidebarMenu tests', () => {
|
||||
test('Renders as a link if set to do so', () => {
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<SettingsAuthProvider>
|
||||
<ProjectSidebarMenu project={projectWellFormed} renderAsLink={true} />
|
||||
</SettingsAuthProvider>
|
||||
<CommandBarProvider>
|
||||
<SettingsAuthProviderJest>
|
||||
<ProjectSidebarMenu
|
||||
project={projectWellFormed}
|
||||
renderAsLink={true}
|
||||
/>
|
||||
</SettingsAuthProviderJest>
|
||||
</CommandBarProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
|
||||
|
@ -1,12 +1,15 @@
|
||||
import { useMachine } from '@xstate/react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useNavigate, useRouteLoaderData } from 'react-router-dom'
|
||||
import { paths } from 'lib/paths'
|
||||
import { authMachine, TOKEN_PERSIST_KEY } from '../machines/authMachine'
|
||||
import withBaseUrl from '../lib/withBaseURL'
|
||||
import React, { createContext, useEffect, useRef } from 'react'
|
||||
import React, { createContext, useEffect } from 'react'
|
||||
import useStateMachineCommands from '../hooks/useStateMachineCommands'
|
||||
import { settingsMachine } from 'machines/settingsMachine'
|
||||
import { SETTINGS_PERSIST_KEY } from 'lib/settings'
|
||||
import {
|
||||
fallbackLoadedSettings,
|
||||
validateSettings,
|
||||
} from 'lib/settings/settingsUtils'
|
||||
import { toast } from 'react-hot-toast'
|
||||
import { setThemeClass, Themes } from 'lib/theme'
|
||||
import {
|
||||
@ -20,6 +23,7 @@ import { isTauri } from 'lib/isTauri'
|
||||
import { settingsCommandBarConfig } from 'lib/commandBarConfigs/settingsCommandConfig'
|
||||
import { authCommandBarConfig } from 'lib/commandBarConfigs/authCommandConfig'
|
||||
import { sceneInfra } from 'clientSideScene/sceneInfra'
|
||||
import { kclManager } from 'lang/KclSingleton'
|
||||
|
||||
type MachineContext<T extends AnyStateMachine> = {
|
||||
state: StateFrom<T>
|
||||
@ -27,7 +31,7 @@ type MachineContext<T extends AnyStateMachine> = {
|
||||
send: Prop<InterpreterFrom<T>, 'send'>
|
||||
}
|
||||
|
||||
type GlobalContext = {
|
||||
type SettingsAuthContextType = {
|
||||
auth: MachineContext<typeof authMachine>
|
||||
settings: MachineContext<typeof settingsMachine>
|
||||
}
|
||||
@ -38,37 +42,66 @@ type GlobalContext = {
|
||||
let settingsStateRef: (typeof settingsMachine)['context'] | undefined
|
||||
export const getSettingsState = () => settingsStateRef
|
||||
|
||||
export const SettingsAuthContext = createContext({} as GlobalContext)
|
||||
export const SettingsAuthContext = createContext({} as SettingsAuthContextType)
|
||||
|
||||
export const SettingsAuthProvider = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
const loadedSettings = useRouteLoaderData(paths.INDEX) as Awaited<
|
||||
ReturnType<typeof validateSettings>
|
||||
>
|
||||
return (
|
||||
<SettingsAuthProviderBase loadedSettings={loadedSettings}>
|
||||
{children}
|
||||
</SettingsAuthProviderBase>
|
||||
)
|
||||
}
|
||||
|
||||
// Settings machine setup
|
||||
const retrievedSettings = useRef(
|
||||
localStorage?.getItem(SETTINGS_PERSIST_KEY) || '{}'
|
||||
)
|
||||
const persistedSettings = Object.assign(
|
||||
settingsMachine.initialState.context,
|
||||
JSON.parse(retrievedSettings.current) as Partial<
|
||||
(typeof settingsMachine)['context']
|
||||
>
|
||||
// For use in jest tests we don't want to use the loader data
|
||||
// and mock the whole Router
|
||||
export const SettingsAuthProviderJest = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) => {
|
||||
const loadedSettings = fallbackLoadedSettings
|
||||
return (
|
||||
<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(
|
||||
settingsMachine,
|
||||
{
|
||||
context: persistedSettings,
|
||||
context: initialLoadedContext,
|
||||
actions: {
|
||||
setClientSideSceneUnits: (context, event) => {
|
||||
const newBaseUnit =
|
||||
event.type === 'Set Base Unit'
|
||||
? event.data.baseUnit
|
||||
: context.baseUnit
|
||||
sceneInfra.baseUnit = newBaseUnit
|
||||
},
|
||||
toastSuccess: (context, event) => {
|
||||
const truncatedNewValue =
|
||||
'data' in event && event.data instanceof Object
|
||||
? (String(
|
||||
context[Object.keys(event.data)[0] as keyof typeof context]
|
||||
).substring(0, 28) as any)
|
||||
? (context[Object.keys(event.data)[0] as keyof typeof context]
|
||||
.toString()
|
||||
.substring(0, 28) as any)
|
||||
: undefined
|
||||
toast.success(
|
||||
event.type +
|
||||
@ -79,6 +112,7 @@ export const SettingsAuthProvider = ({
|
||||
: '')
|
||||
)
|
||||
},
|
||||
'Execute AST': () => kclManager.executeAst(),
|
||||
},
|
||||
}
|
||||
)
|
||||
@ -103,7 +137,6 @@ export const SettingsAuthProvider = ({
|
||||
if (settingsState.context.theme !== 'system') return
|
||||
setThemeClass(e.matches ? Themes.Dark : Themes.Light)
|
||||
}
|
||||
sceneInfra.baseUnit = settingsState?.context?.baseUnit || 'mm'
|
||||
|
||||
matcher.addEventListener('change', listener)
|
||||
return () => matcher.removeEventListener('change', listener)
|
||||
|
@ -7,8 +7,8 @@ import {
|
||||
createRoutesFromElements,
|
||||
} from 'react-router-dom'
|
||||
import { Models } from '@kittycad/lib'
|
||||
import { SettingsAuthProvider } from './SettingsAuthProvider'
|
||||
import CommandBarProvider from './CommandBar/CommandBar'
|
||||
import { SettingsAuthProviderJest } from './SettingsAuthProvider'
|
||||
import { CommandBarProvider } from './CommandBar/CommandBarProvider'
|
||||
|
||||
type User = Models['User_type']
|
||||
|
||||
@ -113,7 +113,7 @@ function TestWrap({ children }: { children: React.ReactNode }) {
|
||||
path="/file/:id"
|
||||
element={
|
||||
<CommandBarProvider>
|
||||
<SettingsAuthProvider>{children}</SettingsAuthProvider>
|
||||
<SettingsAuthProviderJest>{children}</SettingsAuthProviderJest>
|
||||
</CommandBarProvider>
|
||||
}
|
||||
/>
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { CommandsContext } from 'components/CommandBar/CommandBar'
|
||||
import { CommandsContext } from 'components/CommandBar/CommandBarProvider'
|
||||
import { useContext } from 'react'
|
||||
|
||||
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 { BaseUnit, Toggle, UnitSystem, baseUnitsUnion } from 'lib/settings'
|
||||
import { type CommandSetConfig } from '../commandTypes'
|
||||
import {
|
||||
type BaseUnit,
|
||||
type Toggle,
|
||||
UnitSystem,
|
||||
baseUnitsUnion,
|
||||
} from 'lib/settings/settingsTypes'
|
||||
import { settingsMachine } from 'machines/settingsMachine'
|
||||
import { CameraSystem, cameraSystems } from '../cameraControls'
|
||||
import { type CameraSystem, cameraSystems } from '../cameraControls'
|
||||
import { Themes } from '../theme'
|
||||
|
||||
// SETTINGS MACHINE
|
||||
|
@ -1 +1,4 @@
|
||||
export const APP_NAME = 'Modeling App'
|
||||
export const DEFAULT_PROJECT_NAME = 'project-$nnn'
|
||||
export const SETTINGS_PERSIST_KEY = 'SETTINGS_PERSIST_KEY'
|
||||
export const SETTINGS_FILE_NAME = 'settings.json'
|
||||
|
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 { CameraSystem } from './cameraControls'
|
||||
import { Themes } from './theme'
|
||||
|
||||
export const DEFAULT_PROJECT_NAME = 'project-$nnn'
|
||||
export const SETTINGS_PERSIST_KEY = 'SETTINGS_PERSIST_KEY'
|
||||
export const SETTINGS_FILE_NAME = 'settings.json'
|
||||
import { type CameraSystem } from '../cameraControls'
|
||||
import { Themes } from 'lib/theme'
|
||||
|
||||
export enum UnitSystem {
|
||||
Imperial = 'imperial',
|
||||
@ -21,6 +17,7 @@ export type BaseUnit = Models['UnitLength_type']
|
||||
export const baseUnitsUnion = Object.values(baseUnits).flatMap((v) => v)
|
||||
|
||||
export type Toggle = 'On' | 'Off'
|
||||
export const toggleAsArray = ['On', 'Off'] as const
|
||||
|
||||
export type SettingsMachineContext = {
|
||||
baseUnit: BaseUnit
|
||||
@ -33,15 +30,3 @@ export type SettingsMachineContext = {
|
||||
theme: Themes
|
||||
unitSystem: UnitSystem
|
||||
}
|
||||
|
||||
export const initialSettings: SettingsMachineContext = {
|
||||
baseUnit: 'mm' as BaseUnit,
|
||||
cameraControls: 'KittyCAD' as CameraSystem,
|
||||
defaultDirectory: '',
|
||||
defaultProjectName: DEFAULT_PROJECT_NAME,
|
||||
onboardingStatus: '',
|
||||
showDebugPanel: false,
|
||||
textWrapping: 'On' as Toggle,
|
||||
theme: Themes.System,
|
||||
unitSystem: UnitSystem.Metric,
|
||||
}
|
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,
|
||||
exists,
|
||||
readDir,
|
||||
readTextFile,
|
||||
writeTextFile,
|
||||
} from '@tauri-apps/api/fs'
|
||||
import { documentDir, homeDir, sep } from '@tauri-apps/api/path'
|
||||
import { appConfigDir, documentDir, homeDir, sep } from '@tauri-apps/api/path'
|
||||
import { isTauri } from './isTauri'
|
||||
import { type ProjectWithEntryPointMetadata } from 'lib/types'
|
||||
import { metadata } from 'tauri-plugin-fs-extra-api'
|
||||
import { settingsMachine } from 'machines/settingsMachine'
|
||||
import { ContextFrom } from 'xstate'
|
||||
import { SETTINGS_FILE_NAME } from 'lib/constants'
|
||||
|
||||
const PROJECT_FOLDER = 'zoo-modeling-app-projects'
|
||||
export const FILE_EXT = '.kcl'
|
||||
@ -26,39 +30,100 @@ const RELEVANT_FILE_TYPES = [
|
||||
'stl',
|
||||
]
|
||||
|
||||
// Initializes the project directory and returns the path
|
||||
export async function initializeProjectDirectory(directory: string) {
|
||||
if (!isTauri()) {
|
||||
throw new Error(
|
||||
'initializeProjectDirectory() can only be called from a Tauri app'
|
||||
)
|
||||
type PathWithPossibleError = {
|
||||
path: string | null
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
export async function getInitialDefaultDir() {
|
||||
if (!isTauri()) return ''
|
||||
let dir
|
||||
try {
|
||||
dir = await documentDir()
|
||||
} catch (e) {
|
||||
dir = `${await homeDir()}Documents/` // for headless Linux (eg. Github Actions)
|
||||
}
|
||||
|
||||
return dir + PROJECT_FOLDER
|
||||
}
|
||||
|
||||
// Initializes the project directory and returns the path
|
||||
// with any Errors that occurred
|
||||
export async function initializeProjectDirectory(
|
||||
directory: string
|
||||
): Promise<PathWithPossibleError> {
|
||||
let returnValue: PathWithPossibleError = {
|
||||
path: null,
|
||||
error: null,
|
||||
}
|
||||
|
||||
if (!isTauri()) return returnValue
|
||||
|
||||
if (directory) {
|
||||
const dirExists = await exists(directory)
|
||||
if (!dirExists) {
|
||||
await createDir(directory, { recursive: true })
|
||||
returnValue = await testAndCreateDir(directory, returnValue)
|
||||
}
|
||||
|
||||
// If the directory from settings does not exist or could not be created,
|
||||
// use the default directory
|
||||
if (returnValue.path === null) {
|
||||
const INITIAL_DEFAULT_DIR = await getInitialDefaultDir()
|
||||
const defaultReturnValue = await testAndCreateDir(
|
||||
INITIAL_DEFAULT_DIR,
|
||||
returnValue,
|
||||
{
|
||||
exists: 'Error checking default directory.',
|
||||
create: 'Error creating default directory.',
|
||||
}
|
||||
)
|
||||
returnValue.path = defaultReturnValue.path
|
||||
returnValue.error =
|
||||
returnValue.error === null ? defaultReturnValue.error : returnValue.error
|
||||
}
|
||||
|
||||
return returnValue
|
||||
}
|
||||
|
||||
async function testAndCreateDir(
|
||||
directory: string,
|
||||
returnValue = {
|
||||
path: null,
|
||||
error: null,
|
||||
} as PathWithPossibleError,
|
||||
errorMessages = {
|
||||
exists:
|
||||
'Error checking directory at path from saved settings. Using default.',
|
||||
create:
|
||||
'Error creating directory at path from saved settings. Using default.',
|
||||
}
|
||||
): Promise<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
|
||||
}
|
||||
return directory
|
||||
} else if (dirExists === true) {
|
||||
returnValue.path = directory
|
||||
}
|
||||
|
||||
let docDirectory: string
|
||||
try {
|
||||
docDirectory = await documentDir()
|
||||
} catch (e) {
|
||||
console.log('error', e)
|
||||
docDirectory = `${await homeDir()}Documents/` // for headless Linux (eg. Github Actions)
|
||||
}
|
||||
|
||||
const INITIAL_DEFAULT_DIR = docDirectory + PROJECT_FOLDER
|
||||
|
||||
const defaultDirExists = await exists(INITIAL_DEFAULT_DIR)
|
||||
|
||||
if (!defaultDirExists) {
|
||||
await createDir(INITIAL_DEFAULT_DIR, { recursive: true })
|
||||
}
|
||||
|
||||
return INITIAL_DEFAULT_DIR
|
||||
return returnValue
|
||||
}
|
||||
|
||||
export function isProjectDirectory(fileOrDir: Partial<FileEntry>) {
|
||||
@ -309,3 +374,44 @@ function getPaddedIdentifierRegExp() {
|
||||
const escapedIdentifier = escapeRegExpChars(INDEX_IDENTIFIER)
|
||||
return new RegExp(`${escapedIdentifier}(${escapedIdentifier.slice(-1)}*)`)
|
||||
}
|
||||
|
||||
export async function getSettingsFilePath() {
|
||||
const dir = await appConfigDir()
|
||||
return dir + SETTINGS_FILE_NAME
|
||||
}
|
||||
|
||||
export async function writeToSettingsFile(
|
||||
settings: ContextFrom<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 = {
|
||||
projects: ProjectWithEntryPointMetadata[]
|
||||
newDefaultDirectory?: string
|
||||
}
|
||||
|
@ -450,6 +450,7 @@ export const commandBarMachine = createMachine(
|
||||
|
||||
const hasMismatchedDefaultValueType =
|
||||
isRequired &&
|
||||
resolvedDefaultValue !== undefined &&
|
||||
typeof argValue !== typeof resolvedDefaultValue &&
|
||||
!(argConfig.inputType === 'kcl' || argConfig.skip)
|
||||
const hasInvalidKclValue =
|
||||
|
@ -1,40 +1,41 @@
|
||||
import { assign, createMachine } from 'xstate'
|
||||
import { Themes, getSystemTheme, setThemeClass } from '../lib/theme'
|
||||
import { Themes, getSystemTheme, setThemeClass } from 'lib/theme'
|
||||
import { CameraSystem } from 'lib/cameraControls'
|
||||
import { isTauri } from 'lib/isTauri'
|
||||
import { writeToSettingsFile } from 'lib/tauriFS'
|
||||
import { DEFAULT_PROJECT_NAME, SETTINGS_PERSIST_KEY } from 'lib/constants'
|
||||
import {
|
||||
BaseUnit,
|
||||
DEFAULT_PROJECT_NAME,
|
||||
SETTINGS_PERSIST_KEY,
|
||||
SettingsMachineContext,
|
||||
Toggle,
|
||||
UnitSystem,
|
||||
} from 'lib/settings'
|
||||
|
||||
const kclManagerPromise = import('lang/KclSingleton').then(
|
||||
(module) => module.kclManager
|
||||
)
|
||||
type BaseUnit,
|
||||
type SettingsMachineContext,
|
||||
type Toggle,
|
||||
} from 'lib/settings/settingsTypes'
|
||||
|
||||
export const settingsMachine = createMachine(
|
||||
{
|
||||
/** @xstate-layout N4IgpgJg5mDOIC5QGUwBc0EsB2VYDpMIAbMAYlTQAIAVACzAFswBtABgF1FQAHAe1iYsfbNxAAPRAA42+AEwB2KQFYAzGznKAnADZli1QBoQAT2kBGKfm37lOned3nzqgL6vjlLLgJFSFdCoAETAAMwBDAFdiagAFACc+ACswAGNqADlw5nYuJBB+QWFRfMkEABY5fDYa2rra83LjMwQdLWV8BXLyuxlVLU1Ld090bzxCEnJKYLComODMeLS0PniTXLFCoUwRMTK7fC1zNql7NgUjtnKjU0RlBSqpLVUVPVUda60tYZAvHHG-FNAgBVbBCKjIEywNBMDb5LbFPaILqdfRSORsS4qcxXZqIHqyK6qY4XOxsGTKco-P4+Cb+aYAIXCsDAVFBQjhvAE212pWkskUKnUml0+gUNxaqkU+EccnKF1UCnucnMcjcHl+o3+vkmZBofCgUFIMwARpEoFRYuFsGBiJyCtzEXzWrJlGxlKdVFKvfY1XiEBjyvhVOVzBdzu13pYFNStbTAQFqAB5bAmvjheIQf4QtDhNCRWD2hE7EqgfayHTEh7lHQNSxSf1Scz4cpHHFyFVujTKczuDXYPgQOBiGl4TaOktIhAAWg6X3nC4Xp39050sYw2rpYHHRUnztVhPJqmUlIGbEriv9WhrLZ6uibHcqUr7riAA */
|
||||
/** @xstate-layout N4IgpgJg5mDOIC5QGUwBc0EsB2VYDpMIAbMAYlTQAIBBY4qyrXWAbQAYBdRUABwHtYmLP2w8QAD0TsANCACe0gL5K5THHkIlylKgCEAhrDBUAqtmEduSEAKEixNqQgCM7AJz52AJgAsAVg8AZgAOEIA2fxd3XzlFBCCXf3xfdlS0kN9vGIiVNXRmTSJSCnQqAGEDAFswACcDCtE0Wv5iNi5xO2FMUXFnWQVlVRB1Fi0S3QARMAAzAwBXYmpJzFqwAGM0flr5K07Bbt6nRH9w-HcXcPcI8PYAdgu0oLiTu+98EPdQ0-8g8N8gu53HkRgUNARijoytM5otqAAFFoAKw21AActUwHsbF0HH1EFkvOxiSTScSXLFBggrsk7r4AuEQuxAd4oiEQaMitpStQAPLYABG-AMtQgGkYaAMaHm7WsfAOeOOCEC+HCiTevlu5JcYReCBCLhSFzc3m8SWJrJcHLBY0hPKoABUwBJqAB1eq8XgabHy+w9Rygfp69jWjDg8ZQ6gOgAWYBqPtsCv9+P17Hw3juIV+Pn87kiGeeVINIXwuf8rPC4WiVZcQVDhQh3N05mEjHksDQcYTuOTSrp+Du5ZC3g8bizbkp8QCaaelwep3YTP8vnr4btDv4UCgpCo0wF8ygVHhBmwYGI3aTR0DiFupfY-giQSC3iflZfepHvnwQV8Lge93cX4qxCO4VGGbB+AgOBxE5eAcUvANJEQABaXwQj1ZCQLvUkmXpFwzStYZYIjfY-SvJDXBHLxa01Stc0yIE7j1NwKW-NUAl8a4-DuZkwKUIA */
|
||||
id: 'Settings',
|
||||
predictableActionArguments: true,
|
||||
context: {
|
||||
baseUnit: 'mm',
|
||||
cameraControls: 'KittyCAD',
|
||||
defaultDirectory: '',
|
||||
defaultProjectName: DEFAULT_PROJECT_NAME,
|
||||
onboardingStatus: '',
|
||||
showDebugPanel: false,
|
||||
textWrapping: 'On',
|
||||
theme: Themes.System,
|
||||
unitSystem: UnitSystem.Metric,
|
||||
} as SettingsMachineContext,
|
||||
context: {} as SettingsMachineContext,
|
||||
initial: 'idle',
|
||||
states: {
|
||||
idle: {
|
||||
entry: ['setThemeClass'],
|
||||
entry: ['setThemeClass', 'setClientSideSceneUnits', 'persistSettings'],
|
||||
on: {
|
||||
'Set All Settings': {
|
||||
actions: [
|
||||
assign((context, event) => {
|
||||
return {
|
||||
...context,
|
||||
...event.data,
|
||||
}
|
||||
}),
|
||||
'persistSettings',
|
||||
'setThemeClass',
|
||||
],
|
||||
target: 'idle',
|
||||
internal: true,
|
||||
},
|
||||
'Set Base Unit': {
|
||||
actions: [
|
||||
assign({
|
||||
@ -42,9 +43,8 @@ export const settingsMachine = createMachine(
|
||||
}),
|
||||
'persistSettings',
|
||||
'toastSuccess',
|
||||
async () => {
|
||||
;(await kclManagerPromise).executeAst()
|
||||
},
|
||||
'setClientSideSceneUnits',
|
||||
'Execute AST',
|
||||
],
|
||||
target: 'idle',
|
||||
internal: true,
|
||||
@ -125,9 +125,7 @@ export const settingsMachine = createMachine(
|
||||
}),
|
||||
'persistSettings',
|
||||
'toastSuccess',
|
||||
async () => {
|
||||
;(await kclManagerPromise).executeAst()
|
||||
},
|
||||
'Execute AST',
|
||||
],
|
||||
target: 'idle',
|
||||
internal: true,
|
||||
@ -151,6 +149,7 @@ export const settingsMachine = createMachine(
|
||||
tsTypes: {} as import('./settingsMachine.typegen').Typegen0,
|
||||
schema: {
|
||||
events: {} as
|
||||
| { type: 'Set All Settings'; data: Partial<SettingsMachineContext> }
|
||||
| { type: 'Set Base Unit'; data: { baseUnit: BaseUnit } }
|
||||
| {
|
||||
type: 'Set Camera Controls'
|
||||
@ -174,6 +173,11 @@ export const settingsMachine = createMachine(
|
||||
{
|
||||
actions: {
|
||||
persistSettings: (context) => {
|
||||
if (isTauri()) {
|
||||
writeToSettingsFile(context).catch((err) => {
|
||||
console.error('Error writing settings:', err)
|
||||
})
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(SETTINGS_PERSIST_KEY, JSON.stringify(context))
|
||||
} catch (e) {
|
||||
|
@ -31,21 +31,22 @@ import {
|
||||
import useStateMachineCommands from '../hooks/useStateMachineCommands'
|
||||
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
|
||||
import { useCommandsContext } from 'hooks/useCommandsContext'
|
||||
import { DEFAULT_PROJECT_NAME } from 'lib/settings'
|
||||
import { DEFAULT_PROJECT_NAME } from 'lib/constants'
|
||||
import { sep } from '@tauri-apps/api/path'
|
||||
import { homeCommandBarConfig } from 'lib/commandBarConfigs/homeCommandConfig'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { isTauri } from 'lib/isTauri'
|
||||
import { kclManager } from 'lang/KclSingleton'
|
||||
import { useLspContext } from 'components/LspProvider'
|
||||
import { useValidateSettings } from 'hooks/useValidateSettings'
|
||||
|
||||
// This route only opens in the Tauri desktop context for now,
|
||||
// as defined in Router.tsx, so we can use the Tauri APIs and types.
|
||||
const Home = () => {
|
||||
useValidateSettings()
|
||||
const { commandBarSend } = useCommandsContext()
|
||||
const navigate = useNavigate()
|
||||
const { projects: loadedProjects, newDefaultDirectory } =
|
||||
useLoaderData() as HomeLoaderData
|
||||
const { projects: loadedProjects } = useLoaderData() as HomeLoaderData
|
||||
const {
|
||||
settings: {
|
||||
context: { defaultDirectory, defaultProjectName },
|
||||
@ -54,18 +55,11 @@ const Home = () => {
|
||||
} = useSettingsAuthContext()
|
||||
const { onProjectOpen } = useLspContext()
|
||||
|
||||
// Set the default directory if it's been updated
|
||||
// during the loading of the home page. This is wrapped
|
||||
// in a single-use effect to avoid a potential infinite loop.
|
||||
// Cancel all KCL executions while on the home page
|
||||
useEffect(() => {
|
||||
kclManager.cancelAllExecutions()
|
||||
if (newDefaultDirectory) {
|
||||
sendToSettings({
|
||||
type: 'Set Default Directory',
|
||||
data: { defaultDirectory: newDefaultDirectory },
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
useHotkeys(
|
||||
isTauri() ? 'mod+,' : 'shift+mod+,',
|
||||
() => navigate(paths.HOME + paths.SETTINGS),
|
||||
|
@ -1,5 +1,9 @@
|
||||
import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons'
|
||||
import { BaseUnit, baseUnits, UnitSystem } from 'lib/settings'
|
||||
import {
|
||||
type BaseUnit,
|
||||
baseUnits,
|
||||
UnitSystem,
|
||||
} from 'lib/settings/settingsTypes'
|
||||
import { ActionButton } from 'components/ActionButton'
|
||||
import { SettingsSection } from '../Settings'
|
||||
import { Toggle } from 'components/Toggle/Toggle'
|
||||
|
@ -2,7 +2,12 @@ import { faArrowRotateBack, faXmark } from '@fortawesome/free-solid-svg-icons'
|
||||
import { ActionButton } from '../components/ActionButton'
|
||||
import { AppHeader } from '../components/AppHeader'
|
||||
import { open } from '@tauri-apps/api/dialog'
|
||||
import { BaseUnit, DEFAULT_PROJECT_NAME, baseUnits } from 'lib/settings'
|
||||
import { DEFAULT_PROJECT_NAME, SETTINGS_PERSIST_KEY } from 'lib/constants'
|
||||
import {
|
||||
type BaseUnit,
|
||||
UnitSystem,
|
||||
baseUnits,
|
||||
} from 'lib/settings/settingsTypes'
|
||||
import { Toggle } from 'components/Toggle/Toggle'
|
||||
import { useLocation, useNavigate, useRouteLoaderData } from 'react-router-dom'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
@ -15,17 +20,22 @@ import {
|
||||
cameraSystems,
|
||||
cameraMouseDragGuards,
|
||||
} from 'lib/cameraControls'
|
||||
import { UnitSystem } from 'lib/settings'
|
||||
import { useDotDotSlash } from 'hooks/useDotDotSlash'
|
||||
import {
|
||||
createNewProject,
|
||||
getNextProjectIndex,
|
||||
getProjectsInDir,
|
||||
getSettingsFilePath,
|
||||
initializeProjectDirectory,
|
||||
interpolateProjectNameWithIndex,
|
||||
} from 'lib/tauriFS'
|
||||
import { initialSettings } from 'lib/settings/initialSettings'
|
||||
import { ONBOARDING_PROJECT_NAME } from './Onboarding'
|
||||
import { sep } from '@tauri-apps/api/path'
|
||||
import { bracket } from 'lib/exampleKcl'
|
||||
import { isTauri } from 'lib/isTauri'
|
||||
import { invoke } from '@tauri-apps/api'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
export const Settings = () => {
|
||||
const APP_VERSION = import.meta.env.PACKAGE_VERSION || 'unknown'
|
||||
@ -54,8 +64,11 @@ export const Settings = () => {
|
||||
} = useSettingsAuthContext()
|
||||
|
||||
async function handleDirectorySelection() {
|
||||
// the `recursive` property added following
|
||||
// this advice for permissions: https://github.com/tauri-apps/tauri/issues/4851#issuecomment-1210711455
|
||||
const newDirectory = await open({
|
||||
directory: true,
|
||||
recursive: true,
|
||||
defaultPath: defaultDirectory || paths.INDEX,
|
||||
title: 'Choose a new default directory',
|
||||
})
|
||||
@ -302,6 +315,59 @@ export const Settings = () => {
|
||||
Replay Onboarding
|
||||
</ActionButton>
|
||||
</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">
|
||||
{/* This uses a Vite plugin, set in vite.config.ts
|
||||
to inject the version from package.json */}
|
||||
|
@ -6,8 +6,10 @@ import { Themes, getSystemTheme } from '../lib/theme'
|
||||
import { paths } from 'lib/paths'
|
||||
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
|
||||
import { APP_NAME } from 'lib/constants'
|
||||
import { useValidateSettings } from 'hooks/useValidateSettings'
|
||||
|
||||
const SignIn = () => {
|
||||
useValidateSettings()
|
||||
const getLogoTheme = () =>
|
||||
theme === Themes.Light ||
|
||||
(theme === Themes.System && getSystemTheme() === Themes.Light)
|
||||
|
@ -17,6 +17,13 @@ const config = defineConfig({
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
pool: 'forks',
|
||||
poolOptions: {
|
||||
forks: {
|
||||
maxForks: 2,
|
||||
minForks: 1,
|
||||
}
|
||||
},
|
||||
setupFiles: 'src/setupTests.ts',
|
||||
environment: 'happy-dom',
|
||||
coverage: {
|
||||
|
Reference in New Issue
Block a user