Compare commits

..

14 Commits

Author SHA1 Message Date
182865014e try 2024-04-25 10:05:22 +10:00
2452eede0b omg 2024-04-25 09:59:51 +10:00
98442b9ec2 tweak 2024-04-25 09:55:52 +10:00
fb1c8036f6 try debug again 2024-04-25 09:50:22 +10:00
2918612d4b trying shit 2024-04-25 09:47:40 +10:00
abbd065c2c typo 2024-04-25 09:45:00 +10:00
23e29b024f trigger ci 2024-04-25 09:41:31 +10:00
807adac371 more tweaks 2024-04-25 09:36:36 +10:00
03eb8dca32 debug code 2024-04-25 09:35:22 +10:00
e3358f8251 more tweaks 2024-04-25 09:26:26 +10:00
49ea3991b2 another tweak 2024-04-25 09:20:14 +10:00
f32f0e2717 debug 2024-04-25 09:09:00 +10:00
0363e4f4e0 tweak 2024-04-25 08:59:53 +10:00
5e60dbd5e8 speed up playw tests by skipping wasm:build 2024-04-25 08:57:25 +10:00
128 changed files with 2197 additions and 4628 deletions

View File

@ -1,5 +1,6 @@
VITE_KC_API_WS_MODELING_URL=wss://api.dev.zoo.dev/ws/modeling/commands VITE_KC_API_WS_MODELING_URL=wss://api.dev.zoo.dev/ws/modeling/commands
VITE_KC_API_BASE_URL=https://api.dev.zoo.dev VITE_KC_API_BASE_URL=https://api.dev.zoo.dev
VITE_KC_SITE_BASE_URL=https://dev.zoo.dev VITE_KC_SITE_BASE_URL=https://dev.zoo.dev
VITE_KC_WASM_OVERRIDE_URL=""
VITE_KC_SKIP_AUTH=false VITE_KC_SKIP_AUTH=false
VITE_KC_CONNECTION_TIMEOUT_MS=5000 VITE_KC_CONNECTION_TIMEOUT_MS=5000

View File

@ -1,5 +1,6 @@
VITE_KC_API_WS_MODELING_URL=wss://api.zoo.dev/ws/modeling/commands VITE_KC_API_WS_MODELING_URL=wss://api.zoo.dev/ws/modeling/commands
VITE_KC_API_BASE_URL=https://api.zoo.dev VITE_KC_API_BASE_URL=https://api.zoo.dev
VITE_KC_SITE_BASE_URL=https://zoo.dev VITE_KC_SITE_BASE_URL=https://zoo.dev
VITE_KC_WASM_OVERRIDE_URL=""
VITE_KC_SKIP_AUTH=false VITE_KC_SKIP_AUTH=false
VITE_KC_CONNECTION_TIMEOUT_MS=15000 VITE_KC_CONNECTION_TIMEOUT_MS=15000

View File

@ -1,35 +0,0 @@
name: Build and Store WASM
on:
push:
branches:
- main
jobs:
build-and-upload:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'yarn'
- name: Install dependencies
run: yarn
- name: Install Playwright Browsers
run: yarn playwright install --with-deps
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache wasm
uses: Swatinem/rust-cache@v2
with:
workspaces: './src/wasm-lib'
- name: build wasm
run: yarn build:wasm
# Upload the WASM bundle as an artifact
- uses: actions/upload-artifact@v2
with:
name: wasm-bundle
path: src/wasm-lib/pkg

View File

@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
dir: ['src/wasm-lib', 'src-tauri'] dir: ['src/wasm-lib']
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install latest rust - name: Install latest rust
@ -31,22 +31,9 @@ jobs:
- name: install dependencies - name: install dependencies
if: matrix.dir == 'src-tauri' if: matrix.dir == 'src-tauri'
shell: bash
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y \ sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf
libgtk-3-dev \
libayatana-appindicator3-dev \
webkit2gtk-driver \
libsoup-3.0-dev \
libjavascriptcoregtk-4.1-dev \
libwebkit2gtk-4.1-dev \
at-spi2-core \
xvfb
yarn install
yarn build:wasm
yarn build:local
- name: Rust Cache - name: Rust Cache
uses: Swatinem/rust-cache@v2.6.1 uses: Swatinem/rust-cache@v2.6.1

View File

@ -1,57 +0,0 @@
on:
push:
branches:
- main
paths:
- 'src-tauri/**.rs'
- '**/Cargo.toml'
- '**/Cargo.lock'
- '**/rust-toolchain.toml'
- .github/workflows/cargo-test-tauri.yml
pull_request:
paths:
- 'src-tauri/**.rs'
- '**/Cargo.toml'
- '**/Cargo.lock'
- '**/rust-toolchain.toml'
- .github/workflows/cargo-test-tauri.yml
workflow_dispatch:
permissions: read-all
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
name: cargo test of tauri
jobs:
cargotest:
name: cargo test
runs-on: ubuntu-latest-8-cores
strategy:
matrix:
dir: ['src-tauri']
steps:
- uses: actions/checkout@v4
- name: Install latest rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
override: true
- name: install dependencies
if: matrix.dir == 'src-tauri'
run: |
sudo apt-get update
sudo apt-get install -y \
libgtk-3-dev \
libayatana-appindicator3-dev \
webkit2gtk-driver \
libsoup-3.0-dev \
libjavascriptcoregtk-4.1-dev \
libwebkit2gtk-4.1-dev \
at-spi2-core \
xvfb
- name: Rust Cache
uses: Swatinem/rust-cache@v2.6.1
- name: cargo test
shell: bash
run: |-
cd "${{ matrix.dir }}"
cargo test --all

View File

@ -147,17 +147,17 @@ jobs:
- name: Install ubuntu system dependencies - name: Install ubuntu system dependencies
if: matrix.os == 'ubuntu-latest' if: matrix.os == 'ubuntu-latest'
run: | run: >
sudo apt-get update sudo apt-get update &&
sudo apt-get install -y \ sudo apt-get install -y
libgtk-3-dev \ libgtk-3-dev
libayatana-appindicator3-dev \ libayatana-appindicator3-dev
webkit2gtk-driver \ webkit2gtk-driver
libsoup-3.0-dev \ libsoup-3.0-dev
libjavascriptcoregtk-4.1-dev \ libjavascriptcoregtk-4.1-dev
libwebkit2gtk-4.1-dev \ libwebkit2gtk-4.1-dev
at-spi2-core \ at-spi2-core
xvfb xvfb
- name: Sync node version and setup cache - name: Sync node version and setup cache
uses: actions/setup-node@v4 uses: actions/setup-node@v4

View File

@ -14,9 +14,31 @@ permissions:
pull-requests: write pull-requests: write
jobs: jobs:
check-wasm-lib-changes:
runs-on: ubuntu-latest
outputs:
url: ${{ steps.set-output.outputs.url }}
steps:
- uses: actions/checkout@v2
with:
fetch-depth: 0 # Fetches all history for all branches and tags
- name: Check for changes in src/wasm-lib
id: set-output
run: |
if git diff --quiet origin/main...HEAD -- src/wasm-lib; then
echo "url=https://app.zoo.dev" >> $GITHUB_OUTPUT
echo "No changes detected in src/wasm-lib"
else
echo "Changes detected in src/wasm-lib"
echo "url=" >> $GITHUB_OUTPUT
fi
playwright-ubuntu: playwright-ubuntu:
timeout-minutes: 60 timeout-minutes: 60
runs-on: ubuntu-latest-8-cores runs-on: ubuntu-latest-8-cores
needs: check-wasm-lib-changes
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
@ -28,13 +50,19 @@ jobs:
run: yarn run: yarn
- name: Install Playwright Browsers - name: Install Playwright Browsers
run: yarn playwright install --with-deps run: yarn playwright install --with-deps
- name: Print WASM Lib Changes URL
run: |
echo "WASM Lib Changes URL: ${{ needs.check-wasm-lib-changes.outputs.url }}"
- name: Setup Rust - name: Setup Rust
if: ${{ needs.check-wasm-lib-changes.outputs.url }} == ''
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
- name: Cache wasm - name: Cache wasm
if: ${{ needs.check-wasm-lib-changes.outputs.url }} == ''
uses: Swatinem/rust-cache@v2 uses: Swatinem/rust-cache@v2
with: with:
workspaces: './src/wasm-lib' workspaces: './src/wasm-lib'
- name: build wasm - name: build wasm
if: ${{ needs.check-wasm-lib-changes.outputs.url }} == ''
run: yarn build:wasm run: yarn build:wasm
- name: build web - name: build web
run: yarn build:local run: yarn build:local
@ -44,6 +72,7 @@ jobs:
CI: true CI: true
token: ${{ secrets.KITTYCAD_API_TOKEN_DEV }} token: ${{ secrets.KITTYCAD_API_TOKEN_DEV }}
snapshottoken: ${{ secrets.KITTYCAD_API_TOKEN }} snapshottoken: ${{ secrets.KITTYCAD_API_TOKEN }}
WASM_OVERRIDE: ${{ steps.check-wasm-lib-changes.outputs.url }}
- uses: actions/upload-artifact@v3 - uses: actions/upload-artifact@v3
if: always() if: always()
with: with:
@ -79,6 +108,7 @@ jobs:
env: env:
CI: true CI: true
token: ${{ secrets.KITTYCAD_API_TOKEN_DEV }} token: ${{ secrets.KITTYCAD_API_TOKEN_DEV }}
WASM_OVERRIDE: ${{ steps.check-wasm-lib-changes.outputs.url }}
- uses: actions/upload-artifact@v3 - uses: actions/upload-artifact@v3
if: always() if: always()
with: with:
@ -89,6 +119,7 @@ jobs:
playwright-macos: playwright-macos:
timeout-minutes: 60 timeout-minutes: 60
runs-on: macos-14 runs-on: macos-14
needs: check-wasm-lib-changes
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
@ -100,12 +131,15 @@ jobs:
- name: Install Playwright Browsers - name: Install Playwright Browsers
run: yarn playwright install --with-deps run: yarn playwright install --with-deps
- name: Setup Rust - name: Setup Rust
if: needs.check-wasm-lib-changes.outputs.url == ''
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
- name: Cache wasm - name: Cache wasm
if: needs.check-wasm-lib-changes.outputs.url == ''
uses: Swatinem/rust-cache@v2 uses: Swatinem/rust-cache@v2
with: with:
workspaces: './src/wasm-lib' workspaces: './src/wasm-lib'
- name: build wasm - name: build wasm
if: needs.check-wasm-lib-changes.outputs.url == ''
run: yarn build:wasm run: yarn build:wasm
- name: build web - name: build web
run: yarn build:local run: yarn build:local
@ -116,14 +150,10 @@ jobs:
env: env:
CI: true CI: true
token: ${{ secrets.KITTYCAD_API_TOKEN_DEV }} token: ${{ secrets.KITTYCAD_API_TOKEN_DEV }}
WASM_OVERRIDE: ${{ steps.check-wasm-lib-changes.outputs.url }}
- uses: actions/upload-artifact@v3 - uses: actions/upload-artifact@v3
if: always() if: always()
with: with:
name: playwright-report name: playwright-report
path: playwright-report/ path: playwright-report/
retention-days: 30 retention-days: 30
- uses: actions/upload-artifact@v2
if: github.ref == 'refs/heads/main'
with:
name: wasm-bundle
path: src/wasm-lib/pkg

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 one or more lines are too long

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 one or more lines are too long

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 one or more lines are too long

View File

