File based settings (#1361)
* 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
This commit is contained in:
		@ -3,6 +3,7 @@ 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'
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
debug helper: unfortunately we do rely on exact coord mouse clicks in a few places
 | 
			
		||||
@ -386,6 +387,53 @@ test('Auto complete works', async ({ page }) => {
 | 
			
		||||
  |> xLine(5, %)`)
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
// 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)
 | 
			
		||||
 | 
			
		||||
@ -8,6 +8,7 @@ use std::io::Read;
 | 
			
		||||
use anyhow::Result;
 | 
			
		||||
use oauth2::TokenResponse;
 | 
			
		||||
use tauri::{InvokeError, Manager};
 | 
			
		||||
use std::process::Command;
 | 
			
		||||
const DEFAULT_HOST: &str = "https://api.kittycad.io";
 | 
			
		||||
 | 
			
		||||
/// This command returns the a json string parse from a toml file at the path.
 | 
			
		||||
@ -142,6 +143,28 @@ 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 +181,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,8 @@
 | 
			
		||||
      "fs": {
 | 
			
		||||
        "scope": [
 | 
			
		||||
          "$HOME/**/*",
 | 
			
		||||
          "$APPDATA/**/*"
 | 
			
		||||
          "$APPDATA/**/*",
 | 
			
		||||
          "$DOCUMENT/**/*"
 | 
			
		||||
        ],
 | 
			
		||||
        "all": true
 | 
			
		||||
      },
 | 
			
		||||
@ -60,7 +61,7 @@
 | 
			
		||||
        "icons/icon.icns",
 | 
			
		||||
        "icons/icon.ico"
 | 
			
		||||
      ],
 | 
			
		||||
      "identifier": "io.kittycad.modeling-app",
 | 
			
		||||
      "identifier": "zoo-modeling-app",
 | 
			
		||||
      "longDescription": "",
 | 
			
		||||
      "macOS": {
 | 
			
		||||
        "entitlements": null,
 | 
			
		||||
 | 
			
		||||
@ -22,7 +22,7 @@ import { getNormalisedCoordinates } from './lib/utils'
 | 
			
		||||
import { useLoaderData, useNavigate } from 'react-router-dom'
 | 
			
		||||
import { type IndexLoaderData } from 'lib/types'
 | 
			
		||||
import { paths } from 'lib/paths'
 | 
			
		||||
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
 | 
			
		||||
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
 | 
			
		||||
import { onboardingPaths } from 'routes/Onboarding/paths'
 | 
			
		||||
import { CodeMenu } from 'components/CodeMenu'
 | 
			
		||||
import { TextEditor } from 'components/TextEditor'
 | 
			
		||||
@ -53,7 +53,7 @@ export function App() {
 | 
			
		||||
    streamDimensions: s.streamDimensions,
 | 
			
		||||
  }))
 | 
			
		||||
 | 
			
		||||
  const { settings } = useGlobalStateContext()
 | 
			
		||||
  const { settings } = useSettingsAuthContext()
 | 
			
		||||
  const { showDebugPanel, onboardingStatus, theme } = settings?.context || {}
 | 
			
		||||
  const { state, send } = useModelingContext()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -1,9 +1,9 @@
 | 
			
		||||
import Loading from './components/Loading'
 | 
			
		||||
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
 | 
			
		||||
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
 | 
			
		||||
 | 
			
		||||
// Wrapper around protected routes, used in src/Router.tsx
 | 
			
		||||
export const Auth = ({ children }: React.PropsWithChildren) => {
 | 
			
		||||
  const { auth } = useGlobalStateContext()
 | 
			
		||||
  const { auth } = useSettingsAuthContext()
 | 
			
		||||
  const isLoggingIn = auth?.state.matches('checkIfLoggedIn')
 | 
			
		||||
 | 
			
		||||
  return isLoggingIn ? (
 | 
			
		||||
 | 
			
		||||
@ -29,11 +29,9 @@ import {
 | 
			
		||||
import { metadata } from 'tauri-plugin-fs-extra-api'
 | 
			
		||||
import DownloadAppBanner from './components/DownloadAppBanner'
 | 
			
		||||
import { WasmErrBanner } from './components/WasmErrBanner'
 | 
			
		||||
import { GlobalStateProvider } from './components/GlobalStateProvider'
 | 
			
		||||
import {
 | 
			
		||||
  SETTINGS_PERSIST_KEY,
 | 
			
		||||
  settingsMachine,
 | 
			
		||||
} from './machines/settingsMachine'
 | 
			
		||||
import { SettingsAuthStateProvider } from './components/SettingsAuthStateProvider'
 | 
			
		||||
import { settingsMachine } from './machines/settingsMachine'
 | 
			
		||||
import { SETTINGS_PERSIST_KEY } from 'lib/settings'
 | 
			
		||||
import { ContextFrom } from 'xstate'
 | 
			
		||||
import CommandBarProvider from 'components/CommandBar/CommandBar'
 | 
			
		||||
import { TEST, VITE_KC_SENTRY_DSN } from './env'
 | 
			
		||||
@ -91,7 +89,9 @@ const addGlobalContextToElements = (
 | 
			
		||||
          ...route,
 | 
			
		||||
          element: (
 | 
			
		||||
            <CommandBarProvider>
 | 
			
		||||
              <GlobalStateProvider>{route.element}</GlobalStateProvider>
 | 
			
		||||
              <SettingsAuthStateProvider>
 | 
			
		||||
                {route.element}
 | 
			
		||||
              </SettingsAuthStateProvider>
 | 
			
		||||
            </CommandBarProvider>
 | 
			
		||||
          ),
 | 
			
		||||
        }
 | 
			
		||||
@ -229,32 +229,42 @@ const router = createBrowserRouter(
 | 
			
		||||
        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,
 | 
			
		||||
        let newDefaultDirectory: string | undefined = undefined
 | 
			
		||||
        if (projectDir.path) {
 | 
			
		||||
          if (projectDir.path !== persistedSettings.defaultDirectory) {
 | 
			
		||||
            localStorage.setItem(
 | 
			
		||||
              SETTINGS_PERSIST_KEY,
 | 
			
		||||
              JSON.stringify({
 | 
			
		||||
                ...persistedSettings,
 | 
			
		||||
                defaultDirectory: projectDir,
 | 
			
		||||
              })
 | 
			
		||||
            )
 | 
			
		||||
            newDefaultDirectory = projectDir.path
 | 
			
		||||
          }
 | 
			
		||||
          const projectsNoMeta = (await readDir(projectDir.path)).filter(
 | 
			
		||||
            isProjectDirectory
 | 
			
		||||
          )
 | 
			
		||||
          const projects = await Promise.all(
 | 
			
		||||
            projectsNoMeta.map(async (p: FileEntry) => ({
 | 
			
		||||
              entrypointMetadata: await metadata(
 | 
			
		||||
                p.path + sep + PROJECT_ENTRYPOINT
 | 
			
		||||
              ),
 | 
			
		||||
              ...p,
 | 
			
		||||
            }))
 | 
			
		||||
          )
 | 
			
		||||
 | 
			
		||||
          return {
 | 
			
		||||
            projects,
 | 
			
		||||
            newDefaultDirectory,
 | 
			
		||||
            error: projectDir.error,
 | 
			
		||||
          }
 | 
			
		||||
        } else {
 | 
			
		||||
          return {
 | 
			
		||||
            projects: [],
 | 
			
		||||
            newDefaultDirectory,
 | 
			
		||||
            error: projectDir.error,
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      children: [
 | 
			
		||||
 | 
			
		||||
@ -2,7 +2,7 @@ import { useRef, useEffect, useState } from 'react'
 | 
			
		||||
import { useModelingContext } from 'hooks/useModelingContext'
 | 
			
		||||
 | 
			
		||||
import { cameraMouseDragGuards } from 'lib/cameraControls'
 | 
			
		||||
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
 | 
			
		||||
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
 | 
			
		||||
import { useStore } from 'useStore'
 | 
			
		||||
import {
 | 
			
		||||
  DEBUG_SHOW_BOTH_SCENES,
 | 
			
		||||
@ -38,7 +38,7 @@ export const ClientSideScene = ({
 | 
			
		||||
  cameraControls,
 | 
			
		||||
}: {
 | 
			
		||||
  cameraControls: ReturnType<
 | 
			
		||||
    typeof useGlobalStateContext
 | 
			
		||||
    typeof useSettingsAuthContext
 | 
			
		||||
  >['settings']['context']['cameraControls']
 | 
			
		||||
}) => {
 | 
			
		||||
  const canvasRef = useRef<HTMLDivElement>(null)
 | 
			
		||||
 | 
			
		||||
@ -2,7 +2,7 @@ import { Toolbar } from '../Toolbar'
 | 
			
		||||
import UserSidebarMenu from './UserSidebarMenu'
 | 
			
		||||
import { type IndexLoaderData } from 'lib/types'
 | 
			
		||||
import ProjectSidebarMenu from './ProjectSidebarMenu'
 | 
			
		||||
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
 | 
			
		||||
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
 | 
			
		||||
import styles from './AppHeader.module.css'
 | 
			
		||||
import { NetworkHealthIndicator } from './NetworkHealthIndicator'
 | 
			
		||||
import { useCommandsContext } from 'hooks/useCommandsContext'
 | 
			
		||||
@ -25,7 +25,7 @@ export const AppHeader = ({
 | 
			
		||||
}: AppHeaderProps) => {
 | 
			
		||||
  const platform = usePlatform()
 | 
			
		||||
  const { commandBarSend } = useCommandsContext()
 | 
			
		||||
  const { auth } = useGlobalStateContext()
 | 
			
		||||
  const { auth } = useSettingsAuthContext()
 | 
			
		||||
  const user = auth?.context?.user
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
 | 
			
		||||
@ -6,7 +6,7 @@ import React from 'react'
 | 
			
		||||
import { useFormik } from 'formik'
 | 
			
		||||
import { Models } from '@kittycad/lib'
 | 
			
		||||
import { engineCommandManager } from '../lang/std/engineConnection'
 | 
			
		||||
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
 | 
			
		||||
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
 | 
			
		||||
 | 
			
		||||
type OutputFormat = Models['OutputFormat_type']
 | 
			
		||||
type OutputTypeKey = OutputFormat['type']
 | 
			
		||||
@ -29,7 +29,7 @@ export const ExportButton = ({ children, className }: ExportButtonProps) => {
 | 
			
		||||
        context: { baseUnit },
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  } = useGlobalStateContext()
 | 
			
		||||
  } = useSettingsAuthContext()
 | 
			
		||||
 | 
			
		||||
  const defaultType = 'gltf'
 | 
			
		||||
  const [type, setType] = React.useState<OutputTypeKey>(defaultType)
 | 
			
		||||
 | 
			
		||||
@ -10,7 +10,7 @@ import {
 | 
			
		||||
} from 'xstate'
 | 
			
		||||
import { SetSelections, modelingMachine } from 'machines/modelingMachine'
 | 
			
		||||
import { useSetupEngineManager } from 'hooks/useSetupEngineManager'
 | 
			
		||||
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
 | 
			
		||||
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
 | 
			
		||||
import { isCursorInSketchCommandRange } from 'lang/util'
 | 
			
		||||
import { engineCommandManager } from 'lang/std/engineConnection'
 | 
			
		||||
import { kclManager, useKclContext } from 'lang/KclSingleton'
 | 
			
		||||
@ -53,7 +53,7 @@ export const ModelingMachineProvider = ({
 | 
			
		||||
}: {
 | 
			
		||||
  children: React.ReactNode
 | 
			
		||||
}) => {
 | 
			
		||||
  const { auth } = useGlobalStateContext()
 | 
			
		||||
  const { auth } = useSettingsAuthContext()
 | 
			
		||||
  const { code } = useKclContext()
 | 
			
		||||
  const token = auth?.context?.token
 | 
			
		||||
  const streamRef = useRef<HTMLDivElement>(null)
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,6 @@
 | 
			
		||||
import { fireEvent, render, screen } from '@testing-library/react'
 | 
			
		||||
import { BrowserRouter } from 'react-router-dom'
 | 
			
		||||
import { GlobalStateProvider } from './GlobalStateProvider'
 | 
			
		||||
import { SettingsAuthStateProvider } from './SettingsAuthStateProvider'
 | 
			
		||||
import CommandBarProvider from './CommandBar/CommandBar'
 | 
			
		||||
import {
 | 
			
		||||
  NETWORK_HEALTH_TEXT,
 | 
			
		||||
@ -13,7 +13,7 @@ function TestWrap({ children }: { children: React.ReactNode }) {
 | 
			
		||||
  return (
 | 
			
		||||
    <BrowserRouter>
 | 
			
		||||
      <CommandBarProvider>
 | 
			
		||||
        <GlobalStateProvider>{children}</GlobalStateProvider>
 | 
			
		||||
        <SettingsAuthStateProvider>{children}</SettingsAuthStateProvider>
 | 
			
		||||
      </CommandBarProvider>
 | 
			
		||||
    </BrowserRouter>
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
@ -2,7 +2,7 @@ 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 { GlobalStateProvider } from './GlobalStateProvider'
 | 
			
		||||
import { SettingsAuthStateProvider } from './SettingsAuthStateProvider'
 | 
			
		||||
import CommandBarProvider from './CommandBar/CommandBar'
 | 
			
		||||
import { APP_NAME } from 'lib/constants'
 | 
			
		||||
 | 
			
		||||
@ -42,9 +42,9 @@ describe('ProjectSidebarMenu tests', () => {
 | 
			
		||||
    render(
 | 
			
		||||
      <BrowserRouter>
 | 
			
		||||
        <CommandBarProvider>
 | 
			
		||||
          <GlobalStateProvider>
 | 
			
		||||
          <SettingsAuthStateProvider>
 | 
			
		||||
            <ProjectSidebarMenu project={projectWellFormed} />
 | 
			
		||||
          </GlobalStateProvider>
 | 
			
		||||
          </SettingsAuthStateProvider>
 | 
			
		||||
        </CommandBarProvider>
 | 
			
		||||
      </BrowserRouter>
 | 
			
		||||
    )
 | 
			
		||||
@ -63,9 +63,9 @@ describe('ProjectSidebarMenu tests', () => {
 | 
			
		||||
    render(
 | 
			
		||||
      <BrowserRouter>
 | 
			
		||||
        <CommandBarProvider>
 | 
			
		||||
          <GlobalStateProvider>
 | 
			
		||||
          <SettingsAuthStateProvider>
 | 
			
		||||
            <ProjectSidebarMenu />
 | 
			
		||||
          </GlobalStateProvider>
 | 
			
		||||
          </SettingsAuthStateProvider>
 | 
			
		||||
        </CommandBarProvider>
 | 
			
		||||
      </BrowserRouter>
 | 
			
		||||
    )
 | 
			
		||||
@ -79,12 +79,12 @@ describe('ProjectSidebarMenu tests', () => {
 | 
			
		||||
    render(
 | 
			
		||||
      <BrowserRouter>
 | 
			
		||||
        <CommandBarProvider>
 | 
			
		||||
          <GlobalStateProvider>
 | 
			
		||||
          <SettingsAuthStateProvider>
 | 
			
		||||
            <ProjectSidebarMenu
 | 
			
		||||
              project={projectWellFormed}
 | 
			
		||||
              renderAsLink={true}
 | 
			
		||||
            />
 | 
			
		||||
          </GlobalStateProvider>
 | 
			
		||||
          </SettingsAuthStateProvider>
 | 
			
		||||
        </CommandBarProvider>
 | 
			
		||||
      </BrowserRouter>
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
@ -5,7 +5,12 @@ import { authMachine, TOKEN_PERSIST_KEY } from '../machines/authMachine'
 | 
			
		||||
import withBaseUrl from '../lib/withBaseURL'
 | 
			
		||||
import React, { createContext, useEffect, useRef } from 'react'
 | 
			
		||||
import useStateMachineCommands from '../hooks/useStateMachineCommands'
 | 
			
		||||
import { SETTINGS_PERSIST_KEY, settingsMachine } from 'machines/settingsMachine'
 | 
			
		||||
import { settingsMachine } from 'machines/settingsMachine'
 | 
			
		||||
import {
 | 
			
		||||
  initialSettings,
 | 
			
		||||
  SETTINGS_PERSIST_KEY,
 | 
			
		||||
  validateSettings,
 | 
			
		||||
} from 'lib/settings'
 | 
			
		||||
import { toast } from 'react-hot-toast'
 | 
			
		||||
import { setThemeClass, Themes } from 'lib/theme'
 | 
			
		||||
import {
 | 
			
		||||
@ -18,6 +23,7 @@ import {
 | 
			
		||||
import { isTauri } from 'lib/isTauri'
 | 
			
		||||
import { settingsCommandBarConfig } from 'lib/commandBarConfigs/settingsCommandConfig'
 | 
			
		||||
import { authCommandBarConfig } from 'lib/commandBarConfigs/authCommandConfig'
 | 
			
		||||
import { initializeProjectDirectory, readSettingsFile } from 'lib/tauriFS'
 | 
			
		||||
 | 
			
		||||
type MachineContext<T extends AnyStateMachine> = {
 | 
			
		||||
  state: StateFrom<T>
 | 
			
		||||
@ -25,14 +31,14 @@ type MachineContext<T extends AnyStateMachine> = {
 | 
			
		||||
  send: Prop<InterpreterFrom<T>, 'send'>
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type GlobalContext = {
 | 
			
		||||
type SettingsAuthContext = {
 | 
			
		||||
  auth: MachineContext<typeof authMachine>
 | 
			
		||||
  settings: MachineContext<typeof settingsMachine>
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const GlobalStateContext = createContext({} as GlobalContext)
 | 
			
		||||
export const SettingsAuthStateContext = createContext({} as SettingsAuthContext)
 | 
			
		||||
 | 
			
		||||
export const GlobalStateProvider = ({
 | 
			
		||||
export const SettingsAuthStateProvider = ({
 | 
			
		||||
  children,
 | 
			
		||||
}: {
 | 
			
		||||
  children: React.ReactNode
 | 
			
		||||
@ -40,14 +46,17 @@ export const GlobalStateProvider = ({
 | 
			
		||||
  const navigate = useNavigate()
 | 
			
		||||
 | 
			
		||||
  // Settings machine setup
 | 
			
		||||
  // Load settings from local storage
 | 
			
		||||
  // and validate them
 | 
			
		||||
  const retrievedSettings = useRef(
 | 
			
		||||
    localStorage?.getItem(SETTINGS_PERSIST_KEY) || '{}'
 | 
			
		||||
    validateSettings(
 | 
			
		||||
      JSON.parse(localStorage?.getItem(SETTINGS_PERSIST_KEY) || '{}')
 | 
			
		||||
    )
 | 
			
		||||
  )
 | 
			
		||||
  const persistedSettings = Object.assign(
 | 
			
		||||
    settingsMachine.initialState.context,
 | 
			
		||||
    JSON.parse(retrievedSettings.current) as Partial<
 | 
			
		||||
      (typeof settingsMachine)['context']
 | 
			
		||||
    >
 | 
			
		||||
    {},
 | 
			
		||||
    initialSettings,
 | 
			
		||||
    retrievedSettings.current.settings
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  const [settingsState, settingsSend] = useMachine(settingsMachine, {
 | 
			
		||||
@ -72,6 +81,75 @@ export const GlobalStateProvider = ({
 | 
			
		||||
    },
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  // If the app is running in the Tauri context,
 | 
			
		||||
  // try to read the settings from a file
 | 
			
		||||
  // after doing some validation on them
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    async function getFileBasedSettings() {
 | 
			
		||||
      if (isTauri()) {
 | 
			
		||||
        const newSettings = await readSettingsFile()
 | 
			
		||||
 | 
			
		||||
        if (newSettings) {
 | 
			
		||||
          if (newSettings.defaultDirectory) {
 | 
			
		||||
            const newDefaultDirectory = await initializeProjectDirectory(
 | 
			
		||||
              newSettings.defaultDirectory || ''
 | 
			
		||||
            )
 | 
			
		||||
            if (newDefaultDirectory.error !== null) {
 | 
			
		||||
              toast.error(newDefaultDirectory.error.message)
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (newDefaultDirectory.path !== null) {
 | 
			
		||||
              newSettings.defaultDirectory = newDefaultDirectory.path
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
          const { settings: validatedSettings, errors: validationErrors } =
 | 
			
		||||
            validateSettings(newSettings)
 | 
			
		||||
 | 
			
		||||
          retrievedSettings.current = Object.assign(
 | 
			
		||||
            {},
 | 
			
		||||
            initialSettings,
 | 
			
		||||
            retrievedSettings.current,
 | 
			
		||||
            validatedSettings
 | 
			
		||||
          )
 | 
			
		||||
 | 
			
		||||
          settingsSend({
 | 
			
		||||
            type: 'Set All Settings',
 | 
			
		||||
            data: validatedSettings,
 | 
			
		||||
          })
 | 
			
		||||
 | 
			
		||||
          return validationErrors
 | 
			
		||||
        }
 | 
			
		||||
      } else {
 | 
			
		||||
        // If the app is not running in the Tauri context,
 | 
			
		||||
        // just use the settings from local storage
 | 
			
		||||
        // after they've been validated to ensure they are correct.
 | 
			
		||||
        settingsSend({
 | 
			
		||||
          type: 'Set All Settings',
 | 
			
		||||
          data: retrievedSettings.current.settings,
 | 
			
		||||
        })
 | 
			
		||||
      }
 | 
			
		||||
      return []
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // 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.
 | 
			
		||||
    void getFileBasedSettings().then((validationErrors: string[]) => {
 | 
			
		||||
      const combinedErrors = new Set([
 | 
			
		||||
        ...retrievedSettings.current.errors,
 | 
			
		||||
        ...validationErrors,
 | 
			
		||||
      ])
 | 
			
		||||
 | 
			
		||||
      if (combinedErrors.size > 0) {
 | 
			
		||||
        const errorMessage =
 | 
			
		||||
          'Error validating persisted settings: ' +
 | 
			
		||||
          Array.from(combinedErrors).join(', ') +
 | 
			
		||||
          '. Using defaults.'
 | 
			
		||||
        console.error(errorMessage)
 | 
			
		||||
        toast.error(errorMessage)
 | 
			
		||||
      }
 | 
			
		||||
    })
 | 
			
		||||
  }, [settingsSend])
 | 
			
		||||
 | 
			
		||||
  useStateMachineCommands({
 | 
			
		||||
    machineId: 'settings',
 | 
			
		||||
    state: settingsState,
 | 
			
		||||
@ -119,7 +197,7 @@ export const GlobalStateProvider = ({
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <GlobalStateContext.Provider
 | 
			
		||||
    <SettingsAuthStateContext.Provider
 | 
			
		||||
      value={{
 | 
			
		||||
        auth: {
 | 
			
		||||
          state: authState,
 | 
			
		||||
@ -134,11 +212,11 @@ export const GlobalStateProvider = ({
 | 
			
		||||
      }}
 | 
			
		||||
    >
 | 
			
		||||
      {children}
 | 
			
		||||
    </GlobalStateContext.Provider>
 | 
			
		||||
    </SettingsAuthStateContext.Provider>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default GlobalStateProvider
 | 
			
		||||
export default SettingsAuthStateProvider
 | 
			
		||||
 | 
			
		||||
export function logout() {
 | 
			
		||||
  localStorage.removeItem(TOKEN_PERSIST_KEY)
 | 
			
		||||
@ -10,7 +10,7 @@ import { useStore } from '../useStore'
 | 
			
		||||
import { getNormalisedCoordinates, throttle } from '../lib/utils'
 | 
			
		||||
import Loading from './Loading'
 | 
			
		||||
import { cameraMouseDragGuards } from 'lib/cameraControls'
 | 
			
		||||
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
 | 
			
		||||
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
 | 
			
		||||
import { Models } from '@kittycad/lib'
 | 
			
		||||
import { engineCommandManager } from '../lang/std/engineConnection'
 | 
			
		||||
import { useModelingContext } from 'hooks/useModelingContext'
 | 
			
		||||
@ -34,7 +34,7 @@ export const Stream = ({ className = '' }: { className?: string }) => {
 | 
			
		||||
    setDidDragInStream: s.setDidDragInStream,
 | 
			
		||||
    streamDimensions: s.streamDimensions,
 | 
			
		||||
  }))
 | 
			
		||||
  const { settings } = useGlobalStateContext()
 | 
			
		||||
  const { settings } = useSettingsAuthContext()
 | 
			
		||||
  const cameraControls = settings?.context?.cameraControls
 | 
			
		||||
  const { state } = useModelingContext()
 | 
			
		||||
  const { isExecuting } = useKclContext()
 | 
			
		||||
 | 
			
		||||
@ -8,7 +8,7 @@ import Server from '../editor/lsp/server'
 | 
			
		||||
import Client from '../editor/lsp/client'
 | 
			
		||||
import { TEST } from 'env'
 | 
			
		||||
import { useCommandsContext } from 'hooks/useCommandsContext'
 | 
			
		||||
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
 | 
			
		||||
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
 | 
			
		||||
import { useConvertToVariable } from 'hooks/useToolbarGuards'
 | 
			
		||||
import { Themes } from 'lib/theme'
 | 
			
		||||
import { useMemo, useRef } from 'react'
 | 
			
		||||
@ -67,7 +67,7 @@ export const TextEditor = ({
 | 
			
		||||
  } = useModelingContext()
 | 
			
		||||
 | 
			
		||||
  const { settings: { context: { textWrapping } = {} } = {} } =
 | 
			
		||||
    useGlobalStateContext()
 | 
			
		||||
    useSettingsAuthContext()
 | 
			
		||||
  const { commandBarSend } = useCommandsContext()
 | 
			
		||||
  const { enable: convertEnabled, handleClick: convertCallback } =
 | 
			
		||||
    useConvertToVariable()
 | 
			
		||||
 | 
			
		||||
@ -7,7 +7,7 @@ import {
 | 
			
		||||
  createRoutesFromElements,
 | 
			
		||||
} from 'react-router-dom'
 | 
			
		||||
import { Models } from '@kittycad/lib'
 | 
			
		||||
import { GlobalStateProvider } from './GlobalStateProvider'
 | 
			
		||||
import { SettingsAuthStateProvider } from './SettingsAuthStateProvider'
 | 
			
		||||
import CommandBarProvider from './CommandBar/CommandBar'
 | 
			
		||||
 | 
			
		||||
type User = Models['User_type']
 | 
			
		||||
@ -107,7 +107,7 @@ function TestWrap({ children }: { children: React.ReactNode }) {
 | 
			
		||||
        path="/file/:id"
 | 
			
		||||
        element={
 | 
			
		||||
          <CommandBarProvider>
 | 
			
		||||
            <GlobalStateProvider>{children}</GlobalStateProvider>
 | 
			
		||||
            <SettingsAuthStateProvider>{children}</SettingsAuthStateProvider>
 | 
			
		||||
          </CommandBarProvider>
 | 
			
		||||
        }
 | 
			
		||||
      />
 | 
			
		||||
 | 
			
		||||
@ -6,7 +6,7 @@ import { useLocation, useNavigate } from 'react-router-dom'
 | 
			
		||||
import { Fragment, useState } from 'react'
 | 
			
		||||
import { paths } from 'lib/paths'
 | 
			
		||||
import { Models } from '@kittycad/lib'
 | 
			
		||||
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
 | 
			
		||||
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
 | 
			
		||||
import { useAbsoluteFilePath } from 'hooks/useAbsoluteFilePath'
 | 
			
		||||
 | 
			
		||||
type User = Models['User_type']
 | 
			
		||||
@ -17,7 +17,7 @@ const UserSidebarMenu = ({ user }: { user?: User }) => {
 | 
			
		||||
  const displayedName = getDisplayName(user)
 | 
			
		||||
  const [imageLoadFailed, setImageLoadFailed] = useState(false)
 | 
			
		||||
  const navigate = useNavigate()
 | 
			
		||||
  const send = useGlobalStateContext()?.auth?.send
 | 
			
		||||
  const send = useSettingsAuthContext()?.auth?.send
 | 
			
		||||
 | 
			
		||||
  // Fallback logic for displaying user's "name":
 | 
			
		||||
  // 1. user.name
 | 
			
		||||
 | 
			
		||||
@ -1,6 +0,0 @@
 | 
			
		||||
import { GlobalStateContext } from 'components/GlobalStateProvider'
 | 
			
		||||
import { useContext } from 'react'
 | 
			
		||||
 | 
			
		||||
export const useGlobalStateContext = () => {
 | 
			
		||||
  return useContext(GlobalStateContext)
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										6
									
								
								src/hooks/useSettingsAuthContext.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								src/hooks/useSettingsAuthContext.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,6 @@
 | 
			
		||||
import { SettingsAuthStateContext } from 'components/SettingsAuthStateProvider'
 | 
			
		||||
import { useContext } from 'react'
 | 
			
		||||
 | 
			
		||||
export const useSettingsAuthContext = () => {
 | 
			
		||||
  return useContext(SettingsAuthStateContext)
 | 
			
		||||
}
 | 
			
		||||
@ -1,11 +1,6 @@
 | 
			
		||||
import { CommandSetConfig } from '../commandTypes'
 | 
			
		||||
import {
 | 
			
		||||
  BaseUnit,
 | 
			
		||||
  Toggle,
 | 
			
		||||
  UnitSystem,
 | 
			
		||||
  baseUnitsUnion,
 | 
			
		||||
  settingsMachine,
 | 
			
		||||
} from 'machines/settingsMachine'
 | 
			
		||||
import { BaseUnit, Toggle, UnitSystem, baseUnitsUnion } from 'lib/settings'
 | 
			
		||||
import { settingsMachine } from 'machines/settingsMachine'
 | 
			
		||||
import { CameraSystem, cameraSystems } from '../cameraControls'
 | 
			
		||||
import { Themes } from '../theme'
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										95
									
								
								src/lib/settings.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										95
									
								
								src/lib/settings.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,95 @@
 | 
			
		||||
import { type Models } from '@kittycad/lib'
 | 
			
		||||
import { CameraSystem, cameraSystems } 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'
 | 
			
		||||
 | 
			
		||||
export enum UnitSystem {
 | 
			
		||||
  Imperial = 'imperial',
 | 
			
		||||
  Metric = 'metric',
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const baseUnits = {
 | 
			
		||||
  imperial: ['in', 'ft', 'yd'],
 | 
			
		||||
  metric: ['mm', 'cm', 'm'],
 | 
			
		||||
} as const
 | 
			
		||||
 | 
			
		||||
export type BaseUnit = Models['UnitLength_type']
 | 
			
		||||
 | 
			
		||||
export const baseUnitsUnion = Object.values(baseUnits).flatMap((v) => v)
 | 
			
		||||
 | 
			
		||||
export type Toggle = 'On' | 'Off'
 | 
			
		||||
const toggleAsArray = ['On', 'Off'] as const
 | 
			
		||||
 | 
			
		||||
export type SettingsMachineContext = {
 | 
			
		||||
  baseUnit: BaseUnit
 | 
			
		||||
  cameraControls: CameraSystem
 | 
			
		||||
  defaultDirectory: string
 | 
			
		||||
  defaultProjectName: string
 | 
			
		||||
  onboardingStatus: string
 | 
			
		||||
  showDebugPanel: boolean
 | 
			
		||||
  textWrapping: Toggle
 | 
			
		||||
  theme: Themes
 | 
			
		||||
  unitSystem: UnitSystem
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const initialSettings: SettingsMachineContext = {
 | 
			
		||||
  baseUnit: 'in' as BaseUnit,
 | 
			
		||||
  cameraControls: 'KittyCAD' as CameraSystem,
 | 
			
		||||
  defaultDirectory: '',
 | 
			
		||||
  defaultProjectName: DEFAULT_PROJECT_NAME,
 | 
			
		||||
  onboardingStatus: '',
 | 
			
		||||
  showDebugPanel: false,
 | 
			
		||||
  textWrapping: 'On' as Toggle,
 | 
			
		||||
  theme: Themes.System,
 | 
			
		||||
  unitSystem: UnitSystem.Imperial,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function isEnumMember<T extends Record<string, unknown>>(v: unknown, e: T) {
 | 
			
		||||
  return Object.values(e).includes(v)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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',
 | 
			
		||||
  defaultProjectName: (v) => typeof v === 'string',
 | 
			
		||||
  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 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)
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    settings: settingsNoInvalidKeys as Partial<SettingsMachineContext>,
 | 
			
		||||
    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/settings'
 | 
			
		||||
 | 
			
		||||
const PROJECT_FOLDER = 'zoo-modeling-app-projects'
 | 
			
		||||
export const FILE_EXT = '.kcl'
 | 
			
		||||
@ -17,39 +21,101 @@ const INDEX_IDENTIFIER = '$n' // $nn.. will pad the number with 0s
 | 
			
		||||
export const MAX_PADDING = 7
 | 
			
		||||
const RELEVANT_FILE_TYPES = ['kcl']
 | 
			
		||||
 | 
			
		||||
type PathWithPossibleError = {
 | 
			
		||||
  path: string | null
 | 
			
		||||
  error: Error | null
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function getInitialDefaultDir() {
 | 
			
		||||
  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
 | 
			
		||||
export async function initializeProjectDirectory(directory: string) {
 | 
			
		||||
// with any Errors that occurred
 | 
			
		||||
export async function initializeProjectDirectory(
 | 
			
		||||
  directory: string
 | 
			
		||||
): Promise<PathWithPossibleError> {
 | 
			
		||||
  if (!isTauri()) {
 | 
			
		||||
    throw new Error(
 | 
			
		||||
      'initializeProjectDirectory() can only be called from a Tauri app'
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  let returnValue: PathWithPossibleError = {
 | 
			
		||||
    path: null,
 | 
			
		||||
    error: null,
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  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 && dirExists === true) {
 | 
			
		||||
    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
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  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>) {
 | 
			
		||||
@ -300,3 +366,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
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -13,4 +13,5 @@ export type ProjectWithEntryPointMetadata = FileEntry & {
 | 
			
		||||
export type HomeLoaderData = {
 | 
			
		||||
  projects: ProjectWithEntryPointMetadata[]
 | 
			
		||||
  newDefaultDirectory?: string
 | 
			
		||||
  error: Error | null
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -1,49 +1,43 @@
 | 
			
		||||
import { assign, createMachine } from 'xstate'
 | 
			
		||||
import { Themes, getSystemTheme, setThemeClass } from '../lib/theme'
 | 
			
		||||
import { CameraSystem } from 'lib/cameraControls'
 | 
			
		||||
import { Models } from '@kittycad/lib'
 | 
			
		||||
 | 
			
		||||
export const DEFAULT_PROJECT_NAME = 'project-$nnn'
 | 
			
		||||
 | 
			
		||||
export enum UnitSystem {
 | 
			
		||||
  Imperial = 'imperial',
 | 
			
		||||
  Metric = 'metric',
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const baseUnits = {
 | 
			
		||||
  imperial: ['in', 'ft', 'yd'],
 | 
			
		||||
  metric: ['mm', 'cm', 'm'],
 | 
			
		||||
} as const
 | 
			
		||||
 | 
			
		||||
export type BaseUnit = Models['UnitLength_type']
 | 
			
		||||
 | 
			
		||||
export const baseUnitsUnion = Object.values(baseUnits).flatMap((v) => v)
 | 
			
		||||
 | 
			
		||||
export type Toggle = 'On' | 'Off'
 | 
			
		||||
 | 
			
		||||
export const SETTINGS_PERSIST_KEY = 'SETTINGS_PERSIST_KEY'
 | 
			
		||||
import { isTauri } from 'lib/isTauri'
 | 
			
		||||
import { writeToSettingsFile } from 'lib/tauriFS'
 | 
			
		||||
import {
 | 
			
		||||
  BaseUnit,
 | 
			
		||||
  DEFAULT_PROJECT_NAME,
 | 
			
		||||
  SETTINGS_PERSIST_KEY,
 | 
			
		||||
  SettingsMachineContext,
 | 
			
		||||
  Toggle,
 | 
			
		||||
  UnitSystem,
 | 
			
		||||
  initialSettings,
 | 
			
		||||
} from 'lib/settings'
 | 
			
		||||
 | 
			
		||||
export const settingsMachine = createMachine(
 | 
			
		||||
  {
 | 
			
		||||
    /** @xstate-layout N4IgpgJg5mDOIC5QGUwBc0EsB2VYDpMIAbMAYlTQAIAVACzAFswBtABgF1FQAHAe1iYsfbNxAAPRAA42+AEwB2KQFYAzGznKAnADZli1QBoQAT2kBGKfm37lOned3nzqgL6vjlLLgJFSFdCoAETAAMwBDAFdiagAFACc+ACswAGNqADlw5nYuJBB+QWFRfMkEABY5fDYa2rra83LjMwQdLWV8BXLyuxlVLU1Ld090bzxCEnJKYLComODMeLS0PniTXLFCoUwRMTK7fC1zNql7NgUjtnKjU0RlBSqpLVUVPVUda60tYZAvHHG-FNAgBVbBCKjIEywNBMDb5LbFPaILqdfRSORsS4qcxXZqIHqyK6qY4XOxsGTKco-P4+Cb+aYAIXCsDAVFBQjhvAE212pWkskUKnUml0+gUNxaqkU+EccnKF1UCnucnMcjcHl+o3+vkmZBofCgUFIMwARpEoFRYuFsGBiJyCtzEXzWrJlGxlKdVFKvfY1XiEBjyvhVOVzBdzu13pYFNStbTAQFqAB5bAmvjheIQf4QtDhNCRWD2hE7EqgfayHTEh7lHQNSxSf1Scz4cpHHFyFVujTKczuDXYPgQOBiGl4TaOktIhAAWg6X3nC4Xp39050sYw2rpYHHRUnztVhPJqmUlIGbEriv9WhrLZ6uibHcqUr7riAA */
 | 
			
		||||
    id: 'Settings',
 | 
			
		||||
    predictableActionArguments: true,
 | 
			
		||||
    context: {
 | 
			
		||||
      baseUnit: 'in' as BaseUnit,
 | 
			
		||||
      cameraControls: 'KittyCAD' as CameraSystem,
 | 
			
		||||
      defaultDirectory: '',
 | 
			
		||||
      defaultProjectName: DEFAULT_PROJECT_NAME,
 | 
			
		||||
      onboardingStatus: '',
 | 
			
		||||
      showDebugPanel: false,
 | 
			
		||||
      textWrapping: 'On' as Toggle,
 | 
			
		||||
      theme: Themes.System,
 | 
			
		||||
      unitSystem: UnitSystem.Imperial,
 | 
			
		||||
    },
 | 
			
		||||
    context: { ...initialSettings },
 | 
			
		||||
    initial: 'idle',
 | 
			
		||||
    states: {
 | 
			
		||||
      idle: {
 | 
			
		||||
        entry: ['setThemeClass'],
 | 
			
		||||
        on: {
 | 
			
		||||
          'Set All Settings': {
 | 
			
		||||
            actions: [
 | 
			
		||||
              assign((context, event) => {
 | 
			
		||||
                return {
 | 
			
		||||
                  ...context,
 | 
			
		||||
                  ...event.data,
 | 
			
		||||
                }
 | 
			
		||||
              }),
 | 
			
		||||
              'persistSettings',
 | 
			
		||||
              'setThemeClass',
 | 
			
		||||
            ],
 | 
			
		||||
            target: 'idle',
 | 
			
		||||
            internal: true,
 | 
			
		||||
          },
 | 
			
		||||
          'Set Base Unit': {
 | 
			
		||||
            actions: [
 | 
			
		||||
              assign({
 | 
			
		||||
@ -157,6 +151,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'
 | 
			
		||||
@ -180,6 +175,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) {
 | 
			
		||||
 | 
			
		||||
@ -29,9 +29,9 @@ import {
 | 
			
		||||
  getSortIcon,
 | 
			
		||||
} from '../lib/sorting'
 | 
			
		||||
import useStateMachineCommands from '../hooks/useStateMachineCommands'
 | 
			
		||||
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
 | 
			
		||||
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
 | 
			
		||||
import { useCommandsContext } from 'hooks/useCommandsContext'
 | 
			
		||||
import { DEFAULT_PROJECT_NAME } from 'machines/settingsMachine'
 | 
			
		||||
import { DEFAULT_PROJECT_NAME } from 'lib/settings'
 | 
			
		||||
import { sep } from '@tauri-apps/api/path'
 | 
			
		||||
import { homeCommandBarConfig } from 'lib/commandBarConfigs/homeCommandConfig'
 | 
			
		||||
import { useHotkeys } from 'react-hotkeys-hook'
 | 
			
		||||
@ -42,14 +42,17 @@ import { isTauri } from 'lib/isTauri'
 | 
			
		||||
const Home = () => {
 | 
			
		||||
  const { commandBarSend } = useCommandsContext()
 | 
			
		||||
  const navigate = useNavigate()
 | 
			
		||||
  const { projects: loadedProjects, newDefaultDirectory } =
 | 
			
		||||
    useLoaderData() as HomeLoaderData
 | 
			
		||||
  const {
 | 
			
		||||
    projects: loadedProjects,
 | 
			
		||||
    newDefaultDirectory,
 | 
			
		||||
    error,
 | 
			
		||||
  } = useLoaderData() as HomeLoaderData
 | 
			
		||||
  const {
 | 
			
		||||
    settings: {
 | 
			
		||||
      context: { defaultDirectory, defaultProjectName },
 | 
			
		||||
      send: sendToSettings,
 | 
			
		||||
    },
 | 
			
		||||
  } = useGlobalStateContext()
 | 
			
		||||
  } = useSettingsAuthContext()
 | 
			
		||||
 | 
			
		||||
  // Set the default directory if it's been updated
 | 
			
		||||
  // during the loading of the home page. This is wrapped
 | 
			
		||||
@ -57,11 +60,17 @@ const Home = () => {
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (newDefaultDirectory) {
 | 
			
		||||
      sendToSettings({
 | 
			
		||||
        type: 'Set Default Directory',
 | 
			
		||||
        type: 'Set All Settings',
 | 
			
		||||
        data: { defaultDirectory: newDefaultDirectory },
 | 
			
		||||
      })
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Toast any errors that occurred during the loading process
 | 
			
		||||
    if (error) {
 | 
			
		||||
      toast.error(error.message)
 | 
			
		||||
    }
 | 
			
		||||
  }, [])
 | 
			
		||||
 | 
			
		||||
  useHotkeys(
 | 
			
		||||
    isTauri() ? 'mod+,' : 'shift+mod+,',
 | 
			
		||||
    () => navigate(paths.HOME + paths.SETTINGS),
 | 
			
		||||
 | 
			
		||||
@ -2,7 +2,7 @@ import { OnboardingButtons, useDismiss, useNextClick } from '.'
 | 
			
		||||
import { onboardingPaths } from 'routes/Onboarding/paths'
 | 
			
		||||
import { useStore } from '../../useStore'
 | 
			
		||||
import { SettingsSection } from 'routes/Settings'
 | 
			
		||||
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
 | 
			
		||||
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
 | 
			
		||||
import {
 | 
			
		||||
  CameraSystem,
 | 
			
		||||
  cameraMouseDragGuards,
 | 
			
		||||
@ -22,7 +22,7 @@ export default function Units() {
 | 
			
		||||
        context: { cameraControls },
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  } = useGlobalStateContext()
 | 
			
		||||
  } = useSettingsAuthContext()
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="fixed inset-0 z-50 grid items-end justify-start px-4 pointer-events-none">
 | 
			
		||||
 | 
			
		||||
@ -5,7 +5,7 @@ import {
 | 
			
		||||
  useNextClick,
 | 
			
		||||
} from '.'
 | 
			
		||||
import { onboardingPaths } from 'routes/Onboarding/paths'
 | 
			
		||||
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
 | 
			
		||||
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
 | 
			
		||||
import { Themes, getSystemTheme } from 'lib/theme'
 | 
			
		||||
import { bracket } from 'lib/exampleKcl'
 | 
			
		||||
import {
 | 
			
		||||
@ -31,7 +31,7 @@ function OnboardingWithNewFile() {
 | 
			
		||||
    settings: {
 | 
			
		||||
      context: { defaultDirectory },
 | 
			
		||||
    },
 | 
			
		||||
  } = useGlobalStateContext()
 | 
			
		||||
  } = useSettingsAuthContext()
 | 
			
		||||
 | 
			
		||||
  async function createAndOpenNewProject() {
 | 
			
		||||
    const projects = await getProjectsInDir(defaultDirectory)
 | 
			
		||||
@ -111,7 +111,7 @@ export default function Introduction() {
 | 
			
		||||
        context: { theme },
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  } = useGlobalStateContext()
 | 
			
		||||
  } = useSettingsAuthContext()
 | 
			
		||||
  const getLogoTheme = () =>
 | 
			
		||||
    theme === Themes.Light ||
 | 
			
		||||
    (theme === Themes.System && getSystemTheme() === Themes.Light)
 | 
			
		||||
 | 
			
		||||
@ -3,7 +3,7 @@ import { onboardingPaths } from 'routes/Onboarding/paths'
 | 
			
		||||
import { useStore } from '../../useStore'
 | 
			
		||||
import { useBackdropHighlight } from 'hooks/useBackdropHighlight'
 | 
			
		||||
import { Themes, getSystemTheme } from 'lib/theme'
 | 
			
		||||
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
 | 
			
		||||
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
 | 
			
		||||
 | 
			
		||||
export default function ParametricModeling() {
 | 
			
		||||
  const { buttonDownInStream } = useStore((s) => ({
 | 
			
		||||
@ -13,7 +13,7 @@ export default function ParametricModeling() {
 | 
			
		||||
    settings: {
 | 
			
		||||
      context: { theme },
 | 
			
		||||
    },
 | 
			
		||||
  } = useGlobalStateContext()
 | 
			
		||||
  } = useSettingsAuthContext()
 | 
			
		||||
  const getImageTheme = () =>
 | 
			
		||||
    theme === Themes.Light ||
 | 
			
		||||
    (theme === Themes.System && getSystemTheme() === Themes.Light)
 | 
			
		||||
 | 
			
		||||
@ -1,12 +1,11 @@
 | 
			
		||||
import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons'
 | 
			
		||||
import { BaseUnit, baseUnits } from '../../machines/settingsMachine'
 | 
			
		||||
import { BaseUnit, baseUnits, UnitSystem } from 'lib/settings'
 | 
			
		||||
import { ActionButton } from '../../components/ActionButton'
 | 
			
		||||
import { SettingsSection } from '../Settings'
 | 
			
		||||
import { Toggle } from '../../components/Toggle/Toggle'
 | 
			
		||||
import { useDismiss, useNextClick } from '.'
 | 
			
		||||
import { onboardingPaths } from 'routes/Onboarding/paths'
 | 
			
		||||
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
 | 
			
		||||
import { UnitSystem } from 'machines/settingsMachine'
 | 
			
		||||
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
 | 
			
		||||
 | 
			
		||||
export default function Units() {
 | 
			
		||||
  const dismiss = useDismiss()
 | 
			
		||||
@ -16,7 +15,7 @@ export default function Units() {
 | 
			
		||||
      send,
 | 
			
		||||
      context: { unitSystem, baseUnit },
 | 
			
		||||
    },
 | 
			
		||||
  } = useGlobalStateContext()
 | 
			
		||||
  } = useSettingsAuthContext()
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="fixed grid place-content-center inset-0 bg-chalkboard-110/50 z-50">
 | 
			
		||||
 | 
			
		||||
@ -5,7 +5,7 @@ import Camera from './Camera'
 | 
			
		||||
import Sketching from './Sketching'
 | 
			
		||||
import { useCallback } from 'react'
 | 
			
		||||
import makeUrlPathRelative from '../../lib/makeUrlPathRelative'
 | 
			
		||||
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
 | 
			
		||||
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
 | 
			
		||||
import Streaming from './Streaming'
 | 
			
		||||
import CodeEditor from './CodeEditor'
 | 
			
		||||
import ParametricModeling from './ParametricModeling'
 | 
			
		||||
@ -78,7 +78,7 @@ export function useNextClick(newStatus: string) {
 | 
			
		||||
  const filePath = useAbsoluteFilePath()
 | 
			
		||||
  const {
 | 
			
		||||
    settings: { send },
 | 
			
		||||
  } = useGlobalStateContext()
 | 
			
		||||
  } = useSettingsAuthContext()
 | 
			
		||||
  const navigate = useNavigate()
 | 
			
		||||
 | 
			
		||||
  return useCallback(() => {
 | 
			
		||||
@ -94,7 +94,7 @@ export function useDismiss() {
 | 
			
		||||
  const filePath = useAbsoluteFilePath()
 | 
			
		||||
  const {
 | 
			
		||||
    settings: { send },
 | 
			
		||||
  } = useGlobalStateContext()
 | 
			
		||||
  } = useSettingsAuthContext()
 | 
			
		||||
  const navigate = useNavigate()
 | 
			
		||||
 | 
			
		||||
  return useCallback(() => {
 | 
			
		||||
 | 
			
		||||
@ -5,31 +5,38 @@ import { open } from '@tauri-apps/api/dialog'
 | 
			
		||||
import {
 | 
			
		||||
  BaseUnit,
 | 
			
		||||
  DEFAULT_PROJECT_NAME,
 | 
			
		||||
  SETTINGS_PERSIST_KEY,
 | 
			
		||||
  baseUnits,
 | 
			
		||||
} from '../machines/settingsMachine'
 | 
			
		||||
  initialSettings,
 | 
			
		||||
  UnitSystem,
 | 
			
		||||
} from 'lib/settings'
 | 
			
		||||
import { Toggle } from '../components/Toggle/Toggle'
 | 
			
		||||
import { useLocation, useNavigate, useRouteLoaderData } from 'react-router-dom'
 | 
			
		||||
import { useHotkeys } from 'react-hotkeys-hook'
 | 
			
		||||
import { type IndexLoaderData } from 'lib/types'
 | 
			
		||||
import { paths } from 'lib/paths'
 | 
			
		||||
import { Themes } from '../lib/theme'
 | 
			
		||||
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
 | 
			
		||||
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
 | 
			
		||||
import {
 | 
			
		||||
  CameraSystem,
 | 
			
		||||
  cameraSystems,
 | 
			
		||||
  cameraMouseDragGuards,
 | 
			
		||||
} from 'lib/cameraControls'
 | 
			
		||||
import { UnitSystem } from 'machines/settingsMachine'
 | 
			
		||||
import { useDotDotSlash } from 'hooks/useDotDotSlash'
 | 
			
		||||
import {
 | 
			
		||||
  createNewProject,
 | 
			
		||||
  getNextProjectIndex,
 | 
			
		||||
  getProjectsInDir,
 | 
			
		||||
  getSettingsFilePath,
 | 
			
		||||
  initializeProjectDirectory,
 | 
			
		||||
  interpolateProjectNameWithIndex,
 | 
			
		||||
} from 'lib/tauriFS'
 | 
			
		||||
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'
 | 
			
		||||
@ -55,11 +62,14 @@ export const Settings = () => {
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  } = useGlobalStateContext()
 | 
			
		||||
  } = 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',
 | 
			
		||||
    })
 | 
			
		||||
@ -306,6 +316,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 */}
 | 
			
		||||
 | 
			
		||||
@ -4,7 +4,7 @@ import { invoke } from '@tauri-apps/api/tauri'
 | 
			
		||||
import { VITE_KC_SITE_BASE_URL, VITE_KC_API_BASE_URL } from '../env'
 | 
			
		||||
import { Themes, getSystemTheme } from '../lib/theme'
 | 
			
		||||
import { paths } from 'lib/paths'
 | 
			
		||||
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
 | 
			
		||||
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
 | 
			
		||||
import { APP_NAME } from 'lib/constants'
 | 
			
		||||
 | 
			
		||||
const SignIn = () => {
 | 
			
		||||
@ -20,7 +20,7 @@ const SignIn = () => {
 | 
			
		||||
        context: { theme },
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  } = useGlobalStateContext()
 | 
			
		||||
  } = useSettingsAuthContext()
 | 
			
		||||
 | 
			
		||||
  const signInTauri = async () => {
 | 
			
		||||
    // We want to invoke our command to login via device auth.
 | 
			
		||||
 | 
			
		||||
		Reference in New Issue
	
	Block a user