Compare commits

...

22 Commits

Author SHA1 Message Date
648b37c1dd Bump clap from 4.5.23 to 4.5.27 in /src/wasm-lib (#5152)
Bumps [clap](https://github.com/clap-rs/clap) from 4.5.23 to 4.5.27.
- [Release notes](https://github.com/clap-rs/clap/releases)
- [Changelog](https://github.com/clap-rs/clap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/clap-rs/clap/compare/clap_complete-v4.5.23...clap_complete-v4.5.27)

---
updated-dependencies:
- dependency-name: clap
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-27 21:29:09 +00:00
18e5da5ca4 bump modeling commands (#5145)
* add length unit

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* new images

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* updates

Signed-off-by: Jess Frazelle <github@jessfraz.com>

---------

Signed-off-by: Jess Frazelle <github@jessfraz.com>
2025-01-27 11:36:00 -08:00
46524cda10 Bump vite from 5.4.11 to 5.4.14 in /packages/codemirror-lang-kcl (#5128)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.11 to 5.4.14.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.14/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.14/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-27 17:22:19 +00:00
max
4585671a5d Refactor getNodePathFromSourceRange (#5139)
* Refactor getNodePathFromSourceRange

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Trigger CI

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Trigger CI

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Trigger CI

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Trigger CI

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-01-27 08:24:28 -05:00
e7203b9e7a export ast visitor stubs (#5141) 2025-01-24 12:50:23 -05:00
ab375f4b92 Add in a Node::ptr_eq helper (#5140)
Add in a Node::ptr_eq helper

This looks a bit gross (I get it), but it's helpful when walking the
tree. It means we can construct nodes (via `into`) of borrows, and later
check to see if it's the same borrow later on if we need to walk the
tree multiple times or otherwise maintainer a pointer to a specific AST
entry.

The idea here is you could collect nodes, then traverse the tree again,
and know when you've reached the exact same node again.
2025-01-24 12:49:59 -05:00
04ed6f52ee Add ability to create and open URLs to create files (#4166)
* Rename `homeMachine` and accessories to `projectsMachine`

* Separate out `/home` route from `projectsMachine`

* Add logic to navigate out from deleted or renamed project

* Show a warning in the command palette for deleting a project

* Make it navigate when you create a project

* Update "New project" button to use command bar flow
Closes #2585

* More explicit warning message text

* Make projects watching code not run in web

* Tests first version: nested loops

* Tests second version: flattened

* Remove console logs

* Fix tsc

* @jtran feedback, use the type guard util

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

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

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

* Fix tests that relied on one-click, no-navigation project creation

* Revert "A snapshot a day keeps the bugs away! 📷🐛 (OS: windows-latest)"

This reverts commit 7545b61b49.

* Revert "A snapshot a day keeps the bugs away! 📷🐛 (OS: windows-latest)"

This reverts commit 3d2e48732c.

* Add a mask to the state indicator to client-side scale test

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

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

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

* Fix lint

* Fix tsc

* Add menu item to share link to file

* Forward query params while redirecting to /home or /file

* Add (broken) event logic and command triggering logic

* Fix a couple stray tests that still relied on the old way of creating projects

* De-flake another text that could be thrown off by toast-based selectors

* FMT

* Dumb test error because I was rushing

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

* Ahhh more flaky toasts, they're everywhere!

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

* Side quest: Only register commands once, power their disabled status while selecting commands via optional actor

* Get query-triggered command working in browser too

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

* Tests always run on localhost, don't expect the prod origin

* rerun CI

* wip

* wip

* Everything's pretty much done but url.zoo.dev has been broken and we need to think about how to test reliably

* Merge branch 'main' into franknoirot/4088/create-file-url

* Add useCreateFileLinkQuery on Home page

* Get primary user flow working on desktop

* Rework to open browser app first, then send along to the desktop app if asked

* Styling updates to OpenInDesktopAppHandler

* Clean up unecessary file

* Merge branch 'main' into franknoirot/4088/create-file-url

* Separate creating `createFileUrl` and shortlink so it is unit testable

* Add E2E test for importing file from URL

* Add a couple component tests for OpenInDesktopAppHandler

* Fix the "existing project" user flow

* Add E2E test for "add to existing project" user flow

* Undo mistaken or unecessary changes

* Lints, fmt, tsc

* Fix unit test

* Fix broken rename and delete project commands
Something about the `optionsFromContext` config no longer works with file I/O-related commands. I suspect this has to do with our read/write loop patching

* Fix unit test, use kebab-case for url query param

* Use dev urls everywhere when configured that way

I think we were just using some constants that ended up returning bad
values for dev, it seemed to return a working shortlink when I went
through the flow.

* Clean up unneeded PROD_TOKEN

* Fix browser command flow, because we had made the projectMachine desktop-only on main

* Make the test executor a bit more patient (#5004)

* Fix so that all artifact commands are returned regardless of caching (#5005)

* Fix so that all artifact commands are returned regardless of caching

* Add some more docs and fix up old ones

* Add new lint to disallow use of confusing isNaN (#4999)

* Point-and-click Sweep (first PR) (#4989)

* Refactor 'Delete selection' as actor
Will fix #4662

* WIP logging

* WIP: working Solid3dGetExtrusionFaceInfo for loft

* Working wall deletion of loft

* Add offset plane deletion

* Add feature tree deletion of shell

* Clean up

* Revert "Clean up"

This reverts commit 214763cc2b.

* Clean up rust changes, taking the sketch with the most paths

* Working cap selection and deletion

* Clean up

* Add test for loft and offset plane deletion via selection

* A snapshot a day keeps the bugs away! 📷🐛 (OS: windows-16-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-macos-8-cores)

* Set reenter: false as it was originally

* Passing test

* Add shell deletion via feature tree test

* Revert the migration to promise actor

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Trigger CI

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Trigger CI

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Trigger CI

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Trigger CI

* Use cmd.id as solid_id after latest engine merge

* Add feature tree deletion of offset plane and fix lint

* Add feature tree deletion of loft

* Clean up

* Better comment

* Lint fix

* Remove sketch sorting

* WIP: sweep point-and-click

* Working sweep

* Add test

* Make sweep a development command

* Fix tsc error

* Clean up for review

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* Upgrade typescript-eslint from 5.62.0 to 8.19.1 and remove eslint-config-react-app (#5006)

* Fix lost lints and add new ones (#5011)

* Add eslint-plugin-jsx-a11y dependency

* Add jsx-a11y lint

* Add eslint-plugin-react-hooks dependency

* Add react hooks lints

* Ignore new react hooks lint in tests

* Add eslint-plugin-testing-library dependency

* Add testing-library lint

* Fix yarn lint to use all files recursively

* Developer workflow: added auto generated workspace file from vitest extension in vscode (#4997)

* chore: added auto generated workspace file from vitest extension in vscode

* fix: auto fmt fixes

* Change Dependabot PRs to always be made on Mondays (#5025)

* Add packages to Dependabot updates (#5024)

* Bump @lezer/generator from 1.7.1 to 1.7.2 (#5018)

Bumps [@lezer/generator](https://github.com/lezer-parser/generator) from 1.7.1 to 1.7.2.
- [Changelog](https://github.com/lezer-parser/generator/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lezer-parser/generator/compare/1.7.1...1.7.2)

---
updated-dependencies:
- dependency-name: "@lezer/generator"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump handlebars from 6.2.0 to 6.3.0 in /src/wasm-lib (#5012)

Bumps [handlebars](https://github.com/sunng87/handlebars-rust) from 6.2.0 to 6.3.0.
- [Release notes](https://github.com/sunng87/handlebars-rust/releases)
- [Changelog](https://github.com/sunng87/handlebars-rust/blob/master/CHANGELOG.md)
- [Commits](https://github.com/sunng87/handlebars-rust/compare/v6.2.0...v6.3.0)

---
updated-dependencies:
- dependency-name: handlebars
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump syn from 2.0.95 to 2.0.96 in /src/wasm-lib (#5015)

Bumps [syn](https://github.com/dtolnay/syn) from 2.0.95 to 2.0.96.
- [Release notes](https://github.com/dtolnay/syn/releases)
- [Commits](https://github.com/dtolnay/syn/compare/2.0.95...2.0.96)

---
updated-dependencies:
- dependency-name: syn
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Fix artifact types to be more accurate (#5022)

* Fix Cargo.lock to not have changes (#5034)

* Upgrade all wasm-bindgen dependencies together (#5037)

* Disable auto-updater on non-versioned builds (#5042)

* turns on helix from edge (#5036)

* updates for new lib

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* autocomplete

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* bump version

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* bump all the things

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* new samples

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* docs

Signed-off-by: Jess Frazelle <github@jessfraz.com>

---------

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* ci: Add yarn test of packages/codemirror-lang-kcl (#5035)

* ci: Add yarn test of packages/codemirror-lang-kcl

* Fix CI error running tests

* Fix postcss config error

* Bump xstate from 5.17.4 to 5.19.2 (#5027)

* Hook up chamfer UI with AST-mod (#4694)

* button

* config

* hook up with ast

* cmd bar test

* button states fix and test

* little naming fix

* xState action to actor

* remove button state test updates

* fixture-based approach

* nightly

Co-authored-by: Pierre Jacquier <pierrejacquier39@gmail.com>

* Update src/lib/toolbar.ts

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

---------

Co-authored-by: Pierre Jacquier <pierrejacquier39@gmail.com>
Co-authored-by: Frank Noirot <frank@zoo.dev>

* Remove Redundant Fillet Button State Test (#5009)

delete obsolete test

* Bump @types/node from 20.14.9 to 22.10.6 in /packages/codemirror-lsp-client (#5041)

* custom axis and origin example for helix (#5057)

* custom axis and origin for helix

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* empty

---------

Signed-off-by: Jess Frazelle <github@jessfraz.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* Bump typescript from 5.7.2 to 5.7.3 (#5021)

Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.7.2 to 5.7.3.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Changelog](https://github.com/microsoft/TypeScript/blob/main/azure-pipelines.release.yml)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.7.2...v5.7.3)

---
updated-dependencies:
- dependency-name: typescript
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Refactor: break out `copyFileShareLink` into standalone function

* Add "Share file" to command palette

* Update dumb use of site URL instead of prod app URL

* fmt

* @lf94 nit

* @pierremtb spinner feedback

* Hide share link command and disable menu item for now

* Just comment out the command config for now

---------

Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: Jess Frazelle <github@jessfraz.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: 49lf <ircsurfer33@gmail.com>
Co-authored-by: Adam Sunderland <iterion@gmail.com>
Co-authored-by: Adam Sunderland <adam@kittycad.io>
Co-authored-by: Jonathan Tran <jonnytran@gmail.com>
Co-authored-by: Pierre Jacquier <pierrejacquier39@gmail.com>
Co-authored-by: Kevin Nadro <nadr0@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jess Frazelle <jessfraz@users.noreply.github.com>
Co-authored-by: max <margorskyi@gmail.com>
2025-01-24 00:18:27 +00:00
2332338ca1 Show "No results found" for empty search results in command palette (#5143) 2025-01-23 18:49:50 -05:00
41b97de3d1 Fix: Badge indicator is now placed over the respective button in the sidebar (#4979)
* fix: positioning of the badge for the sidebar buttons

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* bump since image is wrong

* bump since image is wrong

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-01-23 14:20:41 -06:00
8ef31a0be1 Refactor: decouple command palette actor from React (#5108)
* Convert commandBarMachine to standalone actor

* Switch all uses of CommandBarProvider pattern to use actor and selector snapshots directly
2025-01-23 10:25:21 -05:00
3adb42b5f2 Supress stdio logs on e2e tests in CI (#5132)
* WIP: pw log error only

* Force tests to run on branch

* Remove all page.on('console', console.log)

* Remove context.console too

* Add --quiet flag

* Revert useless changes

* Supress stdio logs on e2e tests in CI
2025-01-23 16:13:49 +01:00
20016b101e Fix: Properly setting selection range when KCL editor is not mounted. (#4960)
* fix: fixed selection range issue when doing a constraint when the KCL editor is closed

* fix: linter and tsc errors

* fix: trying to reuse logic instead?

* fix: removed console log
2025-01-23 09:45:45 -05:00
8d9dbf36c3 Bump vite from 5.4.6 to 5.4.12 (#5129)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.6 to 5.4.12.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.12/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.12/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-22 20:58:53 -05:00
440704ed9f Remove extra margin on some code editor menu items (#5094)
* Extra padding on 'Load a sample model' menu item
Fixes #5047

* Update src/components/ModelingSidebar/ModelingPanes/KclEditorMenu.module.css

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

---------

Co-authored-by: Frank Noirot <frank@zoo.dev>
2025-01-22 16:57:27 +01:00
2261217a5d Rename debug pane label for artifact graph (#5092)
* Rename debug pane label for artifact graph

* Rename component
2025-01-22 15:37:51 +00:00
10da986649 Add dry-run validation for Sweep (#5097)
* Add dry-run validation for Sweep
Fixes #5095

* Add sweep test failing validation

* Make naming more consistent with engine

* Fix tests after big rename

* Fix tsc after main merge
2025-01-22 15:59:47 +01:00
10789d9c3c set scene units based on a module's default units (#5127)
Signed-off-by: Nick Cameron <nrc@ncameron.org>
2025-01-22 15:23:55 +13:00
67cc4f5835 Tweaks to clarify tooltips from tool dropdown menus (#5123)
* Separate content from ToolbarItemTooltip, make simple and "rich" versions

* Add support for dropdown-arrow-only tooltip

* Add toolbar-wide hover timeouts and clears to switch between simple and rich tooltips

* Fix the dropdown arrow button hover styling now that they're separate

* Add missing doc links to rich toolbar tooltips

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Re-run CI after snapshots

* fix codespell

* fmt

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-01-21 18:32:56 -05:00
2692f2b73a Add units to geometry structs (#5075)
* Make all geometry KclValue variants into struct variants

Signed-off-by: Nick Cameron <nrc@ncameron.org>

* Add units to geometry types

Signed-off-by: Nick Cameron <nrc@ncameron.org>

---------

Signed-off-by: Nick Cameron <nrc@ncameron.org>
2025-01-21 20:42:09 +00:00
965cb18059 Parse units on numeric literals and keep them in the AST (#5061)
* Code changes

Signed-off-by: Nick Cameron <nrc@ncameron.org>

* test changes

Signed-off-by: Nick Cameron <nrc@ncameron.org>

* Frontend changes

Signed-off-by: Nick Cameron <nrc@ncameron.org>

* Refactor asNum

Co-authored-by: Jonathan Tran <jonnytran@gmail.com>

---------

Signed-off-by: Nick Cameron <nrc@ncameron.org>
Co-authored-by: Jonathan Tran <jonnytran@gmail.com>
2025-01-22 08:29:30 +13:00
a022b8ef6c Fix suggestion for updating function decl syntax for anon functions (#5088)
Signed-off-by: Nick Cameron <nrc@ncameron.org>
2025-01-22 07:10:07 +13:00
4d24bf7c94 Add API Call ID log for debugging (#5107) 2025-01-20 19:49:02 +00:00
336 changed files with 101562 additions and 70729 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

28
docs/kcl/types/Face.md Normal file
View File

@ -0,0 +1,28 @@
---
title: "Face"
excerpt: "A face."
layout: manual
---
A face.
**Type:** `object`
## Properties
| Property | Type | Description | Required |
|----------|------|-------------|----------|
| `id` |`string`| The id of the face. | No |
| `value` |`string`| The tag of the face. | No |
| `xAxis` |[`Point3d`](/docs/kcl/types/Point3d)| What should the faces X axis be? | No |
| `yAxis` |[`Point3d`](/docs/kcl/types/Point3d)| What should the faces Y axis be? | No |
| `zAxis` |[`Point3d`](/docs/kcl/types/Point3d)| The z-axis (normal). | No |
| `solid` |[`Solid`](/docs/kcl/types/Solid)| The solid the face is on. | No |
| `units` |[`UnitLen`](/docs/kcl/types/UnitLen)| A face. | No |
| `__meta` |`[` [`Metadata`](/docs/kcl/types/Metadata) `]`| | No |

View File

@ -20,6 +20,7 @@ A helix.
| `revolutions` |`number`| Number of revolutions. | No |
| `angleStart` |`number`| Start angle (in degrees). | No |
| `ccw` |`boolean`| Is the helix rotation counter clockwise? | No |
| `units` |[`UnitLen`](/docs/kcl/types/UnitLen)| A helix. | No |
| `__meta` |`[` [`Metadata`](/docs/kcl/types/Metadata) `]`| | No |

View File

@ -20,6 +20,7 @@ A helix.
| `revolutions` |`number`| Number of revolutions. | No |
| `angleStart` |`number`| Start angle (in degrees). | No |
| `ccw` |`boolean`| Is the helix rotation counter clockwise? | No |
| `units` |[`UnitLen`](/docs/kcl/types/UnitLen)| A helix. | No |
| `__meta` |`[` [`Metadata`](/docs/kcl/types/Metadata) `]`| | No |

View File

@ -168,7 +168,6 @@ Any KCL value.
----
A plane.
**Type:** `object`
@ -181,17 +180,10 @@ A plane.
| Property | Type | Description | Required |
|----------|------|-------------|----------|
| `type` |enum: [`Plane`](/docs/kcl/types/Plane)| | No |
| `id` |`string`| The id of the plane. | No |
| `value` |[`PlaneType`](/docs/kcl/types/PlaneType)| Any KCL value. | No |
| `origin` |[`Point3d`](/docs/kcl/types/Point3d)| Origin of the plane. | No |
| `xAxis` |[`Point3d`](/docs/kcl/types/Point3d)| What should the planes X axis be? | No |
| `yAxis` |[`Point3d`](/docs/kcl/types/Point3d)| What should the planes Y axis be? | No |
| `zAxis` |[`Point3d`](/docs/kcl/types/Point3d)| The z-axis (normal). | No |
| `__meta` |`[` [`Metadata`](/docs/kcl/types/Metadata) `]`| | No |
| `value` |[`Plane`](/docs/kcl/types/Plane)| Any KCL value. | No |
----
A face.
**Type:** `object`
@ -203,14 +195,8 @@ A face.
| Property | Type | Description | Required |
|----------|------|-------------|----------|
| `type` |enum: `Face`| | No |
| `id` |`string`| The id of the face. | No |
| `value` |`string`| The tag of the face. | No |
| `xAxis` |[`Point3d`](/docs/kcl/types/Point3d)| What should the faces X axis be? | No |
| `yAxis` |[`Point3d`](/docs/kcl/types/Point3d)| What should the faces Y axis be? | No |
| `zAxis` |[`Point3d`](/docs/kcl/types/Point3d)| The z-axis (normal). | No |
| `solid` |[`Solid`](/docs/kcl/types/Solid)| The solid the face is on. | No |
| `__meta` |`[` [`Metadata`](/docs/kcl/types/Metadata) `]`| | No |
| `type` |enum: [`Face`](/docs/kcl/types/Face)| | No |
| `value` |[`Face`](/docs/kcl/types/Face)| Any KCL value. | No |
----
@ -246,7 +232,6 @@ A face.
----
An solid is a collection of extrude surfaces.
**Type:** `object`
@ -259,14 +244,7 @@ An solid is a collection of extrude surfaces.
| Property | Type | Description | Required |
|----------|------|-------------|----------|
| `type` |enum: [`Solid`](/docs/kcl/types/Solid)| | No |
| `id` |`string`| The id of the solid. | No |
| `value` |`[` [`ExtrudeSurface`](/docs/kcl/types/ExtrudeSurface) `]`| The extrude surfaces. | No |
| `sketch` |[`Sketch`](/docs/kcl/types/Sketch)| The sketch. | No |
| `height` |`number`| The height of the solid. | No |
| `startCapId` |`string`| The id of the extrusion start cap | No |
| `endCapId` |`string`| The id of the extrusion end cap | No |
| `edgeCuts` |`[` [`EdgeCut`](/docs/kcl/types/EdgeCut) `]`| Chamfers or fillets on this solid. | No |
| `__meta` |`[` [`Metadata`](/docs/kcl/types/Metadata) `]`| Metadata. | No |
| `value` |[`Solid`](/docs/kcl/types/Solid)| Any KCL value. | No |
----
@ -286,7 +264,6 @@ An solid is a collection of extrude surfaces.
----
A helix.
**Type:** `object`
@ -299,11 +276,7 @@ A helix.
| Property | Type | Description | Required |
|----------|------|-------------|----------|
| `type` |enum: [`Helix`](/docs/kcl/types/Helix)| | No |
| `value` |`string`| The id of the helix. | No |
| `revolutions` |`number`| Number of revolutions. | No |
| `angleStart` |`number`| Start angle (in degrees). | No |
| `ccw` |`boolean`| Is the helix rotation counter clockwise? | No |
| `__meta` |`[` [`Metadata`](/docs/kcl/types/Metadata) `]`| | No |
| `value` |[`Helix`](/docs/kcl/types/Helix)| Any KCL value. | No |
----

View File

@ -22,6 +22,7 @@ A plane.
| `xAxis` |[`Point3d`](/docs/kcl/types/Point3d)| What should the planes X axis be? | No |
| `yAxis` |[`Point3d`](/docs/kcl/types/Point3d)| What should the planes Y axis be? | No |
| `zAxis` |[`Point3d`](/docs/kcl/types/Point3d)| The z-axis (normal). | No |
| `units` |[`UnitLen`](/docs/kcl/types/UnitLen)| A plane. | No |
| `__meta` |`[` [`Metadata`](/docs/kcl/types/Metadata) `]`| | No |

View File

@ -21,6 +21,7 @@ A sketch is a collection of paths.
| `on` |[`SketchSurface`](/docs/kcl/types/SketchSurface)| What the sketch is on (can be a plane or a face). | No |
| `start` |[`BasePath`](/docs/kcl/types/BasePath)| The starting path. | No |
| `tags` |`object`| Tag identifiers that have been declared in this sketch. | No |
| `units` |[`UnitLen`](/docs/kcl/types/UnitLen)| A sketch is a collection of paths. | No |
| `__meta` |`[` [`Metadata`](/docs/kcl/types/Metadata) `]`| Metadata. | No |

View File

@ -30,6 +30,7 @@ A sketch is a collection of paths.
| `on` |[`SketchSurface`](/docs/kcl/types/SketchSurface)| What the sketch is on (can be a plane or a face). | No |
| `start` |[`BasePath`](/docs/kcl/types/BasePath)| The starting path. | No |
| `tags` |`object`| Tag identifiers that have been declared in this sketch. | No |
| `units` |[`UnitLen`](/docs/kcl/types/UnitLen)| A sketch or a group of sketches. | No |
| `__meta` |`[` [`Metadata`](/docs/kcl/types/Metadata) `]`| Metadata. | No |

View File

@ -31,6 +31,7 @@ A plane.
| `xAxis` |[`Point3d`](/docs/kcl/types/Point3d)| What should the planes X axis be? | No |
| `yAxis` |[`Point3d`](/docs/kcl/types/Point3d)| What should the planes Y axis be? | No |
| `zAxis` |[`Point3d`](/docs/kcl/types/Point3d)| The z-axis (normal). | No |
| `units` |[`UnitLen`](/docs/kcl/types/UnitLen)| A sketch type. | No |
| `__meta` |`[` [`Metadata`](/docs/kcl/types/Metadata) `]`| | No |
@ -54,6 +55,7 @@ A face.
| `yAxis` |[`Point3d`](/docs/kcl/types/Point3d)| What should the faces Y axis be? | No |
| `zAxis` |[`Point3d`](/docs/kcl/types/Point3d)| The z-axis (normal). | No |
| `solid` |[`Solid`](/docs/kcl/types/Solid)| The solid the face is on. | No |
| `units` |[`UnitLen`](/docs/kcl/types/UnitLen)| A sketch type. | No |
| `__meta` |`[` [`Metadata`](/docs/kcl/types/Metadata) `]`| | No |

View File

@ -23,6 +23,7 @@ An solid is a collection of extrude surfaces.
| `startCapId` |`string`| The id of the extrusion start cap | No |
| `endCapId` |`string`| The id of the extrusion end cap | No |
| `edgeCuts` |`[` [`EdgeCut`](/docs/kcl/types/EdgeCut) `]`| Chamfers or fillets on this solid. | No |
| `units` |[`UnitLen`](/docs/kcl/types/UnitLen)| An solid is a collection of extrude surfaces. | No |
| `__meta` |`[` [`Metadata`](/docs/kcl/types/Metadata) `]`| Metadata. | No |

View File

@ -32,6 +32,7 @@ An solid is a collection of extrude surfaces.
| `startCapId` |`string`| The id of the extrusion start cap | No |
| `endCapId` |`string`| The id of the extrusion end cap | No |
| `edgeCuts` |`[` [`EdgeCut`](/docs/kcl/types/EdgeCut) `]`| Chamfers or fillets on this solid. | No |
| `units` |[`UnitLen`](/docs/kcl/types/UnitLen)| A solid or a group of solids. | No |
| `__meta` |`[` [`Metadata`](/docs/kcl/types/Metadata) `]`| Metadata. | No |

107
docs/kcl/types/UnitLen.md Normal file
View File

@ -0,0 +1,107 @@
---
title: "UnitLen"
excerpt: ""
layout: manual
---
**This schema accepts exactly one of the following:**
**Type:** `object`
## Properties
| Property | Type | Description | Required |
|----------|------|-------------|----------|
| `type` |enum: `Mm`| | No |
----
**Type:** `object`
## Properties
| Property | Type | Description | Required |
|----------|------|-------------|----------|
| `type` |enum: `Cm`| | No |
----
**Type:** `object`
## Properties
| Property | Type | Description | Required |
|----------|------|-------------|----------|
| `type` |enum: `M`| | No |
----
**Type:** `object`
## Properties
| Property | Type | Description | Required |
|----------|------|-------------|----------|
| `type` |enum: `Inches`| | No |
----
**Type:** `object`
## Properties
| Property | Type | Description | Required |
|----------|------|-------------|----------|
| `type` |enum: `Feet`| | No |
----
**Type:** `object`
## Properties
| Property | Type | Description | Required |
|----------|------|-------------|----------|
| `type` |enum: `Yards`| | No |
----

View File

@ -1,7 +1,8 @@
import { test, expect } from './zoo-test'
import { getUtils } from './test-utils'
import * as fsp from 'fs/promises'
import { executorInputPath, getUtils } from './test-utils'
import { KCL_DEFAULT_LENGTH } from 'lib/constants'
import path from 'path'
test.describe('Command bar tests', () => {
test('Extrude from command bar selects extrude line after', async ({
@ -305,4 +306,132 @@ test.describe('Command bar tests', () => {
await arcToolCommand.click()
await expect(arcToolButton).toHaveAttribute('aria-pressed', 'true')
})
test(`Reacts to query param to open "import from URL" command`, async ({
page,
cmdBar,
editor,
homePage,
}) => {
await test.step(`Prepare and navigate to home page with query params`, async () => {
const targetURL = `?create-file&name=test&units=mm&code=ZXh0cnVzaW9uRGlzdGFuY2UgPSAxMg%3D%3D&ask-open-desktop`
await homePage.expectState({
projectCards: [],
sortBy: 'last-modified-desc',
})
await page.goto(page.url() + targetURL)
expect(page.url()).toContain(targetURL)
})
await test.step(`Submit the command`, async () => {
await cmdBar.expectState({
stage: 'arguments',
commandName: 'Import file from URL',
currentArgKey: 'method',
currentArgValue: '',
headerArguments: {
Method: '',
Name: 'test',
Code: '1 line',
},
highlightedHeaderArg: 'method',
})
await cmdBar.selectOption({ name: 'New Project' }).click()
await cmdBar.expectState({
stage: 'review',
commandName: 'Import file from URL',
headerArguments: {
Method: 'New project',
Name: 'test',
Code: '1 line',
},
})
await cmdBar.progressCmdBar()
})
await test.step(`Ensure we created the project and are in the modeling scene`, async () => {
await editor.expectEditor.toContain('extrusionDistance = 12')
})
})
test(`"import from URL" can add to existing project`, async ({
page,
cmdBar,
editor,
homePage,
toolbar,
context,
}) => {
await context.folderSetupFn(async (dir) => {
const testProjectDir = path.join(dir, 'testProjectDir')
await Promise.all([fsp.mkdir(testProjectDir, { recursive: true })])
await Promise.all([
fsp.copyFile(
executorInputPath('cylinder.kcl'),
path.join(testProjectDir, 'main.kcl')
),
])
})
await test.step(`Prepare and navigate to home page with query params`, async () => {
const targetURL = `?create-file&name=test&units=mm&code=ZXh0cnVzaW9uRGlzdGFuY2UgPSAxMg%3D%3D&ask-open-desktop`
await homePage.expectState({
projectCards: [
{
fileCount: 1,
title: 'testProjectDir',
},
],
sortBy: 'last-modified-desc',
})
await page.goto(page.url() + targetURL)
expect(page.url()).toContain(targetURL)
})
await test.step(`Submit the command`, async () => {
await cmdBar.expectState({
stage: 'arguments',
commandName: 'Import file from URL',
currentArgKey: 'method',
currentArgValue: '',
headerArguments: {
Method: '',
Name: 'test',
Code: '1 line',
},
highlightedHeaderArg: 'method',
})
await cmdBar.selectOption({ name: 'Existing Project' }).click()
await cmdBar.expectState({
stage: 'arguments',
commandName: 'Import file from URL',
currentArgKey: 'projectName',
currentArgValue: '',
headerArguments: {
Method: 'Existing project',
Name: 'test',
ProjectName: '',
Code: '1 line',
},
highlightedHeaderArg: 'projectName',
})
await cmdBar.selectOption({ name: 'testProjectDir' }).click()
await cmdBar.expectState({
stage: 'review',
commandName: 'Import file from URL',
headerArguments: {
Method: 'Existing project',
ProjectName: 'testProjectDir',
Name: 'test',
Code: '1 line',
},
})
await cmdBar.progressCmdBar()
})
await test.step(`Ensure we created the project and are in the modeling scene`, async () => {
await editor.expectEditor.toContain('extrusionDistance = 12')
await toolbar.openPane('files')
await toolbar.expectFileTreeState(['main.kcl', 'test.kcl'])
})
})
})

View File

@ -38,14 +38,14 @@ test.describe('Debug pane', () => {
// Set the code in the code editor.
await u.codeLocator.click()
await page.keyboard.type(code, { delay: 0 })
// Scroll to the feature tree.
// Scroll to the artifact graph.
await tree.scrollIntoViewIfNeeded()
// Expand the feature tree.
await tree.getByText('Feature Tree').click()
// Expand the artifact graph.
await tree.getByText('Artifact Graph').click()
// Just expanded the details, making the element taller, so scroll again.
await tree.getByText('Plane').first().scrollIntoViewIfNeeded()
})
// Extract the artifact IDs from the debug feature tree.
// Extract the artifact IDs from the debug artifact graph.
const initialSegmentIds = await segment.innerText({ timeout: 5_000 })
// The artifact ID should include a UUID.
expect(initialSegmentIds).toMatch(

View File

@ -151,4 +151,11 @@ export class CmdBarFixture {
chooseCommand = async (commandName: string) => {
await this.cmdOptions.getByText(commandName).click()
}
/**
* Select an option from the command bar
*/
selectOption = (options: Parameters<typeof this.page.getByRole>[1]) => {
return this.page.getByRole('option', options)
}
}

View File

@ -963,37 +963,31 @@ sketch002 = startSketchOn('XZ')
await toolbar.sweepButton.click()
await cmdBar.expectState({
commandName: 'Sweep',
currentArgKey: 'profile',
currentArgKey: 'target',
currentArgValue: '',
headerArguments: {
Path: '',
Profile: '',
Target: '',
Trajectory: '',
},
highlightedHeaderArg: 'profile',
highlightedHeaderArg: 'target',
stage: 'arguments',
})
await clickOnSketch1()
await cmdBar.expectState({
commandName: 'Sweep',
currentArgKey: 'path',
currentArgKey: 'trajectory',
currentArgValue: '',
headerArguments: {
Path: '',
Profile: '1 face',
Target: '1 face',
Trajectory: '',
},
highlightedHeaderArg: 'path',
highlightedHeaderArg: 'trajectory',
stage: 'arguments',
})
await clickOnSketch2()
await cmdBar.expectState({
commandName: 'Sweep',
headerArguments: {
Path: '1 face',
Profile: '1 face',
},
stage: 'review',
})
await page.waitForTimeout(500)
await cmdBar.progressCmdBar()
await page.waitForTimeout(500)
})
await test.step(`Confirm code is added to the editor, scene has changed`, async () => {
@ -1020,6 +1014,75 @@ sketch002 = startSketchOn('XZ')
})
})
test(`Sweep point-and-click failing validation`, async ({
context,
page,
homePage,
scene,
toolbar,
cmdBar,
}) => {
const initialCode = `sketch001 = startSketchOn('YZ')
|> circle({
center = [0, 0],
radius = 500
}, %)
sketch002 = startSketchOn('XZ')
|> startProfileAt([0, 0], %)
|> xLine(-500, %)
|> lineTo([-2000, 500], %)
`
await context.addInitScript((initialCode) => {
localStorage.setItem('persistCode', initialCode)
}, initialCode)
await page.setBodyDimensions({ width: 1000, height: 500 })
await homePage.goToModelingScene()
await scene.waitForExecutionDone()
// One dumb hardcoded screen pixel value
const testPoint = { x: 700, y: 250 }
const [clickOnSketch1] = scene.makeMouseHelpers(testPoint.x, testPoint.y)
const [clickOnSketch2] = scene.makeMouseHelpers(testPoint.x - 50, testPoint.y)
await test.step(`Look for sketch001`, async () => {
await toolbar.closePane('code')
await scene.expectPixelColor([53, 53, 53], testPoint, 15)
})
await test.step(`Go through the command bar flow and fail validation with a toast`, async () => {
await toolbar.sweepButton.click()
await cmdBar.expectState({
commandName: 'Sweep',
currentArgKey: 'target',
currentArgValue: '',
headerArguments: {
Target: '',
Trajectory: '',
},
highlightedHeaderArg: 'target',
stage: 'arguments',
})
await clickOnSketch1()
await cmdBar.expectState({
commandName: 'Sweep',
currentArgKey: 'trajectory',
currentArgValue: '',
headerArguments: {
Target: '1 face',
Trajectory: '',
},
highlightedHeaderArg: 'trajectory',
stage: 'arguments',
})
await clickOnSketch2()
await page.waitForTimeout(500)
await cmdBar.progressCmdBar()
await expect(
page.getByText('Unable to sweep with the provided selection')
).toBeVisible()
})
})
test(`Fillet point-and-click`, async ({
context,
page,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

After

Width:  |  Height:  |  Size: 145 KiB

View File

@ -113,9 +113,9 @@
"test:unit": "vitest run --mode development --exclude **/kclSamples.test.ts",
"test:unit:kcl-samples": "vitest run --mode development ./src/lang/kclSamples.test.ts",
"test:playwright:electron": "playwright test --config=playwright.electron.config.ts --grep-invert='@snapshot'",
"test:playwright:electron:windows": "playwright test --config=playwright.electron.config.ts --grep-invert=\"@skipWin|@snapshot\"",
"test:playwright:electron:macos": "playwright test --config=playwright.electron.config.ts --grep-invert='@skipMacos|@snapshot'",
"test:playwright:electron:ubuntu": "playwright test --config=playwright.electron.config.ts --grep-invert='@skipLinux|@snapshot'",
"test:playwright:electron:windows": "playwright test --config=playwright.electron.config.ts --grep-invert=\"@skipWin|@snapshot\" --quiet",
"test:playwright:electron:macos": "playwright test --config=playwright.electron.config.ts --grep-invert='@skipMacos|@snapshot' --quiet",
"test:playwright:electron:ubuntu": "playwright test --config=playwright.electron.config.ts --grep-invert='@skipLinux|@snapshot' --quiet",
"test:playwright:electron:local": "yarn tron:package && NODE_ENV=development playwright test --config=playwright.electron.config.ts --grep-invert='@snapshot'",
"test:playwright:electron:windows:local": "yarn tron:package && NODE_ENV=development playwright test --config=playwright.electron.config.ts --grep-invert=\"@skipWin|@snapshot\"",
"test:playwright:electron:macos:local": "yarn tron:package && NODE_ENV=development playwright test --config=playwright.electron.config.ts --grep-invert='@skipMacos|@snapshot'",
@ -201,7 +201,7 @@
"ts-node": "^10.0.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.19.1",
"vite": "^5.4.6",
"vite": "^5.4.12",
"vite-plugin-package-version": "^1.1.0",
"vite-tsconfig-paths": "^4.3.2",
"vitest": "^1.6.0",

View File

@ -683,9 +683,9 @@ vite-tsconfig-paths@^4.3.2:
tsconfck "^3.0.3"
vite@^5.0.0:
version "5.4.11"
resolved "https://registry.yarnpkg.com/vite/-/vite-5.4.11.tgz#3b415cd4aed781a356c1de5a9ebafb837715f6e5"
integrity sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==
version "5.4.14"
resolved "https://registry.yarnpkg.com/vite/-/vite-5.4.14.tgz#ff8255edb02134df180dcfca1916c37a6abe8408"
integrity sha512-EK5cY7Q1D8JNhSaPKVK4pwBFvaTmZxEnoKXLG/U9gmdDcihQGNzFlgIvaxezFR4glP1LsuiedwMBqCXH3wZccA==
dependencies:
esbuild "^0.21.3"
postcss "^8.4.43"

View File

@ -22,13 +22,28 @@ import Gizmo from 'components/Gizmo'
import { CoreDumpManager } from 'lib/coredump'
import { UnitsMenu } from 'components/UnitsMenu'
import { CameraProjectionToggle } from 'components/CameraProjectionToggle'
import { useCreateFileLinkQuery } from 'hooks/useCreateFileLinkQueryWatcher'
import { maybeWriteToDisk } from 'lib/telemetry'
import { commandBarActor } from 'machines/commandBarMachine'
maybeWriteToDisk()
.then(() => {})
.catch(() => {})
export function App() {
const { project, file } = useLoaderData() as IndexLoaderData
// Keep a lookout for a URL query string that invokes the 'import file from URL' command
useCreateFileLinkQuery((argDefaultValues) => {
commandBarActor.send({
type: 'Find and select command',
data: {
groupId: 'projects',
name: 'Import file from URL',
argDefaultValues,
},
})
})
useRefreshSettings(PATHS.FILE + 'SETTINGS')
const navigate = useNavigate()
const filePath = useAbsoluteFilePath()

View File

@ -31,11 +31,10 @@ import {
settingsLoader,
telemetryLoader,
} from 'lib/routeLoaders'
import { CommandBarProvider } from 'components/CommandBar/CommandBarProvider'
import SettingsAuthProvider from 'components/SettingsAuthProvider'
import LspProvider from 'components/LspProvider'
import { KclContextProvider } from 'lang/KclProvider'
import { BROWSER_PROJECT_NAME } from 'lib/constants'
import { ASK_TO_OPEN_QUERY_PARAM, BROWSER_PROJECT_NAME } from 'lib/constants'
import { CoreDumpManager } from 'lib/coredump'
import { codeManager, engineCommandManager } from 'lib/singletons'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
@ -47,6 +46,7 @@ import { AppStateProvider } from 'AppState'
import { reportRejection } from 'lib/trap'
import { RouteProvider } from 'components/RouteProvider'
import { ProjectsContextProvider } from 'components/ProjectsContextProvider'
import { OpenInDesktopAppHandler } from 'components/OpenInDesktopAppHandler'
const createRouter = isDesktop() ? createHashRouter : createBrowserRouter
@ -58,7 +58,7 @@ const router = createRouter([
/* Make sure auth is the outermost provider or else we will have
* inefficient re-renders, use the react profiler to see. */
element: (
<CommandBarProvider>
<OpenInDesktopAppHandler>
<RouteProvider>
<SettingsAuthProvider>
<LspProvider>
@ -74,17 +74,26 @@ const router = createRouter([
</LspProvider>
</SettingsAuthProvider>
</RouteProvider>
</CommandBarProvider>
</OpenInDesktopAppHandler>
),
errorElement: <ErrorPage />,
children: [
{
path: PATHS.INDEX,
loader: async () => {
loader: async ({ request }) => {
const onDesktop = isDesktop()
return onDesktop
? redirect(PATHS.HOME)
: redirect(PATHS.FILE + '/%2F' + BROWSER_PROJECT_NAME)
const url = new URL(request.url)
if (onDesktop) {
return redirect(PATHS.HOME + (url.search || ''))
} else {
const searchParams = new URLSearchParams(url.search)
if (!searchParams.has(ASK_TO_OPEN_QUERY_PARAM)) {
return redirect(
PATHS.FILE + '/%2F' + BROWSER_PROJECT_NAME + (url.search || '')
)
}
}
return null
},
},
{

View File

@ -1,8 +1,7 @@
import { useRef, useMemo, memo } from 'react'
import { useRef, useMemo, memo, useCallback, useState } from 'react'
import { isCursorInSketchCommandRange } from 'lang/util'
import { engineCommandManager, kclManager } from 'lib/singletons'
import { useModelingContext } from 'hooks/useModelingContext'
import { useCommandsContext } from 'hooks/useCommandsContext'
import { useNetworkContext } from 'hooks/useNetworkContext'
import { NetworkHealthState } from 'hooks/useNetworkStatus'
import { ActionButton } from 'components/ActionButton'
@ -22,20 +21,19 @@ import {
} from 'lib/toolbar'
import { isDesktop } from 'lib/isDesktop'
import { openExternalBrowserIfDesktop } from 'lib/openWindow'
import { commandBarActor } from 'machines/commandBarMachine'
export function Toolbar({
className = '',
...props
}: React.HTMLAttributes<HTMLElement>) {
const { state, send, context } = useModelingContext()
const { commandBarSend } = useCommandsContext()
const iconClassName =
'group-disabled:text-chalkboard-50 !text-inherit dark:group-enabled:group-hover:!text-inherit'
const bgClassName = '!bg-transparent'
const buttonBgClassName =
'bg-chalkboard-transparent dark:bg-transparent disabled:bg-transparent dark:disabled:bg-transparent enabled:hover:bg-chalkboard-10 dark:enabled:hover:bg-chalkboard-100 pressed:!bg-primary pressed:enabled:hover:!text-chalkboard-10'
const buttonBorderClassName =
'!border-transparent hover:!border-chalkboard-20 dark:enabled:hover:!border-primary pressed:!border-primary ui-open:!border-primary'
const buttonBorderClassName = '!border-transparent'
const sketchPathId = useMemo(() => {
if (!isSingleCursorInPipe(context.selectionRanges, kclManager.ast))
@ -50,6 +48,7 @@ export function Toolbar({
const { overallState } = useNetworkContext()
const { isExecuting } = useKclContext()
const { isStreamReady } = useAppState()
const [showRichContent, setShowRichContent] = useState(false)
const disableAllButtons =
(overallState !== NetworkHealthState.Ok &&
@ -71,12 +70,45 @@ export function Toolbar({
() => ({
modelingState: state,
modelingSend: send,
commandBarSend,
sketchPathId,
}),
[state, send, commandBarSend, sketchPathId]
[state, send, commandBarActor.send, sketchPathId]
)
const tooltipContentClassName = !showRichContent
? ''
: '!text-left text-wrap !text-xs !p-0 !pb-2 flex gap-2 !max-w-none !w-72 flex-col items-stretch'
const richContentTimeout = useRef<number | null>(null)
const richContentClearTimeout = useRef<number | null>(null)
// On mouse enter, show rich content after a 1s delay
const handleMouseEnter = useCallback(() => {
// Cancel the clear timeout if it's already set
if (richContentClearTimeout.current) {
clearTimeout(richContentClearTimeout.current)
}
// Start our own timeout to show the rich content
richContentTimeout.current = window.setTimeout(() => {
setShowRichContent(true)
if (richContentClearTimeout.current) {
clearTimeout(richContentClearTimeout.current)
}
}, 1000)
}, [setShowRichContent])
// On mouse leave, clear the timeout and hide rich content
const handleMouseLeave = useCallback(() => {
// Clear the timeout to show rich content
if (richContentTimeout.current) {
clearTimeout(richContentTimeout.current)
}
// Start a timeout to hide the rich content
richContentClearTimeout.current = window.setTimeout(() => {
setShowRichContent(false)
if (richContentClearTimeout.current) {
clearTimeout(richContentClearTimeout.current)
}
}, 500)
}, [setShowRichContent])
/**
* Resolve all the callbacks and values for the current mode,
* so we don't need to worry about the other modes
@ -174,44 +206,64 @@ export function Toolbar({
status: itemConfig.status,
}))}
>
<ActionButton
Element="button"
id={maybeIconConfig[0].id}
data-testid={maybeIconConfig[0].id}
iconStart={{
icon: maybeIconConfig[0].icon,
className: iconClassName,
bgClassName: bgClassName,
}}
className={
'!border-transparent !px-0 pressed:!text-chalkboard-10 pressed:enabled:hovered:!text-chalkboard-10 ' +
buttonBgClassName
}
aria-pressed={maybeIconConfig[0].isActive}
disabled={
disableAllButtons ||
maybeIconConfig[0].status !== 'available' ||
maybeIconConfig[0].disabled
}
name={maybeIconConfig[0].title}
// aria-description is still in ARIA 1.3 draft.
// eslint-disable-next-line jsx-a11y/aria-props
aria-description={maybeIconConfig[0].description}
onClick={() =>
maybeIconConfig[0].onClick(configCallbackProps)
}
<div
className="contents"
// Mouse events do not fire on disabled buttons
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<span
className={!maybeIconConfig[0].showTitle ? 'sr-only' : ''}
<ActionButton
Element="button"
id={maybeIconConfig[0].id}
data-testid={maybeIconConfig[0].id}
iconStart={{
icon: maybeIconConfig[0].icon,
className: iconClassName,
bgClassName: bgClassName,
}}
className={
'!border-transparent !px-0 pressed:!text-chalkboard-10 pressed:enabled:hovered:!text-chalkboard-10 ' +
buttonBgClassName
}
aria-pressed={maybeIconConfig[0].isActive}
disabled={
disableAllButtons ||
maybeIconConfig[0].status !== 'available' ||
maybeIconConfig[0].disabled
}
name={maybeIconConfig[0].title}
// aria-description is still in ARIA 1.3 draft.
// eslint-disable-next-line jsx-a11y/aria-props
aria-description={maybeIconConfig[0].description}
onClick={() =>
maybeIconConfig[0].onClick(configCallbackProps)
}
>
{maybeIconConfig[0].title}
</span>
</ActionButton>
<ToolbarItemTooltip
itemConfig={maybeIconConfig[0]}
configCallbackProps={configCallbackProps}
className="ui-open:!hidden"
/>
<span
className={!maybeIconConfig[0].showTitle ? 'sr-only' : ''}
>
{maybeIconConfig[0].title}
</span>
<ToolbarItemTooltip
itemConfig={maybeIconConfig[0]}
configCallbackProps={configCallbackProps}
wrapperClassName="ui-open:!hidden"
contentClassName={tooltipContentClassName}
>
{showRichContent ? (
<ToolbarItemTooltipRichContent
itemConfig={maybeIconConfig[0]}
/>
) : (
<ToolbarItemTooltipShortContent
status={maybeIconConfig[0].status}
title={maybeIconConfig[0].title}
hotkey={maybeIconConfig[0].hotkey}
/>
)}
</ToolbarItemTooltip>
</ActionButton>
</div>
</ActionButtonDropdown>
)
}
@ -219,7 +271,13 @@ export function Toolbar({
// A single button
return (
<div className="relative" key={itemConfig.id}>
<div
className="relative"
key={itemConfig.id}
// Mouse events do not fire on disabled buttons
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<ActionButton
Element="button"
key={itemConfig.id}
@ -256,7 +314,18 @@ export function Toolbar({
<ToolbarItemTooltip
itemConfig={itemConfig}
configCallbackProps={configCallbackProps}
/>
contentClassName={tooltipContentClassName}
>
{showRichContent ? (
<ToolbarItemTooltipRichContent itemConfig={itemConfig} />
) : (
<ToolbarItemTooltipShortContent
status={itemConfig.status}
title={itemConfig.title}
hotkey={itemConfig.hotkey}
/>
)}
</ToolbarItemTooltip>
</div>
)
})}
@ -270,6 +339,12 @@ export function Toolbar({
)
}
interface ToolbarItemContentsProps extends React.PropsWithChildren {
itemConfig: ToolbarItemResolved
configCallbackProps: ToolbarItemCallbackProps
wrapperClassName?: string
contentClassName?: string
}
/**
* The single button and dropdown button share content, so we extract it here
* It contains a tooltip with the title, description, and links
@ -278,14 +353,10 @@ export function Toolbar({
const ToolbarItemTooltip = memo(function ToolbarItemContents({
itemConfig,
configCallbackProps,
className,
}: {
itemConfig: ToolbarItemResolved
configCallbackProps: ToolbarItemCallbackProps
className?: string
}) {
const { state } = useModelingContext()
wrapperClassName = '',
contentClassName = '',
children,
}: ToolbarItemContentsProps) {
useHotkeys(
itemConfig.hotkey || '',
() => {
@ -310,10 +381,48 @@ const ToolbarItemTooltip = memo(function ToolbarItemContents({
}
hoverOnly
position="bottom"
wrapperClassName={'!p-4 !pointer-events-auto ' + className}
contentClassName="!text-left text-wrap !text-xs !p-0 !pb-2 flex gap-2 !max-w-none !w-72 flex-col items-stretch"
wrapperClassName={'!p-4 !pointer-events-auto ' + wrapperClassName}
contentClassName={contentClassName}
delay={0}
>
{children}
</Tooltip>
)
})
const ToolbarItemTooltipShortContent = ({
status,
title,
hotkey,
}: {
status: string
title: string
hotkey?: string | string[]
}) => (
<span
className={`text-sm ${
status !== 'available' ? 'text-chalkboard-70 dark:text-chalkboard-40' : ''
}`}
>
{title}
{hotkey && (
<kbd className="inline-block ml-2 flex-none hotkey">{hotkey}</kbd>
)}
</span>
)
const ToolbarItemTooltipRichContent = ({
itemConfig,
}: {
itemConfig: ToolbarItemResolved
}) => {
const { state } = useModelingContext()
return (
<>
<div className="rounded-top flex items-center gap-2 pt-3 pb-2 px-2 bg-chalkboard-20/50 dark:bg-chalkboard-80/50">
{itemConfig.icon && (
<CustomIcon className="w-5 h-5" name={itemConfig.icon} />
)}
<span
className={`text-sm flex-1 ${
itemConfig.status !== 'available'
@ -382,6 +491,6 @@ const ToolbarItemTooltip = memo(function ToolbarItemContents({
</ul>
</>
)}
</Tooltip>
</>
)
})
}

View File

@ -46,8 +46,8 @@ import {
} from 'lang/modifyAst'
import { ActionButton } from 'components/ActionButton'
import { err, reportRejection, trap } from 'lib/trap'
import { useCommandsContext } from 'hooks/useCommandsContext'
import { Node } from 'wasm-lib/kcl/bindings/Node'
import { commandBarActor } from 'machines/commandBarMachine'
function useShouldHideScene(): { hideClient: boolean; hideServer: boolean } {
const [isCamMoving, setIsCamMoving] = useState(false)
@ -510,7 +510,6 @@ const ConstraintSymbol = ({
constrainInfo: ConstrainInfo
verticalPosition: 'top' | 'bottom'
}) => {
const { commandBarSend } = useCommandsContext()
const { context } = useModelingContext()
const varNameMap: {
[key in ConstrainInfo['type']]: {
@ -630,7 +629,7 @@ const ConstraintSymbol = ({
// disabled={implicitDesc} TODO why does this change styles that are hard to override?
onClick={toSync(async () => {
if (!isConstrained) {
commandBarSend({
commandBarActor.send({
type: 'Find and select command',
data: {
name: 'Constrain with named value',
@ -756,7 +755,6 @@ export const CamDebugSettings = () => {
sceneInfra.camControls.reactCameraProperties
)
const [fov, setFov] = useState(12)
const { commandBarSend } = useCommandsContext()
useEffect(() => {
sceneInfra.camControls.setReactCameraPropertiesCallback(setCamSettings)
@ -775,7 +773,7 @@ export const CamDebugSettings = () => {
type="checkbox"
checked={camSettings.type === 'perspective'}
onChange={() =>
commandBarSend({
commandBarActor.send({
type: 'Find and select command',
data: {
groupId: 'settings',

View File

@ -69,7 +69,8 @@ import {
codeManager,
editorManager,
} from 'lib/singletons'
import { getNodeFromPath, getNodePathFromSourceRange } from 'lang/queryAst'
import { getNodeFromPath } from 'lang/queryAst'
import { getNodePathFromSourceRange } from 'lang/queryAstNodePathUtils'
import { executeAst, ToolTip } from 'lang/langHelpers'
import {
createProfileStartHandle,
@ -1398,23 +1399,23 @@ export class SceneEntities {
const arg0 = arg(kclCircle3PointArgs[0])
if (!arg0) return kclManager.ast
arg0[0].value = points[0].x
arg0[0].value = { value: points[0].x, suffix: 'None' }
arg0[0].raw = points[0].x.toString()
arg0[1].value = points[0].y
arg0[1].value = { value: points[0].y, suffix: 'None' }
arg0[1].raw = points[0].y.toString()
const arg1 = arg(kclCircle3PointArgs[1])
if (!arg1) return kclManager.ast
arg1[0].value = points[1].x
arg1[0].value = { value: points[1].x, suffix: 'None' }
arg1[0].raw = points[1].x.toString()
arg1[1].value = points[1].y
arg1[1].value = { value: points[1].y, suffix: 'None' }
arg1[1].raw = points[1].y.toString()
const arg2 = arg(kclCircle3PointArgs[2])
if (!arg2) return kclManager.ast
arg2[0].value = points[2].x
arg2[0].value = { value: points[2].x, suffix: 'None' }
arg2[0].raw = points[2].x.toString()
arg2[1].value = points[2].y
arg2[1].value = { value: points[2].y, suffix: 'None' }
arg2[1].raw = points[2].y.toString()
const astSnapshot = structuredClone(kclManager.ast)
@ -2051,8 +2052,8 @@ export class SceneEntities {
)
if (!(sk instanceof Reason)) {
sketch = sk
} else if ((maybeSketch as Solid).sketch) {
sketch = (maybeSketch as Solid).sketch
} else if (maybeSketch && (maybeSketch.value as Solid)?.sketch) {
sketch = (maybeSketch.value as Solid).sketch
}
if (!sketch) return
@ -2541,7 +2542,7 @@ export function sketchFromPathToNode({
const varDec = _varDec.node
const result = programMemory.get(varDec?.id?.name || '')
if (result?.type === 'Solid') {
return result.sketch
return result.value.sketch
}
const sg = sketchFromKclValue(result, varDec?.id?.name)
if (err(sg)) {

View File

@ -61,6 +61,7 @@ import { SegmentInputs } from 'lang/std/stdTypes'
import { err } from 'lib/trap'
import { editorManager, sceneInfra } from 'lib/singletons'
import { Selections } from 'lib/selections'
import { commandBarActor } from 'machines/commandBarMachine'
interface CreateSegmentArgs {
input: SegmentInputs
@ -847,7 +848,7 @@ function createLengthIndicator({
})
// Command Bar
editorManager.commandBarSend({
commandBarActor.send({
type: 'Find and select command',
data: {
name: 'Constrain length',

View File

@ -1,9 +1,11 @@
import { Popover } from '@headlessui/react'
import { ActionButtonProps } from './ActionButton'
import { CustomIcon } from './CustomIcon'
import Tooltip from './Tooltip'
type ActionButtonSplitProps = ActionButtonProps & { Element: 'button' } & {
name?: string
dropdownTooltipText?: string
splitMenuItems: {
id: string
label: string
@ -17,6 +19,7 @@ type ActionButtonSplitProps = ActionButtonProps & { Element: 'button' } & {
export function ActionButtonDropdown({
splitMenuItems,
className,
dropdownTooltipText = 'More tools',
children,
...props
}: ActionButtonSplitProps) {
@ -26,7 +29,14 @@ export function ActionButtonDropdown({
{({ close }) => (
<>
{children}
<Popover.Button className="border-transparent dark:border-transparent p-0 m-0 rounded-none !outline-none ui-open:border-primary ui-open:bg-primary">
<Popover.Button
className={
'!border-transparent dark:!border-transparent ' +
'bg-chalkboard-transparent dark:bg-transparent disabled:bg-transparent dark:disabled:bg-transparent ' +
'enabled:hover:bg-chalkboard-10 dark:enabled:hover:bg-chalkboard-100 ' +
'pressed:!bg-primary pressed:enabled:hover:!text-chalkboard-10 p-0 m-0 rounded-none !outline-none ui-open:border-primary ui-open:bg-primary'
}
>
<CustomIcon
name="caretDown"
className={
@ -37,6 +47,14 @@ export function ActionButtonDropdown({
<span className="sr-only">
{props.name ? props.name + ': ' : ''}open menu
</span>
<Tooltip
delay={0}
position="bottom"
hoverOnly
wrapperClassName="ui-open:!hidden"
>
{dropdownTooltipText}
</Tooltip>
</Popover.Button>
<Popover.Panel
as="ul"

View File

@ -1,6 +1,7 @@
import { useModelingContext } from 'hooks/useModelingContext'
import { editorManager, engineCommandManager, kclManager } from 'lib/singletons'
import { getNodeFromPath, getNodePathFromSourceRange } from 'lang/queryAst'
import { getNodeFromPath } from 'lang/queryAst'
import { getNodePathFromSourceRange } from 'lang/queryAstNodePathUtils'
import { useEffect, useRef, useState } from 'react'
import { trap } from 'lib/trap'
import { codeToIdSelections } from 'lib/selections'

View File

@ -1,8 +1,8 @@
import { Combobox } from '@headlessui/react'
import { useSelector } from '@xstate/react'
import Fuse from 'fuse.js'
import { useCommandsContext } from 'hooks/useCommandsContext'
import { CommandArgument, CommandArgumentOption } from 'lib/commandTypes'
import { commandBarActor, useCommandBarState } from 'machines/commandBarMachine'
import { useEffect, useMemo, useRef, useState } from 'react'
import { AnyStateMachine, StateFrom } from 'xstate'
@ -23,7 +23,7 @@ function CommandArgOptionInput({
placeholder?: string
}) {
const actorContext = useSelector(arg.machineActor, contextSelector)
const { commandBarSend, commandBarState } = useCommandsContext()
const commandBarState = useCommandBarState()
const resolvedOptions = useMemo(
() =>
typeof arg.options === 'function'
@ -129,6 +129,7 @@ function CommandArgOptionInput({
<label
htmlFor="option-input"
className="capitalize px-2 py-1 rounded-l bg-chalkboard-100 dark:bg-chalkboard-80 text-chalkboard-10 border-b border-b-chalkboard-100 dark:border-b-chalkboard-80"
data-testid="cmd-bar-arg-name"
>
{argName}
</label>
@ -142,7 +143,7 @@ function CommandArgOptionInput({
className="flex-grow px-2 py-1 border-b border-b-chalkboard-100 dark:border-b-chalkboard-80 !bg-transparent focus:outline-none"
onKeyDown={(event) => {
if (event.metaKey && event.key === 'k')
commandBarSend({ type: 'Close' })
commandBarActor.send({ type: 'Close' })
if (event.key === 'Backspace' && !event.currentTarget.value) {
stepBack()
}

View File

@ -1,6 +1,5 @@
import { Dialog, Popover, Transition } from '@headlessui/react'
import { Fragment, useEffect } from 'react'
import { useCommandsContext } from 'hooks/useCommandsContext'
import CommandBarArgument from './CommandBarArgument'
import CommandComboBox from '../CommandComboBox'
import CommandBarReview from './CommandBarReview'
@ -8,12 +7,13 @@ import { useLocation } from 'react-router-dom'
import useHotkeyWrapper from 'lib/hotkeyWrapper'
import { CustomIcon } from 'components/CustomIcon'
import Tooltip from 'components/Tooltip'
import { commandBarActor, useCommandBarState } from 'machines/commandBarMachine'
export const COMMAND_PALETTE_HOTKEY = 'mod+k'
export const CommandBar = () => {
const { pathname } = useLocation()
const { commandBarState, commandBarSend } = useCommandsContext()
const commandBarState = useCommandBarState()
const {
context: { selectedCommand, currentArgument, commands },
} = commandBarState
@ -23,16 +23,16 @@ export const CommandBar = () => {
// Close the command bar when navigating
useEffect(() => {
if (commandBarState.matches('Closed')) return
commandBarSend({ type: 'Close' })
commandBarActor.send({ type: 'Close' })
}, [pathname])
// Hook up keyboard shortcuts
useHotkeyWrapper([COMMAND_PALETTE_HOTKEY], () => {
if (commandBarState.context.commands.length === 0) return
if (commandBarState.matches('Closed')) {
commandBarSend({ type: 'Open' })
commandBarActor.send({ type: 'Open' })
} else {
commandBarSend({ type: 'Close' })
commandBarActor.send({ type: 'Close' })
}
})
@ -52,14 +52,14 @@ export const CommandBar = () => {
...entries[entries.length - 1][1],
}
commandBarSend({
commandBarActor.send({
type: 'Edit argument',
data: {
arg: currentArg,
},
})
} else {
commandBarSend({ type: 'Deselect command' })
commandBarActor.send({ type: 'Deselect command' })
}
} else {
const entries = Object.entries(selectedCommand?.args || {})
@ -68,9 +68,9 @@ export const CommandBar = () => {
)
if (index === 0) {
commandBarSend({ type: 'Deselect command' })
commandBarActor.send({ type: 'Deselect command' })
} else {
commandBarSend({
commandBarActor.send({
type: 'Change current argument',
data: {
arg: { name: entries[index - 1][0], ...entries[index - 1][1] },
@ -85,14 +85,14 @@ export const CommandBar = () => {
show={!commandBarState.matches('Closed') || false}
afterLeave={() => {
if (selectedCommand?.onCancel) selectedCommand.onCancel()
commandBarSend({ type: 'Clear' })
commandBarActor.send({ type: 'Clear' })
}}
as={Fragment}
>
<WrapperComponent
open={!commandBarState.matches('Closed') || isSelectionArgument}
onClose={() => {
commandBarSend({ type: 'Close' })
commandBarActor.send({ type: 'Close' })
}}
className={
'fixed inset-0 z-50 overflow-y-auto pb-4 pt-1 ' +
@ -122,7 +122,7 @@ export const CommandBar = () => {
)
)}
<button
onClick={() => commandBarSend({ type: 'Close' })}
onClick={() => commandBarActor.send({ type: 'Close' })}
className="group block !absolute left-auto right-full top-[-3px] m-2.5 p-0 border-none bg-transparent hover:bg-transparent"
>
<CustomIcon

View File

@ -2,13 +2,13 @@ import CommandArgOptionInput from './CommandArgOptionInput'
import CommandBarBasicInput from './CommandBarBasicInput'
import CommandBarSelectionInput from './CommandBarSelectionInput'
import { CommandArgument } from 'lib/commandTypes'
import { useCommandsContext } from 'hooks/useCommandsContext'
import CommandBarHeader from './CommandBarHeader'
import CommandBarKclInput from './CommandBarKclInput'
import CommandBarTextareaInput from './CommandBarTextareaInput'
import { commandBarActor, useCommandBarState } from 'machines/commandBarMachine'
function CommandBarArgument({ stepBack }: { stepBack: () => void }) {
const { commandBarState, commandBarSend } = useCommandsContext()
const commandBarState = useCommandBarState()
const {
context: { currentArgument },
} = commandBarState
@ -16,7 +16,7 @@ function CommandBarArgument({ stepBack }: { stepBack: () => void }) {
function onSubmit(data: unknown) {
if (!currentArgument) return
commandBarSend({
commandBarActor.send({
type: 'Submit argument',
data: {
[currentArgument.name]: data,

View File

@ -1,5 +1,5 @@
import { useCommandsContext } from 'hooks/useCommandsContext'
import { CommandArgument } from 'lib/commandTypes'
import { commandBarActor, useCommandBarState } from 'machines/commandBarMachine'
import { useEffect, useRef } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
@ -15,8 +15,8 @@ function CommandBarBasicInput({
stepBack: () => void
onSubmit: (event: unknown) => void
}) {
const { commandBarSend, commandBarState } = useCommandsContext()
useHotkeys('mod + k, mod + /', () => commandBarSend({ type: 'Close' }))
const commandBarState = useCommandBarState()
useHotkeys('mod + k, mod + /', () => commandBarActor.send({ type: 'Close' }))
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {

View File

@ -1,4 +1,3 @@
import { useCommandsContext } from 'hooks/useCommandsContext'
import { CustomIcon } from '../CustomIcon'
import React, { useState } from 'react'
import { ActionButton } from '../ActionButton'
@ -7,9 +6,10 @@ import { useHotkeys } from 'react-hotkeys-hook'
import { KclCommandValue, KclExpressionWithVariable } from 'lib/commandTypes'
import Tooltip from 'components/Tooltip'
import { roundOff } from 'lib/utils'
import { commandBarActor, useCommandBarState } from 'machines/commandBarMachine'
function CommandBarHeader({ children }: React.PropsWithChildren<{}>) {
const { commandBarState, commandBarSend } = useCommandsContext()
const commandBarState = useCommandBarState()
const {
context: { selectedCommand, currentArgument, argumentsToSubmit },
} = commandBarState
@ -49,7 +49,7 @@ function CommandBarHeader({ children }: React.PropsWithChildren<{}>) {
]
const arg = selectedCommand?.args[argName]
if (!argName || !arg) return
commandBarSend({
commandBarActor.send({
type: 'Change current argument',
data: { arg: { ...arg, name: argName } },
})
@ -100,7 +100,7 @@ function CommandBarHeader({ children }: React.PropsWithChildren<{}>) {
}
disabled={!isReviewing && currentArgument?.name === argName}
onClick={() => {
commandBarSend({
commandBarActor.send({
type: isReviewing
? 'Edit argument'
: 'Change current argument',

View File

@ -7,7 +7,6 @@ import {
} from '@codemirror/autocomplete'
import { EditorView, keymap, ViewUpdate } from '@codemirror/view'
import { CustomIcon } from 'components/CustomIcon'
import { useCommandsContext } from 'hooks/useCommandsContext'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { CommandArgument, KclCommandValue } from 'lib/commandTypes'
import { getSystemTheme } from 'lib/theme'
@ -20,6 +19,7 @@ import styles from './CommandBarKclInput.module.css'
import { createIdentifier, createVariableDeclaration } from 'lang/modifyAst'
import { useCodeMirror } from 'components/ModelingSidebar/ModelingPanes/CodeEditor'
import { useSelector } from '@xstate/react'
import { commandBarActor, useCommandBarState } from 'machines/commandBarMachine'
const machineContextSelector = (snapshot?: {
context: Record<string, unknown>
@ -37,7 +37,7 @@ function CommandBarKclInput({
stepBack: () => void
onSubmit: (event: unknown) => void
}) {
const { commandBarSend, commandBarState } = useCommandsContext()
const commandBarState = useCommandBarState()
const previouslySetValue = commandBarState.context.argumentsToSubmit[
arg.name
] as KclCommandValue | undefined
@ -82,7 +82,7 @@ function CommandBarKclInput({
false
)
const [canSubmit, setCanSubmit] = useState(true)
useHotkeys('mod + k, mod + /', () => commandBarSend({ type: 'Close' }))
useHotkeys('mod + k, mod + /', () => commandBarActor.send({ type: 'Close' }))
const editorRef = useRef<HTMLDivElement>(null)
const {

View File

@ -1,43 +0,0 @@
import { createActorContext } from '@xstate/react'
import { editorManager } from 'lib/singletons'
import { commandBarMachine } from 'machines/commandBarMachine'
import { useEffect } from 'react'
export const CommandsContext = createActorContext(
commandBarMachine.provide({
guards: {
'Command has no arguments': ({ context }) => {
return (
!context.selectedCommand?.args ||
Object.keys(context.selectedCommand?.args).length === 0
)
},
'All arguments are skippable': ({ context }) => {
return Object.values(context.selectedCommand!.args!).every(
(argConfig) => argConfig.skip
)
},
},
})
)
export const CommandBarProvider = ({
children,
}: {
children: React.ReactNode
}) => {
return (
<CommandsContext.Provider>
<CommandBarProviderInner>{children}</CommandBarProviderInner>
</CommandsContext.Provider>
)
}
function CommandBarProviderInner({ children }: { children: React.ReactNode }) {
const commandBarActor = CommandsContext.useActorRef()
useEffect(() => {
editorManager.setCommandBarSend(commandBarActor.send)
})
return children
}

View File

@ -1,9 +1,9 @@
import { useCommandsContext } from 'hooks/useCommandsContext'
import { commandBarActor, useCommandBarState } from 'machines/commandBarMachine'
import CommandBarHeader from './CommandBarHeader'
import { useHotkeys } from 'react-hotkeys-hook'
function CommandBarReview({ stepBack }: { stepBack: () => void }) {
const { commandBarState, commandBarSend } = useCommandsContext()
const commandBarState = useCommandBarState()
const {
context: { argumentsToSubmit, selectedCommand },
} = commandBarState
@ -33,7 +33,7 @@ function CommandBarReview({ stepBack }: { stepBack: () => void }) {
parseInt(b.keys[0], 10) - 1
]
const arg = selectedCommand?.args[argName]
commandBarSend({
commandBarActor.send({
type: 'Edit argument',
data: { arg: { ...arg, name: argName } },
})
@ -50,7 +50,7 @@ function CommandBarReview({ stepBack }: { stepBack: () => void }) {
function submitCommand(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
commandBarSend({
commandBarActor.send({
type: 'Submit command',
output: argumentsToSubmit,
})

View File

@ -1,5 +1,4 @@
import { useSelector } from '@xstate/react'
import { useCommandsContext } from 'hooks/useCommandsContext'
import { Artifact } from 'lang/std/artifactGraph'
import { CommandArgument } from 'lib/commandTypes'
import {
@ -10,6 +9,7 @@ import {
import { kclManager } from 'lib/singletons'
import { reportRejection } from 'lib/trap'
import { toSync } from 'lib/utils'
import { commandBarActor, useCommandBarState } from 'machines/commandBarMachine'
import { modelingMachine } from 'machines/modelingMachine'
import { useEffect, useMemo, useRef, useState } from 'react'
import { StateFrom } from 'xstate'
@ -49,7 +49,7 @@ function CommandBarSelectionInput({
onSubmit: (data: unknown) => void
}) {
const inputRef = useRef<HTMLInputElement>(null)
const { commandBarState, commandBarSend } = useCommandsContext()
const commandBarState = useCommandBarState()
const [hasSubmitted, setHasSubmitted] = useState(false)
const selection = useSelector(arg.machineActor, selectionSelector)
const selectionsByType = useMemo(() => {
@ -145,7 +145,7 @@ function CommandBarSelectionInput({
if (event.key === 'Backspace') {
stepBack()
} else if (event.key === 'Escape') {
commandBarSend({ type: 'Close' })
commandBarActor.send({ type: 'Close' })
}
}}
onChange={handleChange}

View File

@ -1,5 +1,5 @@
import { useCommandsContext } from 'hooks/useCommandsContext'
import { CommandArgument } from 'lib/commandTypes'
import { commandBarActor, useCommandBarState } from 'machines/commandBarMachine'
import { RefObject, useEffect, useRef } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
@ -15,8 +15,8 @@ function CommandBarTextareaInput({
stepBack: () => void
onSubmit: (event: unknown) => void
}) {
const { commandBarSend, commandBarState } = useCommandsContext()
useHotkeys('mod + k, mod + /', () => commandBarSend({ type: 'Close' }))
const commandBarState = useCommandBarState()
useHotkeys('mod + k, mod + /', () => commandBarActor.send({ type: 'Close' }))
const formRef = useRef<HTMLFormElement>(null)
const inputRef = useRef<HTMLTextAreaElement>(null)
useTextareaAutoGrow(inputRef)

View File

@ -1,16 +1,15 @@
import { useCommandsContext } from 'hooks/useCommandsContext'
import usePlatform from 'hooks/usePlatform'
import { hotkeyDisplay } from 'lib/hotkeyWrapper'
import { COMMAND_PALETTE_HOTKEY } from './CommandBar/CommandBar'
import { commandBarActor } from 'machines/commandBarMachine'
export function CommandBarOpenButton() {
const { commandBarSend } = useCommandsContext()
const platform = usePlatform()
return (
<button
className="group rounded-full flex items-center justify-center gap-2 px-2 py-1 bg-primary/10 dark:bg-chalkboard-90 dark:backdrop-blur-sm border-primary hover:border-primary dark:border-chalkboard-50 dark:hover:border-inherit text-primary dark:text-inherit"
onClick={() => commandBarSend({ type: 'Open' })}
onClick={() => commandBarActor.send({ type: 'Open' })}
data-testid="command-bar-open-button"
>
<span>Commands</span>

View File

@ -1,11 +1,11 @@
import { Combobox } from '@headlessui/react'
import Fuse from 'fuse.js'
import { useCommandsContext } from 'hooks/useCommandsContext'
import { Command } from 'lib/commandTypes'
import { useEffect, useState } from 'react'
import { CustomIcon } from './CustomIcon'
import { getActorNextEvents } from 'lib/utils'
import { sortCommands } from 'lib/commandUtils'
import { commandBarActor } from 'machines/commandBarMachine'
function CommandComboBox({
options,
@ -14,7 +14,6 @@ function CommandComboBox({
options: Command[]
placeholder?: string
}) {
const { commandBarSend } = useCommandsContext()
const [query, setQuery] = useState('')
const [filteredOptions, setFilteredOptions] = useState<typeof options>()
@ -41,7 +40,7 @@ function CommandComboBox({
}, [query])
function handleSelection(command: Command) {
commandBarSend({ type: 'Select command', data: { command } })
commandBarActor.send({ type: 'Select command', data: { command } })
}
return (
@ -61,7 +60,7 @@ function CommandComboBox({
(event.key === 'Backspace' && !event.currentTarget.value)
) {
event.preventDefault()
commandBarSend({ type: 'Close' })
commandBarActor.send({ type: 'Close' })
}
}}
placeholder={
@ -76,34 +75,40 @@ function CommandComboBox({
autoFocus
/>
</div>
<Combobox.Options
static
className="overflow-y-auto max-h-96 cursor-pointer"
>
{filteredOptions?.map((option) => (
<Combobox.Option
key={option.groupId + option.name + (option.displayName || '')}
value={option}
className="flex items-center gap-4 px-4 py-1.5 first:mt-2 last:mb-2 ui-active:bg-primary/10 dark:ui-active:bg-chalkboard-90 ui-disabled:!text-chalkboard-50"
disabled={optionIsDisabled(option)}
data-testid={`cmd-bar-option`}
>
{'icon' in option && option.icon && (
<CustomIcon name={option.icon} className="w-5 h-5" />
)}
<div className="flex-grow flex flex-col">
<p className="my-0 leading-tight">
{option.displayName || option.name}{' '}
</p>
{option.description && (
<p className="my-0 text-xs text-chalkboard-60 dark:text-chalkboard-50">
{option.description}
</p>
{filteredOptions?.length ? (
<Combobox.Options
static
className="overflow-y-auto max-h-96 cursor-pointer"
>
{filteredOptions?.map((option) => (
<Combobox.Option
key={option.groupId + option.name + (option.displayName || '')}
value={option}
className="flex items-center gap-4 px-4 py-1.5 first:mt-2 last:mb-2 ui-active:bg-primary/10 dark:ui-active:bg-chalkboard-90 ui-disabled:!text-chalkboard-50"
disabled={optionIsDisabled(option)}
data-testid={`cmd-bar-option`}
>
{'icon' in option && option.icon && (
<CustomIcon name={option.icon} className="w-5 h-5" />
)}
</div>
</Combobox.Option>
))}
</Combobox.Options>
<div className="flex-grow flex flex-col">
<p className="my-0 leading-tight">
{option.displayName || option.name}{' '}
</p>
{option.description && (
<p className="my-0 text-xs text-chalkboard-60 dark:text-chalkboard-50">
{option.description}
</p>
)}
</div>
</Combobox.Option>
))}
</Combobox.Options>
) : (
<p className="px-4 pt-2 text-chalkboard-60 dark:text-chalkboard-50">
No results found
</p>
)}
</Combobox>
)
}

View File

@ -4,18 +4,18 @@ import { expandPlane, PlaneArtifactRich } from 'lang/std/artifactGraph'
import { ArtifactGraph } from 'lang/wasm'
import { DebugDisplayArray, GenericObj } from './DebugDisplayObj'
export function DebugFeatureTree() {
const featureTree = useMemo(() => {
export function DebugArtifactGraph() {
const artifactGraphTree = useMemo(() => {
return computeTree(engineCommandManager.artifactGraph)
}, [engineCommandManager.artifactGraph])
const filterKeys: string[] = ['__meta', 'codeRef', 'pathToNode']
return (
<details data-testid="debug-feature-tree" className="relative">
<summary>Feature Tree</summary>
{featureTree.length > 0 ? (
<summary>Artifact Graph</summary>
{artifactGraphTree.length > 0 ? (
<pre className="text-xs">
<DebugDisplayArray arr={featureTree} filterKeys={filterKeys} />
<DebugDisplayArray arr={artifactGraphTree} filterKeys={filterKeys} />
</pre>
) : (
<p>(Empty)</p>

View File

@ -12,7 +12,6 @@ import {
StateFrom,
fromPromise,
} from 'xstate'
import { useCommandsContext } from 'hooks/useCommandsContext'
import { fileMachine } from 'machines/fileMachine'
import { isDesktop } from 'lib/isDesktop'
import {
@ -30,6 +29,7 @@ import {
} from 'lib/getKclSamplesManifest'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { markOnce } from 'lib/performance'
import { commandBarActor } from 'machines/commandBarMachine'
type MachineContext<T extends AnyStateMachine> = {
state: StateFrom<T>
@ -47,9 +47,9 @@ export const FileMachineProvider = ({
children: React.ReactNode
}) => {
const navigate = useNavigate()
const { commandBarSend } = useCommandsContext()
const { settings } = useSettingsAuthContext()
const { project, file } = useRouteLoaderData(PATHS.FILE) as IndexLoaderData
const { settings, auth } = useSettingsAuthContext()
const projectData = useRouteLoaderData(PATHS.FILE) as IndexLoaderData
const { project, file } = projectData
const [kclSamples, setKclSamples] = React.useState<KclSamplesManifestItem[]>(
[]
)
@ -90,7 +90,7 @@ export const FileMachineProvider = ({
navigateToFile: ({ context, event }) => {
if (event.type !== 'xstate.done.actor.create-and-open-file') return
if (event.output && 'name' in event.output) {
commandBarSend({ type: 'Close' })
commandBarActor.send({ type: 'Close' })
navigate(
`..${PATHS.FILE}/${encodeURIComponent(
context.selectedDirectory +
@ -296,55 +296,65 @@ export const FileMachineProvider = ({
const kclCommandMemo = useMemo(
() =>
kclCommands(
async (data) => {
if (data.method === 'overwrite') {
codeManager.updateCodeStateEditor(data.code)
await kclManager.executeCode(true)
await codeManager.writeToFile()
} else if (data.method === 'newFile' && isDesktop()) {
send({
type: 'Create file',
data: {
name: data.sampleName,
content: data.code,
makeDir: false,
},
})
}
// Either way, we want to overwrite the defaultUnit project setting
// with the sample's setting.
if (data.sampleUnits) {
settings.send({
type: 'set.modeling.defaultUnit',
data: {
level: 'project',
value: data.sampleUnits,
},
})
}
kclCommands({
authToken: auth?.context?.token ?? '',
projectData,
settings: {
defaultUnit: settings?.context?.modeling.defaultUnit.current ?? 'mm',
},
kclSamples.map((sample) => ({
value: sample.pathFromProjectDirectoryToFirstFile,
name: sample.title,
}))
).filter(
specialPropsForSampleCommand: {
onSubmit: async (data) => {
if (data.method === 'overwrite') {
codeManager.updateCodeStateEditor(data.code)
await kclManager.executeCode(true)
await codeManager.writeToFile()
} else if (data.method === 'newFile' && isDesktop()) {
send({
type: 'Create file',
data: {
name: data.sampleName,
content: data.code,
makeDir: false,
},
})
}
// Either way, we want to overwrite the defaultUnit project setting
// with the sample's setting.
if (data.sampleUnits) {
settings.send({
type: 'set.modeling.defaultUnit',
data: {
level: 'project',
value: data.sampleUnits,
},
})
}
},
providedOptions: kclSamples.map((sample) => ({
value: sample.pathFromProjectDirectoryToFirstFile,
name: sample.title,
})),
},
}).filter(
(command) => kclSamples.length || command.name !== 'open-kcl-example'
),
[codeManager, kclManager, send, kclSamples]
)
useEffect(() => {
commandBarSend({ type: 'Add commands', data: { commands: kclCommandMemo } })
commandBarActor.send({
type: 'Add commands',
data: { commands: kclCommandMemo },
})
return () => {
commandBarSend({
commandBarActor.send({
type: 'Remove commands',
data: { commands: kclCommandMemo },
})
}
}, [commandBarSend, kclCommandMemo])
}, [commandBarActor.send, kclCommandMemo])
return (
<FileContext.Provider

View File

@ -1,11 +1,11 @@
import { createContext, useEffect, useState } from 'react'
import { engineCommandManager } from 'lib/singletons'
import { CommandsContext } from 'components/CommandBar/CommandBarProvider'
import { isDesktop } from 'lib/isDesktop'
import { components } from 'lib/machine-api'
import { reportRejection } from 'lib/trap'
import { toSync } from 'lib/utils'
import { commandBarActor } from 'machines/commandBarMachine'
export type MachinesListing = Array<
components['schemas']['MachineInfoResponse']
@ -42,8 +42,6 @@ export const MachineManagerProvider = ({
components['schemas']['MachineInfoResponse'] | null
>(null)
const commandBarActor = CommandsContext.useActorRef()
// Get the reason message for why there are no machines.
const noMachinesReason = (): string | undefined => {
if (machines.length > 0) {

View File

@ -1,4 +1,4 @@
import { useMachine } from '@xstate/react'
import { useMachine, useSelector } from '@xstate/react'
import React, {
createContext,
useEffect,
@ -11,6 +11,7 @@ import {
AnyStateMachine,
ContextFrom,
Prop,
SnapshotFrom,
StateFrom,
assign,
fromPromise,
@ -67,18 +68,14 @@ import {
startSketchOnDefault,
} from 'lang/modifyAst'
import { PathToNode, Program, parse, recast, resultIsOk } from 'lang/wasm'
import {
artifactIsPlaneWithPaths,
getNodePathFromSourceRange,
isSingleCursorInPipe,
} from 'lang/queryAst'
import { artifactIsPlaneWithPaths, isSingleCursorInPipe } from 'lang/queryAst'
import { getNodePathFromSourceRange } from 'lang/queryAstNodePathUtils'
import { exportFromEngine } from 'lib/exportFromEngine'
import { Models } from '@kittycad/lib/dist/types/src'
import toast from 'react-hot-toast'
import { useLoaderData, useNavigate, useSearchParams } from 'react-router-dom'
import { letEngineAnimateAndSyncCamAfter } from 'clientSideScene/CameraControls'
import { err, reportRejection, trap } from 'lib/trap'
import { useCommandsContext } from 'hooks/useCommandsContext'
import {
ExportIntent,
EngineConnectionStateType,
@ -91,6 +88,7 @@ import { IndexLoaderData } from 'lib/types'
import { Node } from 'wasm-lib/kcl/bindings/Node'
import { promptToEditFlow } from 'lib/promptToEdit'
import { kclEditorActor } from 'machines/kclEditorMachine'
import { commandBarActor } from 'machines/commandBarMachine'
type MachineContext<T extends AnyStateMachine> = {
state: StateFrom<T>
@ -102,6 +100,10 @@ export const ModelingMachineContext = createContext(
{} as MachineContext<typeof modelingMachine>
)
const commandBarIsClosedSelector = (
state: SnapshotFrom<typeof commandBarActor>
) => state.matches('Closed')
export const ModelingMachineProvider = ({
children,
}: {
@ -132,8 +134,10 @@ export const ModelingMachineProvider = ({
let [searchParams] = useSearchParams()
const pool = searchParams.get('pool')
const { commandBarState, commandBarSend } = useCommandsContext()
const isCommandBarClosed = useSelector(
commandBarActor,
commandBarIsClosedSelector
)
// Settings machine setup
// const retrievedSettings = useRef(
// localStorage?.getItem(MODELING_PERSIST_KEY) || '{}'
@ -388,7 +392,16 @@ export const ModelingMachineProvider = ({
}
if (setSelections.selectionType === 'completeSelection') {
editorManager.selectRange(setSelections.selection)
const codeMirrorSelection = editorManager.createEditorSelection(
setSelections.selection
)
kclEditorActor.send({
type: 'setLastSelectionEvent',
data: {
codeMirrorSelection,
scrollIntoView: false,
},
})
if (!sketchDetails)
return {
selectionRanges: setSelections.selection,
@ -529,7 +542,6 @@ export const ModelingMachineProvider = ({
trimmedPrompt,
fileMachineSend,
navigate,
commandBarSend,
context,
token,
settings: {
@ -543,7 +555,7 @@ export const ModelingMachineProvider = ({
'has valid selection for deletion': ({
context: { selectionRanges },
}) => {
if (!commandBarState.matches('Closed')) return false
if (!isCommandBarClosed) return false
if (selectionRanges.graphSelections.length <= 0) return false
return true
},

View File

@ -1,4 +1,4 @@
import { DebugFeatureTree } from 'components/DebugFeatureTree'
import { DebugArtifactGraph } from 'components/DebugArtifactGraph'
import { AstExplorer } from '../../AstExplorer'
import { EngineCommands } from '../../EngineCommands'
import { CamDebugSettings } from 'clientSideScene/ClientSideSceneComp'
@ -14,7 +14,7 @@ export const DebugPane = () => {
<EngineCommands />
<CamDebugSettings />
<AstExplorer />
<DebugFeatureTree />
<DebugArtifactGraph />
</div>
</section>
</div>

View File

@ -3,6 +3,7 @@
@apply font-mono !no-underline text-xs font-bold select-none text-chalkboard-90;
@apply ui-active:bg-primary/10 ui-active:text-primary ui-active:text-inherit;
@apply transition-colors ease-out;
@apply m-0;
}
:global(.dark) .button {

View File

@ -9,12 +9,11 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { kclManager } from 'lib/singletons'
import { openExternalBrowserIfDesktop } from 'lib/openWindow'
import { reportRejection } from 'lib/trap'
import { useCommandsContext } from 'hooks/useCommandsContext'
import { commandBarActor } from 'machines/commandBarMachine'
export const KclEditorMenu = ({ children }: PropsWithChildren) => {
const { enable: convertToVarEnabled, handleClick: handleConvertToVarClick } =
useConvertToVariable()
const { commandBarSend } = useCommandsContext()
return (
<Menu>
@ -85,7 +84,7 @@ export const KclEditorMenu = ({ children }: PropsWithChildren) => {
<Menu.Item>
<button
onClick={() => {
commandBarSend({
commandBarActor.send({
type: 'Find and select command',
data: {
groupId: 'code',

View File

@ -95,9 +95,11 @@ export const processMemory = (programMemory: ProgramMemory) => {
) {
const sk = sketchFromKclValueOptional(val, key)
if (val.type === 'Solid') {
processedMemory[key] = val.value.map(({ ...rest }: ExtrudeSurface) => {
return rest
})
processedMemory[key] = val.value.value.map(
({ ...rest }: ExtrudeSurface) => {
return rest
}
)
} else if (!(sk instanceof Reason)) {
processedMemory[key] = sk.paths.map(({ __geoMeta, ...rest }: Path) => {
return rest

View File

@ -15,12 +15,12 @@ import { ModelingPane } from './ModelingPane'
import { isDesktop } from 'lib/isDesktop'
import { useModelingContext } from 'hooks/useModelingContext'
import { CustomIconName } from 'components/CustomIcon'
import { useCommandsContext } from 'hooks/useCommandsContext'
import { IconDefinition } from '@fortawesome/free-solid-svg-icons'
import { useKclContext } from 'lang/KclProvider'
import { MachineManagerContext } from 'components/MachineManagerProvider'
import { onboardingPaths } from 'routes/Onboarding/paths'
import { SIDEBAR_BUTTON_SUFFIX } from 'lib/constants'
import { commandBarActor } from 'machines/commandBarMachine'
interface ModelingSidebarProps {
paneOpacity: '' | 'opacity-20' | 'opacity-40'
@ -37,7 +37,6 @@ function getPlatformString(): 'web' | 'desktop' {
export function ModelingSidebar({ paneOpacity }: ModelingSidebarProps) {
const machineManager = useContext(MachineManagerContext)
const { commandBarSend } = useCommandsContext()
const kclContext = useKclContext()
const { settings } = useSettingsAuthContext()
const onboardingStatus = settings.context.app.onboardingStatus
@ -66,7 +65,7 @@ export function ModelingSidebar({ paneOpacity }: ModelingSidebarProps) {
icon: 'floppyDiskArrow',
keybinding: 'Ctrl + Shift + E',
action: () =>
commandBarSend({
commandBarActor.send({
type: 'Find and select command',
data: { name: 'Export', groupId: 'modeling' },
}),
@ -79,7 +78,7 @@ export function ModelingSidebar({ paneOpacity }: ModelingSidebarProps) {
keybinding: 'Ctrl + Shift + M',
// eslint-disable-next-line @typescript-eslint/no-misused-promises
action: async () => {
commandBarSend({
commandBarActor.send({
type: 'Find and select command',
data: { name: 'Make', groupId: 'modeling' },
})
@ -298,7 +297,7 @@ function ModelingPaneButton({
})
return (
<div id={paneConfig.id + '-button-holder'}>
<div id={paneConfig.id + '-button-holder'} className="relative">
<button
className="group pointer-events-auto flex items-center justify-center border-transparent dark:border-transparent disabled:!border-transparent p-0 m-0 rounded-sm !outline-0 focus-visible:border-primary"
onClick={onClick}
@ -340,7 +339,7 @@ function ModelingPaneButton({
<p
id={`${paneConfig.id}-badge`}
className={
'absolute m-0 p-0 top-1 right-0 w-3 h-3 flex items-center justify-center text-[10px] font-semibold text-white bg-primary hue-rotate-90 rounded-full border border-chalkboard-10 dark:border-chalkboard-80 z-50 hover:cursor-pointer hover:scale-[2] transition-transform duration-200'
'absolute m-0 p-0 bottom-4 left-4 w-3 h-3 flex items-center justify-center text-[10px] font-semibold text-white bg-primary hue-rotate-90 rounded-full border border-chalkboard-10 dark:border-chalkboard-80 z-50 hover:cursor-pointer hover:scale-[2] transition-transform duration-200'
}
onClick={showBadge.onClick}
title={`Click to view ${showBadge.value} notification${

View File

@ -1,7 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { BrowserRouter } from 'react-router-dom'
import { SettingsAuthProviderJest } from './SettingsAuthProvider'
import { CommandBarProvider } from './CommandBar/CommandBarProvider'
import {
NETWORK_HEALTH_TEXT,
NetworkHealthIndicator,
@ -12,9 +11,7 @@ function TestWrap({ children }: { children: React.ReactNode }) {
// wrap in router and xState context
return (
<BrowserRouter>
<CommandBarProvider>
<SettingsAuthProviderJest>{children}</SettingsAuthProviderJest>
</CommandBarProvider>
<SettingsAuthProviderJest>{children}</SettingsAuthProviderJest>
</BrowserRouter>
)
}

View File

@ -0,0 +1,68 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { BrowserRouter, Route, Routes } from 'react-router-dom'
import { OpenInDesktopAppHandler } from './OpenInDesktopAppHandler'
/**
* The behavior under test requires a router,
* so we wrap the component in a minimal router setup.
*/
function TestingMinimalRouterWrapper({
children,
location,
}: {
location?: string
children: React.ReactNode
}) {
return (
<Routes location={location}>
<Route
path="/"
element={<OpenInDesktopAppHandler>{children}</OpenInDesktopAppHandler>}
/>
</Routes>
)
}
describe('OpenInDesktopAppHandler tests', () => {
test(`does not render the modal if no query param is present`, () => {
render(
<BrowserRouter>
<TestingMinimalRouterWrapper>
<p>Dummy app contents</p>
</TestingMinimalRouterWrapper>
</BrowserRouter>
)
const dummyAppContents = screen.getByText('Dummy app contents')
const modalContents = screen.queryByText('Open in desktop app')
expect(dummyAppContents).toBeInTheDocument()
expect(modalContents).not.toBeInTheDocument()
})
test(`renders the modal if the query param is present`, () => {
render(
<BrowserRouter>
<TestingMinimalRouterWrapper location="/?ask-open-desktop">
<p>Dummy app contents</p>
</TestingMinimalRouterWrapper>
</BrowserRouter>
)
let dummyAppContents = screen.queryByText('Dummy app contents')
let modalButton = screen.queryByText('Continue to web app')
// Starts as disconnected
expect(dummyAppContents).not.toBeInTheDocument()
expect(modalButton).not.toBeFalsy()
expect(modalButton).toBeInTheDocument()
fireEvent.click(modalButton as Element)
// I don't like that you have to re-query the screen here
dummyAppContents = screen.queryByText('Dummy app contents')
modalButton = screen.queryByText('Continue to web app')
expect(dummyAppContents).toBeInTheDocument()
expect(modalButton).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,125 @@
import { getSystemTheme, Themes } from 'lib/theme'
import { ZOO_STUDIO_PROTOCOL } from 'lib/constants'
import { isDesktop } from 'lib/isDesktop'
import { useSearchParams } from 'react-router-dom'
import { ASK_TO_OPEN_QUERY_PARAM } from 'lib/constants'
import { VITE_KC_SITE_BASE_URL } from 'env'
import { ActionButton } from './ActionButton'
import { Transition } from '@headlessui/react'
/**
* This component is a handler that checks if a certain query parameter
* is present, and if so, it will show a modal asking the user if they
* want to open the current page in the desktop app.
*/
export const OpenInDesktopAppHandler = (props: React.PropsWithChildren) => {
const theme = getSystemTheme()
const buttonClasses =
'bg-transparent flex-0 hover:bg-primary/10 dark:hover:bg-primary/10'
const pathLogomarkSvg = `${isDesktop() ? '.' : ''}/zma-logomark${
theme === Themes.Light ? '-dark' : ''
}.svg`
const [searchParams, setSearchParams] = useSearchParams()
// We also ignore this param on desktop, as it is redundant
const hasAskToOpenParam =
!isDesktop() && searchParams.has(ASK_TO_OPEN_QUERY_PARAM)
/**
* This function removes the query param to ask to open in desktop app
* and then navigates to the same route but with our custom protocol
* `zoo-studio:` instead of `https://${BASE_URL}`, to trigger the user's
* desktop app to open.
*/
function onOpenInDesktopApp() {
const newSearchParams = new URLSearchParams(globalThis.location.search)
newSearchParams.delete(ASK_TO_OPEN_QUERY_PARAM)
const newURL = `${ZOO_STUDIO_PROTOCOL}${globalThis.location.pathname.replace(
'/',
''
)}${searchParams.size > 0 ? `?${newSearchParams.toString()}` : ''}`
globalThis.location.href = newURL
}
/**
* Just remove the query param to ask to open in desktop app
* and continue to the web app.
*/
function continueToWebApp() {
searchParams.delete(ASK_TO_OPEN_QUERY_PARAM)
setSearchParams(searchParams)
}
return hasAskToOpenParam ? (
<Transition
appear
show={true}
as="div"
className={
theme +
` fixed inset-0 grid p-4 place-content-center ${
theme === Themes.Dark ? '!bg-chalkboard-110 text-chalkboard-20' : ''
}`
}
>
<Transition.Child
as="div"
className={`max-w-3xl py-6 px-10 flex flex-col items-center gap-8
mx-auto border rounded-lg shadow-lg dark:bg-chalkboard-100`}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
style={{ zIndex: 10 }}
>
<div>
<h1 className="text-2xl">
Launching{' '}
<img
src={pathLogomarkSvg}
className="w-48"
alt="Zoo Modeling App"
/>
</h1>
</div>
<p className="text-primary flex items-center gap-2">
Choose where to open this link...
</p>
<div className="flex flex-col md:flex-row items-start justify-between gap-4 xl:gap-8">
<div className="flex flex-col gap-2">
<ActionButton
Element="button"
className={buttonClasses + ' !text-base'}
onClick={onOpenInDesktopApp}
iconEnd={{ icon: 'arrowRight' }}
>
Open in desktop app
</ActionButton>
<ActionButton
Element="externalLink"
className={
buttonClasses +
' text-sm border-transparent justify-center dark:bg-transparent'
}
to={`${VITE_KC_SITE_BASE_URL}/modeling-app/download`}
iconEnd={{ icon: 'link', bgClassName: '!bg-transparent' }}
>
Download desktop app
</ActionButton>
</div>
<ActionButton
Element="button"
className={buttonClasses + ' -order-1 !text-base'}
onClick={continueToWebApp}
iconStart={{ icon: 'arrowLeft' }}
>
Continue to web app
</ActionButton>
</div>
</Transition.Child>
</Transition>
) : (
props.children
)
}

View File

@ -2,7 +2,6 @@ import { render, screen } from '@testing-library/react'
import { BrowserRouter } from 'react-router-dom'
import ProjectSidebarMenu from './ProjectSidebarMenu'
import { SettingsAuthProviderJest } from './SettingsAuthProvider'
import { CommandBarProvider } from './CommandBar/CommandBarProvider'
import { Project } from 'lib/project'
const now = new Date()
@ -33,11 +32,9 @@ describe('ProjectSidebarMenu tests', () => {
test('Disables popover menu by default', () => {
render(
<BrowserRouter>
<CommandBarProvider>
<SettingsAuthProviderJest>
<ProjectSidebarMenu project={projectWellFormed} />
</SettingsAuthProviderJest>
</CommandBarProvider>
<SettingsAuthProviderJest>
<ProjectSidebarMenu project={projectWellFormed} />
</SettingsAuthProviderJest>
</BrowserRouter>
)

View File

@ -7,14 +7,19 @@ import { Link, useLocation, useNavigate } from 'react-router-dom'
import { Fragment, useMemo, useContext } from 'react'
import { Logo } from './Logo'
import { APP_NAME } from 'lib/constants'
import { useCommandsContext } from 'hooks/useCommandsContext'
import { CustomIcon } from './CustomIcon'
import { useLspContext } from './LspProvider'
import { engineCommandManager, kclManager } from 'lib/singletons'
import { codeManager, engineCommandManager, kclManager } from 'lib/singletons'
import { MachineManagerContext } from 'components/MachineManagerProvider'
import usePlatform from 'hooks/usePlatform'
import { useAbsoluteFilePath } from 'hooks/useAbsoluteFilePath'
import Tooltip from './Tooltip'
import { SnapshotFrom } from 'xstate'
import { commandBarActor } from 'machines/commandBarMachine'
import { useSelector } from '@xstate/react'
import { copyFileShareLink } from 'lib/links'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { DEV } from 'env'
const ProjectSidebarMenu = ({
project,
@ -84,6 +89,9 @@ function AppLogoLink({
)
}
const commandsSelector = (state: SnapshotFrom<typeof commandBarActor>) =>
state.context.commands
function ProjectMenuPopover({
project,
file,
@ -95,17 +103,16 @@ function ProjectMenuPopover({
const location = useLocation()
const navigate = useNavigate()
const filePath = useAbsoluteFilePath()
const { settings, auth } = useSettingsAuthContext()
const machineManager = useContext(MachineManagerContext)
const commands = useSelector(commandBarActor, commandsSelector)
const { commandBarState, commandBarSend } = useCommandsContext()
const { onProjectClose } = useLspContext()
const exportCommandInfo = { name: 'Export', groupId: 'modeling' }
const makeCommandInfo = { name: 'Make', groupId: 'modeling' }
const findCommand = (obj: { name: string; groupId: string }) =>
Boolean(
commandBarState.context.commands.find(
(c) => c.name === obj.name && c.groupId === obj.groupId
)
commands.find((c) => c.name === obj.name && c.groupId === obj.groupId)
)
const machineCount = machineManager.machines.length
@ -150,12 +157,11 @@ function ProjectMenuPopover({
),
disabled: !findCommand(exportCommandInfo),
onClick: () =>
commandBarSend({
commandBarActor.send({
type: 'Find and select command',
data: exportCommandInfo,
}),
},
'break',
{
id: 'make',
Element: 'button',
@ -175,12 +181,26 @@ function ProjectMenuPopover({
),
disabled: !findCommand(makeCommandInfo) || machineCount === 0,
onClick: () => {
commandBarSend({
commandBarActor.send({
type: 'Find and select command',
data: makeCommandInfo,
})
},
},
{
id: 'share-link',
Element: 'button',
children: 'Share link to file',
disabled: !DEV,
onClick: async () => {
await copyFileShareLink({
token: auth?.context.token || '',
code: codeManager.code,
name: project?.name || '',
units: settings.context.modeling.defaultUnit.current,
})
},
},
'break',
{
id: 'go-home',
@ -200,7 +220,7 @@ function ProjectMenuPopover({
[
platform,
findCommand,
commandBarSend,
commandBarActor.send,
engineCommandManager,
onProjectClose,
isDesktop,

View File

@ -1,13 +1,12 @@
import { useMachine } from '@xstate/react'
import { useCommandsContext } from 'hooks/useCommandsContext'
import { useFileSystemWatcher } from 'hooks/useFileSystemWatcher'
import { useProjectsLoader } from 'hooks/useProjectsLoader'
import { projectsMachine } from 'machines/projectsMachine'
import { createContext, useEffect, useState } from 'react'
import { createContext, useCallback, useEffect, useState } from 'react'
import { Actor, AnyStateMachine, fromPromise, Prop, StateFrom } from 'xstate'
import { useLspContext } from './LspProvider'
import toast from 'react-hot-toast'
import { useLocation, useNavigate } from 'react-router-dom'
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
import { PATHS } from 'lib/paths'
import {
createNewProjectDirectory,
@ -19,11 +18,28 @@ import {
interpolateProjectNameWithIndex,
doesProjectNameNeedInterpolated,
getUniqueProjectName,
getNextFileName,
} from 'lib/desktopFS'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import useStateMachineCommands from 'hooks/useStateMachineCommands'
import { projectsCommandBarConfig } from 'lib/commandBarConfigs/projectsCommandConfig'
import { isDesktop } from 'lib/isDesktop'
import { commandBarActor } from 'machines/commandBarMachine'
import {
CREATE_FILE_URL_PARAM,
FILE_EXT,
PROJECT_ENTRYPOINT,
} from 'lib/constants'
import { DeepPartial } from 'lib/types'
import { Configuration } from 'wasm-lib/kcl/bindings/Configuration'
import { codeManager } from 'lib/singletons'
import {
loadAndValidateSettings,
projectConfigurationToSettingsPayload,
saveSettings,
setSettingsAtLevel,
} from 'lib/settings/settingsUtils'
import { Project } from 'lib/project'
type MachineContext<T extends AnyStateMachine> = {
state?: StateFrom<T>
@ -53,12 +69,110 @@ export const ProjectsContextProvider = ({
)
}
/**
* We need some of the functionality of the ProjectsContextProvider in the web version
* but we can't perform file system operations in the browser,
* so most of the behavior of this machine is stubbed out.
*/
const ProjectsContextWeb = ({ children }: { children: React.ReactNode }) => {
const [searchParams, setSearchParams] = useSearchParams()
const clearImportSearchParams = useCallback(() => {
// Clear the search parameters related to the "Import file from URL" command
// or we'll never be able cancel or submit it.
searchParams.delete(CREATE_FILE_URL_PARAM)
searchParams.delete('code')
searchParams.delete('name')
searchParams.delete('units')
setSearchParams(searchParams)
}, [searchParams, setSearchParams])
const {
settings: { context: settings, send: settingsSend },
} = useSettingsAuthContext()
const [state, send, actor] = useMachine(
projectsMachine.provide({
actions: {
navigateToProject: () => {},
navigateToProjectIfNeeded: () => {},
navigateToFile: () => {},
toastSuccess: ({ event }) =>
toast.success(
('data' in event && typeof event.data === 'string' && event.data) ||
('output' in event &&
'message' in event.output &&
typeof event.output.message === 'string' &&
event.output.message) ||
''
),
toastError: ({ event }) =>
toast.error(
('data' in event && typeof event.data === 'string' && event.data) ||
('output' in event &&
typeof event.output === 'string' &&
event.output) ||
''
),
},
actors: {
readProjects: fromPromise(async () => [] as Project[]),
createProject: fromPromise(async () => ({
message: 'not implemented on web',
})),
renameProject: fromPromise(async () => ({
message: 'not implemented on web',
oldName: '',
newName: '',
})),
deleteProject: fromPromise(async () => ({
message: 'not implemented on web',
name: '',
})),
createFile: fromPromise(async ({ input }) => {
// Browser version doesn't navigate, just overwrites the current file
clearImportSearchParams()
codeManager.updateCodeStateEditor(input.code || '')
await codeManager.writeToFile()
settingsSend({
type: 'set.modeling.defaultUnit',
data: {
level: 'project',
value: input.units,
},
})
return {
message: 'File and units overwritten successfully',
fileName: input.name,
projectName: '',
}
}),
},
}),
{
input: {
projects: [],
defaultProjectName: settings.projects.defaultProjectName.current,
defaultDirectory: settings.app.projectDirectory.current,
},
}
)
// register all project-related command palette commands
useStateMachineCommands({
machineId: 'projects',
send,
state,
commandBarConfig: projectsCommandBarConfig,
actor,
onCancel: clearImportSearchParams,
})
return (
<ProjectsMachineContext.Provider
value={{
state: undefined,
send: () => {},
state,
send,
}}
>
{children}
@ -73,19 +187,21 @@ const ProjectsContextDesktop = ({
}) => {
const navigate = useNavigate()
const location = useLocation()
const { commandBarSend } = useCommandsContext()
const [searchParams, setSearchParams] = useSearchParams()
const clearImportSearchParams = useCallback(() => {
// Clear the search parameters related to the "Import file from URL" command
// or we'll never be able cancel or submit it.
searchParams.delete(CREATE_FILE_URL_PARAM)
searchParams.delete('code')
searchParams.delete('name')
searchParams.delete('units')
setSearchParams(searchParams)
}, [searchParams, setSearchParams])
const { onProjectOpen } = useLspContext()
const {
settings: { context: settings },
} = useSettingsAuthContext()
useEffect(() => {
console.log(
'project directory changed',
settings.app.projectDirectory.current
)
}, [settings.app.projectDirectory.current])
const [projectsLoaderTrigger, setProjectsLoaderTrigger] = useState(0)
const { projectPaths, projectsDir } = useProjectsLoader([
projectsLoaderTrigger,
@ -126,7 +242,7 @@ const ProjectsContextDesktop = ({
},
null
)
commandBarSend({ type: 'Close' })
commandBarActor.send({ type: 'Close' })
const newPathName = `${PATHS.FILE}/${encodeURIComponent(
projectPath
)}`
@ -169,6 +285,31 @@ const ProjectsContextDesktop = ({
}
}
},
navigateToFile: ({ context, event }) => {
if (event.type !== 'xstate.done.actor.create-file') return
// For now, the browser version of create-file doesn't need to navigate
// since it just overwrites the current file.
if (!isDesktop()) return
let projectPath = window.electron.join(
context.defaultDirectory,
event.output.projectName
)
let filePath = window.electron.join(
projectPath,
event.output.fileName
)
onProjectOpen(
{
name: event.output.projectName,
path: projectPath,
},
null
)
const pathToNavigateTo = `${PATHS.FILE}/${encodeURIComponent(
filePath
)}`
navigate(pathToNavigateTo)
},
toastSuccess: ({ event }) =>
toast.success(
('data' in event && typeof event.data === 'string' && event.data) ||
@ -218,8 +359,6 @@ const ProjectsContextDesktop = ({
name = interpolateProjectNameWithIndex(name, nextIndex)
}
console.log('from Project')
await renameProjectDirectory(
window.electron.path.join(defaultDirectory, oldName),
name
@ -242,13 +381,82 @@ const ProjectsContextDesktop = ({
name: input.name,
}
}),
},
guards: {
'Has at least 1 project': ({ event }) => {
if (event.type !== 'xstate.done.actor.read-projects') return false
console.log(`from has at least 1 project: ${event.output.length}`)
return event.output.length ? event.output.length >= 1 : false
},
createFile: fromPromise(async ({ input }) => {
let projectName =
(input.method === 'newProject' ? input.name : input.projectName) ||
settings.projects.defaultProjectName.current
let fileName =
input.method === 'newProject'
? PROJECT_ENTRYPOINT
: input.name.endsWith(FILE_EXT)
? input.name
: input.name + FILE_EXT
let message = 'File created successfully'
const unitsConfiguration: DeepPartial<Configuration> = {
settings: {
project: {
directory: settings.app.projectDirectory.current,
},
modeling: {
base_unit: input.units,
},
},
}
const needsInterpolated = doesProjectNameNeedInterpolated(projectName)
if (needsInterpolated) {
const nextIndex = getNextProjectIndex(projectName, input.projects)
projectName = interpolateProjectNameWithIndex(
projectName,
nextIndex
)
}
// Create the project around the file if newProject
if (input.method === 'newProject') {
await createNewProjectDirectory(
projectName,
input.code,
unitsConfiguration
)
message = `Project "${projectName}" created successfully with link contents`
} else {
let projectPath = window.electron.join(
settings.app.projectDirectory.current,
projectName
)
message = `File "${fileName}" created successfully`
const existingConfiguration = await loadAndValidateSettings(
projectPath
)
const settingsToSave = setSettingsAtLevel(
existingConfiguration.settings,
'project',
projectConfigurationToSettingsPayload(unitsConfiguration)
)
await saveSettings(settingsToSave, projectPath)
}
// Create the file
let baseDir = window.electron.join(
settings.app.projectDirectory.current,
projectName
)
const { name, path } = getNextFileName({
entryName: fileName,
baseDir,
})
fileName = name
await window.electron.writeFile(path, input.code || '')
return {
message,
fileName,
projectName,
}
}),
},
}),
{
@ -271,6 +479,7 @@ const ProjectsContextDesktop = ({
state,
commandBarConfig: projectsCommandBarConfig,
actor,
onCancel: clearImportSearchParams,
})
return (

View File

@ -29,7 +29,6 @@ import {
createSettingsCommand,
settingsWithCommandConfigs,
} from 'lib/commandBarConfigs/settingsCommandConfig'
import { useCommandsContext } from 'hooks/useCommandsContext'
import { Command } from 'lib/commandTypes'
import { BaseUnit } from 'lib/settings/settingsTypes'
import {
@ -42,6 +41,7 @@ import { isDesktop } from 'lib/isDesktop'
import { useFileSystemWatcher } from 'hooks/useFileSystemWatcher'
import { codeManager } from 'lib/singletons'
import { createRouteCommands } from 'lib/commandBarConfigs/routeCommandConfig'
import { commandBarActor } from 'machines/commandBarMachine'
type MachineContext<T extends AnyStateMachine> = {
state: StateFrom<T>
@ -109,7 +109,6 @@ export const SettingsAuthProviderBase = ({
}) => {
const location = useLocation()
const navigate = useNavigate()
const { commandBarSend } = useCommandsContext()
const [settingsPath, setSettingsPath] = useState<string | undefined>(
undefined
)
@ -278,10 +277,10 @@ export const SettingsAuthProviderBase = ({
)
.filter((c) => c !== null) as Command[]
commandBarSend({ type: 'Add commands', data: { commands: commands } })
commandBarActor.send({ type: 'Add commands', data: { commands: commands } })
return () => {
commandBarSend({
commandBarActor.send({
type: 'Remove commands',
data: { commands },
})
@ -290,7 +289,7 @@ export const SettingsAuthProviderBase = ({
settingsState,
settingsSend,
settingsActor,
commandBarSend,
commandBarActor.send,
settingsWithCommandConfigs,
])
@ -303,7 +302,7 @@ export const SettingsAuthProviderBase = ({
encodeURIComponent(loadedProject?.file?.path || BROWSER_PATH)
const { RouteTelemetryCommand, RouteHomeCommand, RouteSettingsCommand } =
createRouteCommands(navigate, location, filePath)
commandBarSend({
commandBarActor.send({
type: 'Remove commands',
data: {
commands: [
@ -314,12 +313,12 @@ export const SettingsAuthProviderBase = ({
},
})
if (location.pathname === PATHS.HOME) {
commandBarSend({
commandBarActor.send({
type: 'Add commands',
data: { commands: [RouteTelemetryCommand, RouteSettingsCommand] },
})
} else if (location.pathname.includes(PATHS.FILE)) {
commandBarSend({
commandBarActor.send({
type: 'Add commands',
data: {
commands: [

View File

@ -17,10 +17,11 @@ import {
import { useRouteLoaderData } from 'react-router-dom'
import { PATHS } from 'lib/paths'
import { IndexLoaderData } from 'lib/types'
import { useCommandsContext } from 'hooks/useCommandsContext'
import { err, reportRejection } from 'lib/trap'
import { getArtifactOfTypes } from 'lang/std/artifactGraph'
import { ViewControlContextMenu } from './ViewControlMenu'
import { commandBarActor, useCommandBarState } from 'machines/commandBarMachine'
import { useSelector } from '@xstate/react'
enum StreamState {
Playing = 'playing',
@ -35,7 +36,7 @@ export const Stream = () => {
const videoRef = useRef<HTMLVideoElement>(null)
const { settings } = useSettingsAuthContext()
const { state, send } = useModelingContext()
const { commandBarState } = useCommandsContext()
const commandBarState = useCommandBarState()
const { mediaStream } = useAppStream()
const { overallState, immediateState } = useNetworkContext()
const [streamState, setStreamState] = useState(StreamState.Unset)

View File

@ -28,7 +28,7 @@ import { base64Decode } from 'lang/wasm'
import { sendTelemetry } from 'lib/textToCad'
import { Themes } from 'lib/theme'
import { ActionButton } from './ActionButton'
import { commandBarMachine } from 'machines/commandBarMachine'
import { commandBarActor, commandBarMachine } from 'machines/commandBarMachine'
import { EventFrom } from 'xstate'
import { fileMachine } from 'machines/fileMachine'
import { reportRejection } from 'lib/trap'
@ -43,15 +43,10 @@ export function ToastTextToCadError({
toastId,
message,
prompt,
commandBarSend,
}: {
toastId: string
message: string
prompt: string
commandBarSend: (
event: EventFrom<typeof commandBarMachine>,
data?: unknown
) => void
}) {
return (
<div className="flex flex-col justify-between gap-6">
@ -81,7 +76,7 @@ export function ToastTextToCadError({
}}
name="Edit prompt"
onClick={() => {
commandBarSend({
commandBarActor.send({
type: 'Find and select command',
data: {
groupId: 'modeling',

View File

@ -1,10 +1,8 @@
import { toolTips } from 'lang/langHelpers'
import { Selections } from 'lib/selections'
import { Program, Expr, VariableDeclarator } from '../../lang/wasm'
import {
getNodePathFromSourceRange,
getNodeFromPath,
} from '../../lang/queryAst'
import { getNodeFromPath } from '../../lang/queryAst'
import { getNodePathFromSourceRange } from 'lang/queryAstNodePathUtils'
import { isSketchVariablesLinked } from '../../lang/std/sketchConstraints'
import {
transformSecondarySketchLinesTagFirst,

View File

@ -8,7 +8,6 @@ import {
} from 'react-router-dom'
import { Models } from '@kittycad/lib'
import { SettingsAuthProviderJest } from './SettingsAuthProvider'
import { CommandBarProvider } from './CommandBar/CommandBarProvider'
type User = Models['User_type']
@ -124,9 +123,7 @@ function TestWrap({ children }: { children: React.ReactNode }) {
<Route
path="/file/:id"
element={
<CommandBarProvider>
<SettingsAuthProviderJest>{children}</SettingsAuthProviderJest>
</CommandBarProvider>
<SettingsAuthProviderJest>{children}</SettingsAuthProviderJest>
}
/>
),

View File

@ -5,7 +5,6 @@ import { engineCommandManager, kclManager } from 'lib/singletons'
import { modelingMachine, ModelingMachineEvent } from 'machines/modelingMachine'
import { Selections, Selection, processCodeMirrorRanges } from 'lib/selections'
import { undo, redo } from '@codemirror/commands'
import { CommandBarMachineEvent } from 'machines/commandBarMachine'
import { addLineHighlight, addLineHighlightEvent } from './highlightextension'
import {
Diagnostic,
@ -52,9 +51,6 @@ export default class EditorManager {
private _modelingSend: (eventInfo: ModelingMachineEvent) => void = () => {}
private _modelingState: StateFrom<typeof modelingMachine> | null = null
private _commandBarSend: (eventInfo: CommandBarMachineEvent) => void =
() => {}
private _convertToVariableEnabled: boolean = false
private _convertToVariableCallback: () => void = () => {}
@ -161,14 +157,6 @@ export default class EditorManager {
this._modelingState = state
}
setCommandBarSend(send: (eventInfo: CommandBarMachineEvent) => void) {
this._commandBarSend = send
}
commandBarSend(eventInfo: CommandBarMachineEvent): void {
return this._commandBarSend(eventInfo)
}
get highlightRange(): Array<[number, number]> {
return this._highlightRange
}
@ -315,6 +303,21 @@ export default class EditorManager {
if (selections?.graphSelections?.length === 0) {
return
}
if (!this._editorView) {
return
}
const codeBaseSelections = this.createEditorSelection(selections)
this._editorView.dispatch({
selection: codeBaseSelections,
annotations: [
updateOutsideEditorEvent,
Transaction.addToHistory.of(false),
],
})
}
createEditorSelection(selections: Selections) {
let codeBasedSelections = []
for (const selection of selections.graphSelections) {
const safeEnd = Math.min(
@ -331,18 +334,7 @@ export default class EditorManager {
.range[1]
const safeEnd = Math.min(end, this._editorView?.state.doc.length || end)
codeBasedSelections.push(EditorSelection.cursor(safeEnd))
if (!this._editorView) {
return
}
this._editorView.dispatch({
selection: EditorSelection.create(codeBasedSelections, 1),
annotations: [
updateOutsideEditorEvent,
Transaction.addToHistory.of(false),
],
})
return EditorSelection.create(codeBasedSelections, 1)
}
// We will ONLY get here if the user called a select event.

View File

@ -1,10 +0,0 @@
import { CommandsContext } from 'components/CommandBar/CommandBarProvider'
export const useCommandsContext = () => {
const commandBarActor = CommandsContext.useActorRef()
const commandBarState = CommandsContext.useSelector((state) => state)
return {
commandBarSend: commandBarActor.send,
commandBarState,
}
}

View File

@ -0,0 +1,65 @@
import { base64ToString } from 'lib/base64'
import { CREATE_FILE_URL_PARAM, DEFAULT_FILE_NAME } from 'lib/constants'
import { useEffect } from 'react'
import { useSearchParams } from 'react-router-dom'
import { useSettingsAuthContext } from './useSettingsAuthContext'
import { isDesktop } from 'lib/isDesktop'
import { FileLinkParams } from 'lib/links'
import { ProjectsCommandSchema } from 'lib/commandBarConfigs/projectsCommandConfig'
import { baseUnitsUnion } from 'lib/settings/settingsTypes'
// For initializing the command arguments, we actually want `method` to be undefined
// so that we don't skip it in the command palette.
export type CreateFileSchemaMethodOptional = Omit<
ProjectsCommandSchema['Import file from URL'],
'method'
> & {
method?: 'newProject' | 'existingProject'
}
/**
* companion to createFileLink. This hook runs an effect on mount that
* checks the URL for the CREATE_FILE_URL_PARAM and triggers the "Create file"
* command if it is present, loading the command's default values from the other
* URL parameters.
*/
export function useCreateFileLinkQuery(
callback: (args: CreateFileSchemaMethodOptional) => void
) {
const [searchParams] = useSearchParams()
const { settings } = useSettingsAuthContext()
useEffect(() => {
const createFileParam = searchParams.has(CREATE_FILE_URL_PARAM)
if (createFileParam) {
const params: FileLinkParams = {
code: base64ToString(
decodeURIComponent(searchParams.get('code') ?? '')
),
name: searchParams.get('name') ?? DEFAULT_FILE_NAME,
units:
(baseUnitsUnion.find((unit) => searchParams.get('units') === unit) ||
settings.context.modeling.defaultUnit.default) ??
settings.context.modeling.defaultUnit.current,
}
const argDefaultValues: CreateFileSchemaMethodOptional = {
name: params.name
? isDesktop()
? params.name.replace('.kcl', '')
: params.name
: isDesktop()
? settings.context.projects.defaultProjectName.current
: DEFAULT_FILE_NAME,
code: params.code || '',
units: params.units,
method: isDesktop() ? undefined : 'existingProject',
}
callback(argDefaultValues)
}
}, [searchParams])
}

View File

@ -17,7 +17,8 @@ import {
} from 'lang/std/artifactGraph'
import { err, reportRejection } from 'lib/trap'
import { DefaultPlaneStr, getFaceDetails } from 'clientSideScene/sceneEntities'
import { getNodeFromPath, getNodePathFromSourceRange } from 'lang/queryAst'
import { getNodeFromPath } from 'lang/queryAst'
import { getNodePathFromSourceRange } from 'lang/queryAstNodePathUtils'
import { CallExpression, defaultSourceRange } from 'lang/wasm'
import { EdgeCutInfo, ExtrudeFacePlane } from 'machines/modelingMachine'

View File

@ -14,7 +14,7 @@ export const useProjectsLoader = (deps?: [number]) => {
useEffect(() => {
// Useless on web, until we get fake filesystems over there.
if (!isDesktop) return
if (!isDesktop()) return
if (deps && deps[0] === lastTs) return

View File

@ -1,7 +1,6 @@
import { useEffect } from 'react'
import { AnyStateMachine, Actor, StateFrom, EventFrom } from 'xstate'
import { createMachineCommand } from '../lib/createMachineCommand'
import { useCommandsContext } from './useCommandsContext'
import { modelingMachine } from 'machines/modelingMachine'
import { authMachine } from 'machines/authMachine'
import { settingsMachine } from 'machines/settingsMachine'
@ -15,6 +14,7 @@ import { useKclContext } from 'lang/KclProvider'
import { useNetworkContext } from 'hooks/useNetworkContext'
import { NetworkHealthState } from 'hooks/useNetworkStatus'
import { useAppState } from 'AppState'
import { commandBarActor } from 'machines/commandBarMachine'
// This might not be necessary, AnyStateMachine from xstate is working
export type AllMachines =
@ -48,7 +48,6 @@ export default function useStateMachineCommands<
allCommandsRequireNetwork = false,
onCancel,
}: UseStateMachineCommandsArgs<T, S>) {
const { commandBarSend } = useCommandsContext()
const { overallState } = useNetworkContext()
const { isExecuting } = useKclContext()
const { isStreamReady } = useAppState()
@ -76,10 +75,13 @@ export default function useStateMachineCommands<
})
.filter((c) => c !== null) as Command[] // TS isn't smart enough to know this filter removes nulls
commandBarSend({ type: 'Add commands', data: { commands: newCommands } })
commandBarActor.send({
type: 'Add commands',
data: { commands: newCommands },
})
return () => {
commandBarSend({
commandBarActor.send({
type: 'Remove commands',
data: { commands: newCommands },
})

View File

@ -24,7 +24,10 @@ describe('testing AST', () => {
type: 'Literal',
start: 0,
end: 1,
value: 5,
value: {
suffix: 'None',
value: 5,
},
raw: '5',
},
operator: '+',
@ -32,7 +35,10 @@ describe('testing AST', () => {
type: 'Literal',
start: 3,
end: 4,
value: 6,
value: {
suffix: 'None',
value: 6,
},
raw: '6',
},
},

View File

@ -54,6 +54,9 @@ const mySketch001 = startSketchOn('XY')
},
],
id: expect.any(String),
units: {
type: 'Mm',
},
__meta: [{ sourceRange: [46, 71, 0] }],
},
})
@ -72,56 +75,65 @@ const mySketch001 = startSketchOn('XY')
const sketch001 = execState.memory.get('mySketch001')
expect(sketch001).toEqual({
type: 'Solid',
id: expect.any(String),
value: [
{
type: 'extrudePlane',
faceId: expect.any(String),
tag: null,
id: expect.any(String),
sourceRange: [77, 102, 0],
},
{
type: 'extrudePlane',
faceId: expect.any(String),
tag: null,
id: expect.any(String),
sourceRange: [108, 132, 0],
},
],
sketch: {
value: {
type: 'Solid',
id: expect.any(String),
__meta: expect.any(Array),
on: expect.any(Object),
start: expect.any(Object),
type: 'Sketch',
paths: [
value: [
{
type: 'ToPoint',
from: [0, 0],
to: [-1.59, -1.54],
type: 'extrudePlane',
faceId: expect.any(String),
tag: null,
__geoMeta: {
id: expect.any(String),
sourceRange: [77, 102, 0],
},
id: expect.any(String),
sourceRange: [77, 102, 0],
},
{
type: 'ToPoint',
from: [-1.59, -1.54],
to: [0.46, -5.82],
type: 'extrudePlane',
faceId: expect.any(String),
tag: null,
__geoMeta: {
id: expect.any(String),
sourceRange: [108, 132, 0],
},
id: expect.any(String),
sourceRange: [108, 132, 0],
},
],
sketch: {
id: expect.any(String),
units: {
type: 'Mm',
},
__meta: expect.any(Array),
on: expect.any(Object),
start: expect.any(Object),
type: 'Sketch',
paths: [
{
type: 'ToPoint',
from: [0, 0],
to: [-1.59, -1.54],
tag: null,
__geoMeta: {
id: expect.any(String),
sourceRange: [77, 102, 0],
},
},
{
type: 'ToPoint',
from: [-1.59, -1.54],
to: [0.46, -5.82],
tag: null,
__geoMeta: {
id: expect.any(String),
sourceRange: [108, 132, 0],
},
},
],
},
height: 2,
startCapId: expect.any(String),
endCapId: expect.any(String),
units: {
type: 'Mm',
},
__meta: [{ sourceRange: [46, 71, 0] }],
},
height: 2,
startCapId: expect.any(String),
endCapId: expect.any(String),
__meta: [{ sourceRange: [46, 71, 0] }],
})
})
test('sketch extrude and sketch on one of the faces', async () => {
@ -154,187 +166,205 @@ const sk2 = startSketchOn('XY')
expect(geos).toEqual([
{
type: 'Solid',
id: expect.any(String),
value: [
{
type: 'extrudePlane',
faceId: expect.any(String),
tag: null,
id: expect.any(String),
sourceRange: [69, 89, 0],
},
{
type: 'extrudePlane',
faceId: expect.any(String),
tag: {
end: 116,
start: 114,
type: 'TagDeclarator',
value: 'p',
},
id: expect.any(String),
sourceRange: [95, 117, 0],
},
{
type: 'extrudePlane',
faceId: expect.any(String),
tag: null,
id: expect.any(String),
sourceRange: [123, 142, 0],
},
],
sketch: {
value: {
type: 'Solid',
id: expect.any(String),
__meta: expect.any(Array),
on: expect.any(Object),
start: expect.any(Object),
type: 'Sketch',
tags: {
p: {
__meta: [
{
sourceRange: [114, 116, 0],
},
],
type: 'TagIdentifier',
value: 'p',
info: expect.any(Object),
},
},
paths: [
value: [
{
type: 'ToPoint',
from: [0, 0],
to: [-2.5, 0],
type: 'extrudePlane',
faceId: expect.any(String),
tag: null,
__geoMeta: {
id: expect.any(String),
sourceRange: [69, 89, 0],
},
id: expect.any(String),
sourceRange: [69, 89, 0],
},
{
type: 'ToPoint',
from: [-2.5, 0],
to: [0, 10],
type: 'extrudePlane',
faceId: expect.any(String),
tag: {
end: 116,
start: 114,
type: 'TagDeclarator',
value: 'p',
},
__geoMeta: {
id: expect.any(String),
sourceRange: [95, 117, 0],
},
id: expect.any(String),
sourceRange: [95, 117, 0],
},
{
type: 'ToPoint',
from: [0, 10],
to: [2.5, 0],
type: 'extrudePlane',
faceId: expect.any(String),
tag: null,
__geoMeta: {
id: expect.any(String),
sourceRange: [123, 142, 0],
},
id: expect.any(String),
sourceRange: [123, 142, 0],
},
],
sketch: {
id: expect.any(String),
__meta: expect.any(Array),
on: expect.any(Object),
start: expect.any(Object),
type: 'Sketch',
units: {
type: 'Mm',
},
tags: {
p: {
__meta: [
{
sourceRange: [114, 116, 0],
},
],
type: 'TagIdentifier',
value: 'p',
info: expect.any(Object),
},
},
paths: [
{
type: 'ToPoint',
from: [0, 0],
to: [-2.5, 0],
tag: null,
__geoMeta: {
id: expect.any(String),
sourceRange: [69, 89, 0],
},
},
{
type: 'ToPoint',
from: [-2.5, 0],
to: [0, 10],
tag: {
end: 116,
start: 114,
type: 'TagDeclarator',
value: 'p',
},
__geoMeta: {
id: expect.any(String),
sourceRange: [95, 117, 0],
},
},
{
type: 'ToPoint',
from: [0, 10],
to: [2.5, 0],
tag: null,
__geoMeta: {
id: expect.any(String),
sourceRange: [123, 142, 0],
},
},
],
},
height: 2,
startCapId: expect.any(String),
endCapId: expect.any(String),
units: {
type: 'Mm',
},
__meta: [{ sourceRange: [38, 63, 0] }],
},
height: 2,
startCapId: expect.any(String),
endCapId: expect.any(String),
__meta: [{ sourceRange: [38, 63, 0] }],
},
{
type: 'Solid',
id: expect.any(String),
value: [
{
type: 'extrudePlane',
faceId: expect.any(String),
tag: null,
id: expect.any(String),
sourceRange: [373, 393, 0],
},
{
type: 'extrudePlane',
faceId: expect.any(String),
tag: {
end: 419,
start: 417,
type: 'TagDeclarator',
value: 'o',
},
id: expect.any(String),
sourceRange: [399, 420, 0],
},
{
type: 'extrudePlane',
faceId: expect.any(String),
tag: null,
id: expect.any(String),
sourceRange: [426, 445, 0],
},
],
sketch: {
value: {
type: 'Solid',
id: expect.any(String),
__meta: expect.any(Array),
on: expect.any(Object),
start: expect.any(Object),
type: 'Sketch',
tags: {
o: {
__meta: [
{
sourceRange: [417, 419, 0],
},
],
type: 'TagIdentifier',
value: 'o',
info: expect.any(Object),
},
},
paths: [
value: [
{
type: 'ToPoint',
from: [0, 0],
to: [-2.5, 0],
type: 'extrudePlane',
faceId: expect.any(String),
tag: null,
__geoMeta: {
id: expect.any(String),
sourceRange: [373, 393, 0],
},
id: expect.any(String),
sourceRange: [373, 393, 0],
},
{
type: 'ToPoint',
from: [-2.5, 0],
to: [0, 3],
type: 'extrudePlane',
faceId: expect.any(String),
tag: {
end: 419,
start: 417,
type: 'TagDeclarator',
value: 'o',
},
__geoMeta: {
id: expect.any(String),
sourceRange: [399, 420, 0],
},
id: expect.any(String),
sourceRange: [399, 420, 0],
},
{
type: 'ToPoint',
from: [0, 3],
to: [2.5, 0],
type: 'extrudePlane',
faceId: expect.any(String),
tag: null,
__geoMeta: {
id: expect.any(String),
sourceRange: [426, 445, 0],
},
id: expect.any(String),
sourceRange: [426, 445, 0],
},
],
sketch: {
id: expect.any(String),
units: {
type: 'Mm',
},
__meta: expect.any(Array),
on: expect.any(Object),
start: expect.any(Object),
type: 'Sketch',
tags: {
o: {
__meta: [
{
sourceRange: [417, 419, 0],
},
],
type: 'TagIdentifier',
value: 'o',
info: expect.any(Object),
},
},
paths: [
{
type: 'ToPoint',
from: [0, 0],
to: [-2.5, 0],
tag: null,
__geoMeta: {
id: expect.any(String),
sourceRange: [373, 393, 0],
},
},
{
type: 'ToPoint',
from: [-2.5, 0],
to: [0, 3],
tag: {
end: 419,
start: 417,
type: 'TagDeclarator',
value: 'o',
},
__geoMeta: {
id: expect.any(String),
sourceRange: [399, 420, 0],
},
},
{
type: 'ToPoint',
from: [0, 3],
to: [2.5, 0],
tag: null,
__geoMeta: {
id: expect.any(String),
sourceRange: [426, 445, 0],
},
},
],
},
height: 2,
startCapId: expect.any(String),
endCapId: expect.any(String),
__meta: [{ sourceRange: [342, 367, 0] }],
units: {
type: 'Mm',
},
},
height: 2,
startCapId: expect.any(String),
endCapId: expect.any(String),
__meta: [{ sourceRange: [342, 367, 0] }],
},
])
})

View File

@ -221,6 +221,9 @@ const newVar = myVar + 1`
},
],
id: expect.any(String),
units: {
type: 'Mm',
},
__meta: [{ sourceRange: [39, 63, 0] }],
},
})

View File

@ -1,4 +1,5 @@
import { getNodePathFromSourceRange, getNodeFromPath } from './queryAst'
import { getNodeFromPath } from './queryAst'
import { getNodePathFromSourceRange } from 'lang/queryAstNodePathUtils'
import {
Identifier,
assertParse,

View File

@ -25,7 +25,8 @@ import {
deleteFromSelection,
} from './modifyAst'
import { enginelessExecutor } from '../lib/testHelpers'
import { findUsesOfTagInPipe, getNodePathFromSourceRange } from './queryAst'
import { findUsesOfTagInPipe } from './queryAst'
import { getNodePathFromSourceRange } from 'lang/queryAstNodePathUtils'
import { err } from 'lib/trap'
import { SimplifiedArgDetails } from './std/stdTypes'
import { Node } from 'wasm-lib/kcl/bindings/Node'
@ -39,7 +40,7 @@ describe('Testing createLiteral', () => {
it('should create a literal', () => {
const result = createLiteral(5)
expect(result.type).toBe('Literal')
expect(result.value).toBe(5)
expect((result as any).value.value).toBe(5)
})
})
describe('Testing createIdentifier', () => {
@ -56,7 +57,7 @@ describe('Testing createCallExpression', () => {
expect(result.callee.type).toBe('Identifier')
expect(result.callee.name).toBe('myFunc')
expect(result.arguments[0].type).toBe('Literal')
expect((result.arguments[0] as any).value).toBe(5)
expect((result.arguments[0] as any).value.value).toBe(5)
})
})
describe('Testing createObjectExpression', () => {
@ -68,7 +69,7 @@ describe('Testing createObjectExpression', () => {
expect(result.properties[0].type).toBe('ObjectProperty')
expect(result.properties[0].key.name).toBe('myProp')
expect(result.properties[0].value.type).toBe('Literal')
expect((result.properties[0].value as any).value).toBe(5)
expect((result.properties[0].value as any).value.value).toBe(5)
})
})
describe('Testing createArrayExpression', () => {
@ -76,7 +77,7 @@ describe('Testing createArrayExpression', () => {
const result = createArrayExpression([createLiteral(5)])
expect(result.type).toBe('ArrayExpression')
expect(result.elements[0].type).toBe('Literal')
expect((result.elements[0] as any).value).toBe(5)
expect((result.elements[0] as any).value.value).toBe(5)
})
})
describe('Testing createPipeSubstitution', () => {
@ -93,7 +94,7 @@ describe('Testing createVariableDeclaration', () => {
expect(result.declaration.id.type).toBe('Identifier')
expect(result.declaration.id.name).toBe('myVar')
expect(result.declaration.init.type).toBe('Literal')
expect((result.declaration.init as any).value).toBe(5)
expect((result.declaration.init as any).value.value).toBe(5)
})
})
describe('Testing createPipeExpression', () => {
@ -101,7 +102,7 @@ describe('Testing createPipeExpression', () => {
const result = createPipeExpression([createLiteral(5)])
expect(result.type).toBe('PipeExpression')
expect(result.body[0].type).toBe('Literal')
expect((result.body[0] as any).value).toBe(5)
expect((result.body[0] as any).value.value).toBe(5)
})
})

View File

@ -26,10 +26,10 @@ import {
findAllPreviousVariables,
findAllPreviousVariablesPath,
getNodeFromPath,
getNodePathFromSourceRange,
isNodeSafeToReplace,
traverse,
} from './queryAst'
import { getNodePathFromSourceRange } from 'lang/queryAstNodePathUtils'
import { addTagForSketchOnFace, getConstraintInfo } from './std/sketch'
import {
PathToNodeMap,
@ -743,14 +743,18 @@ export function splitPathAtPipeExpression(pathToNode: PathToNode): {
return splitPathAtPipeExpression(pathToNode.slice(0, -1))
}
export function createLiteral(value: LiteralValue): Node<Literal> {
export function createLiteral(value: LiteralValue | number): Node<Literal> {
const raw = `${value}`
if (typeof value === 'number') {
value = { value, suffix: 'None' }
}
return {
type: 'Literal',
start: 0,
end: 0,
moduleId: 0,
value,
raw: `${value}`,
raw,
}
}

View File

@ -21,7 +21,8 @@ import {
ChamferParameters,
EdgeTreatmentParameters,
} from './addEdgeTreatment'
import { getNodeFromPath, getNodePathFromSourceRange } from '../queryAst'
import { getNodeFromPath } from '../queryAst'
import { getNodePathFromSourceRange } from 'lang/queryAstNodePathUtils'
import { createLiteral } from 'lang/modifyAst'
import { err } from 'lib/trap'
import { Selection, Selections } from 'lib/selections'

View File

@ -20,10 +20,10 @@ import {
} from '../modifyAst'
import {
getNodeFromPath,
getNodePathFromSourceRange,
hasSketchPipeBeenExtruded,
traverse,
} from '../queryAst'
import { getNodePathFromSourceRange } from 'lang/queryAstNodePathUtils'
import {
addTagForSketchOnFace,
getTagFromCallExpression,

View File

@ -19,7 +19,8 @@ import {
findUniqueName,
createVariableDeclaration,
} from 'lang/modifyAst'
import { getNodeFromPath, getNodePathFromSourceRange } from 'lang/queryAst'
import { getNodeFromPath } from 'lang/queryAst'
import { getNodePathFromSourceRange } from 'lang/queryAstNodePathUtils'
import {
mutateAstWithTagForSketchSegment,
getEdgeTagCall,

View File

@ -10,7 +10,6 @@ import {
findAllPreviousVariables,
isNodeSafeToReplace,
isTypeInValue,
getNodePathFromSourceRange,
hasExtrudeSketch,
findUsesOfTagInPipe,
hasSketchPipeBeenExtruded,
@ -19,6 +18,7 @@ import {
getNodeFromPath,
doesSceneHaveExtrudedSketch,
} from './queryAst'
import { getNodePathFromSourceRange } from 'lang/queryAstNodePathUtils'
import { enginelessExecutor } from '../lib/testHelpers'
import {
createArrayExpression,
@ -660,7 +660,7 @@ myNestedVar = [
enter: (node, path) => {
if (
node.type === 'Literal' &&
String(node.value) === literalOfInterest
String((node as any).value.value) === literalOfInterest
) {
pathToNode = path
} else if (

View File

@ -22,6 +22,7 @@ import {
VariableDeclaration,
VariableDeclarator,
} from './wasm'
import { getNodePathFromSourceRange } from 'lang/queryAstNodePathUtils'
import { createIdentifier, splitPathAtLastIndex } from './modifyAst'
import { getSketchSegmentFromSourceRange } from './std/sketchConstraints'
import { getAngle } from '../lib/utils'
@ -125,311 +126,6 @@ export function getNodeFromPathCurry(
}
}
function moreNodePathFromSourceRange(
node: Node<
| Expr
| ImportStatement
| ExpressionStatement
| VariableDeclaration
| ReturnStatement
>,
sourceRange: SourceRange,
previousPath: PathToNode = [['body', '']]
): PathToNode {
const [start, end] = sourceRange
let path: PathToNode = [...previousPath]
const _node = { ...node }
if (start < _node.start || end > _node.end) return path
const isInRange = _node.start <= start && _node.end >= end
if (
(_node.type === 'Identifier' ||
_node.type === 'Literal' ||
_node.type === 'TagDeclarator') &&
isInRange
) {
return path
}
if (_node.type === 'CallExpression' && isInRange) {
const { callee, arguments: args } = _node
if (
callee.type === 'Identifier' &&
callee.start <= start &&
callee.end >= end
) {
path.push(['callee', 'CallExpression'])
return path
}
if (args.length > 0) {
for (let argIndex = 0; argIndex < args.length; argIndex++) {
const arg = args[argIndex]
if (arg.start <= start && arg.end >= end) {
path.push(['arguments', 'CallExpression'])
path.push([argIndex, 'index'])
return moreNodePathFromSourceRange(arg, sourceRange, path)
}
}
}
return path
}
if (_node.type === 'CallExpressionKw' && isInRange) {
const { callee, arguments: args } = _node
if (
callee.type === 'Identifier' &&
callee.start <= start &&
callee.end >= end
) {
path.push(['callee', 'CallExpressionKw'])
return path
}
if (args.length > 0) {
for (let argIndex = 0; argIndex < args.length; argIndex++) {
const arg = args[argIndex].arg
if (arg.start <= start && arg.end >= end) {
path.push(['arguments', 'CallExpressionKw'])
path.push([argIndex, 'index'])
return moreNodePathFromSourceRange(arg, sourceRange, path)
}
}
}
return path
}
if (_node.type === 'BinaryExpression' && isInRange) {
const { left, right } = _node
if (left.start <= start && left.end >= end) {
path.push(['left', 'BinaryExpression'])
return moreNodePathFromSourceRange(left, sourceRange, path)
}
if (right.start <= start && right.end >= end) {
path.push(['right', 'BinaryExpression'])
return moreNodePathFromSourceRange(right, sourceRange, path)
}
return path
}
if (_node.type === 'PipeExpression' && isInRange) {
const { body } = _node
for (let i = 0; i < body.length; i++) {
const pipe = body[i]
if (pipe.start <= start && pipe.end >= end) {
path.push(['body', 'PipeExpression'])
path.push([i, 'index'])
return moreNodePathFromSourceRange(pipe, sourceRange, path)
}
}
return path
}
if (_node.type === 'ArrayExpression' && isInRange) {
const { elements } = _node
for (let elIndex = 0; elIndex < elements.length; elIndex++) {
const element = elements[elIndex]
if (element.start <= start && element.end >= end) {
path.push(['elements', 'ArrayExpression'])
path.push([elIndex, 'index'])
return moreNodePathFromSourceRange(element, sourceRange, path)
}
}
return path
}
if (_node.type === 'ObjectExpression' && isInRange) {
const { properties } = _node
for (let propIndex = 0; propIndex < properties.length; propIndex++) {
const property = properties[propIndex]
if (property.start <= start && property.end >= end) {
path.push(['properties', 'ObjectExpression'])
path.push([propIndex, 'index'])
if (property.key.start <= start && property.key.end >= end) {
path.push(['key', 'Property'])
return moreNodePathFromSourceRange(property.key, sourceRange, path)
}
if (property.value.start <= start && property.value.end >= end) {
path.push(['value', 'Property'])
return moreNodePathFromSourceRange(property.value, sourceRange, path)
}
}
}
return path
}
if (_node.type === 'ExpressionStatement' && isInRange) {
const { expression } = _node
path.push(['expression', 'ExpressionStatement'])
return moreNodePathFromSourceRange(expression, sourceRange, path)
}
if (_node.type === 'VariableDeclaration' && isInRange) {
const declaration = _node.declaration
if (declaration.start <= start && declaration.end >= end) {
path.push(['declaration', 'VariableDeclaration'])
const init = declaration.init
if (init.start <= start && init.end >= end) {
path.push(['init', ''])
return moreNodePathFromSourceRange(init, sourceRange, path)
}
}
}
if (_node.type === 'VariableDeclaration' && isInRange) {
const declaration = _node.declaration
if (declaration.start <= start && declaration.end >= end) {
const init = declaration.init
if (init.start <= start && init.end >= end) {
path.push(['declaration', 'VariableDeclaration'])
path.push(['init', ''])
return moreNodePathFromSourceRange(init, sourceRange, path)
}
}
return path
}
if (_node.type === 'UnaryExpression' && isInRange) {
const { argument } = _node
if (argument.start <= start && argument.end >= end) {
path.push(['argument', 'UnaryExpression'])
return moreNodePathFromSourceRange(argument, sourceRange, path)
}
return path
}
if (_node.type === 'FunctionExpression' && isInRange) {
for (let i = 0; i < _node.params.length; i++) {
const param = _node.params[i]
if (param.identifier.start <= start && param.identifier.end >= end) {
path.push(['params', 'FunctionExpression'])
path.push([i, 'index'])
return moreNodePathFromSourceRange(param.identifier, sourceRange, path)
}
}
if (_node.body.start <= start && _node.body.end >= end) {
path.push(['body', 'FunctionExpression'])
const fnBody = _node.body.body
for (let i = 0; i < fnBody.length; i++) {
const statement = fnBody[i]
if (statement.start <= start && statement.end >= end) {
path.push(['body', 'FunctionExpression'])
path.push([i, 'index'])
return moreNodePathFromSourceRange(statement, sourceRange, path)
}
}
}
return path
}
if (_node.type === 'ReturnStatement' && isInRange) {
const { argument } = _node
if (argument.start <= start && argument.end >= end) {
path.push(['argument', 'ReturnStatement'])
return moreNodePathFromSourceRange(argument, sourceRange, path)
}
return path
}
if (_node.type === 'MemberExpression' && isInRange) {
const { object, property } = _node
if (object.start <= start && object.end >= end) {
path.push(['object', 'MemberExpression'])
return moreNodePathFromSourceRange(object, sourceRange, path)
}
if (property.start <= start && property.end >= end) {
path.push(['property', 'MemberExpression'])
return moreNodePathFromSourceRange(property, sourceRange, path)
}
return path
}
if (_node.type === 'PipeSubstitution' && isInRange) return path
if (_node.type === 'IfExpression' && isInRange) {
const { cond, then_val, else_ifs, final_else } = _node
if (cond.start <= start && cond.end >= end) {
path.push(['cond', 'IfExpression'])
return moreNodePathFromSourceRange(cond, sourceRange, path)
}
if (then_val.start <= start && then_val.end >= end) {
path.push(['then_val', 'IfExpression'])
path.push(['body', 'IfExpression'])
return getNodePathFromSourceRange(then_val, sourceRange, path)
}
for (let i = 0; i < else_ifs.length; i++) {
const else_if = else_ifs[i]
if (else_if.start <= start && else_if.end >= end) {
path.push(['else_ifs', 'IfExpression'])
path.push([i, 'index'])
const { cond, then_val } = else_if
if (cond.start <= start && cond.end >= end) {
path.push(['cond', 'IfExpression'])
return moreNodePathFromSourceRange(cond, sourceRange, path)
}
path.push(['then_val', 'IfExpression'])
path.push(['body', 'IfExpression'])
return getNodePathFromSourceRange(then_val, sourceRange, path)
}
}
if (final_else.start <= start && final_else.end >= end) {
path.push(['final_else', 'IfExpression'])
path.push(['body', 'IfExpression'])
return getNodePathFromSourceRange(final_else, sourceRange, path)
}
return path
}
if (_node.type === 'ImportStatement' && isInRange) {
if (_node.selector && _node.selector.type === 'List') {
path.push(['selector', 'ImportStatement'])
const { items } = _node.selector
for (let i = 0; i < items.length; i++) {
const item = items[i]
if (item.start <= start && item.end >= end) {
path.push(['items', 'ImportSelector'])
path.push([i, 'index'])
if (item.name.start <= start && item.name.end >= end) {
path.push(['name', 'ImportItem'])
return path
}
if (
item.alias &&
item.alias.start <= start &&
item.alias.end >= end
) {
path.push(['alias', 'ImportItem'])
return path
}
return path
}
}
return path
}
return path
}
console.error('not implemented: ' + node.type)
return path
}
export function getNodePathFromSourceRange(
node: Program,
sourceRange: SourceRange,
previousPath: PathToNode = [['body', '']]
): PathToNode {
const [start, end] = sourceRange || []
let path: PathToNode = [...previousPath]
const _node = { ...node }
// loop over each statement in body getting the index with a for loop
for (
let statementIndex = 0;
statementIndex < _node.body.length;
statementIndex++
) {
const statement = _node.body[statementIndex]
if (statement.start <= start && statement.end >= end) {
path.push([statementIndex, 'index'])
return moreNodePathFromSourceRange(statement, sourceRange, path)
}
}
return path
}
type KCLNode = Node<
| Expr
| ExpressionStatement
@ -717,16 +413,6 @@ function isTypeInArrayExp(
return node.elements.some((el) => isTypeInValue(el, syntaxType))
}
export function isValueZero(val?: Expr): boolean {
return (
(val?.type === 'Literal' && Number(val.value) === 0) ||
(val?.type === 'UnaryExpression' &&
val.operator === '-' &&
val.argument.type === 'Literal' &&
Number(val.argument.value) === 0)
)
}
export function isLinesParallelAndConstrained(
ast: Program,
artifactGraph: ArtifactGraph,

View File

@ -0,0 +1,316 @@
import {
Expr,
ExpressionStatement,
VariableDeclaration,
ReturnStatement,
SourceRange,
PathToNode,
Program,
} from './wasm'
import { ImportStatement } from 'wasm-lib/kcl/bindings/ImportStatement'
import { Node } from 'wasm-lib/kcl/bindings/Node'
function moreNodePathFromSourceRange(
node: Node<
| Expr
| ImportStatement
| ExpressionStatement
| VariableDeclaration
| ReturnStatement
>,
sourceRange: SourceRange,
previousPath: PathToNode = [['body', '']]
): PathToNode {
const [start, end] = sourceRange
let path: PathToNode = [...previousPath]
const _node = { ...node }
if (start < _node.start || end > _node.end) return path
const isInRange = _node.start <= start && _node.end >= end
if (
(_node.type === 'Identifier' ||
_node.type === 'Literal' ||
_node.type === 'TagDeclarator') &&
isInRange
) {
return path
}
if (_node.type === 'CallExpression' && isInRange) {
const { callee, arguments: args } = _node
if (
callee.type === 'Identifier' &&
callee.start <= start &&
callee.end >= end
) {
path.push(['callee', 'CallExpression'])
return path
}
if (args.length > 0) {
for (let argIndex = 0; argIndex < args.length; argIndex++) {
const arg = args[argIndex]
if (arg.start <= start && arg.end >= end) {
path.push(['arguments', 'CallExpression'])
path.push([argIndex, 'index'])
return moreNodePathFromSourceRange(arg, sourceRange, path)
}
}
}
return path
}
if (_node.type === 'CallExpressionKw' && isInRange) {
const { callee, arguments: args } = _node
if (
callee.type === 'Identifier' &&
callee.start <= start &&
callee.end >= end
) {
path.push(['callee', 'CallExpressionKw'])
return path
}
if (args.length > 0) {
for (let argIndex = 0; argIndex < args.length; argIndex++) {
const arg = args[argIndex].arg
if (arg.start <= start && arg.end >= end) {
path.push(['arguments', 'CallExpressionKw'])
path.push([argIndex, 'index'])
return moreNodePathFromSourceRange(arg, sourceRange, path)
}
}
}
return path
}
if (_node.type === 'BinaryExpression' && isInRange) {
const { left, right } = _node
if (left.start <= start && left.end >= end) {
path.push(['left', 'BinaryExpression'])
return moreNodePathFromSourceRange(left, sourceRange, path)
}
if (right.start <= start && right.end >= end) {
path.push(['right', 'BinaryExpression'])
return moreNodePathFromSourceRange(right, sourceRange, path)
}
return path
}
if (_node.type === 'PipeExpression' && isInRange) {
const { body } = _node
for (let i = 0; i < body.length; i++) {
const pipe = body[i]
if (pipe.start <= start && pipe.end >= end) {
path.push(['body', 'PipeExpression'])
path.push([i, 'index'])
return moreNodePathFromSourceRange(pipe, sourceRange, path)
}
}
return path
}
if (_node.type === 'ArrayExpression' && isInRange) {
const { elements } = _node
for (let elIndex = 0; elIndex < elements.length; elIndex++) {
const element = elements[elIndex]
if (element.start <= start && element.end >= end) {
path.push(['elements', 'ArrayExpression'])
path.push([elIndex, 'index'])
return moreNodePathFromSourceRange(element, sourceRange, path)
}
}
return path
}
if (_node.type === 'ObjectExpression' && isInRange) {
const { properties } = _node
for (let propIndex = 0; propIndex < properties.length; propIndex++) {
const property = properties[propIndex]
if (property.start <= start && property.end >= end) {
path.push(['properties', 'ObjectExpression'])
path.push([propIndex, 'index'])
if (property.key.start <= start && property.key.end >= end) {
path.push(['key', 'Property'])
return moreNodePathFromSourceRange(property.key, sourceRange, path)
}
if (property.value.start <= start && property.value.end >= end) {
path.push(['value', 'Property'])
return moreNodePathFromSourceRange(property.value, sourceRange, path)
}
}
}
return path
}
if (_node.type === 'ExpressionStatement' && isInRange) {
const { expression } = _node
path.push(['expression', 'ExpressionStatement'])
return moreNodePathFromSourceRange(expression, sourceRange, path)
}
if (_node.type === 'VariableDeclaration' && isInRange) {
const declaration = _node.declaration
if (declaration.start <= start && declaration.end >= end) {
path.push(['declaration', 'VariableDeclaration'])
const init = declaration.init
if (init.start <= start && init.end >= end) {
path.push(['init', ''])
return moreNodePathFromSourceRange(init, sourceRange, path)
}
}
}
if (_node.type === 'VariableDeclaration' && isInRange) {
const declaration = _node.declaration
if (declaration.start <= start && declaration.end >= end) {
const init = declaration.init
if (init.start <= start && init.end >= end) {
path.push(['declaration', 'VariableDeclaration'])
path.push(['init', ''])
return moreNodePathFromSourceRange(init, sourceRange, path)
}
}
return path
}
if (_node.type === 'UnaryExpression' && isInRange) {
const { argument } = _node
if (argument.start <= start && argument.end >= end) {
path.push(['argument', 'UnaryExpression'])
return moreNodePathFromSourceRange(argument, sourceRange, path)
}
return path
}
if (_node.type === 'FunctionExpression' && isInRange) {
for (let i = 0; i < _node.params.length; i++) {
const param = _node.params[i]
if (param.identifier.start <= start && param.identifier.end >= end) {
path.push(['params', 'FunctionExpression'])
path.push([i, 'index'])
return moreNodePathFromSourceRange(param.identifier, sourceRange, path)
}
}
if (_node.body.start <= start && _node.body.end >= end) {
path.push(['body', 'FunctionExpression'])
const fnBody = _node.body.body
for (let i = 0; i < fnBody.length; i++) {
const statement = fnBody[i]
if (statement.start <= start && statement.end >= end) {
path.push(['body', 'FunctionExpression'])
path.push([i, 'index'])
return moreNodePathFromSourceRange(statement, sourceRange, path)
}
}
}
return path
}
if (_node.type === 'ReturnStatement' && isInRange) {
const { argument } = _node
if (argument.start <= start && argument.end >= end) {
path.push(['argument', 'ReturnStatement'])
return moreNodePathFromSourceRange(argument, sourceRange, path)
}
return path
}
if (_node.type === 'MemberExpression' && isInRange) {
const { object, property } = _node
if (object.start <= start && object.end >= end) {
path.push(['object', 'MemberExpression'])
return moreNodePathFromSourceRange(object, sourceRange, path)
}
if (property.start <= start && property.end >= end) {
path.push(['property', 'MemberExpression'])
return moreNodePathFromSourceRange(property, sourceRange, path)
}
return path
}
if (_node.type === 'PipeSubstitution' && isInRange) return path
if (_node.type === 'IfExpression' && isInRange) {
const { cond, then_val, else_ifs, final_else } = _node
if (cond.start <= start && cond.end >= end) {
path.push(['cond', 'IfExpression'])
return moreNodePathFromSourceRange(cond, sourceRange, path)
}
if (then_val.start <= start && then_val.end >= end) {
path.push(['then_val', 'IfExpression'])
path.push(['body', 'IfExpression'])
return getNodePathFromSourceRange(then_val, sourceRange, path)
}
for (let i = 0; i < else_ifs.length; i++) {
const else_if = else_ifs[i]
if (else_if.start <= start && else_if.end >= end) {
path.push(['else_ifs', 'IfExpression'])
path.push([i, 'index'])
const { cond, then_val } = else_if
if (cond.start <= start && cond.end >= end) {
path.push(['cond', 'IfExpression'])
return moreNodePathFromSourceRange(cond, sourceRange, path)
}
path.push(['then_val', 'IfExpression'])
path.push(['body', 'IfExpression'])
return getNodePathFromSourceRange(then_val, sourceRange, path)
}
}
if (final_else.start <= start && final_else.end >= end) {
path.push(['final_else', 'IfExpression'])
path.push(['body', 'IfExpression'])
return getNodePathFromSourceRange(final_else, sourceRange, path)
}
return path
}
if (_node.type === 'ImportStatement' && isInRange) {
if (_node.selector && _node.selector.type === 'List') {
path.push(['selector', 'ImportStatement'])
const { items } = _node.selector
for (let i = 0; i < items.length; i++) {
const item = items[i]
if (item.start <= start && item.end >= end) {
path.push(['items', 'ImportSelector'])
path.push([i, 'index'])
if (item.name.start <= start && item.name.end >= end) {
path.push(['name', 'ImportItem'])
return path
}
if (
item.alias &&
item.alias.start <= start &&
item.alias.end >= end
) {
path.push(['alias', 'ImportItem'])
return path
}
return path
}
}
return path
}
return path
}
console.error('not implemented: ' + node.type)
return path
}
export function getNodePathFromSourceRange(
node: Program,
sourceRange: SourceRange,
previousPath: PathToNode = [['body', '']]
): PathToNode {
const [start, end] = sourceRange || []
let path: PathToNode = [...previousPath]
const _node = { ...node }
// loop over each statement in body getting the index with a for loop
for (
let statementIndex = 0;
statementIndex < _node.body.length;
statementIndex++
) {
const statement = _node.body[statementIndex]
if (statement.start <= start && statement.end >= end) {
path.push([statementIndex, 'index'])
return moreNodePathFromSourceRange(statement, sourceRange, path)
}
}
return path
}

View File

@ -16,7 +16,7 @@ import {
EdgeCut,
} from 'lang/wasm'
import { Models } from '@kittycad/lib'
import { getNodePathFromSourceRange } from 'lang/queryAst'
import { getNodePathFromSourceRange } from 'lang/queryAstNodePathUtils'
import { err } from 'lib/trap'
export type { Artifact, ArtifactId, SegmentArtifact } from 'lang/wasm'

View File

@ -1014,6 +1014,11 @@ class EngineConnection extends EventTarget {
this.pingPongSpan.pong = new Date()
break
case 'modeling_session_data':
let api_call_id = resp.data?.session?.api_call_id
console.log(`API Call ID: ${api_call_id}`)
break
// Only fires on successful authentication.
case 'ice_server_info':
let ice_servers = resp.data?.ice_servers

View File

@ -14,7 +14,8 @@ import {
CallExpression,
topLevelRange,
} from '../wasm'
import { getNodeFromPath, getNodePathFromSourceRange } from '../queryAst'
import { getNodeFromPath } from '../queryAst'
import { getNodePathFromSourceRange } from 'lang/queryAstNodePathUtils'
import { enginelessExecutor } from '../../lib/testHelpers'
import { err } from 'lib/trap'
import { Node } from 'wasm-lib/kcl/bindings/Node'

View File

@ -17,9 +17,9 @@ import {
import {
getNodeFromPath,
getNodeFromPathCurry,
getNodePathFromSourceRange,
getObjExprProperty,
} from 'lang/queryAst'
import { getNodePathFromSourceRange } from 'lang/queryAstNodePathUtils'
import {
isLiteralArrayOrStatic,
isNotLiteralArrayOrStatic,

View File

@ -20,13 +20,10 @@ import {
sketchFromKclValue,
Literal,
SourceRange,
LiteralValue,
} from '../wasm'
import {
getNodeFromPath,
getNodeFromPathCurry,
getNodePathFromSourceRange,
isValueZero,
} from '../queryAst'
import { getNodeFromPath, getNodeFromPathCurry } from '../queryAst'
import { getNodePathFromSourceRange } from 'lang/queryAstNodePathUtils'
import {
createArrayExpression,
createBinaryExpression,
@ -79,11 +76,32 @@ export type ConstraintType =
| 'setAngleBetween'
const REF_NUM_ERR = new Error('Referenced segment does not have a to value')
function asNum(val: LiteralValue): number | Error {
if (typeof val === 'object') return val.value
return REF_NUM_ERR
}
function forceNum(arg: Literal): number {
if (typeof arg.value === 'boolean' || typeof arg.value === 'string') {
return Number(arg.value)
} else {
return arg.value.value
}
}
function isUndef(val: any): val is undefined {
return typeof val === 'undefined'
}
function isNum(val: any): val is number {
return typeof val === 'number'
function isValueZero(val?: Expr): boolean {
return (
(val?.type === 'Literal' && forceNum(val) === 0) ||
(val?.type === 'UnaryExpression' &&
val.operator === '-' &&
val.argument.type === 'Literal' &&
Number(val.argument.value) === 0)
)
}
function createCallWrapper(
@ -190,7 +208,7 @@ const xyLineSetLength =
: referenceSeg
? segRef
: args[0].expr
const literalARg = getArgLiteralVal(args[0].expr)
const literalARg = asNum(args[0].expr.value)
if (err(literalARg)) return literalARg
return createCallWrapper(xOrY, lineVal, tag, literalARg)
}
@ -211,13 +229,14 @@ const basicAngledLineCreateNode =
referencedSegment: path,
}) => {
const refAng = path ? getAngle(path?.from, path?.to) : 0
if (!isNum(args[0].expr.value)) return REF_NUM_ERR
const argValue = asNum(args[0].expr.value)
if (err(argValue)) return argValue
const nonForcedAng =
varValToUse === 'ang'
? inputs[0].expr
: referenceSeg === 'ang'
? getClosesAngleDirection(
args[0].expr.value,
argValue,
refAng,
createSegAngle(referenceSegName)
)
@ -230,8 +249,8 @@ const basicAngledLineCreateNode =
: args[1].expr
const shouldForceAng = valToForce === 'ang' && forceValueUsedInTransform
const shouldForceLen = valToForce === 'len' && forceValueUsedInTransform
const literalArg = getArgLiteralVal(
valToForce === 'ang' ? args[0].expr : args[1].expr
const literalArg = asNum(
valToForce === 'ang' ? args[0].expr.value : args[1].expr.value
)
if (err(literalArg)) return literalArg
return createCallWrapper(
@ -283,7 +302,7 @@ const getMinAndSegAngVals = (
}
const getSignedLeg = (arg: Literal, legLenVal: BinaryPart) =>
Number(arg.value) < 0 ? createUnaryExpression(legLenVal) : legLenVal
forceNum(arg) < 0 ? createUnaryExpression(legLenVal) : legLenVal
const getLegAng = (ang: number, legAngleVal: BinaryPart) => {
const normalisedAngle = ((ang % 360) + 360) % 360 // between 0 and 360
@ -322,8 +341,7 @@ const setHorzVertDistanceCreateNode =
referencedSegment,
}) => {
const refNum = referencedSegment?.to?.[index]
const literalArg = getArgLiteralVal(args?.[index].expr)
if (err(literalArg)) return literalArg
const literalArg = asNum(args?.[index].expr.value)
if (isUndef(refNum) || err(literalArg)) return REF_NUM_ERR
const valueUsedInTransform = roundOff(literalArg - refNum, 2)
@ -352,7 +370,7 @@ const setHorzVertDistanceForAngleLineCreateNode =
referencedSegment,
}) => {
const refNum = referencedSegment?.to?.[index]
const literalArg = getArgLiteralVal(args?.[1].expr)
const literalArg = asNum(args?.[1].expr.value)
if (isUndef(refNum) || err(literalArg)) return REF_NUM_ERR
const valueUsedInTransform = roundOff(literalArg - refNum, 2)
const binExp = createBinaryExpressionWithUnary([
@ -374,8 +392,8 @@ const setAbsDistanceCreateNode =
index = xOrY === 'x' ? 0 : 1
): CreateStdLibSketchCallExpr =>
({ tag, forceValueUsedInTransform, rawArgs: args }) => {
const literalArg = getArgLiteralVal(args?.[index].expr)
if (err(literalArg)) return REF_NUM_ERR
const literalArg = asNum(args?.[index].expr.value)
if (err(literalArg)) return literalArg
const valueUsedInTransform = roundOff(literalArg, 2)
const val = forceValueUsedInTransform || createLiteral(valueUsedInTransform)
if (isXOrYLine) {
@ -396,8 +414,8 @@ const setAbsDistanceCreateNode =
const setAbsDistanceForAngleLineCreateNode =
(xOrY: 'x' | 'y'): CreateStdLibSketchCallExpr =>
({ tag, forceValueUsedInTransform, inputs, rawArgs: args }) => {
const literalArg = getArgLiteralVal(args?.[1].expr)
if (err(literalArg)) return REF_NUM_ERR
const literalArg = asNum(args?.[1].expr.value)
if (err(literalArg)) return literalArg
const valueUsedInTransform = roundOff(literalArg, 2)
const val = forceValueUsedInTransform || createLiteral(valueUsedInTransform)
return createCallWrapper(
@ -419,7 +437,7 @@ const setHorVertDistanceForXYLines =
}) => {
const index = xOrY === 'x' ? 0 : 1
const refNum = referencedSegment?.to?.[index]
const literalArg = getArgLiteralVal(args?.[index].expr)
const literalArg = asNum(args?.[index].expr.value)
if (isUndef(refNum) || err(literalArg)) return REF_NUM_ERR
const valueUsedInTransform = roundOff(literalArg - refNum, 2)
const makeBinExp = createBinaryExpressionWithUnary([
@ -445,9 +463,9 @@ const setHorzVertDistanceConstraintLineCreateNode =
])
const makeBinExp = (index: 0 | 1) => {
const arg = getArgLiteralVal(args?.[index].expr)
const arg = asNum(args?.[index].expr.value)
const refNum = referencedSegment?.to?.[index]
if (err(arg) || !isNum(refNum)) return REF_NUM_ERR
if (err(arg) || isUndef(refNum)) return REF_NUM_ERR
return createBinaryExpressionWithUnary([
createSegEnd(referenceSegName, isX),
createLiteral(roundOff(arg - refNum, 2)),
@ -468,9 +486,9 @@ const setAngledIntersectLineForLines: CreateStdLibSketchCallExpr = ({
forceValueUsedInTransform,
rawArgs: args,
}) => {
const val = args[1].expr.value,
angle = args[0].expr.value
if (!isNum(val) || !isNum(angle)) return REF_NUM_ERR
const val = asNum(args[1].expr.value),
angle = asNum(args[0].expr.value)
if (err(val) || err(angle)) return REF_NUM_ERR
const valueUsedInTransform = roundOff(val, 2)
const varNamMap: { [key: number]: string } = {
0: 'ZERO',
@ -498,8 +516,8 @@ const setAngledIntersectForAngledLines: CreateStdLibSketchCallExpr = ({
inputs,
rawArgs: args,
}) => {
const val = args[1].expr.value
if (!isNum(val)) return REF_NUM_ERR
const val = asNum(args[1].expr.value)
if (err(val)) return val
const valueUsedInTransform = roundOff(val, 2)
return intersectCallWrapper({
fnName: 'angledLineThatIntersects',
@ -524,8 +542,8 @@ const setAngleBetweenCreateNode =
const refAngle = referencedSegment
? getAngle(referencedSegment?.from, referencedSegment?.to)
: 0
const val = args[0].expr.value
if (!isNum(val)) return REF_NUM_ERR
const val = asNum(args[0].expr.value)
if (err(val)) return val
let valueUsedInTransform = roundOff(normaliseAngle(val - refAngle))
let firstHalfValue = createSegAngle(referenceSegName)
if (Math.abs(valueUsedInTransform) > 90) {
@ -706,13 +724,11 @@ const transformMap: TransformMap = {
createPipeSubstitution(),
]
)
if (!isNum(args[0].expr.value)) return REF_NUM_ERR
const val = asNum(args[0].expr.value)
if (err(val)) return val
return createCallWrapper(
'angledLineToX',
[
getAngleLengthSign(args[0].expr.value, angleToMatchLengthXCall),
inputs[0].expr,
],
[getAngleLengthSign(val, angleToMatchLengthXCall), inputs[0].expr],
tag
)
},
@ -739,13 +755,11 @@ const transformMap: TransformMap = {
createPipeSubstitution(),
]
)
if (!isNum(args[0].expr.value)) return REF_NUM_ERR
const val = asNum(args[0].expr.value)
if (err(val)) return val
return createCallWrapper(
'angledLineToY',
[
getAngleLengthSign(args[0].expr.value, angleToMatchLengthYCall),
inputs[1].expr,
],
[getAngleLengthSign(val, angleToMatchLengthYCall), inputs[1].expr],
tag
)
},
@ -763,7 +777,7 @@ const transformMap: TransformMap = {
forceValueUsedInTransform,
rawArgs: args,
}) => {
const val = getArgLiteralVal(args[0].expr)
const val = asNum(args[0].expr.value)
if (err(val)) return val
return createCallWrapper(
'angledLineToY',
@ -844,7 +858,7 @@ const transformMap: TransformMap = {
tooltip: 'yLine',
createNode: ({ inputs, tag, rawArgs: args }) => {
const expr = inputs[1].expr
if (Number(args[0].expr.value) >= 0)
if (forceNum(args[0].expr) >= 0)
return createCallWrapper('yLine', expr, tag)
if (isExprBinaryPart(expr))
return createCallWrapper('yLine', createUnaryExpression(expr), tag)
@ -856,7 +870,7 @@ const transformMap: TransformMap = {
tooltip: 'xLine',
createNode: ({ inputs, tag, rawArgs: args }) => {
const expr = inputs[1].expr
if (Number(args[0].expr.value) >= 0)
if (forceNum(args[0].expr) >= 0)
return createCallWrapper('xLine', expr, tag)
if (isExprBinaryPart(expr))
return createCallWrapper('xLine', createUnaryExpression(expr), tag)
@ -900,10 +914,11 @@ const transformMap: TransformMap = {
referenceSegName,
getInputOfType(inputs, 'xRelative').expr
)
if (!isNum(args[0].expr.value)) return REF_NUM_ERR
const val = asNum(args[0].expr.value)
if (err(val)) return val
return createCallWrapper(
'angledLineOfXLength',
[getLegAng(args[0].expr.value, legAngle), minVal],
[getLegAng(val, legAngle), minVal],
tag
)
},
@ -912,7 +927,7 @@ const transformMap: TransformMap = {
tooltip: 'xLine',
createNode: ({ inputs, tag, rawArgs: args }) => {
const expr = inputs[1].expr
if (Number(args[0].expr.value) >= 0)
if (forceNum(args[0].expr) >= 0)
return createCallWrapper('xLine', expr, tag)
if (isExprBinaryPart(expr))
return createCallWrapper('xLine', createUnaryExpression(expr), tag)
@ -953,10 +968,11 @@ const transformMap: TransformMap = {
inputs[1].expr,
'legAngY'
)
if (!isNum(args[0].expr.value)) return REF_NUM_ERR
const val = asNum(args[0].expr.value)
if (err(val)) return val
return createCallWrapper(
'angledLineOfXLength',
[getLegAng(args[0].expr.value, legAngle), minVal],
[getLegAng(val, legAngle), minVal],
tag
)
},
@ -965,7 +981,7 @@ const transformMap: TransformMap = {
tooltip: 'yLine',
createNode: ({ inputs, tag, rawArgs: args }) => {
const expr = inputs[1].expr
if (Number(args[0].expr.value) >= 0)
if (forceNum(args[0].expr) >= 0)
return createCallWrapper('yLine', expr, tag)
if (isExprBinaryPart(expr))
return createCallWrapper('yLine', createUnaryExpression(expr), tag)
@ -1005,13 +1021,11 @@ const transformMap: TransformMap = {
createPipeSubstitution(),
]
)
if (!isNum(args[0].expr.value)) return REF_NUM_ERR
const val = asNum(args[0].expr.value)
if (err(val)) return val
return createCallWrapper(
'angledLineToX',
[
getAngleLengthSign(args[0].expr.value, angleToMatchLengthXCall),
inputs[1].expr,
],
[getAngleLengthSign(val, angleToMatchLengthXCall), inputs[1].expr],
tag
)
},
@ -1057,13 +1071,11 @@ const transformMap: TransformMap = {
createPipeSubstitution(),
]
)
if (!isNum(args[0].expr.value)) return REF_NUM_ERR
const val = asNum(args[0].expr.value)
if (err(val)) return val
return createCallWrapper(
'angledLineToY',
[
getAngleLengthSign(args[0].expr.value, angleToMatchLengthXCall),
inputs[1].expr,
],
[getAngleLengthSign(val, angleToMatchLengthXCall), inputs[1].expr],
tag
)
},
@ -1080,7 +1092,7 @@ const transformMap: TransformMap = {
equalLength: {
tooltip: 'xLine',
createNode: ({ referenceSegName, tag, rawArgs: args }) => {
const argVal = getArgLiteralVal(args[0].expr)
const argVal = asNum(args[0].expr.value)
if (err(argVal)) return argVal
const segLen = createSegLen(referenceSegName)
if (argVal > 0) return createCallWrapper('xLine', segLen, tag, argVal)
@ -1118,7 +1130,7 @@ const transformMap: TransformMap = {
equalLength: {
tooltip: 'yLine',
createNode: ({ referenceSegName, tag, rawArgs: args }) => {
const argVal = getArgLiteralVal(args[0].expr)
const argVal = asNum(args[0].expr.value)
if (err(argVal)) return argVal
let segLen = createSegLen(referenceSegName)
if (argVal < 0) segLen = createUnaryExpression(segLen)
@ -1714,7 +1726,7 @@ export function transformAstSketchLines({
let kclVal = programMemory.get(varName)
let sketch
if (kclVal?.type === 'Solid') {
sketch = kclVal.sketch
sketch = kclVal.value.sketch
} else {
sketch = sketchFromKclValue(kclVal, varName)
if (err(sketch)) {
@ -1823,11 +1835,6 @@ function createLastSeg(isX: boolean): Node<CallExpression> {
])
}
function getArgLiteralVal(arg: Literal): number | Error {
if (!isNum(arg.value)) return REF_NUM_ERR
return arg.value
}
export type ConstraintLevel = 'free' | 'partial' | 'full'
export function getConstraintLevelFromSourceRange(

View File

@ -53,7 +53,7 @@ import { ArtifactId } from 'wasm-lib/kcl/bindings/Artifact'
import { ArtifactCommand } from 'wasm-lib/kcl/bindings/Artifact'
import { ArtifactGraph as RustArtifactGraph } from 'wasm-lib/kcl/bindings/Artifact'
import { Artifact } from './std/artifactGraph'
import { getNodePathFromSourceRange } from './queryAst'
import { getNodePathFromSourceRange } from 'lang/queryAstNodePathUtils'
export type { Artifact } from 'wasm-lib/kcl/bindings/Artifact'
export type { ArtifactCommand } from 'wasm-lib/kcl/bindings/Artifact'
@ -539,7 +539,8 @@ export function sketchFromKclValueOptional(
): Sketch | Reason {
if (obj?.value?.type === 'Sketch') return obj.value
if (obj?.value?.type === 'Solid') return obj.value.sketch
if (obj?.type === 'Solid') return obj.sketch
if (obj?.type === 'Sketch') return obj.value
if (obj?.type === 'Solid') return obj.value.sketch
if (!varName) {
varName = 'a KCL value'
}

40
src/lib/base64.test.ts Normal file
View File

@ -0,0 +1,40 @@
import { expect } from 'vitest'
import { base64ToString, stringToBase64 } from './base64'
describe('base64 encoding', () => {
test('to base64, simple code', async () => {
const code = `extrusionDistance = 12`
// Generated by online tool
const expectedBase64 = `ZXh0cnVzaW9uRGlzdGFuY2UgPSAxMg==`
const base64 = stringToBase64(code)
expect(base64).toBe(expectedBase64)
})
test(`to base64, code with UTF-8 characters`, async () => {
// example adapted from MDN docs: https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem
const code = `a = 5; Ā = 12.in(); 𐀀 = Ā; 文 = 12.0; 🦄 = -5;`
// Generated by online tool
const expectedBase64 = `YSA9IDU7IMSAID0gMTIuaW4oKTsg8JCAgCA9IMSAOyDmlocgPSAxMi4wOyDwn6aEID0gLTU7`
const base64 = stringToBase64(code)
expect(base64).toBe(expectedBase64)
})
// The following are simply the reverse of the above tests
test('from base64, simple code', async () => {
const base64 = `ZXh0cnVzaW9uRGlzdGFuY2UgPSAxMg==`
const expectedCode = `extrusionDistance = 12`
const code = base64ToString(base64)
expect(code).toBe(expectedCode)
})
test(`from base64, code with UTF-8 characters`, async () => {
const base64 = `YSA9IDU7IMSAID0gMTIuaW4oKTsg8JCAgCA9IMSAOyDmlocgPSAxMi4wOyDwn6aEID0gLTU7`
const expectedCode = `a = 5; Ā = 12.in(); 𐀀 = Ā; 文 = 12.0; 🦄 = -5;`
const code = base64ToString(base64)
expect(code).toBe(expectedCode)
})
})

29
src/lib/base64.ts Normal file
View File

@ -0,0 +1,29 @@
/**
* Converts a string to a base64 string, preserving the UTF-8 encoding
*/
export function stringToBase64(str: string) {
return bytesToBase64(new TextEncoder().encode(str))
}
/**
* Converts a base64 string to a string, preserving the UTF-8 encoding
*/
export function base64ToString(base64: string) {
return new TextDecoder().decode(base64ToBytes(base64))
}
/**
* From the MDN Web Docs
* https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem
*/
function base64ToBytes(base64: string) {
const binString = atob(base64)
return Uint8Array.from(binString, (m) => m.codePointAt(0)!)
}
function bytesToBase64(bytes: Uint8Array) {
const binString = Array.from(bytes, (byte) =>
String.fromCodePoint(byte)
).join('')
return btoa(binString)
}

View File

@ -13,6 +13,7 @@ import {
loftValidator,
revolveAxisValidator,
shellValidator,
sweepValidator,
} from './validators'
type OutputFormat = Models['OutputFormat_type']
@ -42,8 +43,8 @@ export type ModelingCommandSchema = {
distance: KclCommandValue
}
Sweep: {
path: Selections
profile: Selections
target: Selections
trajectory: Selections
}
Loft: {
selection: Selections
@ -308,25 +309,24 @@ export const modelingMachineCommandConfig: StateMachineCommandSetConfig<
'Create a 3D body by moving a sketch region along an arbitrary path.',
icon: 'sweep',
status: 'development',
needsReview: true,
needsReview: false,
args: {
profile: {
target: {
inputType: 'selection',
selectionTypes: ['solid2d'],
required: true,
skip: true,
multiple: false,
// TODO: add dry-run validation
warningMessage:
'The sweep workflow is new and under tested. Please break it and report issues.',
},
path: {
trajectory: {
inputType: 'selection',
selectionTypes: ['segment', 'path'],
required: true,
skip: true,
skip: false,
multiple: false,
// TODO: add dry-run validation
validation: sweepValidator,
},
},
},

View File

@ -1,5 +1,8 @@
import { UnitLength_type } from '@kittycad/lib/dist/types/src/models'
import { CommandBarOverwriteWarning } from 'components/CommandBarOverwriteWarning'
import { StateMachineCommandSetConfig } from 'lib/commandTypes'
import { isDesktop } from 'lib/isDesktop'
import { baseUnitLabels, baseUnitsUnion } from 'lib/settings/settingsTypes'
import { projectsMachine } from 'machines/projectsMachine'
export type ProjectsCommandSchema = {
@ -17,6 +20,13 @@ export type ProjectsCommandSchema = {
oldName: string
newName: string
}
'Import file from URL': {
name: string
code?: string
units: UnitLength_type
method: 'newProject' | 'existingProject'
projectName?: string
}
}
export const projectsCommandBarConfig: StateMachineCommandSetConfig<
@ -26,6 +36,7 @@ export const projectsCommandBarConfig: StateMachineCommandSetConfig<
'Open project': {
icon: 'arrowRight',
description: 'Open a project',
status: isDesktop() ? 'active' : 'inactive',
args: {
name: {
inputType: 'options',
@ -42,6 +53,7 @@ export const projectsCommandBarConfig: StateMachineCommandSetConfig<
'Create project': {
icon: 'folderPlus',
description: 'Create a project',
status: isDesktop() ? 'active' : 'inactive',
args: {
name: {
inputType: 'string',
@ -53,6 +65,7 @@ export const projectsCommandBarConfig: StateMachineCommandSetConfig<
'Delete project': {
icon: 'close',
description: 'Delete a project',
status: isDesktop() ? 'active' : 'inactive',
needsReview: true,
reviewMessage: ({ argumentsToSubmit }) =>
CommandBarOverwriteWarning({
@ -75,6 +88,7 @@ export const projectsCommandBarConfig: StateMachineCommandSetConfig<
icon: 'folder',
description: 'Rename a project',
needsReview: true,
status: isDesktop() ? 'active' : 'inactive',
args: {
oldName: {
inputType: 'options',
@ -92,4 +106,80 @@ export const projectsCommandBarConfig: StateMachineCommandSetConfig<
},
},
},
'Import file from URL': {
icon: 'file',
description: 'Create a file',
needsReview: true,
status: 'active',
args: {
method: {
inputType: 'options',
required: true,
skip: true,
options: isDesktop()
? [
{ name: 'New project', value: 'newProject' },
{ name: 'Existing project', value: 'existingProject' },
]
: [{ name: 'Overwrite', value: 'existingProject' }],
valueSummary(value) {
return isDesktop()
? value === 'newProject'
? 'New project'
: 'Existing project'
: 'Overwrite'
},
},
// TODO: We can't get the currently-opened project to auto-populate here because
// it's not available on projectMachine, but lower in fileMachine. Unify these.
projectName: {
inputType: 'options',
required: (commandsContext) =>
isDesktop() &&
commandsContext.argumentsToSubmit.method === 'existingProject',
skip: true,
options: (_, context) =>
context?.projects.map((p) => ({
name: p.name!,
value: p.name!,
})) || [],
},
name: {
inputType: 'string',
required: isDesktop(),
skip: true,
},
code: {
inputType: 'text',
required: true,
skip: true,
valueSummary(value) {
const lineCount = value?.trim().split('\n').length
return `${lineCount} line${lineCount === 1 ? '' : 's'}`
},
},
units: {
inputType: 'options',
required: false,
skip: true,
options: baseUnitsUnion.map((unit) => ({
name: baseUnitLabels[unit],
value: unit,
})),
},
},
reviewMessage(commandBarContext) {
return isDesktop()
? `Will add the contents from URL to a new ${
commandBarContext.argumentsToSubmit.method === 'newProject'
? 'project with file main.kcl'
: `file within the project "${commandBarContext.argumentsToSubmit.projectName}"`
} named "${
commandBarContext.argumentsToSubmit.name
}", and set default units to "${
commandBarContext.argumentsToSubmit.units
}".`
: `Will overwrite the contents of the current file with the contents from the URL.`
},
},
}

View File

@ -207,3 +207,64 @@ export const shellValidator = async ({
return 'Unable to shell with the provided selection'
}
export const sweepValidator = async ({
context,
data,
}: {
context: CommandBarContext
data: { trajectory: Selections }
}): Promise<boolean | string> => {
if (!isSelections(data.trajectory)) {
console.log('Unable to sweep, selections are missing')
return 'Unable to sweep, selections are missing'
}
// Retrieve the parent path from the segment selection directly
const trajectoryArtifact = data.trajectory.graphSelections[0].artifact
if (!trajectoryArtifact) {
return "Unable to sweep, couldn't find the trajectory artifact"
}
if (trajectoryArtifact.type !== 'segment') {
return "Unable to sweep, couldn't find the target from a non-segment selection"
}
const trajectory = trajectoryArtifact.pathId
// Get the former arg in the command bar flow, and retrieve the path from the solid2d directly
const targetArg = context.argumentsToSubmit['target'] as Selections
const targetArtifact = targetArg.graphSelections[0].artifact
if (!targetArtifact) {
return "Unable to sweep, couldn't find the profile artifact"
}
if (targetArtifact.type !== 'solid2d') {
return "Unable to sweep, couldn't find the target from a non-solid2d selection"
}
const target = targetArtifact.pathId
const sweepCommand = async () => {
// TODO: second look on defaults here
const DEFAULT_TOLERANCE: Models['LengthUnit_type'] = 1e-7
const DEFAULT_SECTIONAL = false
const cmdArgs = {
target,
trajectory,
sectional: DEFAULT_SECTIONAL,
tolerance: DEFAULT_TOLERANCE,
}
return await engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'sweep',
...cmdArgs,
},
})
}
const attemptSweep = await dryRunWrapper(sweepCommand)
if (attemptSweep?.success) {
return true
}
return 'Unable to sweep with the provided selection'
}

View File

@ -69,6 +69,7 @@ export const KCL_DEFAULT_DEGREE = `360`
export const TEST_SETTINGS_FILE_KEY = 'playwright-test-settings'
export const DEFAULT_HOST = 'https://api.zoo.dev'
export const PROD_APP_URL = 'https://app.zoo.dev'
export const SETTINGS_FILE_NAME = 'settings.toml'
export const TOKEN_FILE_NAME = 'token.txt'
export const PROJECT_SETTINGS_FILE_NAME = 'project.toml'
@ -110,6 +111,9 @@ export const KCL_SAMPLES_MANIFEST_URLS = {
localFallback: '/kcl-samples-manifest-fallback.json',
} as const
/** URL parameter to create a file */
export const CREATE_FILE_URL_PARAM = 'create-file'
/** Toast id for the app auto-updater toast */
export const AUTO_UPDATER_TOAST_ID = 'auto-updater-toast'
@ -139,3 +143,12 @@ export const VIEW_NAMES_SEMANTIC = {
} as const
/** The modeling sidebar buttons' IDs get a suffix to prevent collisions */
export const SIDEBAR_BUTTON_SUFFIX = '-pane-button'
/** Custom URL protocol our desktop registers */
export const ZOO_STUDIO_PROTOCOL = 'zoo-studio:'
/**
* A query parameter that triggers a modal
* to "open in desktop app" when present in the URL
*/
export const ASK_TO_OPEN_QUERY_PARAM = 'ask-open-desktop'

View File

@ -1,12 +1,14 @@
import { CommandBarOverwriteWarning } from 'components/CommandBarOverwriteWarning'
import { Command, CommandArgumentOption } from './commandTypes'
import { kclManager } from './singletons'
import { codeManager, kclManager } from './singletons'
import { isDesktop } from './isDesktop'
import { FILE_EXT, PROJECT_SETTINGS_FILE_NAME } from './constants'
import { UnitLength_type } from '@kittycad/lib/dist/types/src/models'
import { parseProjectSettings } from 'lang/wasm'
import { err, reportRejection } from './trap'
import { projectConfigurationToSettingsPayload } from './settings/settingsUtils'
import { copyFileShareLink } from './links'
import { IndexLoaderData } from './types'
interface OnSubmitProps {
sampleName: string
@ -15,10 +17,21 @@ interface OnSubmitProps {
method: 'overwrite' | 'newFile'
}
export function kclCommands(
onSubmit: (p: OnSubmitProps) => Promise<void>,
providedOptions: CommandArgumentOption<string>[]
): Command[] {
interface KclCommandConfig {
// TODO: find a different approach that doesn't require
// special props for a single command
specialPropsForSampleCommand: {
onSubmit: (p: OnSubmitProps) => Promise<void>
providedOptions: CommandArgumentOption<string>[]
}
projectData: IndexLoaderData
authToken: string
settings: {
defaultUnit: UnitLength_type
}
}
export function kclCommands(commandProps: KclCommandConfig): Command[] {
return [
{
name: 'format-code',
@ -107,7 +120,9 @@ export function kclCommands(
)
.then((props) => {
if (props?.code) {
onSubmit(props).catch(reportError)
commandProps.specialPropsForSampleCommand
.onSubmit(props)
.catch(reportError)
}
})
.catch(reportError)
@ -149,9 +164,25 @@ export function kclCommands(
}
return value
},
options: providedOptions,
options: commandProps.specialPropsForSampleCommand.providedOptions,
},
},
},
// {
// name: 'share-file-link',
// displayName: 'Share file',
// description: 'Create a link that contains a copy of the current file.',
// groupId: 'code',
// needsReview: false,
// icon: 'link',
// onSubmit: () => {
// copyFileShareLink({
// token: commandProps.authToken,
// code: codeManager.code,
// name: commandProps.projectData.project?.name || '',
// units: commandProps.settings.defaultUnit,
// }).catch(reportRejection)
// },
// },
]
}

16
src/lib/links.test.ts Normal file
View File

@ -0,0 +1,16 @@
import { createCreateFileUrl } from './links'
describe(`link creation tests`, () => {
test(`createCreateFileUrl happy path`, async () => {
const code = `extrusionDistance = 12`
const name = `test`
const units = `mm`
// Converted with external online tools
const expectedEncodedCode = `ZXh0cnVzaW9uRGlzdGFuY2UgPSAxMg%3D%3D`
const expectedLink = `http://localhost:3000/?create-file=true&name=test&units=mm&code=${expectedEncodedCode}&ask-open-desktop=true`
const result = createCreateFileUrl({ code, name, units })
expect(result.toString()).toBe(expectedLink)
})
})

100
src/lib/links.ts Normal file
View File

@ -0,0 +1,100 @@
import { UnitLength_type } from '@kittycad/lib/dist/types/src/models'
import {
ASK_TO_OPEN_QUERY_PARAM,
CREATE_FILE_URL_PARAM,
PROD_APP_URL,
} from './constants'
import { stringToBase64 } from './base64'
import { DEV, VITE_KC_API_BASE_URL } from 'env'
import toast from 'react-hot-toast'
import { err } from './trap'
export interface FileLinkParams {
code: string
name: string
units: UnitLength_type
}
export async function copyFileShareLink(
args: FileLinkParams & { token: string }
) {
const token = args.token
if (!token) {
toast.error('You need to be signed in to share a file.', {
duration: 5000,
})
return
}
const shareUrl = createCreateFileUrl(args)
const shortlink = await createShortlink(token, shareUrl.toString())
if (err(shortlink)) {
toast.error(shortlink.message, {
duration: 5000,
})
return
}
await globalThis.navigator.clipboard.writeText(shortlink.url)
toast.success(
'Link copied to clipboard. Anyone who clicks this link will get a copy of this file. Share carefully!',
{
duration: 5000,
}
)
}
/**
* Creates a URL with the necessary query parameters to trigger
* the "Import file from URL" command in the app.
*
* With the additional step of asking the user if they want to
* open the URL in the desktop app.
*/
export function createCreateFileUrl({ code, name, units }: FileLinkParams) {
// Use the dev server if we are in development mode
let origin = DEV ? 'http://localhost:3000' : PROD_APP_URL
const searchParams = new URLSearchParams({
[CREATE_FILE_URL_PARAM]: String(true),
name,
units,
code: stringToBase64(code),
[ASK_TO_OPEN_QUERY_PARAM]: String(true),
})
const createFileUrl = new URL(`?${searchParams.toString()}`, origin)
return createFileUrl
}
/**
* Given a file's code, name, and units, creates shareable link to the
* web app with a query parameter that triggers a modal to "open in desktop app".
* That modal is defined in the `OpenInDesktopAppHandler` component.
* TODO: update the return type to use TS library after its updated
*/
export async function createShortlink(
token: string,
url: string
): Promise<Error | { key: string; url: string }> {
/**
* We don't use our `withBaseURL` function here because
* there is no URL shortener service in the dev API.
*/
const response = await fetch(`${VITE_KC_API_BASE_URL}/user/shortlinks`, {
method: 'POST',
headers: {
'Content-type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
url,
// In future we can support org-scoped and password-protected shortlinks here
// https://zoo.dev/docs/api/shortlinks/create-a-shortlink-for-a-user?lang=typescript
}),
})
if (!response.ok) {
const error = await response.json()
return new Error(`Failed to create shortlink: ${error.message}`)
} else {
return response.json()
}
}

View File

@ -114,7 +114,7 @@ export const fileLoader: LoaderFunction = async (
return redirect(
`${PATHS.FILE}/${encodeURIComponent(
isDesktop() ? fallbackFile : params.id + '/' + PROJECT_ENTRYPOINT
)}`
)}${new URL(routerData.request.url).search || ''}`
)
}
@ -188,11 +188,14 @@ export const fileLoader: LoaderFunction = async (
// Loads the settings and by extension the projects in the default directory
// and returns them to the Home route, along with any errors that occurred
export const homeLoader: LoaderFunction = async (): Promise<
HomeLoaderData | Response
> => {
export const homeLoader: LoaderFunction = async ({
request,
}): Promise<HomeLoaderData | Response> => {
const url = new URL(request.url)
if (!isDesktop()) {
return redirect(PATHS.FILE + '/%2F' + BROWSER_PROJECT_NAME)
return redirect(
PATHS.FILE + '/%2F' + BROWSER_PROJECT_NAME + (url.search || '')
)
}
return {}
}

View File

@ -18,11 +18,8 @@ import { EditorSelection, SelectionRange } from '@codemirror/state'
import { getNormalisedCoordinates, isOverlap } from 'lib/utils'
import { isCursorInSketchCommandRange } from 'lang/util'
import { Program } from 'lang/wasm'
import {
getNodeFromPath,
getNodePathFromSourceRange,
isSingleCursorInPipe,
} from 'lang/queryAst'
import { getNodeFromPath, isSingleCursorInPipe } from 'lang/queryAst'
import { getNodePathFromSourceRange } from 'lang/queryAstNodePathUtils'
import { CommandArgument } from './commandTypes'
import {
DefaultPlaneStr,

View File

@ -68,10 +68,6 @@ interface TextToKclProps {
data?: unknown
) => unknown
navigate: NavigateFunction
commandBarSend: (
type: EventFrom<typeof commandBarMachine>,
data?: unknown
) => unknown
context: ContextFrom<typeof fileMachine>
token?: string
settings: {
@ -84,7 +80,6 @@ export async function submitAndAwaitTextToKcl({
trimmedPrompt,
fileMachineSend,
navigate,
commandBarSend,
context,
token,
settings,
@ -96,7 +91,6 @@ export async function submitAndAwaitTextToKcl({
ToastTextToCadError({
toastId,
message,
commandBarSend,
prompt: trimmedPrompt,
}),
{
@ -195,7 +189,7 @@ export async function submitAndAwaitTextToKcl({
.toLowerCase()}${FILE_EXT}`
if (isDesktop()) {
// We have to pre-emptively run our unique file name logic,
// We have to preemptively run our unique file name logic,
// so that we can pass the unique file name to the toast,
// and by extension the file-deletion-on-reject logic.
newFileName = getNextFileName({

Some files were not shown because too many files have changed in this diff Show More