Compare commits

...

43 Commits

Author SHA1 Message Date
056463b80d Change back to spawnChild, forego actors by reference 2025-05-02 20:16:15 -04:00
da6eef1043 fix: typo 2025-05-02 14:23:04 -05:00
8250f974f7 fix: removing useless logic, we write to disk ahead of time, the continue is to say ya no problem 2025-05-02 14:19:21 -05:00
0f043d2a4d fix: how did this desync? 2025-05-02 14:16:24 -05:00
dca0e2b4c7 fix: how did this desync? 2025-05-02 14:16:08 -05:00
b7c4dc4eba fix: fixing toast success 2025-05-02 14:12:05 -05:00
4828cb98a7 fix: trying to fix a few things at once... 2025-05-02 13:52:42 -05:00
f67753d7a8 fix: trying to clean up some async logic 2025-05-02 12:23:28 -05:00
555b1671a0 chore: leaving a comment for a confusing workflow 2025-05-02 10:57:41 -05:00
7dc94428c6 fix: handling more error flows by dismissing the toast 2025-05-02 10:26:29 -05:00
5787ba36a9 fix: Needed to support the bad request flow, the toast will hang forever, the return control flows don't dismiss a forever toast 2025-05-02 10:26:08 -05:00
f2cb8e387a spot of clean up 2025-05-02 21:58:48 +10:00
dac37864fb better fix 2025-05-02 21:56:29 +10:00
94f0bfef79 push fix of sorts 2025-05-02 21:21:16 +10:00
59f7d4a414 comment the terriable code 2025-05-02 20:45:10 +10:00
2758e64835 Merge remote-tracking branch 'origin' into kurt-multi-file-edit-with-ai 2025-05-02 20:38:52 +10:00
1ef9354bfd fix writing to files when response returns 2025-05-02 20:38:28 +10:00
3fd5e025d4 Merge remote-tracking branch 'origin' into kurt-multi-file-edit-with-ai 2025-05-02 15:42:50 +10:00
41bb25ffbc clean up 2025-05-02 15:39:29 +10:00
e11cd238fd good progress 2025-05-02 15:34:16 +10:00
6a158fcdbe steal Kevin's stuff 2025-05-02 13:17:03 +10:00
d7893a1018 fmt 2025-05-02 08:02:44 +10:00
4938998ba1 Merge remote-tracking branch 'origin' into kurt-multi-file-edit-with-ai 2025-05-02 08:02:24 +10:00
78c14c88d0 remove fake data 2025-05-01 20:40:11 +10:00
a95170281c raw dog form data like a fucking peasant 2025-05-01 16:24:28 +10:00
6760a968bc typo 2025-05-01 14:59:32 +10:00
1e8189c2f9 Merge remote-tracking branch 'origin' into kurt-multi-file-edit-with-ai 2025-05-01 14:58:22 +10:00
0a3114adb1 fix one thing 2025-05-01 14:56:38 +10:00
8be53b2c0c fmt 2025-05-01 10:38:44 +10:00
60190f7df7 Update src/components/ModelingMachineProvider.tsx
Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>
2025-05-01 10:14:30 +10:00
20f5af23de tweak selection filters 2025-05-01 09:59:35 +10:00
0adb4772aa remove log 2025-05-01 09:45:37 +10:00
feb2068d88 Merge remote-tracking branch 'origin' into kurt-multi-file-edit-with-ai 2025-05-01 09:29:42 +10:00
4b46d28904 update snapshot 2025-04-30 13:31:01 +10:00
094d7e2ea7 Merge remote-tracking branch 'origin' into kurt-multi-file-edit-with-ai 2025-04-30 12:49:48 +10:00
b2517c4cca update known circular 2025-04-29 13:58:41 +10:00
e56a4c6cc4 Merge remote-tracking branch 'origin' into kurt-multi-file-edit-with-ai 2025-04-29 13:35:14 +10:00
7a1c083a6c Merge remote-tracking branch 'origin' into kurt-multi-file-edit-with-ai 2025-03-31 17:12:48 +11:00
5d28537c3b warn about big projects 2025-03-31 17:03:11 +11:00
347cb07eb0 add write to disk 2025-03-31 16:01:00 +11:00
5a466e98f9 blobifying files, and making selections work with imports working 2025-03-31 14:26:02 +11:00
146bacce37 get some relative path stuff sorted 2025-03-31 11:54:00 +11:00
e87052d72f start of migrate to multi file endpoint 2025-03-30 22:09:56 +11:00
19 changed files with 922 additions and 289 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,23 +52,20 @@ 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...'
)
@ -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(')
})
}

