Rearchitect settings system to be scoped (#1956)

* BROKEN: start of scopes for each setting

* Clean up later: mostly-functional scoped settings!
Broken command bar, unimplemented generated settings components

* Working persisted project settings in-folder

* Start working toward automatic commands and settings UI

* Relatively stable, settings-menu-editable

* Settings persistence tweaks after merge

* Custom settings UI working properly, cleaner types

* Allow boolean command types, create Settings UI for them

* Add support for option and string Settings input types

* Proof of concept settings from command bar

* Add all settings to command bar

* Allow settings to be hidden on a level

* Better command titles for settings

* Hide the settings the settings from the commands bar

* Derive command defaultValue from *current* settingsMachine context

* Fix generated settings UI for 'options' type settings

* Pretty settings modal 💅

* Allow for rollback to parent level setting

* fmt

* Fix tsc errors not related to loading from localStorage

* Better setting descriptions, better buttons

* Make displayName searchable in command bar

* Consolidate constants, get working in browser

* Start fixing tests, better types for saved settings payloads

* Fix playwright tests

* Add a test for the settings modal

* Add AtLeast to codespell ignore list

* Goofed merge of codespellrc

* Try fixing linux E2E tests

* Make codespellrc word lowercase

* fmt

* Fix data-testid in Tauri test

* Don't set text settings if nothing changed

* Turn off unimplemented settings

* Allow for multiple "execution-done" messages to have appeared in snapshot tests

* Try fixing up snapshot tests

* Switch from .json to .toml settings file format

* Use a different method for overriding the default units

* Try to force using the new common storage state in snapshot tests

* Update tests to use TOML

* fmt and remove console logs

* Restore units to export

* tsc errors, make snapshot tests use TOML

* Ensure that snapshot tests use the basicStorageState

* Re-organize use of test.use()

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

* Update snapshots one more time since lighting changed

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

* Fix broken "Show in folder" for project-level settings

* Fire all relevant actions after settings reset

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

* Properly reset the default directory

* Hide settings by platform

* Actually honor showDebugPanel

* Unify settings hiding logic

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

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

* fix first extrusion snapshot

* another attempt to fix extrustion snapshot

* Rerun test suite

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

* trigger CI

* more extrusion stuff

* Replace resetSettings console log with comment

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Kurt Hutten Irev-Dev <k.hutten@protonmail.ch>
This commit is contained in:
Frank Noirot
2024-04-02 10:29:34 -04:00
committed by GitHub
parent 77f51530f9
commit d605d4a029
67 changed files with 2470 additions and 1392 deletions

View File

@ -1,3 +1,3 @@
[codespell] [codespell]
ignore-words-list: crate,everytime,inout,co-ordinate,ot,nwo,absolutey ignore-words-list: crate,everytime,inout,co-ordinate,ot,nwo,absolutey,atleast
skip: **/target,node_modules,build,**/Cargo.lock,./docs/kcl/*.md skip: **/target,node_modules,build,**/Cargo.lock,./docs/kcl/*.md

View File

@ -1,10 +1,11 @@
import { test, expect } from '@playwright/test' import { test, expect } from '@playwright/test'
import { secrets } from './secrets'
import { getUtils } from './test-utils' import { getUtils } from './test-utils'
import waitOn from 'wait-on' import waitOn from 'wait-on'
import { Themes } from '../../src/lib/theme'
import { initialSettings } from '../../src/lib/settings/initialSettings'
import { roundOff } from 'lib/utils' import { roundOff } from 'lib/utils'
import { basicStorageState } from './storageStates'
import * as TOML from '@iarna/toml'
import { SaveSettingsPayload } from 'lib/settings/settingsTypes'
import { Themes } from 'lib/theme'
/* /*
debug helper: unfortunately we do rely on exact coord mouse clicks in a few places debug helper: unfortunately we do rely on exact coord mouse clicks in a few places
@ -30,31 +31,14 @@ test.beforeEach(async ({ context, page }) => {
resources: ['tcp:3000'], resources: ['tcp:3000'],
timeout: 5000, timeout: 5000,
}) })
await context.addInitScript(async (token) => {
localStorage.setItem('TOKEN_PERSIST_KEY', token)
localStorage.setItem('persistCode', ``)
localStorage.setItem(
'SETTINGS_PERSIST_KEY',
JSON.stringify({
baseUnit: 'in',
cameraControls: 'KittyCAD',
defaultDirectory: '',
defaultProjectName: 'project-$nnn',
onboardingStatus: 'dismissed',
showDebugPanel: true,
textWrapping: 'On',
theme: 'system',
unitSystem: 'imperial',
})
)
}, secrets.token)
// kill animations, speeds up tests and reduced flakiness // kill animations, speeds up tests and reduced flakiness
await page.emulateMedia({ reducedMotion: 'reduce' }) await page.emulateMedia({ reducedMotion: 'reduce' })
}) })
test.setTimeout(60000) test.setTimeout(60000)
test('Basic sketch', async ({ page }) => { test('Basic sketch', async ({ page, context }) => {
const u = getUtils(page) const u = getUtils(page)
await page.setViewportSize({ width: 1200, height: 500 }) await page.setViewportSize({ width: 1200, height: 500 })
const PUR = 400 / 37.5 //pixeltoUnitRatio const PUR = 400 / 37.5 //pixeltoUnitRatio
@ -529,83 +513,119 @@ test('Auto complete works', async ({ page }) => {
}) })
// Stored settings validation test // Stored settings validation test
test('Stored settings are validated and fall back to defaults', async ({ test.describe('Settings persistence and validation tests', () => {
page, // Override test setup
context,
}) => {
// Override beforeEach test setup
// with corrupted settings // with corrupted settings
await context.addInitScript(async () => { const storageState = structuredClone(basicStorageState)
const storedSettings = JSON.parse( const s = TOML.parse(storageState.origins[0].localStorage[2].value) as {
localStorage.getItem('SETTINGS_PERSIST_KEY') || '{}' settings: SaveSettingsPayload
) }
s.settings.app.theme = Themes.Dark
s.settings.app.projectDirectory = 123 as any
s.settings.modeling.defaultUnit = 'invalid' as any
s.settings.modeling.mouseControls = `() => alert('hack the planet')` as any
s.settings.projects.defaultProjectName = false as any
storageState.origins[0].localStorage[2].value = TOML.stringify(s)
// Corrupt the settings test.use({ storageState })
storedSettings.baseUnit = 'invalid'
storedSettings.cameraControls = `() => alert('hack the planet')`
storedSettings.defaultDirectory = 123
storedSettings.defaultProjectName = false
localStorage.setItem('SETTINGS_PERSIST_KEY', JSON.stringify(storedSettings))
})
test('Stored settings are validated and fall back to defaults', async ({
page,
}) => {
const u = getUtils(page)
await page.setViewportSize({ width: 1200, height: 500 }) await page.setViewportSize({ width: 1200, height: 500 })
await page.goto('/', { waitUntil: 'domcontentloaded' }) await page.goto('/')
await u.waitForAuthSkipAppStart()
// Check the toast appeared
await expect(
page.getByText(`Error validating persisted settings:`, {
exact: false,
})
).toBeVisible()
// Check the settings were reset // Check the settings were reset
const storedSettings = JSON.parse( const storedSettings = TOML.parse(
await page.evaluate( await page.evaluate(() => localStorage.getItem('/user.toml') || '{}')
() => localStorage.getItem('SETTINGS_PERSIST_KEY') || '{}' ) as { settings: SaveSettingsPayload }
)
) expect(storedSettings.settings.app?.theme).toBe('dark')
await expect(storedSettings.baseUnit).toBe(initialSettings.baseUnit)
await expect(storedSettings.cameraControls).toBe( // Check that the invalid settings were removed
initialSettings.cameraControls expect(storedSettings.settings.modeling?.defaultUnit).toBe(undefined)
) expect(storedSettings.settings.modeling?.mouseControls).toBe(undefined)
await expect(storedSettings.defaultDirectory).toBe( expect(storedSettings.settings.app?.projectDirectory).toBe(undefined)
initialSettings.defaultDirectory expect(storedSettings.settings.projects?.defaultProjectName).toBe(undefined)
) })
await expect(storedSettings.defaultProjectName).toBe(
initialSettings.defaultProjectName test('Project settings can be set and override user settings', async ({
page,
}) => {
const u = getUtils(page)
await page.setViewportSize({ width: 1200, height: 500 })
await page.goto('/')
await u.waitForAuthSkipAppStart()
// Open the settings modal with the browser keyboard shortcut
await page.keyboard.press('Meta+Shift+,')
await expect(
page.getByRole('heading', { name: 'Settings', exact: true })
).toBeVisible()
await page
.locator('select[name="app-theme"]')
.selectOption({ value: 'light' })
// Verify the toast appeared
await expect(
page.getByText(`Set theme to "light" for this project`)
).toBeVisible()
// Check that the theme changed
await expect(page.locator('body')).not.toHaveClass(`body-bg dark`)
// Check that the user setting was not changed
await page.getByRole('radio', { name: 'User' }).click()
await expect(page.locator('select[name="app-theme"]')).toHaveValue('dark')
// Roll back to default "system" theme
await page
.getByText(
'themeRoll back themeRoll back to match defaultThe overall appearance of the appl'
) )
.hover()
await page
.getByRole('button', {
name: 'Roll back theme ; Has tooltip: Roll back to match default',
})
.click()
await expect(page.locator('select[name="app-theme"]')).toHaveValue('system')
// Check that the project setting did not change
await page.getByRole('radio', { name: 'Project' }).click()
await expect(page.locator('select[name="app-theme"]')).toHaveValue('light')
})
}) })
// Onboarding tests // Onboarding tests
test('Onboarding redirects and code updating', async ({ page, context }) => { test.describe('Onboarding tests', () => {
// Override test setup
const storageState = structuredClone(basicStorageState)
const s = TOML.parse(storageState.origins[0].localStorage[2].value) as {
settings: SaveSettingsPayload
}
s.settings.app.onboardingStatus = '/export'
storageState.origins[0].localStorage[2].value = TOML.stringify(s)
test.use({ storageState })
test('Onboarding redirects and code updating', async ({ page, context }) => {
const u = getUtils(page) const u = getUtils(page)
// Override beforeEach test setup
await context.addInitScript(async () => {
// Give some initial code, so we can test that it's cleared
localStorage.setItem('persistCode', 'const sigmaAllow = 15000')
const storedSettings = JSON.parse(
localStorage.getItem('SETTINGS_PERSIST_KEY') || '{}'
)
storedSettings.onboardingStatus = '/export'
localStorage.setItem('SETTINGS_PERSIST_KEY', JSON.stringify(storedSettings))
})
await page.setViewportSize({ width: 1200, height: 500 }) await page.setViewportSize({ width: 1200, height: 500 })
await page.goto('/') await page.goto('/')
await u.waitForAuthSkipAppStart() await u.waitForAuthSkipAppStart()
// Test that the redirect happened // Test that the redirect happened
await expect(page.url().split(':3000').slice(-1)[0]).toBe( await expect(page.url().split(':3000').slice(-1)[0]).toBe(
`/file/new/onboarding/export` `/file/%2Fbrowser%2Fmain.kcl/onboarding/export`
) )
// Test that you come back to this page when you refresh // Test that you come back to this page when you refresh
await page.reload() await page.reload()
await expect(page.url().split(':3000').slice(-1)[0]).toBe( await expect(page.url().split(':3000').slice(-1)[0]).toBe(
`/file/new/onboarding/export` `/file/%2Fbrowser%2Fmain.kcl/onboarding/export`
) )
// Test that the onboarding pane loaded // Test that the onboarding pane loaded
@ -619,6 +639,7 @@ test('Onboarding redirects and code updating', async ({ page, context }) => {
// Test that the code is not empty when you click on the next step // Test that the code is not empty when you click on the next step
await page.locator('[data-testid="onboarding-next"]').click() await page.locator('[data-testid="onboarding-next"]').click()
await expect(page.locator('.cm-content')).toHaveText(/.+/) await expect(page.locator('.cm-content')).toHaveText(/.+/)
})
}) })
test('Selections work on fresh and edited sketch', async ({ page }) => { test('Selections work on fresh and edited sketch', async ({ page }) => {
@ -779,12 +800,11 @@ test('Selections work on fresh and edited sketch', async ({ page }) => {
await selectionSequence() await selectionSequence()
}) })
test('Command bar works and can change a setting', async ({ page }) => { test.describe('Command bar tests', () => {
test('Command bar works and can change a setting', async ({ page }) => {
// Brief boilerplate // Brief boilerplate
const u = getUtils(page)
await page.setViewportSize({ width: 1200, height: 500 }) await page.setViewportSize({ width: 1200, height: 500 })
await page.goto('/') await page.goto('/', { waitUntil: 'domcontentloaded' })
await u.waitForAuthSkipAppStart()
let cmdSearchBar = page.getByPlaceholder('Search commands') let cmdSearchBar = page.getByPlaceholder('Search commands')
@ -805,33 +825,35 @@ test('Command bar works and can change a setting', async ({ page }) => {
// Try typing in the command bar // Try typing in the command bar
await page.keyboard.type('theme') await page.keyboard.type('theme')
const themeOption = page.getByRole('option', { name: 'Set Theme' }) const themeOption = page.getByRole('option', {
name: 'Settings · app · theme',
})
await expect(themeOption).toBeVisible() await expect(themeOption).toBeVisible()
await themeOption.click() await themeOption.click()
const themeInput = page.getByPlaceholder('system') const themeInput = page.getByPlaceholder('Select an option')
await expect(themeInput).toBeVisible() await expect(themeInput).toBeVisible()
await expect(themeInput).toBeFocused() await expect(themeInput).toBeFocused()
// Select dark theme // Select dark theme
await page.keyboard.press('ArrowDown') await page.keyboard.press('ArrowDown')
await page.keyboard.press('ArrowUp') await page.keyboard.press('ArrowDown')
await expect(page.getByRole('option', { name: Themes.Dark })).toHaveAttribute( await page.keyboard.press('ArrowDown')
await expect(page.getByRole('option', { name: 'system' })).toHaveAttribute(
'data-headlessui-state', 'data-headlessui-state',
'active' 'active'
) )
await page.keyboard.press('Enter') await page.keyboard.press('Enter')
// Check the toast appeared // Check the toast appeared
await expect(page.getByText(`Set Theme to "${Themes.Dark}"`)).toBeVisible() await expect(
page.getByText(`Set theme to "system" for this project`)
).toBeVisible()
// Check that the theme changed // Check that the theme changed
await expect(page.locator('body')).toHaveClass(`body-bg ${Themes.Dark}`) await expect(page.locator('body')).not.toHaveClass(`body-bg dark`)
}) })
test('Can extrude from the command bar', async ({ page, context }) => { // Override test setup code
await context.addInitScript(async (token) => { const storageState = structuredClone(basicStorageState)
localStorage.setItem( storageState.origins[0].localStorage[1].value = `const distance = sqrt(20)
'persistCode',
`
const distance = sqrt(20)
const part001 = startSketchOn('-XZ') const part001 = startSketchOn('-XZ')
|> startProfileAt([-6.95, 4.98], %) |> startProfileAt([-6.95, 4.98], %)
|> line([25.1, 0.41], %) |> line([25.1, 0.41], %)
@ -839,15 +861,26 @@ test('Can extrude from the command bar', async ({ page, context }) => {
|> line([-23.44, 0.52], %) |> line([-23.44, 0.52], %)
|> close(%) |> close(%)
` `
) test.use({ storageState })
})
test('Can extrude from the command bar', async ({ page, context }) => {
const u = getUtils(page) const u = getUtils(page)
await page.setViewportSize({ width: 1200, height: 500 }) await page.setViewportSize({ width: 1200, height: 500 })
await page.goto('/') await page.goto('/')
await u.waitForAuthSkipAppStart() await u.waitForAuthSkipAppStart()
// Make sure the stream is up
await u.openDebugPanel() await u.openDebugPanel()
await u.expectCmdLog('[data-message-type="execution-done"]') await u.expectCmdLog('[data-message-type="execution-done"]')
await u.closeDebugPanel()
await expect(
page.getByRole('button', { name: 'Start Sketch' })
).not.toBeDisabled()
await page.getByText('|> startProfileAt([-6.95, 4.98], %)').click()
await expect(
page.getByRole('button', { name: 'Extrude' })
).not.toBeDisabled()
let cmdSearchBar = page.getByPlaceholder('Search commands') let cmdSearchBar = page.getByPlaceholder('Search commands')
await page.keyboard.press('Meta+K') await page.keyboard.press('Meta+K')
@ -855,14 +888,6 @@ test('Can extrude from the command bar', async ({ page, context }) => {
// Search for extrude command and choose it // Search for extrude command and choose it
await page.getByRole('option', { name: 'Extrude' }).click() await page.getByRole('option', { name: 'Extrude' }).click()
await expect(page.locator('#arg-form > label')).toContainText(
'Please select one face'
)
await expect(page.getByRole('button', { name: 'selection' })).toBeDisabled()
// Click to select face and set distance
await page.getByText('|> startProfileAt([-6.95, 4.98], %)').click()
await page.getByRole('button', { name: 'Continue' }).click()
// Assert that we're on the distance step // Assert that we're on the distance step
await expect(page.getByRole('button', { name: 'distance' })).toBeDisabled() await expect(page.getByRole('button', { name: 'distance' })).toBeDisabled()
@ -902,6 +927,7 @@ const part001 = startSketchOn('-XZ')
|> close(%) |> close(%)
|> extrude(distance001, %)`.replace(/(\r\n|\n|\r)/gm, '') // remove newlines |> extrude(distance001, %)`.replace(/(\r\n|\n|\r)/gm, '') // remove newlines
) )
})
}) })
test('Can add multiple sketches', async ({ page }) => { test('Can add multiple sketches', async ({ page }) => {

View File

@ -7,30 +7,18 @@ import { spawn } from 'child_process'
import { APP_NAME } from 'lib/constants' import { APP_NAME } from 'lib/constants'
import JSZip from 'jszip' import JSZip from 'jszip'
import path from 'path' import path from 'path'
import { basicSettings, basicStorageState } from './storageStates'
import * as TOML from '@iarna/toml'
test.beforeEach(async ({ context, page }) => { test.beforeEach(async ({ page }) => {
await context.addInitScript(async (token) => {
localStorage.setItem('TOKEN_PERSIST_KEY', token)
localStorage.setItem('persistCode', ``)
localStorage.setItem(
'SETTINGS_PERSIST_KEY',
JSON.stringify({
baseUnit: 'in',
cameraControls: 'KittyCAD',
defaultDirectory: '',
defaultProjectName: 'project-$nnn',
onboardingStatus: 'dismissed',
showDebugPanel: true,
textWrapping: 'On',
theme: 'dark',
unitSystem: 'imperial',
})
)
}, secrets.token)
// reducedMotion kills animations, which speeds up tests and reduces flakiness // reducedMotion kills animations, which speeds up tests and reduces flakiness
await page.emulateMedia({ reducedMotion: 'reduce' }) await page.emulateMedia({ reducedMotion: 'reduce' })
}) })
test.use({
storageState: structuredClone(basicStorageState),
})
test.setTimeout(60_000) test.setTimeout(60_000)
test('exports of each format should work', async ({ page, context }) => { test('exports of each format should work', async ({ page, context }) => {
@ -353,12 +341,7 @@ test('extrude on each default plane should be stable', async ({
await u.openDebugPanel() await u.openDebugPanel()
await u.expectCmdLog('[data-message-type="execution-done"]') await u.expectCmdLog('[data-message-type="execution-done"]')
await u.clearAndCloseDebugPanel() await u.clearAndCloseDebugPanel()
await page.waitForTimeout(200)
await page.getByText('Code').click()
await expect(page).toHaveScreenshot({
maxDiffPixels: 100,
})
await page.getByText('Code').click()
const runSnapshotsForOtherPlanes = async (plane = 'XY') => { const runSnapshotsForOtherPlanes = async (plane = 'XY') => {
// clear code // clear code
@ -371,11 +354,13 @@ test('extrude on each default plane should be stable', async ({
await u.clearAndCloseDebugPanel() await u.clearAndCloseDebugPanel()
await page.getByText('Code').click() await page.getByText('Code').click()
await page.waitForTimeout(80)
await expect(page).toHaveScreenshot({ await expect(page).toHaveScreenshot({
maxDiffPixels: 100, maxDiffPixels: 100,
}) })
await page.getByText('Code').click() await page.getByText('Code').click()
} }
await runSnapshotsForOtherPlanes('XY')
await runSnapshotsForOtherPlanes('-XY') await runSnapshotsForOtherPlanes('-XY')
await runSnapshotsForOtherPlanes('XZ') await runSnapshotsForOtherPlanes('XZ')
@ -386,22 +371,6 @@ test('extrude on each default plane should be stable', async ({
}) })
test('Draft segments should look right', async ({ page, context }) => { test('Draft segments should look right', async ({ page, context }) => {
await context.addInitScript(async () => {
localStorage.setItem(
'SETTINGS_PERSIST_KEY',
JSON.stringify({
baseUnit: 'in',
cameraControls: 'KittyCAD',
defaultDirectory: '',
defaultProjectName: 'project-$nnn',
onboardingStatus: 'dismissed',
showDebugPanel: true,
textWrapping: 'On',
theme: 'dark',
unitSystem: 'imperial',
})
)
})
const u = getUtils(page) const u = getUtils(page)
await page.setViewportSize({ width: 1200, height: 500 }) await page.setViewportSize({ width: 1200, height: 500 })
const PUR = 400 / 37.5 //pixeltoUnitRatio const PUR = 400 / 37.5 //pixeltoUnitRatio
@ -460,26 +429,9 @@ test('Draft segments should look right', async ({ page, context }) => {
}) })
}) })
test('Client side scene scale should match engine scale inch', async ({ test('Client side scene scale should match engine scale - Inch', async ({
page, page,
context,
}) => { }) => {
await context.addInitScript(async () => {
localStorage.setItem(
'SETTINGS_PERSIST_KEY',
JSON.stringify({
baseUnit: 'in',
cameraControls: 'KittyCAD',
defaultDirectory: '',
defaultProjectName: 'project-$nnn',
onboardingStatus: 'dismissed',
showDebugPanel: true,
textWrapping: 'On',
theme: 'dark',
unitSystem: 'imperial',
})
)
})
const u = getUtils(page) const u = getUtils(page)
await page.setViewportSize({ width: 1200, height: 500 }) await page.setViewportSize({ width: 1200, height: 500 })
const PUR = 400 / 37.5 //pixeltoUnitRatio const PUR = 400 / 37.5 //pixeltoUnitRatio
@ -512,7 +464,7 @@ test('Client side scene scale should match engine scale inch', async ({
await page.mouse.click(startXPx + PUR * 10, 500 - PUR * 10) await page.mouse.click(startXPx + PUR * 10, 500 - PUR * 10)
await expect(page.locator('.cm-content')) await expect(page.locator('.cm-content'))
.toHaveText(`const part001 = startSketchOn('-XZ') .toHaveText(`const part001 = startSketchOn('-XZ')
|> startProfileAt([9.06, -12.22], %)`) |> startProfileAt([9.06, -12.22], %)`)
await page.waitForTimeout(100) await page.waitForTimeout(100)
await u.closeDebugPanel() await u.closeDebugPanel()
@ -522,8 +474,8 @@ test('Client side scene scale should match engine scale inch', async ({
await expect(page.locator('.cm-content')) await expect(page.locator('.cm-content'))
.toHaveText(`const part001 = startSketchOn('-XZ') .toHaveText(`const part001 = startSketchOn('-XZ')
|> startProfileAt([9.06, -12.22], %) |> startProfileAt([9.06, -12.22], %)
|> line([9.14, 0], %)`) |> line([9.14, 0], %)`)
await page.getByRole('button', { name: 'Tangential Arc' }).click() await page.getByRole('button', { name: 'Tangential Arc' }).click()
await page.waitForTimeout(100) await page.waitForTimeout(100)
@ -532,9 +484,9 @@ test('Client side scene scale should match engine scale inch', async ({
await expect(page.locator('.cm-content')) await expect(page.locator('.cm-content'))
.toHaveText(`const part001 = startSketchOn('-XZ') .toHaveText(`const part001 = startSketchOn('-XZ')
|> startProfileAt([9.06, -12.22], %) |> startProfileAt([9.06, -12.22], %)
|> line([9.14, 0], %) |> line([9.14, 0], %)
|> tangentialArcTo([27.34, -3.08], %)`) |> tangentialArcTo([27.34, -3.08], %)`)
// click tangential arc tool again to unequip it // click tangential arc tool again to unequip it
await page.getByRole('button', { name: 'Tangential Arc' }).click() await page.getByRole('button', { name: 'Tangential Arc' }).click()
@ -560,26 +512,22 @@ test('Client side scene scale should match engine scale inch', async ({
}) })
}) })
test('Client side scene scale should match engine scale mm', async ({ test.describe('Client side scene scale should match engine scale - Millimeters', () => {
page, const storageState = structuredClone(basicStorageState)
context, storageState.origins[0].localStorage[2].value = TOML.stringify({
}) => { settings: {
await context.addInitScript(async () => { ...basicSettings,
localStorage.setItem( modeling: {
'SETTINGS_PERSIST_KEY', ...basicSettings.modeling,
JSON.stringify({ defaultUnit: 'mm',
baseUnit: 'mm', },
cameraControls: 'KittyCAD', },
defaultDirectory: '',
defaultProjectName: 'project-$nnn',
onboardingStatus: 'dismissed',
showDebugPanel: true,
textWrapping: 'On',
theme: 'dark',
unitSystem: 'metric',
}) })
) test.use({
storageState,
}) })
test('Millimeters', async ({ page }) => {
const u = getUtils(page) const u = getUtils(page)
await page.setViewportSize({ width: 1200, height: 500 }) await page.setViewportSize({ width: 1200, height: 500 })
const PUR = 400 / 37.5 //pixeltoUnitRatio const PUR = 400 / 37.5 //pixeltoUnitRatio
@ -590,7 +538,9 @@ test('Client side scene scale should match engine scale mm', async ({
await expect( await expect(
page.getByRole('button', { name: 'Start Sketch' }) page.getByRole('button', { name: 'Start Sketch' })
).not.toBeDisabled() ).not.toBeDisabled()
await expect(page.getByRole('button', { name: 'Start Sketch' })).toBeVisible() await expect(
page.getByRole('button', { name: 'Start Sketch' })
).toBeVisible()
// click on "Start Sketch" button // click on "Start Sketch" button
await u.clearCommandLogs() await u.clearCommandLogs()
@ -657,6 +607,7 @@ test('Client side scene scale should match engine scale mm', async ({
await expect(page).toHaveScreenshot({ await expect(page).toHaveScreenshot({
maxDiffPixels: 100, maxDiffPixels: 100,
}) })
})
}) })
test('Sketch on face with none z-up', async ({ page, context }) => { test('Sketch on face with none z-up', async ({ page, context }) => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 49 KiB

View File

@ -0,0 +1,40 @@
import { SaveSettingsPayload } from 'lib/settings/settingsTypes'
import { secrets } from './secrets'
import * as TOML from '@iarna/toml'
import { Themes } from 'lib/theme'
export const basicSettings = {
app: {
theme: Themes.Dark,
onboardingStatus: 'dismissed',
projectDirectory: '',
},
modeling: {
defaultUnit: 'in',
mouseControls: 'KittyCAD',
showDebugPanel: true,
},
projects: {
defaultProjectName: 'project-$nnn',
},
textEditor: {
textWrapping: true,
},
} satisfies Partial<SaveSettingsPayload>
export const basicStorageState = {
cookies: [],
origins: [
{
origin: 'http://localhost:3000',
localStorage: [
{ name: 'TOKEN_PERSIST_KEY', value: secrets.token },
{ name: 'persistCode', value: '' },
{
name: '/user.toml',
value: TOML.stringify({ settings: basicSettings }),
},
],
},
],
}

View File

@ -33,7 +33,7 @@ async function clearCommandLogs(page: Page) {
} }
async function expectCmdLog(page: Page, locatorStr: string) { async function expectCmdLog(page: Page, locatorStr: string) {
await expect(page.locator(locatorStr)).toBeVisible() await expect(page.locator(locatorStr).last()).toBeVisible()
} }
async function waitForDefaultPlanesToBeVisible(page: Page) { async function waitForDefaultPlanesToBeVisible(page: Page) {

View File

@ -68,10 +68,10 @@ describe('ZMA (Tauri, Linux)', () => {
const defaultDirInput = await $('[data-testid="default-directory-input"]') const defaultDirInput = await $('[data-testid="default-directory-input"]')
expect(await defaultDirInput.getValue()).toEqual(defaultDir) expect(await defaultDirInput.getValue()).toEqual(defaultDir)
const nameInput = await $('[data-testid="name-input"]') const nameInput = await $('[data-testid="projects-defaultProjectName"]')
expect(await nameInput.getValue()).toEqual('project-$nnn') expect(await nameInput.getValue()).toEqual('project-$nnn')
const closeButton = await $('[data-testid="close-button"]') const closeButton = await $('[data-testid="settings-close-button"]')
await click(closeButton) await click(closeButton)
}) })

View File

@ -10,6 +10,7 @@
"@fortawesome/react-fontawesome": "^0.2.0", "@fortawesome/react-fontawesome": "^0.2.0",
"@headlessui/react": "^1.7.18", "@headlessui/react": "^1.7.18",
"@headlessui/tailwindcss": "^0.2.0", "@headlessui/tailwindcss": "^0.2.0",
"@iarna/toml": "^2.2.5",
"@kittycad/lib": "^0.0.56", "@kittycad/lib": "^0.0.56",
"@lezer/javascript": "^1.4.9", "@lezer/javascript": "^1.4.9",
"@open-rpc/client-js": "^1.8.1", "@open-rpc/client-js": "^1.8.1",
@ -29,6 +30,7 @@
"@xstate/react": "^3.2.2", "@xstate/react": "^3.2.2",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"debounce-promise": "^3.1.2", "debounce-promise": "^3.1.2",
"decamelize": "^6.0.0",
"formik": "^2.4.3", "formik": "^2.4.3",
"fuse.js": "^7.0.0", "fuse.js": "^7.0.0",
"http-server": "^14.1.1", "http-server": "^14.1.1",

View File

@ -1,4 +1,5 @@
import { defineConfig, devices } from '@playwright/test' import { defineConfig, devices } from '@playwright/test'
import { basicStorageState } from './e2e/playwright/storageStates'
/** /**
* Read environment variables from file. * Read environment variables from file.
@ -28,6 +29,9 @@ export default defineConfig({
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry', trace: 'on-first-retry',
/* Use a common shared localStorage */
storageState: basicStorageState,
}, },
/* Configure projects for major browsers */ /* Configure projects for major browsers */