@ -1068,7 +1068,7 @@
"unpublished": false, "unpublished": false,
"deprecated": false, "deprecated": false,
"examples": [ "examples": [
"const part001 = startSketchOn('XY')\n |> startProfileAt([0, 0], %)\n |> line([1, 3.82], %, 'seg01')\n |> angledLineToX([\n -angleToMatchLengthX('seg01', 10, %),\n 5\n ], %)\n |> close(%)\n |> extrude(5, %)" "const part001 = startSketchOn('XY')\n |> startProfileAt([0, 0], %)\n |> line([1, 3.82], %, 'seg01')\n |> angledLineToX([\n -angleToMatchLengthX('seg01', 10, %),\n 5\n ], %)\n |> close(%)"
] ]
}, },
{ {
@ -2074,7 +2074,7 @@
"unpublished": false, "unpublished": false,
"deprecated": false, "deprecated": false,
"examples": [ "examples": [
"const part001 = startSketchOn('XY')\n |> startProfileAt([0, 0], %)\n |> line([1, 3.82], %, 'seg01')\n |> angledLineToX([\n -angleToMatchLengthY('seg01', 10, %),\n 5\n ], %)\n |> close(%)\n |> extrude(5, %)" "const part001 = startSketchOn('XY')\n |> startProfileAt([0, 0], %)\n |> line([1, 3.82], %, 'seg01')\n |> angledLineToX([\n -angleToMatchLengthY('seg01', 10, %),\n 5\n ], %)\n |> close(%)"
] ]
}, },
{ {
@ -22380,8 +22380,8 @@
"unpublished": false, "unpublished": false,
"deprecated": false, "deprecated": false,
"examples": [ "examples": [
"startSketchOn('XZ')\n |> startProfileAt([0, 0], %)\n |> line([10, 10], %)\n |> line([10, 0], %)\n |> close(%)\n |> extrude(10, %)", "startSketchOn('XZ')\n |> startProfileAt([0, 0], %)\n |> line([10, 10], %)\n |> line([10, 0], %)\n |> close(%)",
"startSketchOn('YZ')\n |> startProfileAt([0, 0], %)\n |> line([10, 10], %)\n |> line([10, 0], %)\n |> close(%, \"edge1\")\n |> extrude(10, %)" "startSketchOn('YZ')\n |> startProfileAt([0, 0], %)\n |> line([10, 10], %)\n |> line([10, 0], %)\n |> close(%, \"edge1\")"
] ]
}, },
{ {
@ -43695,7 +43695,7 @@
"unpublished": false, "unpublished": false,
"deprecated": false, "deprecated": false,
"examples": [ "examples": [
"fn rectShape = (pos, w, l) => {\n const rr = startSketchOn('YZ')\n |> startProfileAt([pos[0] - (w / 2), pos[1] - (l / 2)], %)\n |> lineTo([pos[0] + w / 2, pos[1] - (l / 2)], %, \"edge1\")\n |> lineTo([pos[0] + w / 2, pos[1] + l / 2], %, \"edge2\")\n |> lineTo([pos[0] - (w / 2), pos[1] + l / 2], %, \"edge3\")\n |> close(%, \"edge4\")\n return rr\n}\n\n// Create the mounting plate extrusion, holes, and fillets\nconst part = rectShape([0, 0], 20, 20)\n |> extrude(10, %)" "fn rectShape = (pos, w, l) => {\n const rr = startSketchOn('YZ')\n |> startProfileAt([pos[0] - (w / 2), pos[1] - (l / 2)], %)\n |> lineTo([pos[0] + w / 2, pos[1] - (l / 2)], %, \"edge1\")\n |> lineTo([pos[0] + w / 2, pos[1] + l / 2], %, \"edge2\")\n |> lineTo([pos[0] - (w / 2), pos[1] + l / 2], %, \"edge3\")\n |> close(%, \"edge4\")\n return rr\n}\n\n// Create the mounting plate extrusion, holes, and fillets\nconst part = rectShape([0, 0], 20, 20)"
] ]
}, },
{ {
@ -45900,7 +45900,7 @@
"unpublished": false, "unpublished": false,
"deprecated": false, "deprecated": false,
"examples": [ "examples": [
"const part = startSketchOn('XY')\n |> circle([0, 0], 2, %)\n |> patternCircular2d({\n center: [20, 20],\n repetitions: 12,\n arcDegrees: 210,\n rotateDuplicates: true\n }, %)\n |> extrude(1, %)" "const part = startSketchOn('XY')\n |> circle([0, 0], 2, %)\n |> patternCircular2d({\n center: [20, 20],\n repetitions: 12,\n arcDegrees: 210,\n rotateDuplicates: true\n }, %)"
] ]
}, },
{ {
@ -50459,7 +50459,7 @@
"unpublished": false, "unpublished": false,
"deprecated": false, "deprecated": false,
"examples": [ "examples": [
"const part = startSketchOn('XY')\n |> circle([0, 0], 2, %)\n |> patternLinear2d({\n axis: [0, 1],\n repetitions: 12,\n distance: 2\n }, %)\n |> extrude(1, %)" "const part = startSketchOn('XY')\n |> circle([0, 0], 2, %)\n |> patternLinear2d({\n axis: [0, 1],\n repetitions: 12,\n distance: 2\n }, %)"
] ]
}, },
{ {
@ -59318,7 +59318,7 @@
"unpublished": false, "unpublished": false,
"deprecated": false, "deprecated": false,
"examples": [ "examples": [
"startSketchOn('XY')\n |> startProfileAt([0, 0], %)\n |> line([10, 10], %)\n |> line([10, 0], %)\n |> close(%)\n |> extrude(10, %)" "startSketchOn('XY')\n |> startProfileAt([0, 0], %)\n |> line([10, 10], %)"
] ]
}, },
{ {
@ -60312,7 +60312,7 @@
"unpublished": false, "unpublished": false,
"deprecated": false, "deprecated": false,
"examples": [ "examples": [
"startSketchAt([0, 0])\n |> line([10, 10], %)\n |> line([20, 10], %, \"edge1\")\n |> close(%, \"edge2\")\n |> extrude(10, %)" "startSketchAt([0, 0])\n |> line([10, 10], %)"
] ]
}, },
{ {
@ -61585,7 +61585,7 @@
"unpublished": false, "unpublished": false,
"deprecated": false, "deprecated": false,
"examples": [ "examples": [
"startSketchOn('XY')\n |> startProfileAt([0, 0], %)\n |> line([10, 10], %)\n |> line([20, 10], %, \"edge1\")\n |> close(%, \"edge2\")\n |> extrude(10, %)", "startSketchOn('XY')\n |> startProfileAt([0, 0], %)\n |> line([10, 10], %)\n |> line([20, 10], %, \"edge1\")\n |> close(%, \"edge2\")",
"fn cube = (pos, scale) => {\n const sg = startSketchOn('XY')\n |> startProfileAt(pos, %)\n |> line([0, scale], %)\n |> line([scale, 0], %)\n |> line([0, -scale], %)\n |> close(%)\n |> extrude(scale, %)\n\n return sg\n}\n\nconst box = cube([0, 0], 20)\n\nconst part001 = startSketchOn(box, \"start\")\n |> startProfileAt([0, 0], %)\n |> line([10, 10], %)\n |> line([20, 10], %, \"edge1\")\n |> close(%)\n |> extrude(20, %)" "fn cube = (pos, scale) => {\n const sg = startSketchOn('XY')\n |> startProfileAt(pos, %)\n |> line([0, scale], %)\n |> line([scale, 0], %)\n |> line([0, -scale], %)\n |> close(%)\n |> extrude(scale, %)\n\n return sg\n}\n\nconst box = cube([0, 0], 20)\n\nconst part001 = startSketchOn(box, \"start\")\n |> startProfileAt([0, 0], %)\n |> line([10, 10], %)\n |> line([20, 10], %, \"edge1\")\n |> close(%)\n |> extrude(20, %)"
] ]
}, },
@ -65584,7 +65584,7 @@
"unpublished": false, "unpublished": false,
"deprecated": false, "deprecated": false,
"examples": [ "examples": [
"startSketchOn('-YZ')\n |> startProfileAt([0, 0], %)\n |> line([10, 10], %, \"edge0\")\n |> tangentialArcTo([10, 0], %)\n |> close(%)\n |> extrude(10, %)" "startSketchOn('-YZ')\n |> startProfileAt([0, 0], %)\n |> line([10, 10], %, \"edge0\")\n |> tangentialArcTo([10, 0], %)\n |> close(%)"
] ]
}, },
{ {

File diff suppressed because one or more lines are too long

View File

@ -596,12 +596,13 @@ test('Auto complete works', async ({ page }) => {
test('Stored settings are validated and fall back to defaults', async ({ test('Stored settings are validated and fall back to defaults', async ({
page, page,
context,
}) => { }) => {
const u = getUtils(page) const u = getUtils(page)
// Override beforeEach test setup // Override beforeEach test setup
// with corrupted settings // with corrupted settings
await page.addInitScript( await context.addInitScript(
async ({ settingsKey, settings }) => { async ({ settingsKey, settings }) => {
localStorage.setItem(settingsKey, settings) localStorage.setItem(settingsKey, settings)
}, },
@ -618,18 +619,18 @@ test('Stored settings are validated and fall back to defaults', async ({
// Check the settings were reset // Check the settings were reset
const storedSettings = TOML.parse( const storedSettings = TOML.parse(
await page.evaluate( await page.evaluate(
({ settingsKey }) => localStorage.getItem(settingsKey) || '', ({ settingsKey }) => localStorage.getItem(settingsKey) || '{}',
{ settingsKey: TEST_SETTINGS_KEY } { settingsKey: TEST_SETTINGS_KEY }
) )
) as { settings: SaveSettingsPayload } ) as { settings: SaveSettingsPayload }
expect(storedSettings.settings?.app?.theme).toBe(undefined) expect(storedSettings.settings.app?.theme).toBe('dark')
// Check that the invalid settings were removed // Check that the invalid settings were removed
expect(storedSettings.settings?.modeling?.defaultUnit).toBe(undefined) expect(storedSettings.settings.modeling?.defaultUnit).toBe(undefined)
expect(storedSettings.settings?.modeling?.mouseControls).toBe(undefined) expect(storedSettings.settings.modeling?.mouseControls).toBe(undefined)
expect(storedSettings.settings?.app?.projectDirectory).toBe(undefined) expect(storedSettings.settings.app?.projectDirectory).toBe(undefined)
expect(storedSettings.settings?.projects?.defaultProjectName).toBe(undefined) expect(storedSettings.settings.projects?.defaultProjectName).toBe(undefined)
}) })
test('Project settings can be set and override user settings', async ({ test('Project settings can be set and override user settings', async ({

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

View File

@ -1,7 +1,7 @@
import { SaveSettingsPayload } from 'lib/settings/settingsTypes' import { SaveSettingsPayload } from 'lib/settings/settingsTypes'
import { Themes } from 'lib/theme' import { Themes } from 'lib/theme'
export const TEST_SETTINGS_KEY = '/settings.toml' export const TEST_SETTINGS_KEY = '/user.toml'
export const TEST_SETTINGS = { export const TEST_SETTINGS = {
app: { app: {
theme: Themes.Dark, theme: Themes.Dark,
@ -24,7 +24,7 @@ export const TEST_SETTINGS = {
export const TEST_SETTINGS_ONBOARDING = { export const TEST_SETTINGS_ONBOARDING = {
...TEST_SETTINGS, ...TEST_SETTINGS,
app: { ...TEST_SETTINGS.app, onboardingStatus: '/export' }, app: { ...TEST_SETTINGS.app, onboardingStatus: '/export ' },
} satisfies Partial<SaveSettingsPayload> } satisfies Partial<SaveSettingsPayload>
export const TEST_SETTINGS_CORRUPTED = { export const TEST_SETTINGS_CORRUPTED = {

View File

@ -71,7 +71,7 @@ describe('ZMA (Tauri, Linux)', () => {
// Now should be signed in // Now should be signed in
const newFileButton = await $('[data-testid="home-new-file"]') const newFileButton = await $('[data-testid="home-new-file"]')
expect(await newFileButton.getText()).toEqual('New project') expect(await newFileButton.getText()).toEqual('New file')
}) })
it('opens the settings page, checks filesystem settings, and closes the settings page', async () => { it('opens the settings page, checks filesystem settings, and closes the settings page', async () => {

View File

@ -1,6 +1,6 @@
{ {
"name": "untitled-app", "name": "untitled-app",
"version": "0.19.1", "version": "0.18.1",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@codemirror/autocomplete": "^6.16.0", "@codemirror/autocomplete": "^6.16.0",

View File

@ -18,7 +18,7 @@ export default defineConfig({
/* Retry on CI only */ /* Retry on CI only */
retries: process.env.CI ? 3 : 0, retries: process.env.CI ? 3 : 0,
/* Opt out of parallel tests on CI. */ /* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : 1, workers: process.env.CI ? 2 : 1,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */ /* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html', reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
@ -72,7 +72,7 @@ export default defineConfig({
/* Run your local dev server before starting the tests */ /* Run your local dev server before starting the tests */
webServer: { webServer: {
command: 'yarn serve', command: 'VITE_KC_WASM_OVERRIDE_URL=$WASM_OVERRIDE yarn serve',
// url: 'http://127.0.0.1:3000', // url: 'http://127.0.0.1:3000',
reuseExistingServer: !process.env.CI, reuseExistingServer: !process.env.CI,
}, },

2385
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -8,6 +8,7 @@ repository = "https://github.com/KittyCAD/modeling-app"
default-run = "app" default-run = "app"
edition = "2021" edition = "2021"
rust-version = "1.70" rust-version = "1.70"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[build-dependencies] [build-dependencies]
@ -15,12 +16,11 @@ tauri-build = { version = "2.0.0-beta.13", features = [] }
[dependencies] [dependencies]
anyhow = "1" anyhow = "1"
kcl-lib = { version = "0.1.52", path = "../src/wasm-lib/kcl" }
kittycad = "0.3.0" kittycad = "0.3.0"
oauth2 = "4.4.2" oauth2 = "4.4.2"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
tauri = { version = "2.0.0-beta.15", features = [ "devtools", "unstable"] } tauri = { version = "2.0.0-beta.15", features = [ "devtools", "unstable"] }
tauri-plugin-cli = { version = "2.0.0-beta.3" }
tauri-plugin-dialog = { version = "2.0.0-beta.6" } tauri-plugin-dialog = { version = "2.0.0-beta.6" }
tauri-plugin-fs = { version = "2.0.0-beta.6" } tauri-plugin-fs = { version = "2.0.0-beta.6" }
tauri-plugin-http = { version = "2.0.0-beta.6" } tauri-plugin-http = { version = "2.0.0-beta.6" }
@ -28,7 +28,7 @@ tauri-plugin-os = { version = "2.0.0-beta.2" }
tauri-plugin-process = { version = "2.0.0-beta.2" } tauri-plugin-process = { version = "2.0.0-beta.2" }
tauri-plugin-shell = { version = "2.0.0-beta.2" } tauri-plugin-shell = { version = "2.0.0-beta.2" }
tauri-plugin-updater = { version = "2.0.0-beta.4" } tauri-plugin-updater = { version = "2.0.0-beta.4" }
tokio = { version = "1.37.0", features = ["time", "fs"] } tokio = { version = "1.37.0", features = ["time"] }
toml = "0.8.2" toml = "0.8.2"
[features] [features]

View File

@ -7,7 +7,6 @@
"main" "main"
], ],
"permissions": [ "permissions": [
"cli:default",
"path:default", "path:default",
"event:default", "event:default",
"window:default", "window:default",
@ -24,6 +23,7 @@
"fs:allow-copy-file", "fs:allow-copy-file",
"fs:allow-mkdir", "fs:allow-mkdir",
"fs:allow-remove", "fs:allow-remove",
"fs:allow-remove",
"fs:allow-rename", "fs:allow-rename",
"fs:allow-exists", "fs:allow-exists",
"fs:allow-stat", "fs:allow-stat",

View File

@ -1,6 +0,0 @@
max_width = 120
edition = "2018"
format_code_in_doc_comments = true
format_strings = false
imports_granularity = "Crate"
group_imports = "StdExternalCrate"

View File

@ -1,205 +1,91 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!! // Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
pub(crate) mod state; use std::env;
use std::fs;
use std::{ use std::io::Read;
env, use std::path::Path;
path::{Path, PathBuf}, use std::path::PathBuf;
process::Command,
};
use anyhow::Result; use anyhow::Result;
use kcl_lib::settings::types::{
file::{FileEntry, Project, ProjectState},
project::ProjectConfiguration,
Configuration, DEFAULT_PROJECT_KCL_FILE,
};
use oauth2::TokenResponse; use oauth2::TokenResponse;
use tauri::{ipc::InvokeError, Manager}; use serde::Serialize;
use tauri_plugin_cli::CliExt; use std::process::Command;
use tauri::ipc::InvokeError;
use tauri_plugin_shell::ShellExt; use tauri_plugin_shell::ShellExt;
const DEFAULT_HOST: &str = "https://api.kittycad.io";
const DEFAULT_HOST: &str = "https://api.zoo.dev"; /// This command returns the a json string parse from a toml file at the path.
const SETTINGS_FILE_NAME: &str = "settings.toml";
const PROJECT_SETTINGS_FILE_NAME: &str = "project.toml";
const PROJECT_FOLDER: &str = "zoo-modeling-app-projects";
#[tauri::command] #[tauri::command]
fn get_initial_default_dir(app: tauri::AppHandle) -> Result<PathBuf, InvokeError> { fn read_toml(path: &str) -> Result<String, InvokeError> {
let dir = match app.path().document_dir() { let mut file = std::fs::File::open(path).map_err(|e| InvokeError::from_anyhow(e.into()))?;
Ok(dir) => dir, let mut contents = String::new();
Err(_) => { file.read_to_string(&mut contents)
// for headless Linux (eg. Github Actions) .map_err(|e| InvokeError::from_anyhow(e.into()))?;
let home_dir = app.path().home_dir()?; let value =
home_dir.join("Documents") toml::from_str::<toml::Value>(&contents).map_err(|e| InvokeError::from_anyhow(e.into()))?;
} let value = serde_json::to_string(&value).map_err(|e| InvokeError::from_anyhow(e.into()))?;
}; Ok(value)
Ok(dir.join(PROJECT_FOLDER))
} }
#[tauri::command] /// From https://github.com/tauri-apps/tauri/blob/1.x/core/tauri/src/api/dir.rs#L51
async fn get_state(app: tauri::AppHandle) -> Result<Option<ProjectState>, InvokeError> { /// Removed from tauri v2
let store = app.state::<state::Store>(); #[derive(Debug, Serialize)]
Ok(store.get().await) pub struct DiskEntry {
/// The path to the entry.
pub path: PathBuf,
/// The name of the entry (file name with extension or directory name).
pub name: Option<String>,
/// The children of this entry if it's a directory.
#[serde(skip_serializing_if = "Option::is_none")]
pub children: Option<Vec<DiskEntry>>,
} }
#[tauri::command] /// From https://github.com/tauri-apps/tauri/blob/1.x/core/tauri/src/api/dir.rs#L51
async fn set_state(app: tauri::AppHandle, state: Option<ProjectState>) -> Result<(), InvokeError> { /// Removed from tauri v2
let store = app.state::<state::Store>(); fn is_dir<P: AsRef<Path>>(path: P) -> Result<bool> {
store.set(state).await; std::fs::metadata(path)
Ok(()) .map(|md| md.is_dir())
} .map_err(Into::into)
fn get_app_settings_file_path(app: &tauri::AppHandle) -> Result<PathBuf, InvokeError> {
let app_config_dir = app.path().app_config_dir()?;
Ok(app_config_dir.join(SETTINGS_FILE_NAME))
} }
/// From https://github.com/tauri-apps/tauri/blob/1.x/core/tauri/src/api/dir.rs#L51
/// Removed from tauri v2
#[tauri::command] #[tauri::command]
async fn read_app_settings_file(app: tauri::AppHandle) -> Result<Configuration, InvokeError> { fn read_dir_recursive(path: &str) -> Result<Vec<DiskEntry>, InvokeError> {
let mut settings_path = get_app_settings_file_path(&app)?; let mut files_and_dirs: Vec<DiskEntry> = vec![];
let mut needs_migration = false; // let path = path.as_ref();
for entry in fs::read_dir(path).map_err(|e| InvokeError::from_anyhow(e.into()))? {
let path = entry
.map_err(|e| InvokeError::from_anyhow(e.into()))?
.path();
// Check if this file exists. if let Ok(flag) = is_dir(&path) {
if !settings_path.exists() { files_and_dirs.push(DiskEntry {
// Try the backwards compatible path. path: path.clone(),
// TODO: Remove this after a few releases. children: if flag {
let app_config_dir = app.path().app_config_dir()?; Some(read_dir_recursive(path.to_str().expect("No path"))?)
settings_path = format!( } else {
"{}user.toml", None
app_config_dir.display().to_string().trim_end_matches('/') },
) name: path
.into(); .file_name()
needs_migration = true; .map(|name| name.to_string_lossy())
// Check if this path exists. .map(|name| name.to_string()),
if !settings_path.exists() { });
let mut default = Configuration::default();
default.settings.project.directory = get_initial_default_dir(app.clone())?;
// Return the default configuration.
return Ok(default);
} }
} }
Ok(files_and_dirs)
}
let contents = tokio::fs::read_to_string(&settings_path) /// This command returns a string that is the contents of a file at the path.
.await #[tauri::command]
fn read_txt_file(path: &str) -> Result<String, InvokeError> {
let mut file = std::fs::File::open(path).map_err(|e| InvokeError::from_anyhow(e.into()))?;
let mut contents = String::new();
file.read_to_string(&mut contents)
.map_err(|e| InvokeError::from_anyhow(e.into()))?; .map_err(|e| InvokeError::from_anyhow(e.into()))?;
let mut parsed = Configuration::backwards_compatible_toml_parse(&contents).map_err(InvokeError::from_anyhow)?; Ok(contents)
if parsed.settings.project.directory == PathBuf::new() {
parsed.settings.project.directory = get_initial_default_dir(app.clone())?;
}
// TODO: Remove this after a few releases.
if needs_migration {
write_app_settings_file(app, parsed.clone()).await?;
// Delete the old file.
tokio::fs::remove_file(settings_path)
.await
.map_err(|e| InvokeError::from_anyhow(e.into()))?;
}
Ok(parsed)
}
#[tauri::command]
async fn write_app_settings_file(app: tauri::AppHandle, configuration: Configuration) -> Result<(), InvokeError> {
let settings_path = get_app_settings_file_path(&app)?;
let contents = toml::to_string_pretty(&configuration).map_err(|e| InvokeError::from_anyhow(e.into()))?;
tokio::fs::write(settings_path, contents.as_bytes())
.await
.map_err(|e| InvokeError::from_anyhow(e.into()))?;
Ok(())
}
fn get_project_settings_file_path(app_settings: Configuration, project_name: &str) -> Result<PathBuf, InvokeError> {
Ok(app_settings
.settings
.project
.directory
.join(project_name)
.join(PROJECT_SETTINGS_FILE_NAME))
}
#[tauri::command]
async fn read_project_settings_file(
app_settings: Configuration,
project_name: &str,
) -> Result<ProjectConfiguration, InvokeError> {
let settings_path = get_project_settings_file_path(app_settings, project_name)?;
// Check if this file exists.
if !settings_path.exists() {
// Return the default configuration.
return Ok(ProjectConfiguration::default());
}
let contents = tokio::fs::read_to_string(&settings_path)
.await
.map_err(|e| InvokeError::from_anyhow(e.into()))?;
let parsed = ProjectConfiguration::backwards_compatible_toml_parse(&contents).map_err(InvokeError::from_anyhow)?;
Ok(parsed)
}
#[tauri::command]
async fn write_project_settings_file(
app_settings: Configuration,
project_name: &str,
configuration: ProjectConfiguration,
) -> Result<(), InvokeError> {
let settings_path = get_project_settings_file_path(app_settings, project_name)?;
let contents = toml::to_string_pretty(&configuration).map_err(|e| InvokeError::from_anyhow(e.into()))?;
tokio::fs::write(settings_path, contents.as_bytes())
.await
.map_err(|e| InvokeError::from_anyhow(e.into()))?;
Ok(())
}
/// Initialize the directory that holds all the projects.
#[tauri::command]
async fn initialize_project_directory(configuration: Configuration) -> Result<PathBuf, InvokeError> {
configuration
.ensure_project_directory_exists()
.await
.map_err(InvokeError::from_anyhow)
}
/// Create a new project directory.
#[tauri::command]
async fn create_new_project_directory(
configuration: Configuration,
project_name: &str,
initial_code: Option<&str>,
) -> Result<Project, InvokeError> {
configuration
.create_new_project_directory(project_name, initial_code)
.await
.map_err(InvokeError::from_anyhow)
}
/// List all the projects in the project directory.
#[tauri::command]
async fn list_projects(configuration: Configuration) -> Result<Vec<Project>, InvokeError> {
configuration.list_projects().await.map_err(InvokeError::from_anyhow)
}
/// Get information about a project.
#[tauri::command]
async fn get_project_info(configuration: Configuration, project_path: &str) -> Result<Project, InvokeError> {
configuration
.get_project_info(project_path)
.await
.map_err(InvokeError::from_anyhow)
}
#[tauri::command]
async fn read_dir_recursive(path: &str) -> Result<FileEntry, InvokeError> {
kcl_lib::settings::utils::walk_dir(&Path::new(path).to_path_buf())
.await
.map_err(InvokeError::from_anyhow)
} }
/// This command instantiates a new window with auth. /// This command instantiates a new window with auth.
@ -217,7 +103,8 @@ async fn login(app: tauri::AppHandle, host: &str) -> Result<String, InvokeError>
let auth_client = oauth2::basic::BasicClient::new( let auth_client = oauth2::basic::BasicClient::new(
oauth2::ClientId::new(client_id), oauth2::ClientId::new(client_id),
None, None,
oauth2::AuthUrl::new(format!("{host}/authorize")).map_err(|e| InvokeError::from_anyhow(e.into()))?, oauth2::AuthUrl::new(format!("{host}/authorize"))
.map_err(|e| InvokeError::from_anyhow(e.into()))?,
Some( Some(
oauth2::TokenUrl::new(format!("{host}/oauth2/device/token")) oauth2::TokenUrl::new(format!("{host}/oauth2/device/token"))
.map_err(|e| InvokeError::from_anyhow(e.into()))?, .map_err(|e| InvokeError::from_anyhow(e.into()))?,
@ -245,10 +132,12 @@ async fn login(app: tauri::AppHandle, host: &str) -> Result<String, InvokeError>
// and bypass the shell::open call as it fails on GitHub Actions. // and bypass the shell::open call as it fails on GitHub Actions.
let e2e_tauri_enabled = env::var("E2E_TAURI_ENABLED").is_ok(); let e2e_tauri_enabled = env::var("E2E_TAURI_ENABLED").is_ok();
if e2e_tauri_enabled { if e2e_tauri_enabled {
println!("E2E_TAURI_ENABLED is set, won't open {} externally", auth_uri.secret()); println!(
tokio::fs::write("/tmp/kittycad_user_code", details.user_code().secret()) "E2E_TAURI_ENABLED is set, won't open {} externally",
.await auth_uri.secret()
.map_err(|e| InvokeError::from_anyhow(e.into()))?; );
fs::write("/tmp/kittycad_user_code", details.user_code().secret())
.expect("Unable to write /tmp/kittycad_user_code file");
} else { } else {
app.shell() app.shell()
.open(auth_uri.secret(), None) .open(auth_uri.secret(), None)
@ -271,7 +160,10 @@ async fn login(app: tauri::AppHandle, host: &str) -> Result<String, InvokeError>
///This command returns the KittyCAD user info given a token. ///This command returns the KittyCAD user info given a token.
/// The string returned from this method is the user info as a json string. /// The string returned from this method is the user info as a json string.
#[tauri::command] #[tauri::command]
async fn get_user(token: &str, hostname: &str) -> Result<kittycad::types::User, InvokeError> { async fn get_user(
token: Option<String>,
hostname: &str,
) -> Result<kittycad::types::User, InvokeError> {
// Use the host passed in if it's set. // Use the host passed in if it's set.
// Otherwise, use the default host. // Otherwise, use the default host.
let host = if hostname.is_empty() { let host = if hostname.is_empty() {
@ -291,7 +183,7 @@ async fn get_user(token: &str, hostname: &str) -> Result<kittycad::types::User,
println!("Getting user info..."); println!("Getting user info...");
// use kittycad library to fetch the user info from /user/me // use kittycad library to fetch the user info from /user/me
let mut client = kittycad::Client::new(token); let mut client = kittycad::Client::new(token.unwrap());
if baseurl != DEFAULT_HOST { if baseurl != DEFAULT_HOST {
client.set_base_url(&baseurl); client.set_base_url(&baseurl);
@ -310,170 +202,50 @@ async fn get_user(token: &str, hostname: &str) -> Result<kittycad::types::User,
/// From this GitHub comment: https://github.com/tauri-apps/tauri/issues/4062#issuecomment-1338048169 /// From this GitHub comment: https://github.com/tauri-apps/tauri/issues/4062#issuecomment-1338048169
/// But with the Linux support removed since we don't need it for now. /// But with the Linux support removed since we don't need it for now.
#[tauri::command] #[tauri::command]
fn show_in_folder(path: &str) -> Result<(), InvokeError> { fn show_in_folder(path: String) {
#[cfg(not(unix))] #[cfg(target_os = "windows")]
{ {
Command::new("explorer") Command::new("explorer")
.args(["/select,", &path]) // The comma after select is not a typo .args(["/select,", &path]) // The comma after select is not a typo
.spawn() .spawn()
.map_err(|e| InvokeError::from_anyhow(e.into()))?; .unwrap();
} }
#[cfg(unix)] #[cfg(target_os = "macos")]
{ {
Command::new("open") Command::new("open").args(["-R", &path]).spawn().unwrap();
.args(["-R", &path])
.spawn()
.map_err(|e| InvokeError::from_anyhow(e.into()))?;
} }
Ok(())
} }
fn main() -> Result<()> { fn main() {
tauri::Builder::default() tauri::Builder::default()
.setup(|_app| { .setup(|_app| {
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
{ {
use tauri::Manager;
_app.get_webview("main").unwrap().open_devtools(); _app.get_webview("main").unwrap().open_devtools();
} }
#[cfg(not(debug_assertions))] #[cfg(not(debug_assertions))]
{ {
_app.handle().plugin(tauri_plugin_updater::Builder::new().build())?; _app.handle()
.plugin(tauri_plugin_updater::Builder::new().build())?;
} }
Ok(()) Ok(())
}) })
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
get_state,
set_state,
get_initial_default_dir,
initialize_project_directory,
create_new_project_directory,
list_projects,
get_project_info,
get_user, get_user,
login, login,
read_toml,
read_txt_file,
read_dir_recursive, read_dir_recursive,
show_in_folder, show_in_folder,
read_app_settings_file,
write_app_settings_file,
read_project_settings_file,
write_project_settings_file,
]) ])
.plugin(tauri_plugin_cli::init())
.setup(|app| {
let mut verbose = false;
let mut source_path: Option<PathBuf> = None;
match app.cli().matches() {
// `matches` here is a Struct with { args, subcommand }.
// `args` is `HashMap<String, ArgData>` where `ArgData` is a struct with { value, occurrences }.
// `subcommand` is `Option<Box<SubcommandMatches>>` where `SubcommandMatches` is a struct with { name, matches }.
Ok(matches) => {
if let Some(verbose_flag) = matches.args.get("verbose") {
let Some(value) = verbose_flag.value.as_bool() else {
return Err(
anyhow::anyhow!("Error parsing CLI arguments: verbose flag is not a boolean").into(),
);
};
verbose = value;
}
// Get the path we are trying to open.
if let Some(source_arg) = matches.args.get("source") {
// We don't do an else here because this can be null.
if let Some(value) = source_arg.value.as_str() {
source_path = Some(Path::new(value).to_path_buf());
}
}
}
Err(err) => {
return Err(anyhow::anyhow!("Error parsing CLI arguments: {:?}", err).into());
}
}
// If we have a source path to open, make sure it exists.
let Some(source_path) = source_path else {
// The user didn't provide a source path to open.
// Run the app as normal.
app.manage(state::Store::default());
return Ok(());
};
if !source_path.exists() {
return Err(anyhow::anyhow!(
"Error: the path `{}` you are trying to open does not exist",
source_path.display()
)
.into());
}
let runner: tauri::async_runtime::JoinHandle<Result<ProjectState>> =
tauri::async_runtime::spawn(async move {
// If the path is a directory, let's assume it is a project directory.
if source_path.is_dir() {
// Load the details about the project from the path.
let project = Project::from_path(&source_path).await.map_err(|e| {
anyhow::anyhow!("Error loading project from path {}: {:?}", source_path.display(), e)
})?;
if verbose {
println!("Project loaded from path: {}", source_path.display());
}
// Create the default file in the project.
// Write the initial project file.
let project_file = source_path.join(DEFAULT_PROJECT_KCL_FILE);
tokio::fs::write(&project_file, vec![]).await?;
return Ok(ProjectState {
project,
current_file: Some(project_file.display().to_string()),
});
}
// We were given a file path, not a directory.
// Let's get the parent directory of the file.
let parent = source_path.parent().ok_or_else(|| {
anyhow::anyhow!(
"Error getting the parent directory of the file: {}",
source_path.display()
)
})?;
// Load the details about the project from the parent directory.
let project = Project::from_path(&parent).await.map_err(|e| {
anyhow::anyhow!("Error loading project from path {}: {:?}", source_path.display(), e)
})?;
if verbose {
println!(
"Project loaded from path: {}, current file: {}",
parent.display(),
source_path.display()
);
}
Ok(ProjectState {
project,
current_file: Some(source_path.display().to_string()),
})
});
// Block on the handle.
let store = tauri::async_runtime::block_on(runner)??;
// Create a state object to hold the project.
app.manage(state::Store::new(store));
Ok(())
})
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_http::init()) .plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_shell::init())
.run(tauri::generate_context!())?; .run(tauri::generate_context!())
.expect("error while running tauri application");
Ok(())
} }

View File

@ -1,21 +0,0 @@
//! State management for the application.
use kcl_lib::settings::types::file::ProjectState;
use tokio::sync::Mutex;
#[derive(Debug, Default)]
pub struct Store(Mutex<Option<ProjectState>>);
impl Store {
pub fn new(p: ProjectState) -> Self {
Self(Mutex::new(Some(p)))
}
pub async fn get(&self) -> Option<ProjectState> {
self.0.lock().await.clone()
}
pub async fn set(&self, p: Option<ProjectState>) {
*self.0.lock().await = p;
}
}

View File

@ -50,26 +50,10 @@
}, },
"identifier": "dev.zoo.modeling-app", "identifier": "dev.zoo.modeling-app",
"plugins": { "plugins": {
"cli": {
"description": "Zoo Modeling App CLI",
"args": [
{
"short": "v",
"name": "verbose",
"description": "Verbosity level"
},
{
"name": "source",
"index": 1,
"takesValue": true
}
],
"subcommands": {}
},
"shell": { "shell": {
"open": true "open": true
} }
}, },
"productName": "Zoo Modeling App", "productName": "Zoo Modeling App",
"version": "0.19.1" "version": "0.18.1"
} }

View File

@ -30,7 +30,6 @@ import SettingsAuthProvider from 'components/SettingsAuthProvider'
import LspProvider from 'components/LspProvider' import LspProvider from 'components/LspProvider'
import { KclContextProvider } from 'lang/KclProvider' import { KclContextProvider } from 'lang/KclProvider'
import { BROWSER_PROJECT_NAME } from 'lib/constants' import { BROWSER_PROJECT_NAME } from 'lib/constants'
import { getState, setState } from 'lib/tauri'
const router = createBrowserRouter([ const router = createBrowserRouter([
{ {
@ -53,30 +52,10 @@ const router = createBrowserRouter([
children: [ children: [
{ {
path: paths.INDEX, path: paths.INDEX,
loader: async () => { loader: () =>
const inTauri = isTauri() isTauri()
if (inTauri) {
const appState = await getState()
if (appState) {
console.log('appState', appState)
// Reset the state.
// We do this so that we load the initial state from the cli but everything
// else we can ignore.
await setState(undefined)
// Redirect to the file if we have a file path.
if (appState.current_file) {
return redirect(
paths.FILE + '/' + encodeURIComponent(appState.current_file)
)
}
}
}
return inTauri
? redirect(paths.HOME) ? redirect(paths.HOME)
: redirect(paths.FILE + '/%2F' + BROWSER_PROJECT_NAME) : redirect(paths.FILE + '/%2F' + BROWSER_PROJECT_NAME),
},
}, },
{ {
loader: fileLoader, loader: fileLoader,

View File

@ -15,10 +15,10 @@ import {
import { useCommandsContext } from 'hooks/useCommandsContext' import { useCommandsContext } from 'hooks/useCommandsContext'
import { fileMachine } from 'machines/fileMachine' import { fileMachine } from 'machines/fileMachine'
import { mkdir, remove, rename, create } from '@tauri-apps/plugin-fs' import { mkdir, remove, rename, create } from '@tauri-apps/plugin-fs'
import { readProject } from 'lib/tauriFS'
import { isTauri } from 'lib/isTauri' import { isTauri } from 'lib/isTauri'
import { join, sep } from '@tauri-apps/api/path' import { join, sep } from '@tauri-apps/api/path'
import { DEFAULT_FILE_NAME, FILE_EXT } from 'lib/constants' import { DEFAULT_FILE_NAME, FILE_EXT } from 'lib/constants'
import { getProjectInfo } from 'lib/tauri'
type MachineContext<T extends AnyStateMachine> = { type MachineContext<T extends AnyStateMachine> = {
state: StateFrom<T> state: StateFrom<T>
@ -62,7 +62,7 @@ export const FileMachineProvider = ({
services: { services: {
readFiles: async (context: ContextFrom<typeof fileMachine>) => { readFiles: async (context: ContextFrom<typeof fileMachine>) => {
const newFiles = isTauri() const newFiles = isTauri()
? (await getProjectInfo(context.project.path)).children ? await readProject(context.project.path)
: [] : []
return { return {
...context.project, ...context.project,

View File

@ -3,7 +3,7 @@ import { paths } from 'lib/paths'
import { ActionButton } from './ActionButton' import { ActionButton } from './ActionButton'
import Tooltip from './Tooltip' import Tooltip from './Tooltip'
import { Dispatch, useEffect, useRef, useState } from 'react' import { Dispatch, useEffect, useRef, useState } from 'react'
import { useNavigate, useRouteLoaderData } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { Dialog, Disclosure } from '@headlessui/react' import { Dialog, Disclosure } from '@headlessui/react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronRight, faTrashAlt } from '@fortawesome/free-solid-svg-icons' import { faChevronRight, faTrashAlt } from '@fortawesome/free-solid-svg-icons'
@ -133,13 +133,18 @@ const FileTreeItem = ({
project, project,
currentFile, currentFile,
fileOrDir, fileOrDir,
onDoubleClick, closePanel,
level = 0, level = 0,
}: { }: {
project?: IndexLoaderData['project'] project?: IndexLoaderData['project']
currentFile?: IndexLoaderData['file'] currentFile?: IndexLoaderData['file']
fileOrDir: FileEntry fileOrDir: FileEntry
onDoubleClick?: () => void closePanel: (
focusableElement?:
| HTMLElement
| React.MutableRefObject<HTMLElement | null>
| undefined
) => void
level?: number level?: number
}) => { }) => {
const { send, context } = useFileContext() const { send, context } = useFileContext()
@ -181,7 +186,7 @@ const FileTreeItem = ({
// Open kcl files // Open kcl files
navigate(`${paths.FILE}/${encodeURIComponent(fileOrDir.path)}`) navigate(`${paths.FILE}/${encodeURIComponent(fileOrDir.path)}`)
} }
onDoubleClick?.() closePanel()
} }
return ( return (
@ -189,10 +194,8 @@ const FileTreeItem = ({
{fileOrDir.children === undefined ? ( {fileOrDir.children === undefined ? (
<li <li
className={ className={
'group m-0 p-0 border-solid border-0 hover:bg-primary/5 focus-within:bg-primary/5 dark:hover:bg-primary/20 dark:focus-within:bg-primary/20 ' + 'group m-0 p-0 border-solid border-0 hover:text-primary hover:bg-primary/5 focus-within:bg-primary/5 ' +
(isCurrentFile (isCurrentFile ? '!bg-primary/10 !text-primary' : '')
? '!bg-primary/10 !text-primary dark:!bg-primary/20 dark:!text-inherit'
: '')
} }
> >
{!isRenaming ? ( {!isRenaming ? (
@ -224,9 +227,9 @@ const FileTreeItem = ({
{!isRenaming ? ( {!isRenaming ? (
<Disclosure.Button <Disclosure.Button
className={ className={
' group border-none text-sm rounded-none p-0 m-0 flex items-center justify-start w-full py-0.5 hover:text-primary hover:bg-primary/5 dark:hover:text-inherit dark:hover:bg-primary/10' + ' group border-none text-sm rounded-none p-0 m-0 flex items-center justify-start w-full py-0.5 hover:text-primary hover:bg-primary/5' +
(context.selectedDirectory.path.includes(fileOrDir.path) (context.selectedDirectory.path.includes(fileOrDir.path)
? ' ui-open:bg-primary/10' ? ' ui-open:text-primary'
: '') : '')
} }
style={{ paddingInlineStart: getIndentationCSS(level) }} style={{ paddingInlineStart: getIndentationCSS(level) }}
@ -290,7 +293,7 @@ const FileTreeItem = ({
fileOrDir={child} fileOrDir={child}
project={project} project={project}
currentFile={currentFile} currentFile={currentFile}
onDoubleClick={onDoubleClick} closePanel={closePanel}
level={level + 1} level={level + 1}
key={level + '-' + child.path} key={level + '-' + child.path}
/> />
@ -322,8 +325,20 @@ interface FileTreeProps {
) => void ) => void
} }
export const FileTreeMenu = () => { export const FileTree = ({
const { send } = useFileContext() className = '',
file,
closePanel,
}: FileTreeProps) => {
const { send, context } = useFileContext()
const docuemntHasFocus = useDocumentHasFocus()
useHotkeys('meta + n', createFile)
useHotkeys('meta + shift + n', createFolder)
// Refresh the file tree when the document gets focus
useEffect(() => {
send({ type: 'Refresh' })
}, [docuemntHasFocus])
async function createFile() { async function createFile() {
send({ type: 'Create file', data: { name: '', makeDir: false } }) send({ type: 'Create file', data: { name: '', makeDir: false } })
@ -333,88 +348,58 @@ export const FileTreeMenu = () => {
send({ type: 'Create file', data: { name: '', makeDir: true } }) send({ type: 'Create file', data: { name: '', makeDir: true } })
} }
useHotkeys('meta + n', createFile)
useHotkeys('meta + shift + n', createFolder)
return (
<>
<ActionButton
Element="button"
icon={{
icon: 'filePlus',
iconClassName: '!text-current',
bgClassName: 'bg-transparent',
}}
className="!p-0 !bg-transparent hover:text-primary border-transparent hover:border-primary !outline-none"
onClick={createFile}
>
<Tooltip position="bottom-right" delay={750}>
Create file
</Tooltip>
</ActionButton>
<ActionButton
Element="button"
icon={{
icon: 'folderPlus',
iconClassName: '!text-current',
bgClassName: 'bg-transparent',
}}
className="!p-0 !bg-transparent hover:text-primary border-transparent hover:border-primary !outline-none"
onClick={createFolder}
>
<Tooltip position="bottom-right" delay={750}>
Create folder
</Tooltip>
</ActionButton>
</>
)
}
export const FileTree = ({ className = '', closePanel }: FileTreeProps) => {
return ( return (
<div className={className}> <div className={className}>
<div className="flex items-center gap-1 px-4 py-1 bg-chalkboard-20/40 dark:bg-chalkboard-80/50 border-b border-b-chalkboard-30 dark:border-b-chalkboard-80"> <div className="flex items-center gap-1 px-4 py-1 bg-chalkboard-20/40 dark:bg-chalkboard-80/50 border-b border-b-chalkboard-30 dark:border-b-chalkboard-80">
<h2 className="flex-1 m-0 p-0 text-sm mono">Files</h2> <h2 className="flex-1 m-0 p-0 text-sm mono">Files</h2>
<FileTreeMenu /> <ActionButton
Element="button"
icon={{
icon: 'filePlus',
iconClassName: '!text-current',
bgClassName: 'bg-transparent',
}}
className="!p-0 !bg-transparent hover:text-primary border-transparent hover:border-primary !outline-none"
onClick={createFile}
>
<Tooltip position="bottom-right" delay={750}>
Create file
</Tooltip>
</ActionButton>
<ActionButton
Element="button"
icon={{
icon: 'folderPlus',
iconClassName: '!text-current',
bgClassName: 'bg-transparent',
}}
className="!p-0 !bg-transparent hover:text-primary border-transparent hover:border-primary !outline-none"
onClick={createFolder}
>
<Tooltip position="bottom-right" delay={750}>
Create folder
</Tooltip>
</ActionButton>
</div>
<div className="overflow-auto max-h-full pb-12">
<ul
className="m-0 p-0 text-sm"
onClickCapture={(e) => {
send({ type: 'Set selected directory', data: context.project })
}}
>
{sortProject(context.project.children || []).map((fileOrDir) => (
<FileTreeItem
project={context.project}
currentFile={file}
fileOrDir={fileOrDir}
closePanel={closePanel}
key={fileOrDir.path}
/>
))}
</ul>
</div> </div>
<FileTreeInner onDoubleClick={closePanel} />
</div>
)
}
export const FileTreeInner = ({
onDoubleClick,
}: {
onDoubleClick?: () => void
}) => {
const loaderData = useRouteLoaderData(paths.FILE) as IndexLoaderData
const { send, context } = useFileContext()
const documentHasFocus = useDocumentHasFocus()
// Refresh the file tree when the document gets focus
useEffect(() => {
send({ type: 'Refresh' })
}, [documentHasFocus])
return (
<div className="overflow-auto max-h-full pb-12">
<ul
className="m-0 p-0 text-sm"
onClickCapture={(e) => {
send({ type: 'Set selected directory', data: context.project })
}}
>
{sortProject(context.project.children || []).map((fileOrDir) => (
<FileTreeItem
project={context.project}
currentFile={loaderData?.file}
fileOrDir={fileOrDir}
onDoubleClick={onDoubleClick}
key={fileOrDir.path}
/>
))}
</ul>
</div> </div>
) )
} }

View File

@ -94,7 +94,10 @@ export function HelpMenu(props: React.PropsWithChildren) {
if (isInProject) { if (isInProject) {
navigate('onboarding') navigate('onboarding')
} else { } else {
createAndOpenNewProject(navigate) createAndOpenNewProject(
settings.context.app.projectDirectory.current,
navigate
)
} }
}} }}
> >

View File

@ -10,32 +10,21 @@ import { KclEditorMenu } from 'components/ModelingSidebar/ModelingPanes/KclEdito
import { CustomIconName } from 'components/CustomIcon' import { CustomIconName } from 'components/CustomIcon'
import { KclEditorPane } from 'components/ModelingSidebar/ModelingPanes/KclEditorPane' import { KclEditorPane } from 'components/ModelingSidebar/ModelingPanes/KclEditorPane'
import { ReactNode } from 'react' import { ReactNode } from 'react'
import type { PaneType } from 'useStore'
import { MemoryPane } from './MemoryPane' import { MemoryPane } from './MemoryPane'
import { KclErrorsPane, LogsPane } from './LoggingPanes' import { KclErrorsPane, LogsPane } from './LoggingPanes'
import { DebugPane } from './DebugPane' import { DebugPane } from './DebugPane'
import { FileTreeInner, FileTreeMenu } from 'components/FileTree'
export type SidebarType = export type Pane = {
| 'code' id: PaneType
| 'debug'
| 'export'
| 'files'
| 'kclErrors'
| 'logs'
| 'lspMessages'
| 'variables'
export type SidebarPane = {
id: SidebarType
title: string title: string
icon: CustomIconName | IconDefinition icon: CustomIconName | IconDefinition
keybinding: string
Content: ReactNode | React.FC Content: ReactNode | React.FC
Menu?: ReactNode | React.FC Menu?: ReactNode | React.FC
hideOnPlatform?: 'desktop' | 'web' keybinding: string
} }
export const topPanes: SidebarPane[] = [ export const topPanes: Pane[] = [
{ {
id: 'code', id: 'code',
title: 'KCL Code', title: 'KCL Code',
@ -44,18 +33,9 @@ export const topPanes: SidebarPane[] = [
keybinding: 'shift + c', keybinding: 'shift + c',
Menu: KclEditorMenu, Menu: KclEditorMenu,
}, },
{
id: 'files',
title: 'Project Files',
icon: 'folder',
Content: FileTreeInner,
keybinding: 'shift + f',
Menu: FileTreeMenu,
hideOnPlatform: 'web',
},
] ]
export const bottomPanes: SidebarPane[] = [ export const bottomPanes: Pane[] = [
{ {
id: 'variables', id: 'variables',
title: 'Variables', title: 'Variables',

View File

@ -2,19 +2,13 @@ import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { Resizable } from 're-resizable' import { Resizable } from 're-resizable'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { useHotkeys } from 'react-hotkeys-hook' import { useHotkeys } from 'react-hotkeys-hook'
import { useStore } from 'useStore' import { PaneType, useStore } from 'useStore'
import { Tab } from '@headlessui/react' import { Tab } from '@headlessui/react'
import { import { Pane, bottomPanes, topPanes } from './ModelingPanes'
SidebarPane,
SidebarType,
bottomPanes,
topPanes,
} from './ModelingPanes'
import Tooltip from 'components/Tooltip' import Tooltip from 'components/Tooltip'
import { ActionIcon } from 'components/ActionIcon' import { ActionIcon } from 'components/ActionIcon'
import styles from './ModelingSidebar.module.css' import styles from './ModelingSidebar.module.css'
import { ModelingPane } from './ModelingPane' import { ModelingPane } from './ModelingPane'
import { isTauri } from 'lib/isTauri'
interface ModelingSidebarProps { interface ModelingSidebarProps {
paneOpacity: '' | 'opacity-20' | 'opacity-40' paneOpacity: '' | 'opacity-20' | 'opacity-40'
@ -58,7 +52,7 @@ export function ModelingSidebar({ paneOpacity }: ModelingSidebarProps) {
} }
interface ModelingSidebarSectionProps { interface ModelingSidebarSectionProps {
panes: SidebarPane[] panes: Pane[]
alignButtons?: 'start' | 'end' alignButtons?: 'start' | 'end'
} }
@ -75,11 +69,11 @@ function ModelingSidebarSection({
})) }))
const foundOpenPane = openPanes.find((pane) => paneIds.includes(pane)) const foundOpenPane = openPanes.find((pane) => paneIds.includes(pane))
const [currentPane, setCurrentPane] = useState( const [currentPane, setCurrentPane] = useState(
foundOpenPane || ('none' as SidebarType | 'none') foundOpenPane || ('none' as PaneType | 'none')
) )
const togglePane = useCallback( const togglePane = useCallback(
(newPane: SidebarType | 'none') => { (newPane: PaneType | 'none') => {
if (newPane === 'none') { if (newPane === 'none') {
setOpenPanes(openPanes.filter((p) => p !== currentPane)) setOpenPanes(openPanes.filter((p) => p !== currentPane))
setCurrentPane('none') setCurrentPane('none')
@ -96,15 +90,9 @@ function ModelingSidebarSection({
// Filter out the debug panel if it's not supposed to be shown // Filter out the debug panel if it's not supposed to be shown
// TODO: abstract out for allowing user to configure which panes to show // TODO: abstract out for allowing user to configure which panes to show
const filteredPanes = ( const filteredPanes = showDebugPanel.current
showDebugPanel.current ? panes : panes.filter((pane) => pane.id !== 'debug') ? panes
).filter( : panes.filter((pane) => pane.id !== 'debug')
(pane) =>
!pane.hideOnPlatform ||
(isTauri()
? pane.hideOnPlatform === 'web'
: pane.hideOnPlatform === 'desktop')
)
useEffect(() => { useEffect(() => {
if ( if (
!showDebugPanel.current && !showDebugPanel.current &&
@ -180,8 +168,8 @@ function ModelingSidebarSection({
} }
interface ModelingPaneButtonProps { interface ModelingPaneButtonProps {
paneConfig: SidebarPane paneConfig: Pane
currentPane: SidebarType | 'none' currentPane: PaneType | 'none'
togglePane: () => void togglePane: () => void
} }

View File

@ -1,4 +1,5 @@
import { FormEvent, useEffect, useRef, useState } from 'react' import { FormEvent, useEffect, useRef, useState } from 'react'
import { type ProjectWithEntryPointMetadata } from 'lib/types'
import { paths } from 'lib/paths' import { paths } from 'lib/paths'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { ActionButton } from './ActionButton' import { ActionButton } from './ActionButton'
@ -8,11 +9,11 @@ import {
faTrashAlt, faTrashAlt,
faX, faX,
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
import { getPartsCount, readProject } from '../lib/tauriFS'
import { FILE_EXT } from 'lib/constants' import { FILE_EXT } from 'lib/constants'
import { Dialog } from '@headlessui/react' import { Dialog } from '@headlessui/react'
import { useHotkeys } from 'react-hotkeys-hook' import { useHotkeys } from 'react-hotkeys-hook'
import Tooltip from './Tooltip' import Tooltip from './Tooltip'
import { Project } from 'wasm-lib/kcl/bindings/Project'
function ProjectCard({ function ProjectCard({
project, project,
@ -20,17 +21,17 @@ function ProjectCard({
handleDeleteProject, handleDeleteProject,
...props ...props
}: { }: {
project: Project project: ProjectWithEntryPointMetadata
handleRenameProject: ( handleRenameProject: (
e: FormEvent<HTMLFormElement>, e: FormEvent<HTMLFormElement>,
f: Project f: ProjectWithEntryPointMetadata
) => Promise<void> ) => Promise<void>
handleDeleteProject: (f: Project) => Promise<void> handleDeleteProject: (f: ProjectWithEntryPointMetadata) => Promise<void>
}) { }) {
useHotkeys('esc', () => setIsEditing(false)) useHotkeys('esc', () => setIsEditing(false))
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
const [isConfirmingDelete, setIsConfirmingDelete] = useState(false) const [isConfirmingDelete, setIsConfirmingDelete] = useState(false)
const [numberOfFiles, setNumberOfFiles] = useState(1) const [numberOfParts, setNumberOfParts] = useState(1)
const [numberOfFolders, setNumberOfFolders] = useState(0) const [numberOfFolders, setNumberOfFolders] = useState(0)
let inputRef = useRef<HTMLInputElement>(null) let inputRef = useRef<HTMLInputElement>(null)
@ -40,8 +41,7 @@ function ProjectCard({
void handleRenameProject(e, project).then(() => setIsEditing(false)) void handleRenameProject(e, project).then(() => setIsEditing(false))
} }
function getDisplayedTime(dateStr: string) { function getDisplayedTime(date: Date) {
const date = new Date(dateStr)
const startOfToday = new Date() const startOfToday = new Date()
startOfToday.setHours(0, 0, 0, 0) startOfToday.setHours(0, 0, 0, 0)
return date.getTime() < startOfToday.getTime() return date.getTime() < startOfToday.getTime()
@ -50,12 +50,15 @@ function ProjectCard({
} }
useEffect(() => { useEffect(() => {
async function getNumberOfFiles() { async function getNumberOfParts() {
setNumberOfFiles(project.kcl_file_count) const { kclFileCount, kclDirCount } = getPartsCount(
setNumberOfFolders(project.directory_count) await readProject(project.path)
)
setNumberOfParts(kclFileCount)
setNumberOfFolders(kclDirCount)
} }
void getNumberOfFiles() void getNumberOfParts()
}, [project.kcl_file_count, project.directory_count]) }, [project.path])
useEffect(() => { useEffect(() => {
if (inputRef.current) { if (inputRef.current) {
@ -126,7 +129,7 @@ function ProjectCard({
{project.name?.replace(FILE_EXT, '')} {project.name?.replace(FILE_EXT, '')}
</Link> </Link>
<span className="text-chalkboard-60 text-xs"> <span className="text-chalkboard-60 text-xs">
{numberOfFiles} file{numberOfFiles === 1 ? '' : 's'}{' '} {numberOfParts} part{numberOfParts === 1 ? '' : 's'}{' '}
{numberOfFolders > 0 && {numberOfFolders > 0 &&
`/ ${numberOfFolders} folder${ `/ ${numberOfFolders} folder${
numberOfFolders === 1 ? '' : 's' numberOfFolders === 1 ? '' : 's'
@ -134,8 +137,8 @@ function ProjectCard({
</span> </span>
<span className="text-chalkboard-60 text-xs"> <span className="text-chalkboard-60 text-xs">
Edited{' '} Edited{' '}
{project.metadata && project.metadata?.modified {project.entrypointMetadata.mtime
? getDisplayedTime(project.metadata.modified) ? getDisplayedTime(project.entrypointMetadata.mtime)
: 'never'} : 'never'}
</span> </span>
<div className="absolute z-10 bottom-2 right-2 flex gap-1 items-center opacity-0 group-hover:opacity-100 group-focus-within:opacity-100"> <div className="absolute z-10 bottom-2 right-2 flex gap-1 items-center opacity-0 group-hover:opacity-100 group-focus-within:opacity-100">

View File

@ -1,10 +1,10 @@
import { fireEvent, render, screen } from '@testing-library/react' import { fireEvent, render, screen } from '@testing-library/react'
import { BrowserRouter } from 'react-router-dom' import { BrowserRouter } from 'react-router-dom'
import ProjectSidebarMenu from './ProjectSidebarMenu' import ProjectSidebarMenu from './ProjectSidebarMenu'
import { type ProjectWithEntryPointMetadata } from 'lib/types'
import { SettingsAuthProviderJest } from './SettingsAuthProvider' import { SettingsAuthProviderJest } from './SettingsAuthProvider'
import { APP_NAME } from 'lib/constants' import { APP_NAME } from 'lib/constants'
import { CommandBarProvider } from './CommandBar/CommandBarProvider' import { CommandBarProvider } from './CommandBar/CommandBarProvider'
import { Project } from 'wasm-lib/kcl/bindings/Project'
const now = new Date() const now = new Date()
const projectWellFormed = { const projectWellFormed = {
@ -14,17 +14,29 @@ const projectWellFormed = {
{ {
name: 'main.kcl', name: 'main.kcl',
path: '/some/path/Simple Box/main.kcl', path: '/some/path/Simple Box/main.kcl',
children: [],
}, },
], ],
metadata: { entrypointMetadata: {
created: now.toISOString(), atime: now,
modified: now.toISOString(), blksize: 32,
blocks: 32,
birthtime: now,
dev: 1,
gid: 1,
ino: 1,
isDirectory: false,
isFile: true,
isSymlink: false,
mode: 1,
mtime: now,
nlink: 1,
readonly: false,
rdev: 1,
size: 32, size: 32,
uid: 1,
fileAttributes: null,
}, },
kcl_file_count: 1, } satisfies ProjectWithEntryPointMetadata
directory_count: 0,
} satisfies Project
describe('ProjectSidebarMenu tests', () => { describe('ProjectSidebarMenu tests', () => {
test('Renders the project name', () => { test('Renders the project name', () => {

View File

@ -133,13 +133,13 @@ function ProjectMenuPopover({
<p className="m-0 text-mono" data-testid="projectName"> <p className="m-0 text-mono" data-testid="projectName">
{project?.name ? project.name : APP_NAME} {project?.name ? project.name : APP_NAME}
</p> </p>
{project?.metadata && project.metadata.created && ( {project?.entrypointMetadata && (
<p <p
className="m-0 text-xs text-chalkboard-80 dark:text-chalkboard-40" className="m-0 text-xs text-chalkboard-80 dark:text-chalkboard-40"
data-testid="createdAt" data-testid="createdAt"
> >
Created{' '} Created{' '}
{new Date(project.metadata.created).toLocaleDateString()} {project.entrypointMetadata.birthtime?.toLocaleDateString()}
</p> </p>
)} )}
</div> </div>

View File

@ -168,7 +168,7 @@ export const SettingsAuthProviderBase = ({
}, },
'Execute AST': () => kclManager.executeCode(true), 'Execute AST': () => kclManager.executeCode(true),
persistSettings: (context) => persistSettings: (context) =>
saveSettings(context, loadedProject?.project?.name), saveSettings(context, loadedProject?.project?.path),
}, },
} }
) )

View File

@ -7,5 +7,7 @@ export const VITE_KC_API_BASE_URL = import.meta.env.VITE_KC_API_BASE_URL
export const VITE_KC_SITE_BASE_URL = import.meta.env.VITE_KC_SITE_BASE_URL export const VITE_KC_SITE_BASE_URL = import.meta.env.VITE_KC_SITE_BASE_URL
export const VITE_KC_CONNECTION_TIMEOUT_MS = import.meta.env export const VITE_KC_CONNECTION_TIMEOUT_MS = import.meta.env
.VITE_KC_CONNECTION_TIMEOUT_MS .VITE_KC_CONNECTION_TIMEOUT_MS
export const VITE_KC_WASM_OVERRIDE_URL = import.meta.env
.VITE_KC_WASM_OVERRIDE_URL
export const TEST = import.meta.env.TEST export const TEST = import.meta.env.TEST
export const DEV = import.meta.env.DEV export const DEV = import.meta.env.DEV

View File

@ -1,7 +1,8 @@
import { readFile, exists as tauriExists } from '@tauri-apps/plugin-fs' import { readFile, exists as tauriExists } from '@tauri-apps/plugin-fs'
import { isTauri } from 'lib/isTauri' import { isTauri } from 'lib/isTauri'
import { join } from '@tauri-apps/api/path' import { join } from '@tauri-apps/api/path'
import { readDirRecursive } from 'lib/tauri' import { invoke } from '@tauri-apps/api/core'
import { FileEntry } from 'lib/types'
/// FileSystemManager is a class that provides a way to read files from the local file system. /// FileSystemManager is a class that provides a way to read files from the local file system.
/// It assumes that you are in a project since it is solely used by the std lib /// It assumes that you are in a project since it is solely used by the std lib
@ -68,7 +69,9 @@ class FileSystemManager {
throw new Error(`Error joining dir: ${error}`) throw new Error(`Error joining dir: ${error}`)
}) })
.then((p) => { .then((p) => {
readDirRecursive(p) invoke<FileEntry[]>('read_dir_recursive', {
path: p,
})
.catch((error) => { .catch((error) => {
throw new Error(`Error reading dir: ${error}`) throw new Error(`Error reading dir: ${error}`)
}) })

View File

@ -10,10 +10,7 @@ import init, {
make_default_planes, make_default_planes,
coredump, coredump,
toml_stringify, toml_stringify,
default_app_settings, toml_parse,
parse_app_settings,
parse_project_settings,
default_project_settings,
} from '../wasm-lib/pkg/wasm_lib' } from '../wasm-lib/pkg/wasm_lib'
import { KCLError } from './errors' import { KCLError } from './errors'
import { KclError as RustKclError } from '../wasm-lib/kcl/bindings/KclError' import { KclError as RustKclError } from '../wasm-lib/kcl/bindings/KclError'
@ -28,9 +25,7 @@ import { AppInfo } from 'wasm-lib/kcl/bindings/AppInfo'
import { CoreDumpManager } from 'lib/coredump' import { CoreDumpManager } from 'lib/coredump'
import openWindow from 'lib/openWindow' import openWindow from 'lib/openWindow'
import { DefaultPlanes } from 'wasm-lib/kcl/bindings/DefaultPlanes' import { DefaultPlanes } from 'wasm-lib/kcl/bindings/DefaultPlanes'
import { TEST } from 'env' import { TEST, VITE_KC_WASM_OVERRIDE_URL } from 'env'
import { Configuration } from 'wasm-lib/kcl/bindings/Configuration'
import { ProjectConfiguration } from 'wasm-lib/kcl/bindings/ProjectConfiguration'
export type { Program } from '../wasm-lib/kcl/bindings/Program' export type { Program } from '../wasm-lib/kcl/bindings/Program'
export type { Value } from '../wasm-lib/kcl/bindings/Value' export type { Value } from '../wasm-lib/kcl/bindings/Value'
@ -81,18 +76,19 @@ export type { MemoryItem } from '../wasm-lib/kcl/bindings/MemoryItem'
export type { ExtrudeSurface } from '../wasm-lib/kcl/bindings/ExtrudeSurface' export type { ExtrudeSurface } from '../wasm-lib/kcl/bindings/ExtrudeSurface'
export const wasmUrl = () => { export const wasmUrl = () => {
const baseUrl = const baseUrl = VITE_KC_WASM_OVERRIDE_URL
typeof window === 'undefined' ? VITE_KC_WASM_OVERRIDE_URL
? 'http://127.0.0.1:3000' : typeof window === 'undefined'
: window.location.origin.includes('tauri://localhost') ? 'http://127.0.0.1:3000'
? 'tauri://localhost' // custom protocol for macOS : window.location.origin.includes('tauri://localhost')
: window.location.origin.includes('tauri.localhost') ? 'tauri://localhost' // custom protocol for macOS
? 'http://tauri.localhost' // fallback for Windows : window.location.origin.includes('tauri.localhost')
: window.location.origin.includes('localhost') ? 'http://tauri.localhost' // fallback for Windows
? 'http://localhost:3000' : window.location.origin.includes('localhost')
: window.location.origin && window.location.origin !== 'null' ? 'http://localhost:3000'
? window.location.origin : window.location.origin && window.location.origin !== 'null'
: 'http://localhost:3000' ? window.location.origin
: 'http://localhost:3000'
const fullUrl = baseUrl + '/wasm_lib_bg.wasm' const fullUrl = baseUrl + '/wasm_lib_bg.wasm'
console.log(`Full URL for WASM: ${fullUrl}`) console.log(`Full URL for WASM: ${fullUrl}`)
@ -354,38 +350,11 @@ export function tomlStringify(toml: any): string {
} }
} }
export function defaultAppSettings(): Configuration { export function tomlParse(toml: string): any {
try { try {
const settings: Configuration = default_app_settings() const parsed: any = toml_parse(toml)
return settings return parsed
} catch (e: any) { } catch (e: any) {
throw new Error(`Error getting default app settings: ${e}`) throw new Error(`Error parsing toml: ${e}`)
}
}
export function parseAppSettings(toml: string): Configuration {
try {
const settings: Configuration = parse_app_settings(toml)
return settings
} catch (e: any) {
throw new Error(`Error parsing app settings: ${e}`)
}
}
export function defaultProjectSettings(): ProjectConfiguration {
try {
const settings: ProjectConfiguration = default_project_settings()
return settings
} catch (e: any) {
throw new Error(`Error getting default project settings: ${e}`)
}
}
export function parseProjectSettings(toml: string): ProjectConfiguration {
try {
const settings: ProjectConfiguration = parse_project_settings(toml)
return settings
} catch (e: any) {
throw new Error(`Error parsing project settings: ${e}`)
} }
} }

View File

@ -1,5 +1,3 @@
import { MouseControlType } from 'wasm-lib/kcl/bindings/MouseControlType'
const noModifiersPressed = (e: React.MouseEvent) => const noModifiersPressed = (e: React.MouseEvent) =>
!e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey !e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey
@ -22,29 +20,6 @@ export const cameraSystems: CameraSystem[] = [
'AutoCAD', 'AutoCAD',
] ]
export function mouseControlsToCameraSystem(
mouseControl: MouseControlType | undefined
): CameraSystem | undefined {
switch (mouseControl) {
case 'kitty_cad':
return 'KittyCAD'
case 'on_shape':
return 'OnShape'
case 'trackpad_friendly':
return 'Trackpad Friendly'
case 'solidworks':
return 'Solidworks'
case 'nx':
return 'NX'
case 'creo':
return 'Creo'
case 'auto_cad':
return 'AutoCAD'
default:
return undefined
}
}
interface MouseGuardHandler { interface MouseGuardHandler {
description: string description: string
callback: (e: React.MouseEvent) => boolean callback: (e: React.MouseEvent) => boolean

View File

@ -8,6 +8,8 @@ export const MAX_PADDING = 7
* This is available for users to edit as a setting. * This is available for users to edit as a setting.
*/ */
export const DEFAULT_PROJECT_NAME = 'project-$nnn' export const DEFAULT_PROJECT_NAME = 'project-$nnn'
/** The file name for settings files, both at the user and project level */
export const SETTINGS_FILE_EXT = '.toml'
/** Name given the temporary "project" in the browser version of the app */ /** Name given the temporary "project" in the browser version of the app */
export const BROWSER_PROJECT_NAME = 'browser' export const BROWSER_PROJECT_NAME = 'browser'
/** Name given the temporary file in the browser version of the app */ /** Name given the temporary file in the browser version of the app */

View File

@ -1,5 +1,10 @@
import { ActionFunction, LoaderFunction, redirect } from 'react-router-dom' import { ActionFunction, LoaderFunction, redirect } from 'react-router-dom'
import { FileLoaderData, HomeLoaderData, IndexLoaderData } from './types' import {
FileEntry,
FileLoaderData,
HomeLoaderData,
IndexLoaderData,
} from './types'
import { isTauri } from './isTauri' import { isTauri } from './isTauri'
import { getProjectMetaByRouteId, paths } from './paths' import { getProjectMetaByRouteId, paths } from './paths'
import { BROWSER_PATH } from 'lib/paths' import { BROWSER_PATH } from 'lib/paths'
@ -9,26 +14,24 @@ import {
PROJECT_ENTRYPOINT, PROJECT_ENTRYPOINT,
} from 'lib/constants' } from 'lib/constants'
import { loadAndValidateSettings } from './settings/settingsUtils' import { loadAndValidateSettings } from './settings/settingsUtils'
import {
getInitialDefaultDir,
getProjectsInDir,
initializeProjectDirectory,
} from './tauriFS'
import makeUrlPathRelative from './makeUrlPathRelative' import makeUrlPathRelative from './makeUrlPathRelative'
import { sep } from '@tauri-apps/api/path' import { join, sep } from '@tauri-apps/api/path'
import { readTextFile } from '@tauri-apps/plugin-fs' import { readTextFile, stat } from '@tauri-apps/plugin-fs'
import { codeManager, kclManager } from 'lib/singletons' import { codeManager, kclManager } from 'lib/singletons'
import { fileSystemManager } from 'lang/std/fileSystemManager' import { fileSystemManager } from 'lang/std/fileSystemManager'
import { import { invoke } from '@tauri-apps/api/core'
getProjectInfo,
initializeProjectDirectory,
listProjects,
} from './tauri'
import { createSettings } from './settings/initialSettings'
// The root loader simply resolves the settings and any errors that // The root loader simply resolves the settings and any errors that
// occurred during the settings load // occurred during the settings load
export const settingsLoader: LoaderFunction = async ({ export const settingsLoader: LoaderFunction = async ({
params, params,
}): Promise< }): ReturnType<typeof loadAndValidateSettings> => {
ReturnType<typeof createSettings> | ReturnType<typeof redirect> let settings = await loadAndValidateSettings()
> => {
let { settings } = await loadAndValidateSettings()
// I don't love that we have to read the settings again here, // I don't love that we have to read the settings again here,
// but we need to get the project path to load the project settings // but we need to get the project path to load the project settings
@ -36,9 +39,8 @@ export const settingsLoader: LoaderFunction = async ({
const defaultDir = settings.app.projectDirectory.current || '' const defaultDir = settings.app.projectDirectory.current || ''
const projectPathData = getProjectMetaByRouteId(params.id, defaultDir) const projectPathData = getProjectMetaByRouteId(params.id, defaultDir)
if (projectPathData) { if (projectPathData) {
const { projectName } = projectPathData const { projectPath } = projectPathData
const { settings: s } = await loadAndValidateSettings(projectName) settings = await loadAndValidateSettings(projectPath)
settings = s
} }
} }
@ -47,7 +49,7 @@ export const settingsLoader: LoaderFunction = async ({
// Redirect users to the appropriate onboarding page if they haven't completed it // Redirect users to the appropriate onboarding page if they haven't completed it
export const onboardingRedirectLoader: ActionFunction = async (args) => { export const onboardingRedirectLoader: ActionFunction = async (args) => {
const { settings } = await loadAndValidateSettings() const settings = await loadAndValidateSettings()
const onboardingStatus = settings.app.onboardingStatus.current || '' const onboardingStatus = settings.app.onboardingStatus.current || ''
const notEnRouteToOnboarding = !args.request.url.includes( const notEnRouteToOnboarding = !args.request.url.includes(
paths.ONBOARDING.INDEX paths.ONBOARDING.INDEX
@ -71,7 +73,7 @@ export const onboardingRedirectLoader: ActionFunction = async (args) => {
export const fileLoader: LoaderFunction = async ({ export const fileLoader: LoaderFunction = async ({
params, params,
}): Promise<FileLoaderData | Response> => { }): Promise<FileLoaderData | Response> => {
let { settings } = await loadAndValidateSettings() let settings = await loadAndValidateSettings()
const defaultDir = settings.app.projectDirectory.current || '/' const defaultDir = settings.app.projectDirectory.current || '/'
const projectPathData = getProjectMetaByRouteId(params.id, defaultDir) const projectPathData = getProjectMetaByRouteId(params.id, defaultDir)
@ -92,7 +94,12 @@ export const fileLoader: LoaderFunction = async ({
// TODO: PROJECT_ENTRYPOINT is hardcoded // TODO: PROJECT_ENTRYPOINT is hardcoded
// until we support setting a project's entrypoint file // until we support setting a project's entrypoint file
const code = await readTextFile(currentFilePath) const code = await readTextFile(currentFilePath)
const entrypointMetadata = await stat(
await join(projectPath, PROJECT_ENTRYPOINT)
)
const children = await invoke<FileEntry[]>('read_dir_recursive', {
path: projectPath,
})
// Update both the state and the editor's code. // Update both the state and the editor's code.
// We explicitly do not write to the file here since we are loading from // We explicitly do not write to the file here since we are loading from
// the file system and not the editor. // the file system and not the editor.
@ -106,19 +113,15 @@ export const fileLoader: LoaderFunction = async ({
const projectData: IndexLoaderData = { const projectData: IndexLoaderData = {
code, code,
project: isTauri() project: {
? await getProjectInfo(projectPath) name: projectName,
: { path: projectPath,
name: projectName, children,
path: projectPath, entrypointMetadata,
children: [], },
kcl_file_count: 0,
directory_count: 0,
},
file: { file: {
name: currentFileName, name: currentFileName,
path: currentFilePath, path: currentFilePath,
children: [],
}, },
} }
@ -137,7 +140,6 @@ export const fileLoader: LoaderFunction = async ({
file: { file: {
name: BROWSER_FILE_NAME, name: BROWSER_FILE_NAME,
path: decodeURIComponent(BROWSER_PATH), path: decodeURIComponent(BROWSER_PATH),
children: [],
}, },
} }
} }
@ -150,12 +152,14 @@ export const homeLoader: LoaderFunction = async (): Promise<
if (!isTauri()) { if (!isTauri()) {
return redirect(paths.FILE + '/%2F' + BROWSER_PROJECT_NAME) return redirect(paths.FILE + '/%2F' + BROWSER_PROJECT_NAME)
} }
const { configuration } = await loadAndValidateSettings() const settings = await loadAndValidateSettings()
const projectDir = await initializeProjectDirectory(configuration) const projectDir = await initializeProjectDirectory(
settings.app.projectDirectory.current || (await getInitialDefaultDir())
)
if (projectDir) { if (projectDir.path) {
const projects = await listProjects(configuration) const projects = await getProjectsInDir(projectDir.path)
return { return {
projects, projects,

View File

@ -1,228 +1,100 @@
import {
getInitialDefaultDir,
getSettingsFilePaths,
readSettingsFile,
} from '../tauriFS'
import { Setting, createSettings, settings } from 'lib/settings/initialSettings' import { Setting, createSettings, settings } from 'lib/settings/initialSettings'
import { SaveSettingsPayload, SettingsLevel } from './settingsTypes' import { SaveSettingsPayload, SettingsLevel } from './settingsTypes'
import { isTauri } from 'lib/isTauri' import { isTauri } from 'lib/isTauri'
import { import { remove, writeTextFile, exists } from '@tauri-apps/plugin-fs'
defaultAppSettings, import { initPromise, tomlParse, tomlStringify } from 'lang/wasm'
defaultProjectSettings,
initPromise,
parseAppSettings,
parseProjectSettings,
tomlStringify,
} from 'lang/wasm'
import { Configuration } from 'wasm-lib/kcl/bindings/Configuration'
import { mouseControlsToCameraSystem } from 'lib/cameraControls'
import { appThemeToTheme } from 'lib/theme'
import {
readAppSettingsFile,
readProjectSettingsFile,
writeAppSettingsFile,
writeProjectSettingsFile,
} from 'lib/tauri'
import { ProjectConfiguration } from 'wasm-lib/kcl/bindings/ProjectConfiguration'
import { BROWSER_PROJECT_NAME } from 'lib/constants'
/** /**
* Convert from a rust settings struct into the JS settings struct. * We expect the settings to be stored in a TOML file
* We do this because the JS settings type has all the fancy shit * or TOML-formatted string in localStorage
* for hiding and showing settings. * under a top-level [settings] key.
**/ * @param path
function configurationToSettingsPayload( * @returns
configuration: Configuration */
): Partial<SaveSettingsPayload> { function getSettingsFromStorage(path: string) {
return { return isTauri()
app: { ? readSettingsFile(path)
theme: appThemeToTheme(configuration?.settings?.app?.appearance?.theme), : (tomlParse(localStorage.getItem(path) ?? '')
themeColor: configuration?.settings?.app?.appearance?.color .settings as Partial<SaveSettingsPayload>)
? configuration?.settings?.app?.appearance?.color.toString()
: undefined,
onboardingStatus: configuration?.settings?.app?.onboarding_status,
dismissWebBanner: configuration?.settings?.app?.dismiss_web_banner,
projectDirectory: configuration?.settings?.project?.directory,
enableSSAO: configuration?.settings?.modeling?.enable_ssao,
},
modeling: {
defaultUnit: configuration?.settings?.modeling?.base_unit,
mouseControls: mouseControlsToCameraSystem(
configuration?.settings?.modeling?.mouse_controls
),
highlightEdges: configuration?.settings?.modeling?.highlight_edges,
showDebugPanel: configuration?.settings?.modeling?.show_debug_panel,
},
textEditor: {
textWrapping: configuration?.settings?.text_editor?.text_wrapping,
blinkingCursor: configuration?.settings?.text_editor?.blinking_cursor,
},
projects: {
defaultProjectName:
configuration?.settings?.project?.default_project_name,
},
commandBar: {
includeSettings: configuration?.settings?.command_bar?.include_settings,
},
}
} }
function projectConfigurationToSettingsPayload( export async function loadAndValidateSettings(projectPath?: string) {
configuration: ProjectConfiguration
): Partial<SaveSettingsPayload> {
return {
app: {
theme: appThemeToTheme(configuration?.settings?.app?.appearance?.theme),
themeColor: configuration?.settings?.app?.appearance?.color
? configuration?.settings?.app?.appearance?.color.toString()
: undefined,
onboardingStatus: configuration?.settings?.app?.onboarding_status,
dismissWebBanner: configuration?.settings?.app?.dismiss_web_banner,
enableSSAO: configuration?.settings?.modeling?.enable_ssao,
},
modeling: {
defaultUnit: configuration?.settings?.modeling?.base_unit,
mouseControls: mouseControlsToCameraSystem(
configuration?.settings?.modeling?.mouse_controls
),
highlightEdges: configuration?.settings?.modeling?.highlight_edges,
showDebugPanel: configuration?.settings?.modeling?.show_debug_panel,
},
textEditor: {
textWrapping: configuration?.settings?.text_editor?.text_wrapping,
blinkingCursor: configuration?.settings?.text_editor?.blinking_cursor,
},
commandBar: {
includeSettings: configuration?.settings?.command_bar?.include_settings,
},
}
}
function localStorageAppSettingsPath() {
return '/settings.toml'
}
function localStorageProjectSettingsPath() {
return '/' + BROWSER_PROJECT_NAME + '/project.toml'
}
function readLocalStorageAppSettingsFile(): Configuration {
// TODO: Remove backwards compatibility after a few releases.
let stored =
localStorage.getItem(localStorageAppSettingsPath()) ??
localStorage.getItem('/user.toml') ??
''
if (stored === '') {
return defaultAppSettings()
}
try {
return parseAppSettings(stored)
} catch (e) {
const settings = defaultAppSettings()
localStorage.setItem(localStorageAppSettingsPath(), tomlStringify(settings))
return settings
}
}
function readLocalStorageProjectSettingsFile(): ProjectConfiguration {
// TODO: Remove backwards compatibility after a few releases.
let stored = localStorage.getItem(localStorageProjectSettingsPath()) ?? ''
if (stored === '') {
return defaultProjectSettings()
}
try {
return parseProjectSettings(stored)
} catch (e) {
const settings = defaultProjectSettings()
localStorage.setItem(
localStorageProjectSettingsPath(),
tomlStringify(settings)
)
return settings
}
}
export interface AppSettings {
settings: ReturnType<typeof createSettings>
configuration: Configuration
}
export async function loadAndValidateSettings(
projectName?: string
): Promise<AppSettings> {
const settings = createSettings() const settings = createSettings()
const inTauri = isTauri() settings.app.projectDirectory.default = await getInitialDefaultDir()
// First, get the settings data at the user and project level
const settingsFilePaths = await getSettingsFilePaths(projectPath)
if (!inTauri) { // Load the settings from the files
// Make sure we have wasm initialized. if (settingsFilePaths.user) {
await initPromise await initPromise
const userSettings = await getSettingsFromStorage(settingsFilePaths.user)
if (userSettings) {
setSettingsAtLevel(settings, 'user', userSettings)
}
} }
// Load the app settings from the file system or localStorage.
const appSettings = inTauri
? await readAppSettingsFile()
: readLocalStorageAppSettingsFile()
// Convert the app settings to the JS settings format.
const appSettingsPayload = configurationToSettingsPayload(appSettings)
setSettingsAtLevel(settings, 'user', appSettingsPayload)
// Load the project settings if they exist // Load the project settings if they exist
if (projectName) { if (settingsFilePaths.project) {
const projectSettings = inTauri const projectSettings = await getSettingsFromStorage(
? await readProjectSettingsFile(appSettings, projectName) settingsFilePaths.project
: readLocalStorageProjectSettingsFile() )
if (projectSettings) {
const projectSettingsPayload = setSettingsAtLevel(settings, 'project', projectSettings)
projectConfigurationToSettingsPayload(projectSettings) }
setSettingsAtLevel(settings, 'project', projectSettingsPayload)
} }
// Return the settings object // Return the settings object
return { settings, configuration: appSettings } return settings
} }
export async function saveSettings( export async function saveSettings(
allSettings: typeof settings, allSettings: typeof settings,
projectName?: string projectPath?: string
) {
const settingsFilePaths = await getSettingsFilePaths(projectPath)
if (settingsFilePaths.user) {
const changedSettings = getChangedSettingsAtLevel(allSettings, 'user')
await writeOrClearPersistedSettings(settingsFilePaths.user, changedSettings)
}
if (settingsFilePaths.project) {
const changedSettings = getChangedSettingsAtLevel(allSettings, 'project')
await writeOrClearPersistedSettings(
settingsFilePaths.project,
changedSettings
)
}
}
async function writeOrClearPersistedSettings(
settingsFilePath: string,
changedSettings: Partial<SaveSettingsPayload>
) { ) {
// Make sure we have wasm initialized.
await initPromise await initPromise
const inTauri = isTauri() if (changedSettings && Object.keys(changedSettings).length) {
if (isTauri()) {
// Get the user settings. await writeTextFile(
const jsAppSettings = getChangedSettingsAtLevel(allSettings, 'user') settingsFilePath,
const tomlString = tomlStringify({ settings: jsAppSettings }) tomlStringify({ settings: changedSettings })
// Parse this as a Configuration. )
const appSettings = parseAppSettings(tomlString) }
// Write the app settings.
if (inTauri) {
await writeAppSettingsFile(appSettings)
} else {
localStorage.setItem( localStorage.setItem(
localStorageAppSettingsPath(), settingsFilePath,
tomlStringify(appSettings) tomlStringify({ settings: changedSettings })
) )
}
if (!projectName) {
// If we're not saving project settings, we're done.
return
}
// Get the project settings.
const jsProjectSettings = getChangedSettingsAtLevel(allSettings, 'project')
const projectTomlString = tomlStringify({ settings: jsProjectSettings })
// Parse this as a Configuration.
const projectSettings = parseProjectSettings(projectTomlString)
// Write the project settings.
if (inTauri) {
await writeProjectSettingsFile(appSettings, projectName, projectSettings)
} else { } else {
localStorage.setItem( if (isTauri() && (await exists(settingsFilePath))) {
localStorageProjectSettingsPath(), await remove(settingsFilePath)
tomlStringify(projectSettings) }
) localStorage.removeItem(settingsFilePath)
} }
} }

View File

@ -3,7 +3,7 @@ import {
faArrowUp, faArrowUp,
faCircle, faCircle,
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
import { Project } from 'wasm-lib/kcl/bindings/Project' import { type ProjectWithEntryPointMetadata } from 'lib/types'
const DESC = ':desc' const DESC = ':desc'
@ -27,7 +27,10 @@ export function getNextSearchParams(currentSort: string, newSort: string) {
} }
export function getSortFunction(sortBy: string) { export function getSortFunction(sortBy: string) {
const sortByName = (a: Project, b: Project) => { const sortByName = (
a: ProjectWithEntryPointMetadata,
b: ProjectWithEntryPointMetadata
) => {
if (a.name && b.name) { if (a.name && b.name) {
return sortBy.includes('desc') return sortBy.includes('desc')
? a.name.localeCompare(b.name) ? a.name.localeCompare(b.name)
@ -36,13 +39,16 @@ export function getSortFunction(sortBy: string) {
return 0 return 0
} }
const sortByModified = (a: Project, b: Project) => { const sortByModified = (
if (a.metadata?.modified && b.metadata?.modified) { a: ProjectWithEntryPointMetadata,
const aDate = new Date(a.metadata.modified) b: ProjectWithEntryPointMetadata
const bDate = new Date(b.metadata.modified) ) => {
if (a.entrypointMetadata?.mtime && b.entrypointMetadata?.mtime) {
return !sortBy || sortBy.includes('desc') return !sortBy || sortBy.includes('desc')
? bDate.getTime() - aDate.getTime() ? b.entrypointMetadata.mtime.getTime() -
: aDate.getTime() - bDate.getTime() a.entrypointMetadata.mtime.getTime()
: a.entrypointMetadata.mtime.getTime() -
b.entrypointMetadata.mtime.getTime()
} }
return 0 return 0
} }

View File

@ -1,139 +0,0 @@
// This file contains wrappers around the tauri commands we define in rust code.
import { Models } from '@kittycad/lib/dist/types/src'
import { invoke } from '@tauri-apps/api/core'
import { Configuration } from 'wasm-lib/kcl/bindings/Configuration'
import { ProjectConfiguration } from 'wasm-lib/kcl/bindings/ProjectConfiguration'
import { Project } from 'wasm-lib/kcl/bindings/Project'
import { FileEntry } from 'wasm-lib/kcl/bindings/FileEntry'
import { ProjectState } from 'wasm-lib/kcl/bindings/ProjectState'
// Get the app state from tauri.
export async function getState(): Promise<ProjectState | undefined> {
return await invoke<ProjectState | undefined>('get_state')
}
// Set the app state in tauri.
export async function setState(state: ProjectState | undefined): Promise<void> {
return await invoke('set_state', { state })
}
// Get the initial default dir for holding all projects.
export async function getInitialDefaultDir(): Promise<string> {
return invoke<string>('get_initial_default_dir')
}
export async function showInFolder(path: string | undefined): Promise<void> {
if (!path) {
console.error('path is undefined cannot call tauri showInFolder')
return
}
return await invoke('show_in_folder', { path })
}
export async function initializeProjectDirectory(
settings: Configuration
): Promise<string> {
return await invoke<string>('initialize_project_directory', {
configuration: settings,
})
}
export async function createNewProjectDirectory(
projectName: string,
initialCode?: string,
configuration?: Configuration
): Promise<Project> {
if (!configuration) {
configuration = await readAppSettingsFile()
}
return await invoke<Project>('create_new_project_directory', {
configuration,
projectName,
initialCode,
})
}
export async function listProjects(
configuration?: Configuration
): Promise<Project[]> {
if (!configuration) {
configuration = await readAppSettingsFile()
}
return await invoke<Project[]>('list_projects', { configuration })
}
export async function getProjectInfo(
projectPath: string,
configuration?: Configuration
): Promise<Project> {
if (!configuration) {
configuration = await readAppSettingsFile()
}
return await invoke<Project>('get_project_info', {
configuration,
projectPath,
})
}
export async function login(host: string): Promise<string> {
return await invoke('login', { host })
}
export async function getUser(
token: string | undefined,
host: string
): Promise<Models['User_type'] | Record<'error_code', unknown> | void> {
if (!token) {
console.error('token is undefined cannot call tauri getUser')
return
}
return await invoke<Models['User_type'] | Record<'error_code', unknown>>(
'get_user',
{
token: token,
hostname: host,
}
).catch((err) => console.error('error from Tauri getUser', err))
}
export async function readDirRecursive(path: string): Promise<FileEntry[]> {
return await invoke<FileEntry[]>('read_dir_recursive', { path })
}
// Read the contents of the app settings.
export async function readAppSettingsFile(): Promise<Configuration> {
return await invoke<Configuration>('read_app_settings_file')
}
// Write the contents of the app settings.
export async function writeAppSettingsFile(
settings: Configuration
): Promise<void> {
return await invoke('write_app_settings_file', { configuration: settings })
}
// Read project settings file.
export async function readProjectSettingsFile(
appSettings: Configuration,
projectName: string
): Promise<ProjectConfiguration> {
return await invoke<ProjectConfiguration>('read_project_settings_file', {
appSettings,
projectName,
})
}
// Write project settings file.
export async function writeProjectSettingsFile(
appSettings: Configuration,
projectName: string,
settings: ProjectConfiguration
): Promise<void> {
return await invoke('write_project_settings_file', {
appSettings,
projectName,
configuration: settings,
})
}

View File

@ -1,4 +1,11 @@
import { getNextProjectIndex, interpolateProjectNameWithIndex } from './tauriFS' import {
deepFileFilter,
getNextProjectIndex,
getPartsCount,
interpolateProjectNameWithIndex,
isRelevantFileOrDir,
} from './tauriFS'
import type { FileEntry } from './types'
import { MAX_PADDING } from './constants' import { MAX_PADDING } from './constants'
describe('Test project name utility functions', () => { describe('Test project name utility functions', () => {
@ -24,22 +31,18 @@ describe('Test project name utility functions', () => {
{ {
name: 'new-project-04.kcl', name: 'new-project-04.kcl',
path: '/projects/new-project-04.kcl', path: '/projects/new-project-04.kcl',
children: [],
}, },
{ {
name: 'new-project-007.kcl', name: 'new-project-007.kcl',
path: '/projects/new-project-007.kcl', path: '/projects/new-project-007.kcl',
children: [],
}, },
{ {
name: 'new-project-05.kcl', name: 'new-project-05.kcl',
path: '/projects/new-project-05.kcl', path: '/projects/new-project-05.kcl',
children: [],
}, },
{ {
name: 'new-project-0.kcl', name: 'new-project-0.kcl',
path: '/projects/new-project-0.kcl', path: '/projects/new-project-0.kcl',
children: [],
}, },
] ]
@ -47,3 +50,101 @@ describe('Test project name utility functions', () => {
expect(getNextProjectIndex('new-project-$n', testFiles)).toBe(8) expect(getNextProjectIndex('new-project-$n', testFiles)).toBe(8)
}) })
}) })
describe('Test file tree utility functions', () => {
const baseFiles: FileEntry[] = [
{
name: 'show-me.kcl',
path: '/projects/show-me.kcl',
},
{
name: 'hide-me.jpg',
path: '/projects/hide-me.jpg',
},
{
name: '.gitignore',
path: '/projects/.gitignore',
},
]
const filteredBaseFiles: FileEntry[] = [
{
name: 'show-me.kcl',
path: '/projects/show-me.kcl',
},
]
it('Only includes files relevant to the project in a flat directory', () => {
expect(deepFileFilter(baseFiles, isRelevantFileOrDir)).toEqual(
filteredBaseFiles
)
})
const nestedFiles: FileEntry[] = [
...baseFiles,
{
name: 'show-me',
path: '/projects/show-me',
children: [
{
name: 'show-me-nested',
path: '/projects/show-me/show-me-nested',
children: baseFiles,
},
{
name: 'hide-me',
path: '/projects/show-me/hide-me',
children: baseFiles.filter((file) => file.name !== 'show-me.kcl'),
},
],
},
{
name: 'hide-me',
path: '/projects/hide-me',
children: baseFiles.filter((file) => file.name !== 'show-me.kcl'),
},
]
const filteredNestedFiles: FileEntry[] = [
...filteredBaseFiles,
{
name: 'show-me',
path: '/projects/show-me',
children: [
{
name: 'show-me-nested',
path: '/projects/show-me/show-me-nested',
children: filteredBaseFiles,
},
],
},
]
it('Only includes directories that include files relevant to the project in a nested directory', () => {
expect(deepFileFilter(nestedFiles, isRelevantFileOrDir)).toEqual(
filteredNestedFiles
)
})
const withHiddenDir: FileEntry[] = [
...baseFiles,
{
name: '.hide-me',
path: '/projects/.hide-me',
children: baseFiles,
},
]
it(`Hides folders that begin with a ".", even if they contain relevant files`, () => {
expect(deepFileFilter(withHiddenDir, isRelevantFileOrDir)).toEqual(
filteredBaseFiles
)
})
it(`Properly counts the number of relevant files and directories in a project`, () => {
expect(getPartsCount(nestedFiles)).toEqual({
kclFileCount: 2,
kclDirCount: 2,
})
})
})

View File

@ -1,19 +1,154 @@
import { appConfigDir } from '@tauri-apps/api/path'
import { isTauri } from './isTauri'
import type { FileEntry } from 'lib/types'
import { import {
mkdir,
exists,
readTextFile,
writeTextFile,
stat,
} from '@tauri-apps/plugin-fs'
import { invoke } from '@tauri-apps/api/core'
import {
appConfigDir,
documentDir,
homeDir,
join,
sep,
} from '@tauri-apps/api/path'
import { isTauri } from './isTauri'
import type { FileEntry, ProjectWithEntryPointMetadata } from 'lib/types'
import {
FILE_EXT,
INDEX_IDENTIFIER, INDEX_IDENTIFIER,
MAX_PADDING, MAX_PADDING,
ONBOARDING_PROJECT_NAME, ONBOARDING_PROJECT_NAME,
PROJECT_ENTRYPOINT, PROJECT_ENTRYPOINT,
PROJECT_FOLDER,
RELEVANT_FILE_TYPES,
SETTINGS_FILE_EXT,
} from 'lib/constants' } from 'lib/constants'
import { SaveSettingsPayload, SettingsLevel } from './settings/settingsTypes'
import { initPromise, tomlParse } from 'lang/wasm'
import { bracket } from './exampleKcl' import { bracket } from './exampleKcl'
import { paths } from './paths' import { paths } from './paths'
import {
createNewProjectDirectory, type PathWithPossibleError = {
listProjects, path: string | null
readAppSettingsFile, error: Error | null
} from './tauri' }
export async function getInitialDefaultDir() {
if (!isTauri()) return ''
let dir
try {
dir = await documentDir()
} catch (e) {
dir = await join(await homeDir(), 'Documents') // for headless Linux (eg. Github Actions)
}
return await join(dir, PROJECT_FOLDER)
}
// Initializes the project directory and returns the path
// with any Errors that occurred
export async function initializeProjectDirectory(
directory: string
): Promise<PathWithPossibleError> {
let returnValue: PathWithPossibleError = {
path: null,
error: null,
}
if (!isTauri()) return returnValue
if (directory) {
returnValue = await testAndCreateDir(directory, returnValue)
}
// If the directory from settings does not exist or could not be created,
// use the default directory
if (returnValue.path === null) {
const INITIAL_DEFAULT_DIR = await getInitialDefaultDir()
const defaultReturnValue = await testAndCreateDir(
INITIAL_DEFAULT_DIR,
returnValue,
{
exists: 'Error checking default directory.',
create: 'Error creating default directory.',
}
)
returnValue.path = defaultReturnValue.path
returnValue.error =
returnValue.error === null ? defaultReturnValue.error : returnValue.error
}
return returnValue
}
async function testAndCreateDir(
directory: string,
returnValue = {
path: null,
error: null,
} as PathWithPossibleError,
errorMessages = {
exists:
'Error checking directory at path from saved settings. Using default.',
create:
'Error creating directory at path from saved settings. Using default.',
}
): Promise<PathWithPossibleError> {
const dirExists = await exists(directory).catch((e) => {
console.error(`Error checking directory ${directory}. Original error:`, e)
return new Error(errorMessages.exists)
})
if (dirExists instanceof Error) {
returnValue.error = dirExists
} else if (dirExists === false) {
const newDirCreated = await mkdir(directory, { recursive: true }).catch(
(e) => {
console.error(
`Error creating directory ${directory}. Original error:`,
e
)
return new Error(errorMessages.create)
}
)
if (newDirCreated instanceof Error) {
returnValue.error = newDirCreated
} else {
returnValue.path = directory
}
} else if (dirExists === true) {
returnValue.path = directory
}
return returnValue
}
export function isProjectDirectory(fileOrDir: Partial<FileEntry>) {
return (
fileOrDir.children?.length &&
fileOrDir.children.some((child) => child.name === PROJECT_ENTRYPOINT)
)
}
// Read the contents of a directory
// and return the valid projects
export async function getProjectsInDir(projectDir: string) {
const readProjects = (
await invoke<FileEntry[]>('read_dir_recursive', { path: projectDir })
).filter(isProjectDirectory)
const projectsWithMetadata = await Promise.all(
readProjects.map(async (p) => ({
entrypointMetadata: await stat(await join(p.path, PROJECT_ENTRYPOINT)),
...p,
}))
)
return projectsWithMetadata
}
export const isHidden = (fileOrDir: FileEntry) => export const isHidden = (fileOrDir: FileEntry) =>
!!fileOrDir.name?.startsWith('.') !!fileOrDir.name?.startsWith('.')
@ -21,6 +156,97 @@ export const isHidden = (fileOrDir: FileEntry) =>
export const isDir = (fileOrDir: FileEntry) => export const isDir = (fileOrDir: FileEntry) =>
'children' in fileOrDir && fileOrDir.children !== undefined 'children' in fileOrDir && fileOrDir.children !== undefined
export function deepFileFilter(
entries: FileEntry[],
filterFn: (f: FileEntry) => boolean
): FileEntry[] {
const filteredEntries: FileEntry[] = []
for (const fileOrDir of entries) {
if ('children' in fileOrDir && fileOrDir.children !== undefined) {
const filteredChildren = deepFileFilter(fileOrDir.children, filterFn)
if (filterFn(fileOrDir)) {
filteredEntries.push({
...fileOrDir,
children: filteredChildren,
})
}
} else if (filterFn(fileOrDir)) {
filteredEntries.push(fileOrDir)
}
}
return filteredEntries
}
export function deepFileFilterFlat(
entries: FileEntry[],
filterFn: (f: FileEntry) => boolean
): FileEntry[] {
const filteredEntries: FileEntry[] = []
for (const fileOrDir of entries) {
if ('children' in fileOrDir && fileOrDir.children !== undefined) {
const filteredChildren = deepFileFilterFlat(fileOrDir.children, filterFn)
if (filterFn(fileOrDir)) {
filteredEntries.push({
...fileOrDir,
children: filteredChildren,
})
}
filteredEntries.push(...filteredChildren)
} else if (filterFn(fileOrDir)) {
filteredEntries.push(fileOrDir)
}
}
return filteredEntries
}
// Read the contents of a project directory
// and return all relevant files and sub-directories recursively
export async function readProject(projectDir: string) {
const readFiles = await invoke<FileEntry[]>('read_dir_recursive', {
path: projectDir,
})
return deepFileFilter(readFiles, isRelevantFileOrDir)
}
// Given a read project, return the number of .kcl files,
// both in the root directory and in sub-directories,
// and folders that contain at least one .kcl file
export function getPartsCount(project: FileEntry[]) {
const flatProject = deepFileFilterFlat(project, isRelevantFileOrDir)
const kclFileCount = flatProject.filter((f) =>
f.name?.endsWith(FILE_EXT)
).length
const kclDirCount = flatProject.filter((f) => f.children !== undefined).length
return {
kclFileCount,
kclDirCount,
}
}
// Determines if a file or directory is relevant to the project
// i.e. not a hidden file or directory, and is a relevant file type
// or contains at least one relevant file (even if it's nested)
// or is a completely empty directory
export function isRelevantFileOrDir(fileOrDir: FileEntry) {
let isRelevantDir = false
if ('children' in fileOrDir && fileOrDir.children !== undefined) {
isRelevantDir =
!isHidden(fileOrDir) &&
(fileOrDir.children.some(isRelevantFileOrDir) ||
fileOrDir.children.length === 0)
}
const isRelevantFile =
!isHidden(fileOrDir) &&
RELEVANT_FILE_TYPES.some((ext) => fileOrDir.name?.endsWith(ext))
return (
(isDir(fileOrDir) && isRelevantDir) || (!isDir(fileOrDir) && isRelevantFile)
)
}
// Deeply sort the files and directories in a project like VS Code does: // Deeply sort the files and directories in a project like VS Code does:
// The main.kcl file is always first, then files, then directories // The main.kcl file is always first, then files, then directories
// Files and directories are sorted alphabetically // Files and directories are sorted alphabetically
@ -53,6 +279,47 @@ export function sortProject(project: FileEntry[]): FileEntry[] {
}) })
} }
// Creates a new file in the default directory with the default project name
// Returns the path to the new file
export async function createNewProject(
path: string,
initCode = ''
): Promise<ProjectWithEntryPointMetadata> {
if (!isTauri) {
throw new Error('createNewProject() can only be called from a Tauri app')
}
const dirExists = await exists(path)
if (!dirExists) {
await mkdir(path, { recursive: true }).catch((err) => {
console.error('Error creating new directory:', err)
throw err
})
}
await writeTextFile(await join(path, PROJECT_ENTRYPOINT), initCode).catch(
(err) => {
console.error('Error creating new file:', err)
throw err
}
)
const m = await stat(path)
return {
name: path.slice(path.lastIndexOf(sep()) + 1),
path: path,
entrypointMetadata: m,
children: [
{
name: PROJECT_ENTRYPOINT,
path: await join(path, PROJECT_ENTRYPOINT),
children: [],
},
],
}
}
// create a regex to match the project name // create a regex to match the project name
// replacing any instances of "$n" with a regex to match any number // replacing any instances of "$n" with a regex to match any number
function interpolateProjectName(projectName: string) { function interpolateProjectName(projectName: string) {
@ -106,6 +373,55 @@ function getPaddedIdentifierRegExp() {
return new RegExp(`${escapedIdentifier}(${escapedIdentifier.slice(-1)}*)`) return new RegExp(`${escapedIdentifier}(${escapedIdentifier.slice(-1)}*)`)
} }
export async function getUserSettingsFilePath(
filename: string = SETTINGS_FILE_EXT
) {
const dir = await appConfigDir()
return await join(dir, filename)
}
export async function readSettingsFile(
path: string
): Promise<Partial<SaveSettingsPayload>> {
const dir = path.slice(0, path.lastIndexOf(sep()))
const dirExists = await exists(dir)
if (!dirExists) {
await mkdir(dir, { recursive: true })
}
const settingsExist = dirExists ? await exists(path) : false
if (!settingsExist) {
console.log(`Settings file does not exist at ${path}`)
return {}
}
try {
await initPromise
const settings = await readTextFile(path)
// We expect the settings to be under a top-level [settings] key
return tomlParse(settings).settings as Partial<SaveSettingsPayload>
} catch (e) {
console.error('Error reading settings file:', e)
return {}
}
}
export async function getSettingsFilePaths(
projectPath?: string
): Promise<Partial<Record<SettingsLevel, string>>> {
const { user, project } = await getSettingsFolderPaths(projectPath)
return {
user: user + 'user' + SETTINGS_FILE_EXT,
project:
project !== undefined
? project + (isTauri() ? sep() : '/') + 'project' + SETTINGS_FILE_EXT
: undefined,
}
}
export async function getSettingsFolderPaths(projectPath?: string) { export async function getSettingsFolderPaths(projectPath?: string) {
const user = isTauri() ? await appConfigDir() : '/' const user = isTauri() ? await appConfigDir() : '/'
const project = projectPath !== undefined ? projectPath : undefined const project = projectPath !== undefined ? projectPath : undefined
@ -117,15 +433,18 @@ export async function getSettingsFolderPaths(projectPath?: string) {
} }
export async function createAndOpenNewProject( export async function createAndOpenNewProject(
projectDirectory: string,
navigate: (path: string) => void navigate: (path: string) => void
) { ) {
const configuration = await readAppSettingsFile() const projects = await getProjectsInDir(projectDirectory)
const projects = await listProjects(configuration) const nextIndex = await getNextProjectIndex(ONBOARDING_PROJECT_NAME, projects)
const nextIndex = getNextProjectIndex(ONBOARDING_PROJECT_NAME, projects)
const name = interpolateProjectNameWithIndex( const name = interpolateProjectNameWithIndex(
ONBOARDING_PROJECT_NAME, ONBOARDING_PROJECT_NAME,
nextIndex nextIndex
) )
const newFile = await createNewProjectDirectory(name, bracket, configuration) const newFile = await createNewProject(
await join(projectDirectory, name),
bracket
)
navigate(`${paths.FILE}/${encodeURIComponent(newFile.path)}`) navigate(`${paths.FILE}/${encodeURIComponent(newFile.path)}`)
} }

View File

@ -1,26 +1,9 @@
import { AppTheme } from 'wasm-lib/kcl/bindings/AppTheme'
export enum Themes { export enum Themes {
Light = 'light', Light = 'light',
Dark = 'dark', Dark = 'dark',
System = 'system', System = 'system',
} }
export function appThemeToTheme(
theme: AppTheme | undefined
): Themes | undefined {
switch (theme) {
case 'light':
return Themes.Light
case 'dark':
return Themes.Dark
case 'system':
return Themes.System
default:
return undefined
}
}
// Get the theme from the system settings manually // Get the theme from the system settings manually
export function getSystemTheme(): Exclude<Themes, 'system'> { export function getSystemTheme(): Exclude<Themes, 'system'> {
return typeof globalThis.window !== 'undefined' && return typeof globalThis.window !== 'undefined' &&

View File

@ -1,22 +1,35 @@
import { FileEntry } from 'wasm-lib/kcl/bindings/FileEntry' import { type FileInfo } from '@tauri-apps/plugin-fs'
import { Project } from 'wasm-lib/kcl/bindings/Project'
export type { FileEntry } from 'wasm-lib/kcl/bindings/FileEntry'
export type IndexLoaderData = { export type IndexLoaderData = {
code: string | null code: string | null
project?: Project project?: ProjectWithEntryPointMetadata
file?: FileEntry file?: FileEntry
} }
export type FileLoaderData = { export type FileLoaderData = {
code: string | null code: string | null
project?: FileEntry | Project project?: FileEntry | ProjectWithEntryPointMetadata
file?: FileEntry file?: FileEntry
} }
export type ProjectWithEntryPointMetadata = FileEntry & {
entrypointMetadata: FileInfo
}
export type HomeLoaderData = { export type HomeLoaderData = {
projects: Project[] projects: ProjectWithEntryPointMetadata[]
}
// From https://github.com/tauri-apps/tauri/blob/1.x/tooling/api/src/fs.ts#L159
// Removed from tauri v2
export interface FileEntry {
path: string
/**
* Name of the directory/file
* can be null if the path terminates with `..`
*/
name?: string
/** Children of this entry if it's a directory; null otherwise */
children?: FileEntry[]
} }
// From the very helpful @jcalz on StackOverflow: https://stackoverflow.com/a/58436959/22753272 // From the very helpful @jcalz on StackOverflow: https://stackoverflow.com/a/58436959/22753272

View File

@ -2,8 +2,8 @@ import { createMachine, assign } from 'xstate'
import { Models } from '@kittycad/lib' import { Models } from '@kittycad/lib'
import withBaseURL from '../lib/withBaseURL' import withBaseURL from '../lib/withBaseURL'
import { isTauri } from 'lib/isTauri' import { isTauri } from 'lib/isTauri'
import { invoke } from '@tauri-apps/api/core'
import { VITE_KC_API_BASE_URL } from 'env' import { VITE_KC_API_BASE_URL } from 'env'
import { getUser as getUserTauri } from 'lib/tauri'
const SKIP_AUTH = const SKIP_AUTH =
import.meta.env.VITE_KC_SKIP_AUTH === 'true' && import.meta.env.DEV import.meta.env.VITE_KC_SKIP_AUTH === 'true' && import.meta.env.DEV
@ -129,7 +129,10 @@ async function getUser(context: UserContext) {
}) })
.then((res) => res.json()) .then((res) => res.json())
.catch((err) => console.error('error from Browser getUser', err)) .catch((err) => console.error('error from Browser getUser', err))
: getUserTauri(context.token, VITE_KC_API_BASE_URL) : invoke<Models['User_type'] | Record<'error_code', unknown>>('get_user', {
token: context.token,
hostname: VITE_KC_API_BASE_URL,
}).catch((err) => console.error('error from Tauri getUser', err))
const user = await userPromise const user = await userPromise

View File

@ -1,6 +1,5 @@
import { assign, createMachine } from 'xstate' import { assign, createMachine } from 'xstate'
import type { FileEntry } from 'lib/types' import type { FileEntry, ProjectWithEntryPointMetadata } from 'lib/types'
import { Project } from 'wasm-lib/kcl/bindings/Project'
export const fileMachine = createMachine( export const fileMachine = createMachine(
{ {
@ -10,7 +9,7 @@ export const fileMachine = createMachine(
initial: 'Reading files', initial: 'Reading files',
context: { context: {
project: {} as Project, project: {} as ProjectWithEntryPointMetadata,
selectedDirectory: {} as FileEntry, selectedDirectory: {} as FileEntry,
}, },
@ -155,7 +154,7 @@ export const fileMachine = createMachine(
| { type: 'navigate'; data: { name: string } } | { type: 'navigate'; data: { name: string } }
| { | {
type: 'done.invoke.read-files' type: 'done.invoke.read-files'
data: Project data: ProjectWithEntryPointMetadata
} }
| { type: 'assign'; data: { [key: string]: any } } | { type: 'assign'; data: { [key: string]: any } }
| { type: 'Refresh' }, | { type: 'Refresh' },

View File

@ -1,6 +1,6 @@
import { assign, createMachine } from 'xstate' import { assign, createMachine } from 'xstate'
import { type ProjectWithEntryPointMetadata } from 'lib/types'
import { HomeCommandSchema } from 'lib/commandBarConfigs/homeCommandConfig' import { HomeCommandSchema } from 'lib/commandBarConfigs/homeCommandConfig'
import { Project } from 'wasm-lib/kcl/bindings/Project'
export const homeMachine = createMachine( export const homeMachine = createMachine(
{ {
@ -10,7 +10,7 @@ export const homeMachine = createMachine(
initial: 'Reading projects', initial: 'Reading projects',
context: { context: {
projects: [] as Project[], projects: [] as ProjectWithEntryPointMetadata[],
defaultProjectName: '', defaultProjectName: '',
defaultDirectory: '', defaultDirectory: '',
}, },
@ -145,7 +145,7 @@ export const homeMachine = createMachine(
| { type: 'navigate'; data: { name: string } } | { type: 'navigate'; data: { name: string } }
| { | {
type: 'done.invoke.read-projects' type: 'done.invoke.read-projects'
data: Project[] data: ProjectWithEntryPointMetadata[]
} }
| { type: 'assign'; data: { [key: string]: any } }, | { type: 'assign'; data: { [key: string]: any } },
}, },
@ -157,7 +157,7 @@ export const homeMachine = createMachine(
{ {
actions: { actions: {
setProjects: assign((_, event) => { setProjects: assign((_, event) => {
return { projects: event.data as Project[] } return { projects: event.data as ProjectWithEntryPointMetadata[] }
}), }),
}, },
} }

View File

@ -1,9 +1,11 @@
import { FormEvent, useEffect } from 'react' import { FormEvent, useEffect } from 'react'
import { remove, rename } from '@tauri-apps/plugin-fs' import { remove, rename } from '@tauri-apps/plugin-fs'
import { import {
createNewProject,
getNextProjectIndex, getNextProjectIndex,
interpolateProjectNameWithIndex, interpolateProjectNameWithIndex,
doesProjectNameNeedInterpolated, doesProjectNameNeedInterpolated,
getProjectsInDir,
} from '../lib/tauriFS' } from '../lib/tauriFS'
import { ActionButton } from '../components/ActionButton' import { ActionButton } from '../components/ActionButton'
import { faArrowDown, faPlus } from '@fortawesome/free-solid-svg-icons' import { faArrowDown, faPlus } from '@fortawesome/free-solid-svg-icons'
@ -12,7 +14,10 @@ import { AppHeader } from '../components/AppHeader'
import ProjectCard from '../components/ProjectCard' import ProjectCard from '../components/ProjectCard'
import { useLoaderData, useNavigate, useSearchParams } from 'react-router-dom' import { useLoaderData, useNavigate, useSearchParams } from 'react-router-dom'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { type HomeLoaderData } from 'lib/types' import {
type ProjectWithEntryPointMetadata,
type HomeLoaderData,
} from 'lib/types'
import Loading from '../components/Loading' import Loading from '../components/Loading'
import { useMachine } from '@xstate/react' import { useMachine } from '@xstate/react'
import { homeMachine } from '../machines/homeMachine' import { homeMachine } from '../machines/homeMachine'
@ -34,8 +39,6 @@ import { kclManager } from 'lib/singletons'
import { useLspContext } from 'components/LspProvider' import { useLspContext } from 'components/LspProvider'
import { useRefreshSettings } from 'hooks/useRefreshSettings' import { useRefreshSettings } from 'hooks/useRefreshSettings'
import { LowerRightControls } from 'components/LowerRightControls' import { LowerRightControls } from 'components/LowerRightControls'
import { Project } from 'wasm-lib/kcl/bindings/Project'
import { createNewProjectDirectory, listProjects } from 'lib/tauri'
// This route only opens in the Tauri desktop context for now, // This route only opens in the Tauri desktop context for now,
// as defined in Router.tsx, so we can use the Tauri APIs and types. // as defined in Router.tsx, so we can use the Tauri APIs and types.
@ -91,7 +94,7 @@ const Home = () => {
}, },
services: { services: {
readProjects: async (context: ContextFrom<typeof homeMachine>) => readProjects: async (context: ContextFrom<typeof homeMachine>) =>
listProjects(), getProjectsInDir(context.defaultDirectory),
createProject: async ( createProject: async (
context: ContextFrom<typeof homeMachine>, context: ContextFrom<typeof homeMachine>,
event: EventFrom<typeof homeMachine, 'Create project'> event: EventFrom<typeof homeMachine, 'Create project'>
@ -107,7 +110,7 @@ const Home = () => {
name = interpolateProjectNameWithIndex(name, nextIndex) name = interpolateProjectNameWithIndex(name, nextIndex)
} }
await createNewProjectDirectory(name) await createNewProject(await join(context.defaultDirectory, name))
return `Successfully created "${name}"` return `Successfully created "${name}"`
}, },
@ -178,7 +181,7 @@ const Home = () => {
async function handleRenameProject( async function handleRenameProject(
e: FormEvent<HTMLFormElement>, e: FormEvent<HTMLFormElement>,
project: Project project: ProjectWithEntryPointMetadata
) { ) {
const { newProjectName } = Object.fromEntries( const { newProjectName } = Object.fromEntries(
new FormData(e.target as HTMLFormElement) new FormData(e.target as HTMLFormElement)
@ -189,7 +192,7 @@ const Home = () => {
}) })
} }
async function handleDeleteProject(project: Project) { async function handleDeleteProject(project: ProjectWithEntryPointMetadata) {
send('Delete project', { data: { name: project.name || '' } }) send('Delete project', { data: { name: project.name || '' } })
} }
@ -281,7 +284,7 @@ const Home = () => {
icon={{ icon: faPlus, iconClassName: 'p-1 w-4' }} icon={{ icon: faPlus, iconClassName: 'p-1 w-4' }}
data-testid="home-new-file" data-testid="home-new-file"
> >
New project New file
</ActionButton> </ActionButton>
</> </>
)} )}

View File

@ -4,7 +4,9 @@ import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { Themes, getSystemTheme } from 'lib/theme' import { Themes, getSystemTheme } from 'lib/theme'
import { bracket } from 'lib/exampleKcl' import { bracket } from 'lib/exampleKcl'
import { import {
createNewProject,
getNextProjectIndex, getNextProjectIndex,
getProjectsInDir,
interpolateProjectNameWithIndex, interpolateProjectNameWithIndex,
} from 'lib/tauriFS' } from 'lib/tauriFS'
import { isTauri } from 'lib/isTauri' import { isTauri } from 'lib/isTauri'
@ -18,21 +20,33 @@ import {
ONBOARDING_PROJECT_NAME, ONBOARDING_PROJECT_NAME,
PROJECT_ENTRYPOINT, PROJECT_ENTRYPOINT,
} from 'lib/constants' } from 'lib/constants'
import { createNewProjectDirectory, listProjects } from 'lib/tauri'
function OnboardingWithNewFile() { function OnboardingWithNewFile() {
const navigate = useNavigate() const navigate = useNavigate()
const dismiss = useDismiss() const dismiss = useDismiss()
const next = useNextClick(onboardingPaths.INDEX) const next = useNextClick(onboardingPaths.INDEX)
const {
settings: {
context: {
app: { projectDirectory },
},
},
} = useSettingsAuthContext()
async function createAndOpenNewProject() { async function createAndOpenNewProject() {
const projects = await listProjects() const projects = await getProjectsInDir(projectDirectory.current)
const nextIndex = getNextProjectIndex(ONBOARDING_PROJECT_NAME, projects) const nextIndex = await getNextProjectIndex(
ONBOARDING_PROJECT_NAME,
projects
)
const name = interpolateProjectNameWithIndex( const name = interpolateProjectNameWithIndex(
ONBOARDING_PROJECT_NAME, ONBOARDING_PROJECT_NAME,
nextIndex nextIndex
) )
const newFile = await createNewProjectDirectory(name, bracket) const newFile = await createNewProject(
await join(projectDirectory.current, name),
bracket
)
navigate( navigate(
`${paths.FILE}/${encodeURIComponent( `${paths.FILE}/${encodeURIComponent(
await join(newFile.path, PROJECT_ENTRYPOINT) await join(newFile.path, PROJECT_ENTRYPOINT)

View File

@ -10,10 +10,15 @@ import { useHotkeys } from 'react-hotkeys-hook'
import { paths } from 'lib/paths' import { paths } from 'lib/paths'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { useDotDotSlash } from 'hooks/useDotDotSlash' import { useDotDotSlash } from 'hooks/useDotDotSlash'
import { createAndOpenNewProject, getSettingsFolderPaths } from 'lib/tauriFS' import {
createAndOpenNewProject,
getInitialDefaultDir,
getSettingsFolderPaths,
} from 'lib/tauriFS'
import { sep } from '@tauri-apps/api/path' import { sep } from '@tauri-apps/api/path'
import { isTauri } from 'lib/isTauri' import { isTauri } from 'lib/isTauri'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { invoke } from '@tauri-apps/api/core'
import React, { Fragment, useMemo, useRef, useState } from 'react' import React, { Fragment, useMemo, useRef, useState } from 'react'
import { Setting } from 'lib/settings/initialSettings' import { Setting } from 'lib/settings/initialSettings'
import decamelize from 'decamelize' import decamelize from 'decamelize'
@ -26,7 +31,6 @@ import {
shouldHideSetting, shouldHideSetting,
shouldShowSettingInput, shouldShowSettingInput,
} from 'lib/settings/settingsUtils' } from 'lib/settings/settingsUtils'
import { getInitialDefaultDir, showInFolder } from 'lib/tauri'
export const APP_VERSION = import.meta.env.PACKAGE_VERSION || 'unknown' export const APP_VERSION = import.meta.env.PACKAGE_VERSION || 'unknown'
@ -66,7 +70,7 @@ export const Settings = () => {
if (isFileSettings) { if (isFileSettings) {
navigate(dotDotSlash(1) + paths.ONBOARDING.INDEX) navigate(dotDotSlash(1) + paths.ONBOARDING.INDEX)
} else { } else {
createAndOpenNewProject(navigate) createAndOpenNewProject(context.app.projectDirectory.current, navigate)
} }
} }
@ -298,7 +302,9 @@ export const Settings = () => {
? decodeURIComponent(projectPath) ? decodeURIComponent(projectPath)
: undefined : undefined
) )
showInFolder(paths[settingsLevel]) void invoke('show_in_folder', {
path: paths[settingsLevel],
})
}} }}
icon={{ icon={{
icon: 'folder', icon: 'folder',

View File

@ -1,11 +1,11 @@
import { ActionButton } from '../components/ActionButton' import { ActionButton } from '../components/ActionButton'
import { isTauri } from '../lib/isTauri' import { isTauri } from '../lib/isTauri'
import { invoke } from '@tauri-apps/api/core'
import { VITE_KC_SITE_BASE_URL, VITE_KC_API_BASE_URL } from '../env' import { VITE_KC_SITE_BASE_URL, VITE_KC_API_BASE_URL } from '../env'
import { Themes, getSystemTheme } from '../lib/theme' import { Themes, getSystemTheme } from '../lib/theme'
import { paths } from 'lib/paths' import { paths } from 'lib/paths'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { APP_NAME } from 'lib/constants' import { APP_NAME } from 'lib/constants'
import { login } from 'lib/tauri'
const SignIn = () => { const SignIn = () => {
const { const {
@ -28,7 +28,9 @@ const SignIn = () => {
const signInTauri = async () => { const signInTauri = async () => {
// We want to invoke our command to login via device auth. // We want to invoke our command to login via device auth.
try { try {
const token: string = await login(VITE_KC_API_BASE_URL) const token: string = await invoke('login', {
host: VITE_KC_API_BASE_URL,
})
send({ type: 'Log in', token }) send({ type: 'Log in', token })
} catch (error) { } catch (error) {
console.error('Error with login button', error) console.error('Error with login button', error)

View File

@ -9,7 +9,6 @@ import {
import { enginelessExecutor } from './lib/testHelpers' import { enginelessExecutor } from './lib/testHelpers'
import { EngineCommandManager } from './lang/std/engineConnection' import { EngineCommandManager } from './lang/std/engineConnection'
import { KCLError } from './lang/errors' import { KCLError } from './lang/errors'
import { SidebarType } from 'components/ModelingSidebar/ModelingPanes'
export type ToolTip = export type ToolTip =
| 'lineTo' | 'lineTo'
@ -45,6 +44,14 @@ export const toolTips = [
'tangentialArcTo', 'tangentialArcTo',
] as any as ToolTip[] ] as any as ToolTip[]
export type PaneType =
| 'code'
| 'variables'
| 'debug'
| 'kclErrors'
| 'logs'
| 'lspMessages'
export interface StoreState { export interface StoreState {
mediaStream?: MediaStream mediaStream?: MediaStream
setMediaStream: (mediaStream: MediaStream) => void setMediaStream: (mediaStream: MediaStream) => void
@ -70,8 +77,8 @@ export interface StoreState {
showHomeMenu: boolean showHomeMenu: boolean
setHomeShowMenu: (showMenu: boolean) => void setHomeShowMenu: (showMenu: boolean) => void
openPanes: SidebarType[] openPanes: PaneType[]
setOpenPanes: (panes: SidebarType[]) => void setOpenPanes: (panes: PaneType[]) => void
homeMenuItems: { homeMenuItems: {
name: string name: string
path: string path: string

View File

@ -582,7 +582,7 @@ dependencies = [
"anstream", "anstream",
"anstyle", "anstyle",
"clap_lex", "clap_lex",
"strsim 0.11.0", "strsim",
"unicase", "unicase",
"unicode-width", "unicode-width",
] ]
@ -849,41 +849,6 @@ dependencies = [
"syn 2.0.60", "syn 2.0.60",
] ]
[[package]]
name = "darling"
version = "0.20.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54e36fcd13ed84ffdfda6f5be89b31287cbb80c439841fe69e04841435464391"
dependencies = [
"darling_core",
"darling_macro",
]
[[package]]
name = "darling_core"
version = "0.20.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c2cf1c23a687a1feeb728783b993c4e1ad83d99f351801977dd809b48d0a70f"
dependencies = [
"fnv",
"ident_case",
"proc-macro2",
"quote",
"strsim 0.10.0",
"syn 2.0.60",
]
[[package]]
name = "darling_macro"
version = "0.20.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a668eda54683121533a393014d8692171709ff57a7d61f187b6e782719f8933f"
dependencies = [
"darling_core",
"quote",
"syn 2.0.60",
]
[[package]] [[package]]
name = "dashmap" name = "dashmap"
version = "5.5.3" version = "5.5.3"
@ -962,7 +927,7 @@ dependencies = [
[[package]] [[package]]
name = "derive-docs" name = "derive-docs"
version = "0.1.17" version = "0.1.16"
dependencies = [ dependencies = [
"Inflector", "Inflector",
"anyhow", "anyhow",
@ -1699,12 +1664,6 @@ dependencies = [
"cc", "cc",
] ]
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]] [[package]]
name = "idna" name = "idna"
version = "0.5.0" version = "0.5.0"
@ -1895,7 +1854,7 @@ dependencies = [
[[package]] [[package]]
name = "kcl-lib" name = "kcl-lib"
version = "0.1.52" version = "0.1.50"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"approx 0.5.1", "approx 0.5.1",
@ -1936,13 +1895,11 @@ dependencies = [
"thiserror", "thiserror",
"tokio", "tokio",
"tokio-tungstenite", "tokio-tungstenite",
"toml",
"tower-lsp", "tower-lsp",
"ts-rs", "ts-rs",
"twenty-twenty", "twenty-twenty",
"url", "url",
"uuid", "uuid",
"validator",
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"web-sys", "web-sys",
@ -3764,12 +3721,6 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9557cb6521e8d009c51a8666f09356f4b817ba9ba0981a305bd86aee47bd35c" checksum = "e9557cb6521e8d009c51a8666f09356f4b817ba9ba0981a305bd86aee47bd35c"
[[package]]
name = "strsim"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
[[package]] [[package]]
name = "strsim" name = "strsim"
version = "0.11.0" version = "0.11.0"
@ -4386,7 +4337,6 @@ version = "7.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc2cae1fc5d05d47aa24b64f9a4f7cba24cdc9187a2084dd97ac57bef5eccae6" checksum = "fc2cae1fc5d05d47aa24b64f9a4f7cba24cdc9187a2084dd97ac57bef5eccae6"
dependencies = [ dependencies = [
"chrono",
"thiserror", "thiserror",
"ts-rs-macros", "ts-rs-macros",
"url", "url",
@ -4571,36 +4521,6 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "validator"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db79c75af171630a3148bd3e6d7c4f42b6a9a014c2945bc5ed0020cbb8d9478e"
dependencies = [
"idna",
"once_cell",
"regex",
"serde",
"serde_derive",
"serde_json",
"url",
"validator_derive",
]
[[package]]
name = "validator_derive"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55591299b7007f551ed1eb79a684af7672c19c3193fb9e0a31936987bb2438ec"
dependencies = [
"darling",
"once_cell",
"proc-macro-error",
"proc-macro2",
"quote",
"syn 2.0.60",
]
[[package]] [[package]]
name = "valuable" name = "valuable"
version = "0.1.0" version = "0.1.0"

View File

@ -1,7 +1,7 @@
[package] [package]
name = "derive-docs" name = "derive-docs"
description = "A tool for generating documentation from Rust derive macros" description = "A tool for generating documentation from Rust derive macros"
version = "0.1.17" version = "0.1.16"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
repository = "https://github.com/KittyCAD/modeling-app" repository = "https://github.com/KittyCAD/modeling-app"

View File

@ -775,11 +775,16 @@ fn generate_code_block_test(
if let Ok(addr) = std::env::var("LOCAL_ENGINE_ADDR") { if let Ok(addr) = std::env::var("LOCAL_ENGINE_ADDR") {
client.set_base_url(addr); client.set_base_url(addr);
} }
let ws = client
.modeling()
.commands_ws(None, None, None, None, None,None, Some(false))
.await.unwrap();
let tokens = crate::token::lexer(#code_block).unwrap(); let tokens = crate::token::lexer(#code_block).unwrap();
let parser = crate::parser::Parser::new(tokens); let parser = crate::parser::Parser::new(tokens);
let program = parser.ast().unwrap(); let program = parser.ast().unwrap();
let ctx = crate::executor::ExecutorContext::new(&client, Default::default()).await.unwrap(); let units = kittycad::types::UnitLength::Mm;
let ctx = crate::executor::ExecutorContext::new(ws, units.clone()).await.unwrap();
ctx.run(program, None).await.unwrap(); ctx.run(program, None).await.unwrap();

View File

@ -20,10 +20,16 @@ mod test_examples_show {
client.set_base_url(addr); client.set_base_url(addr);
} }
let ws = client
.modeling()
.commands_ws(None, None, None, None, None, None, Some(false))
.await
.unwrap();
let tokens = crate::token::lexer("This is another code block.\nyes sirrr.\nshow").unwrap(); let tokens = crate::token::lexer("This is another code block.\nyes sirrr.\nshow").unwrap();
let parser = crate::parser::Parser::new(tokens); let parser = crate::parser::Parser::new(tokens);
let program = parser.ast().unwrap(); let program = parser.ast().unwrap();
let ctx = crate::executor::ExecutorContext::new(&client, Default::default()) let units = kittycad::types::UnitLength::Mm;
let ctx = crate::executor::ExecutorContext::new(ws, units.clone())
.await .await
.unwrap(); .unwrap();
ctx.run(program, None).await.unwrap(); ctx.run(program, None).await.unwrap();
@ -91,10 +97,16 @@ mod test_examples_show {
client.set_base_url(addr); client.set_base_url(addr);
} }
let ws = client
.modeling()
.commands_ws(None, None, None, None, None, None, Some(false))
.await
.unwrap();
let tokens = crate::token::lexer("This is code.\nIt does other shit.\nshow").unwrap(); let tokens = crate::token::lexer("This is code.\nIt does other shit.\nshow").unwrap();
let parser = crate::parser::Parser::new(tokens); let parser = crate::parser::Parser::new(tokens);
let program = parser.ast().unwrap(); let program = parser.ast().unwrap();
let ctx = crate::executor::ExecutorContext::new(&client, Default::default()) let units = kittycad::types::UnitLength::Mm;
let ctx = crate::executor::ExecutorContext::new(ws, units.clone())
.await .await
.unwrap(); .unwrap();
ctx.run(program, None).await.unwrap(); ctx.run(program, None).await.unwrap();

View File

@ -20,10 +20,16 @@ mod test_examples_show {
client.set_base_url(addr); client.set_base_url(addr);
} }
let ws = client
.modeling()
.commands_ws(None, None, None, None, None, None, Some(false))
.await
.unwrap();
let tokens = crate::token::lexer("This is code.\nIt does other shit.\nshow").unwrap(); let tokens = crate::token::lexer("This is code.\nIt does other shit.\nshow").unwrap();
let parser = crate::parser::Parser::new(tokens); let parser = crate::parser::Parser::new(tokens);
let program = parser.ast().unwrap(); let program = parser.ast().unwrap();
let ctx = crate::executor::ExecutorContext::new(&client, Default::default()) let units = kittycad::types::UnitLength::Mm;
let ctx = crate::executor::ExecutorContext::new(ws, units.clone())
.await .await
.unwrap(); .unwrap();
ctx.run(program, None).await.unwrap(); ctx.run(program, None).await.unwrap();

View File

@ -20,11 +20,17 @@ mod test_examples_my_func {
client.set_base_url(addr); client.set_base_url(addr);
} }
let ws = client
.modeling()
.commands_ws(None, None, None, None, None, None, Some(false))
.await
.unwrap();
let tokens = let tokens =
crate::token::lexer("This is another code block.\nyes sirrr.\nmyFunc").unwrap(); crate::token::lexer("This is another code block.\nyes sirrr.\nmyFunc").unwrap();
let parser = crate::parser::Parser::new(tokens); let parser = crate::parser::Parser::new(tokens);
let program = parser.ast().unwrap(); let program = parser.ast().unwrap();
let ctx = crate::executor::ExecutorContext::new(&client, Default::default()) let units = kittycad::types::UnitLength::Mm;
let ctx = crate::executor::ExecutorContext::new(ws, units.clone())
.await .await
.unwrap(); .unwrap();
ctx.run(program, None).await.unwrap(); ctx.run(program, None).await.unwrap();
@ -92,10 +98,16 @@ mod test_examples_my_func {
client.set_base_url(addr); client.set_base_url(addr);
} }
let ws = client
.modeling()
.commands_ws(None, None, None, None, None, None, Some(false))
.await
.unwrap();
let tokens = crate::token::lexer("This is code.\nIt does other shit.\nmyFunc").unwrap(); let tokens = crate::token::lexer("This is code.\nIt does other shit.\nmyFunc").unwrap();
let parser = crate::parser::Parser::new(tokens); let parser = crate::parser::Parser::new(tokens);
let program = parser.ast().unwrap(); let program = parser.ast().unwrap();
let ctx = crate::executor::ExecutorContext::new(&client, Default::default()) let units = kittycad::types::UnitLength::Mm;
let ctx = crate::executor::ExecutorContext::new(ws, units.clone())
.await .await
.unwrap(); .unwrap();
ctx.run(program, None).await.unwrap(); ctx.run(program, None).await.unwrap();

View File

@ -21,11 +21,17 @@ mod test_examples_import {
client.set_base_url(addr); client.set_base_url(addr);
} }
let ws = client
.modeling()
.commands_ws(None, None, None, None, None, None, Some(false))
.await
.unwrap();
let tokens = let tokens =
crate::token::lexer("This is another code block.\nyes sirrr.\nimport").unwrap(); crate::token::lexer("This is another code block.\nyes sirrr.\nimport").unwrap();
let parser = crate::parser::Parser::new(tokens); let parser = crate::parser::Parser::new(tokens);
let program = parser.ast().unwrap(); let program = parser.ast().unwrap();
let ctx = crate::executor::ExecutorContext::new(&client, Default::default()) let units = kittycad::types::UnitLength::Mm;
let ctx = crate::executor::ExecutorContext::new(ws, units.clone())
.await .await
.unwrap(); .unwrap();
ctx.run(program, None).await.unwrap(); ctx.run(program, None).await.unwrap();
@ -94,10 +100,16 @@ mod test_examples_import {
client.set_base_url(addr); client.set_base_url(addr);
} }
let ws = client
.modeling()
.commands_ws(None, None, None, None, None, None, Some(false))
.await
.unwrap();
let tokens = crate::token::lexer("This is code.\nIt does other shit.\nimport").unwrap(); let tokens = crate::token::lexer("This is code.\nIt does other shit.\nimport").unwrap();
let parser = crate::parser::Parser::new(tokens); let parser = crate::parser::Parser::new(tokens);
let program = parser.ast().unwrap(); let program = parser.ast().unwrap();
let ctx = crate::executor::ExecutorContext::new(&client, Default::default()) let units = kittycad::types::UnitLength::Mm;
let ctx = crate::executor::ExecutorContext::new(ws, units.clone())
.await .await
.unwrap(); .unwrap();
ctx.run(program, None).await.unwrap(); ctx.run(program, None).await.unwrap();

View File

@ -20,11 +20,17 @@ mod test_examples_line_to {
client.set_base_url(addr); client.set_base_url(addr);
} }
let ws = client
.modeling()
.commands_ws(None, None, None, None, None, None, Some(false))
.await
.unwrap();
let tokens = let tokens =
crate::token::lexer("This is another code block.\nyes sirrr.\nlineTo").unwrap(); crate::token::lexer("This is another code block.\nyes sirrr.\nlineTo").unwrap();
let parser = crate::parser::Parser::new(tokens); let parser = crate::parser::Parser::new(tokens);
let program = parser.ast().unwrap(); let program = parser.ast().unwrap();
let ctx = crate::executor::ExecutorContext::new(&client, Default::default()) let units = kittycad::types::UnitLength::Mm;
let ctx = crate::executor::ExecutorContext::new(ws, units.clone())
.await .await
.unwrap(); .unwrap();
ctx.run(program, None).await.unwrap(); ctx.run(program, None).await.unwrap();
@ -92,10 +98,16 @@ mod test_examples_line_to {
client.set_base_url(addr); client.set_base_url(addr);
} }
let ws = client
.modeling()
.commands_ws(None, None, None, None, None, None, Some(false))
.await
.unwrap();
let tokens = crate::token::lexer("This is code.\nIt does other shit.\nlineTo").unwrap(); let tokens = crate::token::lexer("This is code.\nIt does other shit.\nlineTo").unwrap();
let parser = crate::parser::Parser::new(tokens); let parser = crate::parser::Parser::new(tokens);
let program = parser.ast().unwrap(); let program = parser.ast().unwrap();
let ctx = crate::executor::ExecutorContext::new(&client, Default::default()) let units = kittycad::types::UnitLength::Mm;
let ctx = crate::executor::ExecutorContext::new(ws, units.clone())
.await .await
.unwrap(); .unwrap();
ctx.run(program, None).await.unwrap(); ctx.run(program, None).await.unwrap();

View File

@ -20,10 +20,16 @@ mod test_examples_min {
client.set_base_url(addr); client.set_base_url(addr);
} }
let ws = client
.modeling()
.commands_ws(None, None, None, None, None, None, Some(false))
.await
.unwrap();
let tokens = crate::token::lexer("This is another code block.\nyes sirrr.\nmin").unwrap(); let tokens = crate::token::lexer("This is another code block.\nyes sirrr.\nmin").unwrap();
let parser = crate::parser::Parser::new(tokens); let parser = crate::parser::Parser::new(tokens);
let program = parser.ast().unwrap(); let program = parser.ast().unwrap();
let ctx = crate::executor::ExecutorContext::new(&client, Default::default()) let units = kittycad::types::UnitLength::Mm;
let ctx = crate::executor::ExecutorContext::new(ws, units.clone())
.await .await
.unwrap(); .unwrap();
ctx.run(program, None).await.unwrap(); ctx.run(program, None).await.unwrap();
@ -91,10 +97,16 @@ mod test_examples_min {
client.set_base_url(addr); client.set_base_url(addr);
} }
let ws = client
.modeling()
.commands_ws(None, None, None, None, None, None, Some(false))
.await
.unwrap();
let tokens = crate::token::lexer("This is code.\nIt does other shit.\nmin").unwrap(); let tokens = crate::token::lexer("This is code.\nIt does other shit.\nmin").unwrap();
let parser = crate::parser::Parser::new(tokens); let parser = crate::parser::Parser::new(tokens);
let program = parser.ast().unwrap(); let program = parser.ast().unwrap();
let ctx = crate::executor::ExecutorContext::new(&client, Default::default()) let units = kittycad::types::UnitLength::Mm;
let ctx = crate::executor::ExecutorContext::new(ws, units.clone())
.await .await
.unwrap(); .unwrap();
ctx.run(program, None).await.unwrap(); ctx.run(program, None).await.unwrap();

View File

@ -20,10 +20,16 @@ mod test_examples_show {
client.set_base_url(addr); client.set_base_url(addr);
} }
let ws = client
.modeling()
.commands_ws(None, None, None, None, None, None, Some(false))
.await
.unwrap();
let tokens = crate::token::lexer("This is code.\nIt does other shit.\nshow").unwrap(); let tokens = crate::token::lexer("This is code.\nIt does other shit.\nshow").unwrap();
let parser = crate::parser::Parser::new(tokens); let parser = crate::parser::Parser::new(tokens);
let program = parser.ast().unwrap(); let program = parser.ast().unwrap();
let ctx = crate::executor::ExecutorContext::new(&client, Default::default()) let units = kittycad::types::UnitLength::Mm;
let ctx = crate::executor::ExecutorContext::new(ws, units.clone())
.await .await
.unwrap(); .unwrap();
ctx.run(program, None).await.unwrap(); ctx.run(program, None).await.unwrap();

View File

@ -20,10 +20,16 @@ mod test_examples_import {
client.set_base_url(addr); client.set_base_url(addr);
} }
let ws = client
.modeling()
.commands_ws(None, None, None, None, None, None, Some(false))
.await
.unwrap();
let tokens = crate::token::lexer("This is code.\nIt does other shit.\nimport").unwrap(); let tokens = crate::token::lexer("This is code.\nIt does other shit.\nimport").unwrap();
let parser = crate::parser::Parser::new(tokens); let parser = crate::parser::Parser::new(tokens);
let program = parser.ast().unwrap(); let program = parser.ast().unwrap();
let ctx = crate::executor::ExecutorContext::new(&client, Default::default()) let units = kittycad::types::UnitLength::Mm;
let ctx = crate::executor::ExecutorContext::new(ws, units.clone())
.await .await
.unwrap(); .unwrap();
ctx.run(program, None).await.unwrap(); ctx.run(program, None).await.unwrap();

View File

@ -20,10 +20,16 @@ mod test_examples_import {
client.set_base_url(addr); client.set_base_url(addr);
} }
let ws = client
.modeling()
.commands_ws(None, None, None, None, None, None, Some(false))
.await
.unwrap();
let tokens = crate::token::lexer("This is code.\nIt does other shit.\nimport").unwrap(); let tokens = crate::token::lexer("This is code.\nIt does other shit.\nimport").unwrap();
let parser = crate::parser::Parser::new(tokens); let parser = crate::parser::Parser::new(tokens);
let program = parser.ast().unwrap(); let program = parser.ast().unwrap();
let ctx = crate::executor::ExecutorContext::new(&client, Default::default()) let units = kittycad::types::UnitLength::Mm;
let ctx = crate::executor::ExecutorContext::new(ws, units.clone())
.await .await
.unwrap(); .unwrap();
ctx.run(program, None).await.unwrap(); ctx.run(program, None).await.unwrap();

View File

@ -20,10 +20,16 @@ mod test_examples_import {
client.set_base_url(addr); client.set_base_url(addr);
} }
let ws = client
.modeling()
.commands_ws(None, None, None, None, None, None, Some(false))
.await
.unwrap();
let tokens = crate::token::lexer("This is code.\nIt does other shit.\nimport").unwrap(); let tokens = crate::token::lexer("This is code.\nIt does other shit.\nimport").unwrap();
let parser = crate::parser::Parser::new(tokens); let parser = crate::parser::Parser::new(tokens);
let program = parser.ast().unwrap(); let program = parser.ast().unwrap();
let ctx = crate::executor::ExecutorContext::new(&client, Default::default()) let units = kittycad::types::UnitLength::Mm;
let ctx = crate::executor::ExecutorContext::new(ws, units.clone())
.await .await
.unwrap(); .unwrap();
ctx.run(program, None).await.unwrap(); ctx.run(program, None).await.unwrap();

View File

@ -20,10 +20,16 @@ mod test_examples_show {
client.set_base_url(addr); client.set_base_url(addr);
} }
let ws = client
.modeling()
.commands_ws(None, None, None, None, None, None, Some(false))
.await
.unwrap();
let tokens = crate::token::lexer("This is code.\nIt does other shit.\nshow").unwrap(); let tokens = crate::token::lexer("This is code.\nIt does other shit.\nshow").unwrap();
let parser = crate::parser::Parser::new(tokens); let parser = crate::parser::Parser::new(tokens);
let program = parser.ast().unwrap(); let program = parser.ast().unwrap();
let ctx = crate::executor::ExecutorContext::new(&client, Default::default()) let units = kittycad::types::UnitLength::Mm;
let ctx = crate::executor::ExecutorContext::new(ws, units.clone())
.await .await
.unwrap(); .unwrap();
ctx.run(program, None).await.unwrap(); ctx.run(program, None).await.unwrap();

View File

@ -1,7 +1,7 @@
[package] [package]
name = "kcl-lib" name = "kcl-lib"
description = "KittyCAD Language implementation and tools" description = "KittyCAD Language implementation and tools"
version = "0.1.52" version = "0.1.50"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
repository = "https://github.com/KittyCAD/modeling-app" repository = "https://github.com/KittyCAD/modeling-app"
@ -19,7 +19,7 @@ chrono = "0.4.38"
clap = { version = "4.5.4", features = ["cargo", "derive", "env", "unicode"], optional = true } clap = { version = "4.5.4", features = ["cargo", "derive", "env", "unicode"], optional = true }
dashmap = "5.5.3" dashmap = "5.5.3"
databake = { version = "0.1.7", features = ["derive"] } databake = { version = "0.1.7", features = ["derive"] }
derive-docs = { version = "0.1.17", path = "../derive-docs" } derive-docs = { version = "0.1.16", path = "../derive-docs" }
form_urlencoded = "1.2.1" form_urlencoded = "1.2.1"
futures = { version = "0.3.30" } futures = { version = "0.3.30" }
git_rev = "0.1.0" git_rev = "0.1.0"
@ -37,11 +37,9 @@ serde = { version = "1.0.198", features = ["derive"] }
serde_json = "1.0.116" serde_json = "1.0.116"
sha2 = "0.10.8" sha2 = "0.10.8"
thiserror = "1.0.59" thiserror = "1.0.59"
toml = "0.8.12" ts-rs = { version = "7.1.1", features = ["uuid-impl", "url-impl"] }
ts-rs = { version = "7.1.1", features = ["uuid-impl", "url-impl", "chrono-impl"] }
url = { version = "2.5.0", features = ["serde"] } url = { version = "2.5.0", features = ["serde"] }
uuid = { version = "1.8.0", features = ["v4", "js", "serde"] } uuid = { version = "1.8.0", features = ["v4", "js", "serde"] }
validator = { version = "0.18.1", features = ["derive"] }
winnow = "0.5.40" winnow = "0.5.40"
zip = { version = "0.6.6", default-features = false } zip = { version = "0.6.6", default-features = false }

View File

@ -1000,103 +1000,21 @@ pub struct ExecutorContext {
pub engine: Arc<Box<dyn EngineManager>>, pub engine: Arc<Box<dyn EngineManager>>,
pub fs: Arc<FileManager>, pub fs: Arc<FileManager>,
pub stdlib: Arc<StdLib>, pub stdlib: Arc<StdLib>,
pub settings: ExecutorSettings, pub units: kittycad::types::UnitLength,
/// Mock mode is only for the modeling app when they just want to mock engine calls and not /// Mock mode is only for the modeling app when they just want to mock engine calls and not
/// actually make them. /// actually make them.
pub is_mock: bool, pub is_mock: bool,
} }
/// The executor settings.
#[derive(Debug, Clone)]
pub struct ExecutorSettings {
/// The unit to use in modeling dimensions.
pub units: crate::settings::types::UnitLength,
/// Highlight edges of 3D objects?
pub highlight_edges: bool,
/// Whether or not Screen Space Ambient Occlusion (SSAO) is enabled.
pub enable_ssao: bool,
}
impl Default for ExecutorSettings {
fn default() -> Self {
Self {
units: Default::default(),
highlight_edges: true,
enable_ssao: false,
}
}
}
impl From<crate::settings::types::Configuration> for ExecutorSettings {
fn from(config: crate::settings::types::Configuration) -> Self {
Self {
units: config.settings.modeling.base_unit,
highlight_edges: config.settings.modeling.highlight_edges.into(),
enable_ssao: config.settings.modeling.enable_ssao.into(),
}
}
}
impl From<crate::settings::types::project::ProjectConfiguration> for ExecutorSettings {
fn from(config: crate::settings::types::project::ProjectConfiguration) -> Self {
Self {
units: config.settings.modeling.base_unit,
highlight_edges: config.settings.modeling.highlight_edges.into(),
enable_ssao: config.settings.modeling.enable_ssao.into(),
}
}
}
impl From<crate::settings::types::ModelingSettings> for ExecutorSettings {
fn from(modeling: crate::settings::types::ModelingSettings) -> Self {
Self {
units: modeling.base_unit,
highlight_edges: modeling.highlight_edges.into(),
enable_ssao: modeling.enable_ssao.into(),
}
}
}
impl ExecutorContext { impl ExecutorContext {
/// Create a new default executor context. /// Create a new default executor context.
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
pub async fn new(client: &kittycad::Client, settings: ExecutorSettings) -> Result<Self> { pub async fn new(ws: reqwest::Upgraded, units: kittycad::types::UnitLength) -> Result<Self> {
let ws = client
.modeling()
.commands_ws(
None,
None,
if settings.enable_ssao {
Some(kittycad::types::PostEffectType::Ssao)
} else {
None
},
None,
None,
None,
Some(false),
)
.await?;
let engine: Arc<Box<dyn EngineManager>> =
Arc::new(Box::new(crate::engine::conn::EngineConnection::new(ws).await?));
// Set the edge visibility.
engine
.send_modeling_cmd(
uuid::Uuid::new_v4(),
SourceRange::default(),
kittycad::types::ModelingCmd::EdgeLinesVisible {
hidden: !settings.highlight_edges,
},
)
.await?;
Ok(Self { Ok(Self {
engine, engine: Arc::new(Box::new(crate::engine::conn::EngineConnection::new(ws).await?)),
fs: Arc::new(FileManager::new()), fs: Arc::new(FileManager::new()),
stdlib: Arc::new(StdLib::new()), stdlib: Arc::new(StdLib::new()),
settings, units,
is_mock: false, is_mock: false,
}) })
} }
@ -1115,7 +1033,7 @@ impl ExecutorContext {
uuid::Uuid::new_v4(), uuid::Uuid::new_v4(),
SourceRange::default(), SourceRange::default(),
kittycad::types::ModelingCmd::SetSceneUnits { kittycad::types::ModelingCmd::SetSceneUnits {
unit: self.settings.units.clone().into(), unit: self.units.clone(),
}, },
) )
.await?; .await?;
@ -1348,8 +1266,8 @@ impl ExecutorContext {
} }
/// Update the units for the executor. /// Update the units for the executor.
pub fn update_units(&mut self, units: crate::settings::types::UnitLength) { pub fn update_units(&mut self, units: kittycad::types::UnitLength) {
self.settings.units = units; self.units = units;
} }
} }
@ -1425,7 +1343,7 @@ mod tests {
engine: Arc::new(Box::new(crate::engine::conn_mock::EngineConnection::new().await?)), engine: Arc::new(Box::new(crate::engine::conn_mock::EngineConnection::new().await?)),
fs: Arc::new(crate::fs::FileManager::new()), fs: Arc::new(crate::fs::FileManager::new()),
stdlib: Arc::new(crate::std::StdLib::new()), stdlib: Arc::new(crate::std::StdLib::new()),
settings: Default::default(), units: kittycad::types::UnitLength::Mm,
is_mock: false, is_mock: false,
}; };
let memory = ctx.run(program, None).await?; let memory = ctx.run(program, None).await?;

View File

@ -13,7 +13,6 @@ pub mod executor;
pub mod fs; pub mod fs;
pub mod lsp; pub mod lsp;
pub mod parser; pub mod parser;
pub mod settings;
pub mod std; pub mod std;
pub mod thread; pub mod thread;
pub mod token; pub mod token;

View File

@ -3,8 +3,6 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tower_lsp::lsp_types::notification::Notification; use tower_lsp::lsp_types::notification::Notification;
use crate::settings::types::UnitLength;
/// A notification that the AST has changed. /// A notification that the AST has changed.
#[derive(Debug)] #[derive(Debug)]
pub enum AstUpdated {} pub enum AstUpdated {}
@ -32,6 +30,56 @@ pub struct TextDocumentIdentifier {
pub uri: url::Url, pub uri: url::Url,
} }
/// The valid types of length units.
#[derive(Debug, Eq, PartialEq, Clone, Deserialize, Serialize, ts_rs::TS)]
#[ts(export)]
pub enum UnitLength {
/// Centimeters <https://en.wikipedia.org/wiki/Centimeter>
#[serde(rename = "cm")]
Cm,
/// Feet <https://en.wikipedia.org/wiki/Foot_(unit)>
#[serde(rename = "ft")]
Ft,
/// Inches <https://en.wikipedia.org/wiki/Inch>
#[serde(rename = "in")]
In,
/// Meters <https://en.wikipedia.org/wiki/Meter>
#[serde(rename = "m")]
M,
/// Millimeters <https://en.wikipedia.org/wiki/Millimeter>
#[serde(rename = "mm")]
Mm,
/// Yards <https://en.wikipedia.org/wiki/Yard>
#[serde(rename = "yd")]
Yd,
}
impl From<kittycad::types::UnitLength> for UnitLength {
fn from(unit: kittycad::types::UnitLength) -> Self {
match unit {
kittycad::types::UnitLength::Cm => UnitLength::Cm,
kittycad::types::UnitLength::Ft => UnitLength::Ft,
kittycad::types::UnitLength::In => UnitLength::In,
kittycad::types::UnitLength::M => UnitLength::M,
kittycad::types::UnitLength::Mm => UnitLength::Mm,
kittycad::types::UnitLength::Yd => UnitLength::Yd,
}
}
}
impl From<UnitLength> for kittycad::types::UnitLength {
fn from(unit: UnitLength) -> Self {
match unit {
UnitLength::Cm => kittycad::types::UnitLength::Cm,
UnitLength::Ft => kittycad::types::UnitLength::Ft,
UnitLength::In => kittycad::types::UnitLength::In,
UnitLength::M => kittycad::types::UnitLength::M,
UnitLength::Mm => kittycad::types::UnitLength::Mm,
UnitLength::Yd => kittycad::types::UnitLength::Yd,
}
}
}
#[derive(Debug, Eq, PartialEq, Clone, Deserialize, Serialize, ts_rs::TS)] #[derive(Debug, Eq, PartialEq, Clone, Deserialize, Serialize, ts_rs::TS)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[ts(export)] #[ts(export)]

View File

@ -575,7 +575,8 @@ impl Backend {
false false
}; };
if executor_ctx.settings.units == params.units let units: kittycad::types::UnitLength = params.units.into();
if executor_ctx.units == units
&& !self.has_diagnostics(params.text_document.uri.as_ref()).await && !self.has_diagnostics(params.text_document.uri.as_ref()).await
&& has_memory && has_memory
{ {
@ -584,7 +585,7 @@ impl Backend {
} }
// Set the engine units. // Set the engine units.
executor_ctx.update_units(params.units); executor_ctx.update_units(units);
// Update the locked executor context. // Update the locked executor context.
self.set_executor_ctx(executor_ctx.clone()).await; self.set_executor_ctx(executor_ctx.clone()).await;

View File

@ -49,7 +49,11 @@ async fn kcl_lsp_server(execute: bool) -> Result<crate::lsp::kcl::Backend> {
let zoo_client = new_zoo_client(); let zoo_client = new_zoo_client();
let executor_ctx = if execute { let executor_ctx = if execute {
Some(crate::executor::ExecutorContext::new(&zoo_client, Default::default()).await?) let ws = zoo_client
.modeling()
.commands_ws(None, None, None, None, None, None, Some(false))
.await?;
Some(crate::executor::ExecutorContext::new(ws, kittycad::types::UnitLength::Mm).await?)
} else { } else {
None None
}; };
@ -1650,8 +1654,8 @@ const part001 = cube([0,0], 20)
// Make sure the memory is the same. // Make sure the memory is the same.
assert_eq!(memory, server.memory_map.get("file:///test.kcl").await.unwrap().clone()); assert_eq!(memory, server.memory_map.get("file:///test.kcl").await.unwrap().clone());
let units = server.executor_ctx.read().await.clone().unwrap().settings.units; let units = server.executor_ctx.read().await.clone().unwrap().units;
assert_eq!(units, crate::settings::types::UnitLength::Mm); assert_eq!(units, kittycad::types::UnitLength::Mm);
// Update the units. // Update the units.
server server
@ -1659,15 +1663,15 @@ const part001 = cube([0,0], 20)
text_document: crate::lsp::kcl::custom_notifications::TextDocumentIdentifier { text_document: crate::lsp::kcl::custom_notifications::TextDocumentIdentifier {
uri: "file:///test.kcl".try_into().unwrap(), uri: "file:///test.kcl".try_into().unwrap(),
}, },
units: crate::settings::types::UnitLength::M, units: crate::lsp::kcl::custom_notifications::UnitLength::M,
text: same_text.clone(), text: same_text.clone(),
}) })
.await .await
.unwrap(); .unwrap();
server.wait_on_handle().await; server.wait_on_handle().await;
let units = server.executor_ctx().await.unwrap().settings.units; let units = server.executor_ctx().await.unwrap().units;
assert_eq!(units, crate::settings::types::UnitLength::M); assert_eq!(units, kittycad::types::UnitLength::M);
// Make sure it forced a memory update. // Make sure it forced a memory update.
assert!(memory != server.memory_map.get("file:///test.kcl").await.unwrap().clone()); assert!(memory != server.memory_map.get("file:///test.kcl").await.unwrap().clone());
@ -2206,8 +2210,8 @@ async fn serial_test_kcl_lsp_code_and_ast_units_unchanged_but_has_diagnostics_re
let memory = server.memory_map.get("file:///test.kcl").await.unwrap().clone(); let memory = server.memory_map.get("file:///test.kcl").await.unwrap().clone();
assert_eq!(memory, ProgramMemory::default()); assert_eq!(memory, ProgramMemory::default());
let units = server.executor_ctx().await.unwrap().settings.units; let units = server.executor_ctx().await.unwrap().units;
assert_eq!(units, crate::settings::types::UnitLength::Mm); assert_eq!(units, kittycad::types::UnitLength::Mm);
// Update the units to the _same_ units. // Update the units to the _same_ units.
server server
@ -2215,15 +2219,15 @@ async fn serial_test_kcl_lsp_code_and_ast_units_unchanged_but_has_diagnostics_re
text_document: crate::lsp::kcl::custom_notifications::TextDocumentIdentifier { text_document: crate::lsp::kcl::custom_notifications::TextDocumentIdentifier {
uri: "file:///test.kcl".try_into().unwrap(), uri: "file:///test.kcl".try_into().unwrap(),
}, },
units: crate::settings::types::UnitLength::Mm, units: crate::lsp::kcl::custom_notifications::UnitLength::Mm,
text: code.to_string(), text: code.to_string(),
}) })
.await .await
.unwrap(); .unwrap();
server.wait_on_handle().await; server.wait_on_handle().await;
let units = server.executor_ctx().await.unwrap().settings.units; let units = server.executor_ctx().await.unwrap().units;
assert_eq!(units, crate::settings::types::UnitLength::Mm); assert_eq!(units, kittycad::types::UnitLength::Mm);
// Get the ast. // Get the ast.
let ast = server.ast_map.get("file:///test.kcl").await.unwrap().clone(); let ast = server.ast_map.get("file:///test.kcl").await.unwrap().clone();
@ -2291,8 +2295,8 @@ async fn serial_test_kcl_lsp_code_and_ast_units_unchanged_but_has_memory_reexecu
let memory = server.memory_map.get("file:///test.kcl").await.unwrap().clone(); let memory = server.memory_map.get("file:///test.kcl").await.unwrap().clone();
assert_eq!(memory, ProgramMemory::default()); assert_eq!(memory, ProgramMemory::default());
let units = server.executor_ctx().await.unwrap().settings.units; let units = server.executor_ctx().await.unwrap().units;
assert_eq!(units, crate::settings::types::UnitLength::Mm); assert_eq!(units, kittycad::types::UnitLength::Mm);
// Update the units to the _same_ units. // Update the units to the _same_ units.
server server
@ -2300,15 +2304,15 @@ async fn serial_test_kcl_lsp_code_and_ast_units_unchanged_but_has_memory_reexecu
text_document: crate::lsp::kcl::custom_notifications::TextDocumentIdentifier { text_document: crate::lsp::kcl::custom_notifications::TextDocumentIdentifier {
uri: "file:///test.kcl".try_into().unwrap(), uri: "file:///test.kcl".try_into().unwrap(),
}, },
units: crate::settings::types::UnitLength::Mm, units: crate::lsp::kcl::custom_notifications::UnitLength::Mm,
text: code.to_string(), text: code.to_string(),
}) })
.await .await
.unwrap(); .unwrap();
server.wait_on_handle().await; server.wait_on_handle().await;
let units = server.executor_ctx().await.unwrap().settings.units; let units = server.executor_ctx().await.unwrap().units;
assert_eq!(units, crate::settings::types::UnitLength::Mm); assert_eq!(units, kittycad::types::UnitLength::Mm);
// Get the ast. // Get the ast.
let ast = server.ast_map.get("file:///test.kcl").await.unwrap().clone(); let ast = server.ast_map.get("file:///test.kcl").await.unwrap().clone();
@ -2377,21 +2381,21 @@ async fn serial_test_kcl_lsp_cant_execute_set() {
assert_eq!(memory, ProgramMemory::default()); assert_eq!(memory, ProgramMemory::default());
// Update the units to the _same_ units. // Update the units to the _same_ units.
let units = server.executor_ctx().await.unwrap().settings.units; let units = server.executor_ctx().await.unwrap().units;
assert_eq!(units, crate::settings::types::UnitLength::Mm); assert_eq!(units, kittycad::types::UnitLength::Mm);
server server
.update_units(crate::lsp::kcl::custom_notifications::UpdateUnitsParams { .update_units(crate::lsp::kcl::custom_notifications::UpdateUnitsParams {
text_document: crate::lsp::kcl::custom_notifications::TextDocumentIdentifier { text_document: crate::lsp::kcl::custom_notifications::TextDocumentIdentifier {
uri: "file:///test.kcl".try_into().unwrap(), uri: "file:///test.kcl".try_into().unwrap(),
}, },
units: crate::settings::types::UnitLength::Mm, units: crate::lsp::kcl::custom_notifications::UnitLength::Mm,
text: code.to_string(), text: code.to_string(),
}) })
.await .await
.unwrap(); .unwrap();
server.wait_on_handle().await; server.wait_on_handle().await;
let units = server.executor_ctx().await.unwrap().settings.units; let units = server.executor_ctx().await.unwrap().units;
assert_eq!(units, crate::settings::types::UnitLength::Mm); assert_eq!(units, kittycad::types::UnitLength::Mm);
// Get the ast. // Get the ast.
let ast = server.ast_map.get("file:///test.kcl").await.unwrap().clone(); let ast = server.ast_map.get("file:///test.kcl").await.unwrap().clone();
@ -2427,21 +2431,21 @@ async fn serial_test_kcl_lsp_cant_execute_set() {
assert_eq!(server.can_execute().await, false); assert_eq!(server.can_execute().await, false);
// Update the units to the _same_ units. // Update the units to the _same_ units.
let units = server.executor_ctx().await.unwrap().settings.units; let units = server.executor_ctx().await.unwrap().units;
assert_eq!(units, crate::settings::types::UnitLength::Mm); assert_eq!(units, kittycad::types::UnitLength::Mm);
server server
.update_units(crate::lsp::kcl::custom_notifications::UpdateUnitsParams { .update_units(crate::lsp::kcl::custom_notifications::UpdateUnitsParams {
text_document: crate::lsp::kcl::custom_notifications::TextDocumentIdentifier { text_document: crate::lsp::kcl::custom_notifications::TextDocumentIdentifier {
uri: "file:///test.kcl".try_into().unwrap(), uri: "file:///test.kcl".try_into().unwrap(),
}, },
units: crate::settings::types::UnitLength::Mm, units: crate::lsp::kcl::custom_notifications::UnitLength::Mm,
text: code.to_string(), text: code.to_string(),
}) })
.await .await
.unwrap(); .unwrap();
server.wait_on_handle().await; server.wait_on_handle().await;
let units = server.executor_ctx().await.unwrap().settings.units; let units = server.executor_ctx().await.unwrap().units;
assert_eq!(units, crate::settings::types::UnitLength::Mm); assert_eq!(units, kittycad::types::UnitLength::Mm);
// Get the ast. // Get the ast.
let ast = server.ast_map.get("file:///test.kcl").await.unwrap().clone(); let ast = server.ast_map.get("file:///test.kcl").await.unwrap().clone();
@ -2468,21 +2472,21 @@ async fn serial_test_kcl_lsp_cant_execute_set() {
assert_eq!(server.can_execute().await, true); assert_eq!(server.can_execute().await, true);
// Update the units to the _same_ units. // Update the units to the _same_ units.
let units = server.executor_ctx.read().await.clone().unwrap().settings.units; let units = server.executor_ctx.read().await.clone().unwrap().units;
assert_eq!(units, crate::settings::types::UnitLength::Mm); assert_eq!(units, kittycad::types::UnitLength::Mm);
server server
.update_units(crate::lsp::kcl::custom_notifications::UpdateUnitsParams { .update_units(crate::lsp::kcl::custom_notifications::UpdateUnitsParams {
text_document: crate::lsp::kcl::custom_notifications::TextDocumentIdentifier { text_document: crate::lsp::kcl::custom_notifications::TextDocumentIdentifier {
uri: "file:///test.kcl".try_into().unwrap(), uri: "file:///test.kcl".try_into().unwrap(),
}, },
units: crate::settings::types::UnitLength::Mm, units: crate::lsp::kcl::custom_notifications::UnitLength::Mm,
text: code.to_string(), text: code.to_string(),
}) })
.await .await
.unwrap(); .unwrap();
server.wait_on_handle().await; server.wait_on_handle().await;
let units = server.executor_ctx.read().await.clone().unwrap().settings.units; let units = server.executor_ctx.read().await.clone().unwrap().units;
assert_eq!(units, crate::settings::types::UnitLength::Mm); assert_eq!(units, kittycad::types::UnitLength::Mm);
// Get the ast. // Get the ast.
let ast = server.ast_map.get("file:///test.kcl").await.unwrap().clone(); let ast = server.ast_map.get("file:///test.kcl").await.unwrap().clone();

View File

@ -1,5 +0,0 @@
//! This module contains settings for kcl projects as well as the modeling app.
pub mod types;
#[cfg(not(target_arch = "wasm32"))]
pub mod utils;

View File

@ -1,235 +0,0 @@
//! Types for interacting with files in projects.
use anyhow::Result;
use parse_display::{Display, FromStr};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
/// State management for the application.
#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq)]
#[ts(export)]
#[serde(rename_all = "snake_case")]
pub struct ProjectState {
pub project: Project,
pub current_file: Option<String>,
}
/// Information about project.
#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq)]
#[ts(export)]
#[serde(rename_all = "snake_case")]
pub struct Project {
#[serde(flatten)]
pub file: FileEntry,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metadata: Option<FileMetadata>,
#[serde(default)]
#[ts(type = "number")]
pub kcl_file_count: u64,
#[serde(default)]
#[ts(type = "number")]
pub directory_count: u64,
}
impl Project {
#[cfg(not(target_arch = "wasm32"))]
/// Populate a project from a path.
pub async fn from_path<P: AsRef<std::path::Path>>(path: P) -> Result<Self> {
// Check if they are using '.' as the path.
let path = if path.as_ref() == std::path::Path::new(".") {
std::env::current_dir()?
} else {
path.as_ref().to_path_buf()
};
// Make sure the path exists.
if !path.exists() {
return Err(anyhow::anyhow!("Path does not exist"));
}
let file = crate::settings::utils::walk_dir(&path).await?;
let metadata = std::fs::metadata(path).ok().map(|m| m.into());
let mut project = Self {
file,
metadata,
kcl_file_count: 0,
directory_count: 0,
};
project.populate_kcl_file_count()?;
project.populate_directory_count()?;
Ok(project)
}
/// Populate the number of KCL files in the project.
pub fn populate_kcl_file_count(&mut self) -> Result<()> {
let mut count = 0;
if let Some(children) = &self.file.children {
for entry in children.iter() {
if entry.name.ends_with(".kcl") {
count += 1;
} else {
count += entry.kcl_file_count();
}
}
}
self.kcl_file_count = count;
Ok(())
}
/// Populate the number of directories in the project.
pub fn populate_directory_count(&mut self) -> Result<()> {
let mut count = 0;
if let Some(children) = &self.file.children {
for entry in children.iter() {
count += entry.directory_count();
}
}
self.directory_count = count;
Ok(())
}
}
/// Information about a file or directory.
#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq)]
#[ts(export)]
#[serde(rename_all = "snake_case")]
pub struct FileEntry {
pub path: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub children: Option<Vec<FileEntry>>,
}
impl FileEntry {
/// Recursively get the number of kcl files in the file entry.
pub fn kcl_file_count(&self) -> u64 {
let mut count = 0;
if let Some(children) = &self.children {
for entry in children.iter() {
if entry.name.ends_with(".kcl") {
count += 1;
} else {
count += entry.kcl_file_count();
}
}
}
count
}
/// Recursively get the number of directories in the file entry.
pub fn directory_count(&self) -> u64 {
let mut count = 0;
if let Some(children) = &self.children {
for entry in children.iter() {
if entry.children.is_some() {
count += 1;
}
}
}
count
}
}
/// Metadata about a file or directory.
#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq)]
#[ts(export)]
#[serde(rename_all = "snake_case")]
pub struct FileMetadata {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub accessed: Option<chrono::DateTime<chrono::Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created: Option<chrono::DateTime<chrono::Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub r#type: Option<FileType>,
#[serde(default)]
#[ts(type = "number")]
pub size: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub modified: Option<chrono::DateTime<chrono::Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub permission: Option<FilePermission>,
}
/// The type of a file.
#[derive(Debug, Copy, Clone, Deserialize, Serialize, JsonSchema, Display, FromStr, ts_rs::TS, PartialEq, Eq)]
#[ts(export)]
#[serde(rename_all = "snake_case")]
#[display(style = "snake_case")]
pub enum FileType {
/// A file.
File,
/// A directory.
Directory,
/// A symbolic link.
Symlink,
}
/// The permissions of a file.
#[derive(Debug, Copy, Clone, Deserialize, Serialize, JsonSchema, Display, FromStr, ts_rs::TS, PartialEq, Eq)]
#[ts(export)]
#[serde(rename_all = "snake_case")]
#[display(style = "snake_case")]
pub enum FilePermission {
/// Read permission.
Read,
/// Write permission.
Write,
/// Execute permission.
Execute,
}
impl From<std::fs::FileType> for FileType {
fn from(file_type: std::fs::FileType) -> Self {
if file_type.is_file() {
FileType::File
} else if file_type.is_dir() {
FileType::Directory
} else if file_type.is_symlink() {
FileType::Symlink
} else {
unreachable!()
}
}
}
impl From<std::fs::Permissions> for FilePermission {
fn from(permissions: std::fs::Permissions) -> Self {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = permissions.mode();
if mode & 0o400 != 0 {
FilePermission::Read
} else if mode & 0o200 != 0 {
FilePermission::Write
} else if mode & 0o100 != 0 {
FilePermission::Execute
} else {
unreachable!()
}
}
#[cfg(not(unix))]
{
if permissions.readonly() {
FilePermission::Read
} else {
FilePermission::Write
}
}
}
}
impl From<std::fs::Metadata> for FileMetadata {
fn from(metadata: std::fs::Metadata) -> Self {
Self {
accessed: metadata.accessed().ok().map(|t| t.into()),
created: metadata.created().ok().map(|t| t.into()),
r#type: Some(metadata.file_type().into()),
size: metadata.len(),
modified: metadata.modified().ok().map(|t| t.into()),
permission: Some(metadata.permissions().into()),
}
}
}

View File

@ -1,929 +0,0 @@
//! Types for kcl project and modeling-app settings.
pub mod file;
pub mod project;
use anyhow::Result;
use parse_display::{Display, FromStr};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use validator::{Validate, ValidateRange};
const DEFAULT_THEME_COLOR: f64 = 264.5;
pub const DEFAULT_PROJECT_KCL_FILE: &str = "main.kcl";
const DEFAULT_PROJECT_NAME_TEMPLATE: &str = "project-$nnn";
/// High level configuration.
#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
#[ts(export)]
#[serde(rename_all = "snake_case")]
pub struct Configuration {
/// The settings for the modeling app.
#[serde(default, skip_serializing_if = "is_default")]
#[validate(nested)]
pub settings: Settings,
}
impl Configuration {
// TODO: remove this when we remove backwards compatibility with the old settings file.
pub fn backwards_compatible_toml_parse(toml_str: &str) -> Result<Self> {
let mut settings = toml::from_str::<Self>(toml_str)?;
if let Some(project_directory) = &settings.settings.app.project_directory {
if settings.settings.project.directory.to_string_lossy().is_empty() {
settings.settings.project.directory = project_directory.clone();
settings.settings.app.project_directory = None;
}
}
if let Some(theme) = &settings.settings.app.theme {
if settings.settings.app.appearance.theme == AppTheme::default() {
settings.settings.app.appearance.theme = *theme;
settings.settings.app.theme = None;
}
}
if let Some(theme_color) = &settings.settings.app.theme_color {
if settings.settings.app.appearance.color == AppColor::default() {
settings.settings.app.appearance.color = theme_color.clone().into();
settings.settings.app.theme_color = None;
}
}
if let Some(enable_ssao) = settings.settings.app.enable_ssao {
if settings.settings.modeling.enable_ssao.into() {
settings.settings.modeling.enable_ssao = enable_ssao.into();
settings.settings.app.enable_ssao = None;
}
}
settings.validate()?;
Ok(settings)
}
#[cfg(not(target_arch = "wasm32"))]
/// Initialize the project directory.
pub async fn ensure_project_directory_exists(&self) -> Result<std::path::PathBuf> {
let project_dir = &self.settings.project.directory;
// Check if the directory exists.
if !project_dir.exists() {
// Create the directory.
tokio::fs::create_dir_all(project_dir).await?;
}
Ok(project_dir.clone())
}
#[cfg(not(target_arch = "wasm32"))]
/// Create a new project directory.
pub async fn create_new_project_directory(
&self,
project_name: &str,
initial_code: Option<&str>,
) -> Result<crate::settings::types::file::Project> {
let main_dir = &self.ensure_project_directory_exists().await?;
if project_name.is_empty() {
return Err(anyhow::anyhow!("Project name cannot be empty."));
}
// Create the project directory.
let project_dir = main_dir.join(project_name);
// Create the directory.
if !project_dir.exists() {
tokio::fs::create_dir_all(&project_dir).await?;
}
// Write the initial project file.
let project_file = project_dir.join(DEFAULT_PROJECT_KCL_FILE);
tokio::fs::write(&project_file, initial_code.unwrap_or_default()).await?;
Ok(crate::settings::types::file::Project {
file: crate::settings::types::file::FileEntry {
path: project_dir.to_string_lossy().to_string(),
name: project_name.to_string(),
// We don't need to recursively get all files in the project directory.
// Because we just created it and it's empty.
children: None,
},
metadata: Some(tokio::fs::metadata(&project_dir).await?.into()),
kcl_file_count: 1,
directory_count: 0,
})
}
#[cfg(not(target_arch = "wasm32"))]
/// List all the projects for the configuration.
pub async fn list_projects(&self) -> Result<Vec<crate::settings::types::file::Project>> {
// Get all the top level directories in the project directory.
let main_dir = &self.ensure_project_directory_exists().await?;
let mut projects = vec![];
let mut entries = tokio::fs::read_dir(main_dir).await?;
while let Some(e) = entries.next_entry().await? {
if !e.file_type().await?.is_dir() {
// We don't care it's not a directory.
continue;
}
projects.push(self.get_project_info(&e.path().display().to_string()).await?);
}
Ok(projects)
}
#[cfg(not(target_arch = "wasm32"))]
/// Get information about a project.
pub async fn get_project_info(&self, project_path: &str) -> Result<crate::settings::types::file::Project> {
// Check the directory.
let project_dir = std::path::Path::new(project_path);
if !project_dir.exists() {
return Err(anyhow::anyhow!("Project directory does not exist: {}", project_path));
}
// Make sure it is a directory.
if !project_dir.is_dir() {
return Err(anyhow::anyhow!("Project path is not a directory: {}", project_path));
}
let mut project = crate::settings::types::file::Project {
file: crate::settings::utils::walk_dir(project_dir).await?,
metadata: Some(tokio::fs::metadata(&project_dir).await?.into()),
kcl_file_count: 0,
directory_count: 0,
};
// Populate the number of KCL files in the project.
project.populate_kcl_file_count()?;
//Populate the number of directories in the project.
project.populate_directory_count()?;
Ok(project)
}
}
/// High level settings.
#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
#[ts(export)]
#[serde(rename_all = "snake_case")]
pub struct Settings {
/// The settings for the modeling app.
#[serde(default, skip_serializing_if = "is_default")]
#[validate(nested)]
pub app: AppSettings,
/// Settings that affect the behavior while modeling.
#[serde(default, skip_serializing_if = "is_default")]
#[validate(nested)]
pub modeling: ModelingSettings,
/// Settings that affect the behavior of the KCL text editor.
#[serde(default, alias = "textEditor", skip_serializing_if = "is_default")]
#[validate(nested)]
pub text_editor: TextEditorSettings,
/// Settings that affect the behavior of project management.
#[serde(default, alias = "projects", skip_serializing_if = "is_default")]
#[validate(nested)]
pub project: ProjectSettings,
/// Settings that affect the behavior of the command bar.
#[serde(default, alias = "commandBar", skip_serializing_if = "is_default")]
#[validate(nested)]
pub command_bar: CommandBarSettings,
}
/// Application wide settings.
// TODO: When we remove backwards compatibility with the old settings file, we can remove the
// aliases to camelCase (and projects plural) from everywhere.
#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
#[ts(export)]
#[serde(rename_all = "snake_case")]
pub struct AppSettings {
/// The settings for the appearance of the app.
#[serde(default, skip_serializing_if = "is_default")]
#[validate(nested)]
pub appearance: AppearanceSettings,
/// The onboarding status of the app.
#[serde(default, alias = "onboardingStatus", skip_serializing_if = "is_default")]
pub onboarding_status: OnboardingStatus,
/// Backwards compatible project directory setting.
#[serde(default, alias = "projectDirectory", skip_serializing_if = "Option::is_none")]
pub project_directory: Option<std::path::PathBuf>,
/// Backwards compatible theme setting.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub theme: Option<AppTheme>,
/// The hue of the primary theme color for the app.
#[serde(default, skip_serializing_if = "Option::is_none", alias = "themeColor")]
pub theme_color: Option<FloatOrInt>,
/// Whether or not Screen Space Ambient Occlusion (SSAO) is enabled.
#[serde(default, alias = "enableSSAO", skip_serializing_if = "Option::is_none")]
pub enable_ssao: Option<bool>,
/// Permanently dismiss the banner warning to download the desktop app.
/// This setting only applies to the web app. And is temporary until we have Linux support.
#[serde(default, alias = "dismissWebBanner", skip_serializing_if = "is_default")]
pub dismiss_web_banner: bool,
}
// TODO: When we remove backwards compatibility with the old settings file, we can remove this.
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq)]
#[ts(export)]
#[serde(untagged)]
pub enum FloatOrInt {
String(String),
Float(f64),
Int(i64),
}
impl From<FloatOrInt> for f64 {
fn from(float_or_int: FloatOrInt) -> Self {
match float_or_int {
FloatOrInt::String(s) => s.parse().unwrap(),
FloatOrInt::Float(f) => f,
FloatOrInt::Int(i) => i as f64,
}
}
}
impl From<FloatOrInt> for AppColor {
fn from(float_or_int: FloatOrInt) -> Self {
match float_or_int {
FloatOrInt::String(s) => s.parse::<f64>().unwrap().into(),
FloatOrInt::Float(f) => f.into(),
FloatOrInt::Int(i) => (i as f64).into(),
}
}
}
/// The settings for the theme of the app.
#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
#[ts(export)]
#[serde(rename_all = "snake_case")]
pub struct AppearanceSettings {
/// The overall theme of the app.
#[serde(default, skip_serializing_if = "is_default")]
pub theme: AppTheme,
/// The hue of the primary theme color for the app.
#[serde(default, skip_serializing_if = "is_default")]
#[validate(nested)]
pub color: AppColor,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq)]
#[ts(export)]
#[serde(transparent)]
pub struct AppColor(pub f64);
impl Default for AppColor {
fn default() -> Self {
Self(DEFAULT_THEME_COLOR)
}
}
impl From<AppColor> for f64 {
fn from(color: AppColor) -> Self {
color.0
}
}
impl From<f64> for AppColor {
fn from(color: f64) -> Self {
Self(color)
}
}
impl Validate for AppColor {
fn validate(&self) -> Result<(), validator::ValidationErrors> {
if !self.0.validate_range(Some(0.0), None, None, Some(360.0)) {
let mut errors = validator::ValidationErrors::new();
let mut err = validator::ValidationError::new("color");
err.add_param(std::borrow::Cow::from("min"), &0.0);
err.add_param(std::borrow::Cow::from("exclusive_max"), &360.0);
errors.add("color", err);
return Err(errors);
}
Ok(())
}
}
/// The overall appearance of the app.
#[derive(
Debug, Default, Copy, Clone, Deserialize, Serialize, JsonSchema, Display, FromStr, ts_rs::TS, PartialEq, Eq,
)]
#[ts(export)]
#[serde(rename_all = "snake_case")]
#[display(style = "snake_case")]
pub enum AppTheme {
/// A light theme.
Light,
/// A dark theme.
Dark,
/// Use the system theme.
/// This will use dark theme if the system theme is dark, and light theme if the system theme is light.
#[default]
System,
}
impl From<AppTheme> for kittycad::types::Color {
fn from(theme: AppTheme) -> Self {
match theme {
AppTheme::Light => kittycad::types::Color {
r: 249.0 / 255.0,
g: 249.0 / 255.0,
b: 249.0 / 255.0,
a: 1.0,
},
AppTheme::Dark => kittycad::types::Color {
r: 28.0 / 255.0,
g: 28.0 / 255.0,
b: 28.0 / 255.0,
a: 1.0,
},
AppTheme::System => {
// TODO: Check the system setting for the user.
todo!()
}
}
}
}
/// Settings that affect the behavior while modeling.
#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
#[serde(rename_all = "snake_case")]
#[ts(export)]
pub struct ModelingSettings {
/// The default unit to use in modeling dimensions.
#[serde(default, alias = "defaultUnit", skip_serializing_if = "is_default")]
pub base_unit: UnitLength,
/// The controls for how to navigate the 3D view.
#[serde(default, alias = "mouseControls", skip_serializing_if = "is_default")]
pub mouse_controls: MouseControlType,
/// Highlight edges of 3D objects?
#[serde(default, alias = "highlightEdges", skip_serializing_if = "is_default")]
pub highlight_edges: DefaultTrue,
/// Whether to show the debug panel, which lets you see various states
/// of the app to aid in development.
#[serde(default, alias = "showDebugPanel", skip_serializing_if = "is_default")]
pub show_debug_panel: bool,
/// Whether or not Screen Space Ambient Occlusion (SSAO) is enabled.
#[serde(default, skip_serializing_if = "is_default")]
pub enable_ssao: DefaultTrue,
}
#[derive(Debug, Copy, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq)]
#[ts(export)]
#[serde(transparent)]
pub struct DefaultTrue(pub bool);
impl Default for DefaultTrue {
fn default() -> Self {
Self(true)
}
}
impl From<DefaultTrue> for bool {
fn from(default_true: DefaultTrue) -> Self {
default_true.0
}
}
impl From<bool> for DefaultTrue {
fn from(b: bool) -> Self {
Self(b)
}
}
/// The valid types of length units.
#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
#[ts(export)]
#[serde(rename_all = "lowercase")]
#[display(style = "lowercase")]
pub enum UnitLength {
/// Centimeters <https://en.wikipedia.org/wiki/Centimeter>
Cm,
/// Feet <https://en.wikipedia.org/wiki/Foot_(unit)>
Ft,
/// Inches <https://en.wikipedia.org/wiki/Inch>
In,
/// Meters <https://en.wikipedia.org/wiki/Meter>
M,
/// Millimeters <https://en.wikipedia.org/wiki/Millimeter>
#[default]
Mm,
/// Yards <https://en.wikipedia.org/wiki/Yard>
Yd,
}
impl From<kittycad::types::UnitLength> for UnitLength {
fn from(unit: kittycad::types::UnitLength) -> Self {
match unit {
kittycad::types::UnitLength::Cm => UnitLength::Cm,
kittycad::types::UnitLength::Ft => UnitLength::Ft,
kittycad::types::UnitLength::In => UnitLength::In,
kittycad::types::UnitLength::M => UnitLength::M,
kittycad::types::UnitLength::Mm => UnitLength::Mm,
kittycad::types::UnitLength::Yd => UnitLength::Yd,
}
}
}
impl From<UnitLength> for kittycad::types::UnitLength {
fn from(unit: UnitLength) -> Self {
match unit {
UnitLength::Cm => kittycad::types::UnitLength::Cm,
UnitLength::Ft => kittycad::types::UnitLength::Ft,
UnitLength::In => kittycad::types::UnitLength::In,
UnitLength::M => kittycad::types::UnitLength::M,
UnitLength::Mm => kittycad::types::UnitLength::Mm,
UnitLength::Yd => kittycad::types::UnitLength::Yd,
}
}
}
/// The types of controls for how to navigate the 3D view.
#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
#[ts(export)]
#[serde(rename_all = "snake_case")]
#[display(style = "snake_case")]
pub enum MouseControlType {
#[default]
#[display("kittycad")]
#[serde(rename = "kittycad", alias = "KittyCAD")]
KittyCad,
#[display("onshape")]
#[serde(rename = "onshape", alias = "OnShape")]
OnShape,
#[serde(alias = "Trackpad Friendly")]
TrackpadFriendly,
#[serde(alias = "Solidworks")]
Solidworks,
#[serde(alias = "NX")]
Nx,
#[serde(alias = "Creo")]
Creo,
#[display("autocad")]
#[serde(rename = "autocad", alias = "AutoCAD")]
AutoCad,
}
/// Settings that affect the behavior of the KCL text editor.
#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
#[serde(rename_all = "snake_case")]
#[ts(export)]
pub struct TextEditorSettings {
/// Whether to wrap text in the editor or overflow with scroll.
#[serde(default, alias = "textWrapping", skip_serializing_if = "is_default")]
pub text_wrapping: DefaultTrue,
/// Whether to make the cursor blink in the editor.
#[serde(default, alias = "blinkingCursor", skip_serializing_if = "is_default")]
pub blinking_cursor: DefaultTrue,
}
/// Settings that affect the behavior of project management.
#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
#[serde(rename_all = "snake_case")]
#[ts(export)]
pub struct ProjectSettings {
/// The directory to save and load projects from.
#[serde(default, skip_serializing_if = "is_default")]
pub directory: std::path::PathBuf,
/// The default project name to use when creating a new project.
#[serde(default, alias = "defaultProjectName", skip_serializing_if = "is_default")]
pub default_project_name: ProjectNameTemplate,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq)]
#[ts(export)]
#[serde(transparent)]
pub struct ProjectNameTemplate(pub String);
impl Default for ProjectNameTemplate {
fn default() -> Self {
Self(DEFAULT_PROJECT_NAME_TEMPLATE.to_string())
}
}
impl From<ProjectNameTemplate> for String {
fn from(project_name: ProjectNameTemplate) -> Self {
project_name.0
}
}
impl From<String> for ProjectNameTemplate {
fn from(s: String) -> Self {
Self(s)
}
}
/// Settings that affect the behavior of the command bar.
#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
#[serde(rename_all = "snake_case")]
#[ts(export)]
pub struct CommandBarSettings {
/// Whether to include settings in the command bar.
#[serde(default, alias = "includeSettings", skip_serializing_if = "is_default")]
pub include_settings: DefaultTrue,
}
/// The types of onboarding status.
#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
#[ts(export)]
#[serde(rename_all = "snake_case")]
#[display(style = "snake_case")]
pub enum OnboardingStatus {
/// The user has completed onboarding.
Completed,
/// The user has not completed onboarding.
#[default]
Incomplete,
/// The user has dismissed onboarding.
Dismissed,
// Routes
#[serde(rename = "/")]
#[display("/")]
Index,
#[serde(rename = "/camera")]
#[display("/camera")]
Camera,
#[serde(rename = "/streaming")]
#[display("/streaming")]
Streaming,
#[serde(rename = "/editor")]
#[display("/editor")]
Editor,
#[serde(rename = "/parametric-modeling")]
#[display("/parametric-modeling")]
ParametricModeling,
#[serde(rename = "/interactive-numbers")]
#[display("/interactive-numbers")]
InteractiveNumbers,
#[serde(rename = "/command-k")]
#[display("/command-k")]
CommandK,
#[serde(rename = "/user-menu")]
#[display("/user-menu")]
UserMenu,
#[serde(rename = "/project-menu")]
#[display("/project-menu")]
ProjectMenu,
#[serde(rename = "/export")]
#[display("/export")]
Export,
#[serde(rename = "/move")]
#[display("/move")]
Move,
#[serde(rename = "/sketching")]
#[display("/sketching")]
Sketching,
#[serde(rename = "/future-work")]
#[display("/future-work")]
FutureWork,
}
fn is_default<T: Default + PartialEq>(t: &T) -> bool {
t == &T::default()
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use validator::Validate;
use super::{
AppColor, AppSettings, AppTheme, AppearanceSettings, CommandBarSettings, Configuration, ModelingSettings,
OnboardingStatus, ProjectSettings, Settings, TextEditorSettings, UnitLength,
};
#[test]
// Test that we can deserialize a project file from the old format.
// TODO: We can remove this functionality after a few versions.
fn test_backwards_compatible_project_settings_file_pw() {
let old_project_file = r#"[settings.app]
theme = "dark"
onboardingStatus = "dismissed"
projectDirectory = ""
enableSSAO = false
[settings.modeling]
defaultUnit = "in"
mouseControls = "KittyCAD"
showDebugPanel = true
[settings.projects]
defaultProjectName = "project-$nnn"
[settings.textEditor]
textWrapping = true
#"#;
//let parsed = toml::from_str::<Configuration(old_project_file).unwrap();
let parsed = Configuration::backwards_compatible_toml_parse(old_project_file).unwrap();
assert_eq!(
parsed,
Configuration {
settings: Settings {
app: AppSettings {
appearance: AppearanceSettings {
theme: AppTheme::Dark,
color: Default::default(),
},
onboarding_status: OnboardingStatus::Dismissed,
project_directory: None,
theme: None,
theme_color: None,
dismiss_web_banner: false,
enable_ssao: None,
},
modeling: ModelingSettings {
base_unit: UnitLength::In,
mouse_controls: Default::default(),
highlight_edges: Default::default(),
show_debug_panel: true,
enable_ssao: false.into(),
},
text_editor: TextEditorSettings {
text_wrapping: true.into(),
blinking_cursor: true.into(),
},
project: Default::default(),
command_bar: CommandBarSettings {
include_settings: true.into(),
},
}
}
);
}
#[test]
// Test that we can deserialize a project file from the old format.
// TODO: We can remove this functionality after a few versions.
fn test_backwards_compatible_project_settings_file() {
let old_project_file = r#"[settings.app]
theme = "dark"
themeColor = "138"
[settings.modeling]
defaultUnit = "yd"
showDebugPanel = true
[settings.textEditor]
textWrapping = false
blinkingCursor = false
[settings.commandBar]
includeSettings = false
#"#;
//let parsed = toml::from_str::<Configuration(old_project_file).unwrap();
let parsed = Configuration::backwards_compatible_toml_parse(old_project_file).unwrap();
assert_eq!(
parsed,
Configuration {
settings: Settings {
app: AppSettings {
appearance: AppearanceSettings {
theme: AppTheme::Dark,
color: 138.0.into(),
},
onboarding_status: Default::default(),
project_directory: None,
theme: None,
theme_color: None,
dismiss_web_banner: false,
enable_ssao: None,
},
modeling: ModelingSettings {
base_unit: UnitLength::Yd,
mouse_controls: Default::default(),
highlight_edges: Default::default(),
show_debug_panel: true,
enable_ssao: true.into(),
},
text_editor: TextEditorSettings {
text_wrapping: false.into(),
blinking_cursor: false.into(),
},
project: Default::default(),
command_bar: CommandBarSettings {
include_settings: false.into(),
},
}
}
);
}
#[test]
// Test that we can deserialize a app settings file from the old format.
// TODO: We can remove this functionality after a few versions.
fn test_backwards_compatible_app_settings_file() {
let old_app_settings_file = r#"[settings.app]
onboardingStatus = "dismissed"
projectDirectory = "/Users/macinatormax/Documents/kittycad-modeling-projects"
theme = "dark"
themeColor = "138"
[settings.modeling]
defaultUnit = "yd"
showDebugPanel = true
[settings.textEditor]
textWrapping = false
blinkingCursor = false
[settings.commandBar]
includeSettings = false
[settings.projects]
defaultProjectName = "projects-$nnn"
#"#;
//let parsed = toml::from_str::<Configuration>(old_app_settings_file).unwrap();
let parsed = Configuration::backwards_compatible_toml_parse(old_app_settings_file).unwrap();
assert_eq!(
parsed,
Configuration {
settings: Settings {
app: AppSettings {
appearance: AppearanceSettings {
theme: AppTheme::Dark,
color: 138.0.into(),
},
onboarding_status: OnboardingStatus::Dismissed,
project_directory: None,
theme: None,
theme_color: None,
dismiss_web_banner: false,
enable_ssao: None,
},
modeling: ModelingSettings {
base_unit: UnitLength::Yd,
mouse_controls: Default::default(),
highlight_edges: Default::default(),
show_debug_panel: true,
enable_ssao: true.into(),
},
text_editor: TextEditorSettings {
text_wrapping: false.into(),
blinking_cursor: false.into(),
},
project: ProjectSettings {
directory: "/Users/macinatormax/Documents/kittycad-modeling-projects".into(),
default_project_name: "projects-$nnn".to_string().into(),
},
command_bar: CommandBarSettings {
include_settings: false.into(),
},
}
}
);
// Write the file back out.
let serialized = toml::to_string(&parsed).unwrap();
assert_eq!(
serialized,
r#"[settings.app]
onboarding_status = "dismissed"
[settings.app.appearance]
theme = "dark"
color = 138.0
[settings.modeling]
base_unit = "yd"
show_debug_panel = true
[settings.text_editor]
text_wrapping = false
blinking_cursor = false
[settings.project]
directory = "/Users/macinatormax/Documents/kittycad-modeling-projects"
default_project_name = "projects-$nnn"
[settings.command_bar]
include_settings = false
"#
);
}
#[test]
fn test_settings_backwards_compat_partial() {
let partial_settings_file = r#"[settings.app]
onboardingStatus = "dismissed"
projectDirectory = "/Users/macinatormax/Documents/kittycad-modeling-projects""#;
//let parsed = toml::from_str::<Configuration>(partial_settings_file).unwrap();
let parsed = Configuration::backwards_compatible_toml_parse(partial_settings_file).unwrap();
assert_eq!(
parsed,
Configuration {
settings: Settings {
app: AppSettings {
appearance: AppearanceSettings {
theme: AppTheme::System,
color: Default::default(),
},
onboarding_status: OnboardingStatus::Dismissed,
project_directory: None,
theme: None,
theme_color: None,
dismiss_web_banner: false,
enable_ssao: None,
},
modeling: ModelingSettings {
base_unit: UnitLength::Mm,
mouse_controls: Default::default(),
highlight_edges: true.into(),
show_debug_panel: false,
enable_ssao: true.into(),
},
text_editor: TextEditorSettings {
text_wrapping: true.into(),
blinking_cursor: true.into(),
},
project: ProjectSettings {
directory: "/Users/macinatormax/Documents/kittycad-modeling-projects".into(),
default_project_name: "project-$nnn".to_string().into(),
},
command_bar: CommandBarSettings {
include_settings: true.into()
},
}
}
);
// Write the file back out.
let serialized = toml::to_string(&parsed).unwrap();
assert_eq!(
serialized,
r#"[settings.app]
onboarding_status = "dismissed"
[settings.project]
directory = "/Users/macinatormax/Documents/kittycad-modeling-projects"
"#
);
}
#[test]
fn test_settings_empty_file_parses() {
let empty_settings_file = r#""#;
let parsed = toml::from_str::<Configuration>(empty_settings_file).unwrap();
assert_eq!(parsed, Configuration::default());
// Write the file back out.
let serialized = toml::to_string(&parsed).unwrap();
assert_eq!(serialized, r#""#);
let parsed = Configuration::backwards_compatible_toml_parse(empty_settings_file).unwrap();
assert_eq!(parsed, Configuration::default());
}
#[test]
fn test_color_validation() {
let color = AppColor(360.0);
let result = color.validate();
if let Ok(r) = result {
panic!("Expected an error, but got success: {:?}", r);
}
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("color: Validation error: color"));
let appearance = AppearanceSettings {
theme: AppTheme::System,
color: AppColor(361.5),
};
let result = appearance.validate();
if let Ok(r) = result {
panic!("Expected an error, but got success: {:?}", r);
}
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("color: Validation error: color"));
}
#[test]
fn test_settings_color_validation_error() {
let settings_file = r#"[settings.app.appearance]
color = 1567.4"#;
let result = Configuration::backwards_compatible_toml_parse(settings_file);
if let Ok(r) = result {
panic!("Expected an error, but got success: {:?}", r);
}
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("color: Validation error: color"));
}
}

View File

@ -1,187 +0,0 @@
//! Types specific for modeling-app projects.
use anyhow::Result;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use validator::Validate;
use crate::settings::types::{
AppColor, AppSettings, AppTheme, CommandBarSettings, ModelingSettings, TextEditorSettings,
};
/// High level project configuration.
#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
#[ts(export)]
#[serde(rename_all = "snake_case")]
pub struct ProjectConfiguration {
/// The settings for the project.
#[serde(default)]
#[validate(nested)]
pub settings: PerProjectSettings,
}
impl ProjectConfiguration {
// TODO: remove this when we remove backwards compatibility with the old settings file.
pub fn backwards_compatible_toml_parse(toml_str: &str) -> Result<Self> {
let mut settings = toml::from_str::<Self>(toml_str)?;
settings.settings.app.project_directory = None;
if let Some(theme) = &settings.settings.app.theme {
if settings.settings.app.appearance.theme == AppTheme::default() {
settings.settings.app.appearance.theme = *theme;
settings.settings.app.theme = None;
}
}
if let Some(theme_color) = &settings.settings.app.theme_color {
if settings.settings.app.appearance.color == AppColor::default() {
settings.settings.app.appearance.color = theme_color.clone().into();
settings.settings.app.theme_color = None;
}
}
if let Some(enable_ssao) = settings.settings.app.enable_ssao {
if settings.settings.modeling.enable_ssao.into() {
settings.settings.modeling.enable_ssao = enable_ssao.into();
settings.settings.app.enable_ssao = None;
}
}
settings.validate()?;
Ok(settings)
}
}
/// High level project settings.
#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
#[ts(export)]
#[serde(rename_all = "snake_case")]
pub struct PerProjectSettings {
/// The settings for the modeling app.
#[serde(default)]
#[validate(nested)]
pub app: AppSettings,
/// Settings that affect the behavior while modeling.
#[serde(default)]
#[validate(nested)]
pub modeling: ModelingSettings,
/// Settings that affect the behavior of the KCL text editor.
#[serde(default, alias = "textEditor")]
#[validate(nested)]
pub text_editor: TextEditorSettings,
/// Settings that affect the behavior of the command bar.
#[serde(default, alias = "commandBar")]
#[validate(nested)]
pub command_bar: CommandBarSettings,
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use super::{
AppSettings, AppTheme, CommandBarSettings, ModelingSettings, PerProjectSettings, ProjectConfiguration,
TextEditorSettings,
};
use crate::settings::types::{AppearanceSettings, UnitLength};
#[test]
// Test that we can deserialize a project file from the old format.
// TODO: We can remove this functionality after a few versions.
fn test_backwards_compatible_project_settings_file() {
let old_project_file = r#"[settings.app]
theme = "dark"
themeColor = "138"
[settings.modeling]
defaultUnit = "yd"
showDebugPanel = true
[settings.textEditor]
textWrapping = false
blinkingCursor = false
[settings.commandBar]
includeSettings = false
#"#;
//let parsed = toml::from_str::<ProjectConfiguration(old_project_file).unwrap();
let parsed = ProjectConfiguration::backwards_compatible_toml_parse(old_project_file).unwrap();
assert_eq!(
parsed,
ProjectConfiguration {
settings: PerProjectSettings {
app: AppSettings {
appearance: AppearanceSettings {
theme: AppTheme::Dark,
color: 138.0.into(),
},
onboarding_status: Default::default(),
project_directory: None,
theme: None,
theme_color: None,
dismiss_web_banner: false,
enable_ssao: None,
},
modeling: ModelingSettings {
base_unit: UnitLength::Yd,
mouse_controls: Default::default(),
highlight_edges: Default::default(),
show_debug_panel: true,
enable_ssao: true.into(),
},
text_editor: TextEditorSettings {
text_wrapping: false.into(),
blinking_cursor: false.into(),
},
command_bar: CommandBarSettings {
include_settings: false.into(),
},
}
}
);
}
#[test]
fn test_project_settings_empty_file_parses() {
let empty_settings_file = r#""#;
let parsed = toml::from_str::<ProjectConfiguration>(empty_settings_file).unwrap();
assert_eq!(parsed, ProjectConfiguration::default());
// Write the file back out.
let serialized = toml::to_string(&parsed).unwrap();
assert_eq!(
serialized,
r#"[settings.app]
[settings.modeling]
[settings.text_editor]
[settings.command_bar]
"#
);
let parsed = ProjectConfiguration::backwards_compatible_toml_parse(empty_settings_file).unwrap();
assert_eq!(parsed, ProjectConfiguration::default());
}
#[test]
fn test_project_settings_color_validation_error() {
let settings_file = r#"[settings.app.appearance]
color = 1567.4"#;
let result = ProjectConfiguration::backwards_compatible_toml_parse(settings_file);
if let Ok(r) = result {
panic!("Expected an error, but got success: {:?}", r);
}
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("color: Validation error: color"));
}
}

View File

@ -1,42 +0,0 @@
//! Utility functions for settings.
use std::path::Path;
use anyhow::Result;
use crate::settings::types::file::FileEntry;
/// Walk a directory recursively and return a list of all files.
#[async_recursion::async_recursion]
pub async fn walk_dir<P: AsRef<Path> + Send>(dir: P) -> Result<FileEntry> {
let mut entry = FileEntry {
name: dir
.as_ref()
.file_name()
.ok_or_else(|| anyhow::anyhow!("No file name"))?
.to_string_lossy()
.to_string(),
path: dir.as_ref().display().to_string(),
children: None,
};
let mut children = vec![];
let mut entries = tokio::fs::read_dir(&dir.as_ref()).await?;
while let Some(e) = entries.next_entry().await? {
if e.file_type().await?.is_dir() {
children.push(walk_dir(&e.path()).await?);
} else {
children.push(FileEntry {
name: e.file_name().to_string_lossy().to_string(),
path: e.path().display().to_string(),
children: None,
});
}
}
// We don't set this to none if there are no children, because it's a directory.
entry.children = Some(children);
Ok(entry)
}

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