Migrate to new split sidebar from accordion-like panes (#2063)
* Split ModelingSidebar out into own component * Consolidate all ModelingPane components and config * Make ModelingSidebar a directory of components and config * Remove unused components * Proper pane styling * Make tooltip configurable to visually appear on hover only * Remove debug panel from App * Fix current tests * Rename to more intuitive names * Fix useEffect loop bug with showDebugPanel * Fix snapshot tests * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu) * Rerun CI * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu) * Merge branch 'main' into franknoirot/sidebar * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu) * Rerun CI * Maybe some flakiness in the validation initScripts? * Avoid test flakiness by waiting for more signals that loading is completed * Don't assert, just wait for the element to be enabled * Don't let users accidentally click the gap between the pane and the side of the window * Firm up extrude from command bar test * Get rid of unused imports * Add setting to disable blinking cursor (#2065) * Add support for "current" marker in command bar for boolean settings * Add a cursorBlinking setting * Rename setting to blinkingCursor, honor it in the UI * Fix scroll layout bug in settings modal * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu) * Rerun CI * CSS tweaks * Allow settings hotkey within KclEditorPane * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu) --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * Rerun CI * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu) * Rerun CI * Ensure the KCL code panel is closed for camera movement test * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu) * Make sure that the camera position inputs are ready to be read from * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu) * Remove repeat awaits * Make camera position fields in debug pane update when the pane is initialized * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu) * Undo that CameraControls change because it made other things weird * retry fixing camera move test * Fix race condition where cam setting cam position parts were overwriting each other * Rerun CI * Rerun CI --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
@ -145,6 +145,7 @@ test('Can moving camera', async ({ page, context }) => {
 | 
			
		||||
  await page.goto('/')
 | 
			
		||||
  await u.waitForAuthSkipAppStart()
 | 
			
		||||
  await u.openAndClearDebugPanel()
 | 
			
		||||
  await u.closeKclCodePanel()
 | 
			
		||||
 | 
			
		||||
  const camPos: [number, number, number] = [0, 85, 85]
 | 
			
		||||
  const bakeInRetries = async (
 | 
			
		||||
@ -178,6 +179,8 @@ test('Can moving camera', async ({ page, context }) => {
 | 
			
		||||
    }, 300)
 | 
			
		||||
 | 
			
		||||
    await u.openAndClearDebugPanel()
 | 
			
		||||
    await page.getByTestId('cam-x-position').isVisible()
 | 
			
		||||
 | 
			
		||||
    const vals = await Promise.all([
 | 
			
		||||
      page.getByTestId('cam-x-position').inputValue(),
 | 
			
		||||
      page.getByTestId('cam-y-position').inputValue(),
 | 
			
		||||
@ -342,7 +345,11 @@ test('executes on load', async ({ page }) => {
 | 
			
		||||
  await u.waitForAuthSkipAppStart()
 | 
			
		||||
 | 
			
		||||
  // expand variables section
 | 
			
		||||
  await page.getByText('Variables').click()
 | 
			
		||||
  const variablesTabButton = page.getByRole('tab', {
 | 
			
		||||
    name: 'Variables',
 | 
			
		||||
    exact: false,
 | 
			
		||||
  })
 | 
			
		||||
  await variablesTabButton.click()
 | 
			
		||||
 | 
			
		||||
  // can find part001 in the variables summary (pretty-json-container, makes sure we're not looking in the code editor)
 | 
			
		||||
  // part001 only shows up in the variables summary if it's been executed
 | 
			
		||||
@ -366,7 +373,11 @@ test('re-executes', async ({ page }) => {
 | 
			
		||||
  await page.goto('/')
 | 
			
		||||
  await u.waitForAuthSkipAppStart()
 | 
			
		||||
 | 
			
		||||
  await page.getByText('Variables').click()
 | 
			
		||||
  const variablesTabButton = page.getByRole('tab', {
 | 
			
		||||
    name: 'Variables',
 | 
			
		||||
    exact: false,
 | 
			
		||||
  })
 | 
			
		||||
  await variablesTabButton.click()
 | 
			
		||||
  // expect to see "myVar:5"
 | 
			
		||||
  await expect(
 | 
			
		||||
    page.locator('.pretty-json-container >> text=myVar:5')
 | 
			
		||||
@ -538,10 +549,13 @@ test('Auto complete works', async ({ page }) => {
 | 
			
		||||
 | 
			
		||||
test('Stored settings are validated and fall back to defaults', async ({
 | 
			
		||||
  page,
 | 
			
		||||
  context,
 | 
			
		||||
}) => {
 | 
			
		||||
  const u = getUtils(page)
 | 
			
		||||
 | 
			
		||||
  // Override beforeEach test setup
 | 
			
		||||
  // with corrupted settings
 | 
			
		||||
  await page.addInitScript(
 | 
			
		||||
  await context.addInitScript(
 | 
			
		||||
    async ({ settingsKey, settings }) => {
 | 
			
		||||
      localStorage.setItem(settingsKey, settings)
 | 
			
		||||
    },
 | 
			
		||||
@ -553,6 +567,7 @@ test('Stored settings are validated and fall back to defaults', async ({
 | 
			
		||||
 | 
			
		||||
  await page.setViewportSize({ width: 1200, height: 500 })
 | 
			
		||||
  await page.goto('/')
 | 
			
		||||
  await u.waitForAuthSkipAppStart()
 | 
			
		||||
 | 
			
		||||
  // Check the settings were reset
 | 
			
		||||
  const storedSettings = TOML.parse(
 | 
			
		||||
@ -876,14 +891,13 @@ test.describe('Command bar tests', () => {
 | 
			
		||||
    await page.addInitScript(async () => {
 | 
			
		||||
      localStorage.setItem(
 | 
			
		||||
        'persistCode',
 | 
			
		||||
        `
 | 
			
		||||
        const distance = sqrt(20)
 | 
			
		||||
        `const distance = sqrt(20)
 | 
			
		||||
        const part001 = startSketchOn('-XZ')
 | 
			
		||||
          |> startProfileAt([-6.95, 4.98], %)
 | 
			
		||||
          |> line([25.1, 0.41], %)
 | 
			
		||||
          |> line([0.73, -14.93], %)
 | 
			
		||||
          |> line([-23.44, 0.52], %)
 | 
			
		||||
          |> close(%)
 | 
			
		||||
  |> startProfileAt([-6.95, 4.98], %)
 | 
			
		||||
  |> line([25.1, 0.41], %)
 | 
			
		||||
  |> line([0.73, -14.93], %)
 | 
			
		||||
  |> line([-23.44, 0.52], %)
 | 
			
		||||
  |> close(%)
 | 
			
		||||
        `
 | 
			
		||||
      )
 | 
			
		||||
    })
 | 
			
		||||
@ -896,15 +910,13 @@ test.describe('Command bar tests', () => {
 | 
			
		||||
    // 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()
 | 
			
		||||
    await u.clearCommandLogs()
 | 
			
		||||
    await page.getByText('|> line([0.73, -14.93], %)').click()
 | 
			
		||||
    await page.getByRole('button', { name: 'Extrude' }).isEnabled()
 | 
			
		||||
 | 
			
		||||
    let cmdSearchBar = page.getByPlaceholder('Search commands')
 | 
			
		||||
    await page.keyboard.press('Meta+K')
 | 
			
		||||
@ -922,23 +934,25 @@ test.describe('Command bar tests', () => {
 | 
			
		||||
    await expect(page.getByPlaceholder('Variable name')).toHaveValue(
 | 
			
		||||
      'distance001'
 | 
			
		||||
    )
 | 
			
		||||
    await expect(page.getByRole('button', { name: 'Continue' })).toBeEnabled()
 | 
			
		||||
    await page.getByRole('button', { name: 'Continue' }).click()
 | 
			
		||||
 | 
			
		||||
    const continueButton = page.getByRole('button', { name: 'Continue' })
 | 
			
		||||
    const submitButton = page.getByRole('button', { name: 'Submit command' })
 | 
			
		||||
    await continueButton.click()
 | 
			
		||||
 | 
			
		||||
    // Review step and argument hotkeys
 | 
			
		||||
    await expect(
 | 
			
		||||
      page.getByRole('button', { name: 'Submit command' })
 | 
			
		||||
    ).toBeEnabled()
 | 
			
		||||
    await expect(submitButton).toBeEnabled()
 | 
			
		||||
    await page.keyboard.press('Backspace')
 | 
			
		||||
 | 
			
		||||
    // Assert we're back on the distance step
 | 
			
		||||
    await expect(
 | 
			
		||||
      page.getByRole('button', { name: 'Distance 12', exact: false })
 | 
			
		||||
    ).toBeDisabled()
 | 
			
		||||
    await page.keyboard.press('Enter')
 | 
			
		||||
 | 
			
		||||
    await expect(page.getByText('Confirm Extrude')).toBeVisible()
 | 
			
		||||
    await continueButton.click()
 | 
			
		||||
    await submitButton.click()
 | 
			
		||||
 | 
			
		||||
    // Check that the code was updated
 | 
			
		||||
    await page.keyboard.press('Enter')
 | 
			
		||||
    await u.waitForCmdReceive('extrude')
 | 
			
		||||
    // Unfortunately this indentation seems to matter for the test
 | 
			
		||||
    await expect(page.locator('.cm-content')).toHaveText(
 | 
			
		||||
      `const distance = sqrt(20)
 | 
			
		||||
 | 
			
		||||
@ -382,11 +382,11 @@ test('extrude on each default plane should be stable', async ({
 | 
			
		||||
    await u.expectCmdLog('[data-message-type="execution-done"]')
 | 
			
		||||
    await u.clearAndCloseDebugPanel()
 | 
			
		||||
 | 
			
		||||
    await page.getByText('Code').click()
 | 
			
		||||
    await u.closeKclCodePanel()
 | 
			
		||||
    await expect(page).toHaveScreenshot({
 | 
			
		||||
      maxDiffPixels: 100,
 | 
			
		||||
    })
 | 
			
		||||
    await page.getByText('Code').click()
 | 
			
		||||
    await u.openKclCodePanel()
 | 
			
		||||
  }
 | 
			
		||||
  await runSnapshotsForOtherPlanes('XY')
 | 
			
		||||
  await runSnapshotsForOtherPlanes('-XY')
 | 
			
		||||
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 40 KiB  | 
| 
		 Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 54 KiB  | 
| 
		 Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB  | 
| 
		 Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 55 KiB  | 
| 
		 Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 32 KiB  | 
| 
		 Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 34 KiB  | 
| 
		 Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 73 KiB  | 
| 
		 Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 47 KiB  | 
| 
		 Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 46 KiB  | 
| 
		 Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 48 KiB  | 
| 
		 Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 49 KiB  | 
| 
		 Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 47 KiB  | 
| 
		 Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 47 KiB  | 
@ -44,26 +44,44 @@ async function waitForDefaultPlanesToBeVisible(page: Page) {
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function openDebugPanel(page: Page) {
 | 
			
		||||
  const isOpen =
 | 
			
		||||
    (await page
 | 
			
		||||
      .locator('[data-testid="debug-panel"]')
 | 
			
		||||
      ?.getAttribute('open')) === ''
 | 
			
		||||
async function openKclCodePanel(page: Page) {
 | 
			
		||||
  const paneLocator = page.getByRole('tab', { name: 'KCL Code', exact: false })
 | 
			
		||||
  const isOpen = (await paneLocator?.getAttribute('aria-selected')) === 'true'
 | 
			
		||||
 | 
			
		||||
  if (!isOpen) {
 | 
			
		||||
    await page.getByText('Debug').click()
 | 
			
		||||
    await page.getByTestId('debug-panel').and(page.locator('[open]')).waitFor()
 | 
			
		||||
    await paneLocator.click()
 | 
			
		||||
    await paneLocator.and(page.locator('[aria-selected="true"]')).waitFor()
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function closeKclCodePanel(page: Page) {
 | 
			
		||||
  const paneLocator = page.getByRole('tab', { name: 'KCL Code', exact: false })
 | 
			
		||||
  const isOpen = (await paneLocator?.getAttribute('aria-selected')) === 'true'
 | 
			
		||||
  if (isOpen) {
 | 
			
		||||
    await paneLocator.click()
 | 
			
		||||
    await paneLocator
 | 
			
		||||
      .and(page.locator(':not([aria-selected="true"])'))
 | 
			
		||||
      .waitFor()
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function openDebugPanel(page: Page) {
 | 
			
		||||
  const debugLocator = page.getByRole('tab', { name: 'Debug', exact: false })
 | 
			
		||||
  const isOpen = (await debugLocator?.getAttribute('aria-selected')) === 'true'
 | 
			
		||||
 | 
			
		||||
  if (!isOpen) {
 | 
			
		||||
    await debugLocator.click()
 | 
			
		||||
    await debugLocator.and(page.locator('[aria-selected="true"]')).waitFor()
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function closeDebugPanel(page: Page) {
 | 
			
		||||
  const isOpen =
 | 
			
		||||
    (await page.getByTestId('debug-panel')?.getAttribute('open')) === ''
 | 
			
		||||
  const debugLocator = page.getByRole('tab', { name: 'Debug', exact: false })
 | 
			
		||||
  const isOpen = (await debugLocator?.getAttribute('aria-selected')) === 'true'
 | 
			
		||||
  if (isOpen) {
 | 
			
		||||
    await page.getByText('Debug').click()
 | 
			
		||||
    await page
 | 
			
		||||
      .getByTestId('debug-panel')
 | 
			
		||||
      .and(page.locator(':not([open])'))
 | 
			
		||||
    await debugLocator.click()
 | 
			
		||||
    await debugLocator
 | 
			
		||||
      .and(page.locator(':not([aria-selected="true"])'))
 | 
			
		||||
      .waitFor()
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -81,20 +99,19 @@ export function getUtils(page: Page) {
 | 
			
		||||
    removeCurrentCode: () => removeCurrentCode(page),
 | 
			
		||||
    sendCustomCmd: (cmd: EngineCommand) => sendCustomCmd(page, cmd),
 | 
			
		||||
    updateCamPosition: async (xyz: [number, number, number]) => {
 | 
			
		||||
      const fillInput = async () => {
 | 
			
		||||
        await page.fill('[data-testid="cam-x-position"]', String(xyz[0]))
 | 
			
		||||
        await page.fill('[data-testid="cam-y-position"]', String(xyz[1]))
 | 
			
		||||
        await page.fill('[data-testid="cam-z-position"]', String(xyz[2]))
 | 
			
		||||
      const fillInput = async (axis: 'x' | 'y' | 'z', value: number) => {
 | 
			
		||||
        await page.fill(`[data-testid="cam-${axis}-position"]`, String(value))
 | 
			
		||||
        await page.waitForTimeout(100)
 | 
			
		||||
      }
 | 
			
		||||
      await fillInput()
 | 
			
		||||
      await page.waitForTimeout(100)
 | 
			
		||||
      await fillInput()
 | 
			
		||||
      await page.waitForTimeout(100)
 | 
			
		||||
      await fillInput()
 | 
			
		||||
      await page.waitForTimeout(100)
 | 
			
		||||
 | 
			
		||||
      await fillInput('x', xyz[0])
 | 
			
		||||
      await fillInput('y', xyz[1])
 | 
			
		||||
      await fillInput('z', xyz[2])
 | 
			
		||||
    },
 | 
			
		||||
    clearCommandLogs: () => clearCommandLogs(page),
 | 
			
		||||
    expectCmdLog: (locatorStr: string) => expectCmdLog(page, locatorStr),
 | 
			
		||||
    openKclCodePanel: () => openKclCodePanel(page),
 | 
			
		||||
    closeKclCodePanel: () => closeKclCodePanel(page),
 | 
			
		||||
    openDebugPanel: () => openDebugPanel(page),
 | 
			
		||||
    closeDebugPanel: () => closeDebugPanel(page),
 | 
			
		||||
    openAndClearDebugPanel: async () => {
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										127
									
								
								src/App.tsx
									
									
									
									
									
								
							
							
						
						@ -1,22 +1,12 @@
 | 
			
		||||
import { useCallback, MouseEventHandler, useEffect, useRef } from 'react'
 | 
			
		||||
import { DebugPanel } from './components/DebugPanel'
 | 
			
		||||
import { MouseEventHandler, useEffect, useRef } from 'react'
 | 
			
		||||
import { uuidv4 } from 'lib/utils'
 | 
			
		||||
import { PaneType, useStore } from './useStore'
 | 
			
		||||
import { Logs, KCLErrors } from './components/Logs'
 | 
			
		||||
import { CollapsiblePanel } from './components/CollapsiblePanel'
 | 
			
		||||
import { MemoryPanel } from './components/MemoryPanel'
 | 
			
		||||
import { useStore } from './useStore'
 | 
			
		||||
import { useHotKeyListener } from './hooks/useHotKeyListener'
 | 
			
		||||
import { Stream } from './components/Stream'
 | 
			
		||||
import ModalContainer from 'react-modal-promise'
 | 
			
		||||
import { EngineCommand } from './lang/std/engineConnection'
 | 
			
		||||
import { throttle } from './lib/utils'
 | 
			
		||||
import { AppHeader } from './components/AppHeader'
 | 
			
		||||
import { Resizable } from 're-resizable'
 | 
			
		||||
import {
 | 
			
		||||
  faCode,
 | 
			
		||||
  faCodeCommit,
 | 
			
		||||
  faSquareRootVariable,
 | 
			
		||||
} from '@fortawesome/free-solid-svg-icons'
 | 
			
		||||
import { useHotkeys } from 'react-hotkeys-hook'
 | 
			
		||||
import { getNormalisedCoordinates } from './lib/utils'
 | 
			
		||||
import { useLoaderData, useNavigate } from 'react-router-dom'
 | 
			
		||||
@ -24,9 +14,6 @@ import { type IndexLoaderData } from 'lib/types'
 | 
			
		||||
import { paths } from 'lib/paths'
 | 
			
		||||
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
 | 
			
		||||
import { onboardingPaths } from 'routes/Onboarding/paths'
 | 
			
		||||
import { CodeMenu } from 'components/CodeMenu'
 | 
			
		||||
import { TextEditor } from 'components/TextEditor'
 | 
			
		||||
import { Themes, getSystemTheme } from 'lib/theme'
 | 
			
		||||
import { useEngineConnectionSubscriptions } from 'hooks/useEngineConnectionSubscriptions'
 | 
			
		||||
import { engineCommandManager } from 'lib/singletons'
 | 
			
		||||
import { useModelingContext } from 'hooks/useModelingContext'
 | 
			
		||||
@ -34,6 +21,7 @@ import { useAbsoluteFilePath } from 'hooks/useAbsoluteFilePath'
 | 
			
		||||
import { isTauri } from 'lib/isTauri'
 | 
			
		||||
import { useLspContext } from 'components/LspProvider'
 | 
			
		||||
import { useRefreshSettings } from 'hooks/useRefreshSettings'
 | 
			
		||||
import { ModelingSidebar } from 'components/ModelingSidebar/ModelingSidebar'
 | 
			
		||||
 | 
			
		||||
export function App() {
 | 
			
		||||
  useRefreshSettings(paths.FILE + 'SETTINGS')
 | 
			
		||||
@ -52,21 +40,13 @@ export function App() {
 | 
			
		||||
  }, [projectName, projectPath])
 | 
			
		||||
 | 
			
		||||
  useHotKeyListener()
 | 
			
		||||
  const {
 | 
			
		||||
    buttonDownInStream,
 | 
			
		||||
    openPanes,
 | 
			
		||||
    setOpenPanes,
 | 
			
		||||
    didDragInStream,
 | 
			
		||||
    streamDimensions,
 | 
			
		||||
    setHtmlRef,
 | 
			
		||||
  } = useStore((s) => ({
 | 
			
		||||
    buttonDownInStream: s.buttonDownInStream,
 | 
			
		||||
    openPanes: s.openPanes,
 | 
			
		||||
    setOpenPanes: s.setOpenPanes,
 | 
			
		||||
    didDragInStream: s.didDragInStream,
 | 
			
		||||
    streamDimensions: s.streamDimensions,
 | 
			
		||||
    setHtmlRef: s.setHtmlRef,
 | 
			
		||||
  }))
 | 
			
		||||
  const { buttonDownInStream, didDragInStream, streamDimensions, setHtmlRef } =
 | 
			
		||||
    useStore((s) => ({
 | 
			
		||||
      buttonDownInStream: s.buttonDownInStream,
 | 
			
		||||
      didDragInStream: s.didDragInStream,
 | 
			
		||||
      streamDimensions: s.streamDimensions,
 | 
			
		||||
      setHtmlRef: s.setHtmlRef,
 | 
			
		||||
    }))
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    setHtmlRef(ref)
 | 
			
		||||
@ -74,27 +54,10 @@ export function App() {
 | 
			
		||||
 | 
			
		||||
  const { settings } = useSettingsAuthContext()
 | 
			
		||||
  const {
 | 
			
		||||
    modeling: { showDebugPanel },
 | 
			
		||||
    app: { theme, onboardingStatus },
 | 
			
		||||
    app: { onboardingStatus },
 | 
			
		||||
  } = settings.context
 | 
			
		||||
  const { state, send } = useModelingContext()
 | 
			
		||||
 | 
			
		||||
  const editorTheme =
 | 
			
		||||
    theme.current === Themes.System ? getSystemTheme() : theme.current
 | 
			
		||||
 | 
			
		||||
  // Pane toggling keyboard shortcuts
 | 
			
		||||
  const togglePane = useCallback(
 | 
			
		||||
    (newPane: PaneType) =>
 | 
			
		||||
      openPanes.includes(newPane)
 | 
			
		||||
        ? setOpenPanes(openPanes.filter((p) => p !== newPane))
 | 
			
		||||
        : setOpenPanes([...openPanes, newPane]),
 | 
			
		||||
    [openPanes, setOpenPanes]
 | 
			
		||||
  )
 | 
			
		||||
  useHotkeys('shift + c', () => togglePane('code'))
 | 
			
		||||
  useHotkeys('shift + v', () => togglePane('variables'))
 | 
			
		||||
  useHotkeys('shift + l', () => togglePane('logs'))
 | 
			
		||||
  useHotkeys('shift + e', () => togglePane('kclErrors'))
 | 
			
		||||
  useHotkeys('shift + d', () => togglePane('debug'))
 | 
			
		||||
  useHotkeys('esc', () => send('Cancel'))
 | 
			
		||||
  useHotkeys('backspace', (e) => {
 | 
			
		||||
    e.preventDefault()
 | 
			
		||||
@ -161,74 +124,8 @@ export function App() {
 | 
			
		||||
        enableMenu={true}
 | 
			
		||||
      />
 | 
			
		||||
      <ModalContainer />
 | 
			
		||||
      <Resizable
 | 
			
		||||
        className={
 | 
			
		||||
          'pointer-events-none h-full flex flex-col flex-1 z-10 my-2 ml-2 pr-1 transition-opacity transition-duration-75 ' +
 | 
			
		||||
          +paneOpacity
 | 
			
		||||
        }
 | 
			
		||||
        defaultSize={{
 | 
			
		||||
          width: '550px',
 | 
			
		||||
          height: 'auto',
 | 
			
		||||
        }}
 | 
			
		||||
        minWidth={200}
 | 
			
		||||
        maxWidth={800}
 | 
			
		||||
        minHeight={'auto'}
 | 
			
		||||
        maxHeight={'auto'}
 | 
			
		||||
        handleClasses={{
 | 
			
		||||
          right:
 | 
			
		||||
            'hover:bg-chalkboard-10 hover:dark:bg-chalkboard-110 bg-transparent transition-colors duration-75 transition-ease-out delay-100 ' +
 | 
			
		||||
            (buttonDownInStream || onboardingStatus.current === 'camera'
 | 
			
		||||
              ? 'pointer-events-none '
 | 
			
		||||
              : 'pointer-events-auto'),
 | 
			
		||||
        }}
 | 
			
		||||
      >
 | 
			
		||||
        <div
 | 
			
		||||
          id="code-pane"
 | 
			
		||||
          className="h-full flex flex-col justify-between pointer-events-none"
 | 
			
		||||
        >
 | 
			
		||||
          <CollapsiblePanel
 | 
			
		||||
            title="Code"
 | 
			
		||||
            icon={faCode}
 | 
			
		||||
            className="open:!mb-2"
 | 
			
		||||
            open={openPanes.includes('code')}
 | 
			
		||||
            menu={<CodeMenu />}
 | 
			
		||||
          >
 | 
			
		||||
            <TextEditor theme={editorTheme} />
 | 
			
		||||
          </CollapsiblePanel>
 | 
			
		||||
          <section className="flex flex-col">
 | 
			
		||||
            <MemoryPanel
 | 
			
		||||
              theme={editorTheme}
 | 
			
		||||
              open={openPanes.includes('variables')}
 | 
			
		||||
              title="Variables"
 | 
			
		||||
              icon={faSquareRootVariable}
 | 
			
		||||
            />
 | 
			
		||||
            <Logs
 | 
			
		||||
              theme={editorTheme}
 | 
			
		||||
              open={openPanes.includes('logs')}
 | 
			
		||||
              title="Logs"
 | 
			
		||||
              icon={faCodeCommit}
 | 
			
		||||
            />
 | 
			
		||||
            <KCLErrors
 | 
			
		||||
              theme={editorTheme}
 | 
			
		||||
              open={openPanes.includes('kclErrors')}
 | 
			
		||||
              title="KCL Errors"
 | 
			
		||||
              iconClassNames={{ bg: 'group-open:bg-destroy-70' }}
 | 
			
		||||
            />
 | 
			
		||||
          </section>
 | 
			
		||||
        </div>
 | 
			
		||||
      </Resizable>
 | 
			
		||||
      <ModelingSidebar paneOpacity={paneOpacity} />
 | 
			
		||||
      <Stream className="absolute inset-0 z-0" />
 | 
			
		||||
      {showDebugPanel.current && (
 | 
			
		||||
        <DebugPanel
 | 
			
		||||
          title="Debug"
 | 
			
		||||
          className={
 | 
			
		||||
            'transition-opacity transition-duration-75 ' +
 | 
			
		||||
            paneOpacity +
 | 
			
		||||
            (buttonDownInStream ? ' pointer-events-none' : '')
 | 
			
		||||
          }
 | 
			
		||||
          open={openPanes.includes('debug')}
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
      {/* <CamToggle /> */}
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
@ -110,14 +110,6 @@ export class CameraControls {
 | 
			
		||||
    }, 400) as any as number
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // reacts hooks into some of this singleton's properties
 | 
			
		||||
  reactCameraProperties: ReactCameraProperties = {
 | 
			
		||||
    type: 'perspective',
 | 
			
		||||
    fov: 12,
 | 
			
		||||
    position: [0, 0, 0],
 | 
			
		||||
    quaternion: [0, 0, 0, 1],
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  setCam = (camProps: ReactCameraProperties) => {
 | 
			
		||||
    if (
 | 
			
		||||
      camProps.type === 'perspective' &&
 | 
			
		||||
@ -910,6 +902,26 @@ export class CameraControls {
 | 
			
		||||
        .start()
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
  get reactCameraProperties(): ReactCameraProperties {
 | 
			
		||||
    return {
 | 
			
		||||
      type: this.isPerspective ? 'perspective' : 'orthographic',
 | 
			
		||||
      [this.isPerspective ? 'fov' : 'zoom']:
 | 
			
		||||
        this.camera instanceof PerspectiveCamera
 | 
			
		||||
          ? this.camera.fov
 | 
			
		||||
          : this.camera.zoom,
 | 
			
		||||
      position: [
 | 
			
		||||
        roundOff(this.camera.position.x, 2),
 | 
			
		||||
        roundOff(this.camera.position.y, 2),
 | 
			
		||||
        roundOff(this.camera.position.z, 2),
 | 
			
		||||
      ],
 | 
			
		||||
      quaternion: [
 | 
			
		||||
        roundOff(this.camera.quaternion.x, 2),
 | 
			
		||||
        roundOff(this.camera.quaternion.y, 2),
 | 
			
		||||
        roundOff(this.camera.quaternion.z, 2),
 | 
			
		||||
        roundOff(this.camera.quaternion.w, 2),
 | 
			
		||||
      ],
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  reactCameraPropertiesCallback: (a: ReactCameraProperties) => void = () => {}
 | 
			
		||||
  setReactCameraPropertiesCallback = (
 | 
			
		||||
    cb: (a: ReactCameraProperties) => void
 | 
			
		||||
@ -937,24 +949,7 @@ export class CameraControls {
 | 
			
		||||
        isPerspective: this.isPerspective,
 | 
			
		||||
        target: this.target,
 | 
			
		||||
      })
 | 
			
		||||
    this.deferReactUpdate({
 | 
			
		||||
      type: this.isPerspective ? 'perspective' : 'orthographic',
 | 
			
		||||
      [this.isPerspective ? 'fov' : 'zoom']:
 | 
			
		||||
        this.camera instanceof PerspectiveCamera
 | 
			
		||||
          ? this.camera.fov
 | 
			
		||||
          : this.camera.zoom,
 | 
			
		||||
      position: [
 | 
			
		||||
        roundOff(this.camera.position.x, 2),
 | 
			
		||||
        roundOff(this.camera.position.y, 2),
 | 
			
		||||
        roundOff(this.camera.position.z, 2),
 | 
			
		||||
      ],
 | 
			
		||||
      quaternion: [
 | 
			
		||||
        roundOff(this.camera.quaternion.x, 2),
 | 
			
		||||
        roundOff(this.camera.quaternion.y, 2),
 | 
			
		||||
        roundOff(this.camera.quaternion.z, 2),
 | 
			
		||||
        roundOff(this.camera.quaternion.w, 2),
 | 
			
		||||
      ],
 | 
			
		||||
    })
 | 
			
		||||
    this.deferReactUpdate(this.reactCameraProperties)
 | 
			
		||||
    Object.values(this._camChangeCallbacks).forEach((cb) => cb())
 | 
			
		||||
  }
 | 
			
		||||
  getInteractionType = (event: any) =>
 | 
			
		||||
 | 
			
		||||
@ -126,12 +126,9 @@ const throttled = throttle((a: ReactCameraProperties) => {
 | 
			
		||||
}, 1000 / 15)
 | 
			
		||||
 | 
			
		||||
export const CamDebugSettings = () => {
 | 
			
		||||
  const [camSettings, setCamSettings] = useState<ReactCameraProperties>({
 | 
			
		||||
    type: 'perspective',
 | 
			
		||||
    fov: 12,
 | 
			
		||||
    position: [0, 0, 0],
 | 
			
		||||
    quaternion: [0, 0, 0, 1],
 | 
			
		||||
  })
 | 
			
		||||
  const [camSettings, setCamSettings] = useState<ReactCameraProperties>(
 | 
			
		||||
    sceneInfra.camControls.reactCameraProperties
 | 
			
		||||
  )
 | 
			
		||||
  const [fov, setFov] = useState(12)
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
 | 
			
		||||
@ -16,7 +16,7 @@ export function AstExplorer() {
 | 
			
		||||
  const [filterKeys, setFilterKeys] = useState<string[]>(['start', 'end'])
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="relative" style={{ width: '300px' }}>
 | 
			
		||||
    <div id="ast-explorer" className="relative">
 | 
			
		||||
      <div className="">
 | 
			
		||||
        filter out keys:<div className="w-2 inline-block"></div>
 | 
			
		||||
        {['start', 'end', 'type'].map((key) => {
 | 
			
		||||
@ -45,7 +45,7 @@ export function AstExplorer() {
 | 
			
		||||
          setHighlightRange([0, 0])
 | 
			
		||||
        }}
 | 
			
		||||
      >
 | 
			
		||||
        <pre className=" text-xs overflow-y-auto" style={{ width: '300px' }}>
 | 
			
		||||
        <pre className="text-xs">
 | 
			
		||||
          <DisplayObj
 | 
			
		||||
            obj={kclManager.ast}
 | 
			
		||||
            filterKeys={filterKeys}
 | 
			
		||||
@ -109,7 +109,7 @@ function DisplayObj({
 | 
			
		||||
    <pre
 | 
			
		||||
      ref={ref}
 | 
			
		||||
      className={`ml-2 border-l border-violet-600 pl-1 ${
 | 
			
		||||
        hasCursor ? 'bg-violet-100/25' : ''
 | 
			
		||||
        hasCursor ? 'bg-violet-100/80 dark:bg-violet-100/25' : ''
 | 
			
		||||
      }`}
 | 
			
		||||
      onMouseEnter={(e) => {
 | 
			
		||||
        setHighlightRange([obj?.start || 0, obj.end])
 | 
			
		||||
 | 
			
		||||
@ -1,57 +0,0 @@
 | 
			
		||||
.panel {
 | 
			
		||||
  @apply relative z-0;
 | 
			
		||||
  @apply bg-chalkboard-10/70 backdrop-blur-sm;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.header::before,
 | 
			
		||||
.header::-webkit-details-marker {
 | 
			
		||||
  display: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
:global(.dark) .panel {
 | 
			
		||||
  @apply bg-chalkboard-110/50 backdrop-blur-0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.header {
 | 
			
		||||
  @apply sticky top-0 z-10 cursor-pointer;
 | 
			
		||||
  @apply flex items-center justify-between gap-2 w-full p-2;
 | 
			
		||||
  @apply font-mono text-xs font-bold select-none text-chalkboard-90;
 | 
			
		||||
  @apply bg-chalkboard-10;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.header:not(:last-of-type) {
 | 
			
		||||
  @apply border-b;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
:global(.dark) .header {
 | 
			
		||||
  @apply bg-chalkboard-110 border-b-chalkboard-90 text-chalkboard-30;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
:global(.dark) .header:not(:last-of-type) {
 | 
			
		||||
  @apply border-b-2;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.panel:first-of-type .header {
 | 
			
		||||
  @apply rounded-t;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.panel:last-of-type .header {
 | 
			
		||||
  @apply rounded-b;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.panel[open] .header {
 | 
			
		||||
  @apply rounded-t rounded-b-none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.panel[open] {
 | 
			
		||||
  @apply flex-grow max-h-full h-48 my-1 rounded;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.panel[open] + .panel[open],
 | 
			
		||||
.panel[open]:first-of-type {
 | 
			
		||||
  @apply mt-0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.panel[open]:last-of-type {
 | 
			
		||||
  @apply mb-0;
 | 
			
		||||
}
 | 
			
		||||
@ -1,76 +0,0 @@
 | 
			
		||||
import { IconDefinition } from '@fortawesome/free-solid-svg-icons'
 | 
			
		||||
import { ActionIcon } from './ActionIcon'
 | 
			
		||||
import styles from './CollapsiblePanel.module.css'
 | 
			
		||||
 | 
			
		||||
export interface CollapsiblePanelProps
 | 
			
		||||
  extends React.PropsWithChildren,
 | 
			
		||||
    React.HTMLAttributes<HTMLDetailsElement> {
 | 
			
		||||
  title: string
 | 
			
		||||
  icon?: IconDefinition
 | 
			
		||||
  open?: boolean
 | 
			
		||||
  menu?: React.ReactNode
 | 
			
		||||
  detailsTestId?: string
 | 
			
		||||
  iconClassNames?: {
 | 
			
		||||
    bg?: string
 | 
			
		||||
    icon?: string
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const PanelHeader = ({
 | 
			
		||||
  title,
 | 
			
		||||
  icon,
 | 
			
		||||
  iconClassNames,
 | 
			
		||||
  menu,
 | 
			
		||||
}: CollapsiblePanelProps) => {
 | 
			
		||||
  return (
 | 
			
		||||
    <summary className={styles.header}>
 | 
			
		||||
      <div className="flex gap-2 items-center flex-1">
 | 
			
		||||
        <ActionIcon
 | 
			
		||||
          icon={icon}
 | 
			
		||||
          className="p-1"
 | 
			
		||||
          size="sm"
 | 
			
		||||
          bgClassName={
 | 
			
		||||
            'dark:!bg-transparent group-open:bg-primary dark:group-open:!bg-primary rounded-sm ' +
 | 
			
		||||
            (iconClassNames?.bg || '')
 | 
			
		||||
          }
 | 
			
		||||
          iconClassName={
 | 
			
		||||
            'group-open:text-chalkboard-10 ' + (iconClassNames?.icon || '')
 | 
			
		||||
          }
 | 
			
		||||
        />
 | 
			
		||||
        {title}
 | 
			
		||||
      </div>
 | 
			
		||||
      <div className="group-open:opacity-100 opacity-0 group-open:pointer-events-auto pointer-events-none">
 | 
			
		||||
        {menu}
 | 
			
		||||
      </div>
 | 
			
		||||
    </summary>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const CollapsiblePanel = ({
 | 
			
		||||
  title,
 | 
			
		||||
  icon,
 | 
			
		||||
  children,
 | 
			
		||||
  className,
 | 
			
		||||
  iconClassNames,
 | 
			
		||||
  menu,
 | 
			
		||||
  detailsTestId,
 | 
			
		||||
  ...props
 | 
			
		||||
}: CollapsiblePanelProps) => {
 | 
			
		||||
  return (
 | 
			
		||||
    <details
 | 
			
		||||
      {...props}
 | 
			
		||||
      data-testid={detailsTestId}
 | 
			
		||||
      className={
 | 
			
		||||
        styles.panel + ' pointer-events-auto group ' + (className || '')
 | 
			
		||||
      }
 | 
			
		||||
    >
 | 
			
		||||
      <PanelHeader
 | 
			
		||||
        title={title}
 | 
			
		||||
        icon={icon}
 | 
			
		||||
        iconClassNames={iconClassNames}
 | 
			
		||||
        menu={menu}
 | 
			
		||||
      />
 | 
			
		||||
      {children}
 | 
			
		||||
    </details>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
@ -1,26 +0,0 @@
 | 
			
		||||
import { CollapsiblePanel, CollapsiblePanelProps } from './CollapsiblePanel'
 | 
			
		||||
import { AstExplorer } from './AstExplorer'
 | 
			
		||||
import { EngineCommands } from './EngineCommands'
 | 
			
		||||
import { CamDebugSettings } from 'clientSideScene/ClientSideSceneComp'
 | 
			
		||||
 | 
			
		||||
export const DebugPanel = ({ className, ...props }: CollapsiblePanelProps) => {
 | 
			
		||||
  return (
 | 
			
		||||
    <CollapsiblePanel
 | 
			
		||||
      {...props}
 | 
			
		||||
      className={
 | 
			
		||||
        '!absolute overflow-auto !h-auto bottom-5 right-5 ' + className
 | 
			
		||||
      }
 | 
			
		||||
      // header height, top-5, and bottom-5
 | 
			
		||||
      style={{ maxHeight: 'calc(100% - 3rem - 1.25rem - 1.25rem)' }}
 | 
			
		||||
      detailsTestId="debug-panel"
 | 
			
		||||
    >
 | 
			
		||||
      <section className="p-4 flex flex-col gap-4">
 | 
			
		||||
        <EngineCommands />
 | 
			
		||||
        <CamDebugSettings />
 | 
			
		||||
        <div style={{ height: '400px' }} className="overflow-y-auto">
 | 
			
		||||
          <AstExplorer />
 | 
			
		||||
        </div>
 | 
			
		||||
      </section>
 | 
			
		||||
    </CollapsiblePanel>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
@ -1,76 +0,0 @@
 | 
			
		||||
import ReactJson from 'react-json-view'
 | 
			
		||||
import { useEffect } from 'react'
 | 
			
		||||
import { CollapsiblePanel, CollapsiblePanelProps } from './CollapsiblePanel'
 | 
			
		||||
import { Themes } from '../lib/theme'
 | 
			
		||||
import { useKclContext } from 'lang/KclProvider'
 | 
			
		||||
 | 
			
		||||
const ReactJsonTypeHack = ReactJson as any
 | 
			
		||||
 | 
			
		||||
interface LogPanelProps extends CollapsiblePanelProps {
 | 
			
		||||
  theme?: Exclude<Themes, Themes.System>
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const Logs = ({ theme = Themes.Light, ...props }: LogPanelProps) => {
 | 
			
		||||
  const { logs } = useKclContext()
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const element = document.querySelector('.console-tile')
 | 
			
		||||
    if (element) {
 | 
			
		||||
      element.scrollTop = element.scrollHeight - element.clientHeight
 | 
			
		||||
    }
 | 
			
		||||
  }, [logs])
 | 
			
		||||
  return (
 | 
			
		||||
    <CollapsiblePanel {...props}>
 | 
			
		||||
      <div className="relative w-full">
 | 
			
		||||
        <div className="absolute inset-0 flex flex-col">
 | 
			
		||||
          <ReactJsonTypeHack
 | 
			
		||||
            src={logs}
 | 
			
		||||
            collapsed={1}
 | 
			
		||||
            collapseStringsAfterLength={60}
 | 
			
		||||
            enableClipboard={false}
 | 
			
		||||
            displayArrayKey={false}
 | 
			
		||||
            displayDataTypes={false}
 | 
			
		||||
            displayObjectSize={true}
 | 
			
		||||
            indentWidth={2}
 | 
			
		||||
            quotesOnKeys={false}
 | 
			
		||||
            name={false}
 | 
			
		||||
            theme={theme === 'light' ? 'rjv-default' : 'monokai'}
 | 
			
		||||
          />
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </CollapsiblePanel>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const KCLErrors = ({
 | 
			
		||||
  theme = Themes.Light,
 | 
			
		||||
  ...props
 | 
			
		||||
}: LogPanelProps) => {
 | 
			
		||||
  const { errors } = useKclContext()
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const element = document.querySelector('.console-tile')
 | 
			
		||||
    if (element) {
 | 
			
		||||
      element.scrollTop = element.scrollHeight - element.clientHeight
 | 
			
		||||
    }
 | 
			
		||||
  }, [errors])
 | 
			
		||||
  return (
 | 
			
		||||
    <CollapsiblePanel {...props}>
 | 
			
		||||
      <div className="h-full relative">
 | 
			
		||||
        <div className="absolute inset-0 flex flex-col">
 | 
			
		||||
          <ReactJsonTypeHack
 | 
			
		||||
            src={errors}
 | 
			
		||||
            collapsed={1}
 | 
			
		||||
            collapseStringsAfterLength={60}
 | 
			
		||||
            enableClipboard={false}
 | 
			
		||||
            displayArrayKey={false}
 | 
			
		||||
            displayDataTypes={false}
 | 
			
		||||
            displayObjectSize={true}
 | 
			
		||||
            indentWidth={2}
 | 
			
		||||
            quotesOnKeys={false}
 | 
			
		||||
            name={false}
 | 
			
		||||
            theme={theme === 'light' ? 'rjv-default' : 'monokai'}
 | 
			
		||||
          />
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </CollapsiblePanel>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
@ -1,70 +0,0 @@
 | 
			
		||||
import ReactJson from 'react-json-view'
 | 
			
		||||
import { CollapsiblePanel, CollapsiblePanelProps } from './CollapsiblePanel'
 | 
			
		||||
import { useMemo } from 'react'
 | 
			
		||||
import { ProgramMemory, Path, ExtrudeSurface } from '../lang/wasm'
 | 
			
		||||
import { Themes } from '../lib/theme'
 | 
			
		||||
import { useKclContext } from 'lang/KclProvider'
 | 
			
		||||
 | 
			
		||||
interface MemoryPanelProps extends CollapsiblePanelProps {
 | 
			
		||||
  theme?: Exclude<Themes, Themes.System>
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const MemoryPanel = ({
 | 
			
		||||
  theme = Themes.Light,
 | 
			
		||||
  ...props
 | 
			
		||||
}: MemoryPanelProps) => {
 | 
			
		||||
  const { programMemory } = useKclContext()
 | 
			
		||||
  const ProcessedMemory = useMemo(
 | 
			
		||||
    () => processMemory(programMemory),
 | 
			
		||||
    [programMemory]
 | 
			
		||||
  )
 | 
			
		||||
  return (
 | 
			
		||||
    <CollapsiblePanel {...props}>
 | 
			
		||||
      <div className="h-full relative">
 | 
			
		||||
        <div className="absolute inset-0 flex flex-col items-start">
 | 
			
		||||
          <div
 | 
			
		||||
            className="overflow-y-auto h-full console-tile w-full"
 | 
			
		||||
            style={{ marginBottom: 36 }}
 | 
			
		||||
          >
 | 
			
		||||
            {/* 36px is the height of PanelHeader */}
 | 
			
		||||
            <ReactJson
 | 
			
		||||
              src={ProcessedMemory}
 | 
			
		||||
              collapsed={1}
 | 
			
		||||
              collapseStringsAfterLength={60}
 | 
			
		||||
              enableClipboard={false}
 | 
			
		||||
              displayDataTypes={false}
 | 
			
		||||
              displayObjectSize={true}
 | 
			
		||||
              indentWidth={2}
 | 
			
		||||
              quotesOnKeys={false}
 | 
			
		||||
              name={false}
 | 
			
		||||
              theme={theme === 'light' ? 'rjv-default' : 'monokai'}
 | 
			
		||||
            />
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </CollapsiblePanel>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const processMemory = (programMemory: ProgramMemory) => {
 | 
			
		||||
  const processedMemory: any = {}
 | 
			
		||||
  Object.keys(programMemory?.root || {}).forEach((key) => {
 | 
			
		||||
    const val = programMemory.root[key]
 | 
			
		||||
    if (typeof val.value !== 'function') {
 | 
			
		||||
      if (val.type === 'SketchGroup') {
 | 
			
		||||
        processedMemory[key] = val.value.map(({ __geoMeta, ...rest }: Path) => {
 | 
			
		||||
          return rest
 | 
			
		||||
        })
 | 
			
		||||
      } else if (val.type === 'ExtrudeGroup') {
 | 
			
		||||
        processedMemory[key] = val.value.map(({ ...rest }: ExtrudeSurface) => {
 | 
			
		||||
          return rest
 | 
			
		||||
        })
 | 
			
		||||
      } else {
 | 
			
		||||
        processedMemory[key] = val.value
 | 
			
		||||
      }
 | 
			
		||||
    } else if (key !== 'log') {
 | 
			
		||||
      processedMemory[key] = '__function__'
 | 
			
		||||
    }
 | 
			
		||||
  })
 | 
			
		||||
  return processedMemory
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										27
									
								
								src/components/ModelingSidebar/ModelingPane.module.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,27 @@
 | 
			
		||||
.panel {
 | 
			
		||||
  @apply relative z-0 rounded-r max-w-full h-full flex-1;
 | 
			
		||||
  display: grid;
 | 
			
		||||
  grid-template-rows: auto 1fr;
 | 
			
		||||
  @apply bg-chalkboard-10/50 backdrop-blur-sm border border-chalkboard-20;
 | 
			
		||||
  scroll-margin-block-start: 41px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.header::before,
 | 
			
		||||
.header::-webkit-details-marker {
 | 
			
		||||
  display: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
:global(.dark) .panel {
 | 
			
		||||
  @apply bg-chalkboard-100/50 backdrop-blur-[3px] border-chalkboard-80;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.header {
 | 
			
		||||
  @apply z-10 relative rounded-tr;
 | 
			
		||||
  @apply flex h-[41px] items-center justify-between gap-2 px-2;
 | 
			
		||||
  @apply font-mono text-xs font-bold select-none text-chalkboard-90;
 | 
			
		||||
  @apply bg-chalkboard-10 border-b border-chalkboard-20;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
:global(.dark) .header {
 | 
			
		||||
  @apply bg-chalkboard-90 text-chalkboard-30 border-chalkboard-80;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										50
									
								
								src/components/ModelingSidebar/ModelingPane.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,50 @@
 | 
			
		||||
import { useStore } from 'useStore'
 | 
			
		||||
import styles from './ModelingPane.module.css'
 | 
			
		||||
 | 
			
		||||
export interface ModelingPaneProps
 | 
			
		||||
  extends React.PropsWithChildren,
 | 
			
		||||
    React.HTMLAttributes<HTMLDivElement> {
 | 
			
		||||
  title: string
 | 
			
		||||
  Menu?: React.ReactNode | React.FC
 | 
			
		||||
  detailsTestId?: string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const ModelingPaneHeader = ({
 | 
			
		||||
  title,
 | 
			
		||||
  Menu,
 | 
			
		||||
}: Pick<ModelingPaneProps, 'title' | 'Menu'>) => {
 | 
			
		||||
  return (
 | 
			
		||||
    <div className={styles.header}>
 | 
			
		||||
      <div className="flex gap-2 items-center flex-1">{title}</div>
 | 
			
		||||
      {Menu instanceof Function ? <Menu /> : Menu}
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const ModelingPane = ({
 | 
			
		||||
  title,
 | 
			
		||||
  children,
 | 
			
		||||
  className,
 | 
			
		||||
  Menu,
 | 
			
		||||
  detailsTestId,
 | 
			
		||||
  ...props
 | 
			
		||||
}: ModelingPaneProps) => {
 | 
			
		||||
  const { buttonDownInStream } = useStore((s) => ({
 | 
			
		||||
    buttonDownInStream: s.buttonDownInStream,
 | 
			
		||||
  }))
 | 
			
		||||
  return (
 | 
			
		||||
    <section
 | 
			
		||||
      {...props}
 | 
			
		||||
      data-testid={detailsTestId}
 | 
			
		||||
      className={
 | 
			
		||||
        (buttonDownInStream ? 'pointer-events-none ' : 'pointer-events-auto ') +
 | 
			
		||||
        styles.panel +
 | 
			
		||||
        ' group ' +
 | 
			
		||||
        (className || '')
 | 
			
		||||
      }
 | 
			
		||||
    >
 | 
			
		||||
      <ModelingPaneHeader title={title} Menu={Menu} />
 | 
			
		||||
      <div className="relative w-full">{children}</div>
 | 
			
		||||
    </section>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										18
									
								
								src/components/ModelingSidebar/ModelingPanes/DebugPane.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,18 @@
 | 
			
		||||
import { AstExplorer } from '../../AstExplorer'
 | 
			
		||||
import { EngineCommands } from '../../EngineCommands'
 | 
			
		||||
import { CamDebugSettings } from 'clientSideScene/ClientSideSceneComp'
 | 
			
		||||
 | 
			
		||||
export const DebugPane = () => {
 | 
			
		||||
  return (
 | 
			
		||||
    <section
 | 
			
		||||
      data-testid="debug-panel"
 | 
			
		||||
      className="absolute inset-0 p-2 box-border overflow-auto"
 | 
			
		||||
    >
 | 
			
		||||
      <div className="flex flex-col">
 | 
			
		||||
        <EngineCommands />
 | 
			
		||||
        <CamDebugSettings />
 | 
			
		||||
        <AstExplorer />
 | 
			
		||||
      </div>
 | 
			
		||||
    </section>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
@ -1,14 +1,14 @@
 | 
			
		||||
import { Menu } from '@headlessui/react'
 | 
			
		||||
import { PropsWithChildren } from 'react'
 | 
			
		||||
import { faArrowUpRightFromSquare } from '@fortawesome/free-solid-svg-icons'
 | 
			
		||||
import { ActionIcon } from './ActionIcon'
 | 
			
		||||
import styles from './CodeMenu.module.css'
 | 
			
		||||
import { ActionIcon } from 'components/ActionIcon'
 | 
			
		||||
import styles from './KclEditorMenu.module.css'
 | 
			
		||||
import { useConvertToVariable } from 'hooks/useToolbarGuards'
 | 
			
		||||
import { editorShortcutMeta } from './TextEditor'
 | 
			
		||||
import { editorShortcutMeta } from './KclEditorPane'
 | 
			
		||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
 | 
			
		||||
import { kclManager } from 'lib/singletons'
 | 
			
		||||
 | 
			
		||||
export const CodeMenu = ({ children }: PropsWithChildren) => {
 | 
			
		||||
export const KclEditorMenu = ({ children }: PropsWithChildren) => {
 | 
			
		||||
  const { enable: convertToVarEnabled, handleClick: handleConvertToVarClick } =
 | 
			
		||||
    useConvertToVariable()
 | 
			
		||||
 | 
			
		||||
@ -30,7 +30,7 @@ export const CodeMenu = ({ children }: PropsWithChildren) => {
 | 
			
		||||
            className="p-1"
 | 
			
		||||
            size="sm"
 | 
			
		||||
            bgClassName={
 | 
			
		||||
              '!bg-transparent hover:!bg-primary/10 hover:dark:!bg-chalkboard-100 ui-active:!bg-primary/10 dark:ui-active:!bg-chalkboard-100 rounded-sm'
 | 
			
		||||
              '!bg-transparent hover:!bg-primary/10 hover:dark:!bg-chalkboard-100 ui-open:!bg-primary/10 dark:ui-open:!bg-chalkboard-100 rounded-sm'
 | 
			
		||||
            }
 | 
			
		||||
            iconClassName={'!text-chalkboard-90 dark:!text-chalkboard-40'}
 | 
			
		||||
          />
 | 
			
		||||
@ -65,7 +65,7 @@ export const CodeMenu = ({ children }: PropsWithChildren) => {
 | 
			
		||||
            >
 | 
			
		||||
              <span>Read the KCL docs</span>
 | 
			
		||||
              <small>
 | 
			
		||||
                On GitHub
 | 
			
		||||
                zoo.dev
 | 
			
		||||
                <FontAwesomeIcon
 | 
			
		||||
                  icon={faArrowUpRightFromSquare}
 | 
			
		||||
                  className="ml-1 align-text-top"
 | 
			
		||||
@ -83,7 +83,7 @@ export const CodeMenu = ({ children }: PropsWithChildren) => {
 | 
			
		||||
            >
 | 
			
		||||
              <span>KCL samples</span>
 | 
			
		||||
              <small>
 | 
			
		||||
                On GitHub
 | 
			
		||||
                zoo.dev
 | 
			
		||||
                <FontAwesomeIcon
 | 
			
		||||
                  icon={faArrowUpRightFromSquare}
 | 
			
		||||
                  className="ml-1 align-text-top"
 | 
			
		||||
@ -3,12 +3,13 @@ import ReactCodeMirror, {
 | 
			
		||||
  Extension,
 | 
			
		||||
  ViewUpdate,
 | 
			
		||||
  SelectionRange,
 | 
			
		||||
  drawSelection,
 | 
			
		||||
} from '@uiw/react-codemirror'
 | 
			
		||||
import { TEST } from 'env'
 | 
			
		||||
import { useCommandsContext } from 'hooks/useCommandsContext'
 | 
			
		||||
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
 | 
			
		||||
import { useConvertToVariable } from 'hooks/useToolbarGuards'
 | 
			
		||||
import { Themes } from 'lib/theme'
 | 
			
		||||
import { Themes, getSystemTheme } from 'lib/theme'
 | 
			
		||||
import { useEffect, useMemo, useRef } from 'react'
 | 
			
		||||
import { useStore } from 'useStore'
 | 
			
		||||
import { processCodeMirrorRanges } from 'lib/selections'
 | 
			
		||||
@ -37,15 +38,21 @@ import {
 | 
			
		||||
  bracketMatching,
 | 
			
		||||
  indentOnInput,
 | 
			
		||||
} from '@codemirror/language'
 | 
			
		||||
import { CSSRuleObject } from 'tailwindcss/types/config'
 | 
			
		||||
import { useModelingContext } from 'hooks/useModelingContext'
 | 
			
		||||
import interact from '@replit/codemirror-interact'
 | 
			
		||||
import { engineCommandManager, sceneInfra, kclManager } from 'lib/singletons'
 | 
			
		||||
import { useKclContext } from 'lang/KclProvider'
 | 
			
		||||
import { ModelingMachineEvent } from 'machines/modelingMachine'
 | 
			
		||||
import { NetworkHealthState, useNetworkStatus } from './NetworkHealthIndicator'
 | 
			
		||||
import {
 | 
			
		||||
  NetworkHealthState,
 | 
			
		||||
  useNetworkStatus,
 | 
			
		||||
} from 'components/NetworkHealthIndicator'
 | 
			
		||||
import { useHotkeys } from 'react-hotkeys-hook'
 | 
			
		||||
import { useLspContext } from './LspProvider'
 | 
			
		||||
import { isTauri } from 'lib/isTauri'
 | 
			
		||||
import { useNavigate } from 'react-router-dom'
 | 
			
		||||
import { paths } from 'lib/paths'
 | 
			
		||||
import makeUrlPathRelative from 'lib/makeUrlPathRelative'
 | 
			
		||||
import { useLspContext } from 'components/LspProvider'
 | 
			
		||||
import { Prec, EditorState } from '@codemirror/state'
 | 
			
		||||
import {
 | 
			
		||||
  closeBrackets,
 | 
			
		||||
@ -65,11 +72,14 @@ export const editorShortcutMeta = {
 | 
			
		||||
  },
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const TextEditor = ({
 | 
			
		||||
  theme,
 | 
			
		||||
}: {
 | 
			
		||||
  theme: Themes.Light | Themes.Dark
 | 
			
		||||
}) => {
 | 
			
		||||
export const KclEditorPane = () => {
 | 
			
		||||
  const {
 | 
			
		||||
    settings: { context },
 | 
			
		||||
  } = useSettingsAuthContext()
 | 
			
		||||
  const theme =
 | 
			
		||||
    context.app.theme.current === Themes.System
 | 
			
		||||
      ? getSystemTheme()
 | 
			
		||||
      : context.app.theme.current
 | 
			
		||||
  const { editorView, setEditorView, isShiftDown } = useStore((s) => ({
 | 
			
		||||
    editorView: s.editorView,
 | 
			
		||||
    setEditorView: s.setEditorView,
 | 
			
		||||
@ -80,6 +90,7 @@ export const TextEditor = ({
 | 
			
		||||
  const { overallState } = useNetworkStatus()
 | 
			
		||||
  const isNetworkOkay = overallState === NetworkHealthState.Ok
 | 
			
		||||
  const { copilotLSP, kclLSP } = useLspContext()
 | 
			
		||||
  const navigate = useNavigate()
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (typeof window === 'undefined') return
 | 
			
		||||
@ -109,6 +120,7 @@ export const TextEditor = ({
 | 
			
		||||
 | 
			
		||||
  const { settings } = useSettingsAuthContext()
 | 
			
		||||
  const textWrapping = settings.context.textEditor.textWrapping
 | 
			
		||||
  const cursorBlinking = settings.context.textEditor.blinkingCursor
 | 
			
		||||
  const { commandBarSend } = useCommandsContext()
 | 
			
		||||
  const { enable: convertEnabled, handleClick: convertCallback } =
 | 
			
		||||
    useConvertToVariable()
 | 
			
		||||
@ -189,6 +201,9 @@ export const TextEditor = ({
 | 
			
		||||
 | 
			
		||||
  const editorExtensions = useMemo(() => {
 | 
			
		||||
    const extensions = [
 | 
			
		||||
      drawSelection({
 | 
			
		||||
        cursorBlinkRate: cursorBlinking.current ? 1200 : 0,
 | 
			
		||||
      }),
 | 
			
		||||
      lineHighlightField,
 | 
			
		||||
      history(),
 | 
			
		||||
      closeBrackets(),
 | 
			
		||||
@ -208,6 +223,13 @@ export const TextEditor = ({
 | 
			
		||||
            return false
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          key: isTauri() ? 'Meta-,' : 'Meta-Shift-,',
 | 
			
		||||
          run: () => {
 | 
			
		||||
            navigate(makeUrlPathRelative(paths.SETTINGS))
 | 
			
		||||
            return false
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          key: editorShortcutMeta.formatCode.codeMirror,
 | 
			
		||||
          run: () => {
 | 
			
		||||
@ -287,16 +309,14 @@ export const TextEditor = ({
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return extensions
 | 
			
		||||
  }, [kclLSP, textWrapping.current, convertCallback])
 | 
			
		||||
  }, [kclLSP, textWrapping.current, cursorBlinking.current, convertCallback])
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      id="code-mirror-override"
 | 
			
		||||
      className="full-height-subtract"
 | 
			
		||||
      style={{ '--height-subtract': '4.25rem' } as CSSRuleObject}
 | 
			
		||||
      className={'absolute inset-0 ' + (cursorBlinking.current ? 'blink' : '')}
 | 
			
		||||
    >
 | 
			
		||||
      <ReactCodeMirror
 | 
			
		||||
        className="h-full"
 | 
			
		||||
        value={code}
 | 
			
		||||
        extensions={editorExtensions}
 | 
			
		||||
        onChange={onChange}
 | 
			
		||||
@ -0,0 +1,53 @@
 | 
			
		||||
import ReactJson from 'react-json-view'
 | 
			
		||||
import { useKclContext } from 'lang/KclProvider'
 | 
			
		||||
import { useResolvedTheme } from 'hooks/useResolvedTheme'
 | 
			
		||||
 | 
			
		||||
const ReactJsonTypeHack = ReactJson as any
 | 
			
		||||
 | 
			
		||||
export const LogsPane = () => {
 | 
			
		||||
  const theme = useResolvedTheme()
 | 
			
		||||
  const { logs } = useKclContext()
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="overflow-hidden">
 | 
			
		||||
      <div className="absolute inset-0 p-2 flex flex-col overflow-auto">
 | 
			
		||||
        <ReactJsonTypeHack
 | 
			
		||||
          src={logs}
 | 
			
		||||
          collapsed={1}
 | 
			
		||||
          collapseStringsAfterLength={60}
 | 
			
		||||
          enableClipboard={false}
 | 
			
		||||
          displayArrayKey={false}
 | 
			
		||||
          displayDataTypes={false}
 | 
			
		||||
          displayObjectSize={true}
 | 
			
		||||
          indentWidth={2}
 | 
			
		||||
          quotesOnKeys={false}
 | 
			
		||||
          name={false}
 | 
			
		||||
          theme={theme === 'light' ? 'rjv-default' : 'monokai'}
 | 
			
		||||
        />
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const KclErrorsPane = () => {
 | 
			
		||||
  const theme = useResolvedTheme()
 | 
			
		||||
  const { errors } = useKclContext()
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="overflow-hidden">
 | 
			
		||||
      <div className="absolute inset-0 p-2 flex flex-col overflow-auto">
 | 
			
		||||
        <ReactJsonTypeHack
 | 
			
		||||
          src={errors}
 | 
			
		||||
          collapsed={1}
 | 
			
		||||
          collapseStringsAfterLength={60}
 | 
			
		||||
          enableClipboard={false}
 | 
			
		||||
          displayArrayKey={false}
 | 
			
		||||
          displayDataTypes={false}
 | 
			
		||||
          displayObjectSize={true}
 | 
			
		||||
          indentWidth={2}
 | 
			
		||||
          quotesOnKeys={false}
 | 
			
		||||
          name={false}
 | 
			
		||||
          theme={theme === 'light' ? 'rjv-default' : 'monokai'}
 | 
			
		||||
        />
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
@ -1,6 +1,6 @@
 | 
			
		||||
import { processMemory } from './MemoryPanel'
 | 
			
		||||
import { enginelessExecutor } from '../lib/testHelpers'
 | 
			
		||||
import { initPromise, parse } from '../lang/wasm'
 | 
			
		||||
import { processMemory } from './MemoryPane'
 | 
			
		||||
import { enginelessExecutor } from '../../../lib/testHelpers'
 | 
			
		||||
import { initPromise, parse } from '../../../lang/wasm'
 | 
			
		||||
 | 
			
		||||
beforeAll(() => initPromise)
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										57
									
								
								src/components/ModelingSidebar/ModelingPanes/MemoryPane.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,57 @@
 | 
			
		||||
import ReactJson from 'react-json-view'
 | 
			
		||||
import { useMemo } from 'react'
 | 
			
		||||
import { ProgramMemory, Path, ExtrudeSurface } from 'lang/wasm'
 | 
			
		||||
import { useKclContext } from 'lang/KclProvider'
 | 
			
		||||
import { useResolvedTheme } from 'hooks/useResolvedTheme'
 | 
			
		||||
 | 
			
		||||
export const MemoryPane = () => {
 | 
			
		||||
  const theme = useResolvedTheme()
 | 
			
		||||
  const { programMemory } = useKclContext()
 | 
			
		||||
  const ProcessedMemory = useMemo(
 | 
			
		||||
    () => processMemory(programMemory),
 | 
			
		||||
    [programMemory]
 | 
			
		||||
  )
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="h-full relative">
 | 
			
		||||
      <div className="absolute inset-0 p-2 flex flex-col items-start">
 | 
			
		||||
        <div className="overflow-auto h-full w-full pb-12">
 | 
			
		||||
          <ReactJson
 | 
			
		||||
            src={ProcessedMemory}
 | 
			
		||||
            collapsed={1}
 | 
			
		||||
            collapseStringsAfterLength={60}
 | 
			
		||||
            enableClipboard={false}
 | 
			
		||||
            displayDataTypes={false}
 | 
			
		||||
            displayObjectSize={true}
 | 
			
		||||
            indentWidth={2}
 | 
			
		||||
            quotesOnKeys={false}
 | 
			
		||||
            name={false}
 | 
			
		||||
            theme={theme === 'light' ? 'rjv-default' : 'monokai'}
 | 
			
		||||
          />
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const processMemory = (programMemory: ProgramMemory) => {
 | 
			
		||||
  const processedMemory: any = {}
 | 
			
		||||
  Object.keys(programMemory?.root || {}).forEach((key) => {
 | 
			
		||||
    const val = programMemory.root[key]
 | 
			
		||||
    if (typeof val.value !== 'function') {
 | 
			
		||||
      if (val.type === 'SketchGroup') {
 | 
			
		||||
        processedMemory[key] = val.value.map(({ __geoMeta, ...rest }: Path) => {
 | 
			
		||||
          return rest
 | 
			
		||||
        })
 | 
			
		||||
      } else if (val.type === 'ExtrudeGroup') {
 | 
			
		||||
        processedMemory[key] = val.value.map(({ ...rest }: ExtrudeSurface) => {
 | 
			
		||||
          return rest
 | 
			
		||||
        })
 | 
			
		||||
      } else {
 | 
			
		||||
        processedMemory[key] = val.value
 | 
			
		||||
      }
 | 
			
		||||
    } else if (key !== 'log') {
 | 
			
		||||
      processedMemory[key] = '__function__'
 | 
			
		||||
    }
 | 
			
		||||
  })
 | 
			
		||||
  return processedMemory
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										67
									
								
								src/components/ModelingSidebar/ModelingPanes/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,67 @@
 | 
			
		||||
import {
 | 
			
		||||
  IconDefinition,
 | 
			
		||||
  faBugSlash,
 | 
			
		||||
  faCode,
 | 
			
		||||
  faCodeCommit,
 | 
			
		||||
  faExclamationCircle,
 | 
			
		||||
  faSquareRootVariable,
 | 
			
		||||
} from '@fortawesome/free-solid-svg-icons'
 | 
			
		||||
import { KclEditorMenu } from 'components/ModelingSidebar/ModelingPanes/KclEditorMenu'
 | 
			
		||||
import { CustomIconName } from 'components/CustomIcon'
 | 
			
		||||
import { KclEditorPane } from 'components/ModelingSidebar/ModelingPanes/KclEditorPane'
 | 
			
		||||
import { ReactNode } from 'react'
 | 
			
		||||
import type { PaneType } from 'useStore'
 | 
			
		||||
import { MemoryPane } from './MemoryPane'
 | 
			
		||||
import { KclErrorsPane, LogsPane } from './LoggingPanes'
 | 
			
		||||
import { DebugPane } from './DebugPane'
 | 
			
		||||
 | 
			
		||||
export type Pane = {
 | 
			
		||||
  id: PaneType
 | 
			
		||||
  title: string
 | 
			
		||||
  icon: CustomIconName | IconDefinition
 | 
			
		||||
  Content: ReactNode | React.FC
 | 
			
		||||
  Menu?: ReactNode | React.FC
 | 
			
		||||
  keybinding: string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const topPanes: Pane[] = [
 | 
			
		||||
  {
 | 
			
		||||
    id: 'code',
 | 
			
		||||
    title: 'KCL Code',
 | 
			
		||||
    icon: faCode,
 | 
			
		||||
    Content: KclEditorPane,
 | 
			
		||||
    keybinding: 'shift + c',
 | 
			
		||||
    Menu: KclEditorMenu,
 | 
			
		||||
  },
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
export const bottomPanes: Pane[] = [
 | 
			
		||||
  {
 | 
			
		||||
    id: 'variables',
 | 
			
		||||
    title: 'Variables',
 | 
			
		||||
    icon: faSquareRootVariable,
 | 
			
		||||
    Content: MemoryPane,
 | 
			
		||||
    keybinding: 'shift + v',
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    id: 'logs',
 | 
			
		||||
    title: 'Logs',
 | 
			
		||||
    icon: faCodeCommit,
 | 
			
		||||
    Content: LogsPane,
 | 
			
		||||
    keybinding: 'shift + l',
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    id: 'kclErrors',
 | 
			
		||||
    title: 'KCL Errors',
 | 
			
		||||
    icon: faExclamationCircle,
 | 
			
		||||
    Content: KclErrorsPane,
 | 
			
		||||
    keybinding: 'shift + e',
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    id: 'debug',
 | 
			
		||||
    title: 'Debug',
 | 
			
		||||
    icon: faBugSlash,
 | 
			
		||||
    Content: DebugPane,
 | 
			
		||||
    keybinding: 'shift + d',
 | 
			
		||||
  },
 | 
			
		||||
]
 | 
			
		||||
							
								
								
									
										11
									
								
								src/components/ModelingSidebar/ModelingSidebar.module.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,11 @@
 | 
			
		||||
.grid {
 | 
			
		||||
  display: grid;
 | 
			
		||||
  grid-template-columns: auto 1fr;
 | 
			
		||||
  grid-template-rows: 1fr 1fr;
 | 
			
		||||
  row-gap: 0.25rem;
 | 
			
		||||
  align-items: stretch;
 | 
			
		||||
  position: relative;
 | 
			
		||||
  padding-block: 1px;
 | 
			
		||||
  max-width: 100%;
 | 
			
		||||
  flex: 1 1 0;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										213
									
								
								src/components/ModelingSidebar/ModelingSidebar.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,213 @@
 | 
			
		||||
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
 | 
			
		||||
import { Resizable } from 're-resizable'
 | 
			
		||||
import { useCallback, useEffect, useState } from 'react'
 | 
			
		||||
import { useHotkeys } from 'react-hotkeys-hook'
 | 
			
		||||
import { PaneType, useStore } from 'useStore'
 | 
			
		||||
import { Tab } from '@headlessui/react'
 | 
			
		||||
import { Pane, bottomPanes, topPanes } from './ModelingPanes'
 | 
			
		||||
import Tooltip from 'components/Tooltip'
 | 
			
		||||
import { ActionIcon } from 'components/ActionIcon'
 | 
			
		||||
import styles from './ModelingSidebar.module.css'
 | 
			
		||||
import { ModelingPane } from './ModelingPane'
 | 
			
		||||
 | 
			
		||||
interface ModelingSidebarProps {
 | 
			
		||||
  paneOpacity: '' | 'opacity-20' | 'opacity-40'
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function ModelingSidebar({ paneOpacity }: ModelingSidebarProps) {
 | 
			
		||||
  const { buttonDownInStream, openPanes } = useStore((s) => ({
 | 
			
		||||
    buttonDownInStream: s.buttonDownInStream,
 | 
			
		||||
    openPanes: s.openPanes,
 | 
			
		||||
  }))
 | 
			
		||||
  const { settings } = useSettingsAuthContext()
 | 
			
		||||
  const {
 | 
			
		||||
    app: { onboardingStatus },
 | 
			
		||||
  } = settings.context
 | 
			
		||||
 | 
			
		||||
  const pointerEventsCssClass =
 | 
			
		||||
    buttonDownInStream || onboardingStatus.current === 'camera'
 | 
			
		||||
      ? 'pointer-events-none '
 | 
			
		||||
      : 'pointer-events-auto'
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Resizable
 | 
			
		||||
      className={`flex-1 flex flex-col z-10 my-2 pr-1 ${paneOpacity} ${pointerEventsCssClass}`}
 | 
			
		||||
      defaultSize={{
 | 
			
		||||
        width: '550px',
 | 
			
		||||
        height: 'auto',
 | 
			
		||||
      }}
 | 
			
		||||
      minWidth={200}
 | 
			
		||||
      maxWidth={800}
 | 
			
		||||
      handleClasses={{
 | 
			
		||||
        right:
 | 
			
		||||
          (openPanes.length === 0 ? 'hidden ' : 'block ') +
 | 
			
		||||
          'translate-x-1/2 hover:bg-chalkboard-10 hover:dark:bg-chalkboard-110 bg-transparent transition-colors duration-75 transition-ease-out delay-100 ' +
 | 
			
		||||
          pointerEventsCssClass,
 | 
			
		||||
      }}
 | 
			
		||||
    >
 | 
			
		||||
      <div className={styles.grid + ' flex-1'}>
 | 
			
		||||
        <ModelingSidebarSection panes={topPanes} />
 | 
			
		||||
        <ModelingSidebarSection panes={bottomPanes} alignButtons="end" />
 | 
			
		||||
      </div>
 | 
			
		||||
    </Resizable>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface ModelingSidebarSectionProps {
 | 
			
		||||
  panes: Pane[]
 | 
			
		||||
  alignButtons?: 'start' | 'end'
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function ModelingSidebarSection({
 | 
			
		||||
  panes,
 | 
			
		||||
  alignButtons = 'start',
 | 
			
		||||
}: ModelingSidebarSectionProps) {
 | 
			
		||||
  const { settings } = useSettingsAuthContext()
 | 
			
		||||
  const showDebugPanel = settings.context.modeling.showDebugPanel
 | 
			
		||||
  const paneIds = panes.map((pane) => pane.id)
 | 
			
		||||
  const { openPanes, setOpenPanes } = useStore((s) => ({
 | 
			
		||||
    openPanes: s.openPanes,
 | 
			
		||||
    setOpenPanes: s.setOpenPanes,
 | 
			
		||||
  }))
 | 
			
		||||
  const foundOpenPane = openPanes.find((pane) => paneIds.includes(pane))
 | 
			
		||||
  const [currentPane, setCurrentPane] = useState(
 | 
			
		||||
    foundOpenPane || ('none' as PaneType | 'none')
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  const togglePane = useCallback(
 | 
			
		||||
    (newPane: PaneType | 'none') => {
 | 
			
		||||
      if (newPane === 'none') {
 | 
			
		||||
        setOpenPanes(openPanes.filter((p) => p !== currentPane))
 | 
			
		||||
        setCurrentPane('none')
 | 
			
		||||
      } else if (newPane === currentPane) {
 | 
			
		||||
        setCurrentPane('none')
 | 
			
		||||
        setOpenPanes(openPanes.filter((p) => p !== newPane))
 | 
			
		||||
      } else {
 | 
			
		||||
        setOpenPanes([...openPanes.filter((p) => p !== currentPane), newPane])
 | 
			
		||||
        setCurrentPane(newPane)
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    [openPanes, setOpenPanes, currentPane, setCurrentPane]
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  // Filter out the debug panel if it's not supposed to be shown
 | 
			
		||||
  // TODO: abstract out for allowing user to configure which panes to show
 | 
			
		||||
  const filteredPanes = showDebugPanel.current
 | 
			
		||||
    ? panes
 | 
			
		||||
    : panes.filter((pane) => pane.id !== 'debug')
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (
 | 
			
		||||
      !showDebugPanel.current &&
 | 
			
		||||
      currentPane === 'debug' &&
 | 
			
		||||
      openPanes.includes('debug')
 | 
			
		||||
    ) {
 | 
			
		||||
      togglePane('debug')
 | 
			
		||||
    }
 | 
			
		||||
  }, [showDebugPanel.current, togglePane, openPanes])
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Tab.Group
 | 
			
		||||
      vertical
 | 
			
		||||
      selectedIndex={
 | 
			
		||||
        currentPane === 'none' ? 0 : paneIds.indexOf(currentPane) + 1
 | 
			
		||||
      }
 | 
			
		||||
      onChange={(index) => {
 | 
			
		||||
        const newPane = index === 0 ? 'none' : paneIds[index - 1]
 | 
			
		||||
        togglePane(newPane)
 | 
			
		||||
      }}
 | 
			
		||||
    >
 | 
			
		||||
      <Tab.List
 | 
			
		||||
        className={
 | 
			
		||||
          (alignButtons === 'start'
 | 
			
		||||
            ? 'justify-start self-start'
 | 
			
		||||
            : 'justify-end self-end') +
 | 
			
		||||
          (currentPane === 'none'
 | 
			
		||||
            ? ' rounded-r focus-within:!border-primary/50'
 | 
			
		||||
            : ' border-r-0') +
 | 
			
		||||
          ' p-2 col-start-1 col-span-1 h-fit w-fit flex flex-col items-start gap-2 bg-chalkboard-10 border border-solid border-chalkboard-20 dark:bg-chalkboard-90 dark:border-chalkboard-80 ' +
 | 
			
		||||
          (openPanes.length === 1 && currentPane === 'none' ? 'pr-0.5' : '')
 | 
			
		||||
        }
 | 
			
		||||
      >
 | 
			
		||||
        <Tab key="none" className="sr-only">
 | 
			
		||||
          No panes open
 | 
			
		||||
        </Tab>
 | 
			
		||||
        {filteredPanes.map((pane) => (
 | 
			
		||||
          <ModelingPaneButton
 | 
			
		||||
            key={pane.id}
 | 
			
		||||
            paneConfig={pane}
 | 
			
		||||
            currentPane={currentPane}
 | 
			
		||||
            togglePane={() => togglePane(pane.id)}
 | 
			
		||||
          />
 | 
			
		||||
        ))}
 | 
			
		||||
      </Tab.List>
 | 
			
		||||
      <Tab.Panels
 | 
			
		||||
        as="article"
 | 
			
		||||
        className={
 | 
			
		||||
          'col-start-2 col-span-1 ' +
 | 
			
		||||
          (openPanes.length === 1
 | 
			
		||||
            ? currentPane !== 'none'
 | 
			
		||||
              ? `row-start-1 row-end-3`
 | 
			
		||||
              : `hidden`
 | 
			
		||||
            : ``)
 | 
			
		||||
        }
 | 
			
		||||
      >
 | 
			
		||||
        <Tab.Panel key="none" />
 | 
			
		||||
        {filteredPanes.map((pane) => (
 | 
			
		||||
          <Tab.Panel key={pane.id} className="h-full">
 | 
			
		||||
            <ModelingPane title={pane.title} Menu={pane.Menu}>
 | 
			
		||||
              {pane.Content instanceof Function ? (
 | 
			
		||||
                <pane.Content />
 | 
			
		||||
              ) : (
 | 
			
		||||
                pane.Content
 | 
			
		||||
              )}
 | 
			
		||||
            </ModelingPane>
 | 
			
		||||
          </Tab.Panel>
 | 
			
		||||
        ))}
 | 
			
		||||
      </Tab.Panels>
 | 
			
		||||
    </Tab.Group>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface ModelingPaneButtonProps {
 | 
			
		||||
  paneConfig: Pane
 | 
			
		||||
  currentPane: PaneType | 'none'
 | 
			
		||||
  togglePane: () => void
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function ModelingPaneButton({
 | 
			
		||||
  paneConfig,
 | 
			
		||||
  currentPane,
 | 
			
		||||
  togglePane,
 | 
			
		||||
}: ModelingPaneButtonProps) {
 | 
			
		||||
  useHotkeys(paneConfig.keybinding, togglePane, {
 | 
			
		||||
    scopes: ['modeling'],
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Tab
 | 
			
		||||
      key={paneConfig.id}
 | 
			
		||||
      className="pointer-events-auto flex items-center justify-center border-transparent dark:border-transparent p-0 m-0 rounded-sm !outline-none"
 | 
			
		||||
      onClick={togglePane}
 | 
			
		||||
    >
 | 
			
		||||
      <ActionIcon
 | 
			
		||||
        icon={paneConfig.icon}
 | 
			
		||||
        className="p-1"
 | 
			
		||||
        size="sm"
 | 
			
		||||
        iconClassName={
 | 
			
		||||
          paneConfig.id === currentPane
 | 
			
		||||
            ? ' !text-chalkboard-10'
 | 
			
		||||
            : '!text-chalkboard-80 dark:!text-chalkboard-30'
 | 
			
		||||
        }
 | 
			
		||||
        bgClassName={
 | 
			
		||||
          'rounded-sm ' +
 | 
			
		||||
          (paneConfig.id === currentPane ? '!bg-primary' : '!bg-transparent')
 | 
			
		||||
        }
 | 
			
		||||
      />
 | 
			
		||||
      <Tooltip position="right" hoverOnly delay={800}>
 | 
			
		||||
        <span>{paneConfig.title}</span>
 | 
			
		||||
        <br />
 | 
			
		||||
        <span className="text-xs capitalize">{paneConfig.keybinding}</span>
 | 
			
		||||
      </Tooltip>
 | 
			
		||||
    </Tab>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
@ -1,6 +1,5 @@
 | 
			
		||||
import { Popover, Transition } from '@headlessui/react'
 | 
			
		||||
import { ActionButton } from './ActionButton'
 | 
			
		||||
import { faHome } from '@fortawesome/free-solid-svg-icons'
 | 
			
		||||
import { type IndexLoaderData } from 'lib/types'
 | 
			
		||||
import { paths } from 'lib/paths'
 | 
			
		||||
import { isTauri } from '../lib/isTauri'
 | 
			
		||||
 | 
			
		||||
@ -116,7 +116,7 @@ export const SettingsAuthProviderBase = ({
 | 
			
		||||
            },
 | 
			
		||||
          })
 | 
			
		||||
        },
 | 
			
		||||
        toastSuccess: (context, event) => {
 | 
			
		||||
        toastSuccess: (_, event) => {
 | 
			
		||||
          const eventParts = event.type.replace(/^set./, '').split('.') as [
 | 
			
		||||
            keyof typeof settings,
 | 
			
		||||
            string
 | 
			
		||||
@ -211,6 +211,19 @@ export const SettingsAuthProviderBase = ({
 | 
			
		||||
    )
 | 
			
		||||
  }, [settingsState.context.app.themeColor.current])
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Update the --cursor-color CSS variable
 | 
			
		||||
   * based on the setting textEditor.blinkingCursor.current
 | 
			
		||||
   */
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    document.documentElement.style.setProperty(
 | 
			
		||||
      `--cursor-color`,
 | 
			
		||||
      settingsState.context.textEditor.blinkingCursor.current
 | 
			
		||||
        ? 'auto'
 | 
			
		||||
        : 'transparent'
 | 
			
		||||
    )
 | 
			
		||||
  }, [settingsState.context.textEditor.blinkingCursor.current])
 | 
			
		||||
 | 
			
		||||
  // Auth machine setup
 | 
			
		||||
  const [authState, authSend, authActor] = useMachine(authMachine, {
 | 
			
		||||
    actions: {
 | 
			
		||||
 | 
			
		||||
@ -94,11 +94,15 @@
 | 
			
		||||
  position: relative;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
:is(:hover, :focus-visible, :active) > .tooltip {
 | 
			
		||||
:is(:hover, :active) > .tooltip {
 | 
			
		||||
  opacity: 1;
 | 
			
		||||
  transition-delay: var(--_delay);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
:is(:focus-visible) > .tooltip.withFocus {
 | 
			
		||||
  opacity: 1;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
:is(:focus, :focus-visible, :focus-within) > .tooltip {
 | 
			
		||||
  --_delay: 0 !important;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -15,6 +15,7 @@ interface TooltipProps extends React.PropsWithChildren {
 | 
			
		||||
    | 'inlineEnd'
 | 
			
		||||
  className?: string
 | 
			
		||||
  delay?: number
 | 
			
		||||
  hoverOnly?: boolean
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default function Tooltip({
 | 
			
		||||
@ -22,13 +23,16 @@ export default function Tooltip({
 | 
			
		||||
  position = 'top',
 | 
			
		||||
  className,
 | 
			
		||||
  delay = 200,
 | 
			
		||||
  hoverOnly = false,
 | 
			
		||||
}: TooltipProps) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      // @ts-ignore while awaiting merge of this PR for support of "inert" https://github.com/DefinitelyTyped/DefinitelyTyped/pull/60822
 | 
			
		||||
      inert="true"
 | 
			
		||||
      role="tooltip"
 | 
			
		||||
      className={styles.tooltip + ' ' + styles[position] + ' ' + className}
 | 
			
		||||
      className={`${styles.tooltip} ${hoverOnly ? '' : styles.withFocus} ${
 | 
			
		||||
        styles[position]
 | 
			
		||||
      } ${className}`}
 | 
			
		||||
      style={{ '--_delay': delay + 'ms' } as React.CSSProperties}
 | 
			
		||||
    >
 | 
			
		||||
      {children}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										16
									
								
								src/hooks/useResolvedTheme.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,16 @@
 | 
			
		||||
import { Themes, getSystemTheme } from 'lib/theme'
 | 
			
		||||
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Resolves the current theme based on the theme setting
 | 
			
		||||
 * and the system theme if needed.
 | 
			
		||||
 * @returns {Themes.Light | Themes.Dark}
 | 
			
		||||
 */
 | 
			
		||||
export function useResolvedTheme() {
 | 
			
		||||
  const {
 | 
			
		||||
    settings: { context },
 | 
			
		||||
  } = useSettingsAuthContext()
 | 
			
		||||
  return context.app.theme.current === Themes.System
 | 
			
		||||
    ? getSystemTheme()
 | 
			
		||||
    : context.app.theme.current
 | 
			
		||||
}
 | 
			
		||||
@ -46,6 +46,15 @@ select {
 | 
			
		||||
  @apply bg-chalkboard-90;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* We hide the cursor if the user has turned off the textEditor.blinkingCursor setting
 | 
			
		||||
 * any elements that could present a blinking cursor to the user
 | 
			
		||||
*/
 | 
			
		||||
input,
 | 
			
		||||
textarea,
 | 
			
		||||
*[contenteditable] {
 | 
			
		||||
  caret-color: var(--cursor-color, auto);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
::-webkit-scrollbar {
 | 
			
		||||
  @apply w-2 h-2 rounded-sm;
 | 
			
		||||
  @apply bg-chalkboard-20;
 | 
			
		||||
@ -113,32 +122,28 @@ code {
 | 
			
		||||
    monospace;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.full-height-subtract {
 | 
			
		||||
  --height-subtract: 2.25rem;
 | 
			
		||||
  height: 100%;
 | 
			
		||||
  max-height: calc(100% - var(--height-subtract));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
 * The first descendent of the CodeMirror wrapper is the theme,
 | 
			
		||||
 * but its identifying class can change depending on the theme.
 | 
			
		||||
*/
 | 
			
		||||
#code-mirror-override > div,
 | 
			
		||||
#code-mirror-override .cm-editor {
 | 
			
		||||
  @apply h-full bg-transparent;
 | 
			
		||||
  @apply bg-transparent h-full;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#code-mirror-override .cm-scroller {
 | 
			
		||||
  @apply h-full;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#code-mirror-override .cm-scroller::-webkit-scrollbar {
 | 
			
		||||
  @apply h-0;
 | 
			
		||||
  overflow: auto;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#code-mirror-override .cm-activeLine,
 | 
			
		||||
#code-mirror-override .cm-activeLineGutter {
 | 
			
		||||
  @apply bg-liquid-10/50;
 | 
			
		||||
  @apply bg-primary/20;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.dark #code-mirror-override .cm-activeLine,
 | 
			
		||||
.dark #code-mirror-override .cm-activeLineGutter {
 | 
			
		||||
  @apply bg-liquid-80/50;
 | 
			
		||||
  @apply bg-primary/20;
 | 
			
		||||
  mix-blend-mode: lighten;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#code-mirror-override .cm-gutters {
 | 
			
		||||
@ -149,19 +154,29 @@ code {
 | 
			
		||||
  @apply bg-chalkboard-110/50;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#code-mirror-override .cm-content {
 | 
			
		||||
  @apply caret-primary;
 | 
			
		||||
}
 | 
			
		||||
.dark #code-mirror-override .cm-content {
 | 
			
		||||
  @apply caret-chalkboard-10;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#code-mirror-override .cm-focused .cm-cursor {
 | 
			
		||||
  width: 0px;
 | 
			
		||||
}
 | 
			
		||||
#code-mirror-override .cm-cursor {
 | 
			
		||||
  display: block;
 | 
			
		||||
  width: 1ch;
 | 
			
		||||
  @apply bg-liquid-40 mix-blend-multiply;
 | 
			
		||||
 | 
			
		||||
  animation: blink 2s ease-out infinite;
 | 
			
		||||
  @apply mix-blend-multiply;
 | 
			
		||||
  @apply border-l-primary;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.dark #code-mirror-override .cm-cursor {
 | 
			
		||||
  @apply bg-liquid-50;
 | 
			
		||||
  @apply border-l-chalkboard-10;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#code-mirror-override.blink .cm-cursor {
 | 
			
		||||
  animation: blink 1200ms ease-out infinite;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@keyframes blink {
 | 
			
		||||
@ -169,8 +184,8 @@ code {
 | 
			
		||||
  100% {
 | 
			
		||||
    opacity: 0;
 | 
			
		||||
  }
 | 
			
		||||
  15% {
 | 
			
		||||
    opacity: 0.75;
 | 
			
		||||
  10% {
 | 
			
		||||
    opacity: 1;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -15,6 +15,7 @@ import { getPropertyByPath } from 'lib/objectPropertyByPath'
 | 
			
		||||
import { buildCommandArgument } from 'lib/createMachineCommand'
 | 
			
		||||
import decamelize from 'decamelize'
 | 
			
		||||
import { isTauri } from 'lib/isTauri'
 | 
			
		||||
import { Setting } from 'lib/settings/initialSettings'
 | 
			
		||||
 | 
			
		||||
// An array of the paths to all of the settings that have commandConfigs
 | 
			
		||||
export const settingsWithCommandConfigs = (
 | 
			
		||||
@ -87,11 +88,34 @@ export function createSettingsCommand({
 | 
			
		||||
  )
 | 
			
		||||
    return null
 | 
			
		||||
 | 
			
		||||
  const valueArgConfig = {
 | 
			
		||||
  let valueArgConfig = {
 | 
			
		||||
    ...valueArgPartialConfig,
 | 
			
		||||
    required: true,
 | 
			
		||||
  } as CommandArgumentConfig<S['default']>
 | 
			
		||||
 | 
			
		||||
  // If the setting is a boolean, we coerce it into an options input type
 | 
			
		||||
  if (valueArgConfig.inputType === 'boolean') {
 | 
			
		||||
    valueArgConfig = {
 | 
			
		||||
      ...valueArgConfig,
 | 
			
		||||
      inputType: 'options',
 | 
			
		||||
      options: (cmdBarContext, machineContext) => {
 | 
			
		||||
        const setting = getPropertyByPath(
 | 
			
		||||
          machineContext,
 | 
			
		||||
          type
 | 
			
		||||
        ) as Setting<boolean>
 | 
			
		||||
        const level = cmdBarContext.argumentsToSubmit.level as SettingsLevel
 | 
			
		||||
        const isCurrent =
 | 
			
		||||
          setting[level] === undefined
 | 
			
		||||
            ? setting.getFallback(level) === true
 | 
			
		||||
            : setting[level] === true
 | 
			
		||||
        return [
 | 
			
		||||
          { name: 'On', value: true, isCurrent },
 | 
			
		||||
          { name: 'Off', value: false, isCurrent: !isCurrent },
 | 
			
		||||
        ]
 | 
			
		||||
      },
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // @ts-ignore - TODO figure out this typing for valueArgConfig
 | 
			
		||||
  const valueArg = buildCommandArgument(valueArgConfig, context, actor)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -151,7 +151,8 @@ export type CommandArgumentConfig<
 | 
			
		||||
          defaultValue?:
 | 
			
		||||
            | OutputType
 | 
			
		||||
            | ((
 | 
			
		||||
                commandBarContext: ContextFrom<typeof commandBarMachine>
 | 
			
		||||
                commandBarContext: ContextFrom<typeof commandBarMachine>,
 | 
			
		||||
                machineContext?: C
 | 
			
		||||
              ) => OutputType)
 | 
			
		||||
          defaultValueFromContext?: (context: C) => OutputType
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
@ -362,6 +362,17 @@ export function createSettings() {
 | 
			
		||||
          inputType: 'boolean',
 | 
			
		||||
        },
 | 
			
		||||
      }),
 | 
			
		||||
      /**
 | 
			
		||||
       * Whether to make the cursor blink in the editor
 | 
			
		||||
       */
 | 
			
		||||
      blinkingCursor: new Setting<boolean>({
 | 
			
		||||
        defaultValue: true,
 | 
			
		||||
        description: 'Whether to make the cursor blink in the editor',
 | 
			
		||||
        validate: (v) => typeof v === 'boolean',
 | 
			
		||||
        commandConfig: {
 | 
			
		||||
          inputType: 'boolean',
 | 
			
		||||
        },
 | 
			
		||||
      }),
 | 
			
		||||
    },
 | 
			
		||||
    /**
 | 
			
		||||
     * Settings that affect the behavior of project management.
 | 
			
		||||
 | 
			
		||||
@ -483,7 +483,8 @@ export const commandBarMachine = createMachine(
 | 
			
		||||
              }
 | 
			
		||||
 | 
			
		||||
              if (
 | 
			
		||||
                (argConfig.inputType !== 'boolean'
 | 
			
		||||
                (argConfig.inputType !== 'boolean' &&
 | 
			
		||||
                argConfig.inputType !== 'options'
 | 
			
		||||
                  ? !argValue
 | 
			
		||||
                  : argValue === undefined) &&
 | 
			
		||||
                isRequired
 | 
			
		||||
 | 
			
		||||
@ -126,7 +126,7 @@ export const Settings = () => {
 | 
			
		||||
          leaveFrom="opacity-100 scale-100"
 | 
			
		||||
          leaveTo="opacity-0 scale-95"
 | 
			
		||||
        >
 | 
			
		||||
          <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">
 | 
			
		||||
          <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">
 | 
			
		||||
            <div className="p-5 pb-0 flex justify-between items-center">
 | 
			
		||||
              <h1 className="text-2xl font-bold">Settings</h1>
 | 
			
		||||
              <button
 | 
			
		||||
@ -163,8 +163,11 @@ export const Settings = () => {
 | 
			
		||||
                </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">
 | 
			
		||||
            <div
 | 
			
		||||
              className="flex-1 grid items-stretch pl-4 pr-5 pb-5 gap-4 overflow-hidden"
 | 
			
		||||
              style={{ gridTemplateColumns: 'auto 1fr' }}
 | 
			
		||||
            >
 | 
			
		||||
              <div className="flex w-32 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
 | 
			
		||||
@ -216,176 +219,175 @@ export const Settings = () => {
 | 
			
		||||
                  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)
 | 
			
		||||
              <div className="relative overflow-y-auto">
 | 
			
		||||
                <div ref={scrollRef} className="flex flex-col gap-6 px-2">
 | 
			
		||||
                  {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>]) =>
 | 
			
		||||
                            shouldShowSettingInput(item[1], settingsLevel)
 | 
			
		||||
                        )
 | 
			
		||||
                        .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>
 | 
			
		||||
                    .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>]) =>
 | 
			
		||||
                              shouldShowSettingInput(item[1], settingsLevel)
 | 
			
		||||
                          )
 | 
			
		||||
                        })}
 | 
			
		||||
                    </Fragment>
 | 
			
		||||
                  ))}
 | 
			
		||||
                <h2 id="settings-resets" className="text-2xl mt-6 font-bold">
 | 
			
		||||
                  Resets
 | 
			
		||||
                </h2>
 | 
			
		||||
                <SettingsSection
 | 
			
		||||
                  title="Onboarding"
 | 
			
		||||
                  description="Replay the onboarding process"
 | 
			
		||||
                >
 | 
			
		||||
                  <ActionButton
 | 
			
		||||
                    Element="button"
 | 
			
		||||
                    onClick={restartOnboarding}
 | 
			
		||||
                    icon={{
 | 
			
		||||
                      icon: 'refresh',
 | 
			
		||||
                      size: 'sm',
 | 
			
		||||
                      className: 'p-1',
 | 
			
		||||
                    }}
 | 
			
		||||
                          .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>
 | 
			
		||||
                            )
 | 
			
		||||
                          })}
 | 
			
		||||
                      </Fragment>
 | 
			
		||||
                    ))}
 | 
			
		||||
                  <h2 id="settings-resets" className="text-2xl mt-6 font-bold">
 | 
			
		||||
                    Resets
 | 
			
		||||
                  </h2>
 | 
			
		||||
                  <SettingsSection
 | 
			
		||||
                    title="Onboarding"
 | 
			
		||||
                    description="Replay the onboarding process"
 | 
			
		||||
                  >
 | 
			
		||||
                    Replay Onboarding
 | 
			
		||||
                  </ActionButton>
 | 
			
		||||
                </SettingsSection>
 | 
			
		||||
                <SettingsSection
 | 
			
		||||
                  title="Reset settings"
 | 
			
		||||
                  description={`Restore settings to their default values. Your settings are saved in
 | 
			
		||||
                    <ActionButton
 | 
			
		||||
                      Element="button"
 | 
			
		||||
                      onClick={restartOnboarding}
 | 
			
		||||
                      icon={{
 | 
			
		||||
                        icon: 'refresh',
 | 
			
		||||
                        size: 'sm',
 | 
			
		||||
                        className: 'p-1',
 | 
			
		||||
                      }}
 | 
			
		||||
                    >
 | 
			
		||||
                      Replay Onboarding
 | 
			
		||||
                    </ActionButton>
 | 
			
		||||
                  </SettingsSection>
 | 
			
		||||
                  <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."
 | 
			
		||||
                    }
 | 
			
		||||
                  `}
 | 
			
		||||
                >
 | 
			
		||||
                  <div className="flex flex-col items-start gap-4">
 | 
			
		||||
                    {isTauri() && (
 | 
			
		||||
                  >
 | 
			
		||||
                    <div className="flex flex-col items-start gap-4">
 | 
			
		||||
                      {isTauri() && (
 | 
			
		||||
                        <ActionButton
 | 
			
		||||
                          Element="button"
 | 
			
		||||
                          onClick={async () => {
 | 
			
		||||
                            const paths = await getSettingsFolderPaths(
 | 
			
		||||
                              projectPath
 | 
			
		||||
                                ? decodeURIComponent(projectPath)
 | 
			
		||||
                                : undefined
 | 
			
		||||
                            )
 | 
			
		||||
                            void invoke('show_in_folder', {
 | 
			
		||||
                              path: paths[settingsLevel],
 | 
			
		||||
                            })
 | 
			
		||||
                          }}
 | 
			
		||||
                          icon={{
 | 
			
		||||
                            icon: 'folder',
 | 
			
		||||
                            size: 'sm',
 | 
			
		||||
                            className: 'p-1',
 | 
			
		||||
                          }}
 | 
			
		||||
                        >
 | 
			
		||||
                          Show in folder
 | 
			
		||||
                        </ActionButton>
 | 
			
		||||
                      )}
 | 
			
		||||
                      <ActionButton
 | 
			
		||||
                        Element="button"
 | 
			
		||||
                        onClick={async () => {
 | 
			
		||||
                          const paths = await getSettingsFolderPaths(
 | 
			
		||||
                            projectPath
 | 
			
		||||
                              ? decodeURIComponent(projectPath)
 | 
			
		||||
                              : undefined
 | 
			
		||||
                          )
 | 
			
		||||
                          void invoke('show_in_folder', {
 | 
			
		||||
                            path: paths[settingsLevel],
 | 
			
		||||
                          const defaultDirectory = await getInitialDefaultDir()
 | 
			
		||||
                          send({
 | 
			
		||||
                            type: 'Reset settings',
 | 
			
		||||
                            defaultDirectory,
 | 
			
		||||
                          })
 | 
			
		||||
                          toast.success('Settings restored to default')
 | 
			
		||||
                        }}
 | 
			
		||||
                        icon={{
 | 
			
		||||
                          icon: 'folder',
 | 
			
		||||
                          icon: 'refresh',
 | 
			
		||||
                          size: 'sm',
 | 
			
		||||
                          className: 'p-1',
 | 
			
		||||
                          className: 'p-1 text-chalkboard-10',
 | 
			
		||||
                          bgClassName: 'bg-destroy-70',
 | 
			
		||||
                        }}
 | 
			
		||||
                      >
 | 
			
		||||
                        Show in folder
 | 
			
		||||
                        Restore default settings
 | 
			
		||||
                      </ActionButton>
 | 
			
		||||
                    )}
 | 
			
		||||
                    <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
 | 
			
		||||
                    </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}.{' '}
 | 
			
		||||
                    <a
 | 
			
		||||
                      href={`https://github.com/KittyCAD/modeling-app/releases/tag/v${APP_VERSION}`}
 | 
			
		||||
                      target="_blank"
 | 
			
		||||
                      rel="noopener noreferrer"
 | 
			
		||||
                    >
 | 
			
		||||
                      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>
 | 
			
		||||
                      App version {APP_VERSION}.{' '}
 | 
			
		||||
                      <a
 | 
			
		||||
                        href={`https://github.com/KittyCAD/modeling-app/releases/tag/v${APP_VERSION}`}
 | 
			
		||||
                        target="_blank"
 | 
			
		||||
                        rel="noopener noreferrer"
 | 
			
		||||
                      >
 | 
			
		||||
                        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>
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||