View File

@ -33,10 +33,10 @@ import { useModelingContext } from 'hooks/useModelingContext'
import { useAbsoluteFilePath } from 'hooks/useAbsoluteFilePath' import { useAbsoluteFilePath } from 'hooks/useAbsoluteFilePath'
import { isTauri } from 'lib/isTauri' import { isTauri } from 'lib/isTauri'
import { useLspContext } from 'components/LspProvider' import { useLspContext } from 'components/LspProvider'
import { useValidateSettings } from 'hooks/useValidateSettings' import { useRefreshSettings } from 'hooks/useRefreshSettings'
export function App() { export function App() {
useValidateSettings() useRefreshSettings(paths.FILE + 'SETTINGS')
const { project, file } = useLoaderData() as IndexLoaderData const { project, file } = useLoaderData() as IndexLoaderData
const navigate = useNavigate() const navigate = useNavigate()
const filePath = useAbsoluteFilePath() const filePath = useAbsoluteFilePath()
@ -64,10 +64,14 @@ export function App() {
})) }))
const { settings } = useSettingsAuthContext() const { settings } = useSettingsAuthContext()
const { showDebugPanel, onboardingStatus, theme } = settings?.context || {} const {
modeling: { showDebugPanel },
app: { theme, onboardingStatus },
} = settings.context
const { state, send } = useModelingContext() const { state, send } = useModelingContext()
const editorTheme = theme === Themes.System ? getSystemTheme() : theme const editorTheme =
theme.current === Themes.System ? getSystemTheme() : theme.current
// Pane toggling keyboard shortcuts // Pane toggling keyboard shortcuts
const togglePane = useCallback( const togglePane = useCallback(
@ -95,7 +99,7 @@ export function App() {
) )
const paneOpacity = [onboardingPaths.CAMERA, onboardingPaths.STREAMING].some( const paneOpacity = [onboardingPaths.CAMERA, onboardingPaths.STREAMING].some(
(p) => p === onboardingStatus (p) => p === onboardingStatus.current
) )
? 'opacity-20' ? 'opacity-20'
: didDragInStream : didDragInStream
@ -163,7 +167,7 @@ export function App() {
handleClasses={{ handleClasses={{
right: right:
'hover:bg-chalkboard-10/50 bg-transparent transition-colors duration-75 transition-ease-out delay-100 ' + 'hover:bg-chalkboard-10/50 bg-transparent transition-colors duration-75 transition-ease-out delay-100 ' +
(buttonDownInStream || onboardingStatus === 'camera' (buttonDownInStream || onboardingStatus.current === 'camera'
? 'pointer-events-none ' ? 'pointer-events-none '
: 'pointer-events-auto'), : 'pointer-events-auto'),
}} }}
@ -204,7 +208,7 @@ export function App() {
</div> </div>
</Resizable> </Resizable>
<Stream className="absolute inset-0 z-0" /> <Stream className="absolute inset-0 z-0" />
{showDebugPanel && ( {showDebugPanel.current && (
<DebugPanel <DebugPanel
title="Debug" title="Debug"
className={ className={

View File

@ -22,19 +22,18 @@ import { paths } from 'lib/paths'
import { import {
fileLoader, fileLoader,
homeLoader, homeLoader,
indexLoader,
onboardingRedirectLoader, onboardingRedirectLoader,
settingsLoader,
} from 'lib/routeLoaders' } from 'lib/routeLoaders'
import { CommandBarProvider } from 'components/CommandBar/CommandBarProvider' import { CommandBarProvider } from 'components/CommandBar/CommandBarProvider'
import SettingsAuthProvider from 'components/SettingsAuthProvider' import SettingsAuthProvider from 'components/SettingsAuthProvider'
import LspProvider from 'components/LspProvider' import LspProvider from 'components/LspProvider'
import { KclContextProvider } from 'lang/KclProvider' import { KclContextProvider } from 'lang/KclProvider'
import { BROWSER_PROJECT_NAME } from 'lib/constants'
export const BROWSER_FILE_NAME = 'new'
const router = createBrowserRouter([ const router = createBrowserRouter([
{ {
loader: indexLoader, loader: settingsLoader,
id: paths.INDEX, id: paths.INDEX,
element: ( element: (
<CommandBarProvider> <CommandBarProvider>
@ -47,14 +46,14 @@ const router = createBrowserRouter([
</KclContextProvider> </KclContextProvider>
</CommandBarProvider> </CommandBarProvider>
), ),
errorElement: <ErrorPage />,
children: [ children: [
{ {
path: paths.INDEX, path: paths.INDEX,
loader: () => loader: () =>
isTauri() isTauri()
? redirect(paths.HOME) ? redirect(paths.HOME)
: redirect(paths.FILE + '/' + BROWSER_FILE_NAME), : redirect(paths.FILE + '/%2F' + BROWSER_PROJECT_NAME),
errorElement: <ErrorPage />,
}, },
{ {
loader: fileLoader, loader: fileLoader,
@ -73,23 +72,23 @@ const router = createBrowserRouter([
{!isTauri() && import.meta.env.PROD && <DownloadAppBanner />} {!isTauri() && import.meta.env.PROD && <DownloadAppBanner />}
</Auth> </Auth>
), ),
children: [
{
id: paths.FILE + 'SETTINGS',
loader: settingsLoader,
children: [ children: [
{ {
loader: onboardingRedirectLoader, loader: onboardingRedirectLoader,
index: true, index: true,
element: <></>, element: <></>,
}, },
{
children: [
{ {
path: makeUrlPathRelative(paths.SETTINGS), path: makeUrlPathRelative(paths.SETTINGS),
loader: indexLoader, // very rare someone will load into settings first, but it's possible in the browser
element: <Settings />, element: <Settings />,
}, },
{ {
path: makeUrlPathRelative(paths.ONBOARDING.INDEX), path: makeUrlPathRelative(paths.ONBOARDING.INDEX),
element: <Onboarding />, element: <Onboarding />,
loader: indexLoader, // very rare someone will load into settings first, but it's possible in the browser
children: onboardingRoutes, children: onboardingRoutes,
}, },
], ],
@ -108,8 +107,15 @@ const router = createBrowserRouter([
id: paths.HOME, id: paths.HOME,
loader: homeLoader, loader: homeLoader,
children: [ children: [
{
index: true,
element: <></>,
id: paths.HOME + 'SETTINGS',
loader: settingsLoader,
},
{ {
path: makeUrlPathRelative(paths.SETTINGS), path: makeUrlPathRelative(paths.SETTINGS),
loader: settingsLoader,
element: <Settings />, element: <Settings />,
}, },
], ],

View File

@ -37,7 +37,7 @@ export const ClientSideScene = ({
}: { }: {
cameraControls: ReturnType< cameraControls: ReturnType<
typeof useSettingsAuthContext typeof useSettingsAuthContext
>['settings']['context']['cameraControls'] >['settings']['context']['modeling']['mouseControls']['current']
}) => { }) => {
const canvasRef = useRef<HTMLDivElement>(null) const canvasRef = useRef<HTMLDivElement>(null)
const { state, send } = useModelingContext() const { state, send } = useModelingContext()

View File

@ -25,9 +25,9 @@ import * as TWEEN from '@tweenjs/tween.js'
import { SourceRange } from 'lang/wasm' import { SourceRange } from 'lang/wasm'
import { Axis } from 'lib/selections' import { Axis } from 'lib/selections'
import { type BaseUnit } from 'lib/settings/settingsTypes' import { type BaseUnit } from 'lib/settings/settingsTypes'
import { SETTINGS_PERSIST_KEY } from 'lib/constants'
import { CameraControls } from './CameraControls' import { CameraControls } from './CameraControls'
import { EngineCommandManager } from 'lang/std/engineConnection' import { EngineCommandManager } from 'lang/std/engineConnection'
import { settings } from 'lib/settings/initialSettings'
type SendType = ReturnType<typeof useModelingContext>['send'] type SendType = ReturnType<typeof useModelingContext>['send']
@ -170,9 +170,7 @@ export class SceneInfra {
// CAMERA // CAMERA
const camHeightDistanceRatio = 0.5 const camHeightDistanceRatio = 0.5
const baseUnit: BaseUnit = const baseUnit: BaseUnit = settings.modeling.defaultUnit.current
JSON.parse(localStorage?.getItem(SETTINGS_PERSIST_KEY) || ('{}' as any))
.baseUnit || 'mm'
const baseRadius = 5.6 const baseRadius = 5.6
const length = baseUnitTomm(baseUnit) * baseRadius const length = baseUnitTomm(baseUnit) * baseRadius
const ang = Math.atan(camHeightDistanceRatio) const ang = Math.atan(camHeightDistanceRatio)

View File

@ -1,29 +1,35 @@
import { Combobox } from '@headlessui/react' import { Combobox } from '@headlessui/react'
import { useSelector } from '@xstate/react'
import Fuse from 'fuse.js' import Fuse from 'fuse.js'
import { useCommandsContext } from 'hooks/useCommandsContext' import { useCommandsContext } from 'hooks/useCommandsContext'
import { CommandArgument, CommandArgumentOption } from 'lib/commandTypes' import { CommandArgument, CommandArgumentOption } from 'lib/commandTypes'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { AnyStateMachine, StateFrom } from 'xstate'
const contextSelector = (snapshot: StateFrom<AnyStateMachine>) =>
snapshot.context
function CommandArgOptionInput({ function CommandArgOptionInput({
options, arg,
argName, argName,
stepBack, stepBack,
onSubmit, onSubmit,
placeholder, placeholder,
}: { }: {
options: (CommandArgument<unknown> & { inputType: 'options' })['options'] arg: CommandArgument<unknown> & { inputType: 'options' }
argName: string argName: string
stepBack: () => void stepBack: () => void
onSubmit: (data: unknown) => void onSubmit: (data: unknown) => void
placeholder?: string placeholder?: string
}) { }) {
const actorContext = useSelector(arg.machineActor, contextSelector)
const { commandBarSend, commandBarState } = useCommandsContext() const { commandBarSend, commandBarState } = useCommandsContext()
const resolvedOptions = useMemo( const resolvedOptions = useMemo(
() => () =>
typeof options === 'function' typeof arg.options === 'function'
? options(commandBarState.context) ? arg.options(commandBarState.context, actorContext)
: options, : arg.options,
[argName, options, commandBarState.context] [argName, arg, commandBarState.context, actorContext]
) )
// The initial current option is either an already-input value or the configured default // The initial current option is either an already-input value or the configured default
const currentOption = useMemo( const currentOption = useMemo(
@ -38,7 +44,7 @@ function CommandArgOptionInput({
const [selectedOption, setSelectedOption] = useState< const [selectedOption, setSelectedOption] = useState<
CommandArgumentOption<unknown> CommandArgumentOption<unknown>
>(currentOption || resolvedOptions[0]) >(currentOption || resolvedOptions[0])
const initialQuery = useMemo(() => '', [options, argName]) const initialQuery = useMemo(() => '', [arg.options, argName])
const [query, setQuery] = useState(initialQuery) const [query, setQuery] = useState(initialQuery)
const [filteredOptions, setFilteredOptions] = const [filteredOptions, setFilteredOptions] =
useState<typeof resolvedOptions>() useState<typeof resolvedOptions>()

View File

@ -51,7 +51,24 @@ function ArgumentInput({
case 'options': case 'options':
return ( return (
<CommandArgOptionInput <CommandArgOptionInput
options={arg.options} arg={arg}
argName={arg.name}
stepBack={stepBack}
onSubmit={onSubmit}
placeholder="Select an option"
/>
)
case 'boolean':
return (
<CommandArgOptionInput
arg={{
...arg,
inputType: 'options',
options: [
{ name: 'On', value: true },
{ name: 'Off', value: false },
],
}}
argName={arg.name} argName={arg.name}
stepBack={stepBack} stepBack={stepBack}
onSubmit={onSubmit} onSubmit={onSubmit}

View File

@ -74,7 +74,7 @@ function CommandBarHeader({ children }: React.PropsWithChildren<{}>) {
selectedCommand.icon && ( selectedCommand.icon && (
<CustomIcon name={selectedCommand.icon} className="w-5 h-5" /> <CustomIcon name={selectedCommand.icon} className="w-5 h-5" />
)} )}
{selectedCommand?.name} {selectedCommand.displayName || selectedCommand.name}
</p> </p>
{Object.entries(selectedCommand?.args || {}) {Object.entries(selectedCommand?.args || {})
.filter(([_, argConfig]) => .filter(([_, argConfig]) =>

View File

@ -76,9 +76,9 @@ function CommandBarKclInput({
}, },
accessKey: 'command-bar', accessKey: 'command-bar',
theme: theme:
settings.context.theme === 'system' settings.context.app.theme.current === 'system'
? getSystemTheme() ? getSystemTheme()
: settings.context.theme, : settings.context.app.theme.current,
extensions: [ extensions: [
EditorView.domEventHandlers({ EditorView.domEventHandlers({
keydown: (event) => { keydown: (event) => {

View File

@ -20,7 +20,7 @@ function CommandComboBox({
options.find((o) => 'isCurrent' in o && o.isCurrent) || null options.find((o) => 'isCurrent' in o && o.isCurrent) || null
const fuse = new Fuse(options, { const fuse = new Fuse(options, {
keys: ['name', 'description'], keys: ['displayName', 'name', 'description'],
threshold: 0.3, threshold: 0.3,
}) })
@ -80,7 +80,12 @@ function CommandComboBox({
className="w-5 h-5 dark:text-energy-10" className="w-5 h-5 dark:text-energy-10"
/> />
)} )}
<p className="flex-grow">{option.name} </p> <p className="flex-grow">{option.displayName || option.name} </p>
{option.description && (
<p className="text-xs text-chalkboard-60 dark:text-chalkboard-40">
{option.description}
</p>
)}
</Combobox.Option> </Combobox.Option>
))} ))}
</Combobox.Options> </Combobox.Options>

View File

@ -25,7 +25,9 @@ export type CustomIconName =
| 'network' | 'network'
| 'networkCrossedOut' | 'networkCrossedOut'
| 'parallel' | 'parallel'
| 'person'
| 'plus' | 'plus'
| 'refresh'
| 'search' | 'search'
| 'settings' | 'settings'
| 'sketch' | 'sketch'
@ -453,6 +455,22 @@ export const CustomIcon = ({
/> />
</svg> </svg>
) )
case 'person':
return (
<svg
{...props}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12 6C12 7.10457 11.1046 8 10 8C8.89543 8 8 7.10457 8 6C8 4.89543 8.89543 4 10 4C11.1046 4 12 4.89543 12 6ZM13 6C13 7.65685 11.6569 9 10 9C8.34315 9 7 7.65685 7 6C7 4.34315 8.34315 3 10 3C11.6569 3 13 4.34315 13 6ZM5 12V11L9 10H11L15 11V12C15 14.7614 12.7614 17 10 17C7.23858 17 5 14.7614 5 12ZM6 11.7808L9.12311 11H10.8769L14 11.7808V12C14 14.2091 12.2091 16 10 16C7.79086 16 6 14.2091 6 12V11.7808Z"
fill="currentColor"
/>
</svg>
)
case 'plus': case 'plus':
return ( return (
<svg <svg
@ -462,13 +480,29 @@ export const CustomIcon = ({
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
> >
<path <path
fill-rule="evenodd" fillRule="evenodd"
clip-rule="evenodd" clipRule="evenodd"
d="M9.5 9.5V5.5H10.5V9.5H14.5V10.5H10.5V14.5H9.5V10.5H5.5V9.5H9.5Z" d="M9.5 9.5V5.5H10.5V9.5H14.5V10.5H10.5V14.5H9.5V10.5H5.5V9.5H9.5Z"
fill="currentColor" fill="currentColor"
/> />
</svg> </svg>
) )
case 'refresh':
return (
<svg
{...props}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M8.93434 4.43959L9.4014 4.26111L14.0251 2.49432L14.382 3.42845L11.5647 4.50499L10.5648 4.51221C10.8323 4.53935 11.0173 4.58539 11.2161 4.63484C11.2179 4.63528 11.2197 4.63572 11.2214 4.63616L11.2195 4.6369C11.8713 4.78513 12.4941 5.05172 13.0556 5.42692C13.9601 6.03127 14.6651 6.89025 15.0813 7.89524C15.4976 8.90024 15.6065 10.0061 15.3943 11.073C15.1821 12.1399 14.6583 13.1199 13.8891 13.8891C13.1199 14.6583 12.1399 15.1821 11.073 15.3943C10.0061 15.6065 8.90023 15.4976 7.89524 15.0813C6.89025 14.6651 6.03126 13.9601 5.42692 13.0556C4.82257 12.1512 4.5 11.0878 4.5 10H5.5C5.5 10.89 5.76392 11.76 6.25839 12.5001C6.75285 13.2401 7.45566 13.8169 8.27792 14.1575C9.10019 14.4981 10.005 14.5872 10.8779 14.4135C11.7508 14.2399 12.5526 13.8113 13.182 13.182C13.8113 12.5526 14.2399 11.7508 14.4135 10.8779C14.5872 10.005 14.4981 9.10019 14.1575 8.27793C13.8169 7.45566 13.2401 6.75286 12.5001 6.25839C11.8763 5.84159 11.1601 5.5886 10.4175 5.51941L11.8137 9.17339L10.8796 9.53033L9.11281 4.90665L8.93434 4.43959Z"
fill="currentColor"
/>
</svg>
)
case 'search': case 'search':
return ( return (
<svg <svg

View File

@ -13,7 +13,7 @@ import {
StateFrom, StateFrom,
} from 'xstate' } from 'xstate'
import { useCommandsContext } from 'hooks/useCommandsContext' import { useCommandsContext } from 'hooks/useCommandsContext'
import { DEFAULT_FILE_NAME, fileMachine } from 'machines/fileMachine' import { fileMachine } from 'machines/fileMachine'
import { import {
createDir, createDir,
removeDir, removeDir,
@ -21,9 +21,10 @@ import {
renameFile, renameFile,
writeFile, writeFile,
} from '@tauri-apps/api/fs' } from '@tauri-apps/api/fs'
import { FILE_EXT, readProject } from 'lib/tauriFS' import { readProject } from 'lib/tauriFS'
import { isTauri } from 'lib/isTauri' import { isTauri } from 'lib/isTauri'
import { sep } from '@tauri-apps/api/path' import { sep } from '@tauri-apps/api/path'
import { DEFAULT_FILE_NAME, FILE_EXT } from 'lib/constants'
type MachineContext<T extends AnyStateMachine> = { type MachineContext<T extends AnyStateMachine> = {
state: StateFrom<T> state: StateFrom<T>

View File

@ -11,7 +11,8 @@ import { faChevronRight, faTrashAlt } from '@fortawesome/free-solid-svg-icons'
import { useFileContext } from 'hooks/useFileContext' import { useFileContext } from 'hooks/useFileContext'
import { useHotkeys } from 'react-hotkeys-hook' import { useHotkeys } from 'react-hotkeys-hook'
import styles from './FileTree.module.css' import styles from './FileTree.module.css'
import { FILE_EXT, sortProject } from 'lib/tauriFS' import { sortProject } from 'lib/tauriFS'
import { FILE_EXT } from 'lib/constants'
import { CustomIcon } from './CustomIcon' import { CustomIcon } from './CustomIcon'
import { kclManager } from 'lib/singletons' import { kclManager } from 'lib/singletons'
import { useDocumentHasFocus } from 'hooks/useDocumentHasFocus' import { useDocumentHasFocus } from 'hooks/useDocumentHasFocus'

View File

@ -66,13 +66,16 @@ export const ModelingMachineProvider = ({
const { const {
auth, auth,
settings: { settings: {
context: { baseUnit, theme }, context: {
app: { theme },
modeling: { defaultUnit },
},
}, },
} = useSettingsAuthContext() } = useSettingsAuthContext()
const { code } = useKclContext() const { code } = useKclContext()
const token = auth?.context?.token const token = auth?.context?.token
const streamRef = useRef<HTMLDivElement>(null) const streamRef = useRef<HTMLDivElement>(null)
useSetupEngineManager(streamRef, token, theme) useSetupEngineManager(streamRef, token, theme.current)
const { const {
isShiftDown, isShiftDown,
@ -234,7 +237,7 @@ export const ModelingMachineProvider = ({
format.type === 'stl' || format.type === 'stl' ||
format.type === 'ply' format.type === 'ply'
) { ) {
format.units = baseUnit format.units = defaultUnit.current
} }
if (format.type === 'ply' || format.type === 'stl') { if (format.type === 'ply' || format.type === 'stl') {

View File

@ -9,7 +9,8 @@ import {
faTrashAlt, faTrashAlt,
faX, faX,
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
import { FILE_EXT, getPartsCount, readProject } from '../lib/tauriFS' import { getPartsCount, readProject } from '../lib/tauriFS'
import { FILE_EXT } from 'lib/constants'
import { Dialog } from '@headlessui/react' import { Dialog } from '@headlessui/react'
import { useHotkeys } from 'react-hotkeys-hook' import { useHotkeys } from 'react-hotkeys-hook'

View File

@ -6,12 +6,9 @@ import withBaseUrl from '../lib/withBaseURL'
import React, { createContext, useEffect } from 'react' import React, { createContext, useEffect } from 'react'
import useStateMachineCommands from '../hooks/useStateMachineCommands' import useStateMachineCommands from '../hooks/useStateMachineCommands'
import { settingsMachine } from 'machines/settingsMachine' import { settingsMachine } from 'machines/settingsMachine'
import {
fallbackLoadedSettings,
validateSettings,
} from 'lib/settings/settingsUtils'
import { toast } from 'react-hot-toast' import { toast } from 'react-hot-toast'
import { getThemeColorForEngine, setThemeClass, Themes } from 'lib/theme' import { getThemeColorForEngine, setThemeClass, Themes } from 'lib/theme'
import decamelize from 'decamelize'
import { import {
AnyStateMachine, AnyStateMachine,
ContextFrom, ContextFrom,
@ -20,10 +17,19 @@ import {
StateFrom, StateFrom,
} from 'xstate' } from 'xstate'
import { isTauri } from 'lib/isTauri' import { isTauri } from 'lib/isTauri'
import { settingsCommandBarConfig } from 'lib/commandBarConfigs/settingsCommandConfig'
import { authCommandBarConfig } from 'lib/commandBarConfigs/authCommandConfig' import { authCommandBarConfig } from 'lib/commandBarConfigs/authCommandConfig'
import { kclManager, sceneInfra, engineCommandManager } from 'lib/singletons' import { kclManager, sceneInfra, engineCommandManager } from 'lib/singletons'
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
import { IndexLoaderData } from 'lib/types'
import { settings } from 'lib/settings/initialSettings'
import {
createSettingsCommand,
settingsWithCommandConfigs,
} from 'lib/commandBarConfigs/settingsCommandConfig'
import { useCommandsContext } from 'hooks/useCommandsContext'
import { Command } from 'lib/commandTypes'
import { BaseUnit } from 'lib/settings/settingsTypes'
import { saveSettings } from 'lib/settings/settingsUtils'
type MachineContext<T extends AnyStateMachine> = { type MachineContext<T extends AnyStateMachine> = {
state: StateFrom<T> state: StateFrom<T>
@ -49,11 +55,13 @@ export const SettingsAuthProvider = ({
}: { }: {
children: React.ReactNode children: React.ReactNode
}) => { }) => {
const loadedSettings = useRouteLoaderData(paths.INDEX) as Awaited< const loadedSettings = useRouteLoaderData(paths.INDEX) as typeof settings
ReturnType<typeof validateSettings> const loadedProject = useRouteLoaderData(paths.FILE) as IndexLoaderData
>
return ( return (
<SettingsAuthProviderBase loadedSettings={loadedSettings}> <SettingsAuthProviderBase
loadedSettings={loadedSettings}
loadedProject={loadedProject}
>
{children} {children}
</SettingsAuthProviderBase> </SettingsAuthProviderBase>
) )
@ -66,7 +74,7 @@ export const SettingsAuthProviderJest = ({
}: { }: {
children: React.ReactNode children: React.ReactNode
}) => { }) => {
const loadedSettings = fallbackLoadedSettings const loadedSettings = settings
return ( return (
<SettingsAuthProviderBase loadedSettings={loadedSettings}> <SettingsAuthProviderBase loadedSettings={loadedSettings}>
{children} {children}
@ -77,23 +85,25 @@ export const SettingsAuthProviderJest = ({
export const SettingsAuthProviderBase = ({ export const SettingsAuthProviderBase = ({
children, children,
loadedSettings, loadedSettings,
loadedProject,
}: { }: {
children: React.ReactNode children: React.ReactNode
loadedSettings: Awaited<ReturnType<typeof validateSettings>> loadedSettings: typeof settings
loadedProject?: IndexLoaderData
}) => { }) => {
const { settings: initialLoadedContext } = loadedSettings
const navigate = useNavigate() const navigate = useNavigate()
const { commandBarSend } = useCommandsContext()
const [settingsState, settingsSend, settingsActor] = useMachine( const [settingsState, settingsSend, settingsActor] = useMachine(
settingsMachine, settingsMachine,
{ {
context: initialLoadedContext, context: loadedSettings,
actions: { actions: {
setClientSideSceneUnits: (context, event) => { setClientSideSceneUnits: (context, event) => {
const newBaseUnit = const newBaseUnit =
event.type === 'Set Base Unit' event.type === 'set.modeling.defaultUnit'
? event.data.baseUnit ? (event.data.value as BaseUnit)
: context.baseUnit : context.modeling.defaultUnit.current
sceneInfra.baseUnit = newBaseUnit sceneInfra.baseUnit = newBaseUnit
}, },
setEngineTheme: (context) => { setEngineTheme: (context) => {
@ -102,39 +112,76 @@ export const SettingsAuthProviderBase = ({
type: 'modeling_cmd_req', type: 'modeling_cmd_req',
cmd: { cmd: {
type: 'set_background_color', type: 'set_background_color',
color: getThemeColorForEngine(context.theme), color: getThemeColorForEngine(context.app.theme.current),
}, },
}) })
}, },
toastSuccess: (context, event) => { toastSuccess: (context, event) => {
const truncatedNewValue = const eventParts = event.type.replace(/^set./, '').split('.') as [
'data' in event && event.data instanceof Object keyof typeof settings,
? (context[Object.keys(event.data)[0] as keyof typeof context] string
.toString() ]
.substring(0, 28) as any) const truncatedNewValue = event.data.value?.toString().slice(0, 28)
: undefined const message =
toast.success( `Set ${decamelize(eventParts[1], { separator: ' ' })}` +
event.type +
(truncatedNewValue (truncatedNewValue
? ` to "${truncatedNewValue}${ ? ` to "${truncatedNewValue}${
truncatedNewValue.length === 28 ? '...' : '' truncatedNewValue.length === 28 ? '...' : ''
}"` }"${
event.data.level === 'project'
? ' for this project'
: ' as a user default'
}`
: '') : '')
) toast.success(message, {
duration: message.split(' ').length * 100 + 1500,
})
}, },
'Execute AST': () => kclManager.executeAst(), 'Execute AST': () => kclManager.executeAst(),
persistSettings: (context) =>
saveSettings(context, loadedProject?.project?.path),
}, },
} }
) )
settingsStateRef = settingsState.context settingsStateRef = settingsState.context
useStateMachineCommands({ // Add settings commands to the command bar
machineId: 'settings', // They're treated slightly differently than other commands
state: settingsState, // Because their state machine doesn't have a meaningful .nextEvents,
// and they are configured statically in initialiSettings
useEffect(() => {
// If the user wants to hide the settings commands
//from the command bar don't add them.
if (settingsState.context.commandBar.includeSettings.current === false)
return
const commands = settingsWithCommandConfigs(settingsState.context)
.map((type) =>
createSettingsCommand({
type,
send: settingsSend, send: settingsSend,
commandBarConfig: settingsCommandBarConfig, context: settingsState.context,
actor: settingsActor, actor: settingsActor,
isProjectAvailable: loadedProject !== undefined,
}) })
)
.filter((c) => c !== null) as Command[]
commandBarSend({ type: 'Add commands', data: { commands: commands } })
return () => {
commandBarSend({
type: 'Remove commands',
data: { commands },
})
}
}, [
settingsState,
settingsSend,
settingsActor,
commandBarSend,
settingsWithCommandConfigs,
])
// Listen for changes to the system theme and update the app theme accordingly // Listen for changes to the system theme and update the app theme accordingly
// This is only done if the theme setting is set to 'system'. // This is only done if the theme setting is set to 'system'.
@ -144,7 +191,7 @@ export const SettingsAuthProviderBase = ({
useEffect(() => { useEffect(() => {
const matcher = window.matchMedia('(prefers-color-scheme: dark)') const matcher = window.matchMedia('(prefers-color-scheme: dark)')
const listener = (e: MediaQueryListEvent) => { const listener = (e: MediaQueryListEvent) => {
if (settingsState.context.theme !== 'system') return if (settingsState.context.app.theme.current !== 'system') return
setThemeClass(e.matches ? Themes.Dark : Themes.Light) setThemeClass(e.matches ? Themes.Dark : Themes.Light)
} }

View File

@ -106,7 +106,9 @@ export const Stream = ({ className = '' }: { className?: string }) => {
style={{ transitionDuration: '200ms', transitionProperty: 'filter' }} style={{ transitionDuration: '200ms', transitionProperty: 'filter' }}
id="video-stream" id="video-stream"
/> />
<ClientSideScene cameraControls={settings.context?.cameraControls} /> <ClientSideScene
cameraControls={settings.context.modeling.mouseControls.current}
/>
{!isNetworkOkay && !isLoading && ( {!isNetworkOkay && !isLoading && (
<div className="text-center absolute inset-0"> <div className="text-center absolute inset-0">
<Loading> <Loading>

View File

@ -81,7 +81,7 @@ export const TextEditor = ({
} = useModelingContext() } = useModelingContext()
const { settings } = useSettingsAuthContext() const { settings } = useSettingsAuthContext()
const textWrapping = settings.context?.textWrapping ?? 'On' const textWrapping = settings.context.textEditor.textWrapping
const { commandBarSend } = useCommandsContext() const { commandBarSend } = useCommandsContext()
const { enable: convertEnabled, handleClick: convertCallback } = const { enable: convertEnabled, handleClick: convertCallback } =
useConvertToVariable() useConvertToVariable()
@ -218,11 +218,11 @@ export const TextEditor = ({
], ],
}) })
) )
if (textWrapping === 'On') extensions.push(EditorView.lineWrapping) if (textWrapping.current) extensions.push(EditorView.lineWrapping)
} }
return extensions return extensions
}, [kclLSP, textWrapping, convertCallback]) }, [kclLSP, textWrapping.current, convertCallback])
return ( return (
<div <div

View File

@ -1,14 +1,11 @@
import { BROWSER_FILE_NAME } from 'Router'
import { type IndexLoaderData } from 'lib/types' import { type IndexLoaderData } from 'lib/types'
import { paths } from 'lib/paths' import { BROWSER_PATH, paths } from 'lib/paths'
import { useRouteLoaderData } from 'react-router-dom' import { useRouteLoaderData } from 'react-router-dom'
export function useAbsoluteFilePath() { export function useAbsoluteFilePath() {
const routeData = useRouteLoaderData(paths.FILE) as IndexLoaderData const routeData = useRouteLoaderData(paths.FILE) as IndexLoaderData
return ( return (
paths.FILE + paths.FILE + '/' + encodeURIComponent(routeData?.file?.path || BROWSER_PATH)
'/' +
encodeURIComponent(routeData?.file?.path || BROWSER_FILE_NAME)
) )
} }

View File

@ -0,0 +1,28 @@
import { useRouteLoaderData } from 'react-router-dom'
import { useSettingsAuthContext } from './useSettingsAuthContext'
import { paths } from 'lib/paths'
import { settings } from 'lib/settings/initialSettings'
import { useEffect } from 'react'
/**
* I was dismayed to learn that index route in Router.tsx where we initially load up the settings
* doesn't re-run on subsequent navigations. This hook is a workaround,
* in conjunction with additional uses of settingsLoader further down the router tree.
* @param routeId - The id defined in Router.tsx to load the settings from.
*/
export function useRefreshSettings(routeId: string = paths.INDEX) {
const ctx = useSettingsAuthContext()
const routeData = useRouteLoaderData(routeId) as typeof settings
if (!ctx) {
throw new Error(
'useRefreshSettings must be used within a SettingsAuthProvider'
)
}
useEffect(() => {
ctx.settings.send('Set all settings', {
settings: routeData,
})
}, [])
}

View File

@ -1,33 +0,0 @@
import { validateSettings } from 'lib/settings/settingsUtils'
import { useEffect } from 'react'
import toast from 'react-hot-toast'
import { useRouteLoaderData } from 'react-router-dom'
import { useSettingsAuthContext } from './useSettingsAuthContext'
import { paths } from 'lib/paths'
// This hook must only be used within a descendant of the SettingsAuthProvider component
// (and, by extension, the Router component).
// Specifically it relies on the Router's indexLoader data and the settingsMachine send function.
// for the settings and validation errors to be available.
export function useValidateSettings() {
const {
settings: { send },
} = useSettingsAuthContext()
const { settings, errors } = useRouteLoaderData(paths.INDEX) as Awaited<
ReturnType<typeof validateSettings>
>
// If there were validation errors either from local storage or from the file,
// log them to the console and show a toast message to the user.
useEffect(() => {
if (errors.length > 0) {
send('Set All Settings', settings)
const errorMessage =
'Error validating persisted settings: ' +
errors.join(', ') +
'. Using defaults.'
console.error(errorMessage)
toast.error(errorMessage)
}
}, [errors])
}

View File

@ -19,10 +19,10 @@ root.render(
<HotkeysProvider> <HotkeysProvider>
<Router /> <Router />
<Toaster <Toaster
position="top-center" position="bottom-center"
toastOptions={{ toastOptions={{
style: { style: {
borderRadius: '0.25rem', borderRadius: '3px',
}, },
className: className:
'bg-chalkboard-10 dark:bg-chalkboard-90 text-chalkboard-110 dark:text-chalkboard-10 rounded-sm border-chalkboard-20/50 dark:border-chalkboard-80/50', 'bg-chalkboard-10 dark:bg-chalkboard-90 text-chalkboard-110 dark:text-chalkboard-10 rounded-sm border-chalkboard-20/50 dark:border-chalkboard-80/50',

View File

@ -88,7 +88,6 @@ export class KclManager {
setTimeout(() => { setTimeout(() => {
// Wait one event loop to give a chance for params to be set // Wait one event loop to give a chance for params to be set
// Save the file to disk // Save the file to disk
// Note that PROJECT_ENTRYPOINT is hardcoded until we support multiple files
this._params.id && this._params.id &&
writeTextFile(this._params.id, code).catch((err) => { writeTextFile(this._params.id, code).catch((err) => {
// TODO: add tracing per GH issue #254 (https://github.com/KittyCAD/modeling-app/issues/254) // TODO: add tracing per GH issue #254 (https://github.com/KittyCAD/modeling-app/issues/254)

View File

@ -154,7 +154,8 @@ export const _executor = async (
isMock: boolean isMock: boolean
): Promise<ProgramMemory> => { ): Promise<ProgramMemory> => {
try { try {
const baseUnit = (await getSettingsState)()?.baseUnit || 'mm' const baseUnit =
(await getSettingsState)()?.modeling.defaultUnit.current || 'mm'
const memory: ProgramMemory = await execute_wasm( const memory: ProgramMemory = await execute_wasm(
JSON.stringify(node), JSON.stringify(node),
JSON.stringify(programMemory), JSON.stringify(programMemory),

View File

@ -1,146 +1,124 @@
import { type CommandSetConfig } from '../commandTypes'
import { import {
type BaseUnit, Command,
type Toggle, CommandArgument,
UnitSystem, CommandArgumentConfig,
baseUnitsUnion, } from '../commandTypes'
import {
SettingsPaths,
SettingsLevel,
SettingProps,
} from 'lib/settings/settingsTypes' } from 'lib/settings/settingsTypes'
import { settingsMachine } from 'machines/settingsMachine' import { settingsMachine } from 'machines/settingsMachine'
import { type CameraSystem, cameraSystems } from '../cameraControls' import { PathValue } from 'lib/types'
import { Themes } from '../theme' import { AnyStateMachine, ContextFrom, InterpreterFrom } from 'xstate'
import { getPropertyByPath } from 'lib/objectPropertyByPath'
import { buildCommandArgument } from 'lib/createMachineCommand'
import decamelize from 'decamelize'
import { isTauri } from 'lib/isTauri'
// SETTINGS MACHINE // An array of the paths to all of the settings that have commandConfigs
export type SettingsCommandSchema = { export const settingsWithCommandConfigs = (
'Set Base Unit': { s: ContextFrom<typeof settingsMachine>
baseUnit: BaseUnit ) =>
} Object.entries(s).flatMap(([categoryName, categorySettings]) =>
'Set Camera Controls': { Object.entries(categorySettings)
cameraControls: CameraSystem .filter(([_, setting]) => setting.commandConfig !== undefined)
} .map(([settingName]) => `${categoryName}.${settingName}`)
'Set Default Project Name': { ) as SettingsPaths[]
defaultProjectName: string
} const levelArgConfig = <T extends AnyStateMachine = AnyStateMachine>(
'Set Text Wrapping': { actor: InterpreterFrom<T>,
textWrapping: Toggle isProjectAvailable: boolean,
} hideOnLevel?: SettingsLevel
'Set Theme': { ): CommandArgument<SettingsLevel, T> => ({
theme: Themes inputType: 'options' as const,
} required: true,
'Set Unit System': { defaultValue:
unitSystem: UnitSystem isProjectAvailable && hideOnLevel !== 'project' ? 'project' : 'user',
} skip: true,
options:
isProjectAvailable && hideOnLevel !== 'project'
? [
{ name: 'User', value: 'user' as SettingsLevel },
{
name: 'Project',
value: 'project' as SettingsLevel,
isCurrent: true,
},
]
: [{ name: 'User', value: 'user' as SettingsLevel, isCurrent: true }],
machineActor: actor,
})
interface CreateSettingsArgs {
type: SettingsPaths
send: Function
context: ContextFrom<typeof settingsMachine>
actor: InterpreterFrom<typeof settingsMachine>
isProjectAvailable: boolean
} }
export const settingsCommandBarConfig: CommandSetConfig< // Takes a Setting with a commandConfig and creates a Command
typeof settingsMachine, // that can be used in the CommandBar component.
SettingsCommandSchema export function createSettingsCommand({
> = { type,
'Set Base Unit': { send,
icon: 'settings', context,
args: { actor,
baseUnit: { isProjectAvailable,
inputType: 'options', }: CreateSettingsArgs) {
type S = PathValue<typeof context, typeof type>
const settingConfig = getPropertyByPath(context, type) as SettingProps<
S['default']
>
const valueArgPartialConfig = settingConfig['commandConfig']
const shouldHideOnThisLevel =
settingConfig?.hideOnLevel === 'user' && !isProjectAvailable
const shouldHideOnThisPlatform =
settingConfig.hideOnPlatform &&
(isTauri()
? settingConfig.hideOnPlatform === 'desktop'
: settingConfig.hideOnPlatform === 'web')
if (
!valueArgPartialConfig ||
shouldHideOnThisLevel ||
shouldHideOnThisPlatform
)
return null
const valueArgConfig = {
...valueArgPartialConfig,
required: true, required: true,
defaultValueFromContext: (context) => context.baseUnit, } as CommandArgumentConfig<S['default']>
options: [],
optionsFromContext: (context) => // @ts-ignore - TODO figure out this typing for valueArgConfig
Object.values(baseUnitsUnion).map((v) => ({ const valueArg = buildCommandArgument(valueArgConfig, context, actor)
name: v,
value: v, const command: Command = {
isCurrent: v === context.baseUnit, name: type,
})), displayName: `Settings · ${decamelize(type.replaceAll('.', ' · '), {
}, separator: ' ',
}, })}`,
}, ownerMachine: 'settings',
'Set Camera Controls': {
icon: 'settings', icon: 'settings',
needsReview: false,
onSubmit: (data) => {
if (data !== undefined && data !== null) {
send({ type: `set.${type}`, data })
} else {
send(type)
}
},
args: { args: {
cameraControls: { level: levelArgConfig(
inputType: 'options', actor,
required: true, isProjectAvailable,
defaultValueFromContext: (context) => context.cameraControls, settingConfig.hideOnLevel
options: [], ),
optionsFromContext: (context) => value: valueArg,
Object.values(cameraSystems).map((v) => ({
name: v,
value: v,
isCurrent: v === context.cameraControls,
})),
},
},
},
'Set Default Project Name': {
icon: 'settings',
hide: 'web',
args: {
defaultProjectName: {
inputType: 'string',
required: true,
defaultValueFromContext: (context) => context.defaultProjectName,
},
},
},
'Set Text Wrapping': {
icon: 'settings',
args: {
textWrapping: {
inputType: 'options',
required: true,
defaultValueFromContext: (context) => context.textWrapping,
options: [],
optionsFromContext: (context) => [
{
name: 'On',
value: 'On' as Toggle,
isCurrent: context.textWrapping === 'On',
},
{
name: 'Off',
value: 'Off' as Toggle,
isCurrent: context.textWrapping === 'Off',
},
],
},
},
},
'Set Theme': {
icon: 'settings',
args: {
theme: {
inputType: 'options',
required: true,
defaultValueFromContext: (context) => context.theme,
options: [],
optionsFromContext: (context) =>
Object.values(Themes).map((v) => ({
name: v,
value: v,
isCurrent: v === context.theme,
})),
},
},
},
'Set Unit System': {
icon: 'settings',
args: {
unitSystem: {
inputType: 'options',
required: true,
defaultValueFromContext: (context) => context.unitSystem,
options: [],
optionsFromContext: (context) => [
{
name: 'Imperial',
value: 'imperial' as UnitSystem,
isCurrent: context.unitSystem === 'imperial',
},
{
name: 'Metric',
value: 'metric' as UnitSystem,
isCurrent: context.unitSystem === 'metric',
},
],
},
},
}, },
}
return command
} }

View File

@ -12,7 +12,13 @@ import { commandBarMachine } from 'machines/commandBarMachine'
type Icon = CustomIconName type Icon = CustomIconName
const PLATFORMS = ['both', 'web', 'desktop'] as const const PLATFORMS = ['both', 'web', 'desktop'] as const
const INPUT_TYPES = ['options', 'string', 'kcl', 'selection'] as const const INPUT_TYPES = [
'options',
'string',
'kcl',
'selection',
'boolean',
] as const
export interface KclExpression { export interface KclExpression {
valueAst: Value valueAst: Value
valueText: string valueText: string
@ -66,6 +72,7 @@ export type Command<
args?: { args?: {
[ArgName in keyof CommandSchema]: CommandArgument<CommandSchema[ArgName], T> [ArgName in keyof CommandSchema]: CommandArgument<CommandSchema[ArgName], T>
} }
displayName?: string
description?: string description?: string
icon?: Icon icon?: Icon
hide?: (typeof PLATFORMS)[number] hide?: (typeof PLATFORMS)[number]
@ -83,57 +90,70 @@ export type CommandConfig<
args?: { args?: {
[ArgName in keyof CommandSchema]: CommandArgumentConfig< [ArgName in keyof CommandSchema]: CommandArgumentConfig<
CommandSchema[ArgName], CommandSchema[ArgName],
T ContextFrom<T>
> >
} }
} }
export type CommandArgumentConfig< export type CommandArgumentConfig<
OutputType, OutputType,
T extends AnyStateMachine = AnyStateMachine C = ContextFrom<AnyStateMachine>
> = > =
| { | {
description?: string description?: string
required: required:
| boolean | boolean
| (( | ((
commandBarContext: { argumentsToSubmit: Record<string, unknown> } // Should be the commandbarMachine's context, but it creates a circular dependency commandBarContext: { argumentsToSubmit: Record<string, unknown> }, // Should be the commandbarMachine's context, but it creates a circular dependency
machineContext?: C
) => boolean) ) => boolean)
skip?: boolean skip?: boolean
} & ( } & (
| { | {
inputType: Extract<CommandInputType, 'options'> inputType: 'options'
options: options:
| CommandArgumentOption<OutputType>[] | CommandArgumentOption<OutputType>[]
| (( | ((
commandBarContext: { commandBarContext: {
argumentsToSubmit: Record<string, unknown> argumentsToSubmit: Record<string, unknown>
} // Should be the commandbarMachine's context, but it creates a circular dependency }, // Should be the commandbarMachine's context, but it creates a circular dependency
machineContext?: C
) => CommandArgumentOption<OutputType>[]) ) => CommandArgumentOption<OutputType>[])
optionsFromContext?: ( optionsFromContext?: (
context: ContextFrom<T> context: C
) => CommandArgumentOption<OutputType>[] ) => CommandArgumentOption<OutputType>[]
defaultValue?: defaultValue?:
| OutputType | OutputType
| (( | ((
commandBarContext: ContextFrom<typeof commandBarMachine> commandBarContext: ContextFrom<typeof commandBarMachine>,
machineContext?: C
) => OutputType) ) => OutputType)
defaultValueFromContext?: (context: ContextFrom<T>) => OutputType defaultValueFromContext?: (context: C) => OutputType
} }
| { | {
inputType: Extract<CommandInputType, 'selection'> inputType: 'selection'
selectionTypes: Selection['type'][] selectionTypes: Selection['type'][]
multiple: boolean multiple: boolean
} }
| { inputType: Extract<CommandInputType, 'kcl'>; defaultValue?: string } // KCL expression inputs have simple strings as default values | { inputType: 'kcl'; defaultValue?: string } // KCL expression inputs have simple strings as default values
| { | {
inputType: Extract<CommandInputType, 'string'> inputType: 'string'
defaultValue?:
| OutputType
| ((
commandBarContext: ContextFrom<typeof commandBarMachine>,
machineContext?: C
) => OutputType)
defaultValueFromContext?: (context: C) => OutputType
}
| {
inputType: 'boolean'
defaultValue?: defaultValue?:
| OutputType | OutputType
| (( | ((
commandBarContext: ContextFrom<typeof commandBarMachine> commandBarContext: ContextFrom<typeof commandBarMachine>
) => OutputType) ) => OutputType)
defaultValueFromContext?: (context: ContextFrom<T>) => OutputType defaultValueFromContext?: (context: C) => OutputType
} }
) )
@ -146,7 +166,8 @@ export type CommandArgument<
required: required:
| boolean | boolean
| (( | ((
commandBarContext: { argumentsToSubmit: Record<string, unknown> } // Should be the commandbarMachine's context, but it creates a circular dependency commandBarContext: { argumentsToSubmit: Record<string, unknown> }, // Should be the commandbarMachine's context, but it creates a circular dependency
machineContext?: ContextFrom<T>
) => boolean) ) => boolean)
skip?: boolean skip?: boolean
machineActor: InterpreterFrom<T> machineActor: InterpreterFrom<T>
@ -158,26 +179,38 @@ export type CommandArgument<
| (( | ((
commandBarContext: { commandBarContext: {
argumentsToSubmit: Record<string, unknown> argumentsToSubmit: Record<string, unknown>
} // Should be the commandbarMachine's context, but it creates a circular dependency }, // Should be the commandbarMachine's context, but it creates a circular dependency
machineContext?: ContextFrom<T>
) => CommandArgumentOption<OutputType>[]) ) => CommandArgumentOption<OutputType>[])
defaultValue?: defaultValue?:
| OutputType | OutputType
| (( | ((
commandBarContext: ContextFrom<typeof commandBarMachine> commandBarContext: ContextFrom<typeof commandBarMachine>,
machineContext?: ContextFrom<T>
) => OutputType) ) => OutputType)
} }
| { | {
inputType: Extract<CommandInputType, 'selection'> inputType: 'selection'
selectionTypes: Selection['type'][] selectionTypes: Selection['type'][]
multiple: boolean multiple: boolean
} }
| { inputType: Extract<CommandInputType, 'kcl'>; defaultValue?: string } // KCL expression inputs have simple strings as default values | { inputType: 'kcl'; defaultValue?: string } // KCL expression inputs have simple strings as default value
| { | {
inputType: Extract<CommandInputType, 'string'> inputType: 'string'
defaultValue?: defaultValue?:
| OutputType | OutputType
| (( | ((
commandBarContext: ContextFrom<typeof commandBarMachine> commandBarContext: ContextFrom<typeof commandBarMachine>,
machineContext?: ContextFrom<T>
) => OutputType)
}
| {
inputType: 'boolean'
defaultValue?:
| OutputType
| ((
commandBarContext: ContextFrom<typeof commandBarMachine>,
machineContext?: ContextFrom<T>
) => OutputType) ) => OutputType)
} }
) )

View File

@ -1,4 +1,44 @@
export const APP_NAME = 'Modeling App' export const APP_NAME = 'Modeling App'
/** Search string in new project names to increment as an index */
export const INDEX_IDENTIFIER = '$n'
/** The maximum number of 0's to pad a default project name's index with */
export const MAX_PADDING = 7
/** The default name for a newly-created project.
* This is used as a template for new projects, with $nnn being replaced by an index
* This is available for users to edit as a setting.
*/
export const DEFAULT_PROJECT_NAME = 'project-$nnn' export const DEFAULT_PROJECT_NAME = 'project-$nnn'
export const SETTINGS_PERSIST_KEY = 'SETTINGS_PERSIST_KEY' /** The file name for settings files, both at the user and project level */
export const SETTINGS_FILE_NAME = 'settings.json' export const SETTINGS_FILE_EXT = '.toml'
/** Name given the temporary "project" in the browser version of the app */
export const BROWSER_PROJECT_NAME = 'browser'
/** Name given the temporary file in the browser version of the app */
export const BROWSER_FILE_NAME = 'main'
/**
* The default name of the project in Desktop.
* This is prefixed by the Documents directory path.
*/
export const PROJECT_FOLDER = 'zoo-modeling-app-projects'
/**
* File extension for Modeling App's files, which are written in kcl
* @link - https://zoo.dev/docs/kcl
* */
export const FILE_EXT = '.kcl'
/** Default file to open when a project is opened */
export const PROJECT_ENTRYPOINT = `main${FILE_EXT}` as const
/** The localStorage key for last-opened projects */
export const FILE_PERSIST_KEY = `${PROJECT_FOLDER}-last-opened` as const
/** The default name given to new kcl files in a project */
export const DEFAULT_FILE_NAME = 'Untitled'
/** The file endings that will appear in
* the file explorer if found in a project directory */
export const RELEVANT_FILE_TYPES = [
'kcl',
'fbx',
'gltf',
'glb',
'obj',
'ply',
'step',
'stl',
] as const

View File

@ -1,4 +1,10 @@
import { AnyStateMachine, EventFrom, InterpreterFrom, StateFrom } from 'xstate' import {
AnyStateMachine,
ContextFrom,
EventFrom,
InterpreterFrom,
StateFrom,
} from 'xstate'
import { isTauri } from './isTauri' import { isTauri } from './isTauri'
import { import {
Command, Command,
@ -97,20 +103,19 @@ function buildCommandArguments<
for (const arg in args) { for (const arg in args) {
const argConfig = args[arg] as CommandArgumentConfig<S[typeof arg], T> const argConfig = args[arg] as CommandArgumentConfig<S[typeof arg], T>
const newArg = buildCommandArgument(argConfig, arg, state, machineActor) const newArg = buildCommandArgument(argConfig, state.context, machineActor)
newArgs[arg] = newArg newArgs[arg] = newArg
} }
return newArgs return newArgs
} }
function buildCommandArgument< export function buildCommandArgument<
O extends CommandSetSchema<T>, T extends AnyStateMachine,
T extends AnyStateMachine O extends CommandSetSchema<T> = CommandSetSchema<T>
>( >(
arg: CommandArgumentConfig<O, T>, arg: CommandArgumentConfig<O, T>,
argName: string, context: ContextFrom<T>,
state: StateFrom<T>,
machineActor: InterpreterFrom<T> machineActor: InterpreterFrom<T>
): CommandArgument<O, T> & { inputType: typeof arg.inputType } { ): CommandArgument<O, T> & { inputType: typeof arg.inputType } {
const baseCommandArgument = { const baseCommandArgument = {
@ -121,7 +126,7 @@ function buildCommandArgument<
} satisfies Omit<CommandArgument<O, T>, 'inputType'> } satisfies Omit<CommandArgument<O, T>, 'inputType'>
if (arg.inputType === 'options') { if (arg.inputType === 'options') {
if (!arg.options) { if (!(arg.options || arg.optionsFromContext)) {
throw new Error('Options must be provided for options input type') throw new Error('Options must be provided for options input type')
} }
@ -129,10 +134,10 @@ function buildCommandArgument<
inputType: arg.inputType, inputType: arg.inputType,
...baseCommandArgument, ...baseCommandArgument,
defaultValue: arg.defaultValueFromContext defaultValue: arg.defaultValueFromContext
? arg.defaultValueFromContext(state.context) ? arg.defaultValueFromContext(context)
: arg.defaultValue, : arg.defaultValue,
options: arg.optionsFromContext options: arg.optionsFromContext
? arg.optionsFromContext(state.context) ? arg.optionsFromContext(context)
: arg.options, : arg.options,
} satisfies CommandArgument<O, T> & { inputType: 'options' } } satisfies CommandArgument<O, T> & { inputType: 'options' }
} else if (arg.inputType === 'selection') { } else if (arg.inputType === 'selection') {
@ -151,7 +156,9 @@ function buildCommandArgument<
} else { } else {
return { return {
inputType: arg.inputType, inputType: arg.inputType,
defaultValue: arg.defaultValue, defaultValue: arg.defaultValueFromContext
? arg.defaultValueFromContext(context)
: arg.defaultValue,
...baseCommandArgument, ...baseCommandArgument,
} }
} }

View File

@ -0,0 +1,36 @@
import { PathValue, Paths } from './types'
export function setPropertyByPath<
T extends { [key: string]: any },
P extends Paths<T, 4>
>(obj: T, path: P, value: PathValue<T, P>) {
if (typeof path === 'string') {
const pList = path.split('.')
const lastKey = pList.pop()
const pointer = pList.reduce(
(accumulator: { [x: string]: any }, currentValue: string | number) => {
if (accumulator[currentValue] === undefined)
accumulator[currentValue] = {}
return accumulator[currentValue]
},
obj
)
if (typeof lastKey !== 'undefined') {
pointer[lastKey] = value
return obj
}
}
return obj
}
export function getPropertyByPath<
T extends { [key: string]: any },
P extends Paths<T, 4>
>(obj: T, path: P): unknown {
if (typeof path === 'string') {
return path.split('.').reduce((accumulator, currentValue) => {
if (accumulator) return accumulator[currentValue]
return undefined
}, obj)
} else return undefined
}

View File

@ -1,4 +1,7 @@
import { sep } from '@tauri-apps/api/path'
import { onboardingPaths } from 'routes/Onboarding/paths' import { onboardingPaths } from 'routes/Onboarding/paths'
import { BROWSER_FILE_NAME, BROWSER_PROJECT_NAME, FILE_EXT } from './constants'
import { isTauri } from './isTauri'
const prependRoutes = const prependRoutes =
(routesObject: Record<string, string>) => (prepend: string) => { (routesObject: Record<string, string>) => (prepend: string) => {
@ -19,4 +22,31 @@ export const paths = {
ONBOARDING: prependRoutes(onboardingPaths)( ONBOARDING: prependRoutes(onboardingPaths)(
'/onboarding' '/onboarding'
) as typeof onboardingPaths, ) as typeof onboardingPaths,
} as const
export const BROWSER_PATH = `%2F${BROWSER_PROJECT_NAME}%2F${BROWSER_FILE_NAME}${FILE_EXT}`
export function getProjectMetaByRouteId(id?: string, defaultDir = '') {
if (!id) return undefined
const s = isTauri() ? sep : '/'
const decodedId = decodeURIComponent(id).replace(/\/$/, '') // remove trailing slash
const projectAndFile =
defaultDir === '/'
? decodedId.replace(defaultDir, '')
: decodedId.replace(defaultDir + s, '')
const filePathParts = projectAndFile.split(s)
const projectName = filePathParts[0]
const projectPath =
(defaultDir === '/' ? defaultDir : defaultDir + s) + projectName
const lastPathPart = filePathParts[filePathParts.length - 1]
const currentFileName =
lastPathPart === projectName ? undefined : lastPathPart
const currentFilePath = lastPathPart === projectName ? undefined : decodedId
return {
projectName,
projectPath,
currentFileName,
currentFilePath,
}
} }

View File

@ -1,15 +1,18 @@
import { ActionFunction, LoaderFunction, redirect } from 'react-router-dom' import { ActionFunction, LoaderFunction, redirect } from 'react-router-dom'
import { HomeLoaderData, IndexLoaderData } from './types' import { FileLoaderData, HomeLoaderData, IndexLoaderData } from './types'
import { isTauri } from './isTauri' import { isTauri } from './isTauri'
import { paths } from './paths' import { getProjectMetaByRouteId, paths } from './paths'
import { BROWSER_FILE_NAME } from 'Router' import { BROWSER_PATH } from 'lib/paths'
import { SETTINGS_PERSIST_KEY } from 'lib/constants' import {
BROWSER_FILE_NAME,
BROWSER_PROJECT_NAME,
PROJECT_ENTRYPOINT,
} from 'lib/constants'
import { loadAndValidateSettings } from './settings/settingsUtils' import { loadAndValidateSettings } from './settings/settingsUtils'
import { import {
getInitialDefaultDir, getInitialDefaultDir,
getProjectsInDir, getProjectsInDir,
initializeProjectDirectory, initializeProjectDirectory,
PROJECT_ENTRYPOINT,
} from './tauriFS' } from './tauriFS'
import makeUrlPathRelative from './makeUrlPathRelative' import makeUrlPathRelative from './makeUrlPathRelative'
import { sep } from '@tauri-apps/api/path' import { sep } from '@tauri-apps/api/path'
@ -20,17 +23,32 @@ import { fileSystemManager } from 'lang/std/fileSystemManager'
// The root loader simply resolves the settings and any errors that // The root loader simply resolves the settings and any errors that
// occurred during the settings load // occurred during the settings load
export const indexLoader: LoaderFunction = async (): ReturnType< export const settingsLoader: LoaderFunction = async ({
typeof loadAndValidateSettings params,
> => { }): ReturnType<typeof loadAndValidateSettings> => {
return await loadAndValidateSettings() let settings = await loadAndValidateSettings()
// I don't love that we have to read the settings again here,
// but we need to get the project path to load the project settings
if (params.id) {
const defaultDir = settings.app.projectDirectory.current || ''
const projectPathData = getProjectMetaByRouteId(params.id, defaultDir)
if (projectPathData) {
const { projectPath } = projectPathData
settings = await loadAndValidateSettings(projectPath)
}
}
return settings
} }
// Redirect users to the appropriate onboarding page if they haven't completed it // Redirect users to the appropriate onboarding page if they haven't completed it
export const onboardingRedirectLoader: ActionFunction = async ({ request }) => { export const onboardingRedirectLoader: ActionFunction = async (args) => {
const { settings } = await loadAndValidateSettings() const settings = await loadAndValidateSettings()
const onboardingStatus = settings.onboardingStatus || '' const onboardingStatus = settings.app.onboardingStatus.current || ''
const notEnRouteToOnboarding = !request.url.includes(paths.ONBOARDING.INDEX) const notEnRouteToOnboarding = !args.request.url.includes(
paths.ONBOARDING.INDEX
)
// '' is the initial state, 'done' and 'dismissed' are the final states // '' is the initial state, 'done' and 'dismissed' are the final states
const hasValidOnboardingStatus = const hasValidOnboardingStatus =
onboardingStatus.length === 0 || onboardingStatus.length === 0 ||
@ -44,34 +62,33 @@ export const onboardingRedirectLoader: ActionFunction = async ({ request }) => {
) )
} }
return null return settingsLoader(args)
} }
export const fileLoader: LoaderFunction = async ({ export const fileLoader: LoaderFunction = async ({
params, params,
}): Promise<IndexLoaderData | Response> => { }): Promise<FileLoaderData | Response> => {
const { settings } = await loadAndValidateSettings() let settings = await loadAndValidateSettings()
const defaultDir = settings.defaultDirectory || '' const defaultDir = settings.app.projectDirectory.current || '/'
const projectPathData = getProjectMetaByRouteId(params.id, defaultDir)
const isBrowserProject = params.id === decodeURIComponent(BROWSER_PATH)
if (params.id && params.id !== BROWSER_FILE_NAME) { if (!isBrowserProject && projectPathData) {
const decodedId = decodeURIComponent(params.id) const { projectName, projectPath, currentFileName, currentFilePath } =
const projectAndFile = decodedId.replace(defaultDir + sep, '') projectPathData
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) if (!currentFileName || !currentFilePath) {
return redirect( return redirect(
`${paths.FILE}/${encodeURIComponent( `${paths.FILE}/${encodeURIComponent(
`${params.id}${sep}${PROJECT_ENTRYPOINT}` `${params.id}${isTauri() ? sep : '/'}${PROJECT_ENTRYPOINT}`
)}` )}`
) )
}
// TODO: PROJECT_ENTRYPOINT is hardcoded // TODO: PROJECT_ENTRYPOINT is hardcoded
// until we support setting a project's entrypoint file // until we support setting a project's entrypoint file
const code = await readTextFile(decodedId) const code = await readTextFile(currentFilePath)
const entrypointMetadata = await metadata( const entrypointMetadata = await metadata(
projectPath + sep + PROJECT_ENTRYPOINT projectPath + sep + PROJECT_ENTRYPOINT
) )
@ -82,7 +99,7 @@ export const fileLoader: LoaderFunction = async ({
// So that WASM gets an updated path for operations // So that WASM gets an updated path for operations
fileSystemManager.dir = projectPath fileSystemManager.dir = projectPath
return { const projectData: IndexLoaderData = {
code, code,
project: { project: {
name: projectName, name: projectName,
@ -92,13 +109,26 @@ export const fileLoader: LoaderFunction = async ({
}, },
file: { file: {
name: currentFileName, name: currentFileName,
path: params.id, path: currentFilePath,
}, },
} }
return {
...projectData,
}
} }
return { return {
code: '', code: '',
project: {
name: BROWSER_PROJECT_NAME,
path: '/' + BROWSER_PROJECT_NAME,
children: [],
},
file: {
name: BROWSER_FILE_NAME,
path: decodeURIComponent(BROWSER_PATH),
},
} }
} }
@ -108,23 +138,15 @@ export const homeLoader: LoaderFunction = async (): Promise<
HomeLoaderData | Response HomeLoaderData | Response
> => { > => {
if (!isTauri()) { if (!isTauri()) {
return redirect(paths.FILE + '/' + BROWSER_FILE_NAME) return redirect(paths.FILE + '/' + BROWSER_PROJECT_NAME)
} }
const { settings } = await loadAndValidateSettings() const settings = await loadAndValidateSettings()
const projectDir = await initializeProjectDirectory( const projectDir = await initializeProjectDirectory(
settings.defaultDirectory || (await getInitialDefaultDir()) settings.app.projectDirectory.current || (await getInitialDefaultDir())
) )
if (projectDir.path) { if (projectDir.path) {
if (projectDir.path !== settings.defaultDirectory) {
localStorage.setItem(
SETTINGS_PERSIST_KEY,
JSON.stringify({
...settings,
defaultDirectory: projectDir,
})
)
}
const projects = await getProjectsInDir(projectDir.path) const projects = await getProjectsInDir(projectDir.path)
return { return {

View File

@ -1,15 +0,0 @@
import { DEFAULT_PROJECT_NAME } from 'lib/constants'
import { SettingsMachineContext, UnitSystem } from 'lib/settings/settingsTypes'
import { Themes } from 'lib/theme'
export const initialSettings: SettingsMachineContext = {
baseUnit: 'mm',
cameraControls: 'KittyCAD',
defaultDirectory: '',
defaultProjectName: DEFAULT_PROJECT_NAME,
onboardingStatus: '',
showDebugPanel: false,
textWrapping: 'On',
theme: Themes.System,
unitSystem: UnitSystem.Metric,
}

View File

@ -0,0 +1,383 @@
import { DEFAULT_PROJECT_NAME } from 'lib/constants'
import {
BaseUnit,
SettingProps,
SettingsLevel,
baseUnitsUnion,
} from 'lib/settings/settingsTypes'
import { Themes } from 'lib/theme'
import { isEnumMember } from 'lib/types'
import {
CameraSystem,
cameraMouseDragGuards,
cameraSystems,
} from 'lib/cameraControls'
import { isTauri } from 'lib/isTauri'
import { useRef } from 'react'
import { open } from '@tauri-apps/api/dialog'
import { CustomIcon } from 'components/CustomIcon'
import Tooltip from 'components/Tooltip'
/**
* A setting that can be set at the user or project level
* @constructor
*/
export class Setting<T = unknown> {
/**
* The current value of the setting, prioritizing project, then user, then default
*/
public current: T
public hideOnLevel: SettingProps<T>['hideOnLevel']
public hideOnPlatform: SettingProps<T>['hideOnPlatform']
public commandConfig: SettingProps<T>['commandConfig']
public Component: SettingProps<T>['Component']
public description?: string
private validate: (v: T) => boolean
private _default: T
private _user?: T
private _project?: T
constructor(props: SettingProps<T>) {
this._default = props.defaultValue
this.current = props.defaultValue
this.validate = props.validate
this.description = props.description
this.hideOnLevel = props.hideOnLevel
this.hideOnPlatform = props.hideOnPlatform
this.commandConfig = props.commandConfig
this.Component = props.Component
}
/**
* The default setting. Overridden by the user and project if set
*/
get default(): T {
return this._default
}
set default(v: T) {
this._default = this.validate(v) ? v : this._default
this.current = this.resolve()
}
/**
* The user-level setting. Overrides the default, overridden by the project
*/
get user(): T | undefined {
return this._user
}
set user(v: T) {
this._user = this.validate(v) ? v : this._user
this.current = this.resolve()
}
/**
* The project-level setting. Overrides the user and default
*/
get project(): T | undefined {
return this._project
}
set project(v: T) {
this._project = this.validate(v) ? v : this._project
this.current = this.resolve()
}
/**
* @returns {T} - The value of the setting, prioritizing project, then user, then default
* @todo - This may have issues if future settings can have a value that is valid but falsy
*/
private resolve() {
return this._project !== undefined
? this._project
: this._user !== undefined
? this._user
: this._default
}
/**
* @param {SettingsLevel} level - The level to get the fallback for
* @returns {T} - The value of the setting above the given level, falling back as needed
*/
public getFallback(level: SettingsLevel | 'default'): T {
return level === 'project'
? this._user !== undefined
? this._user
: this._default
: this._default
}
public getParentLevel(level: SettingsLevel): SettingsLevel | 'default' {
return level === 'project' ? 'user' : 'default'
}
}
export function createSettings() {
return {
/** Settings that affect the behavior of the entire app,
* beyond just modeling or navigating, for example
*/
app: {
/**
* The overall appearance of the app: light, dark, or system
*/
theme: new Setting<Themes>({
defaultValue: Themes.System,
description: 'The overall appearance of the app',
validate: (v) => isEnumMember(v, Themes),
commandConfig: {
inputType: 'options',
defaultValueFromContext: (context) => context.app.theme.current,
options: (cmdContext, settingsContext) =>
Object.values(Themes).map((v) => ({
name: v,
value: v,
isCurrent:
v ===
settingsContext.app.theme[
cmdContext.argumentsToSubmit.level as SettingsLevel
],
})),
},
}),
onboardingStatus: new Setting<string>({
defaultValue: '',
validate: (v) => typeof v === 'string',
}),
projectDirectory: new Setting<string>({
defaultValue: '',
description: 'The directory to save and load projects from',
hideOnLevel: 'project',
hideOnPlatform: 'web',
validate: (v) => typeof v === 'string' && (v.length > 0 || !isTauri()),
Component: ({ value, onChange }) => {
const inputRef = useRef<HTMLInputElement>(null)
return (
<div className="flex gap-4 p-1 border rounded-sm border-chalkboard-30">
<input
className="flex-grow text-xs px-2 bg-transparent"
value={value}
onBlur={onChange}
disabled
data-testid="default-directory-input"
/>
<button
onClick={async () => {
const newValue = await open({
directory: true,
recursive: true,
defaultPath: value,
title: 'Choose a new default directory',
})
if (
inputRef.current &&
newValue &&
newValue !== null &&
!Array.isArray(newValue)
) {
inputRef.current.value = newValue
}
}}
className="p-0 m-0 border-none hover:bg-energy-10 focus:bg-energy-10 dark:hover:bg-energy-80/50 dark:focus::bg-energy-80/50"
>
<CustomIcon name="folder" className="w-5 h-5" />
<Tooltip position="inlineStart">Choose a folder</Tooltip>
</button>
</div>
)
},
}),
},
/**
* Settings that affect the behavior while modeling.
*/
modeling: {
/**
* The default unit to use in modeling dimensions
*/
defaultUnit: new Setting<BaseUnit>({
defaultValue: 'mm',
description: 'The default unit to use in modeling dimensions',
validate: (v) => baseUnitsUnion.includes(v as BaseUnit),
commandConfig: {
inputType: 'options',
defaultValueFromContext: (context) =>
context.modeling.defaultUnit.current,
options: (cmdContext, settingsContext) =>
Object.values(baseUnitsUnion).map((v) => ({
name: v,
value: v,
isCurrent:
v ===
settingsContext.modeling.defaultUnit[
cmdContext.argumentsToSubmit.level as SettingsLevel
],
})),
},
}),
/**
* The controls for how to navigate the 3D view
*/
mouseControls: new Setting<CameraSystem>({
defaultValue: 'KittyCAD',
description: 'The controls for how to navigate the 3D view',
validate: (v) => cameraSystems.includes(v as CameraSystem),
hideOnLevel: 'project',
commandConfig: {
inputType: 'options',
defaultValueFromContext: (context) =>
context.modeling.mouseControls.current,
options: (cmdContext, settingsContext) =>
Object.values(cameraSystems).map((v) => ({
name: v,
value: v,
isCurrent:
v ===
settingsContext.modeling.mouseControls[
cmdContext.argumentsToSubmit.level as SettingsLevel
],
})),
},
Component: ({ value, onChange }) => (
<>
<select
id="camera-controls"
className="block w-full px-3 py-1 bg-transparent border border-chalkboard-30"
value={value}
onChange={onChange}
>
{cameraSystems.map((program) => (
<option key={program} value={program}>
{program}
</option>
))}
</select>
<ul className="mx-0 my-2 flex flex-col gap-2 text-sm">
<li className="grid grid-cols-4 gap-1">
<strong>Pan</strong>
<p className="col-span-3 leading-tight">
{cameraMouseDragGuards[value].pan.description}
</p>
</li>
<li className="grid grid-cols-4 gap-1">
<strong>Zoom</strong>
<p className="col-span-3 leading-tight">
{cameraMouseDragGuards[value].zoom.description}
</p>
</li>
<li className="grid grid-cols-4 gap-1">
<strong>Rotate</strong>
<p className="col-span-3 leading-tight">
{cameraMouseDragGuards[value].rotate.description}
</p>
</li>
</ul>
</>
),
}),
/**
* Whether to show the debug panel, which lets you see
* various states of the app to aid in development
*/
showDebugPanel: new Setting<boolean>({
defaultValue: false,
description: 'Whether to show the debug panel, a development tool',
validate: (v) => typeof v === 'boolean',
commandConfig: {
inputType: 'boolean',
},
}),
/**
* TODO: This setting is not yet implemented.
* Whether to turn off animations and other motion effects
*/
// reduceMotion: new Setting<boolean>({
// defaultValue: false,
// description: 'Whether to turn off animations and other motion effects',
// validate: (v) => typeof v === 'boolean',
// commandConfig: {
// inputType: 'boolean',
// },
// hideOnLevel: 'project',
// }),
/**
* TODO: This setting is not yet implemented.
* Whether to move to view the sketch plane orthogonally
* when creating entering or creating a sketch.
*/
// moveOrthoginalToSketch: new Setting<boolean>({
// defaultValue: false,
// description: 'Whether to move to view sketch planes orthogonally',
// validate: (v) => typeof v === 'boolean',
// commandConfig: {
// inputType: 'boolean',
// },
// }),
},
/**
* Settings that affect the behavior of the KCL text editor.
*/
textEditor: {
/**
* Whether to wrap text in the editor or overflow with scroll
*/
textWrapping: new Setting<boolean>({
defaultValue: true,
description:
'Whether to wrap text in the editor or overflow with scroll',
validate: (v) => typeof v === 'boolean',
commandConfig: {
inputType: 'boolean',
},
}),
},
/**
* Settings that affect the behavior of project management.
*/
projects: {
/**
* The default project name to use when creating a new project
*/
defaultProjectName: new Setting<string>({
defaultValue: DEFAULT_PROJECT_NAME,
description:
'The default project name to use when creating a new project',
validate: (v) => typeof v === 'string' && v.length > 0,
commandConfig: {
inputType: 'string',
defaultValueFromContext: (context) =>
context.projects.defaultProjectName.current,
},
hideOnLevel: 'project',
hideOnPlatform: 'web',
}),
/**
* TODO: This setting is not yet implemented.
* It requires more sophisticated fallback logic if the user sets this setting to a
* non-existent file. This setting is currently hardcoded to PROJECT_ENTRYPOINT.
* The default file to open when a project is loaded
*/
// entryPointFileName: new Setting<string>({
// defaultValue: PROJECT_ENTRYPOINT,
// description: 'The default file to open when a project is loaded',
// validate: (v) => typeof v === 'string' && v.length > 0,
// commandConfig: {
// inputType: 'string',
// defaultValueFromContext: (context) =>
// context.projects.entryPointFileName.current,
// },
// hideOnLevel: 'project',
// }),
},
/**
* Settings that affect the behavior of the command bar.
*/
commandBar: {
/**
* Whether to include settings in the command bar
*/
includeSettings: new Setting<boolean>({
defaultValue: true,
description: 'Whether to include settings in the command bar',
validate: (v) => typeof v === 'boolean',
commandConfig: {
inputType: 'boolean',
},
}),
},
}
}
export const settings = createSettings()

View File

@ -1,6 +1,8 @@
import { type Models } from '@kittycad/lib' import { type Models } from '@kittycad/lib'
import { type CameraSystem } from '../cameraControls' import { Setting, settings } from './initialSettings'
import { Themes } from 'lib/theme' import { AtLeast, PathValue, Paths } from 'lib/types'
import { ChangeEventHandler } from 'react'
import { CommandArgumentConfig } from 'lib/commandTypes'
export enum UnitSystem { export enum UnitSystem {
Imperial = 'imperial', Imperial = 'imperial',
@ -19,14 +21,100 @@ export const baseUnitsUnion = Object.values(baseUnits).flatMap((v) => v)
export type Toggle = 'On' | 'Off' export type Toggle = 'On' | 'Off'
export const toggleAsArray = ['On', 'Off'] as const export const toggleAsArray = ['On', 'Off'] as const
export type SettingsMachineContext = { export type SettingsPaths = Exclude<
baseUnit: BaseUnit Paths<typeof settings, 1>,
cameraControls: CameraSystem keyof typeof settings
defaultDirectory: string >
defaultProjectName: string type SetEvent<T extends SettingsPaths> = {
onboardingStatus: string type: `set.${T}`
showDebugPanel: boolean data: {
textWrapping: Toggle level: SettingsLevel
theme: Themes value: PathValue<typeof settings, T>['default']
unitSystem: UnitSystem }
} }
export type SetEventTypes = SetEvent<SettingsPaths>
export type WildcardSetEvent<T extends SettingsPaths = SettingsPaths> = {
type: `*`
data: {
level: SettingsLevel
value: PathValue<typeof settings, T>['default']
}
}
export interface SettingProps<T = unknown> {
/**
* The default value of the setting, used if no user or project value is set
*/
defaultValue: T
/**
* The name of the setting, used in the settings panel
*/
title?: string
/**
* A description of the setting, used in the settings panel
*/
description?: string
/**
* A function that validates the setting value.
* You can use this to either do simple type checks,
* or do more thorough validation that
* can't be done with TypeScript types alone.
* @param v - The value to validate
* @returns {boolean} - Whether the value is valid
* @example
* ```ts
* const mySetting = new Setting<number>({
* defaultValue: 0,
* validate: (v) => v >= 0, // Only allow positive numbers
* })
* ```
*/
validate: (v: T) => boolean
/**
* A command argument configuration for the setting.
* If this is provided, the setting will appear in the command bar.
*/
commandConfig?: AtLeast<CommandArgumentConfig<T>, 'inputType'>
/**
* Whether to hide the setting on a certain level.
* This will be applied in both the settings panel and the command bar.
*/
hideOnLevel?: SettingsLevel
/**
* Whether to hide the setting on a certain platform.
* This will be applied in both the settings panel and the command bar.
*/
hideOnPlatform?: 'web' | 'desktop'
/**
* A React component to use for the setting in the settings panel.
* If this is not provided but a commandConfig is, the `inputType`
* of the commandConfig will be used to determine the component.
* If this is not provided and there is no commandConfig, the
* setting will not be able to be edited directly by the user.
*/
Component?: React.ComponentType<{
value: T
onChange: ChangeEventHandler
}>
}
/** The levels available to set settings at.
* `project` settings are specific and saved in the project directory.
* `user` settings are global and saved in the app config directory.
*/
export type SettingsLevel = 'user' | 'project'
/**
* A utility type to transform the settings object
* such that instead of having leaves of type `Setting<T>`,
* it has leaves of type `T`.
*/
type RecursiveSettingsPayloads<T> = {
[P in keyof T]: T[P] extends Setting<infer U>
? U
: Partial<RecursiveSettingsPayloads<T[P]>>
}
export type SaveSettingsPayload = RecursiveSettingsPayloads<typeof settings>

View File

@ -1,88 +1,172 @@
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 { import {
type BaseUnit, getInitialDefaultDir,
baseUnitsUnion, getSettingsFilePaths,
type Toggle, readSettingsFile,
type SettingsMachineContext, } from '../tauriFS'
toggleAsArray, import { Setting, createSettings, settings } from 'lib/settings/initialSettings'
UnitSystem, import { SaveSettingsPayload, SettingsLevel } from './settingsTypes'
} from './settingsTypes' import { isTauri } from 'lib/isTauri'
import { SETTINGS_PERSIST_KEY } from '../constants' import { removeFile, writeTextFile } from '@tauri-apps/api/fs'
import { exists } from 'tauri-plugin-fs-extra-api'
import * as TOML from '@iarna/toml'
export const fallbackLoadedSettings = { /**
settings: initialSettings, * We expect the settings to be stored in a TOML file
errors: [] as (keyof SettingsMachineContext)[], * or TOML-formatted string in localStorage
* under a top-level [settings] key.
* @param path
* @returns
*/
function getSettingsFromStorage(path: string) {
return isTauri()
? readSettingsFile(path)
: (TOML.parse(localStorage.getItem(path) ?? '')
.settings as Partial<SaveSettingsPayload>)
} }
function isEnumMember<T extends Record<string, unknown>>(v: unknown, e: T) { export async function loadAndValidateSettings(projectPath?: string) {
return Object.values(e).includes(v) const settings = createSettings()
} settings.app.projectDirectory.default = await getInitialDefaultDir()
// First, get the settings data at the user and project level
const settingsFilePaths = await getSettingsFilePaths(projectPath)
export async function loadAndValidateSettings(): Promise< // Load the settings from the files
ReturnType<typeof validateSettings> if (settingsFilePaths.user) {
> { const userSettings = await getSettingsFromStorage(settingsFilePaths.user)
const fsSettings = isTauri() ? await readSettingsFile() : {} if (userSettings) {
const localStorageSettings = JSON.parse( setSettingsAtLevel(settings, 'user', userSettings)
localStorage?.getItem(SETTINGS_PERSIST_KEY) || '{}' }
}
// Load the project settings if they exist
if (settingsFilePaths.project) {
const projectSettings = await getSettingsFromStorage(
settingsFilePaths.project
) )
const mergedSettings = Object.assign({}, localStorageSettings, fsSettings) if (projectSettings) {
setSettingsAtLevel(settings, 'project', projectSettings)
}
}
return await validateSettings(mergedSettings) // Return the settings object
return settings
} }
const settingsValidators: Record< export async function saveSettings(
keyof SettingsMachineContext, allSettings: typeof settings,
(v: unknown) => boolean projectPath?: string
> = { ) {
baseUnit: (v) => baseUnitsUnion.includes(v as BaseUnit), const settingsFilePaths = await getSettingsFilePaths(projectPath)
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>) { if (settingsFilePaths.user) {
const validKeys = Object.keys(initialSettings) const changedSettings = getChangedSettingsAtLevel(allSettings, 'user')
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>) { await writeOrClearPersistedSettings(settingsFilePaths.user, changedSettings)
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 if (settingsFilePaths.project) {
const defaultDirectory = isTauri() ? await getInitialDefaultDir() : '' const changedSettings = getChangedSettingsAtLevel(allSettings, 'project')
const settings = Object.assign( await writeOrClearPersistedSettings(
initialSettings, settingsFilePaths.project,
{ defaultDirectory }, changedSettings
settingsNoInvalidKeys )
) as SettingsMachineContext
return {
settings,
errors,
} }
} }
async function writeOrClearPersistedSettings(
settingsFilePath: string,
changedSettings: Partial<SaveSettingsPayload>
) {
if (changedSettings && Object.keys(changedSettings).length) {
if (isTauri()) {
await writeTextFile(
settingsFilePath,
TOML.stringify({ settings: changedSettings })
)
}
localStorage.setItem(
settingsFilePath,
TOML.stringify({ settings: changedSettings })
)
} else {
if (isTauri() && (await exists(settingsFilePath))) {
await removeFile(settingsFilePath)
}
localStorage.removeItem(settingsFilePath)
}
}
export function getChangedSettingsAtLevel(
allSettings: typeof settings,
level: SettingsLevel
): Partial<SaveSettingsPayload> {
const changedSettings = {} as Record<
keyof typeof settings,
Record<string, unknown>
>
Object.entries(allSettings).forEach(([category, settingsCategory]) => {
const categoryKey = category as keyof typeof settings
Object.entries(settingsCategory).forEach(
([setting, settingValue]: [string, Setting]) => {
// If setting is different its ancestors' non-undefined values,
// then it has been changed from the default
if (
settingValue[level] !== undefined &&
((level === 'project' &&
(settingValue.user !== undefined
? settingValue.project !== settingValue.user
: settingValue.project !== settingValue.default)) ||
(level === 'user' && settingValue.user !== settingValue.default))
) {
if (!changedSettings[categoryKey]) {
changedSettings[categoryKey] = {}
}
changedSettings[categoryKey][setting] = settingValue[level]
}
}
)
})
return changedSettings
}
export function setSettingsAtLevel(
allSettings: typeof settings,
level: SettingsLevel,
newSettings: Partial<SaveSettingsPayload>
) {
Object.entries(newSettings).forEach(([category, settingsCategory]) => {
const categoryKey = category as keyof typeof settings
if (!allSettings[categoryKey]) return // ignore unrecognized categories
Object.entries(settingsCategory).forEach(
([settingKey, settingValue]: [string, Setting]) => {
// TODO: How do you get a valid type for allSettings[categoryKey][settingKey]?
// it seems to always collapses to `never`, which is not correct
// @ts-ignore
if (!allSettings[categoryKey][settingKey]) return // ignore unrecognized settings
// @ts-ignore
allSettings[categoryKey][settingKey][level] = settingValue as unknown
}
)
})
return allSettings
}
/**
* Returns true if the setting should be hidden
* based on its config, the current settings level,
* and the current platform.
*/
export function shouldHideSetting(
setting: Setting<unknown>,
settingsLevel: SettingsLevel
) {
return (
setting.hideOnLevel === settingsLevel ||
(setting.hideOnPlatform && isTauri()
? setting.hideOnPlatform === 'desktop'
: setting.hideOnPlatform === 'web')
)
}

View File

@ -1,12 +1,12 @@
import { FileEntry } from '@tauri-apps/api/fs' import { FileEntry } from '@tauri-apps/api/fs'
import { import {
MAX_PADDING,
deepFileFilter, deepFileFilter,
getNextProjectIndex, getNextProjectIndex,
getPartsCount, getPartsCount,
interpolateProjectNameWithIndex, interpolateProjectNameWithIndex,
isRelevantFileOrDir, isRelevantFileOrDir,
} from './tauriFS' } from './tauriFS'
import { MAX_PADDING } from './constants'
describe('Test project name utility functions', () => { describe('Test project name utility functions', () => {
it('interpolates a project name without an index', () => { it('interpolates a project name without an index', () => {

View File

@ -10,25 +10,17 @@ import { appConfigDir, documentDir, homeDir, sep } from '@tauri-apps/api/path'
import { isTauri } from './isTauri' import { isTauri } from './isTauri'
import { type ProjectWithEntryPointMetadata } from 'lib/types' import { type ProjectWithEntryPointMetadata } from 'lib/types'
import { metadata } from 'tauri-plugin-fs-extra-api' import { metadata } from 'tauri-plugin-fs-extra-api'
import { settingsMachine } from 'machines/settingsMachine' import {
import { ContextFrom } from 'xstate' FILE_EXT,
import { SETTINGS_FILE_NAME } from 'lib/constants' INDEX_IDENTIFIER,
MAX_PADDING,
const PROJECT_FOLDER = 'zoo-modeling-app-projects' PROJECT_ENTRYPOINT,
export const FILE_EXT = '.kcl' PROJECT_FOLDER,
export const PROJECT_ENTRYPOINT = 'main' + FILE_EXT RELEVANT_FILE_TYPES,
const INDEX_IDENTIFIER = '$n' // $nn.. will pad the number with 0s SETTINGS_FILE_EXT,
export const MAX_PADDING = 7 } from 'lib/constants'
const RELEVANT_FILE_TYPES = [ import { SaveSettingsPayload, SettingsLevel } from './settings/settingsTypes'
'kcl', import * as TOML from '@iarna/toml'
'fbx',
'gltf',
'glb',
'obj',
'ply',
'step',
'stl',
]
type PathWithPossibleError = { type PathWithPossibleError = {
path: string | null path: string | null
@ -375,25 +367,18 @@ function getPaddedIdentifierRegExp() {
return new RegExp(`${escapedIdentifier}(${escapedIdentifier.slice(-1)}*)`) return new RegExp(`${escapedIdentifier}(${escapedIdentifier.slice(-1)}*)`)
} }
export async function getSettingsFilePath() { export async function getUserSettingsFilePath(
const dir = await appConfigDir() filename: string = SETTINGS_FILE_EXT
return dir + SETTINGS_FILE_NAME
}
export async function writeToSettingsFile(
settings: ContextFrom<typeof settingsMachine>
) { ) {
return writeTextFile( const dir = await appConfigDir()
await getSettingsFilePath(), return dir + filename
JSON.stringify(settings, null, 2)
)
} }
export async function readSettingsFile(): Promise<ContextFrom< export async function readSettingsFile(
typeof settingsMachine path: string
> | null> { ): Promise<Partial<SaveSettingsPayload>> {
const dir = await appConfigDir() const dir = path.slice(0, path.lastIndexOf(sep))
const path = dir + SETTINGS_FILE_NAME
const dirExists = await exists(dir) const dirExists = await exists(dir)
if (!dirExists) { if (!dirExists) {
await createDir(dir, { recursive: true }) await createDir(dir, { recursive: true })
@ -403,15 +388,39 @@ export async function readSettingsFile(): Promise<ContextFrom<
if (!settingsExist) { if (!settingsExist) {
console.log(`Settings file does not exist at ${path}`) console.log(`Settings file does not exist at ${path}`)
await writeToSettingsFile(settingsMachine.initialState.context) return {}
return null
} }
try { try {
const settings = await readTextFile(path) const settings = await readTextFile(path)
return JSON.parse(settings) // We expect the settings to be under a top-level [settings] key
return TOML.parse(settings).settings as Partial<SaveSettingsPayload>
} catch (e) { } catch (e) {
console.error('Error reading settings file:', e) console.error('Error reading settings file:', e)
return null return {}
}
}
export async function getSettingsFilePaths(
projectPath?: string
): Promise<Partial<Record<SettingsLevel, string>>> {
const { user, project } = await getSettingsFolderPaths(projectPath)
return {
user: user + 'user' + SETTINGS_FILE_EXT,
project:
project !== undefined
? project + (isTauri() ? sep : '/') + 'project' + SETTINGS_FILE_EXT
: undefined,
}
}
export async function getSettingsFolderPaths(projectPath?: string) {
const user = isTauri() ? await appConfigDir() : '/'
const project = projectPath !== undefined ? projectPath : undefined
return {
user,
project,
} }
} }

View File

@ -7,9 +7,92 @@ export type IndexLoaderData = {
file?: FileEntry file?: FileEntry
} }
export type FileLoaderData = {
code: string | null
project?: FileEntry | ProjectWithEntryPointMetadata
file?: FileEntry
}
export type ProjectWithEntryPointMetadata = FileEntry & { export type ProjectWithEntryPointMetadata = FileEntry & {
entrypointMetadata: Metadata entrypointMetadata: Metadata
} }
export type HomeLoaderData = { export type HomeLoaderData = {
projects: ProjectWithEntryPointMetadata[] projects: ProjectWithEntryPointMetadata[]
} }
// From the very helpful @jcalz on StackOverflow: https://stackoverflow.com/a/58436959/22753272
type Join<K, P> = K extends string | number
? P extends string | number
? `${K}${'' extends P ? '' : '.'}${P}`
: never
: never
type Prev = [
never,
0,
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
...0[]
]
export type Paths<T, D extends number = 10> = [D] extends [never]
? never
: T extends object
? {
[K in keyof T]-?: K extends string | number
? `${K}` | Join<K, Paths<T[K], Prev[D]>>
: never
}[keyof T]
: ''
type Idx<T, K> = K extends keyof T
? T[K]
: number extends keyof T
? K extends `${number}`
? T[number]
: never
: never
export type PathValue<
T,
P extends Paths<T, 1>
> = P extends `${infer Key}.${infer Rest}`
? Rest extends Paths<Idx<T, Key>, 1>
? PathValue<Idx<T, Key>, Rest>
: never
: Idx<T, P>
export type Leaves<T, D extends number = 10> = [D] extends [never]
? never
: T extends object
? { [K in keyof T]-?: Join<K, Leaves<T[K], Prev[D]>> }[keyof T]
: ''
// Thanks to @micfan on StackOverflow for this utility type:
// https://stackoverflow.com/a/57390160/22753272
export type AtLeast<T, K extends keyof T> = Partial<T> & Pick<T, K>
export function isEnumMember<T extends Record<string, unknown>>(
v: unknown,
e: T
) {
return Object.values(e).includes(v)
}

View File

@ -461,7 +461,10 @@ export const commandBarMachine = createMachine(
'options' in argConfig && 'options' in argConfig &&
!( !(
typeof argConfig.options === 'function' typeof argConfig.options === 'function'
? argConfig.options(context) ? argConfig.options(
context,
argConfig.machineActor.getSnapshot().context
)
: argConfig.options : argConfig.options
).some((o) => o.value === argValue) ).some((o) => o.value === argValue)
@ -479,7 +482,12 @@ export const commandBarMachine = createMachine(
}) })
} }
if (!argValue && isRequired) { if (
(argConfig.inputType !== 'boolean'
? !argValue
: argValue === undefined) &&
isRequired
) {
return reject({ return reject({
message: 'Argument payload is falsy but is required', message: 'Argument payload is falsy but is required',
arg: { arg: {

View File

@ -2,9 +2,6 @@ import { assign, createMachine } from 'xstate'
import { type ProjectWithEntryPointMetadata } from 'lib/types' import { type ProjectWithEntryPointMetadata } from 'lib/types'
import { FileEntry } from '@tauri-apps/api/fs' import { FileEntry } from '@tauri-apps/api/fs'
export const FILE_PERSIST_KEY = 'Last opened KCL files'
export const DEFAULT_FILE_NAME = 'Untitled'
export const fileMachine = createMachine( export const fileMachine = createMachine(
{ {
/** @xstate-layout N4IgpgJg5mDOIC5QDECWAbMACAtgQwGMALVAOzAGI9ZZUpSBtABgF1FQAHAe1oBdUupdiAAeiAKwBGcQDoALACYAHAE4AzGoUA2JXK1yANCACeiabOmSlGpkqsqAvg6NpMuQiXIyAEtSykuLAAzDDgKAGEAJzA8XmwQzGY2JBBuPgEhFLEESTVxWS1xBWVVcTU5PXEjUwRNFRkFWwB2FQVxJia5JnE5JxdQ92IyMB8-BLCAJTBSPBx40KThNNR+QWFslSVZJS1u3TUVSR1xJurEXSYZJslipismbTklPpBXbHwhr19YYNDYCOisXmiVYSx4Kwy6zMeQKRRKKjKFUKZwQPXqkjkmjUTCYWi0TQOCheb0GnhG31+mH+ABEwJg4pSwIsUstVplQNlcvkZIVikpSuVKijdFoZOItJJpEomtcYUTnK8Bh8yaMfuN-gB5DjTRnMzjgtlQnIwnlw-kIwXIkyIdSSGRKHF5XFqSwdYlKjzDVWM-4AZTAvCwsDpYAIcQgWAgqGiYa4kWMetSBshWTMNyaMkOuV0ChUBJUEpRnUuux6WmxGkkTF6CpJyq9URi-FIUEZFAgghGZAAblwANYjAiAuIAWnGidZKY50O5vPhiKF1oQcnF9q0myYCN2FSa4ndbnrXkbsTIrfGFDAkUicZkHHQsSCcZwMiHTbAY4WoJZybWqeNq82ddygUaQHiqJc7BkTRcwUOQjmKHR133d5PS8KYZhwU82w7Lwe37EZogw99xy-fV0l-adalzeQVForY1Ada4ni0FE5CaJRM0UAlmm6LRkNJL10NmLDz0va9Ilve9eEfSJn0I2ZiM-ZIyIhCjREQOoaLospGIxHYUQY0U8VaVoJUdB5+MPEZaXpETQnbTsZDwgcZAgENRxI5Sk3I9l1P-UVci6cUbg0JRlBRcRNkzHRdwUGVaPEOxLNQ6z3LszALyvG87wfJ9XPcxSQS8yc1M5PIMz0aU7gYuQCyOIsegaaUCTCwpnT42sPU+EYpjwKMWx9BzcNIXsXMBCAPypCcf187I7DUGQqweWDGgJNR8SLJ55AY9ibgLPJy2S7qZF6-qzz+IauxG-CZHGya4AYSRipmo15sWnFikUDoNA2pcXVkBQbFo7FuMkJojpVU70rCMTsqkmS5JiCb1WmnzXtyd7lq+tbfpqYoFEzSL9DqNofo6-oDxSigpiCaJYCIVHVNmxAtEaBpyxuZQ8iOaQUTBjNpWJxo2l3TpnheAI3PgFI6xSsE0b-EcWKXJWZBxHF2hAip0ySzrKeOikAh9eWmaNSVriappqy2CpWkkAzqLUJp8Q5h4DgRGsKZQg2xj+E3DT-c2OIlGVdwqFc4rUYUuntB0wesaQmhAvc9e9lVj2bc7MH9qc-OkNjMwFfkygLWqIpxe0cTsWCOiOE4IcE6ZhIG8Yc9KxBFFYlp5EkWirFB6Q1AbrwbIDaG2+ZnIegzTYLWLg59BUYUmAWwHKo3B51HlL2BLQpHoellSA8oooOOsSLGiRdowdY2r7RWu5OhdQLycVfWVS1aZx+-BXKPzmei70VLkvJcKhbBijKHicq3QQLiycEAA */ /** @xstate-layout N4IgpgJg5mDOIC5QDECWAbMACAtgQwGMALVAOzAGI9ZZUpSBtABgF1FQAHAe1oBdUupdiAAeiAKwBGcQDoALACYAHAE4AzGoUA2JXK1yANCACeiabOmSlGpkqsqAvg6NpMuQiXIyAEtSykuLAAzDDgKAGEAJzA8XmwQzGY2JBBuPgEhFLEESTVxWS1xBWVVcTU5PXEjUwRNFRkFWwB2FQVxJia5JnE5JxdQ92IyMB8-BLCAJTBSPBx40KThNNR+QWFslSVZJS1u3TUVSR1xJurEXSYZJslipismbTklPpBXbHwhr19YYNDYCOisXmiVYSx4Kwy6zMeQKRRKKjKFUKZwQPXqkjkmjUTCYWi0TQOCheb0GnhG31+mH+ABEwJg4pSwIsUstVplQNlcvkZIVikpSuVKijdFoZOItJJpEomtcYUTnK8Bh8yaMfuN-gB5DjTRnMzjgtlQnIwnlw-kIwXIkyIdSSGRKHF5XFqSwdYlKjzDVWM-4AZTAvCwsDpYAIcQgWAgqGiYa4kWMetSBshWTMNyaMkOuV0ChUBJUEpRnUuux6WmxGkkTF6CpJyq9URi-FIUEZFAgghGZAAblwANYjAiAuIAWnGidZKY50O5vPhiKF1oQcnF9q0myYCN2FSa4ndbnrXkbsTIrfGFDAkUicZkHHQsSCcZwMiHTbAY4WoJZybWqeNq82ddygUaQHiqJc7BkTRcwUOQjmKHR133d5PS8KYZhwU82w7Lwe37EZogw99xy-fV0l-adalzeQVForY1Ada4ni0FE5CaJRM0UAlmm6LRkNJL10NmLDz0va9Ilve9eEfSJn0I2ZiM-ZIyIhCjREQOoaLospGIxHYUQY0U8VaVoJUdB5+MPEZaXpETQnbTsZDwgcZAgENRxI5Sk3I9l1P-UVci6cUbg0JRlBRcRNkzHRdwUGVaPEOxLNQ6z3LszALyvG87wfJ9XPcxSQS8yc1M5PIMz0aU7gYuQCyOIsegaaUCTCwpnT42sPU+EYpjwKMWx9BzcNIXsXMBCAPypCcf187I7DUGQqweWDGgJNR8SLJ55AY9ibgLPJy2S7qZF6-qzz+IauxG-CZHGya4AYSRipmo15sWnFikUDoNA2pcXVkBQbFo7FuMkJojpVU70rCMTsqkmS5JiCb1WmnzXtyd7lq+tbfpqYoFEzSL9DqNofo6-oDxSigpiCaJYCIVHVNmxAtEaBpyxuZQ8iOaQUTBjNpWJxo2l3TpnheAI3PgFI6xSsE0b-EcWKXJWZBxHF2hAip0ySzrKeOikAh9eWmaNSVriappqy2CpWkkAzqLUJp8Q5h4DgRGsKZQg2xj+E3DT-c2OIlGVdwqFc4rUYUuntB0wesaQmhAvc9e9lVj2bc7MH9qc-OkNjMwFfkygLWqIpxe0cTsWCOiOE4IcE6ZhIG8Yc9KxBFFYlp5EkWirFB6Q1AbrwbIDaG2+ZnIegzTYLWLg59BUYUmAWwHKo3B51HlL2BLQpHoellSA8oooOOsSLGiRdowdY2r7RWu5OhdQLycVfWVS1aZx+-BXKPzmei70VLkvJcKhbBijKHicq3QQLiycEAA */

View File

@ -1,148 +1,86 @@
import { assign, createMachine } from 'xstate' import { assign, createMachine } from 'xstate'
import { Themes, getSystemTheme, setThemeClass } from 'lib/theme' import { Themes, getSystemTheme, setThemeClass } from 'lib/theme'
import { CameraSystem } from 'lib/cameraControls' import { createSettings, settings } from 'lib/settings/initialSettings'
import { isTauri } from 'lib/isTauri'
import { writeToSettingsFile } from 'lib/tauriFS'
import { DEFAULT_PROJECT_NAME, SETTINGS_PERSIST_KEY } from 'lib/constants'
import { import {
UnitSystem, BaseUnit,
type BaseUnit, SetEventTypes,
type SettingsMachineContext, SettingsLevel,
type Toggle, SettingsPaths,
WildcardSetEvent,
} from 'lib/settings/settingsTypes' } from 'lib/settings/settingsTypes'
export const settingsMachine = createMachine( export const settingsMachine = createMachine(
{ {
/** @xstate-layout N4IgpgJg5mDOIC5QGUwBc0EsB2VYDpMIAbMAYlTQAIBBY4qyrXWAbQAYBdRUABwHtYmLP2w8QAD0TsANCACe0gL5K5THHkIlylKgCEAhrDBUAqtmEduSEAKEixNqQgCM7AJz52AJgAsAVg8AZgAOEIA2fxd3XzlFBCCXf3xfdlS0kN9vGIiVNXRmTSJSCnQqAGEDAFswACcDCtE0Wv5iNi5xO2FMUXFnWQVlVRB1Fi0S3QARMAAzAwBXYmpJzFqwAGM0flr5K07Bbt6nRH9w-HcXcPcI8PYAdgu0oLiTu+98EPdQ0-8g8N8gu53HkRgUNARijoytM5otqAAFFoAKw21AActUwHsbF0HH1EFkvOxiSTScSXLFBggrsk7r4AuEQuxAd4oiEQaMitpStQAPLYABG-AMtQgGkYaAMaHm7WsfAOeOOCEC+HCiTevlu5JcYReCBCLhSFzc3m8SWJrJcHLBY0hPKoABUwBJqAB1eq8XgabHy+w9Rygfp69jWjDg8ZQ6gOgAWYBqPtsCv9+P17Hw3juIV+Pn87kiGeeVINIXwuf8rPC4WiVZcQVDhQh3N05mEjHksDQcYTuOTSrp+Du5ZC3g8bizbkp8QCaaelwep3YTP8vnr4btDv4UCgpCo0wF8ygVHhBmwYGI3aTR0DiFupfY-giQSC3iflZfepHvnwQV8Lge93cX4qxCO4VGGbB+AgOBxE5eAcUvANJEQABaXwQj1ZCQLvUkmXpFwzStYZYIjfY-SvJDXBHLxa01Stc0yIE7j1NwKW-NUAl8a4-DuZkwKUIA */ /** @xstate-layout N4IgpgJg5mDOIC5QGUwBc0EsB2VYDpMIAbMAYlnXwFsB7CMYnKfAV20zVgG0AGAXUSgADrVidMtbEJAAPRABYAHAFZ8vAEwA2FVq0aNAZgCcARl0B2ADQgAnolO8LvfBYUXT+48YW+tCgF8Am1QMZgIiUgoqAENhYXw0AAswajA+QSQQUXEsKRl5BCM1DQUDMo0VQwVjJxt7BFMDJXwNWo0LFSU3c0DgkFCsXAiScgAlOHQAAkow4YyZHIl8rMKAWn18Q39q3ScVFQ1NFXqHXiVTfGNqhSdeXi1DJQ1TIJD0IbxCUbIAKgWsks8tJVopjFp8KZjEpqi9lKZDE0TnYzgZXO5PG0fH4+u85l9IuRQlMYsRiFNsGAAO4zD7hAEiMTLEGgda6fAKJoIiyIkwWYwWawoxpGFxaHkKFS1a7woL9bD0OAyQbhRZM4EFRBrUyOLY7SVafaHTSnBDGDqQiz6DRKbwqTxvAZ04bfUhq3KSFlyBxHdQIhR6HTQmoC02OUwKPW7GrPdy8QxygJAA */
id: 'Settings', id: 'Settings',
predictableActionArguments: true, predictableActionArguments: true,
context: {} as SettingsMachineContext, context: {} as ReturnType<typeof createSettings>,
initial: 'idle', initial: 'idle',
states: { states: {
idle: { idle: {
entry: ['setThemeClass', 'setClientSideSceneUnits', 'persistSettings'], entry: ['setThemeClass', 'setClientSideSceneUnits', 'persistSettings'],
on: { on: {
'Set All Settings': { '*': {
actions: [
assign((context, event) => {
return {
...context,
...event.data,
}
}),
'persistSettings',
'setThemeClass',
],
target: 'idle', target: 'idle',
internal: true, internal: true,
actions: ['setSettingAtLevel', 'toastSuccess', 'persistSettings'],
}, },
'Set Base Unit': {
'set.app.onboardingStatus': {
target: 'idle',
internal: true,
actions: ['setSettingAtLevel', 'persistSettings'], // No toast
},
'set.modeling.defaultUnit': {
target: 'idle',
internal: true,
actions: [ actions: [
assign({ 'setSettingAtLevel',
baseUnit: (_, event) => event.data.baseUnit,
}),
'persistSettings',
'toastSuccess', 'toastSuccess',
'setClientSideSceneUnits', 'setClientSideSceneUnits',
'Execute AST', 'Execute AST',
],
target: 'idle',
internal: true,
},
'Set Camera Controls': {
actions: [
assign({
cameraControls: (_, event) => event.data.cameraControls,
}),
'persistSettings',
'toastSuccess',
],
target: 'idle',
internal: true,
},
'Set Default Directory': {
actions: [
assign({
defaultDirectory: (_, event) => event.data.defaultDirectory,
}),
'persistSettings',
'toastSuccess',
],
target: 'idle',
internal: true,
},
'Set Default Project Name': {
actions: [
assign({
defaultProjectName: (_, event) =>
event.data.defaultProjectName.trim() || DEFAULT_PROJECT_NAME,
}),
'persistSettings',
'toastSuccess',
],
target: 'idle',
internal: true,
},
'Set Onboarding Status': {
actions: [
assign({
onboardingStatus: (_, event) => event.data.onboardingStatus,
}),
'persistSettings', 'persistSettings',
], ],
},
'set.app.theme': {
target: 'idle', target: 'idle',
internal: true, internal: true,
},
'Set Text Wrapping': {
actions: [ actions: [
assign({ 'setSettingAtLevel',
textWrapping: (_, event) => event.data.textWrapping,
}),
'persistSettings',
'toastSuccess',
],
target: 'idle',
internal: true,
},
'Set Theme': {
actions: [
assign({
theme: (_, event) => event.data.theme,
}),
'persistSettings',
'toastSuccess', 'toastSuccess',
'setThemeClass', 'setThemeClass',
'setEngineTheme', 'setEngineTheme',
'persistSettings',
], ],
},
'Reset settings': {
target: 'idle', target: 'idle',
internal: true, internal: true,
},
'Set Unit System': {
actions: [ actions: [
assign({ 'resetSettings',
unitSystem: (_, event) => event.data.unitSystem, 'setThemeClass',
baseUnit: (_, event) => 'setEngineTheme',
event.data.unitSystem === 'imperial' ? 'in' : 'mm', 'setClientSideSceneUnits',
}),
'persistSettings',
'toastSuccess',
'Execute AST', 'Execute AST',
],
target: 'idle',
internal: true,
},
'Toggle Debug Panel': {
actions: [
assign({
showDebugPanel: (context) => {
return !context.showDebugPanel
},
}),
'persistSettings', 'persistSettings',
'toastSuccess',
], ],
},
'Set all settings': {
target: 'idle', target: 'idle',
internal: true, internal: true,
actions: [
'setAllSettings',
'setThemeClass',
'setEngineTheme',
'setClientSideSceneUnits',
'Execute AST',
'persistSettings',
],
}, },
}, },
}, },
@ -150,44 +88,61 @@ export const settingsMachine = createMachine(
tsTypes: {} as import('./settingsMachine.typegen').Typegen0, tsTypes: {} as import('./settingsMachine.typegen').Typegen0,
schema: { schema: {
events: {} as events: {} as
| { type: 'Set All Settings'; data: Partial<SettingsMachineContext> } | WildcardSetEvent<SettingsPaths>
| { type: 'Set Base Unit'; data: { baseUnit: BaseUnit } } | SetEventTypes
| { | {
type: 'Set Camera Controls' type: 'set.app.theme'
data: { cameraControls: CameraSystem } data: { level: SettingsLevel; value: Themes }
} }
| { type: 'Set Default Directory'; data: { defaultDirectory: string } }
| { | {
type: 'Set Default Project Name' type: 'set.modeling.units'
data: { defaultProjectName: string } data: { level: SettingsLevel; value: BaseUnit }
} }
| { type: 'Set Onboarding Status'; data: { onboardingStatus: string } } | { type: 'Reset settings'; defaultDirectory: string }
| { type: 'Set Text Wrapping'; data: { textWrapping: Toggle } } | { type: 'Set all settings'; settings: typeof settings },
| { type: 'Set Theme'; data: { theme: Themes } }
| {
type: 'Set Unit System'
data: { unitSystem: UnitSystem }
}
| { type: 'Toggle Debug Panel' },
}, },
}, },
{ {
actions: { actions: {
persistSettings: (context) => { resetSettings: assign((context, { defaultDirectory }) => {
if (isTauri()) { // Reset everything except onboarding status,
writeToSettingsFile(context).catch((err) => { // which should be preserved
console.error('Error writing settings:', err) const newSettings = createSettings()
}) if (context.app.onboardingStatus.user) {
} newSettings.app.onboardingStatus.user =
try { context.app.onboardingStatus.user
localStorage.setItem(SETTINGS_PERSIST_KEY, JSON.stringify(context))
} catch (e) {
console.error(e)
} }
// We instead pass in the default directory since it's asynchronous
// to re-initialize, and that can be done by the caller.
newSettings.app.projectDirectory.default = defaultDirectory
return newSettings
}),
setAllSettings: assign((_, event) => {
return event.settings
}),
setSettingAtLevel: assign((context, event) => {
const { level, value } = event.data
const [category, setting] = event.type
.replace(/^set./, '')
.split('.') as [keyof typeof settings, string]
// @ts-ignore
context[category][setting][level] = value
const newContext = {
...context,
[category]: {
...context[category],
// @ts-ignore
[setting]: context[category][setting],
}, },
setThemeClass: (context, event) => { }
const currentTheme =
event.type === 'Set Theme' ? event.data.theme : context.theme return newContext
}),
setThemeClass: (context) => {
const currentTheme = context.app.theme.current ?? Themes.System
setThemeClass( setThemeClass(
currentTheme === Themes.System ? getSystemTheme() : currentTheme currentTheme === Themes.System ? getSystemTheme() : currentTheme
) )

View File

@ -31,27 +31,23 @@ import {
import useStateMachineCommands from '../hooks/useStateMachineCommands' import useStateMachineCommands from '../hooks/useStateMachineCommands'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { useCommandsContext } from 'hooks/useCommandsContext' import { useCommandsContext } from 'hooks/useCommandsContext'
import { DEFAULT_PROJECT_NAME } from 'lib/constants'
import { sep } from '@tauri-apps/api/path' import { sep } from '@tauri-apps/api/path'
import { homeCommandBarConfig } from 'lib/commandBarConfigs/homeCommandConfig' import { homeCommandBarConfig } from 'lib/commandBarConfigs/homeCommandConfig'
import { useHotkeys } from 'react-hotkeys-hook' import { useHotkeys } from 'react-hotkeys-hook'
import { isTauri } from 'lib/isTauri' import { isTauri } from 'lib/isTauri'
import { kclManager } from 'lib/singletons' import { kclManager } from 'lib/singletons'
import { useLspContext } from 'components/LspProvider' import { useLspContext } from 'components/LspProvider'
import { useValidateSettings } from 'hooks/useValidateSettings' import { useRefreshSettings } from 'hooks/useRefreshSettings'
// This route only opens in the Tauri desktop context for now, // This route only opens in the Tauri desktop context for now,
// as defined in Router.tsx, so we can use the Tauri APIs and types. // as defined in Router.tsx, so we can use the Tauri APIs and types.
const Home = () => { const Home = () => {
useValidateSettings() useRefreshSettings(paths.HOME + 'SETTINGS')
const { commandBarSend } = useCommandsContext() const { commandBarSend } = useCommandsContext()
const navigate = useNavigate() const navigate = useNavigate()
const { projects: loadedProjects } = useLoaderData() as HomeLoaderData const { projects: loadedProjects } = useLoaderData() as HomeLoaderData
const { const {
settings: { settings: { context: settings },
context: { defaultDirectory, defaultProjectName },
send: sendToSettings,
},
} = useSettingsAuthContext() } = useSettingsAuthContext()
const { onProjectOpen } = useLspContext() const { onProjectOpen } = useLspContext()
@ -71,8 +67,8 @@ const Home = () => {
const [state, send, actor] = useMachine(homeMachine, { const [state, send, actor] = useMachine(homeMachine, {
context: { context: {
projects: loadedProjects, projects: loadedProjects,
defaultProjectName, defaultProjectName: settings.projects.defaultProjectName.current,
defaultDirectory, defaultDirectory: settings.app.projectDirectory.current,
}, },
actions: { actions: {
navigateToProject: ( navigateToProject: (
@ -105,15 +101,8 @@ const Home = () => {
let name = ( let name = (
event.data && 'name' in event.data event.data && 'name' in event.data
? event.data.name ? event.data.name
: defaultProjectName : settings.projects.defaultProjectName.current
).trim() ).trim()
let shouldUpdateDefaultProjectName = false
// If there is no default project name, flag it to be set to the default
if (!name) {
name = DEFAULT_PROJECT_NAME
shouldUpdateDefaultProjectName = true
}
if (doesProjectNameNeedInterpolated(name)) { if (doesProjectNameNeedInterpolated(name)) {
const nextIndex = await getNextProjectIndex(name, projects) const nextIndex = await getNextProjectIndex(name, projects)
@ -122,13 +111,6 @@ const Home = () => {
await createNewProject(context.defaultDirectory + sep + name) await createNewProject(context.defaultDirectory + sep + name)
if (shouldUpdateDefaultProjectName) {
sendToSettings({
type: 'Set Default Project Name',
data: { defaultProjectName: DEFAULT_PROJECT_NAME },
})
}
return `Successfully created "${name}"` return `Successfully created "${name}"`
}, },
renameProject: async ( renameProject: async (
@ -179,9 +161,21 @@ const Home = () => {
actor, actor,
}) })
// Update the default project name and directory in the home machine
// when the settings change
useEffect(() => { useEffect(() => {
send({ type: 'assign', data: { defaultProjectName, defaultDirectory } }) send({
}, [defaultDirectory, defaultProjectName, send]) type: 'assign',
data: {
defaultProjectName: settings.projects.defaultProjectName.current,
defaultDirectory: settings.app.projectDirectory.current,
},
})
}, [
settings.app.projectDirectory,
settings.projects.defaultProjectName,
send,
])
async function handleRenameProject( async function handleRenameProject(
e: FormEvent<HTMLFormElement>, e: FormEvent<HTMLFormElement>,
@ -254,7 +248,7 @@ const Home = () => {
<p className="my-4 text-sm text-chalkboard-80 dark:text-chalkboard-30"> <p className="my-4 text-sm text-chalkboard-80 dark:text-chalkboard-30">
Loaded from{' '} Loaded from{' '}
<span className="text-energy-70 dark:text-energy-40"> <span className="text-energy-70 dark:text-energy-40">
{defaultDirectory} {settings.app.projectDirectory.current}
</span> </span>
.{' '} .{' '}
<Link to="settings" className="underline underline-offset-2"> <Link to="settings" className="underline underline-offset-2">

View File

@ -19,7 +19,9 @@ export default function Units() {
settings: { settings: {
send, send,
state: { state: {
context: { cameraControls }, context: {
modeling: { mouseControls },
},
}, },
}, },
} = useSettingsAuthContext() } = useSettingsAuthContext()
@ -41,11 +43,14 @@ export default function Units() {
<select <select
id="camera-controls" id="camera-controls"
className="block w-full px-3 py-1 bg-transparent border border-chalkboard-30" className="block w-full px-3 py-1 bg-transparent border border-chalkboard-30"
value={cameraControls} value={mouseControls.current}
onChange={(e) => { onChange={(e) => {
send({ send({
type: 'Set Camera Controls', type: 'set.modeling.mouseControls',
data: { cameraControls: e.target.value as CameraSystem }, data: {
level: 'user',
value: e.target.value as CameraSystem,
},
}) })
}} }}
> >
@ -58,15 +63,15 @@ export default function Units() {
<ul className="mx-4 my-2 text-sm leading-relaxed"> <ul className="mx-4 my-2 text-sm leading-relaxed">
<li> <li>
<strong>Pan:</strong>{' '} <strong>Pan:</strong>{' '}
{cameraMouseDragGuards[cameraControls].pan.description} {cameraMouseDragGuards[mouseControls.current].pan.description}
</li> </li>
<li> <li>
<strong>Zoom:</strong>{' '} <strong>Zoom:</strong>{' '}
{cameraMouseDragGuards[cameraControls].zoom.description} {cameraMouseDragGuards[mouseControls.current].zoom.description}
</li> </li>
<li> <li>
<strong>Rotate:</strong>{' '} <strong>Rotate:</strong>{' '}
{cameraMouseDragGuards[cameraControls].rotate.description} {cameraMouseDragGuards[mouseControls.current].rotate.description}
</li> </li>
</ul> </ul>
</SettingsSection> </SettingsSection>

View File

@ -9,7 +9,6 @@ import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { Themes, getSystemTheme } from 'lib/theme' import { Themes, getSystemTheme } from 'lib/theme'
import { bracket } from 'lib/exampleKcl' import { bracket } from 'lib/exampleKcl'
import { import {
PROJECT_ENTRYPOINT,
createNewProject, createNewProject,
getNextProjectIndex, getNextProjectIndex,
getProjectsInDir, getProjectsInDir,
@ -21,7 +20,7 @@ import { paths } from 'lib/paths'
import { useEffect } from 'react' import { useEffect } from 'react'
import { kclManager } from 'lib/singletons' import { kclManager } from 'lib/singletons'
import { sep } from '@tauri-apps/api/path' import { sep } from '@tauri-apps/api/path'
import { APP_NAME } from 'lib/constants' import { APP_NAME, PROJECT_ENTRYPOINT } from 'lib/constants'
function OnboardingWithNewFile() { function OnboardingWithNewFile() {
const navigate = useNavigate() const navigate = useNavigate()
@ -29,12 +28,14 @@ function OnboardingWithNewFile() {
const next = useNextClick(onboardingPaths.INDEX) const next = useNextClick(onboardingPaths.INDEX)
const { const {
settings: { settings: {
context: { defaultDirectory }, context: {
app: { projectDirectory },
},
}, },
} = useSettingsAuthContext() } = useSettingsAuthContext()
async function createAndOpenNewProject() { async function createAndOpenNewProject() {
const projects = await getProjectsInDir(defaultDirectory) const projects = await getProjectsInDir(projectDirectory.current)
const nextIndex = await getNextProjectIndex( const nextIndex = await getNextProjectIndex(
ONBOARDING_PROJECT_NAME, ONBOARDING_PROJECT_NAME,
projects projects
@ -44,7 +45,7 @@ function OnboardingWithNewFile() {
nextIndex nextIndex
) )
const newFile = await createNewProject( const newFile = await createNewProject(
defaultDirectory + sep + name, projectDirectory.current + sep + name,
bracket bracket
) )
navigate( navigate(
@ -108,13 +109,15 @@ export default function Introduction() {
const { const {
settings: { settings: {
state: { state: {
context: { theme }, context: {
app: { theme },
},
}, },
}, },
} = useSettingsAuthContext() } = useSettingsAuthContext()
const getLogoTheme = () => const getLogoTheme = () =>
theme === Themes.Light || theme.current === Themes.Light ||
(theme === Themes.System && getSystemTheme() === Themes.Light) (theme.current === Themes.System && getSystemTheme() === Themes.Light)
? '-dark' ? '-dark'
: '' : ''
const dismiss = useDismiss() const dismiss = useDismiss()

View File

@ -12,7 +12,11 @@ export default function ParametricModeling() {
})) }))
const { const {
settings: { settings: {
context: { theme }, context: {
app: {
theme: { current: theme },
},
},
}, },
} = useSettingsAuthContext() } = useSettingsAuthContext()
const getImageTheme = () => const getImageTheme = () =>

View File

@ -1,12 +1,7 @@
import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons' import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons'
import { import { type BaseUnit, baseUnitsUnion } from 'lib/settings/settingsTypes'
type BaseUnit,
baseUnits,
UnitSystem,
} from 'lib/settings/settingsTypes'
import { ActionButton } from 'components/ActionButton' import { ActionButton } from 'components/ActionButton'
import { SettingsSection } from '../Settings' import { SettingsSection } from '../Settings'
import { Toggle } from 'components/Toggle/Toggle'
import { useDismiss, useNextClick } from '.' import { useDismiss, useNextClick } from '.'
import { onboardingPaths } from 'routes/Onboarding/paths' import { onboardingPaths } from 'routes/Onboarding/paths'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
@ -17,7 +12,9 @@ export default function Units() {
const { const {
settings: { settings: {
send, send,
context: { unitSystem, baseUnit }, context: {
modeling: { defaultUnit },
},
}, },
} = useSettingsAuthContext() } = useSettingsAuthContext()
@ -26,41 +23,24 @@ export default function Units() {
<div className="max-w-3xl bg-chalkboard-10 dark:bg-chalkboard-90 p-8 rounded"> <div className="max-w-3xl bg-chalkboard-10 dark:bg-chalkboard-90 p-8 rounded">
<h1 className="text-2xl font-bold">Set your units</h1> <h1 className="text-2xl font-bold">Set your units</h1>
<SettingsSection <SettingsSection
title="Unit System" title="Default Unit"
description="Which unit system to use by default" description="Which unit to use in modeling dimensions by default"
>
<Toggle
offLabel="Imperial"
onLabel="Metric"
name="settings-units"
checked={unitSystem === UnitSystem.Metric}
onChange={(e) => {
const newUnitSystem = e.target.checked
? UnitSystem.Metric
: UnitSystem.Imperial
send({
type: 'Set Unit System',
data: { unitSystem: newUnitSystem },
})
}}
/>
</SettingsSection>
<SettingsSection
title="Base Unit"
description="Which base unit to use in dimensions by default"
> >
<select <select
id="base-unit" id="base-unit"
className="block w-full px-3 py-1 border border-chalkboard-30 bg-transparent" className="block w-full px-3 py-1 border border-chalkboard-30 bg-transparent"
value={baseUnit} value={defaultUnit.user}
onChange={(e) => { onChange={(e) => {
send({ send({
type: 'Set Base Unit', type: 'set.modeling.defaultUnit',
data: { baseUnit: e.target.value as BaseUnit }, data: {
level: 'user',
value: e.target.value as BaseUnit,
},
}) })
}} }}
> >
{baseUnits[unitSystem].map((unit) => ( {baseUnitsUnion.map((unit) => (
<option key={unit} value={unit}> <option key={unit} value={unit}>
{unit} {unit}
</option> </option>

View File

@ -85,8 +85,8 @@ export function useNextClick(newStatus: string) {
return useCallback(() => { return useCallback(() => {
send({ send({
type: 'Set Onboarding Status', type: 'set.app.onboardingStatus',
data: { onboardingStatus: newStatus }, data: { level: 'user', value: newStatus },
}) })
navigate(filePath + paths.ONBOARDING.INDEX.slice(0, -1) + newStatus) navigate(filePath + paths.ONBOARDING.INDEX.slice(0, -1) + newStatus)
}, [filePath, newStatus, send, navigate]) }, [filePath, newStatus, send, navigate])
@ -101,8 +101,8 @@ export function useDismiss() {
return useCallback(() => { return useCallback(() => {
send({ send({
type: 'Set Onboarding Status', type: 'set.app.onboardingStatus',
data: { onboardingStatus: 'dismissed' }, data: { level: 'user', value: 'dismissed' },
}) })
navigate(filePath) navigate(filePath)
}, [send, navigate, filePath]) }, [send, navigate, filePath])

View File

@ -1,90 +1,70 @@
import { faArrowRotateBack, faXmark } from '@fortawesome/free-solid-svg-icons'
import { ActionButton } from '../components/ActionButton' import { ActionButton } from '../components/ActionButton'
import { AppHeader } from '../components/AppHeader'
import { open } from '@tauri-apps/api/dialog'
import { DEFAULT_PROJECT_NAME, SETTINGS_PERSIST_KEY } from 'lib/constants'
import { import {
type BaseUnit, SetEventTypes,
UnitSystem, SettingsLevel,
baseUnits, WildcardSetEvent,
} from 'lib/settings/settingsTypes' } from 'lib/settings/settingsTypes'
import { Toggle } from 'components/Toggle/Toggle' import { Toggle } from 'components/Toggle/Toggle'
import { useLocation, useNavigate, useRouteLoaderData } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
import { useHotkeys } from 'react-hotkeys-hook' import { useHotkeys } from 'react-hotkeys-hook'
import { type IndexLoaderData } from 'lib/types'
import { paths } from 'lib/paths' import { paths } from 'lib/paths'
import { Themes } from '../lib/theme'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import {
CameraSystem,
cameraSystems,
cameraMouseDragGuards,
} from 'lib/cameraControls'
import { useDotDotSlash } from 'hooks/useDotDotSlash' import { useDotDotSlash } from 'hooks/useDotDotSlash'
import { import {
createNewProject, createNewProject,
getInitialDefaultDir,
getNextProjectIndex, getNextProjectIndex,
getProjectsInDir, getProjectsInDir,
getSettingsFilePath, getSettingsFolderPaths,
initializeProjectDirectory,
interpolateProjectNameWithIndex, interpolateProjectNameWithIndex,
} from 'lib/tauriFS' } from 'lib/tauriFS'
import { initialSettings } from 'lib/settings/initialSettings'
import { ONBOARDING_PROJECT_NAME } from './Onboarding' import { ONBOARDING_PROJECT_NAME } from './Onboarding'
import { sep } from '@tauri-apps/api/path' import { sep } from '@tauri-apps/api/path'
import { bracket } from 'lib/exampleKcl' import { bracket } from 'lib/exampleKcl'
import { isTauri } from 'lib/isTauri' import { isTauri } from 'lib/isTauri'
import { invoke } from '@tauri-apps/api' import { invoke } from '@tauri-apps/api'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import React, { Fragment, useMemo, useRef, useState } from 'react'
import { Setting } from 'lib/settings/initialSettings'
import decamelize from 'decamelize'
import { Event } from 'xstate'
import { Dialog, RadioGroup, Transition } from '@headlessui/react'
import { CustomIcon, CustomIconName } from 'components/CustomIcon'
import Tooltip from 'components/Tooltip'
import { shouldHideSetting } from 'lib/settings/settingsUtils'
export const Settings = () => { export const Settings = () => {
const APP_VERSION = import.meta.env.PACKAGE_VERSION || 'unknown' const APP_VERSION = import.meta.env.PACKAGE_VERSION || 'unknown'
const loaderData =
(useRouteLoaderData(paths.FILE) as IndexLoaderData) || undefined
const navigate = useNavigate() const navigate = useNavigate()
const close = () => navigate(location.pathname.replace(paths.SETTINGS, ''))
const location = useLocation() const location = useLocation()
const isFileSettings = location.pathname.includes(paths.FILE) const isFileSettings = location.pathname.includes(paths.FILE)
const projectPath =
isFileSettings && isTauri()
? decodeURI(
location.pathname
.replace(paths.FILE + '/', '')
.replace(paths.SETTINGS, '')
.slice(0, decodeURI(location.pathname).lastIndexOf(sep))
)
: undefined
const [settingsLevel, setSettingsLevel] = useState<SettingsLevel>(
isFileSettings ? 'project' : 'user'
)
const scrollRef = useRef<HTMLDivElement>(null)
const dotDotSlash = useDotDotSlash() const dotDotSlash = useDotDotSlash()
useHotkeys('esc', () => navigate(dotDotSlash())) useHotkeys('esc', () => navigate(dotDotSlash()))
const { const {
settings: { settings: {
send, send,
state: { state: { context },
context: {
baseUnit,
cameraControls,
defaultDirectory,
defaultProjectName,
showDebugPanel,
theme,
unitSystem,
},
},
}, },
} = useSettingsAuthContext() } = 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',
})
if (newDirectory && newDirectory !== null && !Array.isArray(newDirectory)) {
send({
type: 'Set Default Directory',
data: { defaultDirectory: newDirectory },
})
}
}
function restartOnboarding() { function restartOnboarding() {
send({ send({
type: 'Set Onboarding Status', type: `set.app.onboardingStatus`,
data: { onboardingStatus: '' }, data: { level: 'user', value: '' },
}) })
if (isFileSettings) { if (isFileSettings) {
@ -95,6 +75,7 @@ export const Settings = () => {
} }
async function createAndOpenNewProject() { async function createAndOpenNewProject() {
const defaultDirectory = context.app.projectDirectory.current
const projects = await getProjectsInDir(defaultDirectory) const projects = await getProjectsInDir(defaultDirectory)
const nextIndex = await getNextProjectIndex( const nextIndex = await getNextProjectIndex(
ONBOARDING_PROJECT_NAME, ONBOARDING_PROJECT_NAME,
@ -112,197 +93,198 @@ export const Settings = () => {
} }
return ( return (
<div className="fixed inset-0 z-40 overflow-auto body-bg"> <Transition appear show={true} as={Fragment}>
<AppHeader showToolbar={false} project={loaderData}> <Dialog
<ActionButton as="div"
Element="link" open={true}
to={location.pathname.replace(paths.SETTINGS, '')} onClose={close}
icon={{ className="fixed inset-0 z-40 overflow-y-auto p-4 grid place-items-center"
icon: faXmark,
bgClassName: 'bg-destroy-80',
iconClassName:
'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10',
}}
className="hover:border-destroy-40"
data-testid="close-button"
> >
Close <Transition.Child
</ActionButton> as={Fragment}
</AppHeader> enter="ease-out duration-300"
<div className="max-w-4xl mx-5 lg:mx-auto my-24"> enterFrom="opacity-0"
<h1 className="text-4xl font-bold">User Settings</h1> enterTo="opacity-100"
<p className="max-w-2xl mt-6"> leave="ease-in duration-75"
Don't see the feature you want? Check to see if it's on{' '} leaveFrom="opacity-100"
<a leaveTo="opacity-0"
href="https://github.com/KittyCAD/modeling-app/discussions"
target="_blank"
rel="noopener noreferrer"
> >
our roadmap <Dialog.Overlay className="fixed inset-0 bg-chalkboard-110/30 dark:bg-chalkboard-110/50" />
</a> </Transition.Child>
, and start a discussion if you don't see it! Your feedback will help
us prioritize what to build next. <Transition.Child
</p> as={Fragment}
<SettingsSection enter="ease-out duration-75"
title="Camera Controls" enterFrom="opacity-0 scale-95"
description="How you want to control the camera in the 3D view" enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
> >
<select <Dialog.Panel className="rounded relative mx-auto bg-chalkboard-10 dark:bg-chalkboard-100 border dark:border-chalkboard-70 max-w-3xl w-full max-h-[66vh] shadow-lg flex flex-col gap-8 overflow-hidden">
id="camera-controls" <div className="p-5 pb-0 flex justify-between items-center">
className="block w-full px-3 py-1 bg-transparent border border-chalkboard-30" <h1 className="text-2xl font-bold">Settings</h1>
value={cameraControls} <button
onChange={(e) => { onClick={close}
send({ className="p-0 m-0 focus:ring-0 focus:outline-none border-none hover:bg-destroy-10 focus:bg-destroy-10 dark:hover:bg-destroy-80/50 dark:focus:bg-destroy-80/50"
type: 'Set Camera Controls', data-testid="settings-close-button"
data: { cameraControls: e.target.value as CameraSystem },
})
}}
> >
{cameraSystems.map((program) => ( <CustomIcon name="close" className="w-5 h-5" />
<option key={program} value={program}> </button>
{program}
</option>
))}
</select>
<ul className="mx-4 my-2 text-sm leading-relaxed">
<li>
<strong>Pan:</strong>{' '}
{cameraMouseDragGuards[cameraControls].pan.description}
</li>
<li>
<strong>Zoom:</strong>{' '}
{cameraMouseDragGuards[cameraControls].zoom.description}
</li>
<li>
<strong>Rotate:</strong>{' '}
{cameraMouseDragGuards[cameraControls].rotate.description}
</li>
</ul>
</SettingsSection>
{(window as any).__TAURI__ && (
<>
<SettingsSection
title="Default Directory"
description="Where newly-created projects are saved on your local computer"
>
<div className="flex w-full gap-4 p-1 border rounded border-chalkboard-30">
<input
className="flex-1 px-2 bg-transparent"
value={defaultDirectory}
disabled
data-testid="default-directory-input"
/>
<ActionButton
Element="button"
onClick={handleDirectorySelection}
icon={{
icon: 'folder',
}}
>
Choose a folder
</ActionButton>
</div> </div>
</SettingsSection> <RadioGroup
<SettingsSection value={settingsLevel}
title="Default Project Name" onChange={setSettingsLevel}
description="Name template for new projects. Use $n to include an incrementing index" className="flex justify-start pl-4 pr-5 gap-5 border-0 border-b border-b-chalkboard-20 dark:border-b-chalkboard-90"
> >
<input <RadioGroup.Option value="user">
className="block w-full px-3 py-1 bg-transparent border border-chalkboard-30" {({ checked }) => (
defaultValue={defaultProjectName} <SettingsTabButton
onBlur={(e) => { checked={checked}
const newValue = e.target.value.trim() || DEFAULT_PROJECT_NAME icon="person"
send({ text="User"
type: 'Set Default Project Name',
data: {
defaultProjectName: newValue,
},
})
e.target.value = newValue
}}
autoCapitalize="off"
autoComplete="off"
data-testid="name-input"
/> />
</SettingsSection>
</>
)} )}
<SettingsSection </RadioGroup.Option>
title="Unit System" {isFileSettings && (
description="Which unit system to use by default" <RadioGroup.Option value="project">
> {({ checked }) => (
<Toggle <SettingsTabButton
offLabel="Imperial" checked={checked}
onLabel="Metric" icon="folder"
name="settings-units" text="This project"
checked={unitSystem === UnitSystem.Metric} />
onChange={(e) => { )}
const newUnitSystem = e.target.checked </RadioGroup.Option>
? UnitSystem.Metric )}
: UnitSystem.Imperial </RadioGroup>
send({ <div className="flex flex-grow overflow-hidden items-stretch pl-4 pr-5 pb-5 gap-4">
type: 'Set Unit System', <div className="flex w-64 flex-col gap-3 pr-2 py-1 border-0 border-r border-r-chalkboard-20 dark:border-r-chalkboard-90">
data: { unitSystem: newUnitSystem }, {Object.entries(context)
.filter(([_, categorySettings]) =>
// Filter out categories that don't have any non-hidden settings
Object.values(categorySettings).some(
(setting: Setting) =>
!shouldHideSetting(setting, settingsLevel)
)
)
.map(([category]) => (
<button
key={category}
onClick={() =>
scrollRef.current
?.querySelector(`#category-${category}`)
?.scrollIntoView({
block: 'nearest',
behavior: 'smooth',
}) })
}} }
className="capitalize text-left border-none px-1"
>
{decamelize(category, { separator: ' ' })}
</button>
))}
<button
onClick={() =>
scrollRef.current
?.querySelector(`#settings-resets`)
?.scrollIntoView({
block: 'nearest',
behavior: 'smooth',
})
}
className="capitalize text-left border-none px-1"
>
Resets
</button>
<button
onClick={() =>
scrollRef.current
?.querySelector(`#settings-about`)
?.scrollIntoView({
block: 'nearest',
behavior: 'smooth',
})
}
className="capitalize text-left border-none px-1"
>
About
</button>
</div>
<div
ref={scrollRef}
className="flex flex-col gap-6 px-2 overflow-y-auto"
>
{Object.entries(context)
.filter(([_, categorySettings]) =>
// Filter out categories that don't have any non-hidden settings
Object.values(categorySettings).some(
(setting) => !shouldHideSetting(setting, settingsLevel)
)
)
.map(([category, categorySettings]) => (
<Fragment key={category}>
<h2
id={`category-${category}`}
className="text-2xl mt-6 first-of-type:mt-0 capitalize font-bold"
>
{decamelize(category, { separator: ' ' })}
</h2>
{Object.entries(categorySettings)
.filter(
// Filter out settings that don't have a Component or inputType
// or are hidden on the current level or the current platform
(item: [string, Setting<unknown>]) =>
!shouldHideSetting(item[1], settingsLevel) &&
(item[1].Component ||
item[1].commandConfig?.inputType)
)
.map(([settingName, s]) => {
const setting = s as Setting
const parentValue =
setting[setting.getParentLevel(settingsLevel)]
return (
<SettingsSection
title={decamelize(settingName, {
separator: ' ',
})}
key={`${category}-${settingName}-${settingsLevel}`}
description={setting.description}
settingHasChanged={
setting[settingsLevel] !== undefined &&
setting[settingsLevel] !==
setting.getFallback(settingsLevel)
}
parentLevel={setting.getParentLevel(
settingsLevel
)}
onFallback={() =>
send({
type: `set.${category}.${settingName}`,
data: {
level: settingsLevel,
value:
parentValue !== undefined
? parentValue
: setting.getFallback(settingsLevel),
},
} as SetEventTypes)
}
>
<GeneratedSetting
category={category}
settingName={settingName}
settingsLevel={settingsLevel}
setting={setting}
/> />
</SettingsSection> </SettingsSection>
<SettingsSection )
title="Base Unit" })}
description="Which base unit to use in dimensions by default" </Fragment>
>
<select
id="base-unit"
className="block w-full px-3 py-1 bg-transparent border border-chalkboard-30"
value={baseUnit}
onChange={(e) => {
send({
type: 'Set Base Unit',
data: { baseUnit: e.target.value as BaseUnit },
})
}}
>
{baseUnits[unitSystem as keyof typeof baseUnits].map((unit) => (
<option key={unit} value={unit}>
{unit}
</option>
))} ))}
</select> <h2 id="settings-resets" className="text-2xl mt-6 font-bold">
</SettingsSection> Resets
<SettingsSection </h2>
title="Debug Panel"
description="Show the debug panel in the editor"
>
<Toggle
name="settings-debug-panel"
checked={showDebugPanel}
onChange={(e) => {
send('Toggle Debug Panel')
}}
/>
</SettingsSection>
<SettingsSection
title="Editor Theme"
description="Apply a light or dark theme to the editor"
>
<select
id="settings-theme"
className="block w-full px-3 py-1 bg-transparent border border-chalkboard-30"
value={theme}
onChange={(e) => {
send({
type: 'Set Theme',
data: { theme: e.target.value as Themes },
})
}}
>
{Object.entries(Themes).map(([label, value]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</SettingsSection>
<SettingsSection <SettingsSection
title="Onboarding" title="Onboarding"
description="Replay the onboarding process" description="Replay the onboarding process"
@ -310,65 +292,74 @@ export const Settings = () => {
<ActionButton <ActionButton
Element="button" Element="button"
onClick={restartOnboarding} onClick={restartOnboarding}
icon={{ icon: faArrowRotateBack, size: 'sm', className: 'p-1' }} icon={{
icon: 'refresh',
size: 'sm',
className: 'p-1',
}}
> >
Replay Onboarding Replay Onboarding
</ActionButton> </ActionButton>
</SettingsSection> </SettingsSection>
<p className="font-mono my-6 leading-loose"> <SettingsSection
Your settings are saved in{' '} title="Reset settings"
{isTauri() description={`Restore settings to their default values. Your settings are saved in
? 'a file in the app data folder for your OS.' ${
: "your browser's local storage."}{' '} isTauri()
{isTauri() ? ( ? ' a file in the app data folder for your OS.'
<span className="flex gap-4 flex-wrap items-center"> : " your browser's local storage."
<button
onClick={async () =>
void invoke('show_in_folder', {
path: await getSettingsFilePath(),
})
} }
className="text-base" `}
> >
Show settings.json in folder <div className="flex flex-col items-start gap-4">
</button> {isTauri() && (
<button <ActionButton
Element="button"
onClick={async () => { onClick={async () => {
// We have to re-call initializeProjectDirectory const paths = await getSettingsFolderPaths(
// since we can't set that in the settings machine's projectPath
// initial context due to it being async ? decodeURIComponent(projectPath)
send({ : undefined
type: 'Set All Settings', )
data: { void invoke('show_in_folder', {
...initialSettings, path: paths[settingsLevel],
defaultDirectory:
(await initializeProjectDirectory('')).path ?? '',
},
}) })
toast.success('Settings restored to default')
}} }}
className="text-base" icon={{
> icon: 'folder',
Restore default settings size: 'sm',
</button> className: 'p-1',
</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 Show in folder
</button> </ActionButton>
)} )}
</p> <ActionButton
<p className="mt-24 text-sm font-mono"> Element="button"
onClick={async () => {
const defaultDirectory = await getInitialDefaultDir()
send({
type: 'Reset settings',
defaultDirectory,
})
toast.success('Settings restored to default')
}}
icon={{
icon: 'refresh',
size: 'sm',
className: 'p-1 text-chalkboard-10',
bgClassName: 'bg-destroy-70',
}}
>
Restore default settings
</ActionButton>
</div>
</SettingsSection>
<h2 id="settings-about" className="text-2xl mt-6 font-bold">
About Modeling App
</h2>
<div className="text-sm mb-12">
<p>
{/* This uses a Vite plugin, set in vite.config.ts {/* This uses a Vite plugin, set in vite.config.ts
to inject the version from package.json */} to inject the version from package.json */}
App version {APP_VERSION}.{' '} App version {APP_VERSION}.{' '}
@ -380,8 +371,25 @@ export const Settings = () => {
View release on GitHub View release on GitHub
</a> </a>
</p> </p>
<p className="max-w-2xl mt-6">
Don't see the feature you want? Check to see if it's on{' '}
<a
href="https://github.com/KittyCAD/modeling-app/discussions"
target="_blank"
rel="noopener noreferrer"
>
our roadmap
</a>
, and start a discussion if you don't see it! Your feedback
will help us prioritize what to build next.
</p>
</div> </div>
</div> </div>
</div>
</Dialog.Panel>
</Transition.Child>
</Dialog>
</Transition>
) )
} }
@ -389,6 +397,9 @@ interface SettingsSectionProps extends React.PropsWithChildren {
title: string title: string
description?: string description?: string
className?: string className?: string
parentLevel?: SettingsLevel | 'default'
onFallback?: () => void
settingHasChanged?: boolean
headingClassName?: string headingClassName?: string
} }
@ -397,20 +408,215 @@ export function SettingsSection({
description, description,
className, className,
children, children,
headingClassName = 'text-2xl font-bold', parentLevel,
settingHasChanged,
onFallback,
headingClassName = 'text-base font-normal capitalize tracking-wide',
}: SettingsSectionProps) { }: SettingsSectionProps) {
return ( return (
<section <section
className={ className={
'my-16 last-of-type:mb-24 grid grid-cols-2 gap-12 items-start ' + 'group grid grid-cols-2 gap-6 items-start ' +
className className +
(settingHasChanged
? ' border-0 border-l-2 -ml-0.5 border-energy-50 dark:border-energy-20'
: '')
} }
> >
<div className="w-80"> <div className="ml-2">
<div className="flex items-center gap-2">
<h2 className={headingClassName}>{title}</h2> <h2 className={headingClassName}>{title}</h2>
<p className="mt-2 text-sm">{description}</p> {onFallback && parentLevel && settingHasChanged && (
<button
onClick={onFallback}
className="hidden group-hover:block group-focus-within:block border-none p-0 hover:bg-warn-10 dark:hover:bg-warn-80 focus:bg-warn-10 dark:focus:bg-warn-80 focus:outline-none"
>
<CustomIcon name="refresh" className="w-4 h-4" />
<span className="sr-only">Roll back {title}</span>
<Tooltip position="right">
Roll back to match {parentLevel}
</Tooltip>
</button>
)}
</div>
{description && (
<p className="mt-2 text-xs text-chalkboard-80 dark:text-chalkboard-30">
{description}
</p>
)}
</div> </div>
<div>{children}</div> <div>{children}</div>
</section> </section>
) )
} }
interface GeneratedSettingProps {
// We don't need the fancy types here,
// it doesn't help us with autocomplete or anything
category: string
settingName: string
settingsLevel: SettingsLevel
setting: Setting<unknown>
}
function GeneratedSetting({
category,
settingName,
settingsLevel,
setting,
}: GeneratedSettingProps) {
const {
settings: { context, send },
} = useSettingsAuthContext()
const options = useMemo(() => {
return setting.commandConfig &&
'options' in setting.commandConfig &&
setting.commandConfig.options
? setting.commandConfig.options instanceof Array
? setting.commandConfig.options
: setting.commandConfig.options(
{
argumentsToSubmit: {
level: settingsLevel,
},
},
context
)
: []
}, [setting, settingsLevel, context])
if (setting.Component)
return (
<setting.Component
value={setting[settingsLevel] || setting.getFallback(settingsLevel)}
onChange={(e) => {
if ('value' in e.target) {
send({
type: `set.${category}.${settingName}`,
data: {
level: settingsLevel,
value: e.target.value,
},
} as unknown as Event<WildcardSetEvent>)
}
}}
/>
)
switch (setting.commandConfig?.inputType) {
case 'boolean':
return (
<Toggle
offLabel="Off"
onLabel="On"
onChange={(e) =>
send({
type: `set.${category}.${settingName}`,
data: {
level: settingsLevel,
value: Boolean(e.target.checked),
},
} as SetEventTypes)
}
checked={Boolean(
setting[settingsLevel] !== undefined
? setting[settingsLevel]
: setting.getFallback(settingsLevel)
)}
name={`${category}-${settingName}`}
data-testid={`${category}-${settingName}`}
/>
)
case 'options':
return (
<select
name={`${category}-${settingName}`}
data-testid={`${category}-${settingName}`}
className="p-1 bg-transparent border rounded-sm border-chalkboard-30 w-full"
value={String(
setting[settingsLevel] || setting.getFallback(settingsLevel)
)}
onChange={(e) =>
send({
type: `set.${category}.${settingName}`,
data: {
level: settingsLevel,
value: e.target.value,
},
} as unknown as Event<WildcardSetEvent>)
}
>
{options &&
options.length > 0 &&
options.map((option) => (
<option key={option.name} value={String(option.value)}>
{option.name}
</option>
))}
</select>
)
case 'string':
return (
<input
name={`${category}-${settingName}`}
data-testid={`${category}-${settingName}`}
type="text"
className="p-1 bg-transparent border rounded-sm border-chalkboard-30 w-full"
defaultValue={String(
setting[settingsLevel] || setting.getFallback(settingsLevel)
)}
onBlur={(e) => {
if (
setting[settingsLevel] === undefined
? setting.getFallback(settingsLevel) !== e.target.value
: setting[settingsLevel] !== e.target.value
) {
send({
type: `set.${category}.${settingName}`,
data: {
level: settingsLevel,
value: e.target.value,
},
} as unknown as Event<WildcardSetEvent>)
}
}}
/>
)
}
return (
<p className="text-destroy-70 dark:text-destroy-20">
No component or input type found for setting {settingName} in category{' '}
{category}
</p>
)
}
interface SettingsTabButtonProps {
checked: boolean
icon: CustomIconName
text: string
}
function SettingsTabButton(props: SettingsTabButtonProps) {
const { checked, icon, text } = props
return (
<div
className={`cursor-pointer select-none flex items-center gap-1 p-1 pr-2 -mb-[1px] border-0 border-b ${
checked
? 'border-energy-10 dark:border-energy-20'
: 'border-chalkboard-20 dark:border-chalkboard-30 hover:bg-energy-10/50 dark:hover:bg-energy-90/50'
}`}
>
<CustomIcon
name={icon}
className={
'w-5 h-5 ' +
(checked
? 'bg-energy-10 dark:bg-energy-20 dark:text-chalkboard-110'
: '')
}
/>
<span>{text}</span>
</div>
)
}

View File

@ -6,24 +6,25 @@ import { Themes, getSystemTheme } from '../lib/theme'
import { paths } from 'lib/paths' import { paths } from 'lib/paths'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { APP_NAME } from 'lib/constants' import { APP_NAME } from 'lib/constants'
import { useValidateSettings } from 'hooks/useValidateSettings'
const SignIn = () => { const SignIn = () => {
useValidateSettings()
const getLogoTheme = () =>
theme === Themes.Light ||
(theme === Themes.System && getSystemTheme() === Themes.Light)
? '-dark'
: ''
const { const {
auth: { send }, auth: { send },
settings: { settings: {
state: { state: {
context: { theme }, context: {
app: { theme },
},
}, },
}, },
} = useSettingsAuthContext() } = useSettingsAuthContext()
const getLogoTheme = () =>
theme.current === Themes.Light ||
(theme.current === Themes.System && getSystemTheme() === Themes.Light)
? '-dark'
: ''
const signInTauri = async () => { const signInTauri = async () => {
// We want to invoke our command to login via device auth. // We want to invoke our command to login via device auth.
try { try {
@ -32,7 +33,7 @@ const SignIn = () => {
}) })
send({ type: 'Log in', token }) send({ type: 'Log in', token })
} catch (error) { } catch (error) {
console.error('login button', error) console.error('Error with login button', error)
} }
} }

View File

@ -11,6 +11,9 @@ import version from 'vite-plugin-package-version'
dns.setDefaultResultOrder('verbatim') dns.setDefaultResultOrder('verbatim')
const config = defineConfig({ const config = defineConfig({
define: {
global: 'window',
},
server: { server: {
open: true, open: true,
port: 3000, port: 3000,

View File

@ -1683,6 +1683,11 @@
resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-2.0.2.tgz#d9fae00a2d5cb40f92cfe64b47ad749fbc38f917" resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-2.0.2.tgz#d9fae00a2d5cb40f92cfe64b47ad749fbc38f917"
integrity sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw== integrity sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==
"@iarna/toml@^2.2.5":
version "2.2.5"
resolved "https://registry.yarnpkg.com/@iarna/toml/-/toml-2.2.5.tgz#b32366c89b43c6f8cefbdefac778b9c828e3ba8c"
integrity sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==
"@isaacs/cliui@^8.0.2": "@isaacs/cliui@^8.0.2":
version "8.0.2" version "8.0.2"
resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550"
@ -8327,7 +8332,6 @@ tar@^6.1.11:
"tauri-plugin-fs-extra-api@https://github.com/tauri-apps/tauri-plugin-fs-extra#v1": "tauri-plugin-fs-extra-api@https://github.com/tauri-apps/tauri-plugin-fs-extra#v1":
version "0.0.0" version "0.0.0"
uid b0a4a479cabb00bb7a689756f742ef89da4f2601
resolved "https://github.com/tauri-apps/tauri-plugin-fs-extra#b0a4a479cabb00bb7a689756f742ef89da4f2601" resolved "https://github.com/tauri-apps/tauri-plugin-fs-extra#b0a4a479cabb00bb7a689756f742ef89da4f2601"
dependencies: dependencies:
"@tauri-apps/api" "1.5.3" "@tauri-apps/api" "1.5.3"