Revert "Make onboarding optional, able to be ignored on desktop" (#6610)
Revert "Make onboarding optional, able to be ignored on desktop (#6564)"
This reverts commit 820082d7f2.
			
			
This commit is contained in:
		@ -24,7 +24,6 @@ export class HomePageFixture {
 | 
			
		||||
  projectTextName!: Locator
 | 
			
		||||
  sortByDateBtn!: Locator
 | 
			
		||||
  sortByNameBtn!: Locator
 | 
			
		||||
  tutorialBtn!: Locator
 | 
			
		||||
 | 
			
		||||
  constructor(page: Page) {
 | 
			
		||||
    this.page = page
 | 
			
		||||
@ -44,7 +43,6 @@ export class HomePageFixture {
 | 
			
		||||
 | 
			
		||||
    this.sortByDateBtn = this.page.getByTestId('home-sort-by-modified')
 | 
			
		||||
    this.sortByNameBtn = this.page.getByTestId('home-sort-by-name')
 | 
			
		||||
    this.tutorialBtn = this.page.getByTestId('home-tutorial-button')
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private _serialiseSortBy = async (): Promise<
 | 
			
		||||
 | 
			
		||||
@ -17,8 +17,6 @@ type LengthUnitLabel = (typeof baseUnitLabels)[keyof typeof baseUnitLabels]
 | 
			
		||||
export class ToolbarFixture {
 | 
			
		||||
  public page: Page
 | 
			
		||||
 | 
			
		||||
  projectName!: Locator
 | 
			
		||||
  fileName!: Locator
 | 
			
		||||
  extrudeButton!: Locator
 | 
			
		||||
  loftButton!: Locator
 | 
			
		||||
  sweepButton!: Locator
 | 
			
		||||
@ -55,8 +53,6 @@ export class ToolbarFixture {
 | 
			
		||||
  constructor(page: Page) {
 | 
			
		||||
    this.page = page
 | 
			
		||||
 | 
			
		||||
    this.projectName = page.getByTestId('app-header-project-name')
 | 
			
		||||
    this.fileName = page.getByTestId('app-header-file-name')
 | 
			
		||||
    this.extrudeButton = page.getByTestId('extrude')
 | 
			
		||||
    this.loftButton = page.getByTestId('loft')
 | 
			
		||||
    this.sweepButton = page.getByTestId('sweep')
 | 
			
		||||
 | 
			
		||||
@ -450,7 +450,7 @@ test.describe(
 | 
			
		||||
          )
 | 
			
		||||
          await expect(actual).toBeVisible()
 | 
			
		||||
        })
 | 
			
		||||
        test('Home.Help.Replay onboarding tutorial', async ({
 | 
			
		||||
        test('Home.Help.Reset onboarding', async ({
 | 
			
		||||
          tronApp,
 | 
			
		||||
          cmdBar,
 | 
			
		||||
          page,
 | 
			
		||||
@ -464,7 +464,7 @@ test.describe(
 | 
			
		||||
                await tronApp.electron.evaluate(async ({ app }) => {
 | 
			
		||||
                  if (!app || !app.applicationMenu) return false
 | 
			
		||||
                  const menu = app.applicationMenu.getMenuItemById(
 | 
			
		||||
                    'Help.Replay onboarding tutorial'
 | 
			
		||||
                    'Help.Reset onboarding'
 | 
			
		||||
                  )
 | 
			
		||||
                  if (!menu) {
 | 
			
		||||
                    return false
 | 
			
		||||
@ -2339,7 +2339,7 @@ test.describe(
 | 
			
		||||
          await scene.connectionEstablished()
 | 
			
		||||
          await expect(toolbar.startSketchBtn).toBeVisible()
 | 
			
		||||
        })
 | 
			
		||||
        test('Modeling.Help.Replay onboarding tutorial', async ({
 | 
			
		||||
        test('Modeling.Help.Reset onboarding', async ({
 | 
			
		||||
          tronApp,
 | 
			
		||||
          cmdBar,
 | 
			
		||||
          page,
 | 
			
		||||
@ -2358,7 +2358,7 @@ test.describe(
 | 
			
		||||
          await tronApp.electron.evaluate(async ({ app }) => {
 | 
			
		||||
            if (!app || !app.applicationMenu) fail()
 | 
			
		||||
            const menu = app.applicationMenu.getMenuItemById(
 | 
			
		||||
              'Help.Replay onboarding tutorial'
 | 
			
		||||
              'Help.Reset onboarding'
 | 
			
		||||
            )
 | 
			
		||||
            if (!menu) fail()
 | 
			
		||||
            menu.click()
 | 
			
		||||
 | 
			
		||||
@ -1,175 +1,560 @@
 | 
			
		||||
import { join } from 'path'
 | 
			
		||||
import { bracket } from '@e2e/playwright/fixtures/bracket'
 | 
			
		||||
import { onboardingPaths } from '@src/routes/Onboarding/paths'
 | 
			
		||||
import fsp from 'fs/promises'
 | 
			
		||||
 | 
			
		||||
import { expectPixelColor } from '@e2e/playwright/fixtures/sceneFixture'
 | 
			
		||||
import {
 | 
			
		||||
  TEST_SETTINGS_KEY,
 | 
			
		||||
  TEST_SETTINGS_ONBOARDING_EXPORT,
 | 
			
		||||
  TEST_SETTINGS_ONBOARDING_START,
 | 
			
		||||
  TEST_SETTINGS_ONBOARDING_USER_MENU,
 | 
			
		||||
} from '@e2e/playwright/storageStates'
 | 
			
		||||
import {
 | 
			
		||||
  createProject,
 | 
			
		||||
  executorInputPath,
 | 
			
		||||
  getUtils,
 | 
			
		||||
  settingsToToml,
 | 
			
		||||
} from '@e2e/playwright/test-utils'
 | 
			
		||||
import { expect, test } from '@e2e/playwright/zoo-test'
 | 
			
		||||
 | 
			
		||||
// Because our default test settings have the onboardingStatus set to 'dismissed',
 | 
			
		||||
// we must set it to empty for the tests where we want to see the onboarding immediately.
 | 
			
		||||
 | 
			
		||||
test.describe('Onboarding tests', () => {
 | 
			
		||||
  test('Desktop onboarding flow works', async ({
 | 
			
		||||
  test('Onboarding code is shown in the editor', async ({
 | 
			
		||||
    page,
 | 
			
		||||
    homePage,
 | 
			
		||||
    toolbar,
 | 
			
		||||
    editor,
 | 
			
		||||
    scene,
 | 
			
		||||
    tronApp,
 | 
			
		||||
  }) => {
 | 
			
		||||
    if (!tronApp) {
 | 
			
		||||
      fail()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Because our default test settings have the onboardingStatus set to 'dismissed',
 | 
			
		||||
    // we must set it to empty for the tests where we want to see the onboarding UI.
 | 
			
		||||
    await tronApp.cleanProjectDir({
 | 
			
		||||
      app: {
 | 
			
		||||
        onboarding_status: '',
 | 
			
		||||
      },
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    const bracketComment = '// Shelf Bracket'
 | 
			
		||||
    const tutorialWelcomHeading = page.getByText(
 | 
			
		||||
      'Welcome to Design Studio! This'
 | 
			
		||||
    )
 | 
			
		||||
    const nextButton = page.getByTestId('onboarding-next')
 | 
			
		||||
    const prevButton = page.getByTestId('onboarding-prev')
 | 
			
		||||
    const userMenuButton = toolbar.userSidebarButton
 | 
			
		||||
    const userMenuSettingsButton = page.getByRole('button', {
 | 
			
		||||
      name: 'User settings',
 | 
			
		||||
    })
 | 
			
		||||
    const settingsHeading = page.getByRole('heading', {
 | 
			
		||||
      name: 'Settings',
 | 
			
		||||
      exact: true,
 | 
			
		||||
    })
 | 
			
		||||
    const restartOnboardingSettingsButton = page.getByRole('button', {
 | 
			
		||||
    const u = await getUtils(page)
 | 
			
		||||
    await page.setBodyDimensions({ width: 1200, height: 500 })
 | 
			
		||||
    await homePage.goToModelingScene()
 | 
			
		||||
 | 
			
		||||
    // Test that the onboarding pane loaded
 | 
			
		||||
    await expect(page.getByText('Welcome to Design Studio! This')).toBeVisible()
 | 
			
		||||
 | 
			
		||||
    // Test that the onboarding pane loaded
 | 
			
		||||
    await expect(page.getByText('Welcome to Design Studio! This')).toBeVisible()
 | 
			
		||||
 | 
			
		||||
    // *and* that the code is shown in the editor
 | 
			
		||||
    await expect(page.locator('.cm-content')).toContainText('// Shelf Bracket')
 | 
			
		||||
 | 
			
		||||
    // Make sure the model loaded
 | 
			
		||||
    const XYPlanePoint = { x: 774, y: 116 } as const
 | 
			
		||||
    const modelColor: [number, number, number] = [45, 45, 45]
 | 
			
		||||
    await page.mouse.move(XYPlanePoint.x, XYPlanePoint.y)
 | 
			
		||||
    expect(await u.getGreatestPixDiff(XYPlanePoint, modelColor)).toBeLessThan(8)
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  test(
 | 
			
		||||
    'Desktop: fresh onboarding executes and loads',
 | 
			
		||||
    {
 | 
			
		||||
      tag: '@electron',
 | 
			
		||||
    },
 | 
			
		||||
    async ({ page, tronApp, scene }) => {
 | 
			
		||||
      if (!tronApp) {
 | 
			
		||||
        fail()
 | 
			
		||||
      }
 | 
			
		||||
      await tronApp.cleanProjectDir({
 | 
			
		||||
        app: {
 | 
			
		||||
          onboarding_status: '',
 | 
			
		||||
        },
 | 
			
		||||
      })
 | 
			
		||||
 | 
			
		||||
      const viewportSize = { width: 1200, height: 500 }
 | 
			
		||||
      await page.setBodyDimensions(viewportSize)
 | 
			
		||||
 | 
			
		||||
      await test.step(`Create a project and open to the onboarding`, async () => {
 | 
			
		||||
        await createProject({ name: 'project-link', page })
 | 
			
		||||
        await test.step(`Ensure the engine connection works by testing the sketch button`, async () => {
 | 
			
		||||
          await scene.connectionEstablished()
 | 
			
		||||
        })
 | 
			
		||||
      })
 | 
			
		||||
 | 
			
		||||
      await test.step(`Ensure we see the onboarding stuff`, async () => {
 | 
			
		||||
        // Test that the onboarding pane loaded
 | 
			
		||||
        await expect(
 | 
			
		||||
          page.getByText('Welcome to Design Studio! This')
 | 
			
		||||
        ).toBeVisible()
 | 
			
		||||
 | 
			
		||||
        // *and* that the code is shown in the editor
 | 
			
		||||
        await expect(page.locator('.cm-content')).toContainText(
 | 
			
		||||
          '// Shelf Bracket'
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        // TODO: jess make less shit
 | 
			
		||||
        // Make sure the model loaded
 | 
			
		||||
        //const XYPlanePoint = { x: 986, y: 522 } as const
 | 
			
		||||
        //const modelColor: [number, number, number] = [76, 76, 76]
 | 
			
		||||
        //await page.mouse.move(XYPlanePoint.x, XYPlanePoint.y)
 | 
			
		||||
 | 
			
		||||
        //await expectPixelColor(page, modelColor, XYPlanePoint, 8)
 | 
			
		||||
      })
 | 
			
		||||
    }
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  test('Code resets after confirmation', async ({
 | 
			
		||||
    page,
 | 
			
		||||
    homePage,
 | 
			
		||||
    tronApp,
 | 
			
		||||
    scene,
 | 
			
		||||
  }) => {
 | 
			
		||||
    if (!tronApp) {
 | 
			
		||||
      fail()
 | 
			
		||||
    }
 | 
			
		||||
    await tronApp.cleanProjectDir()
 | 
			
		||||
 | 
			
		||||
    const initialCode = `sketch001 = startSketchOn(XZ)`
 | 
			
		||||
 | 
			
		||||
    // Load the page up with some code so we see the confirmation warning
 | 
			
		||||
    // when we go to replay onboarding
 | 
			
		||||
    await page.addInitScript((code) => {
 | 
			
		||||
      localStorage.setItem('persistCode', code)
 | 
			
		||||
    }, initialCode)
 | 
			
		||||
 | 
			
		||||
    await page.setBodyDimensions({ width: 1200, height: 500 })
 | 
			
		||||
    await homePage.goToModelingScene()
 | 
			
		||||
    await scene.connectionEstablished()
 | 
			
		||||
 | 
			
		||||
    // Replay the onboarding
 | 
			
		||||
    await page.getByRole('link', { name: 'Settings' }).last().click()
 | 
			
		||||
    const replayButton = page.getByRole('button', {
 | 
			
		||||
      name: 'Replay onboarding',
 | 
			
		||||
    })
 | 
			
		||||
    const helpMenuButton = page.getByRole('button', {
 | 
			
		||||
      name: 'Help and resources',
 | 
			
		||||
    await expect(replayButton).toBeVisible()
 | 
			
		||||
    await replayButton.click()
 | 
			
		||||
 | 
			
		||||
    // Ensure we see the warning, and that the code has not yet updated
 | 
			
		||||
    await expect(page.getByText('Would you like to create')).toBeVisible()
 | 
			
		||||
    await expect(page.locator('.cm-content')).toHaveText(initialCode)
 | 
			
		||||
 | 
			
		||||
    const nextButton = page.getByTestId('onboarding-next')
 | 
			
		||||
    await nextButton.hover()
 | 
			
		||||
    await nextButton.click()
 | 
			
		||||
 | 
			
		||||
    // Ensure we see the introduction and that the code has been reset
 | 
			
		||||
    await expect(page.getByText('Welcome to Design Studio!')).toBeVisible()
 | 
			
		||||
    await expect(page.locator('.cm-content')).toContainText('// Shelf Bracket')
 | 
			
		||||
 | 
			
		||||
    // There used to be old code here that checked if we stored the reset
 | 
			
		||||
    // code into localStorage but that isn't the case on desktop. It gets
 | 
			
		||||
    // saved to the file system, which we have other tests for.
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  test('Click through each onboarding step and back', async ({
 | 
			
		||||
    context,
 | 
			
		||||
    page,
 | 
			
		||||
    homePage,
 | 
			
		||||
    tronApp,
 | 
			
		||||
  }) => {
 | 
			
		||||
    if (!tronApp) {
 | 
			
		||||
      fail()
 | 
			
		||||
    }
 | 
			
		||||
    await tronApp.cleanProjectDir({
 | 
			
		||||
      app: {
 | 
			
		||||
        onboarding_status: '',
 | 
			
		||||
      },
 | 
			
		||||
    })
 | 
			
		||||
    const helpMenuRestartOnboardingButton = page.getByRole('button', {
 | 
			
		||||
      name: 'Replay onboarding tutorial',
 | 
			
		||||
    })
 | 
			
		||||
    const postDismissToast = page.getByText(
 | 
			
		||||
      'Click the question mark in the lower-right corner if you ever want to redo the tutorial!'
 | 
			
		||||
    // Override beforeEach test setup
 | 
			
		||||
    await context.addInitScript(
 | 
			
		||||
      async ({ settingsKey, settings }) => {
 | 
			
		||||
        // Give no initial code, so that the onboarding start is shown immediately
 | 
			
		||||
        localStorage.setItem('persistCode', '')
 | 
			
		||||
        localStorage.setItem(settingsKey, settings)
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        settingsKey: TEST_SETTINGS_KEY,
 | 
			
		||||
        settings: settingsToToml({
 | 
			
		||||
          settings: TEST_SETTINGS_ONBOARDING_START,
 | 
			
		||||
        }),
 | 
			
		||||
      }
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    await test.step('Test initial home page view, showing a tutorial button', async () => {
 | 
			
		||||
      await expect(homePage.tutorialBtn).toBeVisible()
 | 
			
		||||
      await homePage.expectState({
 | 
			
		||||
        projectCards: [],
 | 
			
		||||
        sortBy: 'last-modified-desc',
 | 
			
		||||
      })
 | 
			
		||||
    await page.setBodyDimensions({ width: 1200, height: 1080 })
 | 
			
		||||
    await homePage.goToModelingScene()
 | 
			
		||||
 | 
			
		||||
    // Test that the onboarding pane loaded
 | 
			
		||||
    await expect(page.getByText('Welcome to Design Studio! This')).toBeVisible()
 | 
			
		||||
 | 
			
		||||
    const nextButton = page.getByTestId('onboarding-next')
 | 
			
		||||
    const prevButton = page.getByTestId('onboarding-prev')
 | 
			
		||||
 | 
			
		||||
    while ((await nextButton.innerText()) !== 'Finish') {
 | 
			
		||||
      await nextButton.hover()
 | 
			
		||||
      await nextButton.click()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    while ((await prevButton.innerText()) !== 'Dismiss') {
 | 
			
		||||
      await prevButton.hover()
 | 
			
		||||
      await prevButton.click()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Dismiss the onboarding
 | 
			
		||||
    await prevButton.hover()
 | 
			
		||||
    await prevButton.click()
 | 
			
		||||
 | 
			
		||||
    // Test that the onboarding pane is gone
 | 
			
		||||
    await expect(page.getByTestId('onboarding-content')).not.toBeVisible()
 | 
			
		||||
    await expect.poll(() => page.url()).not.toContain('/onboarding')
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  test('Onboarding redirects and code updating', async ({
 | 
			
		||||
    context,
 | 
			
		||||
    page,
 | 
			
		||||
    homePage,
 | 
			
		||||
    tronApp,
 | 
			
		||||
  }) => {
 | 
			
		||||
    if (!tronApp) {
 | 
			
		||||
      fail()
 | 
			
		||||
    }
 | 
			
		||||
    await tronApp.cleanProjectDir({
 | 
			
		||||
      app: {
 | 
			
		||||
        onboarding_status: '/export',
 | 
			
		||||
      },
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    await test.step('Create a blank project and verify no onboarding chrome is shown', async () => {
 | 
			
		||||
      await homePage.goToModelingScene()
 | 
			
		||||
      await expect(toolbar.projectName).toContainText('testDefault')
 | 
			
		||||
      await expect(tutorialWelcomHeading).not.toBeVisible()
 | 
			
		||||
      await editor.expectEditor.toContain('@settings(defaultLengthUnit = in)', {
 | 
			
		||||
        shouldNormalise: true,
 | 
			
		||||
      })
 | 
			
		||||
      await scene.connectionEstablished()
 | 
			
		||||
      await expect(toolbar.startSketchBtn).toBeEnabled({ timeout: 15_000 })
 | 
			
		||||
    const originalCode = 'sigmaAllow = 15000'
 | 
			
		||||
 | 
			
		||||
    // Override beforeEach test setup
 | 
			
		||||
    await context.addInitScript(
 | 
			
		||||
      async ({ settingsKey, settings, code }) => {
 | 
			
		||||
        // Give some initial code, so we can test that it's cleared
 | 
			
		||||
        localStorage.setItem('persistCode', code)
 | 
			
		||||
        localStorage.setItem(settingsKey, settings)
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        settingsKey: TEST_SETTINGS_KEY,
 | 
			
		||||
        settings: settingsToToml({
 | 
			
		||||
          settings: TEST_SETTINGS_ONBOARDING_EXPORT,
 | 
			
		||||
        }),
 | 
			
		||||
        code: originalCode,
 | 
			
		||||
      }
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    await page.setBodyDimensions({ width: 1200, height: 500 })
 | 
			
		||||
    await homePage.goToModelingScene()
 | 
			
		||||
 | 
			
		||||
    // Test that the redirect happened
 | 
			
		||||
    await expect.poll(() => page.url()).toContain('/onboarding/export')
 | 
			
		||||
 | 
			
		||||
    // Test that you come back to this page when you refresh
 | 
			
		||||
    await page.reload()
 | 
			
		||||
    await expect.poll(() => page.url()).toContain('/onboarding/export')
 | 
			
		||||
 | 
			
		||||
    // Test that the code changes when you advance to the next step
 | 
			
		||||
    await page.getByTestId('onboarding-next').hover()
 | 
			
		||||
    await page.getByTestId('onboarding-next').click()
 | 
			
		||||
 | 
			
		||||
    // Test that the onboarding pane loaded
 | 
			
		||||
    const title = page.locator('[data-testid="onboarding-content"]')
 | 
			
		||||
    await expect(title).toBeAttached()
 | 
			
		||||
 | 
			
		||||
    await expect(page.locator('.cm-content')).not.toHaveText(originalCode)
 | 
			
		||||
 | 
			
		||||
    // Test that the code is not empty when you click on the next step
 | 
			
		||||
    await page.locator('[data-testid="onboarding-next"]').hover()
 | 
			
		||||
    await page.locator('[data-testid="onboarding-next"]').click()
 | 
			
		||||
    await expect(page.locator('.cm-content')).toHaveText(/.+/)
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  test('Onboarding code gets reset to demo on Interactive Numbers step', async ({
 | 
			
		||||
    page,
 | 
			
		||||
    homePage,
 | 
			
		||||
    tronApp,
 | 
			
		||||
    editor,
 | 
			
		||||
    toolbar,
 | 
			
		||||
  }) => {
 | 
			
		||||
    if (!tronApp) {
 | 
			
		||||
      fail()
 | 
			
		||||
    }
 | 
			
		||||
    await tronApp.cleanProjectDir({
 | 
			
		||||
      app: {
 | 
			
		||||
        onboarding_status: '/parametric-modeling',
 | 
			
		||||
      },
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    await test.step('Go home and verify we still see the tutorial button, then begin it.', async () => {
 | 
			
		||||
      await toolbar.logoLink.click()
 | 
			
		||||
      await expect(homePage.tutorialBtn).toBeVisible()
 | 
			
		||||
      await homePage.expectState({
 | 
			
		||||
        projectCards: [
 | 
			
		||||
          {
 | 
			
		||||
            title: 'testDefault',
 | 
			
		||||
            fileCount: 1,
 | 
			
		||||
          },
 | 
			
		||||
        ],
 | 
			
		||||
        sortBy: 'last-modified-desc',
 | 
			
		||||
      })
 | 
			
		||||
      await homePage.tutorialBtn.click()
 | 
			
		||||
    const badCode = `// This is bad code we shouldn't see`
 | 
			
		||||
 | 
			
		||||
    await page.setBodyDimensions({ width: 1200, height: 1080 })
 | 
			
		||||
    await homePage.goToModelingScene()
 | 
			
		||||
 | 
			
		||||
    await expect
 | 
			
		||||
      .poll(() => page.url())
 | 
			
		||||
      .toContain(onboardingPaths.PARAMETRIC_MODELING)
 | 
			
		||||
 | 
			
		||||
    // Check the code got reset on load
 | 
			
		||||
    await toolbar.openPane('code')
 | 
			
		||||
    await editor.expectEditor.toContain(bracket, {
 | 
			
		||||
      shouldNormalise: true,
 | 
			
		||||
      timeout: 10_000,
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    // This is web-only.
 | 
			
		||||
    // TODO: write a new test just for the onboarding in browser
 | 
			
		||||
    // await test.step('Ensure the onboarding request toast appears', async () => {
 | 
			
		||||
    //   await expect(page.getByTestId('onboarding-toast')).toBeVisible()
 | 
			
		||||
    //   await page.getByTestId('onboarding-next').click()
 | 
			
		||||
    // })
 | 
			
		||||
 | 
			
		||||
    await test.step('Ensure we see the welcome screen in a new project', async () => {
 | 
			
		||||
      await expect(toolbar.projectName).toContainText('Tutorial Project 00')
 | 
			
		||||
      await expect(tutorialWelcomHeading).toBeVisible()
 | 
			
		||||
      await editor.expectEditor.toContain(bracketComment)
 | 
			
		||||
      await scene.connectionEstablished()
 | 
			
		||||
      await expect(toolbar.startSketchBtn).toBeEnabled({ timeout: 15_000 })
 | 
			
		||||
    // Mess with the code again
 | 
			
		||||
    await editor.replaceCode('', badCode)
 | 
			
		||||
    await editor.expectEditor.toContain(badCode, {
 | 
			
		||||
      shouldNormalise: true,
 | 
			
		||||
      timeout: 10_000,
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    await test.step('Test the clicking through the onboarding flow', async () => {
 | 
			
		||||
      await test.step('Going forward', async () => {
 | 
			
		||||
        while ((await nextButton.innerText()) !== 'Finish') {
 | 
			
		||||
          await nextButton.hover()
 | 
			
		||||
          await nextButton.click()
 | 
			
		||||
        }
 | 
			
		||||
      })
 | 
			
		||||
 | 
			
		||||
      await test.step('Going backward', async () => {
 | 
			
		||||
        while ((await prevButton.innerText()) !== 'Dismiss') {
 | 
			
		||||
          await prevButton.hover()
 | 
			
		||||
          await prevButton.click()
 | 
			
		||||
        }
 | 
			
		||||
      })
 | 
			
		||||
 | 
			
		||||
      // Dismiss the onboarding
 | 
			
		||||
      await test.step('Dismiss the onboarding', async () => {
 | 
			
		||||
        await prevButton.hover()
 | 
			
		||||
        await prevButton.click()
 | 
			
		||||
        await expect(page.getByTestId('onboarding-content')).not.toBeVisible()
 | 
			
		||||
        await expect(postDismissToast).toBeVisible()
 | 
			
		||||
        await expect.poll(() => page.url()).not.toContain('/onboarding')
 | 
			
		||||
      })
 | 
			
		||||
    // Click to the next step
 | 
			
		||||
    await page.locator('[data-testid="onboarding-next"]').hover()
 | 
			
		||||
    await page.locator('[data-testid="onboarding-next"]').click()
 | 
			
		||||
    await page.waitForURL('**' + onboardingPaths.INTERACTIVE_NUMBERS, {
 | 
			
		||||
      waitUntil: 'domcontentloaded',
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    await test.step('Resetting onboarding from inside project should always make a new one', async () => {
 | 
			
		||||
      await test.step('Reset onboarding from settings', async () => {
 | 
			
		||||
        await userMenuButton.click()
 | 
			
		||||
        await userMenuSettingsButton.click()
 | 
			
		||||
        await expect(settingsHeading).toBeVisible()
 | 
			
		||||
        await expect(restartOnboardingSettingsButton).toBeVisible()
 | 
			
		||||
        await restartOnboardingSettingsButton.click()
 | 
			
		||||
      })
 | 
			
		||||
 | 
			
		||||
      await test.step('Makes a new project', async () => {
 | 
			
		||||
        await expect(toolbar.projectName).toContainText('Tutorial Project 01')
 | 
			
		||||
        await expect(tutorialWelcomHeading).toBeVisible()
 | 
			
		||||
        await editor.expectEditor.toContain(bracketComment)
 | 
			
		||||
        await scene.connectionEstablished()
 | 
			
		||||
        await expect(toolbar.startSketchBtn).toBeEnabled({ timeout: 15_000 })
 | 
			
		||||
      })
 | 
			
		||||
 | 
			
		||||
      await test.step('Dismiss the onboarding', async () => {
 | 
			
		||||
        await postDismissToast.waitFor({ state: 'detached' })
 | 
			
		||||
        await page.keyboard.press('Escape')
 | 
			
		||||
        await expect(postDismissToast).toBeVisible()
 | 
			
		||||
        await expect(page.getByTestId('onboarding-content')).not.toBeVisible()
 | 
			
		||||
        await expect.poll(() => page.url()).not.toContain('/onboarding')
 | 
			
		||||
      })
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    await test.step('Resetting onboarding from home help menu makes a new project', async () => {
 | 
			
		||||
      await test.step('Go home and reset onboarding from lower-right help menu', async () => {
 | 
			
		||||
        await toolbar.logoLink.click()
 | 
			
		||||
        await expect(homePage.tutorialBtn).not.toBeVisible()
 | 
			
		||||
        await expect(
 | 
			
		||||
          homePage.projectCard.getByText('Tutorial Project 00')
 | 
			
		||||
        ).toBeVisible()
 | 
			
		||||
        await expect(
 | 
			
		||||
          homePage.projectCard.getByText('Tutorial Project 01')
 | 
			
		||||
        ).toBeVisible()
 | 
			
		||||
 | 
			
		||||
        await helpMenuButton.click()
 | 
			
		||||
        await helpMenuRestartOnboardingButton.click()
 | 
			
		||||
      })
 | 
			
		||||
 | 
			
		||||
      await test.step('Makes a new project', async () => {
 | 
			
		||||
        await expect(toolbar.projectName).toContainText('Tutorial Project 02')
 | 
			
		||||
        await expect(tutorialWelcomHeading).toBeVisible()
 | 
			
		||||
        await editor.expectEditor.toContain(bracketComment)
 | 
			
		||||
        await scene.connectionEstablished()
 | 
			
		||||
        await expect(toolbar.startSketchBtn).toBeEnabled({ timeout: 15_000 })
 | 
			
		||||
      })
 | 
			
		||||
    // Check that the code has been reset
 | 
			
		||||
    await editor.expectEditor.toContain(bracket, {
 | 
			
		||||
      shouldNormalise: true,
 | 
			
		||||
      timeout: 10_000,
 | 
			
		||||
    })
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  // (lee) The two avatar tests are weird because even on main, we don't have
 | 
			
		||||
  // anything to do with the avatar inside the onboarding test. Due to the
 | 
			
		||||
  // low impact of an avatar not showing I'm changing this to fixme.
 | 
			
		||||
  test('Avatar text updates depending on image load success', async ({
 | 
			
		||||
    context,
 | 
			
		||||
    page,
 | 
			
		||||
    toolbar,
 | 
			
		||||
    homePage,
 | 
			
		||||
    tronApp,
 | 
			
		||||
  }) => {
 | 
			
		||||
    if (!tronApp) {
 | 
			
		||||
      fail()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    await tronApp.cleanProjectDir({
 | 
			
		||||
      app: {
 | 
			
		||||
        onboarding_status: '',
 | 
			
		||||
      },
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    // Override beforeEach test setup
 | 
			
		||||
    await context.addInitScript(
 | 
			
		||||
      async ({ settingsKey, settings }) => {
 | 
			
		||||
        localStorage.setItem(settingsKey, settings)
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        settingsKey: TEST_SETTINGS_KEY,
 | 
			
		||||
        settings: settingsToToml({
 | 
			
		||||
          settings: TEST_SETTINGS_ONBOARDING_USER_MENU,
 | 
			
		||||
        }),
 | 
			
		||||
      }
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    await page.setBodyDimensions({ width: 1200, height: 500 })
 | 
			
		||||
    await homePage.goToModelingScene()
 | 
			
		||||
 | 
			
		||||
    // Test that the text in this step is correct
 | 
			
		||||
    const avatarLocator = toolbar.userSidebarButton.locator('img')
 | 
			
		||||
    const onboardingOverlayLocator = page
 | 
			
		||||
      .getByTestId('onboarding-content')
 | 
			
		||||
      .locator('div')
 | 
			
		||||
      .nth(1)
 | 
			
		||||
 | 
			
		||||
    // Expect the avatar to be visible and for the text to reference it
 | 
			
		||||
    await expect(avatarLocator).toBeVisible()
 | 
			
		||||
    await expect(onboardingOverlayLocator).toBeVisible()
 | 
			
		||||
    await expect(onboardingOverlayLocator).toContainText('your avatar')
 | 
			
		||||
 | 
			
		||||
    // This is to force the avatar to 404.
 | 
			
		||||
    // For our test image (only triggers locally. on CI, it's Kurt's /
 | 
			
		||||
    // gravatar image )
 | 
			
		||||
    await page.route('/cat.jpg', async (route) => {
 | 
			
		||||
      await route.fulfill({
 | 
			
		||||
        status: 404,
 | 
			
		||||
        contentType: 'text/plain',
 | 
			
		||||
        body: 'Not Found!',
 | 
			
		||||
      })
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    // 404 the CI avatar image
 | 
			
		||||
    await page.route('https://lh3.googleusercontent.com/**', async (route) => {
 | 
			
		||||
      await route.fulfill({
 | 
			
		||||
        status: 404,
 | 
			
		||||
        contentType: 'text/plain',
 | 
			
		||||
        body: 'Not Found!',
 | 
			
		||||
      })
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    await page.reload({ waitUntil: 'domcontentloaded' })
 | 
			
		||||
 | 
			
		||||
    // Now expect the text to be different
 | 
			
		||||
    await expect(avatarLocator).not.toBeVisible()
 | 
			
		||||
    await expect(onboardingOverlayLocator).toBeVisible()
 | 
			
		||||
    await expect(onboardingOverlayLocator).toContainText('the menu button')
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  test("Avatar text doesn't mention avatar when no avatar", async ({
 | 
			
		||||
    context,
 | 
			
		||||
    page,
 | 
			
		||||
    toolbar,
 | 
			
		||||
    homePage,
 | 
			
		||||
    tronApp,
 | 
			
		||||
  }) => {
 | 
			
		||||
    if (!tronApp) {
 | 
			
		||||
      fail()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    await tronApp.cleanProjectDir({
 | 
			
		||||
      app: {
 | 
			
		||||
        onboarding_status: '',
 | 
			
		||||
      },
 | 
			
		||||
    })
 | 
			
		||||
    // Override beforeEach test setup
 | 
			
		||||
    await context.addInitScript(
 | 
			
		||||
      async ({ settingsKey, settings }) => {
 | 
			
		||||
        localStorage.setItem(settingsKey, settings)
 | 
			
		||||
        localStorage.setItem('FORCE_NO_IMAGE', 'FORCE_NO_IMAGE')
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        settingsKey: TEST_SETTINGS_KEY,
 | 
			
		||||
        settings: settingsToToml({
 | 
			
		||||
          settings: TEST_SETTINGS_ONBOARDING_USER_MENU,
 | 
			
		||||
        }),
 | 
			
		||||
      }
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    await page.setBodyDimensions({ width: 1200, height: 500 })
 | 
			
		||||
    await homePage.goToModelingScene()
 | 
			
		||||
 | 
			
		||||
    // Test that the text in this step is correct
 | 
			
		||||
    const sidebar = toolbar.userSidebarButton
 | 
			
		||||
    const avatar = sidebar.locator('img')
 | 
			
		||||
    const onboardingOverlayLocator = page
 | 
			
		||||
      .getByTestId('onboarding-content')
 | 
			
		||||
      .locator('div')
 | 
			
		||||
      .nth(1)
 | 
			
		||||
 | 
			
		||||
    // Expect the avatar to be visible and for the text to reference it
 | 
			
		||||
    await expect(avatar).not.toBeVisible()
 | 
			
		||||
    await expect(onboardingOverlayLocator).toBeVisible()
 | 
			
		||||
    await expect(onboardingOverlayLocator).toContainText('the menu button')
 | 
			
		||||
 | 
			
		||||
    // Test we mention what else is in this menu for https://github.com/KittyCAD/modeling-app/issues/2939
 | 
			
		||||
    // which doesn't deserver its own full test spun up
 | 
			
		||||
    const userMenuFeatures = [
 | 
			
		||||
      'manage your account',
 | 
			
		||||
      'report a bug',
 | 
			
		||||
      'request a feature',
 | 
			
		||||
      'sign out',
 | 
			
		||||
    ]
 | 
			
		||||
    for (const feature of userMenuFeatures) {
 | 
			
		||||
      await expect(onboardingOverlayLocator).toContainText(feature)
 | 
			
		||||
    }
 | 
			
		||||
  })
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
test('Restarting onboarding on desktop takes one attempt', async ({
 | 
			
		||||
  context,
 | 
			
		||||
  page,
 | 
			
		||||
  toolbar,
 | 
			
		||||
  tronApp,
 | 
			
		||||
}) => {
 | 
			
		||||
  if (!tronApp) {
 | 
			
		||||
    fail()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  await tronApp.cleanProjectDir({
 | 
			
		||||
    app: {
 | 
			
		||||
      onboarding_status: 'dismissed',
 | 
			
		||||
    },
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  await context.folderSetupFn(async (dir) => {
 | 
			
		||||
    const routerTemplateDir = join(dir, 'router-template-slate')
 | 
			
		||||
    await fsp.mkdir(routerTemplateDir, { recursive: true })
 | 
			
		||||
    await fsp.copyFile(
 | 
			
		||||
      executorInputPath('router-template-slate.kcl'),
 | 
			
		||||
      join(routerTemplateDir, 'main.kcl')
 | 
			
		||||
    )
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  // Our constants
 | 
			
		||||
  const u = await getUtils(page)
 | 
			
		||||
  const projectCard = page.getByText('router-template-slate')
 | 
			
		||||
  const helpMenuButton = page.getByRole('button', {
 | 
			
		||||
    name: 'Help and resources',
 | 
			
		||||
  })
 | 
			
		||||
  const restartOnboardingButton = page.getByRole('button', {
 | 
			
		||||
    name: 'Reset onboarding',
 | 
			
		||||
  })
 | 
			
		||||
  const nextButton = page.getByTestId('onboarding-next')
 | 
			
		||||
 | 
			
		||||
  const tutorialProjectIndicator = page
 | 
			
		||||
    .getByTestId('project-sidebar-toggle')
 | 
			
		||||
    .filter({ hasText: 'Tutorial Project 00' })
 | 
			
		||||
  const tutorialModalText = page.getByText('Welcome to Design Studio!')
 | 
			
		||||
  const tutorialDismissButton = page.getByRole('button', { name: 'Dismiss' })
 | 
			
		||||
  const userMenuButton = toolbar.userSidebarButton
 | 
			
		||||
  const userMenuSettingsButton = page.getByRole('button', {
 | 
			
		||||
    name: 'User settings',
 | 
			
		||||
  })
 | 
			
		||||
  const settingsHeading = page.getByRole('heading', {
 | 
			
		||||
    name: 'Settings',
 | 
			
		||||
    exact: true,
 | 
			
		||||
  })
 | 
			
		||||
  const restartOnboardingSettingsButton = page.getByRole('button', {
 | 
			
		||||
    name: 'Replay onboarding',
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  await test.step('Navigate into project', async () => {
 | 
			
		||||
    await expect(page.getByRole('heading', { name: 'Projects' })).toBeVisible()
 | 
			
		||||
    await expect(projectCard).toBeVisible()
 | 
			
		||||
    await projectCard.click()
 | 
			
		||||
    await u.waitForPageLoad()
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  await test.step('Restart the onboarding from help menu', async () => {
 | 
			
		||||
    await helpMenuButton.click()
 | 
			
		||||
    await restartOnboardingButton.click()
 | 
			
		||||
 | 
			
		||||
    await nextButton.hover()
 | 
			
		||||
    await nextButton.click()
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  await test.step('Confirm that the onboarding has restarted', async () => {
 | 
			
		||||
    await expect(tutorialProjectIndicator).toBeVisible()
 | 
			
		||||
    await expect(tutorialModalText).toBeVisible()
 | 
			
		||||
    // Make sure the model loaded
 | 
			
		||||
    const XYPlanePoint = { x: 988, y: 523 } as const
 | 
			
		||||
    const modelColor: [number, number, number] = [76, 76, 76]
 | 
			
		||||
 | 
			
		||||
    await page.mouse.move(XYPlanePoint.x, XYPlanePoint.y)
 | 
			
		||||
    await expectPixelColor(page, modelColor, XYPlanePoint, 8)
 | 
			
		||||
    await tutorialDismissButton.click()
 | 
			
		||||
    // Make sure model still there.
 | 
			
		||||
    await expectPixelColor(page, modelColor, XYPlanePoint, 8)
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  await test.step('Clear code and restart onboarding from settings', async () => {
 | 
			
		||||
    await u.openKclCodePanel()
 | 
			
		||||
    await expect(u.codeLocator).toContainText('// Shelf Bracket')
 | 
			
		||||
    await u.codeLocator.selectText()
 | 
			
		||||
    await u.codeLocator.fill('')
 | 
			
		||||
 | 
			
		||||
    await test.step('Navigate to settings', async () => {
 | 
			
		||||
      await userMenuButton.click()
 | 
			
		||||
      await userMenuSettingsButton.click()
 | 
			
		||||
      await expect(settingsHeading).toBeVisible()
 | 
			
		||||
      await expect(restartOnboardingSettingsButton).toBeVisible()
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    await restartOnboardingSettingsButton.click()
 | 
			
		||||
    // Since the code is empty, we should not see the confirmation dialog
 | 
			
		||||
    await expect(nextButton).not.toBeVisible()
 | 
			
		||||
    await expect(tutorialProjectIndicator).toBeVisible()
 | 
			
		||||
    await expect(tutorialModalText).toBeVisible()
 | 
			
		||||
  })
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
@ -1,10 +1,12 @@
 | 
			
		||||
import type { SaveSettingsPayload } from '@src/lib/settings/settingsTypes'
 | 
			
		||||
import { Themes } from '@src/lib/theme'
 | 
			
		||||
import type { DeepPartial } from '@src/lib/types'
 | 
			
		||||
import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
 | 
			
		||||
import { onboardingPaths } from '@src/routes/Onboarding/paths'
 | 
			
		||||
 | 
			
		||||
import type { Settings } from '@rust/kcl-lib/bindings/Settings'
 | 
			
		||||
 | 
			
		||||
export const IS_PLAYWRIGHT_KEY = 'playwright'
 | 
			
		||||
 | 
			
		||||
export const TEST_SETTINGS_KEY = '/settings.toml'
 | 
			
		||||
export const TEST_SETTINGS: DeepPartial<Settings> = {
 | 
			
		||||
  app: {
 | 
			
		||||
@ -31,15 +33,12 @@ export const TEST_SETTINGS: DeepPartial<Settings> = {
 | 
			
		||||
 | 
			
		||||
export const TEST_SETTINGS_ONBOARDING_USER_MENU: DeepPartial<Settings> = {
 | 
			
		||||
  ...TEST_SETTINGS,
 | 
			
		||||
  app: {
 | 
			
		||||
    ...TEST_SETTINGS.app,
 | 
			
		||||
    onboarding_status: ONBOARDING_SUBPATHS.USER_MENU,
 | 
			
		||||
  },
 | 
			
		||||
  app: { ...TEST_SETTINGS.app, onboarding_status: onboardingPaths.USER_MENU },
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const TEST_SETTINGS_ONBOARDING_EXPORT: DeepPartial<Settings> = {
 | 
			
		||||
  ...TEST_SETTINGS,
 | 
			
		||||
  app: { ...TEST_SETTINGS.app, onboarding_status: ONBOARDING_SUBPATHS.EXPORT },
 | 
			
		||||
  app: { ...TEST_SETTINGS.app, onboarding_status: onboardingPaths.EXPORT },
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const TEST_SETTINGS_ONBOARDING_PARAMETRIC_MODELING: DeepPartial<Settings> =
 | 
			
		||||
@ -47,7 +46,7 @@ export const TEST_SETTINGS_ONBOARDING_PARAMETRIC_MODELING: DeepPartial<Settings>
 | 
			
		||||
    ...TEST_SETTINGS,
 | 
			
		||||
    app: {
 | 
			
		||||
      ...TEST_SETTINGS.app,
 | 
			
		||||
      onboarding_status: ONBOARDING_SUBPATHS.PARAMETRIC_MODELING,
 | 
			
		||||
      onboarding_status: onboardingPaths.PARAMETRIC_MODELING,
 | 
			
		||||
    },
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -5,7 +5,7 @@ import type { BrowserContext, Locator, Page, TestInfo } from '@playwright/test'
 | 
			
		||||
import { expect } from '@playwright/test'
 | 
			
		||||
import type { EngineCommand } from '@src/lang/std/artifactGraph'
 | 
			
		||||
import type { Configuration } from '@src/lang/wasm'
 | 
			
		||||
import { COOKIE_NAME, IS_PLAYWRIGHT_KEY } from '@src/lib/constants'
 | 
			
		||||
import { COOKIE_NAME } from '@src/lib/constants'
 | 
			
		||||
import { reportRejection } from '@src/lib/trap'
 | 
			
		||||
import type { DeepPartial } from '@src/lib/types'
 | 
			
		||||
import { isArray } from '@src/lib/utils'
 | 
			
		||||
@ -18,7 +18,11 @@ import type { ProjectConfiguration } from '@rust/kcl-lib/bindings/ProjectConfigu
 | 
			
		||||
 | 
			
		||||
import { isErrorWhitelisted } from '@e2e/playwright/lib/console-error-whitelist'
 | 
			
		||||
import { secrets } from '@e2e/playwright/secrets'
 | 
			
		||||
import { TEST_SETTINGS, TEST_SETTINGS_KEY } from '@e2e/playwright/storageStates'
 | 
			
		||||
import {
 | 
			
		||||
  IS_PLAYWRIGHT_KEY,
 | 
			
		||||
  TEST_SETTINGS,
 | 
			
		||||
  TEST_SETTINGS_KEY,
 | 
			
		||||
} from '@e2e/playwright/storageStates'
 | 
			
		||||
import { test } from '@e2e/playwright/zoo-test'
 | 
			
		||||
 | 
			
		||||
const toNormalizedCode = (text: string) => {
 | 
			
		||||
 | 
			
		||||
@ -11,3 +11,4 @@
 | 
			
		||||
  6) src/lib/singletons.ts -> src/clientSideScene/sceneEntities.ts -> src/clientSideScene/segments.ts -> src/components/Toolbar/angleLengthInfo.ts
 | 
			
		||||
  7) src/lib/singletons.ts -> src/clientSideScene/sceneEntities.ts -> src/clientSideScene/segments.ts
 | 
			
		||||
  8) src/hooks/useModelingContext.ts -> src/components/ModelingMachineProvider.tsx -> src/components/Toolbar/Intersect.tsx -> src/components/SetHorVertDistanceModal.tsx -> src/lib/useCalculateKclExpression.ts
 | 
			
		||||
  9) src/routes/Onboarding/index.tsx -> src/routes/Onboarding/Camera.tsx -> src/routes/Onboarding/utils.tsx
 | 
			
		||||
 | 
			
		||||
@ -527,6 +527,9 @@ pub enum OnboardingStatus {
 | 
			
		||||
    #[serde(rename = "/export")]
 | 
			
		||||
    #[display("/export")]
 | 
			
		||||
    Export,
 | 
			
		||||
    #[serde(rename = "/move")]
 | 
			
		||||
    #[display("/move")]
 | 
			
		||||
    Move,
 | 
			
		||||
    #[serde(rename = "/sketching")]
 | 
			
		||||
    #[display("/sketching")]
 | 
			
		||||
    Sketching,
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										55
									
								
								src/App.tsx
									
									
									
									
									
								
							
							
						
						
									
										55
									
								
								src/App.tsx
									
									
									
									
									
								
							@ -4,7 +4,6 @@ import { useHotkeys } from 'react-hotkeys-hook'
 | 
			
		||||
import ModalContainer from 'react-modal-promise'
 | 
			
		||||
import {
 | 
			
		||||
  useLoaderData,
 | 
			
		||||
  useLocation,
 | 
			
		||||
  useNavigate,
 | 
			
		||||
  useRouteLoaderData,
 | 
			
		||||
  useSearchParams,
 | 
			
		||||
@ -27,20 +26,15 @@ import useHotkeyWrapper from '@src/lib/hotkeyWrapper'
 | 
			
		||||
import { isDesktop } from '@src/lib/isDesktop'
 | 
			
		||||
import { PATHS } from '@src/lib/paths'
 | 
			
		||||
import { takeScreenshotOfVideoStreamCanvas } from '@src/lib/screenshot'
 | 
			
		||||
import { sceneInfra, codeManager, kclManager } from '@src/lib/singletons'
 | 
			
		||||
import { sceneInfra } from '@src/lib/singletons'
 | 
			
		||||
import { maybeWriteToDisk } from '@src/lib/telemetry'
 | 
			
		||||
import type { IndexLoaderData } from '@src/lib/types'
 | 
			
		||||
import { type IndexLoaderData } from '@src/lib/types'
 | 
			
		||||
import { engineStreamActor, useSettings, useToken } from '@src/lib/singletons'
 | 
			
		||||
import { commandBarActor } from '@src/lib/singletons'
 | 
			
		||||
import { EngineStreamTransition } from '@src/machines/engineStreamMachine'
 | 
			
		||||
import { onboardingPaths } from '@src/routes/Onboarding/paths'
 | 
			
		||||
import { CommandBarOpenButton } from '@src/components/CommandBarOpenButton'
 | 
			
		||||
import { ShareButton } from '@src/components/ShareButton'
 | 
			
		||||
import {
 | 
			
		||||
  needsToOnboard,
 | 
			
		||||
  ONBOARDING_TOAST_ID,
 | 
			
		||||
  TutorialRequestToast,
 | 
			
		||||
} from '@src/routes/Onboarding/utils'
 | 
			
		||||
import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
 | 
			
		||||
 | 
			
		||||
// CYCLIC REF
 | 
			
		||||
sceneInfra.camControls.engineStreamActor = engineStreamActor
 | 
			
		||||
@ -64,7 +58,6 @@ export function App() {
 | 
			
		||||
    })
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  const location = useLocation()
 | 
			
		||||
  const navigate = useNavigate()
 | 
			
		||||
  const filePath = useAbsoluteFilePath()
 | 
			
		||||
  const { onProjectOpen } = useLspContext()
 | 
			
		||||
@ -73,7 +66,7 @@ export function App() {
 | 
			
		||||
  const ref = useRef<HTMLDivElement>(null)
 | 
			
		||||
 | 
			
		||||
  // Stream related refs and data
 | 
			
		||||
  const [searchParams] = useSearchParams()
 | 
			
		||||
  let [searchParams] = useSearchParams()
 | 
			
		||||
  const pool = searchParams.get('pool')
 | 
			
		||||
 | 
			
		||||
  const projectName = project?.name || null
 | 
			
		||||
@ -83,10 +76,9 @@ export function App() {
 | 
			
		||||
  const loaderData = useRouteLoaderData(PATHS.FILE) as IndexLoaderData
 | 
			
		||||
  const lastCommandType = commands[commands.length - 1]?.type
 | 
			
		||||
 | 
			
		||||
  // Run LSP file open hook when navigating between projects or files
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    onProjectOpen({ name: projectName, path: projectPath }, file || null)
 | 
			
		||||
  }, [onProjectOpen, projectName, projectPath, file])
 | 
			
		||||
  }, [projectName, projectPath])
 | 
			
		||||
 | 
			
		||||
  useHotKeyListener()
 | 
			
		||||
 | 
			
		||||
@ -112,10 +104,9 @@ export function App() {
 | 
			
		||||
    toast.success('Your work is auto-saved in real-time')
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  const paneOpacity = [
 | 
			
		||||
    ONBOARDING_SUBPATHS.CAMERA,
 | 
			
		||||
    ONBOARDING_SUBPATHS.STREAMING,
 | 
			
		||||
  ].some((p) => p === onboardingStatus.current)
 | 
			
		||||
  const paneOpacity = [onboardingPaths.CAMERA, onboardingPaths.STREAMING].some(
 | 
			
		||||
    (p) => p === onboardingStatus.current
 | 
			
		||||
  )
 | 
			
		||||
    ? 'opacity-20'
 | 
			
		||||
    : ''
 | 
			
		||||
 | 
			
		||||
@ -141,7 +132,7 @@ export function App() {
 | 
			
		||||
          })
 | 
			
		||||
      }, 500)
 | 
			
		||||
    }
 | 
			
		||||
  }, [lastCommandType, loaderData?.project?.path])
 | 
			
		||||
  }, [lastCommandType])
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    // When leaving the modeling scene, cut the engine stream.
 | 
			
		||||
@ -150,32 +141,6 @@ export function App() {
 | 
			
		||||
    }
 | 
			
		||||
  }, [])
 | 
			
		||||
 | 
			
		||||
  // Show a custom toast to users if they haven't done the onboarding
 | 
			
		||||
  // and they're on the web
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const onboardingStatus =
 | 
			
		||||
      settings.app.onboardingStatus.current ||
 | 
			
		||||
      settings.app.onboardingStatus.default
 | 
			
		||||
    const needsOnboarded = needsToOnboard(location, onboardingStatus)
 | 
			
		||||
 | 
			
		||||
    if (!isDesktop() && needsOnboarded) {
 | 
			
		||||
      toast.success(
 | 
			
		||||
        () =>
 | 
			
		||||
          TutorialRequestToast({
 | 
			
		||||
            onboardingStatus: settings.app.onboardingStatus.current,
 | 
			
		||||
            navigate,
 | 
			
		||||
            codeManager,
 | 
			
		||||
            kclManager,
 | 
			
		||||
          }),
 | 
			
		||||
        {
 | 
			
		||||
          id: ONBOARDING_TOAST_ID,
 | 
			
		||||
          duration: Number.POSITIVE_INFINITY,
 | 
			
		||||
          icon: null,
 | 
			
		||||
        }
 | 
			
		||||
      )
 | 
			
		||||
    }
 | 
			
		||||
  }, [location, settings.app.onboardingStatus, navigate])
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="relative h-full flex flex-col" ref={ref}>
 | 
			
		||||
      <AppHeader
 | 
			
		||||
@ -190,7 +155,7 @@ export function App() {
 | 
			
		||||
      <ModelingSidebar paneOpacity={paneOpacity} />
 | 
			
		||||
      <EngineStream pool={pool} authToken={authToken} />
 | 
			
		||||
      {/* <CamToggle /> */}
 | 
			
		||||
      <LowerRightControls navigate={navigate}>
 | 
			
		||||
      <LowerRightControls>
 | 
			
		||||
        <UnitsMenu />
 | 
			
		||||
        <Gizmo />
 | 
			
		||||
      </LowerRightControls>
 | 
			
		||||
 | 
			
		||||
@ -28,7 +28,7 @@ import useHotkeyWrapper from '@src/lib/hotkeyWrapper'
 | 
			
		||||
import { isDesktop } from '@src/lib/isDesktop'
 | 
			
		||||
import makeUrlPathRelative from '@src/lib/makeUrlPathRelative'
 | 
			
		||||
import { PATHS } from '@src/lib/paths'
 | 
			
		||||
import { fileLoader, homeLoader } from '@src/lib/routeLoaders'
 | 
			
		||||
import { fileLoader, homeLoader, telemetryLoader } from '@src/lib/routeLoaders'
 | 
			
		||||
import {
 | 
			
		||||
  codeManager,
 | 
			
		||||
  engineCommandManager,
 | 
			
		||||
@ -110,6 +110,7 @@ const router = createRouter([
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            id: PATHS.FILE + 'TELEMETRY',
 | 
			
		||||
            loader: telemetryLoader,
 | 
			
		||||
            children: [
 | 
			
		||||
              {
 | 
			
		||||
                path: makeUrlPathRelative(PATHS.TELEMETRY),
 | 
			
		||||
@ -143,6 +144,7 @@ const router = createRouter([
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            path: makeUrlPathRelative(PATHS.TELEMETRY),
 | 
			
		||||
            loader: telemetryLoader,
 | 
			
		||||
            element: <Telemetry />,
 | 
			
		||||
          },
 | 
			
		||||
        ],
 | 
			
		||||
 | 
			
		||||
@ -854,14 +854,6 @@ const CustomIconMap = {
 | 
			
		||||
      />
 | 
			
		||||
    </svg>
 | 
			
		||||
  ),
 | 
			
		||||
  play: (
 | 
			
		||||
    <svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
 | 
			
		||||
      <path
 | 
			
		||||
        d="M14.2842 9.83887L14.2617 10.6758L7.76172 14.6758L7 14.25V5.75L7.78418 5.33887L14.2842 9.83887ZM8 13.3555L13.0859 10.2246L8 6.70312V13.3555Z"
 | 
			
		||||
        fill="currentColor"
 | 
			
		||||
      />
 | 
			
		||||
    </svg>
 | 
			
		||||
  ),
 | 
			
		||||
  rotate: (
 | 
			
		||||
    <svg
 | 
			
		||||
      viewBox="0 0 20 20"
 | 
			
		||||
 | 
			
		||||
@ -1,46 +1,49 @@
 | 
			
		||||
import { Popover } from '@headlessui/react'
 | 
			
		||||
import { type NavigateFunction, useLocation } from 'react-router-dom'
 | 
			
		||||
import { useLocation, useNavigate } from 'react-router-dom'
 | 
			
		||||
 | 
			
		||||
import { CustomIcon } from '@src/components/CustomIcon'
 | 
			
		||||
import { useLspContext } from '@src/components/LspProvider'
 | 
			
		||||
import Tooltip from '@src/components/Tooltip'
 | 
			
		||||
import { useAbsoluteFilePath } from '@src/hooks/useAbsoluteFilePath'
 | 
			
		||||
import { useMenuListener } from '@src/hooks/useMenu'
 | 
			
		||||
import { createAndOpenNewTutorialProject } from '@src/lib/desktopFS'
 | 
			
		||||
import { openExternalBrowserIfDesktop } from '@src/lib/openWindow'
 | 
			
		||||
import { PATHS } from '@src/lib/paths'
 | 
			
		||||
import { codeManager, kclManager } from '@src/lib/singletons'
 | 
			
		||||
import { reportRejection } from '@src/lib/trap'
 | 
			
		||||
import { settingsActor } from '@src/lib/singletons'
 | 
			
		||||
import type { WebContentSendPayload } from '@src/menu/channels'
 | 
			
		||||
import {
 | 
			
		||||
  acceptOnboarding,
 | 
			
		||||
  catchOnboardingWarnError,
 | 
			
		||||
} from '@src/routes/Onboarding/utils'
 | 
			
		||||
import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
 | 
			
		||||
 | 
			
		||||
const HelpMenuDivider = () => (
 | 
			
		||||
  <div className="h-[1px] bg-chalkboard-110 dark:bg-chalkboard-80" />
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
export function HelpMenu({
 | 
			
		||||
  navigate = () => {},
 | 
			
		||||
}: {
 | 
			
		||||
  navigate?: NavigateFunction
 | 
			
		||||
}) {
 | 
			
		||||
export function HelpMenu(props: React.PropsWithChildren) {
 | 
			
		||||
  const location = useLocation()
 | 
			
		||||
  const { onProjectOpen } = useLspContext()
 | 
			
		||||
  const filePath = useAbsoluteFilePath()
 | 
			
		||||
  const isInProject = location.pathname.includes(PATHS.FILE)
 | 
			
		||||
  const navigate = useNavigate()
 | 
			
		||||
 | 
			
		||||
  const resetOnboardingWorkflow = () => {
 | 
			
		||||
    const props = {
 | 
			
		||||
      onboardingStatus: ONBOARDING_SUBPATHS.INDEX,
 | 
			
		||||
      navigate,
 | 
			
		||||
      codeManager,
 | 
			
		||||
      kclManager,
 | 
			
		||||
    settingsActor.send({
 | 
			
		||||
      type: 'set.app.onboardingStatus',
 | 
			
		||||
      data: {
 | 
			
		||||
        value: '',
 | 
			
		||||
        level: 'user',
 | 
			
		||||
      },
 | 
			
		||||
    })
 | 
			
		||||
    if (isInProject) {
 | 
			
		||||
      navigate(filePath + PATHS.ONBOARDING.INDEX)
 | 
			
		||||
    } else {
 | 
			
		||||
      createAndOpenNewTutorialProject({
 | 
			
		||||
        onProjectOpen,
 | 
			
		||||
        navigate,
 | 
			
		||||
      }).catch(reportRejection)
 | 
			
		||||
    }
 | 
			
		||||
    acceptOnboarding(props).catch((reason) =>
 | 
			
		||||
      catchOnboardingWarnError(reason, props)
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const cb = (data: WebContentSendPayload) => {
 | 
			
		||||
    if (data.menuLabel === 'Help.Replay onboarding tutorial') {
 | 
			
		||||
    if (data.menuLabel === 'Help.Reset onboarding') {
 | 
			
		||||
      resetOnboardingWorkflow()
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
@ -65,81 +68,71 @@ export function HelpMenu({
 | 
			
		||||
        as="ul"
 | 
			
		||||
        className="absolute right-0 left-auto flex flex-col w-64 gap-1 p-0 py-2 m-0 mb-1 text-sm border border-solid rounded shadow-lg bottom-full align-stretch text-chalkboard-10 dark:text-inherit bg-chalkboard-110 dark:bg-chalkboard-100 border-chalkboard-110 dark:border-chalkboard-80"
 | 
			
		||||
      >
 | 
			
		||||
        {({ close }) => (
 | 
			
		||||
          <>
 | 
			
		||||
            <HelpMenuItem
 | 
			
		||||
              as="a"
 | 
			
		||||
              href="https://github.com/KittyCAD/modeling-app/issues/new/choose"
 | 
			
		||||
              target="_blank"
 | 
			
		||||
              rel="noopener noreferrer"
 | 
			
		||||
            >
 | 
			
		||||
              Report a bug
 | 
			
		||||
            </HelpMenuItem>
 | 
			
		||||
            <HelpMenuItem
 | 
			
		||||
              as="a"
 | 
			
		||||
              href="https://github.com/KittyCAD/modeling-app/discussions"
 | 
			
		||||
              target="_blank"
 | 
			
		||||
              rel="noopener noreferrer"
 | 
			
		||||
            >
 | 
			
		||||
              Request a feature
 | 
			
		||||
            </HelpMenuItem>
 | 
			
		||||
            <HelpMenuItem
 | 
			
		||||
              as="a"
 | 
			
		||||
              href="https://discord.gg/JQEpHR7Nt2"
 | 
			
		||||
              target="_blank"
 | 
			
		||||
              rel="noopener noreferrer"
 | 
			
		||||
            >
 | 
			
		||||
              Ask the community
 | 
			
		||||
            </HelpMenuItem>
 | 
			
		||||
            <HelpMenuDivider />
 | 
			
		||||
            <HelpMenuItem
 | 
			
		||||
              as="a"
 | 
			
		||||
              href="https://zoo.dev/docs/kcl-samples"
 | 
			
		||||
              target="_blank"
 | 
			
		||||
              rel="noopener noreferrer"
 | 
			
		||||
            >
 | 
			
		||||
              KCL code samples
 | 
			
		||||
            </HelpMenuItem>
 | 
			
		||||
            <HelpMenuItem
 | 
			
		||||
              as="a"
 | 
			
		||||
              href="https://zoo.dev/docs/kcl"
 | 
			
		||||
              target="_blank"
 | 
			
		||||
              rel="noopener noreferrer"
 | 
			
		||||
            >
 | 
			
		||||
              KCL docs
 | 
			
		||||
            </HelpMenuItem>
 | 
			
		||||
            <HelpMenuDivider />
 | 
			
		||||
            <HelpMenuItem
 | 
			
		||||
              as="a"
 | 
			
		||||
              href="https://github.com/KittyCAD/modeling-app/releases"
 | 
			
		||||
              target="_blank"
 | 
			
		||||
              rel="noopener noreferrer"
 | 
			
		||||
            >
 | 
			
		||||
              Release notes
 | 
			
		||||
            </HelpMenuItem>
 | 
			
		||||
            <HelpMenuItem
 | 
			
		||||
              as="button"
 | 
			
		||||
              onClick={() => {
 | 
			
		||||
                const targetPath = location.pathname.includes(PATHS.FILE)
 | 
			
		||||
                  ? filePath + PATHS.SETTINGS_KEYBINDINGS
 | 
			
		||||
                  : PATHS.HOME + PATHS.SETTINGS_KEYBINDINGS
 | 
			
		||||
                navigate(targetPath)
 | 
			
		||||
              }}
 | 
			
		||||
              data-testid="keybindings-button"
 | 
			
		||||
            >
 | 
			
		||||
              Keyboard shortcuts
 | 
			
		||||
            </HelpMenuItem>
 | 
			
		||||
            <HelpMenuItem
 | 
			
		||||
              as="button"
 | 
			
		||||
              onClick={() => {
 | 
			
		||||
                close()
 | 
			
		||||
                resetOnboardingWorkflow()
 | 
			
		||||
              }}
 | 
			
		||||
            >
 | 
			
		||||
              Replay onboarding tutorial
 | 
			
		||||
            </HelpMenuItem>
 | 
			
		||||
          </>
 | 
			
		||||
        )}
 | 
			
		||||
        <HelpMenuItem
 | 
			
		||||
          as="a"
 | 
			
		||||
          href="https://github.com/KittyCAD/modeling-app/issues/new/choose"
 | 
			
		||||
          target="_blank"
 | 
			
		||||
          rel="noopener noreferrer"
 | 
			
		||||
        >
 | 
			
		||||
          Report a bug
 | 
			
		||||
        </HelpMenuItem>
 | 
			
		||||
        <HelpMenuItem
 | 
			
		||||
          as="a"
 | 
			
		||||
          href="https://github.com/KittyCAD/modeling-app/discussions"
 | 
			
		||||
          target="_blank"
 | 
			
		||||
          rel="noopener noreferrer"
 | 
			
		||||
        >
 | 
			
		||||
          Request a feature
 | 
			
		||||
        </HelpMenuItem>
 | 
			
		||||
        <HelpMenuItem
 | 
			
		||||
          as="a"
 | 
			
		||||
          href="https://discord.gg/JQEpHR7Nt2"
 | 
			
		||||
          target="_blank"
 | 
			
		||||
          rel="noopener noreferrer"
 | 
			
		||||
        >
 | 
			
		||||
          Ask the community
 | 
			
		||||
        </HelpMenuItem>
 | 
			
		||||
        <HelpMenuDivider />
 | 
			
		||||
        <HelpMenuItem
 | 
			
		||||
          as="a"
 | 
			
		||||
          href="https://zoo.dev/docs/kcl-samples"
 | 
			
		||||
          target="_blank"
 | 
			
		||||
          rel="noopener noreferrer"
 | 
			
		||||
        >
 | 
			
		||||
          KCL code samples
 | 
			
		||||
        </HelpMenuItem>
 | 
			
		||||
        <HelpMenuItem
 | 
			
		||||
          as="a"
 | 
			
		||||
          href="https://zoo.dev/docs/kcl"
 | 
			
		||||
          target="_blank"
 | 
			
		||||
          rel="noopener noreferrer"
 | 
			
		||||
        >
 | 
			
		||||
          KCL docs
 | 
			
		||||
        </HelpMenuItem>
 | 
			
		||||
        <HelpMenuDivider />
 | 
			
		||||
        <HelpMenuItem
 | 
			
		||||
          as="a"
 | 
			
		||||
          href="https://github.com/KittyCAD/modeling-app/releases"
 | 
			
		||||
          target="_blank"
 | 
			
		||||
          rel="noopener noreferrer"
 | 
			
		||||
        >
 | 
			
		||||
          Release notes
 | 
			
		||||
        </HelpMenuItem>
 | 
			
		||||
        <HelpMenuItem
 | 
			
		||||
          as="button"
 | 
			
		||||
          onClick={() => {
 | 
			
		||||
            const targetPath = location.pathname.includes(PATHS.FILE)
 | 
			
		||||
              ? filePath + PATHS.SETTINGS_KEYBINDINGS
 | 
			
		||||
              : PATHS.HOME + PATHS.SETTINGS_KEYBINDINGS
 | 
			
		||||
            navigate(targetPath)
 | 
			
		||||
          }}
 | 
			
		||||
          data-testid="keybindings-button"
 | 
			
		||||
        >
 | 
			
		||||
          Keyboard shortcuts
 | 
			
		||||
        </HelpMenuItem>
 | 
			
		||||
        <HelpMenuItem as="button" onClick={resetOnboardingWorkflow}>
 | 
			
		||||
          Reset onboarding
 | 
			
		||||
        </HelpMenuItem>
 | 
			
		||||
      </Popover.Panel>
 | 
			
		||||
    </Popover>
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,5 @@
 | 
			
		||||
import { Link, type NavigateFunction, useLocation } from 'react-router-dom'
 | 
			
		||||
import { Link, useLocation } from 'react-router-dom'
 | 
			
		||||
 | 
			
		||||
import { CustomIcon } from '@src/components/CustomIcon'
 | 
			
		||||
import { HelpMenu } from '@src/components/HelpMenu'
 | 
			
		||||
import { NetworkHealthIndicator } from '@src/components/NetworkHealthIndicator'
 | 
			
		||||
@ -11,10 +12,8 @@ import { APP_VERSION, getReleaseUrl } from '@src/routes/utils'
 | 
			
		||||
 | 
			
		||||
export function LowerRightControls({
 | 
			
		||||
  children,
 | 
			
		||||
  navigate = () => {},
 | 
			
		||||
}: {
 | 
			
		||||
  children?: React.ReactNode
 | 
			
		||||
  navigate?: NavigateFunction
 | 
			
		||||
}) {
 | 
			
		||||
  const location = useLocation()
 | 
			
		||||
  const filePath = useAbsoluteFilePath()
 | 
			
		||||
@ -73,7 +72,7 @@ export function LowerRightControls({
 | 
			
		||||
        {!location.pathname.startsWith(PATHS.HOME) && (
 | 
			
		||||
          <NetworkHealthIndicator />
 | 
			
		||||
        )}
 | 
			
		||||
        <HelpMenu navigate={navigate} />
 | 
			
		||||
        <HelpMenu />
 | 
			
		||||
      </menu>
 | 
			
		||||
    </section>
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
@ -6,7 +6,7 @@ import { ActionIcon } from '@src/components/ActionIcon'
 | 
			
		||||
import type { CustomIconName } from '@src/components/CustomIcon'
 | 
			
		||||
import Tooltip from '@src/components/Tooltip'
 | 
			
		||||
import { useSettings } from '@src/lib/singletons'
 | 
			
		||||
import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
 | 
			
		||||
import { onboardingPaths } from '@src/routes/Onboarding/paths'
 | 
			
		||||
 | 
			
		||||
import styles from './ModelingPane.module.css'
 | 
			
		||||
 | 
			
		||||
@ -71,7 +71,7 @@ export const ModelingPane = ({
 | 
			
		||||
  const settings = useSettings()
 | 
			
		||||
  const onboardingStatus = settings.app.onboardingStatus
 | 
			
		||||
  const pointerEventsCssClass =
 | 
			
		||||
    onboardingStatus.current === ONBOARDING_SUBPATHS.CAMERA
 | 
			
		||||
    onboardingStatus.current === onboardingPaths.CAMERA
 | 
			
		||||
      ? 'pointer-events-none '
 | 
			
		||||
      : 'pointer-events-auto '
 | 
			
		||||
  return (
 | 
			
		||||
 | 
			
		||||
@ -24,7 +24,7 @@ import { SIDEBAR_BUTTON_SUFFIX } from '@src/lib/constants'
 | 
			
		||||
import { isDesktop } from '@src/lib/isDesktop'
 | 
			
		||||
import { useSettings } from '@src/lib/singletons'
 | 
			
		||||
import { commandBarActor } from '@src/lib/singletons'
 | 
			
		||||
import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
 | 
			
		||||
import { onboardingPaths } from '@src/routes/Onboarding/paths'
 | 
			
		||||
import { reportRejection } from '@src/lib/trap'
 | 
			
		||||
import { refreshPage } from '@src/lib/utils'
 | 
			
		||||
import { hotkeyDisplay } from '@src/lib/hotkeyWrapper'
 | 
			
		||||
@ -53,7 +53,7 @@ export function ModelingSidebar({ paneOpacity }: ModelingSidebarProps) {
 | 
			
		||||
  const onboardingStatus = settings.app.onboardingStatus
 | 
			
		||||
  const { send, context } = useModelingContext()
 | 
			
		||||
  const pointerEventsCssClass =
 | 
			
		||||
    onboardingStatus.current === ONBOARDING_SUBPATHS.CAMERA ||
 | 
			
		||||
    onboardingStatus.current === onboardingPaths.CAMERA ||
 | 
			
		||||
    context.store?.openPanes.length === 0
 | 
			
		||||
      ? 'pointer-events-none '
 | 
			
		||||
      : 'pointer-events-auto '
 | 
			
		||||
 | 
			
		||||
@ -1,10 +1,5 @@
 | 
			
		||||
import { useFileSystemWatcher } from '@src/hooks/useFileSystemWatcher'
 | 
			
		||||
import {
 | 
			
		||||
  PATHS,
 | 
			
		||||
  joinRouterPaths,
 | 
			
		||||
  joinOSPaths,
 | 
			
		||||
  safeEncodeForRouterPaths,
 | 
			
		||||
} from '@src/lib/paths'
 | 
			
		||||
import { PATHS } from '@src/lib/paths'
 | 
			
		||||
import { systemIOActor, useSettings, useToken } from '@src/lib/singletons'
 | 
			
		||||
import {
 | 
			
		||||
  useHasListedProjects,
 | 
			
		||||
@ -40,14 +35,14 @@ export function SystemIOMachineLogicListenerDesktop() {
 | 
			
		||||
      if (!requestedProjectName.name) {
 | 
			
		||||
        return
 | 
			
		||||
      }
 | 
			
		||||
      const projectPathWithoutSpecificKCLFile = joinOSPaths(
 | 
			
		||||
        projectDirectoryPath,
 | 
			
		||||
      let projectPathWithoutSpecificKCLFile =
 | 
			
		||||
        projectDirectoryPath +
 | 
			
		||||
        window.electron.path.sep +
 | 
			
		||||
        requestedProjectName.name
 | 
			
		||||
      )
 | 
			
		||||
      const requestedPath = joinRouterPaths(
 | 
			
		||||
        PATHS.FILE,
 | 
			
		||||
        safeEncodeForRouterPaths(projectPathWithoutSpecificKCLFile)
 | 
			
		||||
      )
 | 
			
		||||
 | 
			
		||||
      const requestedPath = `${PATHS.FILE}/${encodeURIComponent(
 | 
			
		||||
        projectPathWithoutSpecificKCLFile
 | 
			
		||||
      )}`
 | 
			
		||||
      navigate(requestedPath)
 | 
			
		||||
    }, [requestedProjectName])
 | 
			
		||||
  }
 | 
			
		||||
@ -57,16 +52,12 @@ export function SystemIOMachineLogicListenerDesktop() {
 | 
			
		||||
      if (!requestedFileName.file || !requestedFileName.project) {
 | 
			
		||||
        return
 | 
			
		||||
      }
 | 
			
		||||
      const filePath = joinOSPaths(
 | 
			
		||||
      const projectPath = window.electron.join(
 | 
			
		||||
        projectDirectoryPath,
 | 
			
		||||
        requestedFileName.project,
 | 
			
		||||
        requestedFileName.file
 | 
			
		||||
      )
 | 
			
		||||
      const requestedPath = joinRouterPaths(
 | 
			
		||||
        PATHS.FILE,
 | 
			
		||||
        safeEncodeForRouterPaths(filePath),
 | 
			
		||||
        requestedFileName.subRoute || ''
 | 
			
		||||
        requestedFileName.project
 | 
			
		||||
      )
 | 
			
		||||
      const filePath = window.electron.join(projectPath, requestedFileName.file)
 | 
			
		||||
      const requestedPath = `${PATHS.FILE}/${encodeURIComponent(filePath)}`
 | 
			
		||||
      navigate(requestedPath)
 | 
			
		||||
    }, [requestedFileName])
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -7,6 +7,8 @@ import {
 | 
			
		||||
  useRouteLoaderData,
 | 
			
		||||
} from 'react-router-dom'
 | 
			
		||||
 | 
			
		||||
import type { OnboardingStatus } from '@rust/kcl-lib/bindings/OnboardingStatus'
 | 
			
		||||
 | 
			
		||||
import { useAuthNavigation } from '@src/hooks/useAuthNavigation'
 | 
			
		||||
import { useFileSystemWatcher } from '@src/hooks/useFileSystemWatcher'
 | 
			
		||||
import { getAppSettingsFilePath } from '@src/lib/desktop'
 | 
			
		||||
@ -16,7 +18,7 @@ import { markOnce } from '@src/lib/performance'
 | 
			
		||||
import { loadAndValidateSettings } from '@src/lib/settings/settingsUtils'
 | 
			
		||||
import { trap } from '@src/lib/trap'
 | 
			
		||||
import type { IndexLoaderData } from '@src/lib/types'
 | 
			
		||||
import { settingsActor } from '@src/lib/singletons'
 | 
			
		||||
import { settingsActor, useSettings } from '@src/lib/singletons'
 | 
			
		||||
 | 
			
		||||
export const RouteProviderContext = createContext({})
 | 
			
		||||
 | 
			
		||||
@ -30,6 +32,7 @@ export function RouteProvider({ children }: { children: ReactNode }) {
 | 
			
		||||
  const navigation = useNavigation()
 | 
			
		||||
  const navigate = useNavigate()
 | 
			
		||||
  const location = useLocation()
 | 
			
		||||
  const settings = useSettings()
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    // On initialization, the react-router-dom does not send a 'loading' state event.
 | 
			
		||||
@ -43,9 +46,35 @@ export function RouteProvider({ children }: { children: ReactNode }) {
 | 
			
		||||
      markOnce('code/willLoadHome')
 | 
			
		||||
    } else if (isFile) {
 | 
			
		||||
      markOnce('code/willLoadFile')
 | 
			
		||||
 | 
			
		||||
      /**
 | 
			
		||||
       * TODO: Move to XState. This block has been moved from routerLoaders
 | 
			
		||||
       * and is borrowing the `isFile` logic from the rest of this
 | 
			
		||||
       * telemetry-focused `useEffect`. Once `appMachine` knows about
 | 
			
		||||
       * the current route and navigation, this can be moved into settingsMachine
 | 
			
		||||
       * to fire as soon as the user settings have been read.
 | 
			
		||||
       */
 | 
			
		||||
      const onboardingStatus: OnboardingStatus =
 | 
			
		||||
        settings.app.onboardingStatus.current || ''
 | 
			
		||||
      // '' is the initial state, 'completed' and 'dismissed' are the final states
 | 
			
		||||
      const needsToOnboard =
 | 
			
		||||
        onboardingStatus.length === 0 ||
 | 
			
		||||
        !(onboardingStatus === 'completed' || onboardingStatus === 'dismissed')
 | 
			
		||||
      const shouldRedirectToOnboarding = isFile && needsToOnboard
 | 
			
		||||
 | 
			
		||||
      if (
 | 
			
		||||
        shouldRedirectToOnboarding &&
 | 
			
		||||
        settingsActor.getSnapshot().matches('idle')
 | 
			
		||||
      ) {
 | 
			
		||||
        navigate(
 | 
			
		||||
          (first ? location.pathname : navigation.location?.pathname) +
 | 
			
		||||
            PATHS.ONBOARDING.INDEX +
 | 
			
		||||
            onboardingStatus.slice(1)
 | 
			
		||||
        )
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    setFirstState(false)
 | 
			
		||||
  }, [first, navigation, location.pathname])
 | 
			
		||||
  }, [navigation])
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (!isDesktop()) return
 | 
			
		||||
 | 
			
		||||
@ -6,12 +6,16 @@ import { useLocation, useNavigate } from 'react-router-dom'
 | 
			
		||||
import { Fragment } from 'react/jsx-runtime'
 | 
			
		||||
 | 
			
		||||
import { ActionButton } from '@src/components/ActionButton'
 | 
			
		||||
import { useLspContext } from '@src/components/LspProvider'
 | 
			
		||||
import { SettingsFieldInput } from '@src/components/Settings/SettingsFieldInput'
 | 
			
		||||
import { SettingsSection } from '@src/components/Settings/SettingsSection'
 | 
			
		||||
import { getSettingsFolderPaths } from '@src/lib/desktopFS'
 | 
			
		||||
import { useDotDotSlash } from '@src/hooks/useDotDotSlash'
 | 
			
		||||
import {
 | 
			
		||||
  createAndOpenNewTutorialProject,
 | 
			
		||||
  getSettingsFolderPaths,
 | 
			
		||||
} from '@src/lib/desktopFS'
 | 
			
		||||
import { isDesktop } from '@src/lib/isDesktop'
 | 
			
		||||
import { openExternalBrowserIfDesktop } from '@src/lib/openWindow'
 | 
			
		||||
import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
 | 
			
		||||
import { PATHS } from '@src/lib/paths'
 | 
			
		||||
import type { Setting } from '@src/lib/settings/initialSettings'
 | 
			
		||||
import type {
 | 
			
		||||
@ -24,17 +28,9 @@ import {
 | 
			
		||||
} from '@src/lib/settings/settingsUtils'
 | 
			
		||||
import { reportRejection } from '@src/lib/trap'
 | 
			
		||||
import { toSync } from '@src/lib/utils'
 | 
			
		||||
import {
 | 
			
		||||
  codeManager,
 | 
			
		||||
  kclManager,
 | 
			
		||||
  settingsActor,
 | 
			
		||||
  useSettings,
 | 
			
		||||
} from '@src/lib/singletons'
 | 
			
		||||
import { settingsActor, useSettings } from '@src/lib/singletons'
 | 
			
		||||
import { APP_VERSION, IS_NIGHTLY, getReleaseUrl } from '@src/routes/utils'
 | 
			
		||||
import {
 | 
			
		||||
  acceptOnboarding,
 | 
			
		||||
  catchOnboardingWarnError,
 | 
			
		||||
} from '@src/routes/Onboarding/utils'
 | 
			
		||||
import { waitFor } from 'xstate'
 | 
			
		||||
 | 
			
		||||
interface AllSettingsFieldsProps {
 | 
			
		||||
  searchParamTab: SettingsLevel
 | 
			
		||||
@ -48,6 +44,8 @@ export const AllSettingsFields = forwardRef(
 | 
			
		||||
  ) => {
 | 
			
		||||
    const location = useLocation()
 | 
			
		||||
    const navigate = useNavigate()
 | 
			
		||||
    const { onProjectOpen } = useLspContext()
 | 
			
		||||
    const dotDotSlash = useDotDotSlash()
 | 
			
		||||
    const context = useSettings()
 | 
			
		||||
 | 
			
		||||
    const projectPath = useMemo(() => {
 | 
			
		||||
@ -65,18 +63,26 @@ export const AllSettingsFields = forwardRef(
 | 
			
		||||
          : undefined
 | 
			
		||||
 | 
			
		||||
      return projectPath
 | 
			
		||||
    }, [location.pathname, isFileSettings])
 | 
			
		||||
    }, [location.pathname])
 | 
			
		||||
 | 
			
		||||
    async function restartOnboarding() {
 | 
			
		||||
      const props = {
 | 
			
		||||
        onboardingStatus: ONBOARDING_SUBPATHS.INDEX,
 | 
			
		||||
        navigate,
 | 
			
		||||
        codeManager,
 | 
			
		||||
        kclManager,
 | 
			
		||||
      settingsActor.send({
 | 
			
		||||
        type: `set.app.onboardingStatus`,
 | 
			
		||||
        data: { level: 'user', value: '' },
 | 
			
		||||
      })
 | 
			
		||||
      await waitFor(settingsActor, (s) => s.matches('idle'), {
 | 
			
		||||
        timeout: 10_000,
 | 
			
		||||
      }).catch(reportRejection)
 | 
			
		||||
 | 
			
		||||
      if (isFileSettings) {
 | 
			
		||||
        // If we're in a project, first navigate to the onboarding start here
 | 
			
		||||
        // so we can trigger the warning screen if necessary
 | 
			
		||||
        navigate(dotDotSlash(1) + PATHS.ONBOARDING.INDEX)
 | 
			
		||||
      } else {
 | 
			
		||||
        // If we're in the global settings, create a new project and navigate
 | 
			
		||||
        // to the onboarding start in that project
 | 
			
		||||
        await createAndOpenNewTutorialProject({ onProjectOpen, navigate })
 | 
			
		||||
      }
 | 
			
		||||
      acceptOnboarding(props).catch((reason) =>
 | 
			
		||||
        catchOnboardingWarnError(reason, props)
 | 
			
		||||
      )
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
 | 
			
		||||
@ -2,7 +2,6 @@ import type { Models } from '@kittycad/lib/dist/types/src'
 | 
			
		||||
 | 
			
		||||
import type { UnitAngle, UnitLength } from '@rust/kcl-lib/bindings/ModelingCmd'
 | 
			
		||||
 | 
			
		||||
export const IS_PLAYWRIGHT_KEY = 'playwright'
 | 
			
		||||
export const APP_NAME = 'Design Studio'
 | 
			
		||||
/** Search string in new project names to increment as an index */
 | 
			
		||||
export const INDEX_IDENTIFIER = '$n'
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,18 @@
 | 
			
		||||
import { relevantFileExtensions } from '@src/lang/wasmUtils'
 | 
			
		||||
import { FILE_EXT, INDEX_IDENTIFIER, MAX_PADDING } from '@src/lib/constants'
 | 
			
		||||
import {
 | 
			
		||||
  FILE_EXT,
 | 
			
		||||
  INDEX_IDENTIFIER,
 | 
			
		||||
  MAX_PADDING,
 | 
			
		||||
  ONBOARDING_PROJECT_NAME,
 | 
			
		||||
} from '@src/lib/constants'
 | 
			
		||||
import {
 | 
			
		||||
  createNewProjectDirectory,
 | 
			
		||||
  listProjects,
 | 
			
		||||
  readAppSettingsFile,
 | 
			
		||||
} from '@src/lib/desktop'
 | 
			
		||||
import { bracket } from '@src/lib/exampleKcl'
 | 
			
		||||
import { isDesktop } from '@src/lib/isDesktop'
 | 
			
		||||
import { PATHS } from '@src/lib/paths'
 | 
			
		||||
import type { FileEntry } from '@src/lib/project'
 | 
			
		||||
 | 
			
		||||
export const isHidden = (fileOrDir: FileEntry) =>
 | 
			
		||||
@ -120,6 +132,65 @@ export async function getSettingsFolderPaths(projectPath?: string) {
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function createAndOpenNewTutorialProject({
 | 
			
		||||
  onProjectOpen,
 | 
			
		||||
  navigate,
 | 
			
		||||
}: {
 | 
			
		||||
  onProjectOpen: (
 | 
			
		||||
    project: {
 | 
			
		||||
      name: string | null
 | 
			
		||||
      path: string | null
 | 
			
		||||
    } | null,
 | 
			
		||||
    file: FileEntry | null
 | 
			
		||||
  ) => void
 | 
			
		||||
  navigate: (path: string) => void
 | 
			
		||||
}) {
 | 
			
		||||
  // Create a new project with the onboarding project name
 | 
			
		||||
  const configuration = await readAppSettingsFile()
 | 
			
		||||
  const projects = await listProjects(configuration)
 | 
			
		||||
  const nextIndex = getNextProjectIndex(ONBOARDING_PROJECT_NAME, projects)
 | 
			
		||||
  const name = interpolateProjectNameWithIndex(
 | 
			
		||||
    ONBOARDING_PROJECT_NAME,
 | 
			
		||||
    nextIndex
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  // Delete the tutorial project if it already exists.
 | 
			
		||||
  if (isDesktop()) {
 | 
			
		||||
    if (configuration.settings?.project?.directory === undefined) {
 | 
			
		||||
      return Promise.reject(new Error('configuration settings are undefined'))
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const fullPath = window.electron.join(
 | 
			
		||||
      configuration.settings.project.directory,
 | 
			
		||||
      name
 | 
			
		||||
    )
 | 
			
		||||
    if (window.electron.exists(fullPath)) {
 | 
			
		||||
      await window.electron.rm(fullPath)
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const newProject = await createNewProjectDirectory(
 | 
			
		||||
    name,
 | 
			
		||||
    bracket,
 | 
			
		||||
    configuration
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  // Prep the LSP and navigate to the onboarding start
 | 
			
		||||
  onProjectOpen(
 | 
			
		||||
    {
 | 
			
		||||
      name: newProject.name,
 | 
			
		||||
      path: newProject.path,
 | 
			
		||||
    },
 | 
			
		||||
    null
 | 
			
		||||
  )
 | 
			
		||||
  navigate(
 | 
			
		||||
    `${PATHS.FILE}/${encodeURIComponent(newProject.default_file)}${
 | 
			
		||||
      PATHS.ONBOARDING.INDEX
 | 
			
		||||
    }`
 | 
			
		||||
  )
 | 
			
		||||
  return newProject
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Get the next available file name by appending a hyphen and number to the end of the name
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
@ -2,7 +2,7 @@ import type { PlatformPath } from 'path'
 | 
			
		||||
 | 
			
		||||
import type { Configuration } from '@rust/kcl-lib/bindings/Configuration'
 | 
			
		||||
 | 
			
		||||
import { IS_PLAYWRIGHT_KEY } from '@src/lib/constants'
 | 
			
		||||
import { IS_PLAYWRIGHT_KEY } from '@e2e/playwright/storageStates'
 | 
			
		||||
 | 
			
		||||
import {
 | 
			
		||||
  BROWSER_FILE_NAME,
 | 
			
		||||
@ -14,7 +14,7 @@ import { isDesktop } from '@src/lib/isDesktop'
 | 
			
		||||
import { readLocalStorageAppSettingsFile } from '@src/lib/settings/settingsUtils'
 | 
			
		||||
import { err } from '@src/lib/trap'
 | 
			
		||||
import type { DeepPartial } from '@src/lib/types'
 | 
			
		||||
import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
 | 
			
		||||
import { onboardingPaths } from '@src/routes/Onboarding/paths'
 | 
			
		||||
 | 
			
		||||
const prependRoutes =
 | 
			
		||||
  (routesObject: Record<string, string>) => (prepend: string) => {
 | 
			
		||||
@ -27,7 +27,7 @@ const prependRoutes =
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
type OnboardingPaths = {
 | 
			
		||||
  [K in keyof typeof ONBOARDING_SUBPATHS]: `/onboarding${(typeof ONBOARDING_SUBPATHS)[K]}`
 | 
			
		||||
  [K in keyof typeof onboardingPaths]: `/onboarding${(typeof onboardingPaths)[K]}`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const SETTINGS = '/settings'
 | 
			
		||||
@ -48,9 +48,7 @@ export const PATHS = {
 | 
			
		||||
  SETTINGS_PROJECT: `${SETTINGS}?tab=project` as const,
 | 
			
		||||
  SETTINGS_KEYBINDINGS: `${SETTINGS}?tab=keybindings` as const,
 | 
			
		||||
  SIGN_IN: '/signin',
 | 
			
		||||
  ONBOARDING: prependRoutes(ONBOARDING_SUBPATHS)(
 | 
			
		||||
    '/onboarding'
 | 
			
		||||
  ) as OnboardingPaths,
 | 
			
		||||
  ONBOARDING: prependRoutes(onboardingPaths)('/onboarding') as OnboardingPaths,
 | 
			
		||||
  TELEMETRY: '/telemetry',
 | 
			
		||||
} as const
 | 
			
		||||
export const BROWSER_PATH = `%2F${BROWSER_PROJECT_NAME}%2F${BROWSER_FILE_NAME}${FILE_EXT}`
 | 
			
		||||
@ -138,56 +136,3 @@ export function parseProjectRoute(
 | 
			
		||||
    currentFilePath: currentFilePath,
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Joins any number of arguments of strings to create a Router level path that is safe
 | 
			
		||||
 * A path will be created of the format /value/value1/value2
 | 
			
		||||
 * Filters out '/', ''
 | 
			
		||||
 * Removes all leading and ending slashes, this allows you to pass '//dog//','//cat//' it will resolve to
 | 
			
		||||
 * /dog/cat
 | 
			
		||||
 */
 | 
			
		||||
export function joinRouterPaths(...parts: string[]): string {
 | 
			
		||||
  return (
 | 
			
		||||
    '/' +
 | 
			
		||||
    parts
 | 
			
		||||
      .map((part) => part.replace(/^\/+|\/+$/g, '')) // Remove leading/trailing slashes
 | 
			
		||||
      .filter((part) => part.length > 0) // Remove empty segments
 | 
			
		||||
      .join('/')
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Joins any number of arguments of strings to create a OS level path that is safe
 | 
			
		||||
 * A path will be created of the format /value/value1/value2
 | 
			
		||||
 * or \value\value1\value2 for POSIX OSes like Windows
 | 
			
		||||
 * Filters out the separator slashes
 | 
			
		||||
 * Removes all leading and ending slashes, this allows you to pass '//dog//','//cat//' it will resolve to
 | 
			
		||||
 * /dog/cat
 | 
			
		||||
 * or \dog\cat on POSIX
 | 
			
		||||
 */
 | 
			
		||||
export function joinOSPaths(...parts: string[]): string {
 | 
			
		||||
  const sep = window.electron?.sep || '/'
 | 
			
		||||
  const regexSep = sep === '/' ? '/' : '\\'
 | 
			
		||||
  return (
 | 
			
		||||
    (sep === '\\' ? '' : sep) + // Windows absolute paths should not be prepended with a separator, they start with the drive name
 | 
			
		||||
    parts
 | 
			
		||||
      .map((part) =>
 | 
			
		||||
        part.replace(new RegExp(`^${regexSep}+|${regexSep}+$`, 'g'), '')
 | 
			
		||||
      ) // Remove leading/trailing slashes
 | 
			
		||||
      .filter((part) => part.length > 0) // Remove empty segments
 | 
			
		||||
      .join(sep)
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function safeEncodeForRouterPaths(dynamicValue: string): string {
 | 
			
		||||
  return `${encodeURIComponent(dynamicValue)}`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * /dog/cat/house.kcl gives you house.kcl
 | 
			
		||||
 * \dog\cat\house.kcl gives you house.kcl
 | 
			
		||||
 * Works on all OS!
 | 
			
		||||
 */
 | 
			
		||||
export function getStringAfterLastSeparator(path: string): string {
 | 
			
		||||
  return path.split(window.electron.sep).pop() || ''
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -22,6 +22,12 @@ import type {
 | 
			
		||||
} from '@src/lib/types'
 | 
			
		||||
import { settingsActor } from '@src/lib/singletons'
 | 
			
		||||
 | 
			
		||||
export const telemetryLoader: LoaderFunction = async ({
 | 
			
		||||
  params,
 | 
			
		||||
}): Promise<null> => {
 | 
			
		||||
  return null
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const fileLoader: LoaderFunction = async (
 | 
			
		||||
  routerData
 | 
			
		||||
): Promise<FileLoaderData | Response> => {
 | 
			
		||||
 | 
			
		||||
@ -43,11 +43,7 @@ export const systemIOMachine = setup({
 | 
			
		||||
        }
 | 
			
		||||
      | {
 | 
			
		||||
          type: SystemIOMachineEvents.navigateToFile
 | 
			
		||||
          data: {
 | 
			
		||||
            requestedProjectName: string
 | 
			
		||||
            requestedFileName: string
 | 
			
		||||
            requestedSubRoute?: string
 | 
			
		||||
          }
 | 
			
		||||
          data: { requestedProjectName: string; requestedFileName: string }
 | 
			
		||||
        }
 | 
			
		||||
      | {
 | 
			
		||||
          type: SystemIOMachineEvents.createProject
 | 
			
		||||
@ -79,7 +75,6 @@ export const systemIOMachine = setup({
 | 
			
		||||
            requestedProjectName: string
 | 
			
		||||
            requestedFileName: string
 | 
			
		||||
            requestedCode: string
 | 
			
		||||
            requestedSubRoute?: string
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      | {
 | 
			
		||||
@ -122,9 +117,7 @@ export const systemIOMachine = setup({
 | 
			
		||||
    [SystemIOMachineActions.setRequestedProjectName]: assign({
 | 
			
		||||
      requestedProjectName: ({ event }) => {
 | 
			
		||||
        assertEvent(event, SystemIOMachineEvents.navigateToProject)
 | 
			
		||||
        return {
 | 
			
		||||
          name: event.data.requestedProjectName,
 | 
			
		||||
        }
 | 
			
		||||
        return { name: event.data.requestedProjectName }
 | 
			
		||||
      },
 | 
			
		||||
    }),
 | 
			
		||||
    [SystemIOMachineActions.setRequestedFileName]: assign({
 | 
			
		||||
@ -133,7 +126,6 @@ export const systemIOMachine = setup({
 | 
			
		||||
        return {
 | 
			
		||||
          project: event.data.requestedProjectName,
 | 
			
		||||
          file: event.data.requestedFileName,
 | 
			
		||||
          subRoute: event.data.requestedSubRoute,
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
    }),
 | 
			
		||||
@ -232,15 +224,13 @@ export const systemIOMachine = setup({
 | 
			
		||||
          requestedFileName: string
 | 
			
		||||
          requestedCode: string
 | 
			
		||||
          rootContext: AppMachineContext
 | 
			
		||||
          requestedSubRoute?: string
 | 
			
		||||
        }
 | 
			
		||||
      }): Promise<{
 | 
			
		||||
        message: string
 | 
			
		||||
        fileName: string
 | 
			
		||||
        projectName: string
 | 
			
		||||
        subRoute: string
 | 
			
		||||
      }> => {
 | 
			
		||||
        return { message: '', fileName: '', projectName: '', subRoute: '' }
 | 
			
		||||
        return { message: '', fileName: '', projectName: '' }
 | 
			
		||||
      }
 | 
			
		||||
    ),
 | 
			
		||||
    [SystemIOMachineActors.checkReadWrite]: fromPromise(
 | 
			
		||||
@ -468,7 +458,6 @@ export const systemIOMachine = setup({
 | 
			
		||||
            context,
 | 
			
		||||
            requestedProjectName: event.data.requestedProjectName,
 | 
			
		||||
            requestedFileName: event.data.requestedFileName,
 | 
			
		||||
            requestedSubRoute: event.data.requestedSubRoute,
 | 
			
		||||
            requestedCode: event.data.requestedCode,
 | 
			
		||||
            rootContext: self.system.get('root').getSnapshot().context,
 | 
			
		||||
          }
 | 
			
		||||
@ -487,7 +476,6 @@ export const systemIOMachine = setup({
 | 
			
		||||
                return {
 | 
			
		||||
                  project: event.output.projectName,
 | 
			
		||||
                  file,
 | 
			
		||||
                  subRoute: event.output.subRoute,
 | 
			
		||||
                }
 | 
			
		||||
              },
 | 
			
		||||
            }),
 | 
			
		||||
 | 
			
		||||
@ -158,7 +158,6 @@ export const systemIOMachineDesktop = systemIOMachine.provide({
 | 
			
		||||
          requestedFileName: string
 | 
			
		||||
          requestedCode: string
 | 
			
		||||
          rootContext: AppMachineContext
 | 
			
		||||
          requestedSubRoute?: string
 | 
			
		||||
        }
 | 
			
		||||
      }) => {
 | 
			
		||||
        const requestedProjectName = input.requestedProjectName
 | 
			
		||||
@ -207,7 +206,6 @@ export const systemIOMachineDesktop = systemIOMachine.provide({
 | 
			
		||||
          message: 'File created successfully',
 | 
			
		||||
          fileName: newFileName,
 | 
			
		||||
          projectName: newProjectName,
 | 
			
		||||
          subRoute: input.requestedSubRoute || '',
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    ),
 | 
			
		||||
 | 
			
		||||
@ -20,7 +20,6 @@ export const systemIOMachineWeb = systemIOMachine.provide({
 | 
			
		||||
          requestedFileName: string
 | 
			
		||||
          requestedCode: string
 | 
			
		||||
          rootContext: AppMachineContext
 | 
			
		||||
          requestedSubRoute?: string
 | 
			
		||||
        }
 | 
			
		||||
      }) => {
 | 
			
		||||
        // Browser version doesn't navigate, just overwrites the current file
 | 
			
		||||
@ -44,7 +43,6 @@ export const systemIOMachineWeb = systemIOMachine.provide({
 | 
			
		||||
          message: 'File overwritten successfully',
 | 
			
		||||
          fileName: input.requestedFileName,
 | 
			
		||||
          projectName: '',
 | 
			
		||||
          subRoute: input.requestedSubRoute || '',
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    ),
 | 
			
		||||
 | 
			
		||||
@ -8,7 +8,6 @@ export enum SystemIOMachineActors {
 | 
			
		||||
  deleteProject = 'delete project',
 | 
			
		||||
  createKCLFile = 'create kcl file',
 | 
			
		||||
  checkReadWrite = 'check read write',
 | 
			
		||||
  /** TODO: rename this event to be more generic, like `createKCLFileAndNavigate` */
 | 
			
		||||
  importFileFromURL = 'import file from URL',
 | 
			
		||||
  deleteKCLFile = 'delete kcl delete',
 | 
			
		||||
}
 | 
			
		||||
@ -22,7 +21,6 @@ export enum SystemIOMachineStates {
 | 
			
		||||
  deletingProject = 'deletingProject',
 | 
			
		||||
  creatingKCLFile = 'creatingKCLFile',
 | 
			
		||||
  checkingReadWrite = 'checkingReadWrite',
 | 
			
		||||
  /** TODO: rename this event to be more generic, like `createKCLFileAndNavigate` */
 | 
			
		||||
  importFileFromURL = 'importFileFromURL',
 | 
			
		||||
  deletingKCLFile = 'deletingKCLFile',
 | 
			
		||||
}
 | 
			
		||||
@ -43,7 +41,6 @@ export enum SystemIOMachineEvents {
 | 
			
		||||
  createKCLFile = 'create kcl file',
 | 
			
		||||
  setDefaultProjectFolderName = 'set default project folder name',
 | 
			
		||||
  done_checkReadWrite = donePrefix + 'check read write',
 | 
			
		||||
  /** TODO: rename this event to be more generic, like `createKCLFileAndNavigate` */
 | 
			
		||||
  importFileFromURL = 'import file from URL',
 | 
			
		||||
  done_importFileFromURL = donePrefix + 'import file from URL',
 | 
			
		||||
  generateTextToCAD = 'generate text to CAD',
 | 
			
		||||
@ -77,7 +74,7 @@ export type SystemIOContext = {
 | 
			
		||||
   * this is required to prevent chokidar from spamming invalid events during initialization. */
 | 
			
		||||
  hasListedProjects: boolean
 | 
			
		||||
  requestedProjectName: { name: string }
 | 
			
		||||
  requestedFileName: { project: string; file: string; subRoute?: string }
 | 
			
		||||
  requestedFileName: { project: string; file: string }
 | 
			
		||||
  canReadWriteProjectDirectory: { value: boolean; error: unknown }
 | 
			
		||||
  clearURLParams: { value: boolean }
 | 
			
		||||
  requestedTextToCadGeneration: {
 | 
			
		||||
 | 
			
		||||
@ -6,7 +6,7 @@ import type { Channel } from '@src/channels'
 | 
			
		||||
export type MenuLabels =
 | 
			
		||||
  | 'Help.Command Palette...'
 | 
			
		||||
  | 'Help.Report a bug'
 | 
			
		||||
  | 'Help.Replay onboarding tutorial'
 | 
			
		||||
  | 'Help.Reset onboarding'
 | 
			
		||||
  | 'Edit.Rename project'
 | 
			
		||||
  | 'Edit.Delete project'
 | 
			
		||||
  | 'Edit.Change project directory'
 | 
			
		||||
 | 
			
		||||
@ -84,11 +84,11 @@ export const helpRole = (
 | 
			
		||||
      },
 | 
			
		||||
      { type: 'separator' },
 | 
			
		||||
      {
 | 
			
		||||
        id: 'Help.Replay onboarding tutorial',
 | 
			
		||||
        label: 'Replay onboarding tutorial',
 | 
			
		||||
        id: 'Help.Reset onboarding',
 | 
			
		||||
        label: 'Reset onboarding',
 | 
			
		||||
        click: () => {
 | 
			
		||||
          typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
 | 
			
		||||
            menuLabel: 'Help.Replay onboarding tutorial',
 | 
			
		||||
            menuLabel: 'Help.Reset onboarding',
 | 
			
		||||
          })
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
 | 
			
		||||
@ -45,7 +45,7 @@ type HelpRoleLabel =
 | 
			
		||||
  | 'Ask the community discourse'
 | 
			
		||||
  | 'KCL code samples'
 | 
			
		||||
  | 'KCL docs'
 | 
			
		||||
  | 'Replay onboarding tutorial'
 | 
			
		||||
  | 'Reset onboarding'
 | 
			
		||||
  | 'Show release notes'
 | 
			
		||||
  | 'Manage account'
 | 
			
		||||
  | 'Get started with Text-to-CAD'
 | 
			
		||||
 | 
			
		||||
@ -2,12 +2,7 @@ import type { FormEvent, HTMLProps } from 'react'
 | 
			
		||||
import { useEffect, useRef } from 'react'
 | 
			
		||||
import { toast } from 'react-hot-toast'
 | 
			
		||||
import { useHotkeys } from 'react-hotkeys-hook'
 | 
			
		||||
import {
 | 
			
		||||
  Link,
 | 
			
		||||
  useLocation,
 | 
			
		||||
  useNavigate,
 | 
			
		||||
  useSearchParams,
 | 
			
		||||
} from 'react-router-dom'
 | 
			
		||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
 | 
			
		||||
 | 
			
		||||
import { ActionButton } from '@src/components/ActionButton'
 | 
			
		||||
import { AppHeader } from '@src/components/AppHeader'
 | 
			
		||||
@ -24,7 +19,7 @@ import { isDesktop } from '@src/lib/isDesktop'
 | 
			
		||||
import { PATHS } from '@src/lib/paths'
 | 
			
		||||
import { markOnce } from '@src/lib/performance'
 | 
			
		||||
import type { Project } from '@src/lib/project'
 | 
			
		||||
import { codeManager, kclManager } from '@src/lib/singletons'
 | 
			
		||||
import { kclManager } from '@src/lib/singletons'
 | 
			
		||||
import {
 | 
			
		||||
  getNextSearchParams,
 | 
			
		||||
  getSortFunction,
 | 
			
		||||
@ -44,12 +39,6 @@ import {
 | 
			
		||||
} from '@src/machines/systemIO/utils'
 | 
			
		||||
import type { WebContentSendPayload } from '@src/menu/channels'
 | 
			
		||||
import { openExternalBrowserIfDesktop } from '@src/lib/openWindow'
 | 
			
		||||
import {
 | 
			
		||||
  acceptOnboarding,
 | 
			
		||||
  needsToOnboard,
 | 
			
		||||
  onDismissOnboardingInvite,
 | 
			
		||||
} from '@src/routes/Onboarding/utils'
 | 
			
		||||
import Tooltip from '@src/components/Tooltip'
 | 
			
		||||
 | 
			
		||||
type ReadWriteProjectState = {
 | 
			
		||||
  value: boolean
 | 
			
		||||
@ -80,10 +69,8 @@ const Home = () => {
 | 
			
		||||
    })
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  const location = useLocation()
 | 
			
		||||
  const navigate = useNavigate()
 | 
			
		||||
  const settings = useSettings()
 | 
			
		||||
  const onboardingStatus = settings.app.onboardingStatus.current
 | 
			
		||||
 | 
			
		||||
  // Menu listeners
 | 
			
		||||
  const cb = (data: WebContentSendPayload) => {
 | 
			
		||||
@ -206,7 +193,7 @@ const Home = () => {
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="relative flex flex-col h-screen overflow-hidden" ref={ref}>
 | 
			
		||||
      <AppHeader showToolbar={false} />
 | 
			
		||||
      <div className="overflow-hidden flex-1 home-layout max-w-4xl xl:max-w-7xl mb-12 px-4 mx-auto mt-24 lg:px-0">
 | 
			
		||||
      <div className="overflow-hidden home-layout max-w-4xl xl:max-w-7xl mb-12 px-4 mx-auto mt-24 lg:px-0">
 | 
			
		||||
        <HomeHeader
 | 
			
		||||
          setQuery={setQuery}
 | 
			
		||||
          sort={sort}
 | 
			
		||||
@ -215,44 +202,8 @@ const Home = () => {
 | 
			
		||||
          readWriteProjectDir={readWriteProjectDir}
 | 
			
		||||
          className="col-start-2 -col-end-1"
 | 
			
		||||
        />
 | 
			
		||||
        <aside className="lg:row-start-1 -row-end-1 flex flex-col justify-between">
 | 
			
		||||
        <aside className="row-start-1 -row-end-1 flex flex-col justify-between">
 | 
			
		||||
          <ul className="flex flex-col">
 | 
			
		||||
            {needsToOnboard(location, onboardingStatus) && (
 | 
			
		||||
              <li className="flex group">
 | 
			
		||||
                <ActionButton
 | 
			
		||||
                  Element="button"
 | 
			
		||||
                  onClick={() => {
 | 
			
		||||
                    acceptOnboarding({
 | 
			
		||||
                      onboardingStatus,
 | 
			
		||||
                      navigate,
 | 
			
		||||
                      codeManager,
 | 
			
		||||
                      kclManager,
 | 
			
		||||
                    }).catch(reportRejection)
 | 
			
		||||
                  }}
 | 
			
		||||
                  className={`${sidebarButtonClasses} !text-primary flex-1`}
 | 
			
		||||
                  iconStart={{
 | 
			
		||||
                    icon: 'play',
 | 
			
		||||
                    bgClassName: '!bg-primary rounded-sm',
 | 
			
		||||
                    iconClassName: '!text-white',
 | 
			
		||||
                  }}
 | 
			
		||||
                  data-testid="home-tutorial-button"
 | 
			
		||||
                >
 | 
			
		||||
                  {onboardingStatus === '' ? 'Start' : 'Continue'} tutorial
 | 
			
		||||
                </ActionButton>
 | 
			
		||||
                <ActionButton
 | 
			
		||||
                  Element="button"
 | 
			
		||||
                  onClick={onDismissOnboardingInvite}
 | 
			
		||||
                  className={`${sidebarButtonClasses} hidden group-hover:flex flex-none ml-auto`}
 | 
			
		||||
                  iconStart={{
 | 
			
		||||
                    icon: 'close',
 | 
			
		||||
                    bgClassName: '!bg-transparent rounded-sm',
 | 
			
		||||
                  }}
 | 
			
		||||
                  data-testid="onboarding-dismiss"
 | 
			
		||||
                >
 | 
			
		||||
                  <Tooltip>Dismiss tutorial</Tooltip>
 | 
			
		||||
                </ActionButton>
 | 
			
		||||
              </li>
 | 
			
		||||
            )}
 | 
			
		||||
            <li className="contents">
 | 
			
		||||
              <ActionButton
 | 
			
		||||
                Element="button"
 | 
			
		||||
@ -373,7 +324,7 @@ const Home = () => {
 | 
			
		||||
          sort={sort}
 | 
			
		||||
          className="flex-1 col-start-2 -col-end-1 overflow-y-auto pr-2 pb-24"
 | 
			
		||||
        />
 | 
			
		||||
        <LowerRightControls navigate={navigate} />
 | 
			
		||||
        <LowerRightControls />
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
@ -1,11 +1,18 @@
 | 
			
		||||
import { SettingsSection } from '@src/components/Settings/SettingsSection'
 | 
			
		||||
import type { CameraSystem } from '@src/lib/cameraControls'
 | 
			
		||||
import { cameraMouseDragGuards, cameraSystems } from '@src/lib/cameraControls'
 | 
			
		||||
import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
 | 
			
		||||
import { settingsActor, useSettings } from '@src/lib/singletons'
 | 
			
		||||
import { OnboardingButtons } from '@src/routes/Onboarding/utils'
 | 
			
		||||
import { onboardingPaths } from '@src/routes/Onboarding/paths'
 | 
			
		||||
 | 
			
		||||
import {
 | 
			
		||||
  OnboardingButtons,
 | 
			
		||||
  useDismiss,
 | 
			
		||||
  useNextClick,
 | 
			
		||||
} from '@src/routes/Onboarding/utils'
 | 
			
		||||
 | 
			
		||||
export default function Units() {
 | 
			
		||||
  useDismiss()
 | 
			
		||||
  useNextClick(onboardingPaths.STREAMING)
 | 
			
		||||
  const {
 | 
			
		||||
    modeling: { mouseControls },
 | 
			
		||||
  } = useSettings()
 | 
			
		||||
@ -59,7 +66,7 @@ export default function Units() {
 | 
			
		||||
          </ul>
 | 
			
		||||
        </SettingsSection>
 | 
			
		||||
        <OnboardingButtons
 | 
			
		||||
          currentSlug={ONBOARDING_SUBPATHS.CAMERA}
 | 
			
		||||
          currentSlug={onboardingPaths.CAMERA}
 | 
			
		||||
          dismissClassName="right-auto left-full"
 | 
			
		||||
        />
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
@ -1,7 +1,8 @@
 | 
			
		||||
import { COMMAND_PALETTE_HOTKEY } from '@src/components/CommandBar/CommandBar'
 | 
			
		||||
import usePlatform from '@src/hooks/usePlatform'
 | 
			
		||||
import { hotkeyDisplay } from '@src/lib/hotkeyWrapper'
 | 
			
		||||
import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
 | 
			
		||||
import { onboardingPaths } from '@src/routes/Onboarding/paths'
 | 
			
		||||
 | 
			
		||||
import { OnboardingButtons, kbdClasses } from '@src/routes/Onboarding/utils'
 | 
			
		||||
 | 
			
		||||
export default function CmdK() {
 | 
			
		||||
@ -36,7 +37,7 @@ export default function CmdK() {
 | 
			
		||||
          . You can control settings, authentication, and file management from
 | 
			
		||||
          the command bar, as well as a growing number of modeling commands.
 | 
			
		||||
        </p>
 | 
			
		||||
        <OnboardingButtons currentSlug={ONBOARDING_SUBPATHS.COMMAND_K} />
 | 
			
		||||
        <OnboardingButtons currentSlug={onboardingPaths.COMMAND_K} />
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,5 @@
 | 
			
		||||
import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
 | 
			
		||||
import { onboardingPaths } from '@src/routes/Onboarding/paths'
 | 
			
		||||
 | 
			
		||||
import {
 | 
			
		||||
  OnboardingButtons,
 | 
			
		||||
  kbdClasses,
 | 
			
		||||
@ -69,7 +70,7 @@ export default function OnboardingCodeEditor() {
 | 
			
		||||
            pressing <kbd className={kbdClasses}>Shift + C</kbd>.
 | 
			
		||||
          </p>
 | 
			
		||||
        </section>
 | 
			
		||||
        <OnboardingButtons currentSlug={ONBOARDING_SUBPATHS.EDITOR} />
 | 
			
		||||
        <OnboardingButtons currentSlug={onboardingPaths.EDITOR} />
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,6 @@
 | 
			
		||||
import { APP_NAME } from '@src/lib/constants'
 | 
			
		||||
import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
 | 
			
		||||
import { onboardingPaths } from '@src/routes/Onboarding/paths'
 | 
			
		||||
 | 
			
		||||
import { OnboardingButtons } from '@src/routes/Onboarding/utils'
 | 
			
		||||
 | 
			
		||||
export default function Export() {
 | 
			
		||||
@ -49,7 +50,7 @@ export default function Export() {
 | 
			
		||||
            !
 | 
			
		||||
          </p>
 | 
			
		||||
        </section>
 | 
			
		||||
        <OnboardingButtons currentSlug={ONBOARDING_SUBPATHS.EXPORT} />
 | 
			
		||||
        <OnboardingButtons currentSlug={onboardingPaths.EXPORT} />
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
@ -1,9 +1,11 @@
 | 
			
		||||
import { useEffect } from 'react'
 | 
			
		||||
 | 
			
		||||
import { useModelingContext } from '@src/hooks/useModelingContext'
 | 
			
		||||
import { APP_NAME } from '@src/lib/constants'
 | 
			
		||||
import { sceneInfra } from '@src/lib/singletons'
 | 
			
		||||
import { onboardingPaths } from '@src/routes/Onboarding/paths'
 | 
			
		||||
 | 
			
		||||
import { OnboardingButtons, useDemoCode } from '@src/routes/Onboarding/utils'
 | 
			
		||||
import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
 | 
			
		||||
 | 
			
		||||
export default function FutureWork() {
 | 
			
		||||
  const { send } = useModelingContext()
 | 
			
		||||
@ -56,7 +58,7 @@ export default function FutureWork() {
 | 
			
		||||
        </p>
 | 
			
		||||
        <p className="my-4">💚 The Zoo Team</p>
 | 
			
		||||
        <OnboardingButtons
 | 
			
		||||
          currentSlug={ONBOARDING_SUBPATHS.FUTURE_WORK}
 | 
			
		||||
          currentSlug={onboardingPaths.FUTURE_WORK}
 | 
			
		||||
          className="mt-6"
 | 
			
		||||
        />
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,6 @@
 | 
			
		||||
import { bracketWidthConstantLine } from '@src/lib/exampleKcl'
 | 
			
		||||
import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
 | 
			
		||||
import { onboardingPaths } from '@src/routes/Onboarding/paths'
 | 
			
		||||
 | 
			
		||||
import {
 | 
			
		||||
  OnboardingButtons,
 | 
			
		||||
  kbdClasses,
 | 
			
		||||
@ -84,9 +85,7 @@ export default function OnboardingInteractiveNumbers() {
 | 
			
		||||
            your ideas for how to make it better.
 | 
			
		||||
          </p>
 | 
			
		||||
        </section>
 | 
			
		||||
        <OnboardingButtons
 | 
			
		||||
          currentSlug={ONBOARDING_SUBPATHS.INTERACTIVE_NUMBERS}
 | 
			
		||||
        />
 | 
			
		||||
        <OnboardingButtons currentSlug={onboardingPaths.INTERACTIVE_NUMBERS} />
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
@ -1,11 +1,124 @@
 | 
			
		||||
import { APP_NAME } from '@src/lib/constants'
 | 
			
		||||
import { isDesktop } from '@src/lib/isDesktop'
 | 
			
		||||
import { Themes, getSystemTheme } from '@src/lib/theme'
 | 
			
		||||
import { useSettings } from '@src/lib/singletons'
 | 
			
		||||
import { OnboardingButtons, useDemoCode } from '@src/routes/Onboarding/utils'
 | 
			
		||||
import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
 | 
			
		||||
import { useEffect, useState } from 'react'
 | 
			
		||||
import { useNavigate, useRouteLoaderData } from 'react-router-dom'
 | 
			
		||||
 | 
			
		||||
export default function Introduction() {
 | 
			
		||||
import { useLspContext } from '@src/components/LspProvider'
 | 
			
		||||
import { useFileContext } from '@src/hooks/useFileContext'
 | 
			
		||||
import { isKclEmptyOrOnlySettings } from '@src/lang/wasm'
 | 
			
		||||
import { APP_NAME } from '@src/lib/constants'
 | 
			
		||||
import { createAndOpenNewTutorialProject } from '@src/lib/desktopFS'
 | 
			
		||||
import { bracket } from '@src/lib/exampleKcl'
 | 
			
		||||
import { isDesktop } from '@src/lib/isDesktop'
 | 
			
		||||
import { PATHS } from '@src/lib/paths'
 | 
			
		||||
import { codeManager, kclManager } from '@src/lib/singletons'
 | 
			
		||||
import { Themes, getSystemTheme } from '@src/lib/theme'
 | 
			
		||||
import { reportRejection } from '@src/lib/trap'
 | 
			
		||||
import type { IndexLoaderData } from '@src/lib/types'
 | 
			
		||||
import { useSettings } from '@src/lib/singletons'
 | 
			
		||||
import { onboardingPaths } from '@src/routes/Onboarding/paths'
 | 
			
		||||
 | 
			
		||||
import { OnboardingButtons, useDemoCode } from '@src/routes/Onboarding/utils'
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Show either a welcome screen or a warning screen
 | 
			
		||||
 * depending on if the user has code in the editor.
 | 
			
		||||
 */
 | 
			
		||||
export default function OnboardingIntroduction() {
 | 
			
		||||
  const [shouldShowWarning, setShouldShowWarning] = useState(
 | 
			
		||||
    !isKclEmptyOrOnlySettings(codeManager.code) && codeManager.code !== bracket
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  return shouldShowWarning ? (
 | 
			
		||||
    <OnboardingResetWarning setShouldShowWarning={setShouldShowWarning} />
 | 
			
		||||
  ) : (
 | 
			
		||||
    <OnboardingIntroductionInner />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface OnboardingResetWarningProps {
 | 
			
		||||
  setShouldShowWarning: (arg: boolean) => void
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function OnboardingResetWarning(props: OnboardingResetWarningProps) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="fixed inset-0 z-50 grid place-content-center bg-chalkboard-110/50">
 | 
			
		||||
      <div className="relative max-w-3xl p-8 rounded bg-chalkboard-10 dark:bg-chalkboard-90">
 | 
			
		||||
        {!isDesktop() ? (
 | 
			
		||||
          <OnboardingWarningWeb {...props} />
 | 
			
		||||
        ) : (
 | 
			
		||||
          <OnboardingWarningDesktop {...props} />
 | 
			
		||||
        )}
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function OnboardingWarningDesktop(props: OnboardingResetWarningProps) {
 | 
			
		||||
  const navigate = useNavigate()
 | 
			
		||||
  const loaderData = useRouteLoaderData(PATHS.FILE) as IndexLoaderData
 | 
			
		||||
  const { context: fileContext } = useFileContext()
 | 
			
		||||
  const { onProjectClose, onProjectOpen } = useLspContext()
 | 
			
		||||
 | 
			
		||||
  async function onAccept() {
 | 
			
		||||
    onProjectClose(
 | 
			
		||||
      loaderData.file || null,
 | 
			
		||||
      fileContext.project.path || null,
 | 
			
		||||
      false
 | 
			
		||||
    )
 | 
			
		||||
    await createAndOpenNewTutorialProject({ onProjectOpen, navigate })
 | 
			
		||||
    props.setShouldShowWarning(false)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <h1 className="flex flex-wrap items-center gap-4 text-3xl font-bold">
 | 
			
		||||
        Would you like to create a new project?
 | 
			
		||||
      </h1>
 | 
			
		||||
      <section className="my-12">
 | 
			
		||||
        <p className="my-4">
 | 
			
		||||
          You have some content in this project that we don't want to overwrite.
 | 
			
		||||
          If you would like to create a new project, please click the button
 | 
			
		||||
          below.
 | 
			
		||||
        </p>
 | 
			
		||||
      </section>
 | 
			
		||||
      <OnboardingButtons
 | 
			
		||||
        className="mt-6"
 | 
			
		||||
        onNextOverride={() => {
 | 
			
		||||
          onAccept().catch(reportRejection)
 | 
			
		||||
        }}
 | 
			
		||||
      />
 | 
			
		||||
    </>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function OnboardingWarningWeb(props: OnboardingResetWarningProps) {
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    async function beforeNavigate() {
 | 
			
		||||
      // We do want to update both the state and editor here.
 | 
			
		||||
      codeManager.updateCodeStateEditor(bracket)
 | 
			
		||||
      await codeManager.writeToFile()
 | 
			
		||||
 | 
			
		||||
      await kclManager.executeCode()
 | 
			
		||||
      props.setShouldShowWarning(false)
 | 
			
		||||
    }
 | 
			
		||||
    return () => {
 | 
			
		||||
      beforeNavigate().catch(reportRejection)
 | 
			
		||||
    }
 | 
			
		||||
  }, [])
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <h1 className="text-3xl font-bold text-warn-80 dark:text-warn-10">
 | 
			
		||||
        Replaying onboarding resets your code
 | 
			
		||||
      </h1>
 | 
			
		||||
      <p className="my-4">
 | 
			
		||||
        We see you have some of your own code written in this project. Please
 | 
			
		||||
        save it somewhere else before continuing the onboarding.
 | 
			
		||||
      </p>
 | 
			
		||||
      <OnboardingButtons className="mt-6" />
 | 
			
		||||
    </>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function OnboardingIntroductionInner() {
 | 
			
		||||
  // Reset the code to the bracket code
 | 
			
		||||
  useDemoCode()
 | 
			
		||||
 | 
			
		||||
@ -69,7 +182,7 @@ export default function Introduction() {
 | 
			
		||||
          </p>
 | 
			
		||||
        </section>
 | 
			
		||||
        <OnboardingButtons
 | 
			
		||||
          currentSlug={ONBOARDING_SUBPATHS.INDEX}
 | 
			
		||||
          currentSlug={onboardingPaths.INDEX}
 | 
			
		||||
          className="mt-6"
 | 
			
		||||
        />
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
@ -2,8 +2,9 @@ import { bracketThicknessCalculationLine } from '@src/lib/exampleKcl'
 | 
			
		||||
import { isDesktop } from '@src/lib/isDesktop'
 | 
			
		||||
import { Themes, getSystemTheme } from '@src/lib/theme'
 | 
			
		||||
import { useSettings } from '@src/lib/singletons'
 | 
			
		||||
import { onboardingPaths } from '@src/routes/Onboarding/paths'
 | 
			
		||||
 | 
			
		||||
import { OnboardingButtons, useDemoCode } from '@src/routes/Onboarding/utils'
 | 
			
		||||
import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
 | 
			
		||||
 | 
			
		||||
export default function OnboardingParametricModeling() {
 | 
			
		||||
  useDemoCode()
 | 
			
		||||
@ -71,9 +72,7 @@ export default function OnboardingParametricModeling() {
 | 
			
		||||
            </figcaption>
 | 
			
		||||
          </figure>
 | 
			
		||||
        </section>
 | 
			
		||||
        <OnboardingButtons
 | 
			
		||||
          currentSlug={ONBOARDING_SUBPATHS.PARAMETRIC_MODELING}
 | 
			
		||||
        />
 | 
			
		||||
        <OnboardingButtons currentSlug={onboardingPaths.PARAMETRIC_MODELING} />
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,6 @@
 | 
			
		||||
import { isDesktop } from '@src/lib/isDesktop'
 | 
			
		||||
import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
 | 
			
		||||
import { onboardingPaths } from '@src/routes/Onboarding/paths'
 | 
			
		||||
 | 
			
		||||
import { OnboardingButtons } from '@src/routes/Onboarding/utils'
 | 
			
		||||
 | 
			
		||||
export default function ProjectMenu() {
 | 
			
		||||
@ -55,7 +56,7 @@ export default function ProjectMenu() {
 | 
			
		||||
            </>
 | 
			
		||||
          )}
 | 
			
		||||
        </section>
 | 
			
		||||
        <OnboardingButtons currentSlug={ONBOARDING_SUBPATHS.PROJECT_MENU} />
 | 
			
		||||
        <OnboardingButtons currentSlug={onboardingPaths.PROJECT_MENU} />
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
@ -1,7 +1,9 @@
 | 
			
		||||
import { useEffect } from 'react'
 | 
			
		||||
 | 
			
		||||
import { codeManager, kclManager } from '@src/lib/singletons'
 | 
			
		||||
import { onboardingPaths } from '@src/routes/Onboarding/paths'
 | 
			
		||||
 | 
			
		||||
import { OnboardingButtons } from '@src/routes/Onboarding/utils'
 | 
			
		||||
import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
 | 
			
		||||
 | 
			
		||||
export default function Sketching() {
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
@ -40,7 +42,7 @@ export default function Sketching() {
 | 
			
		||||
          always just modifying and generating code in Zoo Design Studio.
 | 
			
		||||
        </p>
 | 
			
		||||
        <OnboardingButtons
 | 
			
		||||
          currentSlug={ONBOARDING_SUBPATHS.SKETCHING}
 | 
			
		||||
          currentSlug={onboardingPaths.SKETCHING}
 | 
			
		||||
          className="mt-6"
 | 
			
		||||
        />
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,5 @@
 | 
			
		||||
import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
 | 
			
		||||
import { onboardingPaths } from '@src/routes/Onboarding/paths'
 | 
			
		||||
 | 
			
		||||
import { OnboardingButtons } from '@src/routes/Onboarding/utils'
 | 
			
		||||
 | 
			
		||||
export default function Streaming() {
 | 
			
		||||
@ -40,7 +41,7 @@ export default function Streaming() {
 | 
			
		||||
          </p>
 | 
			
		||||
        </section>
 | 
			
		||||
        <OnboardingButtons
 | 
			
		||||
          currentSlug={ONBOARDING_SUBPATHS.STREAMING}
 | 
			
		||||
          currentSlug={onboardingPaths.STREAMING}
 | 
			
		||||
          dismissClassName="right-auto left-full"
 | 
			
		||||
        />
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
@ -4,12 +4,13 @@ import { ActionButton } from '@src/components/ActionButton'
 | 
			
		||||
import { SettingsSection } from '@src/components/Settings/SettingsSection'
 | 
			
		||||
import { type BaseUnit, baseUnitsUnion } from '@src/lib/settings/settingsTypes'
 | 
			
		||||
import { settingsActor, useSettings } from '@src/lib/singletons'
 | 
			
		||||
import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
 | 
			
		||||
import { onboardingPaths } from '@src/routes/Onboarding/paths'
 | 
			
		||||
 | 
			
		||||
import { useDismiss, useNextClick } from '@src/routes/Onboarding/utils'
 | 
			
		||||
 | 
			
		||||
export default function Units() {
 | 
			
		||||
  const dismiss = useDismiss()
 | 
			
		||||
  const next = useNextClick(ONBOARDING_SUBPATHS.CAMERA)
 | 
			
		||||
  const next = useNextClick(onboardingPaths.CAMERA)
 | 
			
		||||
  const {
 | 
			
		||||
    modeling: { defaultUnit },
 | 
			
		||||
  } = useSettings()
 | 
			
		||||
 | 
			
		||||
@ -1,7 +1,9 @@
 | 
			
		||||
import { useEffect, useState } from 'react'
 | 
			
		||||
 | 
			
		||||
import { useUser } from '@src/lib/singletons'
 | 
			
		||||
import { onboardingPaths } from '@src/routes/Onboarding/paths'
 | 
			
		||||
 | 
			
		||||
import { OnboardingButtons } from '@src/routes/Onboarding/utils'
 | 
			
		||||
import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
 | 
			
		||||
 | 
			
		||||
export default function UserMenu() {
 | 
			
		||||
  const user = useUser()
 | 
			
		||||
@ -46,7 +48,7 @@ export default function UserMenu() {
 | 
			
		||||
            only apply to the current project.
 | 
			
		||||
          </p>
 | 
			
		||||
        </section>
 | 
			
		||||
        <OnboardingButtons currentSlug={ONBOARDING_SUBPATHS.USER_MENU} />
 | 
			
		||||
        <OnboardingButtons currentSlug={onboardingPaths.USER_MENU} />
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
@ -14,8 +14,8 @@ import ProjectMenu from '@src/routes/Onboarding/ProjectMenu'
 | 
			
		||||
import Sketching from '@src/routes/Onboarding/Sketching'
 | 
			
		||||
import Streaming from '@src/routes/Onboarding/Streaming'
 | 
			
		||||
import UserMenu from '@src/routes/Onboarding/UserMenu'
 | 
			
		||||
import { onboardingPaths } from '@src/routes/Onboarding/paths'
 | 
			
		||||
import { useDismiss } from '@src/routes/Onboarding/utils'
 | 
			
		||||
import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
 | 
			
		||||
 | 
			
		||||
export const onboardingRoutes = [
 | 
			
		||||
  {
 | 
			
		||||
@ -23,48 +23,48 @@ export const onboardingRoutes = [
 | 
			
		||||
    element: <Introduction />,
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    path: makeUrlPathRelative(ONBOARDING_SUBPATHS.CAMERA),
 | 
			
		||||
    path: makeUrlPathRelative(onboardingPaths.CAMERA),
 | 
			
		||||
    element: <Camera />,
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    path: makeUrlPathRelative(ONBOARDING_SUBPATHS.STREAMING),
 | 
			
		||||
    path: makeUrlPathRelative(onboardingPaths.STREAMING),
 | 
			
		||||
    element: <Streaming />,
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    path: makeUrlPathRelative(ONBOARDING_SUBPATHS.EDITOR),
 | 
			
		||||
    path: makeUrlPathRelative(onboardingPaths.EDITOR),
 | 
			
		||||
    element: <CodeEditor />,
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    path: makeUrlPathRelative(ONBOARDING_SUBPATHS.PARAMETRIC_MODELING),
 | 
			
		||||
    path: makeUrlPathRelative(onboardingPaths.PARAMETRIC_MODELING),
 | 
			
		||||
    element: <ParametricModeling />,
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    path: makeUrlPathRelative(ONBOARDING_SUBPATHS.INTERACTIVE_NUMBERS),
 | 
			
		||||
    path: makeUrlPathRelative(onboardingPaths.INTERACTIVE_NUMBERS),
 | 
			
		||||
    element: <InteractiveNumbers />,
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    path: makeUrlPathRelative(ONBOARDING_SUBPATHS.COMMAND_K),
 | 
			
		||||
    path: makeUrlPathRelative(onboardingPaths.COMMAND_K),
 | 
			
		||||
    element: <CmdK />,
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    path: makeUrlPathRelative(ONBOARDING_SUBPATHS.USER_MENU),
 | 
			
		||||
    path: makeUrlPathRelative(onboardingPaths.USER_MENU),
 | 
			
		||||
    element: <UserMenu />,
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    path: makeUrlPathRelative(ONBOARDING_SUBPATHS.PROJECT_MENU),
 | 
			
		||||
    path: makeUrlPathRelative(onboardingPaths.PROJECT_MENU),
 | 
			
		||||
    element: <ProjectMenu />,
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    path: makeUrlPathRelative(ONBOARDING_SUBPATHS.EXPORT),
 | 
			
		||||
    path: makeUrlPathRelative(onboardingPaths.EXPORT),
 | 
			
		||||
    element: <Export />,
 | 
			
		||||
  },
 | 
			
		||||
  // Export / conversion API
 | 
			
		||||
  {
 | 
			
		||||
    path: makeUrlPathRelative(ONBOARDING_SUBPATHS.SKETCHING),
 | 
			
		||||
    path: makeUrlPathRelative(onboardingPaths.SKETCHING),
 | 
			
		||||
    element: <Sketching />,
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    path: makeUrlPathRelative(ONBOARDING_SUBPATHS.FUTURE_WORK),
 | 
			
		||||
    path: makeUrlPathRelative(onboardingPaths.FUTURE_WORK),
 | 
			
		||||
    element: <FutureWork />,
 | 
			
		||||
  },
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,6 @@
 | 
			
		||||
import type { OnboardingStatus } from '@rust/kcl-lib/bindings/OnboardingStatus'
 | 
			
		||||
 | 
			
		||||
export const ONBOARDING_SUBPATHS: Record<string, OnboardingStatus> = {
 | 
			
		||||
export const onboardingPaths: Record<string, OnboardingStatus> = {
 | 
			
		||||
  INDEX: '/',
 | 
			
		||||
  CAMERA: '/camera',
 | 
			
		||||
  STREAMING: '/streaming',
 | 
			
		||||
@ -11,6 +11,7 @@ export const ONBOARDING_SUBPATHS: Record<string, OnboardingStatus> = {
 | 
			
		||||
  USER_MENU: '/user-menu',
 | 
			
		||||
  PROJECT_MENU: '/project-menu',
 | 
			
		||||
  EXPORT: '/export',
 | 
			
		||||
  MOVE: '/move',
 | 
			
		||||
  SKETCHING: '/sketching',
 | 
			
		||||
  FUTURE_WORK: '/future-work',
 | 
			
		||||
} as const
 | 
			
		||||
@ -1,9 +1,5 @@
 | 
			
		||||
import { useCallback, useEffect } from 'react'
 | 
			
		||||
import {
 | 
			
		||||
  type NavigateFunction,
 | 
			
		||||
  type useLocation,
 | 
			
		||||
  useNavigate,
 | 
			
		||||
} from 'react-router-dom'
 | 
			
		||||
import { useNavigate } from 'react-router-dom'
 | 
			
		||||
import { waitFor } from 'xstate'
 | 
			
		||||
 | 
			
		||||
import { ActionButton } from '@src/components/ActionButton'
 | 
			
		||||
@ -15,39 +11,30 @@ import { NetworkHealthState } from '@src/hooks/useNetworkStatus'
 | 
			
		||||
import { EngineConnectionStateType } from '@src/lang/std/engineConnection'
 | 
			
		||||
import { bracket } from '@src/lib/exampleKcl'
 | 
			
		||||
import makeUrlPathRelative from '@src/lib/makeUrlPathRelative'
 | 
			
		||||
import { joinRouterPaths, PATHS } from '@src/lib/paths'
 | 
			
		||||
import {
 | 
			
		||||
  codeManager,
 | 
			
		||||
  editorManager,
 | 
			
		||||
  kclManager,
 | 
			
		||||
  systemIOActor,
 | 
			
		||||
} from '@src/lib/singletons'
 | 
			
		||||
import { PATHS } from '@src/lib/paths'
 | 
			
		||||
import { codeManager, editorManager, kclManager } from '@src/lib/singletons'
 | 
			
		||||
import { reportRejection, trap } from '@src/lib/trap'
 | 
			
		||||
import { settingsActor } from '@src/lib/singletons'
 | 
			
		||||
import { isKclEmptyOrOnlySettings, parse, resultIsOk } from '@src/lang/wasm'
 | 
			
		||||
import { onboardingRoutes } from '@src/routes/Onboarding'
 | 
			
		||||
import { onboardingPaths } from '@src/routes/Onboarding/paths'
 | 
			
		||||
import { parse, resultIsOk } from '@src/lang/wasm'
 | 
			
		||||
import { updateModelingState } from '@src/lang/modelingWorkflows'
 | 
			
		||||
import {
 | 
			
		||||
  DEFAULT_PROJECT_KCL_FILE,
 | 
			
		||||
  EXECUTION_TYPE_REAL,
 | 
			
		||||
  ONBOARDING_PROJECT_NAME,
 | 
			
		||||
} from '@src/lib/constants'
 | 
			
		||||
import toast from 'react-hot-toast'
 | 
			
		||||
import type CodeManager from '@src/lang/codeManager'
 | 
			
		||||
import type { OnboardingStatus } from '@rust/kcl-lib/bindings/OnboardingStatus'
 | 
			
		||||
import { isDesktop } from '@src/lib/isDesktop'
 | 
			
		||||
import type { KclManager } from '@src/lang/KclSingleton'
 | 
			
		||||
import { Logo } from '@src/components/Logo'
 | 
			
		||||
import { SystemIOMachineEvents } from '@src/machines/systemIO/utils'
 | 
			
		||||
import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
 | 
			
		||||
import { EXECUTION_TYPE_REAL } from '@src/lib/constants'
 | 
			
		||||
 | 
			
		||||
export const kbdClasses =
 | 
			
		||||
  'py-0.5 px-1 text-sm rounded bg-chalkboard-10 dark:bg-chalkboard-100 border border-chalkboard-50 border-b-2'
 | 
			
		||||
 | 
			
		||||
// Get the 1-indexed step number of the current onboarding step
 | 
			
		||||
function useStepNumber(
 | 
			
		||||
  slug?: (typeof ONBOARDING_SUBPATHS)[keyof typeof ONBOARDING_SUBPATHS]
 | 
			
		||||
  slug?: (typeof onboardingPaths)[keyof typeof onboardingPaths]
 | 
			
		||||
) {
 | 
			
		||||
  return slug ? Object.values(ONBOARDING_SUBPATHS).indexOf(slug) + 1 : -1
 | 
			
		||||
  return slug
 | 
			
		||||
    ? slug === onboardingPaths.INDEX
 | 
			
		||||
      ? 1
 | 
			
		||||
      : onboardingRoutes.findIndex(
 | 
			
		||||
          (r) => r.path === makeUrlPathRelative(slug)
 | 
			
		||||
        ) + 1
 | 
			
		||||
    : 1
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function useDemoCode() {
 | 
			
		||||
@ -93,7 +80,7 @@ export function useNextClick(newStatus: string) {
 | 
			
		||||
      data: { level: 'user', value: newStatus },
 | 
			
		||||
    })
 | 
			
		||||
    navigate(filePath + PATHS.ONBOARDING.INDEX.slice(0, -1) + newStatus)
 | 
			
		||||
  }, [filePath, newStatus, navigate])
 | 
			
		||||
  }, [filePath, newStatus, settingsActor.send, navigate])
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function useDismiss() {
 | 
			
		||||
@ -107,17 +94,9 @@ export function useDismiss() {
 | 
			
		||||
      data: { level: 'user', value: 'dismissed' },
 | 
			
		||||
    })
 | 
			
		||||
    waitFor(settingsActor, (state) => state.matches('idle'))
 | 
			
		||||
      .then(() => {
 | 
			
		||||
        navigate(filePath)
 | 
			
		||||
        toast.success(
 | 
			
		||||
          'Click the question mark in the lower-right corner if you ever want to redo the tutorial!',
 | 
			
		||||
          {
 | 
			
		||||
            duration: 5_000,
 | 
			
		||||
          }
 | 
			
		||||
        )
 | 
			
		||||
      })
 | 
			
		||||
      .then(() => navigate(filePath))
 | 
			
		||||
      .catch(reportRejection)
 | 
			
		||||
  }, [send, filePath, navigate])
 | 
			
		||||
  }, [send])
 | 
			
		||||
 | 
			
		||||
  return settingsCallback
 | 
			
		||||
}
 | 
			
		||||
@ -128,31 +107,32 @@ export function OnboardingButtons({
 | 
			
		||||
  onNextOverride,
 | 
			
		||||
  ...props
 | 
			
		||||
}: {
 | 
			
		||||
  currentSlug?: (typeof ONBOARDING_SUBPATHS)[keyof typeof ONBOARDING_SUBPATHS]
 | 
			
		||||
  currentSlug?: (typeof onboardingPaths)[keyof typeof onboardingPaths]
 | 
			
		||||
  className?: string
 | 
			
		||||
  dismissClassName?: string
 | 
			
		||||
  onNextOverride?: () => void
 | 
			
		||||
} & React.HTMLAttributes<HTMLDivElement>) {
 | 
			
		||||
  const onboardingPathsArray = Object.values(ONBOARDING_SUBPATHS)
 | 
			
		||||
  const dismiss = useDismiss()
 | 
			
		||||
  const stepNumber = useStepNumber(currentSlug)
 | 
			
		||||
  const previousStep =
 | 
			
		||||
    !stepNumber || stepNumber <= 1 ? null : onboardingPathsArray[stepNumber]
 | 
			
		||||
  const goToPrevious = useNextClick(previousStep ?? ONBOARDING_SUBPATHS.INDEX)
 | 
			
		||||
    !stepNumber || stepNumber === 0 ? null : onboardingRoutes[stepNumber - 2]
 | 
			
		||||
  const goToPrevious = useNextClick(
 | 
			
		||||
    onboardingPaths.INDEX + (previousStep?.path ?? '')
 | 
			
		||||
  )
 | 
			
		||||
  const nextStep =
 | 
			
		||||
    !stepNumber || stepNumber === onboardingPathsArray.length
 | 
			
		||||
    !stepNumber || stepNumber === onboardingRoutes.length
 | 
			
		||||
      ? null
 | 
			
		||||
      : onboardingPathsArray[stepNumber]
 | 
			
		||||
  const goToNext = useNextClick(nextStep + ONBOARDING_SUBPATHS.INDEX)
 | 
			
		||||
      : onboardingRoutes[stepNumber]
 | 
			
		||||
  const goToNext = useNextClick(onboardingPaths.INDEX + (nextStep?.path ?? ''))
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <button
 | 
			
		||||
        type="button"
 | 
			
		||||
        onClick={dismiss}
 | 
			
		||||
        className={`group block !absolute left-auto right-full top-[-3px] m-2.5 p-0 border-none bg-transparent hover:bg-transparent ${
 | 
			
		||||
        className={
 | 
			
		||||
          'group block !absolute left-auto right-full top-[-3px] m-2.5 p-0 border-none bg-transparent hover:bg-transparent ' +
 | 
			
		||||
          dismissClassName
 | 
			
		||||
        }`}
 | 
			
		||||
        }
 | 
			
		||||
        data-testid="onboarding-dismiss"
 | 
			
		||||
      >
 | 
			
		||||
        <CustomIcon
 | 
			
		||||
@ -164,12 +144,16 @@ export function OnboardingButtons({
 | 
			
		||||
        </Tooltip>
 | 
			
		||||
      </button>
 | 
			
		||||
      <div
 | 
			
		||||
        className={`flex items-center justify-between ${className ?? ''}`}
 | 
			
		||||
        className={'flex items-center justify-between ' + (className ?? '')}
 | 
			
		||||
        {...props}
 | 
			
		||||
      >
 | 
			
		||||
        <ActionButton
 | 
			
		||||
          Element="button"
 | 
			
		||||
          onClick={() => (previousStep ? goToPrevious() : dismiss())}
 | 
			
		||||
          onClick={() =>
 | 
			
		||||
            previousStep?.path || previousStep?.index
 | 
			
		||||
              ? goToPrevious()
 | 
			
		||||
              : dismiss()
 | 
			
		||||
          }
 | 
			
		||||
          iconStart={{
 | 
			
		||||
            icon: previousStep ? 'arrowLeft' : 'close',
 | 
			
		||||
            className: 'text-chalkboard-10',
 | 
			
		||||
@ -178,18 +162,18 @@ export function OnboardingButtons({
 | 
			
		||||
          className="hover:border-destroy-40 hover:bg-destroy-10/50 dark:hover:bg-destroy-80/50"
 | 
			
		||||
          data-testid="onboarding-prev"
 | 
			
		||||
        >
 | 
			
		||||
          {previousStep ? 'Back' : 'Dismiss'}
 | 
			
		||||
          {previousStep ? `Back` : 'Dismiss'}
 | 
			
		||||
        </ActionButton>
 | 
			
		||||
        {stepNumber !== undefined && (
 | 
			
		||||
          <p className="font-mono text-xs text-center m-0">
 | 
			
		||||
            {stepNumber} / {onboardingPathsArray.length}
 | 
			
		||||
            {stepNumber} / {onboardingRoutes.length}
 | 
			
		||||
          </p>
 | 
			
		||||
        )}
 | 
			
		||||
        <ActionButton
 | 
			
		||||
          autoFocus
 | 
			
		||||
          Element="button"
 | 
			
		||||
          onClick={() => {
 | 
			
		||||
            if (nextStep) {
 | 
			
		||||
            if (nextStep?.path) {
 | 
			
		||||
              onNextOverride ? onNextOverride() : goToNext()
 | 
			
		||||
            } else {
 | 
			
		||||
              dismiss()
 | 
			
		||||
@ -202,221 +186,9 @@ export function OnboardingButtons({
 | 
			
		||||
          className="dark:hover:bg-chalkboard-80/50"
 | 
			
		||||
          data-testid="onboarding-next"
 | 
			
		||||
        >
 | 
			
		||||
          {nextStep ? 'Next' : 'Finish'}
 | 
			
		||||
          {nextStep ? `Next` : 'Finish'}
 | 
			
		||||
        </ActionButton>
 | 
			
		||||
      </div>
 | 
			
		||||
    </>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface OnboardingUtilDeps {
 | 
			
		||||
  onboardingStatus: OnboardingStatus
 | 
			
		||||
  codeManager: CodeManager
 | 
			
		||||
  kclManager: KclManager
 | 
			
		||||
  navigate: NavigateFunction
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const ERROR_MUST_WARN = 'Must warn user before overwrite'
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Accept to begin the onboarding tutorial,
 | 
			
		||||
 * depending on the platform and the state of the user's code.
 | 
			
		||||
 */
 | 
			
		||||
export async function acceptOnboarding(deps: OnboardingUtilDeps) {
 | 
			
		||||
  if (isDesktop()) {
 | 
			
		||||
    /** TODO: rename this event to be more generic, like `createKCLFileAndNavigate` */
 | 
			
		||||
    systemIOActor.send({
 | 
			
		||||
      type: SystemIOMachineEvents.importFileFromURL,
 | 
			
		||||
      data: {
 | 
			
		||||
        requestedProjectName: ONBOARDING_PROJECT_NAME,
 | 
			
		||||
        requestedFileName: DEFAULT_PROJECT_KCL_FILE,
 | 
			
		||||
        requestedCode: bracket,
 | 
			
		||||
        requestedSubRoute: joinRouterPaths(
 | 
			
		||||
          PATHS.ONBOARDING.INDEX,
 | 
			
		||||
          deps.onboardingStatus
 | 
			
		||||
        ),
 | 
			
		||||
      },
 | 
			
		||||
    })
 | 
			
		||||
    return Promise.resolve()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const isCodeResettable = hasResetReadyCode(deps.codeManager)
 | 
			
		||||
  if (isCodeResettable) {
 | 
			
		||||
    return resetCodeAndAdvanceOnboarding(deps)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return Promise.reject(new Error(ERROR_MUST_WARN))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Given that the user has accepted overwriting their web editor,
 | 
			
		||||
 * advance to the next step and clear their editor.
 | 
			
		||||
 */
 | 
			
		||||
export async function resetCodeAndAdvanceOnboarding({
 | 
			
		||||
  onboardingStatus,
 | 
			
		||||
  codeManager,
 | 
			
		||||
  kclManager,
 | 
			
		||||
  navigate,
 | 
			
		||||
}: OnboardingUtilDeps) {
 | 
			
		||||
  // We do want to update both the state and editor here.
 | 
			
		||||
  codeManager.updateCodeStateEditor(bracket)
 | 
			
		||||
  codeManager.writeToFile().catch(reportRejection)
 | 
			
		||||
  kclManager.executeCode().catch(reportRejection)
 | 
			
		||||
  // TODO: this is not navigating to the correct `/onboarding/blah` path yet
 | 
			
		||||
  navigate(
 | 
			
		||||
    makeUrlPathRelative(
 | 
			
		||||
      `${PATHS.ONBOARDING.INDEX}${makeUrlPathRelative(onboardingStatus)}`
 | 
			
		||||
    )
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function hasResetReadyCode(codeManager: CodeManager) {
 | 
			
		||||
  return (
 | 
			
		||||
    isKclEmptyOrOnlySettings(codeManager.code) || codeManager.code === bracket
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function needsToOnboard(
 | 
			
		||||
  location: ReturnType<typeof useLocation>,
 | 
			
		||||
  onboardingStatus: OnboardingStatus
 | 
			
		||||
) {
 | 
			
		||||
  return (
 | 
			
		||||
    !location.pathname.includes(PATHS.ONBOARDING.INDEX) &&
 | 
			
		||||
    (onboardingStatus.length === 0 ||
 | 
			
		||||
      !(onboardingStatus === 'completed' || onboardingStatus === 'dismissed'))
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const ONBOARDING_TOAST_ID = 'onboarding-toast'
 | 
			
		||||
 | 
			
		||||
export function onDismissOnboardingInvite() {
 | 
			
		||||
  settingsActor.send({
 | 
			
		||||
    type: 'set.app.onboardingStatus',
 | 
			
		||||
    data: { level: 'user', value: 'dismissed' },
 | 
			
		||||
  })
 | 
			
		||||
  toast.dismiss(ONBOARDING_TOAST_ID)
 | 
			
		||||
  toast.success(
 | 
			
		||||
    'Click the question mark in the lower-right corner if you ever want to do the tutorial!',
 | 
			
		||||
    {
 | 
			
		||||
      duration: 5_000,
 | 
			
		||||
    }
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function TutorialRequestToast(props: OnboardingUtilDeps) {
 | 
			
		||||
  function onAccept() {
 | 
			
		||||
    acceptOnboarding(props)
 | 
			
		||||
      .then(() => {
 | 
			
		||||
        toast.dismiss(ONBOARDING_TOAST_ID)
 | 
			
		||||
      })
 | 
			
		||||
      .catch((reason) => catchOnboardingWarnError(reason, props))
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      data-testid="onboarding-toast"
 | 
			
		||||
      className="flex items-center gap-6 min-w-80"
 | 
			
		||||
    >
 | 
			
		||||
      <Logo className="w-auto h-8 flex-none" />
 | 
			
		||||
      <div className="flex flex-col justify-between gap-6">
 | 
			
		||||
        <section>
 | 
			
		||||
          <h2>Welcome to Zoo Design Studio</h2>
 | 
			
		||||
          <p className="text-sm text-chalkboard-70 dark:text-chalkboard-30">
 | 
			
		||||
            Would you like a tutorial to show you around the app?
 | 
			
		||||
          </p>
 | 
			
		||||
        </section>
 | 
			
		||||
        <div className="flex justify-between gap-8">
 | 
			
		||||
          <ActionButton
 | 
			
		||||
            Element="button"
 | 
			
		||||
            iconStart={{
 | 
			
		||||
              icon: 'close',
 | 
			
		||||
            }}
 | 
			
		||||
            data-negative-button="dismiss"
 | 
			
		||||
            name="dismiss"
 | 
			
		||||
            onClick={onDismissOnboardingInvite}
 | 
			
		||||
          >
 | 
			
		||||
            Not right now
 | 
			
		||||
          </ActionButton>
 | 
			
		||||
          <ActionButton
 | 
			
		||||
            Element="button"
 | 
			
		||||
            iconStart={{
 | 
			
		||||
              icon: 'checkmark',
 | 
			
		||||
            }}
 | 
			
		||||
            name="accept"
 | 
			
		||||
            onClick={onAccept}
 | 
			
		||||
          >
 | 
			
		||||
            Get started
 | 
			
		||||
          </ActionButton>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Helper function to catch the `ERROR_MUST_WARN` error from
 | 
			
		||||
 * `acceptOnboarding` and show a warning toast.
 | 
			
		||||
 */
 | 
			
		||||
export async function catchOnboardingWarnError(
 | 
			
		||||
  err: Error,
 | 
			
		||||
  props: OnboardingUtilDeps
 | 
			
		||||
) {
 | 
			
		||||
  if (err instanceof Error && err.message === ERROR_MUST_WARN) {
 | 
			
		||||
    toast.success(TutorialWebConfirmationToast(props), {
 | 
			
		||||
      id: ONBOARDING_TOAST_ID,
 | 
			
		||||
      duration: Number.POSITIVE_INFINITY,
 | 
			
		||||
      icon: null,
 | 
			
		||||
    })
 | 
			
		||||
  } else {
 | 
			
		||||
    toast.dismiss(ONBOARDING_TOAST_ID)
 | 
			
		||||
    return reportRejection(err)
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function TutorialWebConfirmationToast(props: OnboardingUtilDeps) {
 | 
			
		||||
  function onAccept() {
 | 
			
		||||
    toast.dismiss(ONBOARDING_TOAST_ID)
 | 
			
		||||
    resetCodeAndAdvanceOnboarding(props).catch(reportRejection)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      data-testid="onboarding-toast-confirmation"
 | 
			
		||||
      className="flex items-center gap-6 min-w-80"
 | 
			
		||||
    >
 | 
			
		||||
      <Logo className="w-auto h-8 flex-none" />
 | 
			
		||||
      <div className="flex flex-col justify-between gap-6">
 | 
			
		||||
        <section>
 | 
			
		||||
          <h2>The welcome tutorial resets your code in the browser</h2>
 | 
			
		||||
          <p className="text-sm text-chalkboard-70 dark:text-chalkboard-30">
 | 
			
		||||
            We see you have some of your own code written in this project.
 | 
			
		||||
            Please save it somewhere else before continuing the onboarding.
 | 
			
		||||
          </p>
 | 
			
		||||
        </section>
 | 
			
		||||
        <div className="flex justify-between gap-8">
 | 
			
		||||
          <ActionButton
 | 
			
		||||
            Element="button"
 | 
			
		||||
            iconStart={{
 | 
			
		||||
              icon: 'close',
 | 
			
		||||
            }}
 | 
			
		||||
            data-negative-button="dismiss"
 | 
			
		||||
            name="dismiss"
 | 
			
		||||
            onClick={onDismissOnboardingInvite}
 | 
			
		||||
          >
 | 
			
		||||
            I'll save it
 | 
			
		||||
          </ActionButton>
 | 
			
		||||
          <ActionButton
 | 
			
		||||
            Element="button"
 | 
			
		||||
            iconStart={{
 | 
			
		||||
              icon: 'checkmark',
 | 
			
		||||
            }}
 | 
			
		||||
            name="accept"
 | 
			
		||||
            onClick={onAccept}
 | 
			
		||||
          >
 | 
			
		||||
            Overwrite and begin
 | 
			
		||||
          </ActionButton>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -26,9 +26,7 @@ const SignIn = () => {
 | 
			
		||||
  if (isDesktop()) {
 | 
			
		||||
    window.electron.createFallbackMenu().catch(reportRejection)
 | 
			
		||||
    // Disable these since they cannot be accessed within the sign in page.
 | 
			
		||||
    window.electron
 | 
			
		||||
      .disableMenu('Help.Replay onboarding tutorial')
 | 
			
		||||
      .catch(reportRejection)
 | 
			
		||||
    window.electron.disableMenu('Help.Reset onboarding').catch(reportRejection)
 | 
			
		||||
    window.electron.disableMenu('Help.Show all commands').catch(reportRejection)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -1,7 +1,7 @@
 | 
			
		||||
import { NODE_ENV } from '@src/env'
 | 
			
		||||
import { isDesktop } from '@src/lib/isDesktop'
 | 
			
		||||
 | 
			
		||||
import { IS_PLAYWRIGHT_KEY } from '@src/lib/constants'
 | 
			
		||||
import { IS_PLAYWRIGHT_KEY } from '@e2e/playwright/storageStates'
 | 
			
		||||
 | 
			
		||||
const isTestEnv = window?.localStorage.getItem(IS_PLAYWRIGHT_KEY) === 'true'
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
		Reference in New Issue
	
	Block a user