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>
@ -1,3 +1,3 @@
|
||||
[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
|
||||
|
@ -1,10 +1,11 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { secrets } from './secrets'
|
||||
import { getUtils } from './test-utils'
|
||||
import waitOn from 'wait-on'
|
||||
import { Themes } from '../../src/lib/theme'
|
||||
import { initialSettings } from '../../src/lib/settings/initialSettings'
|
||||
import { roundOff } from 'lib/utils'
|
||||
import { 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
|
||||
@ -30,31 +31,14 @@ test.beforeEach(async ({ context, page }) => {
|
||||
resources: ['tcp:3000'],
|
||||
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
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' })
|
||||
})
|
||||
|
||||
test.setTimeout(60000)
|
||||
|
||||
test('Basic sketch', async ({ page }) => {
|
||||
test('Basic sketch', async ({ page, context }) => {
|
||||
const u = getUtils(page)
|
||||
await page.setViewportSize({ width: 1200, height: 500 })
|
||||
const PUR = 400 / 37.5 //pixeltoUnitRatio
|
||||
@ -529,83 +513,119 @@ test('Auto complete works', async ({ page }) => {
|
||||
})
|
||||
|
||||
// Stored settings validation test
|
||||
test('Stored settings are validated and fall back to defaults', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
// Override beforeEach test setup
|
||||
test.describe('Settings persistence and validation tests', () => {
|
||||
// Override test setup
|
||||
// with corrupted settings
|
||||
await context.addInitScript(async () => {
|
||||
const storedSettings = JSON.parse(
|
||||
localStorage.getItem('SETTINGS_PERSIST_KEY') || '{}'
|
||||
)
|
||||
const storageState = structuredClone(basicStorageState)
|
||||
const s = TOML.parse(storageState.origins[0].localStorage[2].value) as {
|
||||
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
|
||||
storedSettings.baseUnit = 'invalid'
|
||||
storedSettings.cameraControls = `() => alert('hack the planet')`
|
||||
storedSettings.defaultDirectory = 123
|
||||
storedSettings.defaultProjectName = false
|
||||
|
||||
localStorage.setItem('SETTINGS_PERSIST_KEY', JSON.stringify(storedSettings))
|
||||
})
|
||||
test.use({ storageState })
|
||||
|
||||
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.goto('/', { waitUntil: 'domcontentloaded' })
|
||||
|
||||
// Check the toast appeared
|
||||
await expect(
|
||||
page.getByText(`Error validating persisted settings:`, {
|
||||
exact: false,
|
||||
})
|
||||
).toBeVisible()
|
||||
await page.goto('/')
|
||||
await u.waitForAuthSkipAppStart()
|
||||
|
||||
// Check the settings were reset
|
||||
const storedSettings = JSON.parse(
|
||||
await page.evaluate(
|
||||
() => localStorage.getItem('SETTINGS_PERSIST_KEY') || '{}'
|
||||
)
|
||||
)
|
||||
await expect(storedSettings.baseUnit).toBe(initialSettings.baseUnit)
|
||||
await expect(storedSettings.cameraControls).toBe(
|
||||
initialSettings.cameraControls
|
||||
)
|
||||
await expect(storedSettings.defaultDirectory).toBe(
|
||||
initialSettings.defaultDirectory
|
||||
)
|
||||
await expect(storedSettings.defaultProjectName).toBe(
|
||||
initialSettings.defaultProjectName
|
||||
const storedSettings = TOML.parse(
|
||||
await page.evaluate(() => localStorage.getItem('/user.toml') || '{}')
|
||||
) as { settings: SaveSettingsPayload }
|
||||
|
||||
expect(storedSettings.settings.app?.theme).toBe('dark')
|
||||
|
||||
// Check that the invalid settings were removed
|
||||
expect(storedSettings.settings.modeling?.defaultUnit).toBe(undefined)
|
||||
expect(storedSettings.settings.modeling?.mouseControls).toBe(undefined)
|
||||
expect(storedSettings.settings.app?.projectDirectory).toBe(undefined)
|
||||
expect(storedSettings.settings.projects?.defaultProjectName).toBe(undefined)
|
||||
})
|
||||
|
||||
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
|
||||
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)
|
||||
|
||||
// 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.goto('/')
|
||||
await u.waitForAuthSkipAppStart()
|
||||
|
||||
// Test that the redirect happened
|
||||
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
|
||||
await page.reload()
|
||||
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
|
||||
@ -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
|
||||
await page.locator('[data-testid="onboarding-next"]').click()
|
||||
await expect(page.locator('.cm-content')).toHaveText(/.+/)
|
||||
})
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
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
|
||||
const u = getUtils(page)
|
||||
await page.setViewportSize({ width: 1200, height: 500 })
|
||||
await page.goto('/')
|
||||
await u.waitForAuthSkipAppStart()
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' })
|
||||
|
||||
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
|
||||
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 themeOption.click()
|
||||
const themeInput = page.getByPlaceholder('system')
|
||||
const themeInput = page.getByPlaceholder('Select an option')
|
||||
await expect(themeInput).toBeVisible()
|
||||
await expect(themeInput).toBeFocused()
|
||||
// Select dark theme
|
||||
await page.keyboard.press('ArrowDown')
|
||||
await page.keyboard.press('ArrowUp')
|
||||
await expect(page.getByRole('option', { name: Themes.Dark })).toHaveAttribute(
|
||||
await page.keyboard.press('ArrowDown')
|
||||
await page.keyboard.press('ArrowDown')
|
||||
await expect(page.getByRole('option', { name: 'system' })).toHaveAttribute(
|
||||
'data-headlessui-state',
|
||||
'active'
|
||||
)
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
// 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
|
||||
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 }) => {
|
||||
await context.addInitScript(async (token) => {
|
||||
localStorage.setItem(
|
||||
'persistCode',
|
||||
`
|
||||
const distance = sqrt(20)
|
||||
// Override test setup code
|
||||
const storageState = structuredClone(basicStorageState)
|
||||
storageState.origins[0].localStorage[1].value = `const distance = sqrt(20)
|
||||
const part001 = startSketchOn('-XZ')
|
||||
|> startProfileAt([-6.95, 4.98], %)
|
||||
|> line([25.1, 0.41], %)
|
||||
@ -839,15 +861,26 @@ test('Can extrude from the command bar', async ({ page, context }) => {
|
||||
|> line([-23.44, 0.52], %)
|
||||
|> close(%)
|
||||
`
|
||||
)
|
||||
})
|
||||
test.use({ storageState })
|
||||
|
||||
test('Can extrude from the command bar', async ({ page, context }) => {
|
||||
const u = getUtils(page)
|
||||
await page.setViewportSize({ width: 1200, height: 500 })
|
||||
await page.goto('/')
|
||||
await u.waitForAuthSkipAppStart()
|
||||
|
||||
// Make sure the stream is up
|
||||
await u.openDebugPanel()
|
||||
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')
|
||||
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
|
||||
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
|
||||
await expect(page.getByRole('button', { name: 'distance' })).toBeDisabled()
|
||||
@ -902,6 +927,7 @@ const part001 = startSketchOn('-XZ')
|
||||
|> close(%)
|
||||
|> extrude(distance001, %)`.replace(/(\r\n|\n|\r)/gm, '') // remove newlines
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test('Can add multiple sketches', async ({ page }) => {
|
||||
|
@ -7,30 +7,18 @@ import { spawn } from 'child_process'
|
||||
import { APP_NAME } from 'lib/constants'
|
||||
import JSZip from 'jszip'
|
||||
import path from 'path'
|
||||
import { basicSettings, basicStorageState } from './storageStates'
|
||||
import * as TOML from '@iarna/toml'
|
||||
|
||||
test.beforeEach(async ({ context, 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)
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// reducedMotion kills animations, which speeds up tests and reduces flakiness
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' })
|
||||
})
|
||||
|
||||
test.use({
|
||||
storageState: structuredClone(basicStorageState),
|
||||
})
|
||||
|
||||
test.setTimeout(60_000)
|
||||
|
||||
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.expectCmdLog('[data-message-type="execution-done"]')
|
||||
await u.clearAndCloseDebugPanel()
|
||||
|
||||
await page.getByText('Code').click()
|
||||
await expect(page).toHaveScreenshot({
|
||||
maxDiffPixels: 100,
|
||||
})
|
||||
await page.getByText('Code').click()
|
||||
await page.waitForTimeout(200)
|
||||
|
||||
const runSnapshotsForOtherPlanes = async (plane = 'XY') => {
|
||||
// clear code
|
||||
@ -371,11 +354,13 @@ test('extrude on each default plane should be stable', async ({
|
||||
await u.clearAndCloseDebugPanel()
|
||||
|
||||
await page.getByText('Code').click()
|
||||
await page.waitForTimeout(80)
|
||||
await expect(page).toHaveScreenshot({
|
||||
maxDiffPixels: 100,
|
||||
})
|
||||
await page.getByText('Code').click()
|
||||
}
|
||||
await runSnapshotsForOtherPlanes('XY')
|
||||
await runSnapshotsForOtherPlanes('-XY')
|
||||
|
||||
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 }) => {
|
||||
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)
|
||||
await page.setViewportSize({ width: 1200, height: 500 })
|
||||
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,
|
||||
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)
|
||||
await page.setViewportSize({ width: 1200, height: 500 })
|
||||
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 expect(page.locator('.cm-content'))
|
||||
.toHaveText(`const part001 = startSketchOn('-XZ')
|
||||
|> startProfileAt([9.06, -12.22], %)`)
|
||||
|> startProfileAt([9.06, -12.22], %)`)
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
await u.closeDebugPanel()
|
||||
@ -522,8 +474,8 @@ test('Client side scene scale should match engine scale inch', async ({
|
||||
|
||||
await expect(page.locator('.cm-content'))
|
||||
.toHaveText(`const part001 = startSketchOn('-XZ')
|
||||
|> startProfileAt([9.06, -12.22], %)
|
||||
|> line([9.14, 0], %)`)
|
||||
|> startProfileAt([9.06, -12.22], %)
|
||||
|> line([9.14, 0], %)`)
|
||||
|
||||
await page.getByRole('button', { name: 'Tangential Arc' }).click()
|
||||
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'))
|
||||
.toHaveText(`const part001 = startSketchOn('-XZ')
|
||||
|> startProfileAt([9.06, -12.22], %)
|
||||
|> line([9.14, 0], %)
|
||||
|> tangentialArcTo([27.34, -3.08], %)`)
|
||||
|> startProfileAt([9.06, -12.22], %)
|
||||
|> line([9.14, 0], %)
|
||||
|> tangentialArcTo([27.34, -3.08], %)`)
|
||||
|
||||
// click tangential arc tool again to unequip it
|
||||
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 ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await context.addInitScript(async () => {
|
||||
localStorage.setItem(
|
||||
'SETTINGS_PERSIST_KEY',
|
||||
JSON.stringify({
|
||||
baseUnit: 'mm',
|
||||
cameraControls: 'KittyCAD',
|
||||
defaultDirectory: '',
|
||||
defaultProjectName: 'project-$nnn',
|
||||
onboardingStatus: 'dismissed',
|
||||
showDebugPanel: true,
|
||||
textWrapping: 'On',
|
||||
theme: 'dark',
|
||||
unitSystem: 'metric',
|
||||
test.describe('Client side scene scale should match engine scale - Millimeters', () => {
|
||||
const storageState = structuredClone(basicStorageState)
|
||||
storageState.origins[0].localStorage[2].value = TOML.stringify({
|
||||
settings: {
|
||||
...basicSettings,
|
||||
modeling: {
|
||||
...basicSettings.modeling,
|
||||
defaultUnit: 'mm',
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
test.use({
|
||||
storageState,
|
||||
})
|
||||
|
||||
test('Millimeters', async ({ page }) => {
|
||||
const u = getUtils(page)
|
||||
await page.setViewportSize({ width: 1200, height: 500 })
|
||||
const PUR = 400 / 37.5 //pixeltoUnitRatio
|
||||
@ -590,7 +538,9 @@ test('Client side scene scale should match engine scale mm', async ({
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Start Sketch' })
|
||||
).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
|
||||
await u.clearCommandLogs()
|
||||
@ -657,6 +607,7 @@ test('Client side scene scale should match engine scale mm', async ({
|
||||
await expect(page).toHaveScreenshot({
|
||||
maxDiffPixels: 100,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('Sketch on face with none z-up', async ({ page, context }) => {
|
||||
|
After Width: | Height: | Size: 44 KiB |
After Width: | Height: | Size: 48 KiB |
After Width: | Height: | Size: 47 KiB |
After Width: | Height: | Size: 49 KiB |
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 52 KiB |
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 50 KiB |
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 51 KiB |
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 54 KiB |
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 49 KiB |
40
e2e/playwright/storageStates.ts
Normal 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 }),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
@ -33,7 +33,7 @@ async function clearCommandLogs(page: Page) {
|
||||
}
|
||||
|
||||
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) {
|
||||
|
@ -68,10 +68,10 @@ describe('ZMA (Tauri, Linux)', () => {
|
||||
const defaultDirInput = await $('[data-testid="default-directory-input"]')
|
||||
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')
|
||||
|
||||
const closeButton = await $('[data-testid="close-button"]')
|
||||
const closeButton = await $('[data-testid="settings-close-button"]')
|
||||
await click(closeButton)
|
||||
})
|
||||
|
||||
|
@ -10,6 +10,7 @@
|
||||
"@fortawesome/react-fontawesome": "^0.2.0",
|
||||
"@headlessui/react": "^1.7.18",
|
||||
"@headlessui/tailwindcss": "^0.2.0",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@kittycad/lib": "^0.0.56",
|
||||
"@lezer/javascript": "^1.4.9",
|
||||
"@open-rpc/client-js": "^1.8.1",
|
||||
@ -29,6 +30,7 @@
|
||||
"@xstate/react": "^3.2.2",
|
||||
"crypto-js": "^4.2.0",
|
||||
"debounce-promise": "^3.1.2",
|
||||
"decamelize": "^6.0.0",
|
||||
"formik": "^2.4.3",
|
||||
"fuse.js": "^7.0.0",
|
||||
"http-server": "^14.1.1",
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
import { basicStorageState } from './e2e/playwright/storageStates'
|
||||
|
||||
/**
|
||||
* 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 */
|
||||
trace: 'on-first-retry',
|
||||
|
||||
/* Use a common shared localStorage */
|
||||
storageState: basicStorageState,
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
|
18
src/App.tsx
@ -33,10 +33,10 @@ import { useModelingContext } from 'hooks/useModelingContext'
|
||||
import { useAbsoluteFilePath } from 'hooks/useAbsoluteFilePath'
|
||||
import { isTauri } from 'lib/isTauri'
|
||||
import { useLspContext } from 'components/LspProvider'
|
||||
import { useValidateSettings } from 'hooks/useValidateSettings'
|
||||
import { useRefreshSettings } from 'hooks/useRefreshSettings'
|
||||
|
||||
export function App() {
|
||||
useValidateSettings()
|
||||
useRefreshSettings(paths.FILE + 'SETTINGS')
|
||||
const { project, file } = useLoaderData() as IndexLoaderData
|
||||
const navigate = useNavigate()
|
||||
const filePath = useAbsoluteFilePath()
|
||||
@ -64,10 +64,14 @@ export function App() {
|
||||
}))
|
||||
|
||||
const { settings } = useSettingsAuthContext()
|
||||
const { showDebugPanel, onboardingStatus, theme } = settings?.context || {}
|
||||
const {
|
||||
modeling: { showDebugPanel },
|
||||
app: { theme, onboardingStatus },
|
||||
} = settings.context
|
||||
const { state, send } = useModelingContext()
|
||||
|
||||
const editorTheme = theme === Themes.System ? getSystemTheme() : theme
|
||||
const editorTheme =
|
||||
theme.current === Themes.System ? getSystemTheme() : theme.current
|
||||
|
||||
// Pane toggling keyboard shortcuts
|
||||
const togglePane = useCallback(
|
||||
@ -95,7 +99,7 @@ export function App() {
|
||||
)
|
||||
|
||||
const paneOpacity = [onboardingPaths.CAMERA, onboardingPaths.STREAMING].some(
|
||||
(p) => p === onboardingStatus
|
||||
(p) => p === onboardingStatus.current
|
||||
)
|
||||
? 'opacity-20'
|
||||
: didDragInStream
|
||||
@ -163,7 +167,7 @@ export function App() {
|
||||
handleClasses={{
|
||||
right:
|
||||
'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-auto'),
|
||||
}}
|
||||
@ -204,7 +208,7 @@ export function App() {
|
||||
</div>
|
||||
</Resizable>
|
||||
<Stream className="absolute inset-0 z-0" />
|
||||
{showDebugPanel && (
|
||||
{showDebugPanel.current && (
|
||||
<DebugPanel
|
||||
title="Debug"
|
||||
className={
|
||||
|
@ -22,19 +22,18 @@ import { paths } from 'lib/paths'
|
||||
import {
|
||||
fileLoader,
|
||||
homeLoader,
|
||||
indexLoader,
|
||||
onboardingRedirectLoader,
|
||||
settingsLoader,
|
||||
} from 'lib/routeLoaders'
|
||||
import { CommandBarProvider } from 'components/CommandBar/CommandBarProvider'
|
||||
import SettingsAuthProvider from 'components/SettingsAuthProvider'
|
||||
import LspProvider from 'components/LspProvider'
|
||||
import { KclContextProvider } from 'lang/KclProvider'
|
||||
|
||||
export const BROWSER_FILE_NAME = 'new'
|
||||
import { BROWSER_PROJECT_NAME } from 'lib/constants'
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
loader: indexLoader,
|
||||
loader: settingsLoader,
|
||||
id: paths.INDEX,
|
||||
element: (
|
||||
<CommandBarProvider>
|
||||
@ -47,14 +46,14 @@ const router = createBrowserRouter([
|
||||
</KclContextProvider>
|
||||
</CommandBarProvider>
|
||||
),
|
||||
errorElement: <ErrorPage />,
|
||||
children: [
|
||||
{
|
||||
path: paths.INDEX,
|
||||
loader: () =>
|
||||
isTauri()
|
||||
? redirect(paths.HOME)
|
||||
: redirect(paths.FILE + '/' + BROWSER_FILE_NAME),
|
||||
errorElement: <ErrorPage />,
|
||||
: redirect(paths.FILE + '/%2F' + BROWSER_PROJECT_NAME),
|
||||
},
|
||||
{
|
||||
loader: fileLoader,
|
||||
@ -73,23 +72,23 @@ const router = createBrowserRouter([
|
||||
{!isTauri() && import.meta.env.PROD && <DownloadAppBanner />}
|
||||
</Auth>
|
||||
),
|
||||
children: [
|
||||
{
|
||||
id: paths.FILE + 'SETTINGS',
|
||||
loader: settingsLoader,
|
||||
children: [
|
||||
{
|
||||
loader: onboardingRedirectLoader,
|
||||
index: true,
|
||||
element: <></>,
|
||||
},
|
||||
{
|
||||
children: [
|
||||
{
|
||||
path: makeUrlPathRelative(paths.SETTINGS),
|
||||
loader: indexLoader, // very rare someone will load into settings first, but it's possible in the browser
|
||||
element: <Settings />,
|
||||
},
|
||||
{
|
||||
path: makeUrlPathRelative(paths.ONBOARDING.INDEX),
|
||||
element: <Onboarding />,
|
||||
loader: indexLoader, // very rare someone will load into settings first, but it's possible in the browser
|
||||
children: onboardingRoutes,
|
||||
},
|
||||
],
|
||||
@ -108,8 +107,15 @@ const router = createBrowserRouter([
|
||||
id: paths.HOME,
|
||||
loader: homeLoader,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <></>,
|
||||
id: paths.HOME + 'SETTINGS',
|
||||
loader: settingsLoader,
|
||||
},
|
||||
{
|
||||
path: makeUrlPathRelative(paths.SETTINGS),
|
||||
loader: settingsLoader,
|
||||
element: <Settings />,
|
||||
},
|
||||
],
|
||||
|
@ -37,7 +37,7 @@ export const ClientSideScene = ({
|
||||
}: {
|
||||
cameraControls: ReturnType<
|
||||
typeof useSettingsAuthContext
|
||||
>['settings']['context']['cameraControls']
|
||||
>['settings']['context']['modeling']['mouseControls']['current']
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLDivElement>(null)
|
||||
const { state, send } = useModelingContext()
|
||||
|
@ -25,9 +25,9 @@ import * as TWEEN from '@tweenjs/tween.js'
|
||||
import { SourceRange } from 'lang/wasm'
|
||||
import { Axis } from 'lib/selections'
|
||||
import { type BaseUnit } from 'lib/settings/settingsTypes'
|
||||
import { SETTINGS_PERSIST_KEY } from 'lib/constants'
|
||||
import { CameraControls } from './CameraControls'
|
||||
import { EngineCommandManager } from 'lang/std/engineConnection'
|
||||
import { settings } from 'lib/settings/initialSettings'
|
||||
|
||||
type SendType = ReturnType<typeof useModelingContext>['send']
|
||||
|
||||
@ -170,9 +170,7 @@ export class SceneInfra {
|
||||
|
||||
// CAMERA
|
||||
const camHeightDistanceRatio = 0.5
|
||||
const baseUnit: BaseUnit =
|
||||
JSON.parse(localStorage?.getItem(SETTINGS_PERSIST_KEY) || ('{}' as any))
|
||||
.baseUnit || 'mm'
|
||||
const baseUnit: BaseUnit = settings.modeling.defaultUnit.current
|
||||
const baseRadius = 5.6
|
||||
const length = baseUnitTomm(baseUnit) * baseRadius
|
||||
const ang = Math.atan(camHeightDistanceRatio)
|
||||
|
@ -1,29 +1,35 @@
|
||||
import { Combobox } from '@headlessui/react'
|
||||
import { useSelector } from '@xstate/react'
|
||||
import Fuse from 'fuse.js'
|
||||
import { useCommandsContext } from 'hooks/useCommandsContext'
|
||||
import { CommandArgument, CommandArgumentOption } from 'lib/commandTypes'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { AnyStateMachine, StateFrom } from 'xstate'
|
||||
|
||||
const contextSelector = (snapshot: StateFrom<AnyStateMachine>) =>
|
||||
snapshot.context
|
||||
|
||||
function CommandArgOptionInput({
|
||||
options,
|
||||
arg,
|
||||
argName,
|
||||
stepBack,
|
||||
onSubmit,
|
||||
placeholder,
|
||||
}: {
|
||||
options: (CommandArgument<unknown> & { inputType: 'options' })['options']
|
||||
arg: CommandArgument<unknown> & { inputType: 'options' }
|
||||
argName: string
|
||||
stepBack: () => void
|
||||
onSubmit: (data: unknown) => void
|
||||
placeholder?: string
|
||||
}) {
|
||||
const actorContext = useSelector(arg.machineActor, contextSelector)
|
||||
const { commandBarSend, commandBarState } = useCommandsContext()
|
||||
const resolvedOptions = useMemo(
|
||||
() =>
|
||||
typeof options === 'function'
|
||||
? options(commandBarState.context)
|
||||
: options,
|
||||
[argName, options, commandBarState.context]
|
||||
typeof arg.options === 'function'
|
||||
? arg.options(commandBarState.context, actorContext)
|
||||
: arg.options,
|
||||
[argName, arg, commandBarState.context, actorContext]
|
||||
)
|
||||
// The initial current option is either an already-input value or the configured default
|
||||
const currentOption = useMemo(
|
||||
@ -38,7 +44,7 @@ function CommandArgOptionInput({
|
||||
const [selectedOption, setSelectedOption] = useState<
|
||||
CommandArgumentOption<unknown>
|
||||
>(currentOption || resolvedOptions[0])
|
||||
const initialQuery = useMemo(() => '', [options, argName])
|
||||
const initialQuery = useMemo(() => '', [arg.options, argName])
|
||||
const [query, setQuery] = useState(initialQuery)
|
||||
const [filteredOptions, setFilteredOptions] =
|
||||
useState<typeof resolvedOptions>()
|
||||
|
@ -51,7 +51,24 @@ function ArgumentInput({
|
||||
case 'options':
|
||||
return (
|
||||
<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}
|
||||
stepBack={stepBack}
|
||||
onSubmit={onSubmit}
|
||||
|
@ -74,7 +74,7 @@ function CommandBarHeader({ children }: React.PropsWithChildren<{}>) {
|
||||
selectedCommand.icon && (
|
||||
<CustomIcon name={selectedCommand.icon} className="w-5 h-5" />
|
||||
)}
|
||||
{selectedCommand?.name}
|
||||
{selectedCommand.displayName || selectedCommand.name}
|
||||
</p>
|
||||
{Object.entries(selectedCommand?.args || {})
|
||||
.filter(([_, argConfig]) =>
|
||||
|
@ -76,9 +76,9 @@ function CommandBarKclInput({
|
||||
},
|
||||
accessKey: 'command-bar',
|
||||
theme:
|
||||
settings.context.theme === 'system'
|
||||
settings.context.app.theme.current === 'system'
|
||||
? getSystemTheme()
|
||||
: settings.context.theme,
|
||||
: settings.context.app.theme.current,
|
||||
extensions: [
|
||||
EditorView.domEventHandlers({
|
||||
keydown: (event) => {
|
||||
|
@ -20,7 +20,7 @@ function CommandComboBox({
|
||||
options.find((o) => 'isCurrent' in o && o.isCurrent) || null
|
||||
|
||||
const fuse = new Fuse(options, {
|
||||
keys: ['name', 'description'],
|
||||
keys: ['displayName', 'name', 'description'],
|
||||
threshold: 0.3,
|
||||
})
|
||||
|
||||
@ -80,7 +80,12 @@ function CommandComboBox({
|
||||
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.Options>
|
||||
|
@ -25,7 +25,9 @@ export type CustomIconName =
|
||||
| 'network'
|
||||
| 'networkCrossedOut'
|
||||
| 'parallel'
|
||||
| 'person'
|
||||
| 'plus'
|
||||
| 'refresh'
|
||||
| 'search'
|
||||
| 'settings'
|
||||
| 'sketch'
|
||||
@ -453,6 +455,22 @@ export const CustomIcon = ({
|
||||
/>
|
||||
</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':
|
||||
return (
|
||||
<svg
|
||||
@ -462,13 +480,29 @@ export const CustomIcon = ({
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M9.5 9.5V5.5H10.5V9.5H14.5V10.5H10.5V14.5H9.5V10.5H5.5V9.5H9.5Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</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':
|
||||
return (
|
||||
<svg
|
||||
|
@ -13,7 +13,7 @@ import {
|
||||
StateFrom,
|
||||
} from 'xstate'
|
||||
import { useCommandsContext } from 'hooks/useCommandsContext'
|
||||
import { DEFAULT_FILE_NAME, fileMachine } from 'machines/fileMachine'
|
||||
import { fileMachine } from 'machines/fileMachine'
|
||||
import {
|
||||
createDir,
|
||||
removeDir,
|
||||
@ -21,9 +21,10 @@ import {
|
||||
renameFile,
|
||||
writeFile,
|
||||
} from '@tauri-apps/api/fs'
|
||||
import { FILE_EXT, readProject } from 'lib/tauriFS'
|
||||
import { readProject } from 'lib/tauriFS'
|
||||
import { isTauri } from 'lib/isTauri'
|
||||
import { sep } from '@tauri-apps/api/path'
|
||||
import { DEFAULT_FILE_NAME, FILE_EXT } from 'lib/constants'
|
||||
|
||||
type MachineContext<T extends AnyStateMachine> = {
|
||||
state: StateFrom<T>
|
||||
|
@ -11,7 +11,8 @@ import { faChevronRight, faTrashAlt } from '@fortawesome/free-solid-svg-icons'
|
||||
import { useFileContext } from 'hooks/useFileContext'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
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 { kclManager } from 'lib/singletons'
|
||||
import { useDocumentHasFocus } from 'hooks/useDocumentHasFocus'
|
||||
|
@ -66,13 +66,16 @@ export const ModelingMachineProvider = ({
|
||||
const {
|
||||
auth,
|
||||
settings: {
|
||||
context: { baseUnit, theme },
|
||||
context: {
|
||||
app: { theme },
|
||||
modeling: { defaultUnit },
|
||||
},
|
||||
},
|
||||
} = useSettingsAuthContext()
|
||||
const { code } = useKclContext()
|
||||
const token = auth?.context?.token
|
||||
const streamRef = useRef<HTMLDivElement>(null)
|
||||
useSetupEngineManager(streamRef, token, theme)
|
||||
useSetupEngineManager(streamRef, token, theme.current)
|
||||
|
||||
const {
|
||||
isShiftDown,
|
||||
@ -234,7 +237,7 @@ export const ModelingMachineProvider = ({
|
||||
format.type === 'stl' ||
|
||||
format.type === 'ply'
|
||||
) {
|
||||
format.units = baseUnit
|
||||
format.units = defaultUnit.current
|
||||
}
|
||||
|
||||
if (format.type === 'ply' || format.type === 'stl') {
|
||||
|
@ -9,7 +9,8 @@ import {
|
||||
faTrashAlt,
|
||||
faX,
|
||||
} 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 { useHotkeys } from 'react-hotkeys-hook'
|
||||
|
||||
|
@ -6,12 +6,9 @@ import withBaseUrl from '../lib/withBaseURL'
|
||||
import React, { createContext, useEffect } from 'react'
|
||||
import useStateMachineCommands from '../hooks/useStateMachineCommands'
|
||||
import { settingsMachine } from 'machines/settingsMachine'
|
||||
import {
|
||||
fallbackLoadedSettings,
|
||||
validateSettings,
|
||||
} from 'lib/settings/settingsUtils'
|
||||
import { toast } from 'react-hot-toast'
|
||||
import { getThemeColorForEngine, setThemeClass, Themes } from 'lib/theme'
|
||||
import decamelize from 'decamelize'
|
||||
import {
|
||||
AnyStateMachine,
|
||||
ContextFrom,
|
||||
@ -20,10 +17,19 @@ import {
|
||||
StateFrom,
|
||||
} from 'xstate'
|
||||
import { isTauri } from 'lib/isTauri'
|
||||
import { settingsCommandBarConfig } from 'lib/commandBarConfigs/settingsCommandConfig'
|
||||
import { authCommandBarConfig } from 'lib/commandBarConfigs/authCommandConfig'
|
||||
import { kclManager, sceneInfra, engineCommandManager } from 'lib/singletons'
|
||||
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> = {
|
||||
state: StateFrom<T>
|
||||
@ -49,11 +55,13 @@ export const SettingsAuthProvider = ({
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) => {
|
||||
const loadedSettings = useRouteLoaderData(paths.INDEX) as Awaited<
|
||||
ReturnType<typeof validateSettings>
|
||||
>
|
||||
const loadedSettings = useRouteLoaderData(paths.INDEX) as typeof settings
|
||||
const loadedProject = useRouteLoaderData(paths.FILE) as IndexLoaderData
|
||||
return (
|
||||
<SettingsAuthProviderBase loadedSettings={loadedSettings}>
|
||||
<SettingsAuthProviderBase
|
||||
loadedSettings={loadedSettings}
|
||||
loadedProject={loadedProject}
|
||||
>
|
||||
{children}
|
||||
</SettingsAuthProviderBase>
|
||||
)
|
||||
@ -66,7 +74,7 @@ export const SettingsAuthProviderJest = ({
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) => {
|
||||
const loadedSettings = fallbackLoadedSettings
|
||||
const loadedSettings = settings
|
||||
return (
|
||||
<SettingsAuthProviderBase loadedSettings={loadedSettings}>
|
||||
{children}
|
||||
@ -77,23 +85,25 @@ export const SettingsAuthProviderJest = ({
|
||||
export const SettingsAuthProviderBase = ({
|
||||
children,
|
||||
loadedSettings,
|
||||
loadedProject,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
loadedSettings: Awaited<ReturnType<typeof validateSettings>>
|
||||
loadedSettings: typeof settings
|
||||
loadedProject?: IndexLoaderData
|
||||
}) => {
|
||||
const { settings: initialLoadedContext } = loadedSettings
|
||||
const navigate = useNavigate()
|
||||
const { commandBarSend } = useCommandsContext()
|
||||
|
||||
const [settingsState, settingsSend, settingsActor] = useMachine(
|
||||
settingsMachine,
|
||||
{
|
||||
context: initialLoadedContext,
|
||||
context: loadedSettings,
|
||||
actions: {
|
||||
setClientSideSceneUnits: (context, event) => {
|
||||
const newBaseUnit =
|
||||
event.type === 'Set Base Unit'
|
||||
? event.data.baseUnit
|
||||
: context.baseUnit
|
||||
event.type === 'set.modeling.defaultUnit'
|
||||
? (event.data.value as BaseUnit)
|
||||
: context.modeling.defaultUnit.current
|
||||
sceneInfra.baseUnit = newBaseUnit
|
||||
},
|
||||
setEngineTheme: (context) => {
|
||||
@ -102,39 +112,76 @@ export const SettingsAuthProviderBase = ({
|
||||
type: 'modeling_cmd_req',
|
||||
cmd: {
|
||||
type: 'set_background_color',
|
||||
color: getThemeColorForEngine(context.theme),
|
||||
color: getThemeColorForEngine(context.app.theme.current),
|
||||
},
|
||||
})
|
||||
},
|
||||
toastSuccess: (context, event) => {
|
||||
const truncatedNewValue =
|
||||
'data' in event && event.data instanceof Object
|
||||
? (context[Object.keys(event.data)[0] as keyof typeof context]
|
||||
.toString()
|
||||
.substring(0, 28) as any)
|
||||
: undefined
|
||||
toast.success(
|
||||
event.type +
|
||||
const eventParts = event.type.replace(/^set./, '').split('.') as [
|
||||
keyof typeof settings,
|
||||
string
|
||||
]
|
||||
const truncatedNewValue = event.data.value?.toString().slice(0, 28)
|
||||
const message =
|
||||
`Set ${decamelize(eventParts[1], { separator: ' ' })}` +
|
||||
(truncatedNewValue
|
||||
? ` to "${truncatedNewValue}${
|
||||
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(),
|
||||
persistSettings: (context) =>
|
||||
saveSettings(context, loadedProject?.project?.path),
|
||||
},
|
||||
}
|
||||
)
|
||||
settingsStateRef = settingsState.context
|
||||
|
||||
useStateMachineCommands({
|
||||
machineId: 'settings',
|
||||
state: settingsState,
|
||||
// Add settings commands to the command bar
|
||||
// They're treated slightly differently than other commands
|
||||
// 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,
|
||||
commandBarConfig: settingsCommandBarConfig,
|
||||
context: settingsState.context,
|
||||
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
|
||||
// This is only done if the theme setting is set to 'system'.
|
||||
@ -144,7 +191,7 @@ export const SettingsAuthProviderBase = ({
|
||||
useEffect(() => {
|
||||
const matcher = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
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)
|
||||
}
|
||||
|
||||
|
@ -106,7 +106,9 @@ export const Stream = ({ className = '' }: { className?: string }) => {
|
||||
style={{ transitionDuration: '200ms', transitionProperty: 'filter' }}
|
||||
id="video-stream"
|
||||
/>
|
||||
<ClientSideScene cameraControls={settings.context?.cameraControls} />
|
||||
<ClientSideScene
|
||||
cameraControls={settings.context.modeling.mouseControls.current}
|
||||
/>
|
||||
{!isNetworkOkay && !isLoading && (
|
||||
<div className="text-center absolute inset-0">
|
||||
<Loading>
|
||||
|
@ -81,7 +81,7 @@ export const TextEditor = ({
|
||||
} = useModelingContext()
|
||||
|
||||
const { settings } = useSettingsAuthContext()
|
||||
const textWrapping = settings.context?.textWrapping ?? 'On'
|
||||
const textWrapping = settings.context.textEditor.textWrapping
|
||||
const { commandBarSend } = useCommandsContext()
|
||||
const { enable: convertEnabled, handleClick: convertCallback } =
|
||||
useConvertToVariable()
|
||||
@ -218,11 +218,11 @@ export const TextEditor = ({
|
||||
],
|
||||
})
|
||||
)
|
||||
if (textWrapping === 'On') extensions.push(EditorView.lineWrapping)
|
||||
if (textWrapping.current) extensions.push(EditorView.lineWrapping)
|
||||
}
|
||||
|
||||
return extensions
|
||||
}, [kclLSP, textWrapping, convertCallback])
|
||||
}, [kclLSP, textWrapping.current, convertCallback])
|
||||
|
||||
return (
|
||||
<div
|
||||
|
@ -1,14 +1,11 @@
|
||||
import { BROWSER_FILE_NAME } from 'Router'
|
||||
import { type IndexLoaderData } from 'lib/types'
|
||||
import { paths } from 'lib/paths'
|
||||
import { BROWSER_PATH, paths } from 'lib/paths'
|
||||
import { useRouteLoaderData } from 'react-router-dom'
|
||||
|
||||
export function useAbsoluteFilePath() {
|
||||
const routeData = useRouteLoaderData(paths.FILE) as IndexLoaderData
|
||||
|
||||
return (
|
||||
paths.FILE +
|
||||
'/' +
|
||||
encodeURIComponent(routeData?.file?.path || BROWSER_FILE_NAME)
|
||||
paths.FILE + '/' + encodeURIComponent(routeData?.file?.path || BROWSER_PATH)
|
||||
)
|
||||
}
|
||||
|
28
src/hooks/useRefreshSettings.ts
Normal 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,
|
||||
})
|
||||
}, [])
|
||||
}
|
@ -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])
|
||||
}
|
@ -19,10 +19,10 @@ root.render(
|
||||
<HotkeysProvider>
|
||||
<Router />
|
||||
<Toaster
|
||||
position="top-center"
|
||||
position="bottom-center"
|
||||
toastOptions={{
|
||||
style: {
|
||||
borderRadius: '0.25rem',
|
||||
borderRadius: '3px',
|
||||
},
|
||||
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',
|
||||
|
@ -88,7 +88,6 @@ export class KclManager {
|
||||
setTimeout(() => {
|
||||
// Wait one event loop to give a chance for params to be set
|
||||
// Save the file to disk
|
||||
// Note that PROJECT_ENTRYPOINT is hardcoded until we support multiple files
|
||||
this._params.id &&
|
||||
writeTextFile(this._params.id, code).catch((err) => {
|
||||
// TODO: add tracing per GH issue #254 (https://github.com/KittyCAD/modeling-app/issues/254)
|
||||
|
@ -154,7 +154,8 @@ export const _executor = async (
|
||||
isMock: boolean
|
||||
): Promise<ProgramMemory> => {
|
||||
try {
|
||||
const baseUnit = (await getSettingsState)()?.baseUnit || 'mm'
|
||||
const baseUnit =
|
||||
(await getSettingsState)()?.modeling.defaultUnit.current || 'mm'
|
||||
const memory: ProgramMemory = await execute_wasm(
|
||||
JSON.stringify(node),
|
||||
JSON.stringify(programMemory),
|
||||
|
@ -1,146 +1,124 @@
|
||||
import { type CommandSetConfig } from '../commandTypes'
|
||||
import {
|
||||
type BaseUnit,
|
||||
type Toggle,
|
||||
UnitSystem,
|
||||
baseUnitsUnion,
|
||||
Command,
|
||||
CommandArgument,
|
||||
CommandArgumentConfig,
|
||||
} from '../commandTypes'
|
||||
import {
|
||||
SettingsPaths,
|
||||
SettingsLevel,
|
||||
SettingProps,
|
||||
} from 'lib/settings/settingsTypes'
|
||||
import { settingsMachine } from 'machines/settingsMachine'
|
||||
import { type CameraSystem, cameraSystems } from '../cameraControls'
|
||||
import { Themes } from '../theme'
|
||||
import { PathValue } from 'lib/types'
|
||||
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
|
||||
export type SettingsCommandSchema = {
|
||||
'Set Base Unit': {
|
||||
baseUnit: BaseUnit
|
||||
}
|
||||
'Set Camera Controls': {
|
||||
cameraControls: CameraSystem
|
||||
}
|
||||
'Set Default Project Name': {
|
||||
defaultProjectName: string
|
||||
}
|
||||
'Set Text Wrapping': {
|
||||
textWrapping: Toggle
|
||||
}
|
||||
'Set Theme': {
|
||||
theme: Themes
|
||||
}
|
||||
'Set Unit System': {
|
||||
unitSystem: UnitSystem
|
||||
}
|
||||
// An array of the paths to all of the settings that have commandConfigs
|
||||
export const settingsWithCommandConfigs = (
|
||||
s: ContextFrom<typeof settingsMachine>
|
||||
) =>
|
||||
Object.entries(s).flatMap(([categoryName, categorySettings]) =>
|
||||
Object.entries(categorySettings)
|
||||
.filter(([_, setting]) => setting.commandConfig !== undefined)
|
||||
.map(([settingName]) => `${categoryName}.${settingName}`)
|
||||
) as SettingsPaths[]
|
||||
|
||||
const levelArgConfig = <T extends AnyStateMachine = AnyStateMachine>(
|
||||
actor: InterpreterFrom<T>,
|
||||
isProjectAvailable: boolean,
|
||||
hideOnLevel?: SettingsLevel
|
||||
): CommandArgument<SettingsLevel, T> => ({
|
||||
inputType: 'options' as const,
|
||||
required: true,
|
||||
defaultValue:
|
||||
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<
|
||||
typeof settingsMachine,
|
||||
SettingsCommandSchema
|
||||
> = {
|
||||
'Set Base Unit': {
|
||||
icon: 'settings',
|
||||
args: {
|
||||
baseUnit: {
|
||||
inputType: 'options',
|
||||
// Takes a Setting with a commandConfig and creates a Command
|
||||
// that can be used in the CommandBar component.
|
||||
export function createSettingsCommand({
|
||||
type,
|
||||
send,
|
||||
context,
|
||||
actor,
|
||||
isProjectAvailable,
|
||||
}: 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,
|
||||
defaultValueFromContext: (context) => context.baseUnit,
|
||||
options: [],
|
||||
optionsFromContext: (context) =>
|
||||
Object.values(baseUnitsUnion).map((v) => ({
|
||||
name: v,
|
||||
value: v,
|
||||
isCurrent: v === context.baseUnit,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
'Set Camera Controls': {
|
||||
} as CommandArgumentConfig<S['default']>
|
||||
|
||||
// @ts-ignore - TODO figure out this typing for valueArgConfig
|
||||
const valueArg = buildCommandArgument(valueArgConfig, context, actor)
|
||||
|
||||
const command: Command = {
|
||||
name: type,
|
||||
displayName: `Settings · ${decamelize(type.replaceAll('.', ' · '), {
|
||||
separator: ' ',
|
||||
})}`,
|
||||
ownerMachine: 'settings',
|
||||
icon: 'settings',
|
||||
needsReview: false,
|
||||
onSubmit: (data) => {
|
||||
if (data !== undefined && data !== null) {
|
||||
send({ type: `set.${type}`, data })
|
||||
} else {
|
||||
send(type)
|
||||
}
|
||||
},
|
||||
args: {
|
||||
cameraControls: {
|
||||
inputType: 'options',
|
||||
required: true,
|
||||
defaultValueFromContext: (context) => context.cameraControls,
|
||||
options: [],
|
||||
optionsFromContext: (context) =>
|
||||
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',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
level: levelArgConfig(
|
||||
actor,
|
||||
isProjectAvailable,
|
||||
settingConfig.hideOnLevel
|
||||
),
|
||||
value: valueArg,
|
||||
},
|
||||
}
|
||||
|
||||
return command
|
||||
}
|
||||
|
@ -12,7 +12,13 @@ import { commandBarMachine } from 'machines/commandBarMachine'
|
||||
|
||||
type Icon = CustomIconName
|
||||
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 {
|
||||
valueAst: Value
|
||||
valueText: string
|
||||
@ -66,6 +72,7 @@ export type Command<
|
||||
args?: {
|
||||
[ArgName in keyof CommandSchema]: CommandArgument<CommandSchema[ArgName], T>
|
||||
}
|
||||
displayName?: string
|
||||
description?: string
|
||||
icon?: Icon
|
||||
hide?: (typeof PLATFORMS)[number]
|
||||
@ -83,57 +90,70 @@ export type CommandConfig<
|
||||
args?: {
|
||||
[ArgName in keyof CommandSchema]: CommandArgumentConfig<
|
||||
CommandSchema[ArgName],
|
||||
T
|
||||
ContextFrom<T>
|
||||
>
|
||||
}
|
||||
}
|
||||
|
||||
export type CommandArgumentConfig<
|
||||
OutputType,
|
||||
T extends AnyStateMachine = AnyStateMachine
|
||||
C = ContextFrom<AnyStateMachine>
|
||||
> =
|
||||
| {
|
||||
description?: string
|
||||
required:
|
||||
| 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)
|
||||
skip?: boolean
|
||||
} & (
|
||||
| {
|
||||
inputType: Extract<CommandInputType, 'options'>
|
||||
inputType: 'options'
|
||||
options:
|
||||
| CommandArgumentOption<OutputType>[]
|
||||
| ((
|
||||
commandBarContext: {
|
||||
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>[])
|
||||
optionsFromContext?: (
|
||||
context: ContextFrom<T>
|
||||
context: C
|
||||
) => CommandArgumentOption<OutputType>[]
|
||||
defaultValue?:
|
||||
| OutputType
|
||||
| ((
|
||||
commandBarContext: ContextFrom<typeof commandBarMachine>
|
||||
commandBarContext: ContextFrom<typeof commandBarMachine>,
|
||||
machineContext?: C
|
||||
) => OutputType)
|
||||
defaultValueFromContext?: (context: ContextFrom<T>) => OutputType
|
||||
defaultValueFromContext?: (context: C) => OutputType
|
||||
}
|
||||
| {
|
||||
inputType: Extract<CommandInputType, 'selection'>
|
||||
inputType: 'selection'
|
||||
selectionTypes: Selection['type'][]
|
||||
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?:
|
||||
| OutputType
|
||||
| ((
|
||||
commandBarContext: ContextFrom<typeof commandBarMachine>
|
||||
) => OutputType)
|
||||
defaultValueFromContext?: (context: ContextFrom<T>) => OutputType
|
||||
defaultValueFromContext?: (context: C) => OutputType
|
||||
}
|
||||
)
|
||||
|
||||
@ -146,7 +166,8 @@ export type CommandArgument<
|
||||
required:
|
||||
| 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)
|
||||
skip?: boolean
|
||||
machineActor: InterpreterFrom<T>
|
||||
@ -158,26 +179,38 @@ export type CommandArgument<
|
||||
| ((
|
||||
commandBarContext: {
|
||||
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>[])
|
||||
defaultValue?:
|
||||
| OutputType
|
||||
| ((
|
||||
commandBarContext: ContextFrom<typeof commandBarMachine>
|
||||
commandBarContext: ContextFrom<typeof commandBarMachine>,
|
||||
machineContext?: ContextFrom<T>
|
||||
) => OutputType)
|
||||
}
|
||||
| {
|
||||
inputType: Extract<CommandInputType, 'selection'>
|
||||
inputType: 'selection'
|
||||
selectionTypes: Selection['type'][]
|
||||
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?:
|
||||
| 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)
|
||||
}
|
||||
)
|
||||
|
@ -1,4 +1,44 @@
|
||||
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 SETTINGS_PERSIST_KEY = 'SETTINGS_PERSIST_KEY'
|
||||
export const SETTINGS_FILE_NAME = 'settings.json'
|
||||
/** The file name for settings files, both at the user and project level */
|
||||
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
|
||||
|
@ -1,4 +1,10 @@
|
||||
import { AnyStateMachine, EventFrom, InterpreterFrom, StateFrom } from 'xstate'
|
||||
import {
|
||||
AnyStateMachine,
|
||||
ContextFrom,
|
||||
EventFrom,
|
||||
InterpreterFrom,
|
||||
StateFrom,
|
||||
} from 'xstate'
|
||||
import { isTauri } from './isTauri'
|
||||
import {
|
||||
Command,
|
||||
@ -97,20 +103,19 @@ function buildCommandArguments<
|
||||
|
||||
for (const arg in args) {
|
||||
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
|
||||
}
|
||||
|
||||
return newArgs
|
||||
}
|
||||
|
||||
function buildCommandArgument<
|
||||
O extends CommandSetSchema<T>,
|
||||
T extends AnyStateMachine
|
||||
export function buildCommandArgument<
|
||||
T extends AnyStateMachine,
|
||||
O extends CommandSetSchema<T> = CommandSetSchema<T>
|
||||
>(
|
||||
arg: CommandArgumentConfig<O, T>,
|
||||
argName: string,
|
||||
state: StateFrom<T>,
|
||||
context: ContextFrom<T>,
|
||||
machineActor: InterpreterFrom<T>
|
||||
): CommandArgument<O, T> & { inputType: typeof arg.inputType } {
|
||||
const baseCommandArgument = {
|
||||
@ -121,7 +126,7 @@ function buildCommandArgument<
|
||||
} satisfies Omit<CommandArgument<O, T>, 'inputType'>
|
||||
|
||||
if (arg.inputType === 'options') {
|
||||
if (!arg.options) {
|
||||
if (!(arg.options || arg.optionsFromContext)) {
|
||||
throw new Error('Options must be provided for options input type')
|
||||
}
|
||||
|
||||
@ -129,10 +134,10 @@ function buildCommandArgument<
|
||||
inputType: arg.inputType,
|
||||
...baseCommandArgument,
|
||||
defaultValue: arg.defaultValueFromContext
|
||||
? arg.defaultValueFromContext(state.context)
|
||||
? arg.defaultValueFromContext(context)
|
||||
: arg.defaultValue,
|
||||
options: arg.optionsFromContext
|
||||
? arg.optionsFromContext(state.context)
|
||||
? arg.optionsFromContext(context)
|
||||
: arg.options,
|
||||
} satisfies CommandArgument<O, T> & { inputType: 'options' }
|
||||
} else if (arg.inputType === 'selection') {
|
||||
@ -151,7 +156,9 @@ function buildCommandArgument<
|
||||
} else {
|
||||
return {
|
||||
inputType: arg.inputType,
|
||||
defaultValue: arg.defaultValue,
|
||||
defaultValue: arg.defaultValueFromContext
|
||||
? arg.defaultValueFromContext(context)
|
||||
: arg.defaultValue,
|
||||
...baseCommandArgument,
|
||||
}
|
||||
}
|
||||
|
36
src/lib/objectPropertyByPath.ts
Normal 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
|
||||
}
|
@ -1,4 +1,7 @@
|
||||
import { sep } from '@tauri-apps/api/path'
|
||||
import { onboardingPaths } from 'routes/Onboarding/paths'
|
||||
import { BROWSER_FILE_NAME, BROWSER_PROJECT_NAME, FILE_EXT } from './constants'
|
||||
import { isTauri } from './isTauri'
|
||||
|
||||
const prependRoutes =
|
||||
(routesObject: Record<string, string>) => (prepend: string) => {
|
||||
@ -19,4 +22,31 @@ export const paths = {
|
||||
ONBOARDING: prependRoutes(onboardingPaths)(
|
||||
'/onboarding'
|
||||
) 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,
|
||||
}
|
||||
}
|
||||
|
@ -1,15 +1,18 @@
|
||||
import { ActionFunction, LoaderFunction, redirect } from 'react-router-dom'
|
||||
import { HomeLoaderData, IndexLoaderData } from './types'
|
||||
import { FileLoaderData, HomeLoaderData, IndexLoaderData } from './types'
|
||||
import { isTauri } from './isTauri'
|
||||
import { paths } from './paths'
|
||||
import { BROWSER_FILE_NAME } from 'Router'
|
||||
import { SETTINGS_PERSIST_KEY } from 'lib/constants'
|
||||
import { getProjectMetaByRouteId, paths } from './paths'
|
||||
import { BROWSER_PATH } from 'lib/paths'
|
||||
import {
|
||||
BROWSER_FILE_NAME,
|
||||
BROWSER_PROJECT_NAME,
|
||||
PROJECT_ENTRYPOINT,
|
||||
} from 'lib/constants'
|
||||
import { loadAndValidateSettings } from './settings/settingsUtils'
|
||||
import {
|
||||
getInitialDefaultDir,
|
||||
getProjectsInDir,
|
||||
initializeProjectDirectory,
|
||||
PROJECT_ENTRYPOINT,
|
||||
} from './tauriFS'
|
||||
import makeUrlPathRelative from './makeUrlPathRelative'
|
||||
import { sep } from '@tauri-apps/api/path'
|
||||
@ -20,17 +23,32 @@ import { fileSystemManager } from 'lang/std/fileSystemManager'
|
||||
|
||||
// The root loader simply resolves the settings and any errors that
|
||||
// occurred during the settings load
|
||||
export const indexLoader: LoaderFunction = async (): ReturnType<
|
||||
typeof loadAndValidateSettings
|
||||
> => {
|
||||
return await loadAndValidateSettings()
|
||||
export const settingsLoader: LoaderFunction = async ({
|
||||
params,
|
||||
}): ReturnType<typeof 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
|
||||
export const onboardingRedirectLoader: ActionFunction = async ({ request }) => {
|
||||
const { settings } = await loadAndValidateSettings()
|
||||
const onboardingStatus = settings.onboardingStatus || ''
|
||||
const notEnRouteToOnboarding = !request.url.includes(paths.ONBOARDING.INDEX)
|
||||
export const onboardingRedirectLoader: ActionFunction = async (args) => {
|
||||
const settings = await loadAndValidateSettings()
|
||||
const onboardingStatus = settings.app.onboardingStatus.current || ''
|
||||
const notEnRouteToOnboarding = !args.request.url.includes(
|
||||
paths.ONBOARDING.INDEX
|
||||
)
|
||||
// '' is the initial state, 'done' and 'dismissed' are the final states
|
||||
const hasValidOnboardingStatus =
|
||||
onboardingStatus.length === 0 ||
|
||||
@ -44,34 +62,33 @@ export const onboardingRedirectLoader: ActionFunction = async ({ request }) => {
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
return settingsLoader(args)
|
||||
}
|
||||
|
||||
export const fileLoader: LoaderFunction = async ({
|
||||
params,
|
||||
}): Promise<IndexLoaderData | Response> => {
|
||||
const { settings } = await loadAndValidateSettings()
|
||||
}): Promise<FileLoaderData | Response> => {
|
||||
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) {
|
||||
const decodedId = decodeURIComponent(params.id)
|
||||
const projectAndFile = decodedId.replace(defaultDir + sep, '')
|
||||
const firstSlashIndex = projectAndFile.indexOf(sep)
|
||||
const projectName = projectAndFile.slice(0, firstSlashIndex)
|
||||
const projectPath = defaultDir + sep + projectName
|
||||
const currentFileName = projectAndFile.slice(firstSlashIndex + 1)
|
||||
if (!isBrowserProject && projectPathData) {
|
||||
const { projectName, projectPath, currentFileName, currentFilePath } =
|
||||
projectPathData
|
||||
|
||||
if (firstSlashIndex === -1 || !currentFileName)
|
||||
if (!currentFileName || !currentFilePath) {
|
||||
return redirect(
|
||||
`${paths.FILE}/${encodeURIComponent(
|
||||
`${params.id}${sep}${PROJECT_ENTRYPOINT}`
|
||||
`${params.id}${isTauri() ? sep : '/'}${PROJECT_ENTRYPOINT}`
|
||||
)}`
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: PROJECT_ENTRYPOINT is hardcoded
|
||||
// until we support setting a project's entrypoint file
|
||||
const code = await readTextFile(decodedId)
|
||||
const code = await readTextFile(currentFilePath)
|
||||
const entrypointMetadata = await metadata(
|
||||
projectPath + sep + PROJECT_ENTRYPOINT
|
||||
)
|
||||
@ -82,7 +99,7 @@ export const fileLoader: LoaderFunction = async ({
|
||||
// So that WASM gets an updated path for operations
|
||||
fileSystemManager.dir = projectPath
|
||||
|
||||
return {
|
||||
const projectData: IndexLoaderData = {
|
||||
code,
|
||||
project: {
|
||||
name: projectName,
|
||||
@ -92,13 +109,26 @@ export const fileLoader: LoaderFunction = async ({
|
||||
},
|
||||
file: {
|
||||
name: currentFileName,
|
||||
path: params.id,
|
||||
path: currentFilePath,
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
...projectData,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
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
|
||||
> => {
|
||||
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(
|
||||
settings.defaultDirectory || (await getInitialDefaultDir())
|
||||
settings.app.projectDirectory.current || (await getInitialDefaultDir())
|
||||
)
|
||||
|
||||
if (projectDir.path) {
|
||||
if (projectDir.path !== settings.defaultDirectory) {
|
||||
localStorage.setItem(
|
||||
SETTINGS_PERSIST_KEY,
|
||||
JSON.stringify({
|
||||
...settings,
|
||||
defaultDirectory: projectDir,
|
||||
})
|
||||
)
|
||||
}
|
||||
const projects = await getProjectsInDir(projectDir.path)
|
||||
|
||||
return {
|
||||
|
@ -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,
|
||||
}
|
383
src/lib/settings/initialSettings.tsx
Normal 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()
|
@ -1,6 +1,8 @@
|
||||
import { type Models } from '@kittycad/lib'
|
||||
import { type CameraSystem } from '../cameraControls'
|
||||
import { Themes } from 'lib/theme'
|
||||
import { Setting, settings } from './initialSettings'
|
||||
import { AtLeast, PathValue, Paths } from 'lib/types'
|
||||
import { ChangeEventHandler } from 'react'
|
||||
import { CommandArgumentConfig } from 'lib/commandTypes'
|
||||
|
||||
export enum UnitSystem {
|
||||
Imperial = 'imperial',
|
||||
@ -19,14 +21,100 @@ export const baseUnitsUnion = Object.values(baseUnits).flatMap((v) => v)
|
||||
export type Toggle = 'On' | 'Off'
|
||||
export const toggleAsArray = ['On', 'Off'] as const
|
||||
|
||||
export type SettingsMachineContext = {
|
||||
baseUnit: BaseUnit
|
||||
cameraControls: CameraSystem
|
||||
defaultDirectory: string
|
||||
defaultProjectName: string
|
||||
onboardingStatus: string
|
||||
showDebugPanel: boolean
|
||||
textWrapping: Toggle
|
||||
theme: Themes
|
||||
unitSystem: UnitSystem
|
||||
export type SettingsPaths = Exclude<
|
||||
Paths<typeof settings, 1>,
|
||||
keyof typeof settings
|
||||
>
|
||||
type SetEvent<T extends SettingsPaths> = {
|
||||
type: `set.${T}`
|
||||
data: {
|
||||
level: SettingsLevel
|
||||
value: PathValue<typeof settings, T>['default']
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
|
@ -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 {
|
||||
type BaseUnit,
|
||||
baseUnitsUnion,
|
||||
type Toggle,
|
||||
type SettingsMachineContext,
|
||||
toggleAsArray,
|
||||
UnitSystem,
|
||||
} from './settingsTypes'
|
||||
import { SETTINGS_PERSIST_KEY } from '../constants'
|
||||
getInitialDefaultDir,
|
||||
getSettingsFilePaths,
|
||||
readSettingsFile,
|
||||
} from '../tauriFS'
|
||||
import { Setting, createSettings, settings } from 'lib/settings/initialSettings'
|
||||
import { SaveSettingsPayload, SettingsLevel } from './settingsTypes'
|
||||
import { isTauri } from 'lib/isTauri'
|
||||
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,
|
||||
errors: [] as (keyof SettingsMachineContext)[],
|
||||
/**
|
||||
* We expect the settings to be stored in a TOML file
|
||||
* 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) {
|
||||
return Object.values(e).includes(v)
|
||||
}
|
||||
export async function loadAndValidateSettings(projectPath?: string) {
|
||||
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<
|
||||
ReturnType<typeof validateSettings>
|
||||
> {
|
||||
const fsSettings = isTauri() ? await readSettingsFile() : {}
|
||||
const localStorageSettings = JSON.parse(
|
||||
localStorage?.getItem(SETTINGS_PERSIST_KEY) || '{}'
|
||||
// Load the settings from the files
|
||||
if (settingsFilePaths.user) {
|
||||
const userSettings = await getSettingsFromStorage(settingsFilePaths.user)
|
||||
if (userSettings) {
|
||||
setSettingsAtLevel(settings, 'user', userSettings)
|
||||
}
|
||||
}
|
||||
|
||||
// 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<
|
||||
keyof SettingsMachineContext,
|
||||
(v: unknown) => boolean
|
||||
> = {
|
||||
baseUnit: (v) => baseUnitsUnion.includes(v as BaseUnit),
|
||||
cameraControls: (v) => cameraSystems.includes(v as CameraSystem),
|
||||
defaultDirectory: (v) =>
|
||||
typeof v === 'string' && (v.length > 0 || !isTauri()),
|
||||
defaultProjectName: (v) => typeof v === 'string' && v.length > 0,
|
||||
onboardingStatus: (v) => typeof v === 'string',
|
||||
showDebugPanel: (v) => typeof v === 'boolean',
|
||||
textWrapping: (v) => toggleAsArray.includes(v as Toggle),
|
||||
theme: (v) => isEnumMember(v, Themes),
|
||||
unitSystem: (v) => isEnumMember(v, UnitSystem),
|
||||
}
|
||||
export async function saveSettings(
|
||||
allSettings: typeof settings,
|
||||
projectPath?: string
|
||||
) {
|
||||
const settingsFilePaths = await getSettingsFilePaths(projectPath)
|
||||
|
||||
function removeInvalidSettingsKeys(s: Record<string, unknown>) {
|
||||
const validKeys = Object.keys(initialSettings)
|
||||
for (const key in s) {
|
||||
if (!validKeys.includes(key)) {
|
||||
console.warn(`Invalid key found in settings: ${key}`)
|
||||
delete s[key]
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
if (settingsFilePaths.user) {
|
||||
const changedSettings = getChangedSettingsAtLevel(allSettings, 'user')
|
||||
|
||||
export async function validateSettings(s: Record<string, unknown>) {
|
||||
let settingsNoInvalidKeys = removeInvalidSettingsKeys({ ...s })
|
||||
let errors: (keyof SettingsMachineContext)[] = []
|
||||
for (const key in settingsNoInvalidKeys) {
|
||||
const k = key as keyof SettingsMachineContext
|
||||
if (!settingsValidators[k](settingsNoInvalidKeys[k])) {
|
||||
delete settingsNoInvalidKeys[k]
|
||||
errors.push(k)
|
||||
}
|
||||
await writeOrClearPersistedSettings(settingsFilePaths.user, changedSettings)
|
||||
}
|
||||
|
||||
// Here's our chance to insert the fallback defaultDir
|
||||
const defaultDirectory = isTauri() ? await getInitialDefaultDir() : ''
|
||||
if (settingsFilePaths.project) {
|
||||
const changedSettings = getChangedSettingsAtLevel(allSettings, 'project')
|
||||
|
||||
const settings = Object.assign(
|
||||
initialSettings,
|
||||
{ defaultDirectory },
|
||||
settingsNoInvalidKeys
|
||||
) as SettingsMachineContext
|
||||
|
||||
return {
|
||||
settings,
|
||||
errors,
|
||||
await writeOrClearPersistedSettings(
|
||||
settingsFilePaths.project,
|
||||
changedSettings
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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')
|
||||
)
|
||||
}
|
||||
|
@ -1,12 +1,12 @@
|
||||
import { FileEntry } from '@tauri-apps/api/fs'
|
||||
import {
|
||||
MAX_PADDING,
|
||||
deepFileFilter,
|
||||
getNextProjectIndex,
|
||||
getPartsCount,
|
||||
interpolateProjectNameWithIndex,
|
||||
isRelevantFileOrDir,
|
||||
} from './tauriFS'
|
||||
import { MAX_PADDING } from './constants'
|
||||
|
||||
describe('Test project name utility functions', () => {
|
||||
it('interpolates a project name without an index', () => {
|
||||
|
@ -10,25 +10,17 @@ import { appConfigDir, documentDir, homeDir, sep } from '@tauri-apps/api/path'
|
||||
import { isTauri } from './isTauri'
|
||||
import { type ProjectWithEntryPointMetadata } from 'lib/types'
|
||||
import { metadata } from 'tauri-plugin-fs-extra-api'
|
||||
import { settingsMachine } from 'machines/settingsMachine'
|
||||
import { ContextFrom } from 'xstate'
|
||||
import { SETTINGS_FILE_NAME } from 'lib/constants'
|
||||
|
||||
const PROJECT_FOLDER = 'zoo-modeling-app-projects'
|
||||
export const FILE_EXT = '.kcl'
|
||||
export const PROJECT_ENTRYPOINT = 'main' + FILE_EXT
|
||||
const INDEX_IDENTIFIER = '$n' // $nn.. will pad the number with 0s
|
||||
export const MAX_PADDING = 7
|
||||
const RELEVANT_FILE_TYPES = [
|
||||
'kcl',
|
||||
'fbx',
|
||||
'gltf',
|
||||
'glb',
|
||||
'obj',
|
||||
'ply',
|
||||
'step',
|
||||
'stl',
|
||||
]
|
||||
import {
|
||||
FILE_EXT,
|
||||
INDEX_IDENTIFIER,
|
||||
MAX_PADDING,
|
||||
PROJECT_ENTRYPOINT,
|
||||
PROJECT_FOLDER,
|
||||
RELEVANT_FILE_TYPES,
|
||||
SETTINGS_FILE_EXT,
|
||||
} from 'lib/constants'
|
||||
import { SaveSettingsPayload, SettingsLevel } from './settings/settingsTypes'
|
||||
import * as TOML from '@iarna/toml'
|
||||
|
||||
type PathWithPossibleError = {
|
||||
path: string | null
|
||||
@ -375,25 +367,18 @@ function getPaddedIdentifierRegExp() {
|
||||
return new RegExp(`${escapedIdentifier}(${escapedIdentifier.slice(-1)}*)`)
|
||||
}
|
||||
|
||||
export async function getSettingsFilePath() {
|
||||
const dir = await appConfigDir()
|
||||
return dir + SETTINGS_FILE_NAME
|
||||
}
|
||||
|
||||
export async function writeToSettingsFile(
|
||||
settings: ContextFrom<typeof settingsMachine>
|
||||
export async function getUserSettingsFilePath(
|
||||
filename: string = SETTINGS_FILE_EXT
|
||||
) {
|
||||
return writeTextFile(
|
||||
await getSettingsFilePath(),
|
||||
JSON.stringify(settings, null, 2)
|
||||
)
|
||||
const dir = await appConfigDir()
|
||||
return dir + filename
|
||||
}
|
||||
|
||||
export async function readSettingsFile(): Promise<ContextFrom<
|
||||
typeof settingsMachine
|
||||
> | null> {
|
||||
const dir = await appConfigDir()
|
||||
const path = dir + SETTINGS_FILE_NAME
|
||||
export async function readSettingsFile(
|
||||
path: string
|
||||
): Promise<Partial<SaveSettingsPayload>> {
|
||||
const dir = path.slice(0, path.lastIndexOf(sep))
|
||||
|
||||
const dirExists = await exists(dir)
|
||||
if (!dirExists) {
|
||||
await createDir(dir, { recursive: true })
|
||||
@ -403,15 +388,39 @@ export async function readSettingsFile(): Promise<ContextFrom<
|
||||
|
||||
if (!settingsExist) {
|
||||
console.log(`Settings file does not exist at ${path}`)
|
||||
await writeToSettingsFile(settingsMachine.initialState.context)
|
||||
return null
|
||||
return {}
|
||||
}
|
||||
|
||||
try {
|
||||
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) {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
@ -7,9 +7,92 @@ export type IndexLoaderData = {
|
||||
file?: FileEntry
|
||||
}
|
||||
|
||||
export type FileLoaderData = {
|
||||
code: string | null
|
||||
project?: FileEntry | ProjectWithEntryPointMetadata
|
||||
file?: FileEntry
|
||||
}
|
||||
|
||||
export type ProjectWithEntryPointMetadata = FileEntry & {
|
||||
entrypointMetadata: Metadata
|
||||
}
|
||||
export type HomeLoaderData = {
|
||||
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)
|
||||
}
|
||||
|
@ -461,7 +461,10 @@ export const commandBarMachine = createMachine(
|
||||
'options' in argConfig &&
|
||||
!(
|
||||
typeof argConfig.options === 'function'
|
||||
? argConfig.options(context)
|
||||
? argConfig.options(
|
||||
context,
|
||||
argConfig.machineActor.getSnapshot().context
|
||||
)
|
||||
: argConfig.options
|
||||
).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({
|
||||
message: 'Argument payload is falsy but is required',
|
||||
arg: {
|
||||
|
@ -2,9 +2,6 @@ import { assign, createMachine } from 'xstate'
|
||||
import { type ProjectWithEntryPointMetadata } from 'lib/types'
|
||||
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(
|
||||
{
|
||||
/** @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 */
|
||||
|
@ -1,148 +1,86 @@
|
||||
import { assign, createMachine } from 'xstate'
|
||||
import { Themes, getSystemTheme, setThemeClass } from 'lib/theme'
|
||||
import { CameraSystem } from 'lib/cameraControls'
|
||||
import { isTauri } from 'lib/isTauri'
|
||||
import { writeToSettingsFile } from 'lib/tauriFS'
|
||||
import { DEFAULT_PROJECT_NAME, SETTINGS_PERSIST_KEY } from 'lib/constants'
|
||||
import { createSettings, settings } from 'lib/settings/initialSettings'
|
||||
import {
|
||||
UnitSystem,
|
||||
type BaseUnit,
|
||||
type SettingsMachineContext,
|
||||
type Toggle,
|
||||
BaseUnit,
|
||||
SetEventTypes,
|
||||
SettingsLevel,
|
||||
SettingsPaths,
|
||||
WildcardSetEvent,
|
||||
} from 'lib/settings/settingsTypes'
|
||||
|
||||
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',
|
||||
predictableActionArguments: true,
|
||||
context: {} as SettingsMachineContext,
|
||||
context: {} as ReturnType<typeof createSettings>,
|
||||
initial: 'idle',
|
||||
states: {
|
||||
idle: {
|
||||
entry: ['setThemeClass', 'setClientSideSceneUnits', 'persistSettings'],
|
||||
|
||||
on: {
|
||||
'Set All Settings': {
|
||||
actions: [
|
||||
assign((context, event) => {
|
||||
return {
|
||||
...context,
|
||||
...event.data,
|
||||
}
|
||||
}),
|
||||
'persistSettings',
|
||||
'setThemeClass',
|
||||
],
|
||||
'*': {
|
||||
target: 'idle',
|
||||
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: [
|
||||
assign({
|
||||
baseUnit: (_, event) => event.data.baseUnit,
|
||||
}),
|
||||
'persistSettings',
|
||||
'setSettingAtLevel',
|
||||
'toastSuccess',
|
||||
'setClientSideSceneUnits',
|
||||
'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',
|
||||
],
|
||||
},
|
||||
|
||||
'set.app.theme': {
|
||||
target: 'idle',
|
||||
internal: true,
|
||||
},
|
||||
'Set Text Wrapping': {
|
||||
actions: [
|
||||
assign({
|
||||
textWrapping: (_, event) => event.data.textWrapping,
|
||||
}),
|
||||
'persistSettings',
|
||||
'toastSuccess',
|
||||
],
|
||||
target: 'idle',
|
||||
internal: true,
|
||||
},
|
||||
'Set Theme': {
|
||||
actions: [
|
||||
assign({
|
||||
theme: (_, event) => event.data.theme,
|
||||
}),
|
||||
'persistSettings',
|
||||
'setSettingAtLevel',
|
||||
'toastSuccess',
|
||||
'setThemeClass',
|
||||
'setEngineTheme',
|
||||
'persistSettings',
|
||||
],
|
||||
},
|
||||
|
||||
'Reset settings': {
|
||||
target: 'idle',
|
||||
internal: true,
|
||||
},
|
||||
'Set Unit System': {
|
||||
actions: [
|
||||
assign({
|
||||
unitSystem: (_, event) => event.data.unitSystem,
|
||||
baseUnit: (_, event) =>
|
||||
event.data.unitSystem === 'imperial' ? 'in' : 'mm',
|
||||
}),
|
||||
'persistSettings',
|
||||
'toastSuccess',
|
||||
'resetSettings',
|
||||
'setThemeClass',
|
||||
'setEngineTheme',
|
||||
'setClientSideSceneUnits',
|
||||
'Execute AST',
|
||||
],
|
||||
target: 'idle',
|
||||
internal: true,
|
||||
},
|
||||
'Toggle Debug Panel': {
|
||||
actions: [
|
||||
assign({
|
||||
showDebugPanel: (context) => {
|
||||
return !context.showDebugPanel
|
||||
},
|
||||
}),
|
||||
'persistSettings',
|
||||
'toastSuccess',
|
||||
],
|
||||
},
|
||||
|
||||
'Set all settings': {
|
||||
target: 'idle',
|
||||
internal: true,
|
||||
actions: [
|
||||
'setAllSettings',
|
||||
'setThemeClass',
|
||||
'setEngineTheme',
|
||||
'setClientSideSceneUnits',
|
||||
'Execute AST',
|
||||
'persistSettings',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -150,44 +88,61 @@ export const settingsMachine = createMachine(
|
||||
tsTypes: {} as import('./settingsMachine.typegen').Typegen0,
|
||||
schema: {
|
||||
events: {} as
|
||||
| { type: 'Set All Settings'; data: Partial<SettingsMachineContext> }
|
||||
| { type: 'Set Base Unit'; data: { baseUnit: BaseUnit } }
|
||||
| WildcardSetEvent<SettingsPaths>
|
||||
| SetEventTypes
|
||||
| {
|
||||
type: 'Set Camera Controls'
|
||||
data: { cameraControls: CameraSystem }
|
||||
type: 'set.app.theme'
|
||||
data: { level: SettingsLevel; value: Themes }
|
||||
}
|
||||
| { type: 'Set Default Directory'; data: { defaultDirectory: string } }
|
||||
| {
|
||||
type: 'Set Default Project Name'
|
||||
data: { defaultProjectName: string }
|
||||
type: 'set.modeling.units'
|
||||
data: { level: SettingsLevel; value: BaseUnit }
|
||||
}
|
||||
| { type: 'Set Onboarding Status'; data: { onboardingStatus: string } }
|
||||
| { type: 'Set Text Wrapping'; data: { textWrapping: Toggle } }
|
||||
| { type: 'Set Theme'; data: { theme: Themes } }
|
||||
| {
|
||||
type: 'Set Unit System'
|
||||
data: { unitSystem: UnitSystem }
|
||||
}
|
||||
| { type: 'Toggle Debug Panel' },
|
||||
| { type: 'Reset settings'; defaultDirectory: string }
|
||||
| { type: 'Set all settings'; settings: typeof settings },
|
||||
},
|
||||
},
|
||||
{
|
||||
actions: {
|
||||
persistSettings: (context) => {
|
||||
if (isTauri()) {
|
||||
writeToSettingsFile(context).catch((err) => {
|
||||
console.error('Error writing settings:', err)
|
||||
})
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(SETTINGS_PERSIST_KEY, JSON.stringify(context))
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
resetSettings: assign((context, { defaultDirectory }) => {
|
||||
// Reset everything except onboarding status,
|
||||
// which should be preserved
|
||||
const newSettings = createSettings()
|
||||
if (context.app.onboardingStatus.user) {
|
||||
newSettings.app.onboardingStatus.user =
|
||||
context.app.onboardingStatus.user
|
||||
}
|
||||
// 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(
|
||||
currentTheme === Themes.System ? getSystemTheme() : currentTheme
|
||||
)
|
||||
|
@ -31,27 +31,23 @@ import {
|
||||
import useStateMachineCommands from '../hooks/useStateMachineCommands'
|
||||
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
|
||||
import { useCommandsContext } from 'hooks/useCommandsContext'
|
||||
import { DEFAULT_PROJECT_NAME } from 'lib/constants'
|
||||
import { sep } from '@tauri-apps/api/path'
|
||||
import { homeCommandBarConfig } from 'lib/commandBarConfigs/homeCommandConfig'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { isTauri } from 'lib/isTauri'
|
||||
import { kclManager } from 'lib/singletons'
|
||||
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,
|
||||
// as defined in Router.tsx, so we can use the Tauri APIs and types.
|
||||
const Home = () => {
|
||||
useValidateSettings()
|
||||
useRefreshSettings(paths.HOME + 'SETTINGS')
|
||||
const { commandBarSend } = useCommandsContext()
|
||||
const navigate = useNavigate()
|
||||
const { projects: loadedProjects } = useLoaderData() as HomeLoaderData
|
||||
const {
|
||||
settings: {
|
||||
context: { defaultDirectory, defaultProjectName },
|
||||
send: sendToSettings,
|
||||
},
|
||||
settings: { context: settings },
|
||||
} = useSettingsAuthContext()
|
||||
const { onProjectOpen } = useLspContext()
|
||||
|
||||
@ -71,8 +67,8 @@ const Home = () => {
|
||||
const [state, send, actor] = useMachine(homeMachine, {
|
||||
context: {
|
||||
projects: loadedProjects,
|
||||
defaultProjectName,
|
||||
defaultDirectory,
|
||||
defaultProjectName: settings.projects.defaultProjectName.current,
|
||||
defaultDirectory: settings.app.projectDirectory.current,
|
||||
},
|
||||
actions: {
|
||||
navigateToProject: (
|
||||
@ -105,15 +101,8 @@ const Home = () => {
|
||||
let name = (
|
||||
event.data && 'name' in event.data
|
||||
? event.data.name
|
||||
: defaultProjectName
|
||||
: settings.projects.defaultProjectName.current
|
||||
).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)) {
|
||||
const nextIndex = await getNextProjectIndex(name, projects)
|
||||
@ -122,13 +111,6 @@ const Home = () => {
|
||||
|
||||
await createNewProject(context.defaultDirectory + sep + name)
|
||||
|
||||
if (shouldUpdateDefaultProjectName) {
|
||||
sendToSettings({
|
||||
type: 'Set Default Project Name',
|
||||
data: { defaultProjectName: DEFAULT_PROJECT_NAME },
|
||||
})
|
||||
}
|
||||
|
||||
return `Successfully created "${name}"`
|
||||
},
|
||||
renameProject: async (
|
||||
@ -179,9 +161,21 @@ const Home = () => {
|
||||
actor,
|
||||
})
|
||||
|
||||
// Update the default project name and directory in the home machine
|
||||
// when the settings change
|
||||
useEffect(() => {
|
||||
send({ type: 'assign', data: { defaultProjectName, defaultDirectory } })
|
||||
}, [defaultDirectory, defaultProjectName, send])
|
||||
send({
|
||||
type: 'assign',
|
||||
data: {
|
||||
defaultProjectName: settings.projects.defaultProjectName.current,
|
||||
defaultDirectory: settings.app.projectDirectory.current,
|
||||
},
|
||||
})
|
||||
}, [
|
||||
settings.app.projectDirectory,
|
||||
settings.projects.defaultProjectName,
|
||||
send,
|
||||
])
|
||||
|
||||
async function handleRenameProject(
|
||||
e: FormEvent<HTMLFormElement>,
|
||||
@ -254,7 +248,7 @@ const Home = () => {
|
||||
<p className="my-4 text-sm text-chalkboard-80 dark:text-chalkboard-30">
|
||||
Loaded from{' '}
|
||||
<span className="text-energy-70 dark:text-energy-40">
|
||||
{defaultDirectory}
|
||||
{settings.app.projectDirectory.current}
|
||||
</span>
|
||||
.{' '}
|
||||
<Link to="settings" className="underline underline-offset-2">
|
||||
|
@ -19,7 +19,9 @@ export default function Units() {
|
||||
settings: {
|
||||
send,
|
||||
state: {
|
||||
context: { cameraControls },
|
||||
context: {
|
||||
modeling: { mouseControls },
|
||||
},
|
||||
},
|
||||
},
|
||||
} = useSettingsAuthContext()
|
||||
@ -41,11 +43,14 @@ export default function Units() {
|
||||
<select
|
||||
id="camera-controls"
|
||||
className="block w-full px-3 py-1 bg-transparent border border-chalkboard-30"
|
||||
value={cameraControls}
|
||||
value={mouseControls.current}
|
||||
onChange={(e) => {
|
||||
send({
|
||||
type: 'Set Camera Controls',
|
||||
data: { cameraControls: e.target.value as CameraSystem },
|
||||
type: 'set.modeling.mouseControls',
|
||||
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">
|
||||
<li>
|
||||
<strong>Pan:</strong>{' '}
|
||||
{cameraMouseDragGuards[cameraControls].pan.description}
|
||||
{cameraMouseDragGuards[mouseControls.current].pan.description}
|
||||
</li>
|
||||
<li>
|
||||
<strong>Zoom:</strong>{' '}
|
||||
{cameraMouseDragGuards[cameraControls].zoom.description}
|
||||
{cameraMouseDragGuards[mouseControls.current].zoom.description}
|
||||
</li>
|
||||
<li>
|
||||
<strong>Rotate:</strong>{' '}
|
||||
{cameraMouseDragGuards[cameraControls].rotate.description}
|
||||
{cameraMouseDragGuards[mouseControls.current].rotate.description}
|
||||
</li>
|
||||
</ul>
|
||||
</SettingsSection>
|
||||
|
@ -9,7 +9,6 @@ import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
|
||||
import { Themes, getSystemTheme } from 'lib/theme'
|
||||
import { bracket } from 'lib/exampleKcl'
|
||||
import {
|
||||
PROJECT_ENTRYPOINT,
|
||||
createNewProject,
|
||||
getNextProjectIndex,
|
||||
getProjectsInDir,
|
||||
@ -21,7 +20,7 @@ import { paths } from 'lib/paths'
|
||||
import { useEffect } from 'react'
|
||||
import { kclManager } from 'lib/singletons'
|
||||
import { sep } from '@tauri-apps/api/path'
|
||||
import { APP_NAME } from 'lib/constants'
|
||||
import { APP_NAME, PROJECT_ENTRYPOINT } from 'lib/constants'
|
||||
|
||||
function OnboardingWithNewFile() {
|
||||
const navigate = useNavigate()
|
||||
@ -29,12 +28,14 @@ function OnboardingWithNewFile() {
|
||||
const next = useNextClick(onboardingPaths.INDEX)
|
||||
const {
|
||||
settings: {
|
||||
context: { defaultDirectory },
|
||||
context: {
|
||||
app: { projectDirectory },
|
||||
},
|
||||
},
|
||||
} = useSettingsAuthContext()
|
||||
|
||||
async function createAndOpenNewProject() {
|
||||
const projects = await getProjectsInDir(defaultDirectory)
|
||||
const projects = await getProjectsInDir(projectDirectory.current)
|
||||
const nextIndex = await getNextProjectIndex(
|
||||
ONBOARDING_PROJECT_NAME,
|
||||
projects
|
||||
@ -44,7 +45,7 @@ function OnboardingWithNewFile() {
|
||||
nextIndex
|
||||
)
|
||||
const newFile = await createNewProject(
|
||||
defaultDirectory + sep + name,
|
||||
projectDirectory.current + sep + name,
|
||||
bracket
|
||||
)
|
||||
navigate(
|
||||
@ -108,13 +109,15 @@ export default function Introduction() {
|
||||
const {
|
||||
settings: {
|
||||
state: {
|
||||
context: { theme },
|
||||
context: {
|
||||
app: { theme },
|
||||
},
|
||||
},
|
||||
},
|
||||
} = useSettingsAuthContext()
|
||||
const getLogoTheme = () =>
|
||||
theme === Themes.Light ||
|
||||
(theme === Themes.System && getSystemTheme() === Themes.Light)
|
||||
theme.current === Themes.Light ||
|
||||
(theme.current === Themes.System && getSystemTheme() === Themes.Light)
|
||||
? '-dark'
|
||||
: ''
|
||||
const dismiss = useDismiss()
|
||||
|
@ -12,7 +12,11 @@ export default function ParametricModeling() {
|
||||
}))
|
||||
const {
|
||||
settings: {
|
||||
context: { theme },
|
||||
context: {
|
||||
app: {
|
||||
theme: { current: theme },
|
||||
},
|
||||
},
|
||||
},
|
||||
} = useSettingsAuthContext()
|
||||
const getImageTheme = () =>
|
||||
|
@ -1,12 +1,7 @@
|
||||
import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons'
|
||||
import {
|
||||
type BaseUnit,
|
||||
baseUnits,
|
||||
UnitSystem,
|
||||
} from 'lib/settings/settingsTypes'
|
||||
import { type BaseUnit, baseUnitsUnion } from 'lib/settings/settingsTypes'
|
||||
import { ActionButton } from 'components/ActionButton'
|
||||
import { SettingsSection } from '../Settings'
|
||||
import { Toggle } from 'components/Toggle/Toggle'
|
||||
import { useDismiss, useNextClick } from '.'
|
||||
import { onboardingPaths } from 'routes/Onboarding/paths'
|
||||
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
|
||||
@ -17,7 +12,9 @@ export default function Units() {
|
||||
const {
|
||||
settings: {
|
||||
send,
|
||||
context: { unitSystem, baseUnit },
|
||||
context: {
|
||||
modeling: { defaultUnit },
|
||||
},
|
||||
},
|
||||
} = useSettingsAuthContext()
|
||||
|
||||
@ -26,41 +23,24 @@ export default function Units() {
|
||||
<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>
|
||||
<SettingsSection
|
||||
title="Unit System"
|
||||
description="Which unit system to use 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"
|
||||
title="Default Unit"
|
||||
description="Which unit to use in modeling dimensions by default"
|
||||
>
|
||||
<select
|
||||
id="base-unit"
|
||||
className="block w-full px-3 py-1 border border-chalkboard-30 bg-transparent"
|
||||
value={baseUnit}
|
||||
value={defaultUnit.user}
|
||||
onChange={(e) => {
|
||||
send({
|
||||
type: 'Set Base Unit',
|
||||
data: { baseUnit: e.target.value as BaseUnit },
|
||||
type: 'set.modeling.defaultUnit',
|
||||
data: {
|
||||
level: 'user',
|
||||
value: e.target.value as BaseUnit,
|
||||
},
|
||||
})
|
||||
}}
|
||||
>
|
||||
{baseUnits[unitSystem].map((unit) => (
|
||||
{baseUnitsUnion.map((unit) => (
|
||||
<option key={unit} value={unit}>
|
||||
{unit}
|
||||
</option>
|
||||
|
@ -85,8 +85,8 @@ export function useNextClick(newStatus: string) {
|
||||
|
||||
return useCallback(() => {
|
||||
send({
|
||||
type: 'Set Onboarding Status',
|
||||
data: { onboardingStatus: newStatus },
|
||||
type: 'set.app.onboardingStatus',
|
||||
data: { level: 'user', value: newStatus },
|
||||
})
|
||||
navigate(filePath + paths.ONBOARDING.INDEX.slice(0, -1) + newStatus)
|
||||
}, [filePath, newStatus, send, navigate])
|
||||
@ -101,8 +101,8 @@ export function useDismiss() {
|
||||
|
||||
return useCallback(() => {
|
||||
send({
|
||||
type: 'Set Onboarding Status',
|
||||
data: { onboardingStatus: 'dismissed' },
|
||||
type: 'set.app.onboardingStatus',
|
||||
data: { level: 'user', value: 'dismissed' },
|
||||
})
|
||||
navigate(filePath)
|
||||
}, [send, navigate, filePath])
|
||||
|
@ -1,90 +1,70 @@
|
||||
import { faArrowRotateBack, faXmark } from '@fortawesome/free-solid-svg-icons'
|
||||
import { ActionButton } from '../components/ActionButton'
|
||||
import { AppHeader } from '../components/AppHeader'
|
||||
import { open } from '@tauri-apps/api/dialog'
|
||||
import { DEFAULT_PROJECT_NAME, SETTINGS_PERSIST_KEY } from 'lib/constants'
|
||||
import {
|
||||
type BaseUnit,
|
||||
UnitSystem,
|
||||
baseUnits,
|
||||
SetEventTypes,
|
||||
SettingsLevel,
|
||||
WildcardSetEvent,
|
||||
} from 'lib/settings/settingsTypes'
|
||||
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 { type IndexLoaderData } from 'lib/types'
|
||||
import { paths } from 'lib/paths'
|
||||
import { Themes } from '../lib/theme'
|
||||
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
|
||||
import {
|
||||
CameraSystem,
|
||||
cameraSystems,
|
||||
cameraMouseDragGuards,
|
||||
} from 'lib/cameraControls'
|
||||
import { useDotDotSlash } from 'hooks/useDotDotSlash'
|
||||
import {
|
||||
createNewProject,
|
||||
getInitialDefaultDir,
|
||||
getNextProjectIndex,
|
||||
getProjectsInDir,
|
||||
getSettingsFilePath,
|
||||
initializeProjectDirectory,
|
||||
getSettingsFolderPaths,
|
||||
interpolateProjectNameWithIndex,
|
||||
} from 'lib/tauriFS'
|
||||
import { initialSettings } from 'lib/settings/initialSettings'
|
||||
import { ONBOARDING_PROJECT_NAME } from './Onboarding'
|
||||
import { sep } from '@tauri-apps/api/path'
|
||||
import { bracket } from 'lib/exampleKcl'
|
||||
import { isTauri } from 'lib/isTauri'
|
||||
import { invoke } from '@tauri-apps/api'
|
||||
import toast from 'react-hot-toast'
|
||||
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 = () => {
|
||||
const APP_VERSION = import.meta.env.PACKAGE_VERSION || 'unknown'
|
||||
const loaderData =
|
||||
(useRouteLoaderData(paths.FILE) as IndexLoaderData) || undefined
|
||||
const navigate = useNavigate()
|
||||
const close = () => navigate(location.pathname.replace(paths.SETTINGS, ''))
|
||||
const location = useLocation()
|
||||
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()
|
||||
useHotkeys('esc', () => navigate(dotDotSlash()))
|
||||
const {
|
||||
settings: {
|
||||
send,
|
||||
state: {
|
||||
context: {
|
||||
baseUnit,
|
||||
cameraControls,
|
||||
defaultDirectory,
|
||||
defaultProjectName,
|
||||
showDebugPanel,
|
||||
theme,
|
||||
unitSystem,
|
||||
},
|
||||
},
|
||||
state: { context },
|
||||
},
|
||||
} = 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() {
|
||||
send({
|
||||
type: 'Set Onboarding Status',
|
||||
data: { onboardingStatus: '' },
|
||||
type: `set.app.onboardingStatus`,
|
||||
data: { level: 'user', value: '' },
|
||||
})
|
||||
|
||||
if (isFileSettings) {
|
||||
@ -95,6 +75,7 @@ export const Settings = () => {
|
||||
}
|
||||
|
||||
async function createAndOpenNewProject() {
|
||||
const defaultDirectory = context.app.projectDirectory.current
|
||||
const projects = await getProjectsInDir(defaultDirectory)
|
||||
const nextIndex = await getNextProjectIndex(
|
||||
ONBOARDING_PROJECT_NAME,
|
||||
@ -112,197 +93,198 @@ export const Settings = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 overflow-auto body-bg">
|
||||
<AppHeader showToolbar={false} project={loaderData}>
|
||||
<ActionButton
|
||||
Element="link"
|
||||
to={location.pathname.replace(paths.SETTINGS, '')}
|
||||
icon={{
|
||||
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"
|
||||
<Transition appear show={true} as={Fragment}>
|
||||
<Dialog
|
||||
as="div"
|
||||
open={true}
|
||||
onClose={close}
|
||||
className="fixed inset-0 z-40 overflow-y-auto p-4 grid place-items-center"
|
||||
>
|
||||
Close
|
||||
</ActionButton>
|
||||
</AppHeader>
|
||||
<div className="max-w-4xl mx-5 lg:mx-auto my-24">
|
||||
<h1 className="text-4xl font-bold">User Settings</h1>
|
||||
<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"
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-75"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
our roadmap
|
||||
</a>
|
||||
, and start a discussion if you don't see it! Your feedback will help
|
||||
us prioritize what to build next.
|
||||
</p>
|
||||
<SettingsSection
|
||||
title="Camera Controls"
|
||||
description="How you want to control the camera in the 3D view"
|
||||
<Dialog.Overlay className="fixed inset-0 bg-chalkboard-110/30 dark:bg-chalkboard-110/50" />
|
||||
</Transition.Child>
|
||||
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-75"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<select
|
||||
id="camera-controls"
|
||||
className="block w-full px-3 py-1 bg-transparent border border-chalkboard-30"
|
||||
value={cameraControls}
|
||||
onChange={(e) => {
|
||||
send({
|
||||
type: 'Set Camera Controls',
|
||||
data: { cameraControls: e.target.value as CameraSystem },
|
||||
})
|
||||
}}
|
||||
<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">
|
||||
<div className="p-5 pb-0 flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold">Settings</h1>
|
||||
<button
|
||||
onClick={close}
|
||||
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"
|
||||
data-testid="settings-close-button"
|
||||
>
|
||||
{cameraSystems.map((program) => (
|
||||
<option key={program} value={program}>
|
||||
{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>
|
||||
<CustomIcon name="close" className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
<SettingsSection
|
||||
title="Default Project Name"
|
||||
description="Name template for new projects. Use $n to include an incrementing index"
|
||||
<RadioGroup
|
||||
value={settingsLevel}
|
||||
onChange={setSettingsLevel}
|
||||
className="flex justify-start pl-4 pr-5 gap-5 border-0 border-b border-b-chalkboard-20 dark:border-b-chalkboard-90"
|
||||
>
|
||||
<input
|
||||
className="block w-full px-3 py-1 bg-transparent border border-chalkboard-30"
|
||||
defaultValue={defaultProjectName}
|
||||
onBlur={(e) => {
|
||||
const newValue = e.target.value.trim() || DEFAULT_PROJECT_NAME
|
||||
send({
|
||||
type: 'Set Default Project Name',
|
||||
data: {
|
||||
defaultProjectName: newValue,
|
||||
},
|
||||
})
|
||||
e.target.value = newValue
|
||||
}}
|
||||
autoCapitalize="off"
|
||||
autoComplete="off"
|
||||
data-testid="name-input"
|
||||
<RadioGroup.Option value="user">
|
||||
{({ checked }) => (
|
||||
<SettingsTabButton
|
||||
checked={checked}
|
||||
icon="person"
|
||||
text="User"
|
||||
/>
|
||||
</SettingsSection>
|
||||
</>
|
||||
)}
|
||||
<SettingsSection
|
||||
title="Unit System"
|
||||
description="Which unit system to use 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 },
|
||||
</RadioGroup.Option>
|
||||
{isFileSettings && (
|
||||
<RadioGroup.Option value="project">
|
||||
{({ checked }) => (
|
||||
<SettingsTabButton
|
||||
checked={checked}
|
||||
icon="folder"
|
||||
text="This project"
|
||||
/>
|
||||
)}
|
||||
</RadioGroup.Option>
|
||||
)}
|
||||
</RadioGroup>
|
||||
<div className="flex flex-grow overflow-hidden items-stretch pl-4 pr-5 pb-5 gap-4">
|
||||
<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">
|
||||
{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
|
||||
title="Base Unit"
|
||||
description="Which base unit to use in dimensions by default"
|
||||
>
|
||||
<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>
|
||||
)
|
||||
})}
|
||||
</Fragment>
|
||||
))}
|
||||
</select>
|
||||
</SettingsSection>
|
||||
<SettingsSection
|
||||
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>
|
||||
<h2 id="settings-resets" className="text-2xl mt-6 font-bold">
|
||||
Resets
|
||||
</h2>
|
||||
<SettingsSection
|
||||
title="Onboarding"
|
||||
description="Replay the onboarding process"
|
||||
@ -310,65 +292,74 @@ export const Settings = () => {
|
||||
<ActionButton
|
||||
Element="button"
|
||||
onClick={restartOnboarding}
|
||||
icon={{ icon: faArrowRotateBack, size: 'sm', className: 'p-1' }}
|
||||
icon={{
|
||||
icon: 'refresh',
|
||||
size: 'sm',
|
||||
className: 'p-1',
|
||||
}}
|
||||
>
|
||||
Replay Onboarding
|
||||
</ActionButton>
|
||||
</SettingsSection>
|
||||
<p className="font-mono my-6 leading-loose">
|
||||
Your settings are saved in{' '}
|
||||
{isTauri()
|
||||
? 'a file in the app data folder for your OS.'
|
||||
: "your browser's local storage."}{' '}
|
||||
{isTauri() ? (
|
||||
<span className="flex gap-4 flex-wrap items-center">
|
||||
<button
|
||||
onClick={async () =>
|
||||
void invoke('show_in_folder', {
|
||||
path: await getSettingsFilePath(),
|
||||
})
|
||||
<SettingsSection
|
||||
title="Reset settings"
|
||||
description={`Restore settings to their default values. Your settings are saved in
|
||||
${
|
||||
isTauri()
|
||||
? ' a file in the app data folder for your OS.'
|
||||
: " your browser's local storage."
|
||||
}
|
||||
className="text-base"
|
||||
`}
|
||||
>
|
||||
Show settings.json in folder
|
||||
</button>
|
||||
<button
|
||||
<div className="flex flex-col items-start gap-4">
|
||||
{isTauri() && (
|
||||
<ActionButton
|
||||
Element="button"
|
||||
onClick={async () => {
|
||||
// We have to re-call initializeProjectDirectory
|
||||
// since we can't set that in the settings machine's
|
||||
// initial context due to it being async
|
||||
send({
|
||||
type: 'Set All Settings',
|
||||
data: {
|
||||
...initialSettings,
|
||||
defaultDirectory:
|
||||
(await initializeProjectDirectory('')).path ?? '',
|
||||
},
|
||||
const paths = await getSettingsFolderPaths(
|
||||
projectPath
|
||||
? decodeURIComponent(projectPath)
|
||||
: undefined
|
||||
)
|
||||
void invoke('show_in_folder', {
|
||||
path: paths[settingsLevel],
|
||||
})
|
||||
toast.success('Settings restored to default')
|
||||
}}
|
||||
className="text-base"
|
||||
>
|
||||
Restore default settings
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => {
|
||||
localStorage.removeItem(SETTINGS_PERSIST_KEY)
|
||||
send({
|
||||
type: 'Set All Settings',
|
||||
data: initialSettings,
|
||||
})
|
||||
toast.success('Settings restored to default')
|
||||
icon={{
|
||||
icon: 'folder',
|
||||
size: 'sm',
|
||||
className: 'p-1',
|
||||
}}
|
||||
className="text-base"
|
||||
>
|
||||
Restore default settings
|
||||
</button>
|
||||
Show in folder
|
||||
</ActionButton>
|
||||
)}
|
||||
</p>
|
||||
<p className="mt-24 text-sm font-mono">
|
||||
<ActionButton
|
||||
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
|
||||
to inject the version from package.json */}
|
||||
App version {APP_VERSION}.{' '}
|
||||
@ -380,8 +371,25 @@ export const Settings = () => {
|
||||
View release on GitHub
|
||||
</a>
|
||||
</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>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
)
|
||||
}
|
||||
|
||||
@ -389,6 +397,9 @@ interface SettingsSectionProps extends React.PropsWithChildren {
|
||||
title: string
|
||||
description?: string
|
||||
className?: string
|
||||
parentLevel?: SettingsLevel | 'default'
|
||||
onFallback?: () => void
|
||||
settingHasChanged?: boolean
|
||||
headingClassName?: string
|
||||
}
|
||||
|
||||
@ -397,20 +408,215 @@ export function SettingsSection({
|
||||
description,
|
||||
className,
|
||||
children,
|
||||
headingClassName = 'text-2xl font-bold',
|
||||
parentLevel,
|
||||
settingHasChanged,
|
||||
onFallback,
|
||||
headingClassName = 'text-base font-normal capitalize tracking-wide',
|
||||
}: SettingsSectionProps) {
|
||||
return (
|
||||
<section
|
||||
className={
|
||||
'my-16 last-of-type:mb-24 grid grid-cols-2 gap-12 items-start ' +
|
||||
className
|
||||
'group grid grid-cols-2 gap-6 items-start ' +
|
||||
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>
|
||||
<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>{children}</div>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
@ -6,24 +6,25 @@ import { Themes, getSystemTheme } from '../lib/theme'
|
||||
import { paths } from 'lib/paths'
|
||||
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
|
||||
import { APP_NAME } from 'lib/constants'
|
||||
import { useValidateSettings } from 'hooks/useValidateSettings'
|
||||
|
||||
const SignIn = () => {
|
||||
useValidateSettings()
|
||||
const getLogoTheme = () =>
|
||||
theme === Themes.Light ||
|
||||
(theme === Themes.System && getSystemTheme() === Themes.Light)
|
||||
? '-dark'
|
||||
: ''
|
||||
const {
|
||||
auth: { send },
|
||||
settings: {
|
||||
state: {
|
||||
context: { theme },
|
||||
context: {
|
||||
app: { theme },
|
||||
},
|
||||
},
|
||||
},
|
||||
} = useSettingsAuthContext()
|
||||
|
||||
const getLogoTheme = () =>
|
||||
theme.current === Themes.Light ||
|
||||
(theme.current === Themes.System && getSystemTheme() === Themes.Light)
|
||||
? '-dark'
|
||||
: ''
|
||||
|
||||
const signInTauri = async () => {
|
||||
// We want to invoke our command to login via device auth.
|
||||
try {
|
||||
@ -32,7 +33,7 @@ const SignIn = () => {
|
||||
})
|
||||
send({ type: 'Log in', token })
|
||||
} catch (error) {
|
||||
console.error('login button', error)
|
||||
console.error('Error with login button', error)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -11,6 +11,9 @@ import version from 'vite-plugin-package-version'
|
||||
dns.setDefaultResultOrder('verbatim')
|
||||
|
||||
const config = defineConfig({
|
||||
define: {
|
||||
global: 'window',
|
||||
},
|
||||
server: {
|
||||
open: true,
|
||||
port: 3000,
|
||||
|
@ -1683,6 +1683,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-2.0.2.tgz#d9fae00a2d5cb40f92cfe64b47ad749fbc38f917"
|
||||
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":
|
||||
version "8.0.2"
|
||||
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":
|
||||
version "0.0.0"
|
||||
uid b0a4a479cabb00bb7a689756f742ef89da4f2601
|
||||
resolved "https://github.com/tauri-apps/tauri-plugin-fs-extra#b0a4a479cabb00bb7a689756f742ef89da4f2601"
|
||||
dependencies:
|
||||
"@tauri-apps/api" "1.5.3"
|
||||
|