Migrate Text-to-CAD (edit) to multi-file endpoint (#6066)

* start of migrate to multi file endpoint

* get some relative path stuff sorted

* blobifying files, and making selections work with imports working

* add write to disk

* warn about big projects

* update known circular

* update snapshot

* remove log

* tweak selection filters

* Update src/components/ModelingMachineProvider.tsx

Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>

* fmt

* fix one thing

* typo

* raw dog form data like a fucking peasant

* remove fake data

* fmt

* steal Kevin's stuff

* good progress

* clean up

* fix writing to files when response returns

* comment the terriable code

* push fix of sorts

* better fix

* spot of clean up

* fix: Needed to support the bad request flow, the toast will hang forever, the return control flows don't dismiss a forever toast

* fix: handling more error flows by dismissing the toast

* chore: leaving a comment for a confusing workflow

* fix: trying to clean up some async logic

* fix: trying to fix a few things at once...

* fix: fixing toast success

* fix: how did this desync?

* fix: removing useless logic, we write to disk ahead of time, the continue is to say ya no problem

* fix: typo

* Change back to `spawnChild`, forego `actors` by reference

* fix: updating PR comments

* fix: found a bug with paths from rust! it is actually OS paths!

* fix: updated type still is failing tsc

* fix: the type of the machine was wrong, we always set it to at least ''

* fix: idk man

* Fix happy path test (button labels)

---------

Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>
Co-authored-by: Kevin Nadro <kevin@zoo.dev>
Co-authored-by: Frank Noirot <frankjohnson1993@gmail.com>
Co-authored-by: Kevin Nadro <nadr0@users.noreply.github.com>
Co-authored-by: Pierre Jacquier <pierre@zoo.dev>
This commit is contained in:
Kurt Hutten
2025-05-08 03:54:40 +10:00
committed by GitHub
parent f938364d54
commit 43d5a72514
19 changed files with 931 additions and 307 deletions

View File

@ -224,7 +224,51 @@ export class CmdBarFixture {
// Create a handler function that saves request bodies to a file
const requestHandler = (route: Route, request: Request) => {
try {
const requestBody = request.postDataJSON()
// Get the raw post data
const postData = request.postData()
if (!postData) {
console.error('No post data found in request')
return
}
// Extract all parts from the multipart form data
const boundary = postData.match(/------WebKitFormBoundary[^\r\n]*/)?.[0]
if (!boundary) {
console.error('Could not find form boundary')
return
}
const parts = postData.split(boundary).filter((part) => part.trim())
const files: Record<string, string> = {}
let eventData = null
for (const part of parts) {
// Skip the final boundary marker
if (part.startsWith('--')) continue
const nameMatch = part.match(/name="([^"]+)"/)
if (!nameMatch) continue
const name = nameMatch[1]
const content = part.split(/\r?\n\r?\n/)[1]?.trim()
if (!content) continue
if (name === 'event') {
eventData = JSON.parse(content)
} else {
files[name] = content
}
}
if (!eventData) {
console.error('Could not find event JSON in multipart form data')
return
}
const requestBody = {
...eventData,
files,
}
// Ensure directory exists
const dir = path.dirname(outputPath)
@ -245,7 +289,10 @@ export class CmdBarFixture {
}
// Start monitoring requests
await this.page.route('**/ml/text-to-cad/iteration', requestHandler)
await this.page.route(
'**/ml/text-to-cad/multi-file/iteration',
requestHandler
)
console.log(
`Monitoring text-to-cad API requests. Output will be saved to: ${outputPath}`

View File

@ -1,4 +1,6 @@
import { expect, test } from '@e2e/playwright/zoo-test'
import path from 'path'
import fsp from 'fs/promises'
/* eslint-disable jest/no-conditional-expect */
@ -22,7 +24,8 @@ import { expect, test } from '@e2e/playwright/zoo-test'
*
*/
const file = `sketch001 = startSketchOn(XZ)
const fileWithImport = `import "b.kcl" as b
sketch001 = startSketchOn(XZ)
profile001 = startProfile(sketch001, at = [57.81, 250.51])
|> line(end = [121.13, 56.63], tag = $seg02)
|> line(end = [83.37, -34.61], tag = $seg01)
@ -39,7 +42,10 @@ sketch002 = startSketchOn(XZ)
|> line(endAbsolute = [profileStartX(%), profileStartY(%)])
|> close()
extrude002 = extrude(sketch002, length = 50)
sketch003 = startSketchOn(XY)
b
`
const importedFile = `sketch003 = startSketchOn(XY)
|> startProfile(at = [52.92, 157.81])
|> angledLine(angle = 0, length = 176.4, tag = $rectangleSegmentA001)
|> angledLine(
@ -50,17 +56,28 @@ sketch003 = startSketchOn(XY)
|> angledLine(angle = segAng(rectangleSegmentA001), length = -segLen(rectangleSegmentA001), tag = $rectangleSegmentC001)
|> line(endAbsolute = [profileStartX(%), profileStartY(%)])
|> close()
extrude003 = extrude(sketch003, length = 20)
extrude(sketch003, length = 20)
`
test.describe('edit with AI example snapshots', () => {
test(
`change colour`,
{ tag: '@snapshot' },
// TODO this is more of a snapshot, but atm it needs to be manually run locally to update the files
{ tag: ['@electron'] },
async ({ context, homePage, cmdBar, editor, page, scene }) => {
await context.addInitScript((file) => {
localStorage.setItem('persistCode', file)
}, file)
await homePage.goToModelingScene()
const project = 'test-dir'
await context.folderSetupFn(async (dir) => {
const projectDir = path.join(dir, project)
await fsp.mkdir(projectDir, { recursive: true })
// Create the imported file
await fsp.writeFile(path.join(projectDir, 'b.kcl'), importedFile)
// Create the main file that imports
await fsp.writeFile(path.join(projectDir, 'main.kcl'), fileWithImport)
})
await homePage.openProject(project)
await scene.settled(cmdBar)
const body1CapCoords = { x: 571, y: 351 }

View File

@ -1,4 +1,6 @@
import { expect, test } from '@e2e/playwright/zoo-test'
import * as fsp from 'fs/promises'
import * as path from 'path'
/* eslint-disable jest/no-conditional-expect */
@ -50,31 +52,28 @@ test.describe('Prompt-to-edit tests', () => {
page,
scene,
}) => {
await context.addInitScript((file) => {
localStorage.setItem('persistCode', file)
}, file)
await homePage.goToModelingScene()
await context.folderSetupFn(async (dir) => {
const projectDir = path.join(dir, 'test-project')
await fsp.mkdir(projectDir, { recursive: true })
await fsp.writeFile(path.join(projectDir, 'main.kcl'), file)
})
await homePage.openProject('test-project')
await scene.settled(cmdBar)
const body1CapCoords = { x: 571, y: 311 }
const greenCheckCoords = { x: 565, y: 305 }
const body2WallCoords = { x: 609, y: 153 }
const [clickBody1Cap] = scene.makeMouseHelpers(
body1CapCoords.x,
body1CapCoords.y
)
const yellow: [number, number, number] = [179, 179, 131]
const green: [number, number, number] = [128, 194, 88]
const notGreen: [number, number, number] = [132, 132, 132]
const body2NotGreen: [number, number, number] = [88, 88, 88]
const submittingToast = page.getByText(
'Submitting to Text-to-CAD API...'
)
const successToast = page.getByText('Prompt to edit successful')
const acceptBtn = page.getByRole('button', {
name: 'checkmark Accept',
name: 'checkmark Continue',
})
const rejectBtn = page.getByRole('button', { name: 'close Reject' })
const rejectBtn = page.getByRole('button', { name: 'close Revert' })
await test.step('wait for scene to load select body and check selection came through', async () => {
await scene.expectPixelColor([134, 134, 134], body1CapCoords, 15)
@ -103,32 +102,21 @@ test.describe('Prompt-to-edit tests', () => {
})
await test.step('verify initial change', async () => {
await scene.expectPixelColor(green, greenCheckCoords, 20)
await scene.expectPixelColor(body2NotGreen, body2WallCoords, 15)
await editor.expectEditor.toContain('appearance(')
})
if (!shouldReject) {
await test.step('check accept works and can be "undo"ed', async () => {
await test.step('check accept works', async () => {
await acceptBtn.click()
await expect(successToast).not.toBeVisible()
await scene.expectPixelColor(green, greenCheckCoords, 15)
await editor.expectEditor.toContain('appearance(')
// ctrl-z works after accepting
await page.keyboard.down('ControlOrMeta')
await page.keyboard.press('KeyZ')
await page.keyboard.up('ControlOrMeta')
await editor.expectEditor.not.toContain('appearance(')
await scene.expectPixelColor(notGreen, greenCheckCoords, 15)
})
} else {
await test.step('check reject works', async () => {
await rejectBtn.click()
await expect(successToast).not.toBeVisible()
await scene.expectPixelColor(notGreen, greenCheckCoords, 15)
await editor.expectEditor.not.toContain('appearance(')
})
}
@ -208,7 +196,7 @@ test.describe('Prompt-to-edit tests', () => {
const submittingToast = page.getByText('Submitting to Text-to-CAD API...')
const successToast = page.getByText('Prompt to edit successful')
const acceptBtn = page.getByRole('button', { name: 'checkmark Accept' })
const acceptBtn = page.getByRole('button', { name: 'checkmark Continue' })
await test.step('wait for scene to load and select code in editor', async () => {
// Find and select the text "sketch002" in the editor
@ -277,7 +265,7 @@ test.describe('Prompt-to-edit tests', () => {
const submittingToast = page.getByText('Submitting to Text-to-CAD API...')
const successToast = page.getByText('Prompt to edit successful')
const acceptBtn = page.getByRole('button', { name: 'checkmark Accept' })
const acceptBtn = page.getByRole('button', { name: 'checkmark Continue' })
await test.step('select multiple bodies and fire prompt', async () => {
// Initial color check

View File

@ -5,9 +5,9 @@
• Circular Dependencies
1) src/lang/std/sketch.ts -> src/lang/modifyAst.ts
2) src/lang/std/sketch.ts -> src/lang/modifyAst.ts -> src/lang/std/sketchcombos.ts
3) src/lib/singletons.ts -> src/editor/manager.ts -> src/lib/selections.ts
3) src/hooks/useModelingContext.ts -> src/components/ModelingMachineProvider.tsx -> src/components/Toolbar/setAngleLength.tsx -> src/components/SetAngleLengthModal.tsx -> src/lib/useCalculateKclExpression.ts
4) src/lib/singletons.ts -> src/editor/manager.ts -> src/lib/selections.ts
5) src/lib/singletons.ts -> src/lang/codeManager.ts
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
5) src/lib/singletons.ts -> src/editor/manager.ts -> src/lib/selections.ts
6) src/lib/singletons.ts -> src/lang/codeManager.ts
7) src/lib/singletons.ts -> src/clientSideScene/sceneEntities.ts -> src/clientSideScene/segments.ts -> src/components/Toolbar/angleLengthInfo.ts
8) src/lib/singletons.ts -> src/clientSideScene/sceneEntities.ts -> src/clientSideScene/segments.ts

View File

@ -21,30 +21,19 @@ import type { Plane } from '@rust/kcl-lib/bindings/Plane'
import { useAppState } from '@src/AppState'
import { letEngineAnimateAndSyncCamAfter } from '@src/clientSideScene/CameraControls'
import {
SEGMENT_BODIES,
getParentGroup,
} from '@src/clientSideScene/sceneConstants'
import type { MachineManager } from '@src/components/MachineManagerProvider'
import { MachineManagerContext } from '@src/components/MachineManagerProvider'
import type { SidebarType } from '@src/components/ModelingSidebar/ModelingPanes'
import { applyConstraintIntersect } from '@src/components/Toolbar/Intersect'
import { applyConstraintAbsDistance } from '@src/components/Toolbar/SetAbsDistance'
import {
angleBetweenInfo,
applyConstraintAngleBetween,
} from '@src/components/Toolbar/SetAngleBetween'
import { applyConstraintHorzVertDistance } from '@src/components/Toolbar/SetHorzVertDistance'
import {
applyConstraintAngleLength,
applyConstraintLength,
} from '@src/components/Toolbar/setAngleLength'
import {
SEGMENT_BODIES,
getParentGroup,
} from '@src/clientSideScene/sceneConstants'
import { useFileContext } from '@src/hooks/useFileContext'
import {
useMenuListener,
useSketchModeMenuEnableDisable,
} from '@src/hooks/useMenu'
import { useNetworkContext } from '@src/hooks/useNetworkContext'
import useStateMachineCommands from '@src/hooks/useStateMachineCommands'
import { useKclContext } from '@src/lang/KclProvider'
import { updateModelingState } from '@src/lang/modelingWorkflows'
@ -70,34 +59,33 @@ import {
getPlaneFromArtifact,
} from '@src/lang/std/artifactGraph'
import {
EngineConnectionEvents,
EngineConnectionStateType,
EngineConnectionEvents,
} from '@src/lang/std/engineConnection'
import { err, reportRejection, trap, reject } from '@src/lib/trap'
import { isNonNullable, platform, uuidv4 } from '@src/lib/utils'
import { promptToEditFlow } from '@src/lib/promptToEdit'
import type { FileMeta } from '@src/lib/types'
import { kclEditorActor } from '@src/machines/kclEditorMachine'
import { commandBarActor } from '@src/lib/singletons'
import { useToken, useSettings } from '@src/lib/singletons'
import type { IndexLoaderData } from '@src/lib/types'
import {
crossProduct,
isCursorInSketchCommandRange,
updateSketchDetailsNodePaths,
} from '@src/lang/util'
import type {
KclValue,
PathToNode,
PipeExpression,
Program,
VariableDeclaration,
} from '@src/lang/wasm'
import { parse, recast, resultIsOk } from '@src/lang/wasm'
import type { ModelingCommandSchema } from '@src/lib/commandBarConfigs/modelingCommandConfig'
import { modelingMachineCommandConfig } from '@src/lib/commandBarConfigs/modelingCommandConfig'
import {
EXECUTION_TYPE_MOCK,
EXPORT_TOAST_MESSAGES,
MAKE_TOAST_MESSAGES,
EXECUTION_TYPE_MOCK,
FILE_EXT,
} from '@src/lib/constants'
import { exportMake } from '@src/lib/exportMake'
import { exportSave } from '@src/lib/exportSave'
import { promptToEditFlow } from '@src/lib/promptToEdit'
import type { Selections } from '@src/lib/selections'
import { handleSelectionBatch, updateSelections } from '@src/lib/selections'
import { isDesktop } from '@src/lib/isDesktop'
import type { FileEntry } from '@src/lib/project'
import type { WebContentSendPayload } from '@src/menu/channels'
import {
getPersistedContext,
modelingMachine,
modelingMachineDefaultContext,
} from '@src/machines/modelingMachine'
import {
codeManager,
editorManager,
@ -107,18 +95,39 @@ import {
sceneEntitiesManager,
sceneInfra,
} from '@src/lib/singletons'
import { err, reject, reportRejection, trap } from '@src/lib/trap'
import type { IndexLoaderData } from '@src/lib/types'
import { platform, uuidv4 } from '@src/lib/utils'
import { useSettings, useToken } from '@src/lib/singletons'
import { commandBarActor } from '@src/lib/singletons'
import { kclEditorActor } from '@src/machines/kclEditorMachine'
import type { MachineManager } from '@src/components/MachineManagerProvider'
import { MachineManagerContext } from '@src/components/MachineManagerProvider'
import {
getPersistedContext,
modelingMachine,
modelingMachineDefaultContext,
} from '@src/machines/modelingMachine'
import type { WebContentSendPayload } from '@src/menu/channels'
handleSelectionBatch,
updateSelections,
type Selections,
} from '@src/lib/selections'
import {
crossProduct,
isCursorInSketchCommandRange,
updateSketchDetailsNodePaths,
} from '@src/lang/util'
import {
modelingMachineCommandConfig,
type ModelingCommandSchema,
} from '@src/lib/commandBarConfigs/modelingCommandConfig'
import type {
KclValue,
PathToNode,
PipeExpression,
Program,
VariableDeclaration,
} from '@src/lang/wasm'
import { parse, recast, resultIsOk } from '@src/lang/wasm'
import { applyConstraintHorzVertDistance } from '@src/components/Toolbar/SetHorzVertDistance'
import {
angleBetweenInfo,
applyConstraintAngleBetween,
} from '@src/components/Toolbar/SetAngleBetween'
import { applyConstraintIntersect } from '@src/components/Toolbar/Intersect'
import { applyConstraintAbsDistance } from '@src/components/Toolbar/SetAbsDistance'
import type { SidebarType } from '@src/components/ModelingSidebar/ModelingPanes'
import { useNetworkContext } from '@src/hooks/useNetworkContext'
export const ModelingMachineContext = createContext(
{} as {
@ -1726,8 +1735,94 @@ export const ModelingMachineProvider = ({
}
),
'submit-prompt-edit': fromPromise(async ({ input }) => {
let projectFiles: FileMeta[] = [
{
type: 'kcl',
relPath: 'main.kcl',
absPath: 'main.kcl',
fileContents: codeManager.code,
execStateFileNamesIndex: 0,
},
]
const execStateNameToIndexMap: { [fileName: string]: number } = {}
Object.entries(kclManager.execState.filenames).forEach(
([index, val]) => {
if (val?.type === 'Local') {
execStateNameToIndexMap[val.value] = Number(index)
}
}
)
let basePath = ''
if (isDesktop() && context?.project?.children) {
basePath = context?.selectedDirectory?.path
const filePromises: Promise<FileMeta | null>[] = []
let uploadSize = 0
const recursivelyPushFilePromises = (files: FileEntry[]) => {
// mutates filePromises declared above, so this function definition should stay here
// if pulled out, it would need to be refactored.
for (const file of files) {
if (file.children !== null) {
// is directory
recursivelyPushFilePromises(file.children)
continue
}
const absolutePathToFileNameWithExtension = file.path
const fileNameWithExtension = window.electron.path.relative(
basePath,
absolutePathToFileNameWithExtension
)
const filePromise = window.electron
.readFile(absolutePathToFileNameWithExtension)
.then((file): FileMeta => {
uploadSize += file.byteLength
const decoder = new TextDecoder('utf-8')
const fileType = window.electron.path.extname(
absolutePathToFileNameWithExtension
)
if (fileType === FILE_EXT) {
return {
type: 'kcl',
absPath: absolutePathToFileNameWithExtension,
relPath: fileNameWithExtension,
fileContents: decoder.decode(file),
execStateFileNamesIndex:
execStateNameToIndexMap[
absolutePathToFileNameWithExtension
],
}
}
const blob = new Blob([file], {
type: 'application/octet-stream',
})
return {
type: 'other',
relPath: fileNameWithExtension,
data: blob,
}
})
.catch((e) => {
console.error('error reading file', e)
return null
})
filePromises.push(filePromise)
}
}
recursivelyPushFilePromises(context?.project?.children)
projectFiles = (await Promise.all(filePromises)).filter(
isNonNullable
)
const MB20 = 2 ** 20 * 20
if (uploadSize > MB20) {
toast.error(
'Your project exceeds 20Mb, this will slow down Text-to-CAD\nPlease remove any unnecessary files'
)
}
}
return await promptToEditFlow({
code: codeManager.code,
projectFiles,
prompt: input.prompt,
selections: input.selection,
token,

View File

@ -1,6 +1,6 @@
import type {
TextToCadIteration_type,
TextToCad_type,
TextToCadMultiFileIteration_type,
} from '@kittycad/lib/dist/types/src/models'
import { useCallback, useEffect, useRef } from 'react'
import toast from 'react-hot-toast'
@ -29,9 +29,17 @@ import { PATHS } from '@src/lib/paths'
import { codeManager, kclManager, systemIOActor } from '@src/lib/singletons'
import { sendTelemetry } from '@src/lib/textToCadTelemetry'
import { reportRejection } from '@src/lib/trap'
import {
SystemIOMachineEvents,
waitForIdleState,
} from '@src/machines/systemIO/utils'
import {
useProjectDirectoryPath,
useRequestedProjectName,
} from '@src/machines/systemIO/hooks'
import { commandBarActor } from '@src/lib/singletons'
import { SystemIOMachineEvents } from '@src/machines/systemIO/utils'
import { useProjectDirectoryPath } from '@src/machines/systemIO/hooks'
import type { FileMeta } from '@src/lib/types'
import type { RequestedKCLFile } from '@src/machines/systemIO/utils'
const CANVAS_SIZE = 128
const PROMPT_TRUNCATE_LENGTH = 128
@ -478,14 +486,17 @@ export function ToastPromptToEditCadSuccess({
toastId,
token,
data,
oldCode,
oldCodeWebAppOnly: oldCode,
oldFiles,
}: {
toastId: string
oldCode: string
data: TextToCadIteration_type
oldCodeWebAppOnly: string
oldFiles: FileMeta[]
data: TextToCadMultiFileIteration_type
token?: string
}) {
const modelId = data.id
const requestedProjectName = useRequestedProjectName()
return (
<div className="flex gap-4 min-w-80">
@ -514,13 +525,37 @@ export function ToastPromptToEditCadSuccess({
data-negative-button={'reject'}
name={'Reject'}
onClick={() => {
void (async () => {
sendTelemetry(modelId, 'rejected', token).catch(reportRejection)
// revert to before the prompt-to-edit
if (isDesktop()) {
const requestedFiles: RequestedKCLFile[] = []
for (const file of oldFiles) {
if (file.type !== 'kcl') {
// only need to write the kcl files
// as the endpoint would have never overwritten other file types
continue
}
requestedFiles.push({
requestedCode: file.fileContents,
requestedFileName: file.relPath,
requestedProjectName: requestedProjectName.name,
})
}
toast.dismiss(toastId)
await writeOverFilesAndExecute({
requestedFiles: requestedFiles,
projectName: requestedProjectName.name,
})
} else {
codeManager.updateCodeEditor(oldCode)
kclManager.executeCode().catch(reportRejection)
toast.dismiss(toastId)
}
})()
}}
>
{'Reject'}
{'Revert'}
</ActionButton>
<ActionButton
@ -532,23 +567,38 @@ export function ToastPromptToEditCadSuccess({
onClick={() => {
sendTelemetry(modelId, 'accepted', token).catch(reportRejection)
toast.dismiss(toastId)
// Write new content to disk since they have accepted.
codeManager
.writeToFile()
.then(() => {
// no-op
})
.catch((e) => {
console.error('Failed to save prompt-to-edit to disk')
console.error(e)
})
/**
* NO OP. Do not rewrite code to disk, we already do this ahead of time. This will dismiss the toast.
* All of the files were already written! Don't rewrite the current code editor.
* If this prompt to edit makes 5 new files, the code manager is only watching 1 of these files, why
* would it rewrite the current file selected when this is completed?
*/
}}
>
Accept
Continue
</ActionButton>
</div>
</div>
</div>
)
}
export const writeOverFilesAndExecute = async ({
requestedFiles,
projectName,
}: {
requestedFiles: RequestedKCLFile[]
projectName: string
}) => {
systemIOActor.send({
type: SystemIOMachineEvents.bulkCreateKCLFilesAndNavigateToProject,
data: {
files: requestedFiles,
requestedProjectName: projectName,
override: true,
},
})
// to await the result of the send event above
await waitForIdleState({ systemIOActor })
}

View File

@ -34,8 +34,8 @@ import type {
ExtrudeFacePlane,
} from '@src/machines/modelingMachine'
import toast from 'react-hot-toast'
import { getStringAfterLastSeparator } from '@src/lib/paths'
import { findAllChildrenAndOrderByPlaceInCode } from '@src/lang/modifyAst/boolean'
import { localModuleSafePathSplit } from '@src/lib/paths'
export function useEngineConnectionSubscriptions() {
const { send, context, state } = useModelingContext()
@ -210,9 +210,11 @@ export function useEngineConnectionSubscriptions() {
return
}
if (importDetails?.type === 'Local') {
const paths = localModuleSafePathSplit(importDetails.value)
const fileName = paths[paths.length - 1]
showSketchOnImportToast(fileName)
// importDetails has OS specific separators from the rust side!
const fileNameWithExtension = getStringAfterLastSeparator(
importDetails.value
)
showSketchOnImportToast(fileNameWithExtension)
} else if (
importDetails?.type === 'Main' ||
importDetails?.type === 'Std'

View File

@ -215,8 +215,3 @@ export function desktopSafePathSplit(path: string): string[] {
export function desktopSafePathJoin(paths: string[]): string {
return isDesktop() ? paths.join(window?.electron?.sep) : webSafeJoin(paths)
}
export function localModuleSafePathSplit(path: string) {
const modulePathSafeSep = '/'
return path.split(modulePathSafeSep)
}

View File

@ -4,8 +4,18 @@ import type { Models } from '@kittycad/lib'
import { VITE_KC_API_BASE_URL } from '@src/env'
import { diffLines } from 'diff'
import toast from 'react-hot-toast'
import type { TextToCadMultiFileIteration_type } from '@kittycad/lib/dist/types/src/models'
import { getCookie, TOKEN_PERSIST_KEY } from '@src/machines/authMachine'
import { COOKIE_NAME } from '@src/lib/constants'
import { isDesktop } from '@src/lib/isDesktop'
import { openExternalBrowserIfDesktop } from '@src/lib/openWindow'
import { ActionButton } from '@src/components/ActionButton'
import { CustomIcon } from '@src/components/CustomIcon'
import { ToastPromptToEditCadSuccess } from '@src/components/ToastTextToCad'
import {
ToastPromptToEditCadSuccess,
writeOverFilesAndExecute,
} from '@src/components/ToastTextToCad'
import { modelingMachineEvent } from '@src/editor/manager'
import { getArtifactOfTypes } from '@src/lang/std/artifactGraph'
import { topLevelRange } from '@src/lang/util'
@ -15,6 +25,13 @@ import type { Selections } from '@src/lib/selections'
import { codeManager, editorManager, kclManager } from '@src/lib/singletons'
import { err, reportRejection } from '@src/lib/trap'
import { uuidv4 } from '@src/lib/utils'
import type { File as KittyCadLibFile } from '@kittycad/lib/dist/types/src/models'
import type { FileMeta } from '@src/lib/types'
import type { RequestedKCLFile } from '@src/machines/systemIO/utils'
type KclFileMetaMap = {
[execStateFileNamesIndex: number]: Extract<FileMeta, { type: 'kcl' }>
}
function sourceIndexToLineColumn(
code: string,
@ -37,43 +54,117 @@ function convertAppRangeToApiRange(
}
}
type TextToCadErrorResponse = {
error_code: string
message: string
}
async function submitTextToCadRequest(
body: {
prompt: string
source_ranges: Models['SourceRangePrompt_type'][]
project_name?: string
kcl_version: string
},
files: KittyCadLibFile[],
token: string
): Promise<TextToCadMultiFileIteration_type | Error> {
const formData = new FormData()
formData.append('body', JSON.stringify(body))
files.forEach((file) => {
formData.append('files', file.data, file.name)
})
const response = await fetch(
`${VITE_KC_API_BASE_URL}/ml/text-to-cad/multi-file/iteration`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
body: formData,
}
)
if (!response.ok) {
return new Error(`HTTP error! status: ${response.status}`)
}
const data = await response.json()
if ('error_code' in data) {
const errorData = data as TextToCadErrorResponse
return new Error(errorData.message || 'Unknown error')
}
return data as TextToCadMultiFileIteration_type
}
export async function submitPromptToEditToQueue({
prompt,
selections,
code,
projectFiles,
token,
artifactGraph,
projectName,
}: {
prompt: string
selections: Selections | null
code: string
projectFiles: FileMeta[]
projectName: string
token?: string
artifactGraph: ArtifactGraph
}): Promise<Models['TextToCadIteration_type'] | Error> {
}) {
const _token =
token && token !== ''
? token
: getCookie(COOKIE_NAME) || localStorage?.getItem(TOKEN_PERSIST_KEY) || ''
const kclFilesMap: KclFileMetaMap = {}
const endPointFiles: KittyCadLibFile[] = []
projectFiles.forEach((file) => {
let data: Blob
if (file.type === 'other') {
data = file.data
} else {
// file.type === 'kcl'
kclFilesMap[file.execStateFileNamesIndex] = file
data = new Blob([file.fileContents], { type: 'text/kcl' })
}
endPointFiles.push({
name: file.relPath,
data,
})
})
// If no selection, use whole file
if (selections === null) {
const body: Models['TextToCadIterationBody_type'] = {
original_source_code: code,
return submitTextToCadRequest(
{
prompt,
source_ranges: [], // Empty ranges indicates whole file
source_ranges: [],
project_name:
projectName !== '' && projectName !== 'browser'
? projectName
: undefined,
kcl_version: kclManager.kclVersion,
}
return submitToApi(body, token)
},
endPointFiles,
_token
)
}
// Handle manual code selections and artifact selections differently
const ranges: Models['TextToCadIterationBody_type']['source_ranges'] =
const ranges: Models['SourceRangePrompt_type'][] =
selections.graphSelections.flatMap((selection) => {
const artifact = selection.artifact
const execStateFileNamesIndex = selection?.codeRef?.range?.[2]
const file = kclFilesMap?.[execStateFileNamesIndex]
const code = file?.fileContents || ''
const filePath = file?.relPath || ''
// For artifact selections, add context
const prompts: Models['TextToCadIterationBody_type']['source_ranges'] = []
const prompts: Models['SourceRangePrompt_type'][] = []
if (artifact?.type === 'cap') {
prompts.push({
@ -87,6 +178,7 @@ If you need to operate on this cap, for example for sketching on the face, you c
When they made this selection they main have intended this surface directly or meant something more general like the sweep body.
See later source ranges for more context.`,
range: convertAppRangeToApiRange(selection.codeRef.range, code),
file: filePath,
})
let sweep = getArtifactOfTypes(
{ key: artifact.sweepId, types: ['sweep'] },
@ -96,6 +188,7 @@ See later source ranges for more context.`,
prompts.push({
prompt: `This is the sweep's source range from the user's main selection of the end cap.`,
range: convertAppRangeToApiRange(sweep.codeRef.range, code),
file: filePath,
})
}
}
@ -105,6 +198,7 @@ See later source ranges for more context.`,
The source range though is for the original segment before it was extruded, you can add a tag to that segment in order to refer to this wall, for example "startSketchOn(someSweepVariable, face = segmentTag)"
But it's also worth bearing in mind that the user may have intended to select the sweep itself, not this individual wall, see later source ranges for more context. about the sweep`,
range: convertAppRangeToApiRange(selection.codeRef.range, code),
file: filePath,
})
let sweep = getArtifactOfTypes(
{ key: artifact.sweepId, types: ['sweep'] },
@ -114,6 +208,7 @@ But it's also worth bearing in mind that the user may have intended to select th
prompts.push({
prompt: `This is the sweep's source range from the user's main selection of the end cap.`,
range: convertAppRangeToApiRange(sweep.codeRef.range, code),
file: filePath,
})
}
}
@ -130,6 +225,7 @@ and then use the function ${
}
See later source ranges for more context. about the sweep`,
range: convertAppRangeToApiRange(selection.codeRef.range, code),
file: filePath,
})
let sweep = getArtifactOfTypes(
{ key: artifact.sweepId, types: ['sweep'] },
@ -139,6 +235,7 @@ See later source ranges for more context. about the sweep`,
prompts.push({
prompt: `This is the sweep's source range from the user's main selection of the end cap.`,
range: convertAppRangeToApiRange(sweep.codeRef.range, code),
file: filePath,
})
}
}
@ -147,6 +244,7 @@ See later source ranges for more context. about the sweep`,
prompts.push({
prompt: `This selection is of a segment, likely an individual part of a profile. Segments are often "constrained" by the use of variables and relationships with other segments. Adding tags to segments helps refer to their length, angle or other properties`,
range: convertAppRangeToApiRange(selection.codeRef.range, code),
file: filePath,
})
} else {
prompts.push({
@ -155,6 +253,7 @@ Because it now refers to an edge the way to refer to this edge is to add a tag t
i.e. \`fillet( radius = someInteger, tags = [newTag])\` will work in the case of filleting this edge
See later source ranges for more context. about the sweep`,
range: convertAppRangeToApiRange(selection.codeRef.range, code),
file: filePath,
})
let path = getArtifactOfTypes(
{ key: artifact.pathId, types: ['path'] },
@ -169,6 +268,7 @@ See later source ranges for more context. about the sweep`,
prompts.push({
prompt: `This is the sweep's source range from the user's main selection of the edge.`,
range: convertAppRangeToApiRange(sweep.codeRef.range, code),
file: filePath,
})
}
}
@ -180,56 +280,32 @@ See later source ranges for more context. about the sweep`,
prompts.push({
prompt: '',
range: convertAppRangeToApiRange(selection.codeRef.range, code),
file: filePath,
})
}
return prompts
})
const body: Models['TextToCadIterationBody_type'] = {
original_source_code: code,
return submitTextToCadRequest(
{
prompt,
source_ranges: ranges,
project_name:
projectName !== '' && projectName !== 'browser' ? projectName : undefined,
projectName !== '' && projectName !== 'browser'
? projectName
: undefined,
kcl_version: kclManager.kclVersion,
}
return submitToApi(body, token)
}
// Helper function to handle API submission
async function submitToApi(
body: Models['TextToCadIterationBody_type'],
token?: string
): Promise<Models['TextToCadIteration_type'] | Error> {
const url = VITE_KC_API_BASE_URL + '/ml/text-to-cad/iteration'
const data: Models['TextToCadIteration_type'] | Error =
await crossPlatformFetch(
url,
{
method: 'POST',
body: JSON.stringify(body),
},
token
endPointFiles,
_token
)
// Make sure we have an id.
if (data instanceof Error) {
return data
}
if (!data.id) {
return new Error('No id returned from Text-to-CAD API')
}
return data
}
export async function getPromptToEditResult(
id: string,
token?: string
): Promise<Models['TextToCadIteration_type'] | Error> {
): Promise<Models['TextToCadMultiFileIteration_type'] | Error> {
const url = VITE_KC_API_BASE_URL + '/async/operations/' + id
const data: Models['TextToCadIteration_type'] | Error =
const data: Models['TextToCadMultiFileIteration_type'] | Error =
await crossPlatformFetch(
url,
{
@ -244,31 +320,50 @@ export async function getPromptToEditResult(
export async function doPromptEdit({
prompt,
selections,
code,
projectFiles,
token,
artifactGraph,
projectName,
}: {
prompt: string
selections: Selections
code: string
projectFiles: FileMeta[]
token?: string
projectName: string
artifactGraph: ArtifactGraph
}): Promise<Models['TextToCadIteration_type'] | Error> {
}): Promise<Models['TextToCadMultiFileIteration_type'] | Error> {
const toastId = toast.loading('Submitting to Text-to-CAD API...')
const submitResult = await submitPromptToEditToQueue({
let submitResult
// work around for @kittycad/lib not really being built for the browser
;(window as any).process = {
env: {
ZOO_API_TOKEN: token,
ZOO_HOST: VITE_KC_API_BASE_URL,
},
}
try {
submitResult = await submitPromptToEditToQueue({
prompt,
selections,
code,
projectFiles,
token,
artifactGraph,
projectName,
})
if (err(submitResult)) return submitResult
} catch (e: any) {
toast.dismiss(toastId)
return new Error(e.message)
}
if (submitResult instanceof Error) {
toast.dismiss(toastId)
return submitResult
}
const textToCadComplete = new Promise<Models['TextToCadIteration_type']>(
(resolve, reject) => {
const textToCadComplete = new Promise<
Models['TextToCadMultiFileIteration_type']
>((resolve, reject) => {
;(async () => {
const MAX_CHECK_TIMEOUT = 3 * 60_000
const CHECK_DELAY = 200
@ -277,7 +372,11 @@ export async function doPromptEdit({
while (timeElapsed < MAX_CHECK_TIMEOUT) {
const check = await getPromptToEditResult(submitResult.id, token)
if (check instanceof Error || check.status === 'failed') {
if (
check instanceof Error ||
check.status === 'failed' ||
check.error
) {
reject(check)
return
} else if (check.status === 'completed') {
@ -291,8 +390,7 @@ export async function doPromptEdit({
reject(new Error('Text-to-CAD API timed out'))
})().catch(reportRejection)
}
)
})
try {
const result = await textToCadComplete
@ -313,14 +411,14 @@ export async function doPromptEdit({
export async function promptToEditFlow({
prompt,
selections,
code,
projectFiles,
token,
artifactGraph,
projectName,
}: {
prompt: string
selections: Selections
code: string
projectFiles: FileMeta[]
token?: string
artifactGraph: ArtifactGraph
projectName: string
@ -328,16 +426,84 @@ export async function promptToEditFlow({
const result = await doPromptEdit({
prompt,
selections,
code,
projectFiles,
token,
artifactGraph,
projectName,
})
if (err(result)) return Promise.reject(result)
const oldCode = codeManager.code
const { code: newCode } = result
if (err(result)) {
toast.error('Failed to modify.')
return Promise.reject(result)
}
const oldCodeWebAppOnly = codeManager.code
if (!isDesktop() && Object.values(result.outputs).length > 1) {
const toastId = uuidv4()
toast.error(
(t) => (
<div className="flex flex-col gap-2">
<p>Multiple files were returned from Text-to-CAD.</p>
<p>You need to use the desktop app to support this.</p>
<div className="flex justify-between items-center mt-2">
<>
<a
href="https://zoo.dev/modeling-app/download"
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 hover:text-blue-300 underline flex align-middle"
onClick={openExternalBrowserIfDesktop(
'https://zoo.dev/modeling-app/download'
)}
>
<CustomIcon
name="link"
className="w-4 h-4 text-chalkboard-70 dark:text-chalkboard-40"
/>
Download Desktop App
</a>
</>
<ActionButton
Element="button"
iconStart={{
icon: 'close',
}}
name="Dismiss"
onClick={() => {
toast.dismiss(toastId)
}}
>
Dismiss
</ActionButton>
</div>
</div>
),
{
id: toastId,
duration: Infinity,
icon: null,
}
)
return
}
if (isDesktop()) {
const requestedFiles: RequestedKCLFile[] = []
for (const [relativePath, fileContents] of Object.entries(result.outputs)) {
requestedFiles.push({
requestedCode: fileContents,
requestedFileName: relativePath,
requestedProjectName: projectName,
})
}
await writeOverFilesAndExecute({
requestedFiles,
projectName,
})
} else {
const newCode = result.outputs['main.kcl']
codeManager.updateCodeEditor(newCode)
const diff = reBuildNewCodeWithRanges(oldCode, newCode)
const diff = reBuildNewCodeWithRanges(oldCodeWebAppOnly, newCode)
const ranges: SelectionRange[] = diff.insertRanges.map((range) =>
EditorSelection.range(range[0], range[1])
)
@ -349,6 +515,7 @@ export async function promptToEditFlow({
annotations: [modelingMachineEvent, Transaction.addToHistory.of(false)],
})
await kclManager.executeCode()
}
const toastId = uuidv4()
toast.success(
@ -357,7 +524,8 @@ export async function promptToEditFlow({
toastId,
data: result,
token,
oldCode,
oldCodeWebAppOnly,
oldFiles: projectFiles,
}),
{
id: toastId,

View File

@ -12,8 +12,8 @@ import { SceneInfra } from '@src/clientSideScene/sceneInfra'
import type { BaseUnit } from '@src/lib/settings/settingsTypes'
import { useSelector } from '@xstate/react'
import type { SnapshotFrom } from 'xstate'
import { createActor, setup, assign } from 'xstate'
import type { ActorRefFrom, SnapshotFrom } from 'xstate'
import { createActor, setup, spawnChild } from 'xstate'
import { isDesktop } from '@src/lib/isDesktop'
import { createSettings } from '@src/lib/settings/initialSettings'
@ -131,7 +131,6 @@ const appMachine = setup({
types: {} as {
context: AppMachineContext
},
actors: appMachineActors,
}).createMachine({
id: 'modeling-app',
context: {
@ -143,50 +142,36 @@ const appMachine = setup({
},
entry: [
/**
* We originally wanted to use spawnChild but the inferred type blew up. The more children we
* created the type complexity went through the roof. This functionally should act the same.
* the system and parent internals are tracked properly. After reading the documentation
* it suggests either method but this method requires manual clean up as described in the gotcha
* comment block below. If this becomes an issue we can always move this spawn into createActor functions
* in javascript above and reference those directly but the system and parent internals within xstate
* will not work.
* We have been battling XState's type unions exploding in size,
* so for these global actors, we have decided to forego creating them by reference
* using the `actors` property in the `setup` function, and
* inline them instead.
*/
assign({
// Gotcha, if you use spawn, make sure you remove the ActorRef from context
// to prevent memory leaks when the spawned actor is no longer needed
authActor: ({ spawn }) => spawn(AUTH, { id: AUTH, systemId: AUTH }),
settingsActor: ({ spawn }) =>
spawn(SETTINGS, {
id: SETTINGS,
spawnChild(appMachineActors[AUTH], { systemId: AUTH }),
spawnChild(appMachineActors[SETTINGS], {
systemId: SETTINGS,
input: createSettings(),
}),
systemIOActor: ({ spawn }) =>
spawn(SYSTEM_IO, { id: SYSTEM_IO, systemId: SYSTEM_IO }),
engineStreamActor: ({ spawn }) =>
spawn(ENGINE_STREAM, {
id: ENGINE_STREAM,
spawnChild(appMachineActors[ENGINE_STREAM], {
systemId: ENGINE_STREAM,
input: engineStreamContextCreate(),
}),
commandBarActor: ({ spawn }) =>
spawn(COMMAND_BAR, {
id: COMMAND_BAR,
spawnChild(appMachineActors[SYSTEM_IO], {
systemId: SYSTEM_IO,
}),
spawnChild(appMachineActors[COMMAND_BAR], {
systemId: COMMAND_BAR,
input: {
commands: [],
},
}),
billingActor: ({ spawn }) =>
spawn(BILLING, {
id: BILLING,
spawnChild(appMachineActors[BILLING], {
systemId: BILLING,
input: {
...BILLING_CONTEXT_DEFAULTS,
urlUserService: VITE_KC_API_BASE_URL,
},
}),
}),
],
})
@ -199,7 +184,9 @@ export const appActor = createActor(appMachine, {
* the lifetime of {appActor}, but would not work if it were invoked
* or if it were destroyed under any conditions during {appActor}'s life
*/
export const authActor = appActor.getSnapshot().context.authActor!
export const authActor = appActor.system.get(AUTH) as ActorRefFrom<
(typeof appMachineActors)[typeof AUTH]
>
export const useAuthState = () => useSelector(authActor, (state) => state)
export const useToken = () =>
useSelector(authActor, (state) => state.context.token)
@ -211,7 +198,9 @@ export const useUser = () =>
* the lifetime of {appActor}, but would not work if it were invoked
* or if it were destroyed under any conditions during {appActor}'s life
*/
export const settingsActor = appActor.getSnapshot().context.settingsActor!
export const settingsActor = appActor.system.get(SETTINGS) as ActorRefFrom<
(typeof appMachineActors)[typeof SETTINGS]
>
export const getSettings = () => {
const { currentProject: _, ...settings } = settingsActor.getSnapshot().context
return settings
@ -223,14 +212,21 @@ export const useSettings = () =>
return settings
})
export const systemIOActor = appActor.getSnapshot().context.systemIOActor!
export const systemIOActor = appActor.system.get(SYSTEM_IO) as ActorRefFrom<
(typeof appMachineActors)[typeof SYSTEM_IO]
>
export const engineStreamActor =
appActor.getSnapshot().context.engineStreamActor!
export const engineStreamActor = appActor.system.get(
ENGINE_STREAM
) as ActorRefFrom<(typeof appMachineActors)[typeof ENGINE_STREAM]>
export const commandBarActor = appActor.getSnapshot().context.commandBarActor!
export const commandBarActor = appActor.system.get(COMMAND_BAR) as ActorRefFrom<
(typeof appMachineActors)[typeof COMMAND_BAR]
>
export const billingActor = appActor.system.get(BILLING)
export const billingActor = appActor.system.get(BILLING) as ActorRefFrom<
(typeof appMachineActors)[typeof BILLING]
>
const cmdBarStateSelector = (state: SnapshotFrom<typeof commandBarActor>) =>
state

View File

@ -11,7 +11,10 @@ import crossPlatformFetch from '@src/lib/crossPlatformFetch'
import { getNextFileName } from '@src/lib/desktopFS'
import { isDesktop } from '@src/lib/isDesktop'
import { kclManager, systemIOActor } from '@src/lib/singletons'
import { SystemIOMachineEvents } from '@src/machines/systemIO/utils'
import {
SystemIOMachineEvents,
waitForIdleState,
} from '@src/machines/systemIO/utils'
import { reportRejection } from '@src/lib/trap'
import { toSync } from '@src/lib/utils'
@ -218,6 +221,9 @@ export async function submitAndAwaitTextToKclSystemIO({
})
}
// wait for the createKCLFile action to be completed
await waitForIdleState({ systemIOActor })
return {
...value,
fileName: newFileName,

View File

@ -10,7 +10,7 @@ import type { settingsMachine } from '@src/machines/settingsMachine'
import type { systemIOMachine } from '@src/machines/systemIO/systemIOMachine'
import type { ActorRefFrom } from 'xstate'
import type { commandBarMachine } from '@src/machines/commandBarMachine'
import type { BillingActor } from '@src/machines/billingMachine'
import type { billingMachine } from '@src/machines/billingMachine'
export type IndexLoaderData = {
code: string | null
@ -135,5 +135,19 @@ export type AppMachineContext = {
systemIOActor?: ActorRefFrom<typeof systemIOMachine>
engineStreamActor?: ActorRefFrom<typeof engineStreamMachine>
commandBarActor?: ActorRefFrom<typeof commandBarMachine>
billingActor?: BillingActor
billingActor?: ActorRefFrom<typeof billingMachine>
}
export type FileMeta =
| {
type: 'kcl'
relPath: string
absPath: string
fileContents: string
execStateFileNamesIndex: number
}
| {
type: 'other'
relPath: string
data: Blob
}

View File

@ -1,7 +1,6 @@
import type { Binary as BSONBinary } from 'bson'
import { v4 } from 'uuid'
import type { AnyMachineSnapshot } from 'xstate'
import type { CallExpressionKw, SourceRange } from '@src/lang/wasm'
import { isDesktop } from '@src/lib/isDesktop'
import type { AsyncFn } from '@src/lib/types'

View File

@ -43,7 +43,7 @@ const LOCAL_USER: Models['User_type'] = {
export interface UserContext {
user?: Models['User_type']
token?: string
token: string
}
export type Events =
@ -204,7 +204,7 @@ async function getUser(input: { token?: string }) {
}
}
function getCookie(cname: string): string | null {
export function getCookie(cname: string): string | null {
if (isDesktop()) {
return null
}

View File

@ -807,18 +807,6 @@ export const modelingMachine = setup({
sketchDetails: event.output,
}
}),
'set selection filter to curves only': () => {
;(async () => {
await engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'set_selection_filter',
filter: ['curve'],
},
})
})().catch(reportRejection)
},
'tear down client sketch': () => {
sceneEntitiesManager.tearDownSketch({ removeAxis: false })
},
@ -3130,7 +3118,7 @@ export const modelingMachine = setup({
'Artifact graph emptied': 'hidePlanes',
},
entry: ['show default planes', 'set selection filter to curves only'],
entry: ['show default planes'],
description: `We want to disable selections and hover highlights here, because users can't do anything with that information until they actually add something to the scene. The planes are just for orientation here.`,
exit: 'set selection filter to defaults',
},

View File

@ -43,6 +43,7 @@ import {
setThemeClass,
} from '@src/lib/theme'
import { reportRejection } from '@src/lib/trap'
import { ACTOR_IDS } from '@src/machines/machineConstants'
type SettingsMachineContext = SettingsType & {
currentProject?: Project
@ -140,9 +141,9 @@ export const settingsMachine = setup({
registerCommands: fromCallback<
{ type: 'update' },
{ settings: SettingsType; actor: AnyActorRef }
>(({ input, receive, self }) => {
const commandBarActor = self.system.get('root').getSnapshot()
.context.commandBarActor
>(({ input, receive, system }) => {
// This assumes this actor is running in a system with a command palette
const commandBarActor = system.get(ACTOR_IDS.COMMAND_BAR)
// If the user wants to hide the settings commands
//from the command bar don't add them.
if (settings.commandBar.includeSettings.current === false) return
@ -157,14 +158,19 @@ export const settingsMachine = setup({
})
)
.filter((c) => c !== null)
if (commandBarActor === undefined) {
console.warn(
'Tried to register commands, but no command bar actor was found'
)
}
const addCommands = () =>
commandBarActor.send({
commandBarActor?.send({
type: 'Add commands',
data: { commands: commands },
})
const removeCommands = () =>
commandBarActor.send({
commandBarActor?.send({
type: 'Remove commands',
data: { commands: commands },
})

View File

@ -1,6 +1,9 @@
import { DEFAULT_PROJECT_NAME } from '@src/lib/constants'
import type { Project } from '@src/lib/project'
import type { SystemIOContext } from '@src/machines/systemIO/utils'
import type {
SystemIOContext,
RequestedKCLFile,
} from '@src/machines/systemIO/utils'
import {
NO_PROJECT_DIRECTORY,
SystemIOMachineActions,
@ -84,6 +87,20 @@ export const systemIOMachine = setup({
requestedCode: string
}
}
| {
type: SystemIOMachineEvents.bulkCreateKCLFiles
data: {
files: RequestedKCLFile[]
}
}
| {
type: SystemIOMachineEvents.bulkCreateKCLFilesAndNavigateToProject
data: {
files: RequestedKCLFile[]
requestedProjectName: string
override?: boolean
}
}
| {
type: SystemIOMachineEvents.importFileFromURL
data: {
@ -283,6 +300,41 @@ export const systemIOMachine = setup({
return { message: '', fileName: '', projectName: '' }
}
),
[SystemIOMachineActors.bulkCreateKCLFiles]: fromPromise(
async ({
input,
}: {
input: {
context: SystemIOContext
files: RequestedKCLFile[]
rootContext: AppMachineContext
}
}): Promise<{
message: string
fileName: string
projectName: string
}> => {
return { message: '', fileName: '', projectName: '' }
}
),
[SystemIOMachineActors.bulkCreateKCLFilesAndNavigateToProject]: fromPromise(
async ({
input,
}: {
input: {
context: SystemIOContext
files: RequestedKCLFile[]
rootContext: AppMachineContext
requestedProjectName: string
}
}): Promise<{
message: string
fileName: string
projectName: string
}> => {
return { message: '', fileName: '', projectName: '' }
}
),
},
}).createMachine({
initial: SystemIOMachineStates.idle,
@ -351,6 +403,13 @@ export const systemIOMachine = setup({
[SystemIOMachineEvents.deleteKCLFile]: {
target: SystemIOMachineStates.deletingKCLFile,
},
[SystemIOMachineEvents.bulkCreateKCLFiles]: {
target: SystemIOMachineStates.bulkCreatingKCLFiles,
},
[SystemIOMachineEvents.bulkCreateKCLFilesAndNavigateToProject]: {
target:
SystemIOMachineStates.bulkCreatingKCLFilesAndNavigateToProject,
},
},
},
[SystemIOMachineStates.readingFolders]: {
@ -555,5 +614,60 @@ export const systemIOMachine = setup({
},
},
},
[SystemIOMachineStates.bulkCreatingKCLFiles]: {
invoke: {
id: SystemIOMachineActors.bulkCreateKCLFiles,
src: SystemIOMachineActors.bulkCreateKCLFiles,
input: ({ context, event, self }) => {
assertEvent(event, SystemIOMachineEvents.bulkCreateKCLFiles)
return {
context,
files: event.data.files,
rootContext: self.system.get('root').getSnapshot().context,
}
},
onDone: {
target: SystemIOMachineStates.readingFolders,
},
onError: {
target: SystemIOMachineStates.idle,
actions: [SystemIOMachineActions.toastError],
},
},
},
[SystemIOMachineStates.bulkCreatingKCLFilesAndNavigateToProject]: {
invoke: {
id: SystemIOMachineActors.bulkCreateKCLFilesAndNavigateToProject,
src: SystemIOMachineActors.bulkCreateKCLFilesAndNavigateToProject,
input: ({ context, event, self }) => {
assertEvent(
event,
SystemIOMachineEvents.bulkCreateKCLFilesAndNavigateToProject
)
return {
context,
files: event.data.files,
rootContext: self.system.get('root').getSnapshot().context,
requestedProjectName: event.data.requestedProjectName,
override: event.data.override,
}
},
onDone: {
target: SystemIOMachineStates.readingFolders,
actions: [
assign({
requestedProjectName: ({ event }) => {
return { name: event.output.projectName }
},
}),
SystemIOMachineActions.toastSuccess,
],
},
onError: {
target: SystemIOMachineStates.idle,
actions: [SystemIOMachineActions.toastError],
},
},
},
},
})

View File

@ -14,7 +14,10 @@ import {
} from '@src/lib/desktopFS'
import type { Project } from '@src/lib/project'
import { systemIOMachine } from '@src/machines/systemIO/systemIOMachine'
import type { SystemIOContext } from '@src/machines/systemIO/utils'
import type {
RequestedKCLFile,
SystemIOContext,
} from '@src/machines/systemIO/utils'
import {
NO_PROJECT_DIRECTORY,
SystemIOMachineActors,
@ -22,6 +25,74 @@ import {
import { fromPromise } from 'xstate'
import type { AppMachineContext } from '@src/lib/types'
const sharedBulkCreateWorkflow = async ({
input,
}: {
input: {
context: SystemIOContext
files: RequestedKCLFile[]
rootContext: AppMachineContext
override?: boolean
}
}) => {
const configuration = await readAppSettingsFile()
for (let fileIndex = 0; fileIndex < input.files.length; fileIndex++) {
const file = input.files[fileIndex]
const requestedProjectName = file.requestedProjectName
const requestedFileName = file.requestedFileName
const requestedCode = file.requestedCode
const folders = input.context.folders
let newProjectName = requestedProjectName
if (!newProjectName) {
newProjectName = getUniqueProjectName(
input.context.defaultProjectFolderName,
input.context.folders
)
}
const needsInterpolated = doesProjectNameNeedInterpolated(newProjectName)
if (needsInterpolated) {
const nextIndex = getNextProjectIndex(newProjectName, folders)
newProjectName = interpolateProjectNameWithIndex(
newProjectName,
nextIndex
)
}
const baseDir = window.electron.join(
input.context.projectDirectoryPath,
newProjectName
)
// If override is true, use the requested filename directly
const fileName = input.override
? requestedFileName
: getNextFileName({
entryName: requestedFileName,
baseDir,
}).name
// Create the project around the file if newProject
await createNewProjectDirectory(
newProjectName,
requestedCode,
configuration,
fileName
)
}
const numberOfFiles = input.files.length
const fileText = numberOfFiles > 1 ? 'files' : 'file'
const message = input.override
? `Successfully overwrote ${numberOfFiles} ${fileText}`
: `Successfully created ${numberOfFiles} ${fileText}`
return {
message,
fileName: '',
projectName: '',
}
}
export const systemIOMachineDesktop = systemIOMachine.provide({
actors: {
[SystemIOMachineActors.readFoldersFromProjectDirectory]: fromPromise(
@ -255,5 +326,40 @@ export const systemIOMachineDesktop = systemIOMachine.provide({
}
}
),
[SystemIOMachineActors.bulkCreateKCLFiles]: fromPromise(
async ({
input,
}: {
input: {
context: SystemIOContext
files: RequestedKCLFile[]
rootContext: AppMachineContext
}
}) => {
return await sharedBulkCreateWorkflow({ input })
}
),
[SystemIOMachineActors.bulkCreateKCLFilesAndNavigateToProject]: fromPromise(
async ({
input,
}: {
input: {
context: SystemIOContext
files: RequestedKCLFile[]
rootContext: AppMachineContext
requestedProjectName: string
override?: boolean
}
}) => {
const message = await sharedBulkCreateWorkflow({
input: {
...input,
override: input.override,
},
})
message.projectName = input.requestedProjectName
return message
}
),
},
})

View File

@ -1,4 +1,6 @@
import type { Project } from '@src/lib/project'
import type { ActorRefFrom } from 'xstate'
import type { systemIOMachine } from '@src/machines/systemIO/systemIOMachine'
export enum SystemIOMachineActors {
readFoldersFromProjectDirectory = 'read folders from project directory',
@ -11,6 +13,8 @@ export enum SystemIOMachineActors {
/** TODO: rename this event to be more generic, like `createKCLFileAndNavigate` */
importFileFromURL = 'import file from URL',
deleteKCLFile = 'delete kcl delete',
bulkCreateKCLFiles = 'bulk create kcl files',
bulkCreateKCLFilesAndNavigateToProject = 'bulk create kcl files and navigate to project',
}
export enum SystemIOMachineStates {
@ -25,6 +29,8 @@ export enum SystemIOMachineStates {
/** TODO: rename this event to be more generic, like `createKCLFileAndNavigate` */
importFileFromURL = 'importFileFromURL',
deletingKCLFile = 'deletingKCLFile',
bulkCreatingKCLFiles = 'bulkCreatingKCLFiles',
bulkCreatingKCLFilesAndNavigateToProject = 'bulkCreatingKCLFilesAndNavigateToProject',
}
const donePrefix = 'xstate.done.actor.'
@ -48,6 +54,8 @@ export enum SystemIOMachineEvents {
done_importFileFromURL = donePrefix + 'import file from URL',
generateTextToCAD = 'generate text to CAD',
deleteKCLFile = 'delete kcl file',
bulkCreateKCLFiles = 'bulk create kcl files',
bulkCreateKCLFilesAndNavigateToProject = 'bulk create kcl files and navigate to project',
}
export enum SystemIOMachineActions {
@ -89,3 +97,28 @@ export type SystemIOContext = {
project: string
}
}
export type RequestedKCLFile = {
requestedProjectName: string
requestedFileName: string
requestedCode: string
}
export const waitForIdleState = async ({
systemIOActor,
}: { systemIOActor: ActorRefFrom<typeof systemIOMachine> }) => {
// Check if already idle before setting up subscription
if (systemIOActor.getSnapshot().matches(SystemIOMachineStates.idle)) {
return Promise.resolve()
}
const waitForIdlePromise = new Promise((resolve) => {
const subscription = systemIOActor.subscribe((state) => {
if (state.matches(SystemIOMachineStates.idle)) {
subscription.unsubscribe()
resolve(undefined)
}
})
})
return waitForIdlePromise
}