View File

@ -1,33 +1,39 @@
{
"original_source_code": "sketch001 = startSketchOn(XZ)\nprofile001 = startProfile(sketch001, at = [57.81, 250.51])\n |> line(end = [121.13, 56.63], tag = $seg02)\n |> line(end = [83.37, -34.61], tag = $seg01)\n |> line(end = [19.66, -116.4])\n |> line(end = [-221.8, -41.69])\n |> line(endAbsolute = [profileStartX(%), profileStartY(%)])\n |> close()\nextrude001 = extrude(profile001, length = 200)\nsketch002 = startSketchOn(XZ)\n |> startProfile(at = [-73.64, -42.89])\n |> xLine(length = 173.71)\n |> line(end = [-22.12, -94.4])\n |> xLine(length = -156.98)\n |> line(endAbsolute = [profileStartX(%), profileStartY(%)])\n |> close()\nextrude002 = extrude(sketch002, length = 50)\nsketch003 = startSketchOn(XY)\n |> startProfile(at = [52.92, 157.81])\n |> angledLine(angle = 0, length = 176.4, tag = $rectangleSegmentA001)\n |> angledLine(\n angle = segAng(rectangleSegmentA001) - 90,\n length = 53.4,\n tag = $rectangleSegmentB001,\n )\n |> angledLine(angle = segAng(rectangleSegmentA001), length = -segLen(rectangleSegmentA001), tag = $rectangleSegmentC001)\n |> line(endAbsolute = [profileStartX(%), profileStartY(%)])\n |> close()\nextrude003 = extrude(sketch003, length = 20)\n",
"prompt": "make this neon green please, use #39FF14",
"source_ranges": [
{
"prompt": "The users main selection is the end cap of a general-sweep (that is an extrusion, revolve, sweep or loft).\nThe source range most likely refers to \"startProfile\" simply because this is the start of the profile that was swept.\nIf you need to operate on this cap, for example for sketching on the face, you can use the special string END i.e. `startSketchOn(someSweepVariable, face = END)`\nWhen they made this selection they main have intended this surface directly or meant something more general like the sweep body.\nSee later source ranges for more context.",
"range": {
"start": {
"line": 11,
"line": 12,
"column": 5
},
"end": {
"line": 11,
"line": 12,
"column": 40
}
}
},
"file": "main.kcl"
},
{
"prompt": "This is the sweep's source range from the user's main selection of the end cap.",
"range": {
"start": {
"line": 17,
"line": 18,
"column": 13
},
"end": {
"line": 17,
"line": 18,
"column": 44
}
}
},
"file": "main.kcl"
}
],
"kcl_version": "0.2.63"
"project_name": "test-dir",
"kcl_version": "0.2.65",
"files": {
"b.kcl": "sketch003 = startSketchOn(XY)\n |> startProfile(at = [52.92, 157.81])\n |> angledLine(angle = 0, length = 176.4, tag = $rectangleSegmentA001)\n |> angledLine(\n angle = segAng(rectangleSegmentA001) - 90,\n length = 53.4,\n tag = $rectangleSegmentB001,\n )\n |> angledLine(angle = segAng(rectangleSegmentA001), length = -segLen(rectangleSegmentA001), tag = $rectangleSegmentC001)\n |> line(endAbsolute = [profileStartX(%), profileStartY(%)])\n |> close()\nextrude(sketch003, length = 20)",
"main.kcl": "import \"b.kcl\" as b\nsketch001 = startSketchOn(XZ)\nprofile001 = startProfile(sketch001, at = [57.81, 250.51])\n |> line(end = [121.13, 56.63], tag = $seg02)\n |> line(end = [83.37, -34.61], tag = $seg01)\n |> line(end = [19.66, -116.4])\n |> line(end = [-221.8, -41.69])\n |> line(endAbsolute = [profileStartX(%), profileStartY(%)])\n |> close()\nextrude001 = extrude(profile001, length = 200)\nsketch002 = startSketchOn(XZ)\n |> startProfile(at = [-73.64, -42.89])\n |> xLine(length = 173.71)\n |> line(end = [-22.12, -94.4])\n |> xLine(length = -156.98)\n |> line(endAbsolute = [profileStartX(%), profileStartY(%)])\n |> close()\nextrude002 = extrude(sketch002, length = 50)\nb"
}
}

