Reunite split sidebar, add ability to register action buttons to it (#3100)

* Rework ribbon to support panes and actions

* Restore nice focus-within highlighting

* A better export icon

* Fix up some issues with tests due to sidebar and tooltip tweaks

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

* Re-run CI

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Frank Noirot
2024-07-24 22:02:16 -04:00
committed by GitHub
parent 385589ddf9
commit ea0a3ac3ba
26 changed files with 218 additions and 221 deletions

View File

@ -3864,10 +3864,7 @@ test.describe('Regression tests', () => {
await u.waitForAuthSkipAppStart()
// expand variables section
const variablesTabButton = page.getByRole('tab', {
name: 'Variables',
exact: false,
})
const variablesTabButton = page.getByTestId('Variables')
await variablesTabButton.click()
// can find sketch001 in the variables summary (pretty-json-container, makes sure we're not looking in the code editor)
@ -3892,10 +3889,7 @@ test.describe('Regression tests', () => {
await u.waitForAuthSkipAppStart()
const variablesTabButton = page.getByRole('tab', {
name: 'Variables',
exact: false,
})
const variablesTabButton = page.getByTestId('Variables')
await variablesTabButton.click()
// expect to see "myVar:5"
await expect(
@ -7777,7 +7771,7 @@ test('Basic default modeling and sketch hotkeys work', async ({ page }) => {
await u.closeDebugPanel()
const codePane = page.getByRole('textbox').locator('div')
const codePaneButton = page.getByRole('tab', { name: 'KCL Code' })
const codePaneButton = page.getByTestId('KCL Code')
const lineButton = page.getByRole('button', { name: 'Line' })
const arcButton = page.getByRole('button', { name: 'Tangential Arc' })
const extrudeButton = page.getByRole('button', { name: 'Extrude' })

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

View File

@ -58,44 +58,40 @@ async function waitForDefaultPlanesToBeVisible(page: Page) {
}
async function openKclCodePanel(page: Page) {
const paneLocator = page.getByRole('tab', { name: 'KCL Code', exact: false })
const isOpen = (await paneLocator?.getAttribute('aria-selected')) === 'true'
const paneLocator = page.getByTestId('KCL Code')
const isOpen = (await paneLocator?.getAttribute('aria-pressed')) === 'true'
if (!isOpen) {
await paneLocator.click()
await paneLocator.and(page.locator('[aria-selected="true"]')).waitFor()
await expect(paneLocator).toHaveAttribute('aria-pressed', 'true')
}
}
async function closeKclCodePanel(page: Page) {
const paneLocator = page.getByRole('tab', { name: 'KCL Code', exact: false })
const isOpen = (await paneLocator?.getAttribute('aria-selected')) === 'true'
const paneLocator = page.getByTestId('KCL Code')
const isOpen = (await paneLocator?.getAttribute('aria-pressed')) === 'true'
if (isOpen) {
await paneLocator.click()
await paneLocator
.and(page.locator(':not([aria-selected="true"])'))
.waitFor()
await expect(paneLocator).not.toHaveAttribute('aria-pressed', 'true')
}
}
async function openDebugPanel(page: Page) {
const debugLocator = page.getByRole('tab', { name: 'Debug', exact: false })
const isOpen = (await debugLocator?.getAttribute('aria-selected')) === 'true'
const debugLocator = page.getByTestId('Debug')
const isOpen = (await debugLocator?.getAttribute('aria-pressed')) === 'true'
if (!isOpen) {
await debugLocator.click()
await debugLocator.and(page.locator('[aria-selected="true"]')).waitFor()
await expect(debugLocator).toHaveAttribute('aria-pressed', 'true')
}
}
async function closeDebugPanel(page: Page) {
const debugLocator = page.getByRole('tab', { name: 'Debug', exact: false })
const isOpen = (await debugLocator?.getAttribute('aria-selected')) === 'true'
const debugLocator = page.getByTestId('Debug')
const isOpen = (await debugLocator?.getAttribute('aria-pressed')) === 'true'
if (isOpen) {
await debugLocator.click()
await debugLocator
.and(page.locator(':not([aria-selected="true"])'))
.waitFor()
await expect(debugLocator).not.toHaveAttribute('aria-pressed', 'true')
}
}
@ -471,10 +467,11 @@ export const doExport = async (
page: Page
): Promise<Paths> => {
await page.getByRole('button', { name: APP_NAME }).click()
await expect(
page.getByRole('button', { name: 'Export', exact: false })
).toBeVisible()
await page.getByRole('button', { name: 'Export', exact: false }).click()
const exportMenuButton = page.getByRole('button', {
name: 'Export current part',
})
await expect(exportMenuButton).toBeVisible()
await exportMenuButton.click()
await expect(page.getByTestId('command-bar')).toBeVisible()
// Go through export via command bar

View File

@ -223,6 +223,16 @@ const CustomIconMap = {
/>
</svg>
),
floppyDiskArrow: (
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M4 4H4.5L7 4L13 4L13.2071 4L13.3536 4.14645L15.8536 6.64645L16 6.79289V7V11.3773C15.6992 11.1362 15.3628 10.9376 15 10.7908V7.20711L13.5 5.70711V7V7.5L13 7.5L7 7.5L6.5 7.5V7V5L5 5V15H6.5V10V9.5H7H13H13.5V10V10.5C13.1547 10.5 12.8196 10.5438 12.5 10.626V10.5H7.5V15H9.53095C9.57451 15.3493 9.66311 15.6847 9.79076 16H7H4.5H4V15.5V4.5V4ZM7.5 5V6.5L12.5 6.5V5L7.5 5ZM16.3904 14.1877L14.3904 11.6877L13.6096 12.3123L14.9597 14H11V15H14.9597L13.6096 16.6877L14.3904 17.3123L16.3904 14.8123L16.6403 14.5L16.3904 14.1877Z"
fill="currentColor"
/>
</svg>
),
folder: (
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path

View File

@ -1,5 +1,5 @@
.panel {
@apply relative z-0 rounded-r max-w-full h-full flex-1;
@apply relative z-0 rounded-r max-w-full flex-auto;
display: grid;
grid-template-rows: auto 1fr;
@apply bg-chalkboard-10/50 focus-within:bg-chalkboard-10/90 backdrop-blur-sm border border-chalkboard-20;

View File

@ -3,10 +3,14 @@ import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { useModelingContext } from 'hooks/useModelingContext'
import { ActionButton } from 'components/ActionButton'
import Tooltip from 'components/Tooltip'
import { CustomIconName } from 'components/CustomIcon'
import { IconDefinition } from '@fortawesome/free-solid-svg-icons'
import { ActionIcon } from 'components/ActionIcon'
export interface ModelingPaneProps
extends React.PropsWithChildren,
React.HTMLAttributes<HTMLDivElement> {
icon?: CustomIconName | IconDefinition
title: string
Menu?: React.ReactNode | React.FC
detailsTestId?: string
@ -14,13 +18,25 @@ export interface ModelingPaneProps
}
export const ModelingPaneHeader = ({
icon,
title,
Menu,
onClose,
}: Pick<ModelingPaneProps, 'title' | 'Menu' | 'onClose'>) => {
}: Pick<ModelingPaneProps, 'icon' | 'title' | 'Menu' | 'onClose'>) => {
return (
<div className={styles.header}>
<div className="flex gap-2 items-center flex-1">{title}</div>
<div className="flex gap-2 items-center flex-1">
{icon && (
<ActionIcon
icon={icon}
className="p-1"
size="sm"
iconClassName="!text-chalkboard-80 dark:!text-chalkboard-30"
bgClassName="!bg-transparent"
/>
)}
<span>{title}</span>
</div>
{Menu instanceof Function ? <Menu /> : Menu}
<ActionButton
Element="button"
@ -42,6 +58,7 @@ export const ModelingPaneHeader = ({
export const ModelingPane = ({
title,
icon,
id,
children,
className,
@ -63,14 +80,19 @@ export const ModelingPane = ({
data-testid={detailsTestId}
id={id}
className={
'group-focus-within:border-primary dark:group-focus-within:border-chalkboard-50 ' +
'focus-within:border-primary dark:focus-within:border-chalkboard-50 ' +
pointerEventsCssClass +
styles.panel +
' group ' +
(className || '')
}
>
<ModelingPaneHeader title={title} Menu={Menu} onClose={onClose} />
<ModelingPaneHeader
icon={icon}
title={title}
Menu={Menu}
onClose={onClose}
/>
<div className="relative w-full">{children}</div>
</section>
)

View File

@ -35,13 +35,13 @@ export type SidebarPane = {
hideOnPlatform?: 'desktop' | 'web'
}
export const topPanes: SidebarPane[] = [
export const sidebarPanes: SidebarPane[] = [
{
id: 'code',
title: 'KCL Code',
icon: faCode,
Content: KclEditorPane,
keybinding: 'shift + c',
keybinding: 'Shift + C',
Menu: KclEditorMenu,
},
{
@ -49,40 +49,37 @@ export const topPanes: SidebarPane[] = [
title: 'Project Files',
icon: 'folder',
Content: FileTreeInner,
keybinding: 'shift + f',
keybinding: 'Shift + F',
Menu: FileTreeMenu,
hideOnPlatform: 'web',
},
]
export const bottomPanes: SidebarPane[] = [
{
id: 'variables',
title: 'Variables',
icon: faSquareRootVariable,
Content: MemoryPane,
Menu: MemoryPaneMenu,
keybinding: 'shift + v',
keybinding: 'Shift + V',
},
{
id: 'logs',
title: 'Logs',
icon: faCodeCommit,
Content: LogsPane,
keybinding: 'shift + l',
keybinding: 'Shift + L',
},
{
id: 'kclErrors',
title: 'KCL Errors',
icon: faExclamationCircle,
Content: KclErrorsPane,
keybinding: 'shift + e',
keybinding: 'Shift + E',
},
{
id: 'debug',
title: 'Debug',
icon: faBugSlash,
Content: DebugPane,
keybinding: 'shift + d',
keybinding: 'Shift + D',
},
]

View File

@ -1,39 +1,84 @@
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { Resizable } from 're-resizable'
import { HTMLAttributes, useCallback, useEffect, useState } from 'react'
import { useCallback, useMemo } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import { Tab } from '@headlessui/react'
import {
SidebarPane,
SidebarType,
bottomPanes,
topPanes,
} from './ModelingPanes'
import { SidebarType, sidebarPanes } from './ModelingPanes'
import Tooltip from 'components/Tooltip'
import { ActionIcon } from 'components/ActionIcon'
import styles from './ModelingSidebar.module.css'
import { ModelingPane } from './ModelingPane'
import { isTauri } from 'lib/isTauri'
import { useModelingContext } from 'hooks/useModelingContext'
import { CustomIconName } from 'components/CustomIcon'
import { useCommandsContext } from 'hooks/useCommandsContext'
import { IconDefinition } from '@fortawesome/free-solid-svg-icons'
interface ModelingSidebarProps {
paneOpacity: '' | 'opacity-20' | 'opacity-40'
}
export function ModelingSidebar({ paneOpacity }: ModelingSidebarProps) {
const { commandBarSend } = useCommandsContext()
const { settings } = useSettingsAuthContext()
const onboardingStatus = settings.context.app.onboardingStatus
const { context } = useModelingContext()
const { send, context } = useModelingContext()
const pointerEventsCssClass =
context.store?.buttonDownInStream ||
onboardingStatus.current === 'camera' ||
context.store?.openPanes.length === 0
? 'pointer-events-none '
: 'pointer-events-auto '
const showDebugPanel = settings.context.modeling.showDebugPanel
const sidebarActions: SidebarAction[] = [
{
id: 'export',
title: 'Export part',
icon: 'floppyDiskArrow',
iconClassName: '!p-0',
keybinding: 'Ctrl + Shift + E',
action: () =>
commandBarSend({
type: 'Find and select command',
data: { name: 'Export', groupId: 'modeling' },
}),
},
]
// // Filter out the debug panel if it's not supposed to be shown
// // TODO: abstract out for allowing user to configure which panes to show
const filteredPanes = useMemo(
() =>
(showDebugPanel.current
? sidebarPanes
: sidebarPanes.filter((pane) => pane.id !== 'debug')
).filter(
(pane) =>
!pane.hideOnPlatform ||
(isTauri()
? pane.hideOnPlatform === 'web'
: pane.hideOnPlatform === 'desktop')
),
[sidebarPanes, showDebugPanel.current]
)
const togglePane = useCallback(
(newPane: SidebarType) => {
send({
type: 'Set context',
data: {
openPanes: context.store?.openPanes.includes(newPane)
? context.store?.openPanes.filter((pane) => pane !== newPane)
: [...context.store?.openPanes, newPane],
},
})
},
[context.store?.openPanes, send]
)
return (
<Resizable
className={`flex-1 flex flex-col z-10 my-2 pr-1 ${paneOpacity} ${pointerEventsCssClass}`}
className={`group flex-1 flex flex-col z-10 my-2 pr-1 ${paneOpacity} ${pointerEventsCssClass}`}
defaultSize={{
width: '550px',
height: 'auto',
@ -54,153 +99,64 @@ export function ModelingSidebar({ paneOpacity }: ModelingSidebarProps) {
}}
>
<div id="app-sidebar" className={styles.grid + ' flex-1'}>
<ModelingSidebarSection id="sidebar-top" panes={topPanes} />
<ModelingSidebarSection
id="sidebar-bottom"
panes={bottomPanes}
alignButtons="end"
/>
</div>
</Resizable>
)
}
interface ModelingSidebarSectionProps extends HTMLAttributes<HTMLDivElement> {
panes: SidebarPane[]
alignButtons?: 'start' | 'end'
}
function ModelingSidebarSection({
panes,
alignButtons = 'start',
className,
...props
}: ModelingSidebarSectionProps) {
const { settings } = useSettingsAuthContext()
const showDebugPanel = settings.context.modeling.showDebugPanel
const paneIds = panes.map((pane) => pane.id)
const { send, context } = useModelingContext()
const foundOpenPane = context.store?.openPanes.find((pane) =>
paneIds.includes(pane)
)
const [currentPane, setCurrentPane] = useState(
foundOpenPane || ('none' as SidebarType | 'none')
)
const togglePane = useCallback(
(newPane: SidebarType | 'none') => {
if (newPane === 'none') {
send({
type: 'Set context',
data: {
openPanes: context.store?.openPanes.filter(
(p) => p !== currentPane
),
},
})
setCurrentPane('none')
} else if (newPane === currentPane) {
setCurrentPane('none')
send({
type: 'Set context',
data: {
openPanes: context.store?.openPanes.filter((p) => p !== newPane),
},
})
} else {
send({
type: 'Set context',
data: {
openPanes: [
...context.store?.openPanes.filter((p) => p !== currentPane),
newPane,
],
},
})
setCurrentPane(newPane)
}
},
[context.store?.openPanes, send, currentPane, setCurrentPane]
)
// Filter out the debug panel if it's not supposed to be shown
// TODO: abstract out for allowing user to configure which panes to show
const filteredPanes = (
showDebugPanel.current ? panes : panes.filter((pane) => pane.id !== 'debug')
).filter(
(pane) =>
!pane.hideOnPlatform ||
(isTauri()
? pane.hideOnPlatform === 'web'
: pane.hideOnPlatform === 'desktop')
)
useEffect(() => {
if (
!showDebugPanel.current &&
currentPane === 'debug' &&
context.store?.openPanes.includes('debug')
) {
togglePane('debug')
}
}, [showDebugPanel.current, togglePane, context.store?.openPanes])
return (
<div className={'group contents ' + className} {...props}>
<Tab.Group
vertical
selectedIndex={
currentPane === 'none' ? 0 : paneIds.indexOf(currentPane) + 1
}
onChange={(index) => {
const newPane = index === 0 ? 'none' : paneIds[index - 1]
togglePane(newPane)
}}
>
<Tab.List
id={`${props.id}-ribbon`}
<ul
className={
'pointer-events-auto ' +
(alignButtons === 'start'
? 'justify-start self-start'
: 'justify-end self-end') +
(currentPane === 'none'
? ' rounded-r focus-within:!border-primary/50'
: ' border-r-0') +
' p-2 col-start-1 col-span-1 h-fit w-fit flex flex-col items-start gap-2 ' +
'bg-chalkboard-10 border border-solid border-chalkboard-20 dark:bg-chalkboard-90 dark:border-chalkboard-80 group-focus-within:border-primary dark:group-focus-within:border-chalkboard-50 ' +
(context.store?.openPanes.length === 1 && currentPane === 'none'
? 'pr-0.5'
: '')
(context.store?.openPanes.length === 0 ? 'rounded-r ' : '') +
'relative z-[2] pointer-events-auto p-0 col-start-1 col-span-1 h-fit w-fit flex flex-col ' +
'bg-chalkboard-10 border border-solid border-chalkboard-20 dark:bg-chalkboard-90 dark:border-chalkboard-80 group-focus-within:border-primary dark:group-focus-within:border-chalkboard-50 '
}
>
<ul
id="pane-buttons-section"
className={
'w-fit p-2 flex flex-col gap-2 ' +
(context.store?.openPanes.length >= 1 ? 'pr-0.5' : '')
}
>
<Tab key="none" className="sr-only">
No panes open
</Tab>
{filteredPanes.map((pane) => (
<ModelingPaneButton
key={pane.id}
paneConfig={pane}
currentPane={currentPane}
togglePane={() => togglePane(pane.id)}
paneIsOpen={context.store?.openPanes.includes(pane.id)}
onClick={() => togglePane(pane.id)}
aria-pressed={context.store?.openPanes.includes(pane.id)}
/>
))}
</Tab.List>
<Tab.Panels
id={`${props.id}-pane`}
as="article"
</ul>
<hr className="w-full border-chalkboard-20 dark:border-chalkboard-80" />
<ul id="sidebar-actions" className="w-fit p-2 flex flex-col gap-2">
{sidebarActions.map((action) => (
<ModelingPaneButton
key={action.id}
paneConfig={{
id: action.id,
title: action.title,
icon: action.icon,
keybinding: action.keybinding,
iconClassName: action.iconClassName,
iconSize: 'md',
}}
paneIsOpen={false}
onClick={action.action}
/>
))}
</ul>
</ul>
<ul
id="pane-section"
className={
'col-start-2 col-span-1 ' +
(context.store?.openPanes.length === 1
? currentPane !== 'none'
'ml-[-1px] col-start-2 col-span-1 flex flex-col gap-2 ' +
(context.store?.openPanes.length >= 1
? `row-start-1 row-end-3`
: `hidden`
: ``)
: `hidden`)
}
>
<Tab.Panel key="none" />
{filteredPanes.map((pane) => (
<Tab.Panel key={pane.id} className="h-full">
{filteredPanes
.filter((pane) => context?.store.openPanes.includes(pane.id))
.map((pane) => (
<ModelingPane
key={pane.id}
icon={pane.icon}
id={`${pane.id}-pane`}
title={pane.title}
Menu={pane.Menu}
@ -212,55 +168,76 @@ function ModelingSidebarSection({
pane.Content
)}
</ModelingPane>
</Tab.Panel>
))}
</Tab.Panels>
</Tab.Group>
</ul>
</div>
</Resizable>
)
}
interface ModelingPaneButtonProps {
paneConfig: SidebarPane
currentPane: SidebarType | 'none'
togglePane: () => void
interface ModelingPaneButtonProps
extends React.HTMLAttributes<HTMLButtonElement> {
paneConfig: {
id: string
title: string
icon: CustomIconName | IconDefinition
keybinding: string
iconClassName?: string
iconSize?: 'sm' | 'md' | 'lg'
}
onClick: () => void
paneIsOpen: boolean
}
function ModelingPaneButton({
paneConfig,
currentPane,
togglePane,
onClick,
paneIsOpen,
...props
}: ModelingPaneButtonProps) {
useHotkeys(paneConfig.keybinding, togglePane, {
useHotkeys(paneConfig.keybinding, onClick, {
scopes: ['modeling'],
})
return (
<Tab
key={paneConfig.id}
className="pointer-events-auto flex items-center justify-center border-transparent dark:border-transparent p-0 m-0 rounded-sm !outline-none"
onClick={togglePane}
<button
className="pointer-events-auto flex items-center justify-center border-transparent dark:border-transparent p-0 m-0 rounded-sm !outline-0 focus-visible:border-primary"
onClick={onClick}
data-testid={paneConfig.title}
{...props}
>
<ActionIcon
icon={paneConfig.icon}
className="p-1"
size="sm"
className={'p-1 ' + paneConfig.iconClassName || ''}
size={paneConfig.iconSize || 'sm'}
iconClassName={
paneConfig.id === currentPane
paneIsOpen
? ' !text-chalkboard-10'
: '!text-chalkboard-80 dark:!text-chalkboard-30'
}
bgClassName={
'rounded-sm ' +
(paneConfig.id === currentPane ? '!bg-primary' : '!bg-transparent')
'rounded-sm ' + (paneIsOpen ? '!bg-primary' : '!bg-transparent')
}
/>
<Tooltip position="right" hoverOnly delay={800}>
<span>{paneConfig.title}</span>
<br />
<span className="text-xs capitalize">{paneConfig.keybinding}</span>
<Tooltip
position="right"
className="!max-w-none flex gap-4 items-center justify-between"
hoverOnly
delay={800}
>
<span className="flex-none">{paneConfig.title}</span>
<kbd className="hotkey">{paneConfig.keybinding}</kbd>
</Tooltip>
</Tab>
</button>
)
}
export type SidebarAction = {
id: string
title: string
icon: CustomIconName
iconClassName?: string // Just until we get rid of FontAwesome icons
keybinding: string
action: () => void
hideOnPlatform?: 'desktop' | 'web'
}

View File

@ -88,7 +88,7 @@ export const modelingMachineCommandConfig: StateMachineCommandSetConfig<
],
Export: {
description: 'Export the current model.',
icon: 'exportFile',
icon: 'floppyDiskArrow',
needsReview: true,
args: {
type: {