Assemblies: Load outside files into project via point-and-click (#6217)

* WIP: Add point-and-click Import for geometry
Will eventually fix #6120
Right now the whole loop is there but the codemod doesn't work yet

* Better pathToNOde, log on non-working cm dispatch call

* Add workaround to updateModelingState not working

* Back to updateModelingState with a skip flag

* Better todo

* Change working from Import to Insert, cleanups

* Sister command in kclCommands to populate file options

* Improve path selector

* Unsure: move importAstMod to kclCommands onSubmit 😶

* Add e2e test

* Clean up for review

* Add native file menu entry and test

* No await yo lint said so

* WIP: UX improvements around foreign file imports
Fixes #6152

* @lrev-Dev's suggestion to remove a comment

Co-authored-by: Kurt Hutten <k.hutten@protonmail.ch>

* Update to scene.settled(cmdBar)

* Add partNNN default name for alias

* Lint

* Lint

* Fix unit tests

* Add sad path insert test
Thanks @Irev-Dev for the suggestion

* Add step insert test

* Lint

* Add test for second foreign import thru file tree click

* WIP: Add point-and-click Load to copy files from outside the project into the project
Towards #6210

* Move Insert button to modeling toolbar, update menus and toolbars

* Add default value for local name alias

* Aligning tests

* Fix tests

* Add padding for filenames starting with a digit

* Lint

* Lint

* Update snapshots

* Merge branch 'main' into pierremtb/issue6210-Add-point-and-click-Load-to-copy-files-from-outside-the-project-into-the-project

* Add disabled transform subbutton

* Merge kcl-samples and local disk load into one 'Load external model' command

* Fix em tests

* Fix test

* Add test for file pick import, better input

* Fix non .kcl loading

* Lint

* Update snapshots

* Fix issue leading to test failure

* Fix clone test

* Add note

* Fix nested clone issue

* Clean up for review

* Add valueSummary for path

* Fix test after path change

* Clean up for review

* Update src/lib/kclCommands.ts

Thanks @franknoirot!

Co-authored-by: Frank Noirot <frank@zoo.dev>

* Improve path input arg

* Fix tests

* Merge branch 'main' into pierremtb/issue6210-Add-point-and-click-Load-to-copy-files-from-outside-the-project-into-the-project

* Fix path header not showing and improve tests

* Clean up

---------

Co-authored-by: Kurt Hutten <k.hutten@protonmail.ch>
Co-authored-by: Frank Noirot <frank@zoo.dev>
This commit is contained in:
Pierre Jacquier
2025-04-14 14:53:01 -04:00
committed by GitHub
parent 39af110ac1
commit add1b21503
44 changed files with 552 additions and 186 deletions

View File

@ -3,7 +3,7 @@ import { expect } from '@playwright/test'
import * as fs from 'fs' import * as fs from 'fs'
import * as path from 'path' import * as path from 'path'
type CmdBarSerialised = export type CmdBarSerialised =
| { | {
stage: 'commandBarClosed' stage: 'commandBarClosed'
} }

View File

@ -27,6 +27,7 @@ export class ToolbarFixture {
offsetPlaneButton!: Locator offsetPlaneButton!: Locator
helixButton!: Locator helixButton!: Locator
startSketchBtn!: Locator startSketchBtn!: Locator
insertButton!: Locator
lineBtn!: Locator lineBtn!: Locator
tangentialArcBtn!: Locator tangentialArcBtn!: Locator
circleBtn!: Locator circleBtn!: Locator
@ -44,7 +45,7 @@ export class ToolbarFixture {
featureTreePane!: Locator featureTreePane!: Locator
gizmo!: Locator gizmo!: Locator
gizmoDisabled!: Locator gizmoDisabled!: Locator
insertButton!: Locator loadButton!: Locator
constructor(page: Page) { constructor(page: Page) {
this.page = page this.page = page
@ -59,6 +60,7 @@ export class ToolbarFixture {
this.offsetPlaneButton = page.getByTestId('plane-offset') this.offsetPlaneButton = page.getByTestId('plane-offset')
this.helixButton = page.getByTestId('helix') this.helixButton = page.getByTestId('helix')
this.startSketchBtn = page.getByTestId('sketch') this.startSketchBtn = page.getByTestId('sketch')
this.insertButton = page.getByTestId('insert')
this.lineBtn = page.getByTestId('line') this.lineBtn = page.getByTestId('line')
this.tangentialArcBtn = page.getByTestId('tangential-arc') this.tangentialArcBtn = page.getByTestId('tangential-arc')
this.circleBtn = page.getByTestId('circle-center') this.circleBtn = page.getByTestId('circle-center')
@ -68,6 +70,7 @@ export class ToolbarFixture {
this.fileTreeBtn = page.locator('[id="files-button-holder"]') this.fileTreeBtn = page.locator('[id="files-button-holder"]')
this.createFileBtn = page.getByTestId('create-file-button') this.createFileBtn = page.getByTestId('create-file-button')
this.treeInputField = page.getByTestId('tree-input-field') this.treeInputField = page.getByTestId('tree-input-field')
this.loadButton = page.getByTestId('load-external-model-pane-button')
this.filePane = page.locator('#files-pane') this.filePane = page.locator('#files-pane')
this.featureTreePane = page.locator('#feature-tree-pane') this.featureTreePane = page.locator('#feature-tree-pane')
@ -79,8 +82,6 @@ export class ToolbarFixture {
// element or two different elements can represent these states. // element or two different elements can represent these states.
this.gizmo = page.getByTestId('gizmo') this.gizmo = page.getByTestId('gizmo')
this.gizmoDisabled = page.getByTestId('gizmo-disabled') this.gizmoDisabled = page.getByTestId('gizmo-disabled')
this.insertButton = page.getByTestId('insert-pane-button')
} }
get logoLink() { get logoLink() {

View File

@ -534,7 +534,7 @@ test.describe('Native file menu', { tag: ['@electron'] }, () => {
const expected = 'Open project' const expected = 'Open project'
expect(actual).toBe(expected) expect(actual).toBe(expected)
}) })
test('Modeling.File.Load a sample model', async ({ test('Modeling.File.Load external model', async ({
tronApp, tronApp,
cmdBar, cmdBar,
page, page,
@ -555,10 +555,10 @@ test.describe('Native file menu', { tag: ['@electron'] }, () => {
throw new Error('app or app.applicationMenu is missing') throw new Error('app or app.applicationMenu is missing')
} }
const openProject = app.applicationMenu.getMenuItemById( const openProject = app.applicationMenu.getMenuItemById(
'File.Load a sample model' 'File.Load external model'
) )
if (!openProject) { if (!openProject) {
throw new Error('File.Load a sample model') throw new Error('File.Load external model')
} }
openProject.click() openProject.click()
}) })
@ -568,44 +568,7 @@ test.describe('Native file menu', { tag: ['@electron'] }, () => {
const actual = await cmdBar.cmdBarElement const actual = await cmdBar.cmdBarElement
.getByTestId('command-name') .getByTestId('command-name')
.textContent() .textContent()
const expected = 'Open sample' const expected = 'Load external model'
expect(actual).toBe(expected)
})
test('Modeling.File.Insert from project file', async ({
tronApp,
cmdBar,
page,
homePage,
scene,
}) => {
if (!tronApp) {
throwTronAppMissing()
return
}
await homePage.goToModelingScene()
await scene.settled(cmdBar)
// Run electron snippet to find the Menu!
await page.waitForTimeout(100) // wait for createModelingPageMenu() to run
await tronApp.electron.evaluate(async ({ app }) => {
if (!app || !app.applicationMenu) {
throw new Error('app or app.applicationMenu is missing')
}
const openProject = app.applicationMenu.getMenuItemById(
'File.Insert from project file'
)
if (!openProject) {
throw new Error('File.Insert from project file')
}
openProject.click()
})
// Check that the command bar is opened
await expect(cmdBar.cmdBarElement).toBeVisible()
// Check the placeholder project name exists
const actual = await cmdBar.cmdBarElement
.getByTestId('command-name')
.textContent()
const expected = 'Insert'
expect(actual).toBe(expected) expect(actual).toBe(expected)
}) })
test('Modeling.File.Export current part', async ({ test('Modeling.File.Export current part', async ({
@ -2159,6 +2122,44 @@ test.describe('Native file menu', { tag: ['@electron'] }, () => {
expect(actual).toBe(expected) expect(actual).toBe(expected)
}) })
test('Modeling.Design.Insert from project file', async ({
tronApp,
cmdBar,
page,
homePage,
scene,
}) => {
if (!tronApp) {
throwTronAppMissing()
return
}
await homePage.goToModelingScene()
await scene.settled(cmdBar)
// Run electron snippet to find the Menu!
await page.waitForTimeout(100) // wait for createModelingPageMenu() to run
await tronApp.electron.evaluate(async ({ app }) => {
if (!app || !app.applicationMenu) {
throw new Error('app or app.applicationMenu is missing')
}
const openProject = app.applicationMenu.getMenuItemById(
'Design.Insert from project file'
)
if (!openProject) {
throw new Error('Design.Insert from project file')
}
openProject.click()
})
// Check that the command bar is opened
await expect(cmdBar.cmdBarElement).toBeVisible()
// Check the placeholder project name exists
const actual = await cmdBar.cmdBarElement
.getByTestId('command-name')
.textContent()
const expected = 'Insert'
expect(actual).toBe(expected)
})
test('Modeling.Design.Create with Zoo Text-To-CAD', async ({ test('Modeling.Design.Create with Zoo Text-To-CAD', async ({
tronApp, tronApp,
cmdBar, cmdBar,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 63 KiB

View File

@ -3,14 +3,18 @@ import { FILE_EXT } from '@src/lib/constants'
import * as fsp from 'fs/promises' import * as fsp from 'fs/promises'
import { join } from 'path' import { join } from 'path'
import type { CmdBarSerialised } from '@e2e/playwright/fixtures/cmdBarFixture'
import type { ElectronZoo } from '@e2e/playwright/fixtures/fixtureSetup'
import { import {
executorInputPath,
getUtils, getUtils,
orRunWhenFullSuiteEnabled, orRunWhenFullSuiteEnabled,
runningOnWindows, runningOnWindows,
testsInputPath,
} from '@e2e/playwright/test-utils' } from '@e2e/playwright/test-utils'
import { expect, test } from '@e2e/playwright/zoo-test' import { expect, test } from '@e2e/playwright/zoo-test'
test.describe('Testing in-app sample loading', () => { test.describe('Testing loading external models', () => {
/** /**
* Note this test implicitly depends on the KCL sample "parametric-bearing-pillow-block", * Note this test implicitly depends on the KCL sample "parametric-bearing-pillow-block",
* its title, and its units settings. https://github.com/KittyCAD/kcl-samples/blob/main/parametric-bearing-pillow-block/main.kcl * its title, and its units settings. https://github.com/KittyCAD/kcl-samples/blob/main/parametric-bearing-pillow-block/main.kcl
@ -39,7 +43,7 @@ test.describe('Testing in-app sample loading', () => {
} }
const commandBarButton = page.getByRole('button', { name: 'Commands' }) const commandBarButton = page.getByRole('button', { name: 'Commands' })
const samplesCommandOption = page.getByRole('option', { const samplesCommandOption = page.getByRole('option', {
name: 'Open Sample', name: 'Load external model',
}) })
const commandSampleOption = page.getByRole('option', { const commandSampleOption = page.getByRole('option', {
name: newSample.title, name: newSample.title,
@ -83,7 +87,7 @@ test.describe('Testing in-app sample loading', () => {
test( test(
'Desktop: should create new file by default, optionally overwrite', 'Desktop: should create new file by default, optionally overwrite',
{ tag: '@electron' }, { tag: '@electron' },
async ({ editor, context, page, scene, cmdBar }, testInfo) => { async ({ editor, context, page, scene, cmdBar, toolbar }) => {
if (runningOnWindows()) { if (runningOnWindows()) {
test.fixme(orRunWhenFullSuiteEnabled()) test.fixme(orRunWhenFullSuiteEnabled())
} }
@ -106,20 +110,12 @@ test.describe('Testing in-app sample loading', () => {
title: '100mm Gear Rack', title: '100mm Gear Rack',
} }
const projectCard = page.getByRole('link', { name: 'bracket' }) const projectCard = page.getByRole('link', { name: 'bracket' })
const commandBarButton = page.getByRole('button', { name: 'Commands' })
const commandOption = page.getByRole('option', { name: 'Open Sample' })
const commandSampleOption = (name: string) =>
page.getByRole('option', {
name,
exact: true,
})
const commandMethodArgButton = page.getByRole('button', { const commandMethodArgButton = page.getByRole('button', {
name: 'Method', name: 'Method',
}) })
const commandMethodOption = page.getByRole('option', { const commandMethodOption = page.getByRole('option', {
name: 'Overwrite', name: 'Overwrite',
}) })
const newFileWarning = page.getByText('Create a new file from sample?')
const overwriteWarning = page.getByText( const overwriteWarning = page.getByText(
'Overwrite current file with sample?' 'Overwrite current file with sample?'
) )
@ -129,6 +125,18 @@ test.describe('Testing in-app sample loading', () => {
page.getByRole('listitem').filter({ page.getByRole('listitem').filter({
has: page.getByRole('button', { name }), has: page.getByRole('button', { name }),
}) })
const defaultLoadCmdBarState: CmdBarSerialised = {
commandName: 'Load external model',
currentArgKey: 'source',
currentArgValue: '',
headerArguments: {
Method: 'newFile',
Sample: '',
Source: '',
},
highlightedHeaderArg: 'source',
stage: 'arguments',
}
await test.step(`Test setup`, async () => { await test.step(`Test setup`, async () => {
await page.setBodyDimensions({ width: 1200, height: 500 }) await page.setBodyDimensions({ width: 1200, height: 500 })
@ -147,14 +155,12 @@ test.describe('Testing in-app sample loading', () => {
}) })
await test.step(`Load a KCL sample with the command palette`, async () => { await test.step(`Load a KCL sample with the command palette`, async () => {
await commandBarButton.click() await toolbar.loadButton.click()
await page.waitForTimeout(1000) await cmdBar.expectState(defaultLoadCmdBarState)
await commandOption.click() await cmdBar.progressCmdBar()
await page.waitForTimeout(1000) await cmdBar.selectOption({ name: sampleOne.title }).click()
await commandSampleOption(sampleOne.title).click()
await expect(overwriteWarning).not.toBeVisible() await expect(overwriteWarning).not.toBeVisible()
await expect(newFileWarning).toBeVisible() await cmdBar.progressCmdBar()
await confirmButton.click()
await page.waitForTimeout(1000) await page.waitForTimeout(1000)
}) })
@ -165,21 +171,15 @@ test.describe('Testing in-app sample loading', () => {
}) })
await test.step(`Now overwrite the current file`, async () => { await test.step(`Now overwrite the current file`, async () => {
await commandBarButton.click() await toolbar.loadButton.click()
await page.waitForTimeout(1000) await cmdBar.expectState(defaultLoadCmdBarState)
await commandOption.click() await cmdBar.progressCmdBar()
await page.waitForTimeout(1000) await cmdBar.selectOption({ name: sampleTwo.title }).click()
await commandSampleOption(sampleTwo.title).click()
await page.waitForTimeout(1000)
await commandMethodArgButton.click() await commandMethodArgButton.click()
await page.waitForTimeout(1000)
await commandMethodOption.click() await commandMethodOption.click()
await page.waitForTimeout(1000)
await expect(commandMethodArgButton).toContainText('overwrite') await expect(commandMethodArgButton).toContainText('overwrite')
await expect(newFileWarning).not.toBeVisible()
await expect(overwriteWarning).toBeVisible() await expect(overwriteWarning).toBeVisible()
await confirmButton.click() await confirmButton.click()
await page.waitForTimeout(1000)
}) })
await test.step(`Ensure we overwrote the current file without navigating`, async () => { await test.step(`Ensure we overwrote the current file without navigating`, async () => {
@ -200,4 +200,96 @@ test.describe('Testing in-app sample loading', () => {
}) })
} }
) )
const externalModelCases = [
{
modelName: 'cylinder.kcl',
deconflictedModelName: 'cylinder-1.kcl',
modelPath: executorInputPath('cylinder.kcl'),
},
{
modelName: 'cube.step',
deconflictedModelName: 'cube-1.step',
modelPath: testsInputPath('cube.step'),
},
]
externalModelCases.map(({ modelName, deconflictedModelName, modelPath }) => {
test(
`Load external models from local drive - ${modelName}`,
{ tag: ['@electron'] },
async ({ page, homePage, scene, toolbar, cmdBar, tronApp }) => {
if (!tronApp) {
fail()
}
await page.setBodyDimensions({ width: 1000, height: 500 })
await homePage.goToModelingScene()
await scene.settled(cmdBar)
const modelFileContent = await fsp.readFile(modelPath, 'utf-8')
const { editorTextMatches } = await getUtils(page, test)
async function loadExternalFileThroughCommandBar(tronApp: ElectronZoo) {
await toolbar.loadButton.click()
await cmdBar.expectState({
commandName: 'Load external model',
currentArgKey: 'source',
currentArgValue: '',
headerArguments: {
Method: 'newFile',
Sample: '',
Source: '',
},
highlightedHeaderArg: 'source',
stage: 'arguments',
})
await cmdBar.selectOption({ name: 'Local Drive' }).click()
// Mock the file picker selection
const handleFile = tronApp.electron.evaluate(
async ({ dialog }, filePaths) => {
dialog.showOpenDialog = () =>
Promise.resolve({ canceled: false, filePaths })
},
[modelPath]
)
await page.getByTestId('cmd-bar-arg-file-button').click()
await handleFile
await cmdBar.progressCmdBar()
await cmdBar.expectState({
commandName: 'Load external model',
headerArguments: {
Source: 'local',
Path: modelName,
},
stage: 'review',
})
await cmdBar.progressCmdBar()
}
await test.step('Load the external model from local drive', async () => {
await loadExternalFileThroughCommandBar(tronApp)
// TODO: I think the files pane should auto open?
await toolbar.openPane('files')
await toolbar.expectFileTreeState([modelName, 'main.kcl'])
if (modelName.endsWith('.kcl')) {
await editorTextMatches(modelFileContent)
}
})
await test.step('Load the same external model, except deconflicted name', async () => {
await loadExternalFileThroughCommandBar(tronApp)
await toolbar.openPane('files')
await toolbar.expectFileTreeState([
deconflictedModelName,
modelName,
'main.kcl',
])
if (modelName.endsWith('.kcl')) {
await editorTextMatches(modelFileContent)
}
})
}
)
})
}) })

View File

@ -2,6 +2,7 @@ import CommandArgOptionInput from '@src/components/CommandBar/CommandArgOptionIn
import CommandBarBasicInput from '@src/components/CommandBar/CommandBarBasicInput' import CommandBarBasicInput from '@src/components/CommandBar/CommandBarBasicInput'
import CommandBarHeader from '@src/components/CommandBar/CommandBarHeader' import CommandBarHeader from '@src/components/CommandBar/CommandBarHeader'
import CommandBarKclInput from '@src/components/CommandBar/CommandBarKclInput' import CommandBarKclInput from '@src/components/CommandBar/CommandBarKclInput'
import CommandBarPathInput from '@src/components/CommandBar/CommandBarPathInput'
import CommandBarSelectionInput from '@src/components/CommandBar/CommandBarSelectionInput' import CommandBarSelectionInput from '@src/components/CommandBar/CommandBarSelectionInput'
import CommandBarSelectionMixedInput from '@src/components/CommandBar/CommandBarSelectionMixedInput' import CommandBarSelectionMixedInput from '@src/components/CommandBar/CommandBarSelectionMixedInput'
import CommandBarTextareaInput from '@src/components/CommandBar/CommandBarTextareaInput' import CommandBarTextareaInput from '@src/components/CommandBar/CommandBarTextareaInput'
@ -108,6 +109,14 @@ function ArgumentInput({
onSubmit={onSubmit} onSubmit={onSubmit}
/> />
) )
case 'path':
return (
<CommandBarPathInput
arg={arg}
stepBack={stepBack}
onSubmit={onSubmit}
/>
)
default: default:
return ( return (
<CommandBarBasicInput <CommandBarBasicInput

View File

@ -0,0 +1,120 @@
import { useEffect, useMemo, useRef } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import { ActionButton } from '@src/components/ActionButton'
import type { CommandArgument } from '@src/lib/commandTypes'
import { reportRejection } from '@src/lib/trap'
import { isArray, toSync } from '@src/lib/utils'
import {
commandBarActor,
useCommandBarState,
} from '@src/machines/commandBarMachine'
import { useSelector } from '@xstate/react'
import type { AnyStateMachine, SnapshotFrom } from 'xstate'
// TODO: remove the need for this selector once we decouple all actors from React
const machineContextSelector = (snapshot?: SnapshotFrom<AnyStateMachine>) =>
snapshot?.context
function CommandBarPathInput({
arg,
stepBack,
onSubmit,
}: {
arg: CommandArgument<unknown> & {
inputType: 'path'
name: string
}
stepBack: () => void
onSubmit: (event: unknown) => void
}) {
const commandBarState = useCommandBarState()
useHotkeys('mod + k, mod + /', () => commandBarActor.send({ type: 'Close' }))
const inputRef = useRef<HTMLInputElement>(null)
const argMachineContext = useSelector(
arg.machineActor,
machineContextSelector
)
const defaultValue = useMemo(
() =>
arg.defaultValue
? arg.defaultValue instanceof Function
? arg.defaultValue(commandBarState.context, argMachineContext)
: arg.defaultValue
: '',
[arg.defaultValue, commandBarState.context, argMachineContext]
)
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
onSubmit(inputRef.current?.value)
}
async function pickFileThroughNativeDialog() {
// In desktop end-to-end tests we can't control the file picker,
// so we seed the new directory value in the element's dataset
const inputRefVal = inputRef.current?.dataset.testValue
if (inputRef.current && inputRefVal && !isArray(inputRefVal)) {
inputRef.current.value = inputRefVal
} else if (inputRef.current) {
const newPath = await window.electron.open({
properties: ['openFile'],
title: 'Pick a file to load into the current project',
})
if (newPath.canceled) return
inputRef.current.value = newPath.filePaths[0]
} else {
return new Error("Couldn't find inputRef")
}
}
// Fire on component mount, if outside of e2e test context
useEffect(() => {
window.electron.process.env.IS_PLAYWRIGHT !== 'true' &&
toSync(pickFileThroughNativeDialog, reportRejection)()
}, [])
return (
<form id="arg-form" onSubmit={handleSubmit}>
<label
data-testid="cmd-bar-arg-name"
className="flex items-center mx-4 my-4 border-b border-b-chalkboard-100 dark:border-b-chalkboard-80"
>
<span className="capitalize px-2 py-1 bg-chalkboard-100 dark:bg-chalkboard-80 text-chalkboard-10">
{arg.displayName || arg.name}
</span>
<input
type="text"
data-testid="cmd-bar-arg-value"
id="arg-form"
name={arg.inputType}
ref={inputRef}
required
className="flex-grow px-2 py-1 !bg-transparent focus:outline-none"
placeholder="Enter a path"
defaultValue={defaultValue}
onKeyDown={(event) => {
if (event.key === 'Backspace' && event.shiftKey) {
stepBack()
}
}}
/>
<ActionButton
Element="button"
onClick={toSync(pickFileThroughNativeDialog, reportRejection)}
className="p-0 m-0 border-none hover:bg-primary/10 focus:bg-primary/10 dark:hover:bg-primary/20 dark:focus::bg-primary/20"
data-testid="cmd-bar-arg-file-button"
iconEnd={{
icon: 'file',
size: 'sm',
className: 'p-1',
}}
>
Open file
</ActionButton>
</label>
</form>
)
}
export default CommandBarPathInput

View File

@ -619,6 +619,22 @@ const CustomIconMap = {
/> />
</svg> </svg>
), ),
importFile: (
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M4 3H4.5H11H11.2071L11.3536 3.14645L15.8536 7.64646L16 7.7929V8.00001V11.3773C15.6992 11.1362 15.3628 10.9376 15 10.7908V8.50001H11H10.5V8.00001V4H5V16H9.79076C9.93763 16.3628 10.1362 16.6992 10.3773 17H4.5H4V16.5V3.5V3ZM11.5 4.70711L14.2929 7.50001H11.5V4.70711ZM13.8123 17.3904L16.3123 15.3904L15.6877 14.6096L14 15.9597V12H13V15.9597L11.3123 14.6096L10.6877 15.3904L13.1877 17.3904L13.5 17.6403L13.8123 17.3904Z"
fill="currentColor"
/>
</svg>
),
'intersection-offset': ( 'intersection-offset': (
<svg <svg
viewBox="0 0 20 20" viewBox="0 0 20 20"

View File

@ -228,16 +228,30 @@ export const FileMachineProvider = ({
createdPath = path createdPath = path
await window.electron.mkdir(createdPath) await window.electron.mkdir(createdPath)
} else { } else {
const { name, path } = getNextFileName({ const isTargetPathToCloneASubPath =
entryName: input.targetPathToClone input.targetPathToClone &&
? window.electron.path.basename(input.targetPathToClone) input.selectedDirectory.path.indexOf(input.targetPathToClone) > -1
: createdName, if (isTargetPathToCloneASubPath) {
baseDir: input.targetPathToClone const { name, path } = getNextFileName({
? window.electron.path.dirname(input.targetPathToClone) entryName: input.targetPathToClone
: input.selectedDirectory.path, ? window.electron.path.basename(input.targetPathToClone)
}) : createdName,
createdName = name baseDir: input.targetPathToClone
createdPath = path ? window.electron.path.dirname(input.targetPathToClone)
: input.selectedDirectory.path,
})
createdName = name
createdPath = path
} else {
const { name, path } = getNextFileName({
entryName: input.targetPathToClone
? window.electron.path.basename(input.targetPathToClone)
: createdName,
baseDir: input.selectedDirectory.path,
})
createdName = name
createdPath = path
}
if (input.targetPathToClone) { if (input.targetPathToClone) {
await window.electron.copyFile( await window.electron.copyFile(
input.targetPathToClone, input.targetPathToClone,
@ -437,19 +451,19 @@ export const FileMachineProvider = ({
settings.modeling.defaultUnit.current ?? settings.modeling.defaultUnit.current ??
DEFAULT_DEFAULT_LENGTH_UNIT, DEFAULT_DEFAULT_LENGTH_UNIT,
}, },
specialPropsForSampleCommand: { specialPropsForLoadCommand: {
onSubmit: async (data) => { onSubmit: async (data) => {
if (data.method === 'overwrite') { if (data.method === 'overwrite' && data.content) {
codeManager.updateCodeStateEditor(data.code) codeManager.updateCodeStateEditor(data.content)
await kclManager.executeCode() await kclManager.executeCode()
await codeManager.writeToFile() await codeManager.writeToFile()
} else if (data.method === 'newFile' && isDesktop()) { } else if (data.method === 'newFile' && isDesktop()) {
send({ send({
type: 'Create file', type: 'Create file',
data: { data: {
name: data.sampleName, ...data,
content: data.code,
makeDir: false, makeDir: false,
shouldSetToRename: false,
}, },
}) })
} }
@ -480,7 +494,7 @@ export const FileMachineProvider = ({
}), }),
}, },
}).filter( }).filter(
(command) => kclSamples.length || command.name !== 'open-kcl-example' (command) => kclSamples.length || command.name !== 'load-external-model'
), ),
[codeManager, kclManager, send, kclSamples, project, file] [codeManager, kclManager, send, kclSamples, project, file]
) )

View File

@ -90,13 +90,13 @@ export const KclEditorMenu = ({ children }: PropsWithChildren) => {
type: 'Find and select command', type: 'Find and select command',
data: { data: {
groupId: 'code', groupId: 'code',
name: 'open-kcl-example', name: 'load-external-model',
}, },
}) })
}} }}
className={styles.button} className={styles.button}
> >
<span>Load a sample model</span> <span>Load external model</span>
</button> </button>
</Menu.Item> </Menu.Item>
<Menu.Item> <Menu.Item>

View File

@ -15,7 +15,6 @@ import type {
} from '@src/components/ModelingSidebar/ModelingPanes' } from '@src/components/ModelingSidebar/ModelingPanes'
import { sidebarPanes } from '@src/components/ModelingSidebar/ModelingPanes' import { sidebarPanes } from '@src/components/ModelingSidebar/ModelingPanes'
import Tooltip from '@src/components/Tooltip' import Tooltip from '@src/components/Tooltip'
import { DEV } from '@src/env'
import { useModelingContext } from '@src/hooks/useModelingContext' import { useModelingContext } from '@src/hooks/useModelingContext'
import { useNetworkContext } from '@src/hooks/useNetworkContext' import { useNetworkContext } from '@src/hooks/useNetworkContext'
import { NetworkHealthState } from '@src/hooks/useNetworkStatus' import { NetworkHealthState } from '@src/hooks/useNetworkStatus'
@ -26,7 +25,6 @@ import { isDesktop } from '@src/lib/isDesktop'
import { useSettings } from '@src/machines/appMachine' import { useSettings } from '@src/machines/appMachine'
import { commandBarActor } from '@src/machines/commandBarMachine' import { commandBarActor } from '@src/machines/commandBarMachine'
import { onboardingPaths } from '@src/routes/Onboarding/paths' import { onboardingPaths } from '@src/routes/Onboarding/paths'
import { IS_NIGHTLY_OR_DEBUG } from '@src/routes/utils'
interface ModelingSidebarProps { interface ModelingSidebarProps {
paneOpacity: '' | 'opacity-20' | 'opacity-40' paneOpacity: '' | 'opacity-20' | 'opacity-40'
@ -77,16 +75,15 @@ export function ModelingSidebar({ paneOpacity }: ModelingSidebarProps) {
const sidebarActions: SidebarAction[] = [ const sidebarActions: SidebarAction[] = [
{ {
id: 'insert', id: 'load-external-model',
title: 'Insert from project file', title: 'Load external model',
sidebarName: 'Insert from project file', sidebarName: 'Load external model',
icon: 'import', icon: 'importFile',
keybinding: 'Ctrl + Shift + I', keybinding: 'Ctrl + Shift + I',
hide: (a) => a.platform === 'web' || !(DEV || IS_NIGHTLY_OR_DEBUG),
action: () => action: () =>
commandBarActor.send({ commandBarActor.send({
type: 'Find and select command', type: 'Find and select command',
data: { name: 'Insert', groupId: 'code' }, data: { name: 'load-external-model', groupId: 'code' },
}), }),
}, },
{ {

View File

@ -344,6 +344,15 @@ export type CommandArgument<
machineContext?: ContextFrom<T> machineContext?: ContextFrom<T>
) => OutputType) ) => OutputType)
} }
| {
inputType: 'path'
defaultValue?:
| OutputType
| ((
commandBarContext: ContextFrom<typeof commandBarMachine>,
machineContext?: ContextFrom<T>
) => OutputType)
}
| { | {
inputType: 'text' inputType: 'text'
defaultValue?: defaultValue?:

View File

@ -1,3 +1,4 @@
import { relevantFileExtensions } from '@src/lang/wasmUtils'
import { import {
FILE_EXT, FILE_EXT,
INDEX_IDENTIFIER, INDEX_IDENTIFIER,
@ -200,14 +201,20 @@ export function getNextFileName({
entryName: string entryName: string
baseDir: string baseDir: string
}) { }) {
// Preserve the extension in case of a relevant but foreign file
let extension = window.electron.path.extname(entryName)
if (!relevantFileExtensions().includes(extension.replace('.', ''))) {
extension = FILE_EXT
}
// Remove any existing index from the name before adding a new one // Remove any existing index from the name before adding a new one
let createdName = entryName.replace(FILE_EXT, '') + FILE_EXT let createdName = entryName.replace(extension, '') + extension
let createdPath = window.electron.path.join(baseDir, createdName) let createdPath = window.electron.path.join(baseDir, createdName)
let i = 1 let i = 1
while (window.electron.exists(createdPath)) { while (window.electron.exists(createdPath)) {
const matchOnIndexAndExtension = new RegExp(`(-\\d+)?(${FILE_EXT})?$`) const matchOnIndexAndExtension = new RegExp(`(-\\d+)?(${extension})?$`)
createdName = createdName =
entryName.replace(matchOnIndexAndExtension, '') + `-${i}` + FILE_EXT entryName.replace(matchOnIndexAndExtension, '') + `-${i}` + extension
createdPath = window.electron.path.join(baseDir, createdName) createdPath = window.electron.path.join(baseDir, createdName)
i++ i++
} }

View File

@ -28,16 +28,17 @@ import type { CommandBarContext } from '@src/machines/commandBarMachine'
import { IS_NIGHTLY_OR_DEBUG } from '@src/routes/utils' import { IS_NIGHTLY_OR_DEBUG } from '@src/routes/utils'
interface OnSubmitProps { interface OnSubmitProps {
sampleName: string name: string
code: string content?: string
sampleUnits?: UnitLength_type targetPathToClone?: string
method: 'overwrite' | 'newFile' method: 'overwrite' | 'newFile'
source: 'kcl-samples' | 'local'
} }
interface KclCommandConfig { interface KclCommandConfig {
// TODO: find a different approach that doesn't require // TODO: find a different approach that doesn't require
// special props for a single command // special props for a single command
specialPropsForSampleCommand: { specialPropsForLoadCommand: {
onSubmit: (p: OnSubmitProps) => Promise<void> onSubmit: (p: OnSubmitProps) => Promise<void>
providedOptions: CommandArgumentOption<string>[] providedOptions: CommandArgumentOption<string>[]
} }
@ -170,69 +171,108 @@ export function kclCommands(commandProps: KclCommandConfig): Command[] {
}, },
}, },
{ {
name: 'open-kcl-example', name: 'load-external-model',
displayName: 'Open sample', displayName: 'Load external model',
description: 'Imports an example KCL program into the editor.', description:
'Loads a model from an external source into the current project.',
needsReview: true, needsReview: true,
icon: 'code', icon: 'importFile',
reviewMessage: ({ argumentsToSubmit }) => reviewMessage: ({ argumentsToSubmit }) =>
CommandBarOverwriteWarning({ argumentsToSubmit['method'] === 'overwrite'
heading: ? CommandBarOverwriteWarning({
'method' in argumentsToSubmit && heading: 'Overwrite current file with sample?',
argumentsToSubmit.method === 'newFile' message:
? 'Create a new file from sample?' 'This will erase your current file and load the sample part.',
: 'Overwrite current file with sample?', })
message: : 'This will create a new file in the current project and open it.',
'method' in argumentsToSubmit &&
argumentsToSubmit.method === 'newFile'
? 'This will create a new file in the current project and open it.'
: 'This will erase your current file and load the sample part.',
}),
groupId: 'code', groupId: 'code',
onSubmit(data) { onSubmit(data) {
if (!data?.sample) { if (!data) {
return return new Error('No input data')
} }
const pathParts = data.sample.split('/')
const projectPathPart = pathParts[0]
const primaryKclFile = pathParts[1]
// local only
const sampleCodeUrl =
(isDesktop() ? '.' : '') +
`/kcl-samples/${encodeURIComponent(
projectPathPart
)}/${encodeURIComponent(primaryKclFile)}`
fetch(sampleCodeUrl) const { method, source, sample, path } = data
.then(async (codeResponse): Promise<OnSubmitProps> => { if (source === 'local' && path) {
if (!codeResponse.ok) { commandProps.specialPropsForLoadCommand
console.error( .onSubmit({
'Failed to fetch sample code:', name: '',
codeResponse.statusText targetPathToClone: path,
) method,
return Promise.reject(new Error('Failed to fetch sample code')) source,
} })
const code = await codeResponse.text() .catch(reportError)
return { } else if (source === 'kcl-samples' && sample) {
sampleName: data.sample.split('/')[0] + FILE_EXT, const pathParts = sample.split('/')
code, const projectPathPart = pathParts[0]
method: data.method, const primaryKclFile = pathParts[1]
} // local only
}) const sampleCodeUrl =
.then((props) => { (isDesktop() ? '.' : '') +
if (props?.code) { `/kcl-samples/${encodeURIComponent(
commandProps.specialPropsForSampleCommand projectPathPart
.onSubmit(props) )}/${encodeURIComponent(primaryKclFile)}`
fetch(sampleCodeUrl)
.then(async (codeResponse) => {
if (!codeResponse.ok) {
console.error(
'Failed to fetch sample code:',
codeResponse.statusText
)
return Promise.reject(new Error('Failed to fetch sample code'))
}
const code = await codeResponse.text()
commandProps.specialPropsForLoadCommand
.onSubmit({
name: data.sample.split('/')[0] + FILE_EXT,
content: code,
source,
method,
})
.catch(reportError) .catch(reportError)
} })
}) .catch(reportError)
.catch(reportError) } else {
toast.error("The command couldn't be submitted, check the arguments.")
}
}, },
args: { args: {
method: { source: {
inputType: 'options', inputType: 'options',
required: true, required: true,
skip: false,
defaultValue: 'local',
hidden: !isDesktop(),
options() {
return [
{
value: 'kcl-samples',
name: 'KCL Samples',
isCurrent: true,
},
...(isDesktop()
? [
{
value: 'local',
name: 'Local Drive',
isCurrent: false,
},
]
: []),
]
},
},
method: {
inputType: 'options',
skip: true, skip: true,
required: (commandContext) =>
!['local'].includes(
commandContext.argumentsToSubmit.source as string
),
hidden: (commandContext) =>
['local'].includes(
commandContext.argumentsToSubmit.source as string
),
defaultValue: isDesktop() ? 'newFile' : 'overwrite', defaultValue: isDesktop() ? 'newFile' : 'overwrite',
options() { options() {
return [ return [
@ -255,7 +295,14 @@ export function kclCommands(commandProps: KclCommandConfig): Command[] {
}, },
sample: { sample: {
inputType: 'options', inputType: 'options',
required: true, required: (commandContext) =>
!['local'].includes(
commandContext.argumentsToSubmit.source as string
),
hidden: (commandContext) =>
['local'].includes(
commandContext.argumentsToSubmit.source as string
),
valueSummary(value) { valueSummary(value) {
const MAX_LENGTH = 12 const MAX_LENGTH = 12
if (typeof value === 'string') { if (typeof value === 'string') {
@ -265,7 +312,15 @@ export function kclCommands(commandProps: KclCommandConfig): Command[] {
} }
return value return value
}, },
options: commandProps.specialPropsForSampleCommand.providedOptions, options: commandProps.specialPropsForLoadCommand.providedOptions,
},
path: {
inputType: 'path',
valueSummary: (value) => window.electron.path.basename(value),
required: (commandContext) =>
['local'].includes(
commandContext.argumentsToSubmit.source as string
),
}, },
}, },
}, },

View File

@ -3,6 +3,7 @@ import type { EventFrom, StateFrom } from 'xstate'
import type { CustomIconName } from '@src/components/CustomIcon' import type { CustomIconName } from '@src/components/CustomIcon'
import { createLiteral } from '@src/lang/create' import { createLiteral } from '@src/lang/create'
import { isDesktop } from '@src/lib/isDesktop'
import { commandBarActor } from '@src/machines/commandBarMachine' import { commandBarActor } from '@src/machines/commandBarMachine'
import type { modelingMachine } from '@src/machines/modelingMachine' import type { modelingMachine } from '@src/machines/modelingMachine'
import { import {
@ -337,6 +338,50 @@ export const toolbarConfig: Record<ToolbarModeName, ToolbarMode> = {
links: [{ label: 'KCL docs', url: 'https://zoo.dev/docs/kcl/helix' }], links: [{ label: 'KCL docs', url: 'https://zoo.dev/docs/kcl/helix' }],
}, },
'break', 'break',
{
id: 'modules',
array: [
{
id: 'insert',
onClick: () =>
commandBarActor.send({
type: 'Find and select command',
data: { name: 'Insert', groupId: 'code' },
}),
hotkey: 'I',
icon: 'import',
status: DEV || IS_NIGHTLY_OR_DEBUG ? 'available' : 'kcl-only',
disabled: () => !isDesktop(),
title: 'Insert',
description: 'Insert from a file in the current project directory',
links: [
{
label: 'API docs',
url: 'https://zoo.dev/docs/kcl/import',
},
],
},
{
id: 'transform',
icon: 'angle',
status: 'kcl-only',
title: 'Transform',
description: 'Apply a translation and/or rotation to a module',
onClick: () => undefined,
links: [
{
label: 'API docs',
url: 'https://zoo.dev/docs/kcl/translate',
},
{
label: 'API docs',
url: 'https://zoo.dev/docs/kcl/rotate',
},
],
},
],
},
'break',
{ {
id: 'ai', id: 'ai',
array: [ array: [

View File

@ -24,8 +24,7 @@ export type MenuLabels =
| 'File.Sign out' | 'File.Sign out'
| 'File.Create new file' | 'File.Create new file'
| 'File.Create new folder' | 'File.Create new folder'
| 'File.Load a sample model' | 'File.Load external model'
| 'File.Insert from project file'
| 'File.Export current part' | 'File.Export current part'
| 'File.Share current part (via Zoo link)' | 'File.Share current part (via Zoo link)'
| 'File.Preferences.Project settings' | 'File.Preferences.Project settings'
@ -40,6 +39,7 @@ export type MenuLabels =
| 'Design.Apply modification feature.Fillet' | 'Design.Apply modification feature.Fillet'
| 'Design.Apply modification feature.Chamfer' | 'Design.Apply modification feature.Chamfer'
| 'Design.Apply modification feature.Shell' | 'Design.Apply modification feature.Shell'
| 'Design.Insert from project file'
| 'Design.Create with Zoo Text-To-CAD' | 'Design.Create with Zoo Text-To-CAD'
| 'Design.Modify with Zoo Text-To-CAD' | 'Design.Modify with Zoo Text-To-CAD'
| 'View.Command Palette...' | 'View.Command Palette...'

View File

@ -122,6 +122,16 @@ export const modelingDesignRole = (
], ],
}, },
{ type: 'separator' }, { type: 'separator' },
{
label: 'Insert from project file',
id: 'Design.Insert from project file',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'Design.Insert from project file',
})
},
},
{ type: 'separator' },
{ {
label: 'Create with Zoo Text-To-CAD', label: 'Create with Zoo Text-To-CAD',
id: 'Design.Create with Zoo Text-To-CAD', id: 'Design.Create with Zoo Text-To-CAD',

View File

@ -148,21 +148,11 @@ export const modelingFileRole = (
// Appears to be only Windows and Mac OS specific. Linux does not have support // Appears to be only Windows and Mac OS specific. Linux does not have support
{ type: 'separator' }, { type: 'separator' },
{ {
label: 'Load a sample model', label: 'Load external model',
id: 'File.Load a sample model', id: 'File.Load external model',
click: () => { click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', { typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'File.Load a sample model', menuLabel: 'File.Load external model',
})
},
},
{ type: 'separator' },
{
label: 'Insert from project file',
id: 'File.Insert from project file',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'File.Insert from project file',
}) })
}, },
}, },

View File

@ -92,12 +92,12 @@ export function modelingMenuCallbackMostActions(
}).catch(reportRejection) }).catch(reportRejection)
} else if (data.menuLabel === 'File.Preferences.User default units') { } else if (data.menuLabel === 'File.Preferences.User default units') {
navigate(filePath + PATHS.SETTINGS_USER + '#defaultUnit') navigate(filePath + PATHS.SETTINGS_USER + '#defaultUnit')
} else if (data.menuLabel === 'File.Insert from project file') { } else if (data.menuLabel === 'File.Load external model') {
commandBarActor.send({ commandBarActor.send({
type: 'Find and select command', type: 'Find and select command',
data: { data: {
groupId: 'code', groupId: 'code',
name: 'Insert', name: 'load-external-model',
}, },
}) })
} else if (data.menuLabel === 'File.Export current part') { } else if (data.menuLabel === 'File.Export current part') {
@ -108,14 +108,6 @@ export function modelingMenuCallbackMostActions(
name: 'Export', name: 'Export',
}, },
}) })
} else if (data.menuLabel === 'File.Load a sample model') {
commandBarActor.send({
type: 'Find and select command',
data: {
groupId: 'code',
name: 'open-kcl-example',
},
})
} else if (data.menuLabel === 'File.Create new file') { } else if (data.menuLabel === 'File.Create new file') {
// NO OP. A safe command bar create new file is not implemented yet. // NO OP. A safe command bar create new file is not implemented yet.
} else if (data.menuLabel === 'Edit.Modify with Zoo Text-To-CAD') { } else if (data.menuLabel === 'Edit.Modify with Zoo Text-To-CAD') {
@ -256,6 +248,14 @@ export function modelingMenuCallbackMostActions(
type: 'Find and select command', type: 'Find and select command',
data: { name: 'Shell', groupId: 'modeling' }, data: { name: 'Shell', groupId: 'modeling' },
}) })
} else if (data.menuLabel === 'Design.Insert from project file') {
commandBarActor.send({
type: 'Find and select command',
data: {
groupId: 'code',
name: 'Insert',
},
})
} else if (data.menuLabel === 'Design.Create with Zoo Text-To-CAD') { } else if (data.menuLabel === 'Design.Create with Zoo Text-To-CAD') {
commandBarActor.send({ commandBarActor.send({
type: 'Find and select command', type: 'Find and select command',

View File

@ -21,13 +21,12 @@ type FileRoleLabel =
| 'Sign out' | 'Sign out'
| 'Theme' | 'Theme'
| 'Theme color' | 'Theme color'
| 'Insert from project file'
| 'Export current part' | 'Export current part'
| 'Create new file' | 'Create new file'
| 'Create new folder' | 'Create new folder'
| 'Share current part (via Zoo link)' | 'Share current part (via Zoo link)'
| 'Project settings' | 'Project settings'
| 'Load a sample model' | 'Load external model'
| 'User default units' | 'User default units'
type EditRoleLabel = type EditRoleLabel =
@ -82,6 +81,7 @@ type ViewRoleLabel =
type DesignRoleLabel = type DesignRoleLabel =
| 'Design' | 'Design'
| 'Create a parameter' | 'Create a parameter'
| 'Insert from project file'
| 'Create with Zoo Text-To-CAD' | 'Create with Zoo Text-To-CAD'
| 'Start sketch' | 'Start sketch'
| 'Create an offset plane' | 'Create an offset plane'