View File

@ -5,10 +5,10 @@
• 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
9) src/routes/Onboarding/index.tsx -> src/routes/Onboarding/Camera.tsx -> src/routes/Onboarding/utils.tsx

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, useState } 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
@ -479,14 +487,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">
@ -515,13 +526,37 @@ export function ToastPromptToEditCadSuccess({
data-negative-button={'reject'}
name={'Reject'}
onClick={() => {
sendTelemetry(modelId, 'rejected', token).catch(reportRejection)
codeManager.updateCodeEditor(oldCode)
kclManager.executeCode().catch(reportRejection)
toast.dismiss(toastId)
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
@ -533,23 +568,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

@ -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,
prompt,
source_ranges: [], // Empty ranges indicates whole file
project_name:
projectName !== '' && projectName !== 'browser'
? projectName
: undefined,
kcl_version: kclManager.kclVersion,
}
return submitToApi(body, token)
return submitTextToCadRequest(
{
prompt,
source_ranges: [],
project_name:
projectName !== '' && projectName !== 'browser'
? projectName
: undefined,
kcl_version: kclManager.kclVersion,
},
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,
prompt,
source_ranges: ranges,
project_name:
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
)
// 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
return submitTextToCadRequest(
{
prompt,
source_ranges: ranges,
project_name:
projectName !== '' && projectName !== 'browser'
? projectName
: undefined,
kcl_version: kclManager.kclVersion,
},
endPointFiles,
_token
)
}
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,55 +320,77 @@ 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({
prompt,
selections,
code,
token,
artifactGraph,
projectName,
})
if (err(submitResult)) return submitResult
const textToCadComplete = new Promise<Models['TextToCadIteration_type']>(
(resolve, reject) => {
;(async () => {
const MAX_CHECK_TIMEOUT = 3 * 60_000
const CHECK_DELAY = 200
let submitResult
let timeElapsed = 0
// 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,
projectFiles,
token,
artifactGraph,
projectName,
})
} catch (e: any) {
toast.dismiss(toastId)
return new Error(e.message)
}
if (submitResult instanceof Error) {
toast.dismiss(toastId)
return submitResult
}
while (timeElapsed < MAX_CHECK_TIMEOUT) {
const check = await getPromptToEditResult(submitResult.id, token)
if (check instanceof Error || check.status === 'failed') {
reject(check)
return
} else if (check.status === 'completed') {
resolve(check)
return
}
const textToCadComplete = new Promise<
Models['TextToCadMultiFileIteration_type']
>((resolve, reject) => {
;(async () => {
const MAX_CHECK_TIMEOUT = 3 * 60_000
const CHECK_DELAY = 200
await new Promise((r) => setTimeout(r, CHECK_DELAY))
timeElapsed += CHECK_DELAY
let timeElapsed = 0
while (timeElapsed < MAX_CHECK_TIMEOUT) {
const check = await getPromptToEditResult(submitResult.id, token)
if (
check instanceof Error ||
check.status === 'failed' ||
check.error
) {
reject(check)
return
} else if (check.status === 'completed') {
resolve(check)
return
}
reject(new Error('Text-to-CAD API timed out'))
})().catch(reportRejection)
}
)
await new Promise((r) => setTimeout(r, CHECK_DELAY))
timeElapsed += CHECK_DELAY
}
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,27 +426,96 @@ 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
codeManager.updateCodeEditor(newCode)
const diff = reBuildNewCodeWithRanges(oldCode, newCode)
const ranges: SelectionRange[] = diff.insertRanges.map((range) =>
EditorSelection.range(range[0], range[1])
)
editorManager?.editorView?.dispatch({
selection: EditorSelection.create(
ranges,
selections.graphSelections.length - 1
),
annotations: [modelingMachineEvent, Transaction.addToHistory.of(false)],
})
await kclManager.executeCode()
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(oldCodeWebAppOnly, newCode)
const ranges: SelectionRange[] = diff.insertRanges.map((range) =>
EditorSelection.range(range[0], range[1])
)
editorManager?.editorView?.dispatch({
selection: EditorSelection.create(
ranges,
selections.graphSelections.length - 1
),
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

@ -21,6 +21,7 @@ import type {
IndexLoaderData,
} from '@src/lib/types'
import { settingsActor } from '@src/lib/singletons'
import { NAVIGATION_COMPLETE_EVENT } from '@src/machines/systemIO/utils'
export const telemetryLoader: LoaderFunction = async ({
params,
@ -89,6 +90,7 @@ export const fileLoader: LoaderFunction = async (
// We pass true on the end here to clear the code editor history.
// This way undo and redo are not super weird when opening new files.
codeManager.updateCodeStateEditor(code, true)
window.dispatchEvent(new CustomEvent(NAVIGATION_COMPLETE_EVENT))
}
// Set the file system manager to the project path

View File

@ -10,8 +10,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'
@ -123,7 +123,6 @@ const appMachine = setup({
types: {} as {
context: AppMachineContext
},
actors: appMachineActors,
}).createMachine({
id: 'modeling-app',
context: {
@ -135,40 +134,28 @@ 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,
systemId: SETTINGS,
input: createSettings(),
}),
systemIOActor: ({ spawn }) =>
spawn(SYSTEM_IO, { id: SYSTEM_IO, systemId: SYSTEM_IO }),
engineStreamActor: ({ spawn }) =>
spawn(ENGINE_STREAM, {
id: ENGINE_STREAM,
systemId: ENGINE_STREAM,
input: engineStreamContextCreate(),
}),
commandBarActor: ({ spawn }) =>
spawn(COMMAND_BAR, {
id: COMMAND_BAR,
systemId: COMMAND_BAR,
input: {
commands: [],
},
}),
spawnChild(appMachineActors[AUTH], { systemId: AUTH }),
spawnChild(appMachineActors[SETTINGS], {
systemId: SETTINGS,
input: createSettings(),
}),
spawnChild(appMachineActors[ENGINE_STREAM], {
systemId: ENGINE_STREAM,
input: engineStreamContextCreate(),
}),
spawnChild(appMachineActors[SYSTEM_IO], {
systemId: SYSTEM_IO,
}),
spawnChild(appMachineActors[COMMAND_BAR], {
systemId: COMMAND_BAR,
input: {
commands: [],
},
}),
],
})
@ -182,7 +169,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)
@ -194,7 +183,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
@ -206,12 +197,17 @@ 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]
>
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

@ -135,3 +135,17 @@ export type AppMachineContext = {
engineStreamActor?: ActorRefFrom<typeof engineStreamMachine>
commandBarActor?: ActorRefFrom<typeof commandBarMachine>
}
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

@ -203,7 +203,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

@ -810,18 +810,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 })
},
@ -3328,7 +3316,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,
@ -69,6 +72,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: {
@ -262,6 +279,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,
@ -330,6 +382,13 @@ export const systemIOMachine = setup({
[SystemIOMachineEvents.deleteKCLFile]: {
target: SystemIOMachineStates.deletingKCLFile,
},
[SystemIOMachineEvents.bulkCreateKCLFiles]: {
target: SystemIOMachineStates.bulkCreatingKCLFiles,
},
[SystemIOMachineEvents.bulkCreateKCLFilesAndNavigateToProject]: {
target:
SystemIOMachineStates.bulkCreatingKCLFilesAndNavigateToProject,
},
},
},
[SystemIOMachineStates.readingFolders]: {
@ -530,5 +589,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(
@ -251,5 +322,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',
@ -10,6 +12,8 @@ export enum SystemIOMachineActors {
checkReadWrite = 'check read write',
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 {
@ -23,6 +27,8 @@ export enum SystemIOMachineStates {
checkingReadWrite = 'checkingReadWrite',
importFileFromURL = 'importFileFromURL',
deletingKCLFile = 'deletingKCLFile',
bulkCreatingKCLFiles = 'bulkCreatingKCLFiles',
bulkCreatingKCLFilesAndNavigateToProject = 'bulkCreatingKCLFilesAndNavigateToProject',
}
const donePrefix = 'xstate.done.actor.'
@ -45,6 +51,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 {
@ -86,3 +94,26 @@ export type SystemIOContext = {
project: string
}
}
export type RequestedKCLFile = {
requestedProjectName: string
requestedFileName: string
requestedCode: string
}
// Custom event for navigation completion
export const NAVIGATION_COMPLETE_EVENT = 'navigation-complete'
export const waitForIdleState = async ({
systemIOActor,
}: { systemIOActor: ActorRefFrom<typeof systemIOMachine> }) => {
const waitForIdlePromise = new Promise((resolve) => {
const subscription = systemIOActor.subscribe((state) => {
if (state.matches(SystemIOMachineStates.idle)) {
subscription.unsubscribe()
resolve(undefined)
}
})
})
return waitForIdlePromise
}