diff --git a/.github/workflows/ci-sync.yml b/.github/workflows/ci-sync.yml index 413b6e2aa..06e2bf5a5 100644 --- a/.github/workflows/ci-sync.yml +++ b/.github/workflows/ci-sync.yml @@ -19,34 +19,51 @@ jobs: runs-on: ubuntu-latest steps: + - name: Check for cross-repo token + id: token-check + run: | + if [ -z "$TOKEN" ]; then + echo "::warning::CROSS_REPO_TOKEN is not set. Skipping sync check (expected on fork PRs)." + echo "skip=true" >> "$GITHUB_OUTPUT" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + env: + TOKEN: ${{ secrets.CROSS_REPO_TOKEN }} + - name: Checkout editor repo + if: steps.token-check.outputs.skip == 'false' uses: actions/checkout@v4 with: path: editor - name: Checkout web repo (target branch) + if: steps.token-check.outputs.skip == 'false' uses: actions/checkout@v4 with: repository: Autonomy-Logic/openplc-web ref: ${{ github.event.pull_request.base.ref }} path: web + token: ${{ secrets.CROSS_REPO_TOKEN }} continue-on-error: true id: checkout-web-target - name: Checkout web repo (main fallback) - if: steps.checkout-web-target.outcome == 'failure' + if: steps.token-check.outputs.skip == 'false' && steps.checkout-web-target.outcome == 'failure' uses: actions/checkout@v4 with: repository: Autonomy-Logic/openplc-web ref: main path: web + token: ${{ secrets.CROSS_REPO_TOKEN }} - name: Warn about branch fallback - if: steps.checkout-web-target.outcome == 'failure' + if: steps.token-check.outputs.skip == 'false' && steps.checkout-web-target.outcome == 'failure' run: | echo "::warning::Web repo does not have branch '${{ github.event.pull_request.base.ref }}'. Fell back to 'main'." - name: Compare surfaces against target branch + if: steps.token-check.outputs.skip == 'false' id: compare-target run: | set +e @@ -74,10 +91,10 @@ jobs: fi - name: Find matching web PRs - if: steps.compare-target.outputs.match == 'False' + if: steps.token-check.outputs.skip == 'false' && steps.compare-target.outputs.match == 'False' id: find-prs env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ secrets.CROSS_REPO_TOKEN }} run: | BASE_BRANCH="${{ github.event.pull_request.base.ref }}" @@ -101,7 +118,7 @@ jobs: fi - name: Check web PRs for sync - if: steps.compare-target.outputs.match == 'False' && steps.find-prs.outputs.pr_count != '0' + if: steps.token-check.outputs.skip == 'false' && steps.compare-target.outputs.match == 'False' && steps.find-prs.outputs.pr_count != '0' id: check-prs run: | PR_JSON=$(cat /tmp/web-prs.json) @@ -147,7 +164,7 @@ jobs: done - name: Report result - if: always() + if: always() && steps.token-check.outputs.skip == 'false' run: | MATCH="${{ steps.compare-target.outputs.match }}" MATCHED_PR="${{ steps.check-prs.outputs.matched_pr }}" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bbe6e17bb..c3351cc16 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,15 +3,7 @@ name: Test run-name: OpenPLC CI pipeline - Unit test on: - push: - branches: - - main - - development - pull_request: - # The branches below must be a subset of the branches above - branches: - - main - - development + workflow_dispatch: jobs: test: runs-on: ${{ matrix.os }} diff --git a/package-lock.json b/package-lock.json index 4d83d74c7..5838f9729 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,7 @@ "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-menubar": "^1.1.15", "@radix-ui/react-popover": "^1.1.14", - "@radix-ui/react-portal": "^1.1.9", + "@radix-ui/react-portal": "^1.1.10", "@radix-ui/react-radio-group": "^1.3.7", "@radix-ui/react-scroll-area": "^1.0.5", "@radix-ui/react-select": "^2.2.5", @@ -30,14 +30,14 @@ "@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-toast": "^1.2.14", "@radix-ui/react-tooltip": "^1.2.7", - "@tailwindcss/forms": "^0.5.6", + "@tailwindcss/forms": "^0.5.10", "@tanstack/react-query": "^5.90.21", - "@tanstack/react-table": "^8.10.7", + "@tanstack/react-table": "^8.21.2", "@xyflow/react": "^12.0.1", "auto-zustand-selectors-hook": "^2.0.0", "avr8js": "0.20.0", "axios": "^1.13.6", - "clsx": "^2.0.0", + "clsx": "^2.1.1", "cva": "npm:class-variance-authority@^0.7.0", "dompurify": "^3.2.4", "electron-debug": "^3.2.0", @@ -45,31 +45,31 @@ "electron-store": "^8.1.0", "electron-updater": "^6.1.4", "embla-carousel-react": "^8.0.0-rc17", - "i18next": "^23.5.1", + "i18next": "^24.2.2", "immer": "^10.1.1", "lodash": "^4.17.21", "lucide-react": "^0.563.0", - "monaco-editor": "^0.52.2", + "monaco-editor": "^0.54.0", "monaco-editor-webpack-plugin": "^7.1.0", "monaco-pyright-lsp": "^0.1.7", "path-browserify": "^1.0.1", - "react": "^18.2.0", - "react-apexcharts": "^1.4.1", - "react-dom": "^18.2.0", + "react": "^18.3.1", + "react-apexcharts": "^1.7.0", + "react-dom": "^18.3.1", "react-draggable": "^4.4.6", - "react-hook-form": "^7.47.0", - "react-i18next": "^13.3.0", + "react-hook-form": "^7.66.0", + "react-i18next": "^15.4.0", "react-icons": "^4.11.0", "react-markdown": "^10.1.0", - "react-resizable-panels": "^2.0.3", + "react-resizable-panels": "^2.1.7", "socket.io-client": "^4.8.1", "tailwind-merge": "^2.1.0", "url": "^0.11.3", - "uuid": "^9.0.1", + "uuid": "^11.1.0", "vscode-languageserver": "^9.0.1", "winston": "^3.17.0", "xmlbuilder2": "^3.1.1", - "yaml": "^2.7.0", + "yaml": "^2.8.1", "zod": "^3.25.67", "zustand": "^5.0.5" }, @@ -2178,9 +2178,9 @@ "license": "MIT" }, "node_modules/@babel/runtime": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.1.tgz", - "integrity": "sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog==", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -6070,6 +6070,30 @@ } } }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-presence": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.4.tgz", @@ -6619,6 +6643,30 @@ } } }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-presence": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.4.tgz", @@ -6911,6 +6959,30 @@ } } }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-presence": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.4.tgz", @@ -7125,12 +7197,12 @@ } }, "node_modules/@radix-ui/react-portal": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", - "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.10.tgz", + "integrity": "sha512-4kY9IVa6+9nJPsYmngK5Uk2kUmZnv7ChhHAFeQ5oaj8jrR1bIi3xww8nH71pz1/Ve4d/cXO3YxT8eikt1B0a8w==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-primitive": "2.1.4", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { @@ -7164,12 +7236,12 @@ } }, "node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", "license": "MIT", "dependencies": { - "@radix-ui/react-slot": "1.2.3" + "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", @@ -7187,9 +7259,9 @@ } }, "node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" @@ -7698,6 +7770,30 @@ } } }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-primitive": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", @@ -8124,6 +8220,30 @@ } } }, + "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-presence": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.4.tgz", @@ -8289,6 +8409,30 @@ } } }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-presence": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.4.tgz", @@ -9243,15 +9387,15 @@ } }, "node_modules/@tailwindcss/forms": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.7.tgz", - "integrity": "sha512-QE7X69iQI+ZXwldE+rzasvbJiyV/ju1FGHH0Qn2W3FKbuYtqp8LKcy6iSw79fVUT5/Vvf+0XgLCeYVG+UV6hOw==", + "version": "0.5.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.11.tgz", + "integrity": "sha512-h9wegbZDPurxG22xZSoWtdzc41/OlNEUQERNqI/0fOwa2aVlWGu7C35E/x6LDyD3lgtztFSSjKZyuVM0hxhbgA==", "license": "MIT", "dependencies": { "mini-svg-data-uri": "^1.2.3" }, "peerDependencies": { - "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1" + "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1" } }, "node_modules/@tanstack/query-core": { @@ -9281,12 +9425,12 @@ } }, "node_modules/@tanstack/react-table": { - "version": "8.19.2", - "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.19.2.tgz", - "integrity": "sha512-itoSIAkA/Vsg+bjY23FSemcTyPhc5/1YjYyaMsr9QSH/cdbZnQxHVWrpWn0Sp2BWN71qkzR7e5ye8WuMmwyOjg==", + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", + "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", "license": "MIT", "dependencies": { - "@tanstack/table-core": "8.19.2" + "@tanstack/table-core": "8.21.3" }, "engines": { "node": ">=12" @@ -9301,9 +9445,9 @@ } }, "node_modules/@tanstack/table-core": { - "version": "8.19.2", - "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.19.2.tgz", - "integrity": "sha512-KpRjhgehIhbfH78ARm/GJDXGnpdw4bCg3qas6yjWSi7czJhI/J6pWln7NHtmBkGE9ZbohiiNtLqwGzKmBfixig==", + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", "license": "MIT", "engines": { "node": ">=12" @@ -10945,13 +11089,6 @@ "d3-zoom": "^3.0.0" } }, - "node_modules/@yr/monotone-cubic-spline": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@yr/monotone-cubic-spline/-/monotone-cubic-spline-1.0.3.tgz", - "integrity": "sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA==", - "license": "MIT", - "peer": true - }, "node_modules/7zip-bin": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz", @@ -11228,20 +11365,11 @@ } }, "node_modules/apexcharts": { - "version": "3.49.2", - "resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-3.49.2.tgz", - "integrity": "sha512-vBB8KgwfD9rSObA7s4kY2rU6DeaN67gTR3JN7r32ztgKVf8lKkdFQ6iUhk6oIHrV7W8PoHhr5EwKymn0z5Fz6A==", - "license": "MIT", - "peer": true, - "dependencies": { - "@yr/monotone-cubic-spline": "^1.0.3", - "svg.draggable.js": "^2.2.2", - "svg.easing.js": "^2.0.0", - "svg.filter.js": "^2.0.2", - "svg.pathmorphing.js": "^0.1.3", - "svg.resize.js": "^1.4.3", - "svg.select.js": "^3.0.1" - } + "version": "5.10.6", + "resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-5.10.6.tgz", + "integrity": "sha512-FJQGbso3iRuOwUYnj0yUhkWeKeJE6aboVol+ae09lsc+lbLMWZqSRbrAWVa/qishLiaeG2icxdvmVkm+9n6kOQ==", + "license": "SEE LICENSE IN LICENSE", + "peer": true }, "node_modules/app-builder-bin": { "version": "5.0.0-alpha.12", @@ -16812,9 +16940,9 @@ } }, "node_modules/i18next": { - "version": "23.11.5", - "resolved": "https://registry.npmjs.org/i18next/-/i18next-23.11.5.tgz", - "integrity": "sha512-41pvpVbW9rhZPk5xjCX2TPJi2861LEig/YRhUkY+1FQ2IQPS0bKUDYnEqY8XPPbB48h1uIwLnP9iiEfuSl20CA==", + "version": "24.2.3", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-24.2.3.tgz", + "integrity": "sha512-lfbf80OzkocvX7nmZtu7nSTNbrTYR52sLWxPtlXX1zAhVw8WEnFk4puUkCR4B1dNQwbSpEHHHemcZu//7EcB7A==", "funding": [ { "type": "individual", @@ -16831,7 +16959,15 @@ ], "license": "MIT", "dependencies": { - "@babel/runtime": "^7.23.2" + "@babel/runtime": "^7.26.10" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/iconv-corefoundation": { @@ -21871,6 +22007,18 @@ "tmpl": "1.0.5" } }, + "node_modules/marked": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", + "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/matcher": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", @@ -22835,10 +22983,14 @@ } }, "node_modules/monaco-editor": { - "version": "0.52.2", - "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz", - "integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==", - "license": "MIT" + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.54.0.tgz", + "integrity": "sha512-hx45SEUoLatgWxHKCmlLJH81xBo0uXP4sRkESUpmDQevfi+e7K1VuiSprK6UpQ8u4zOcKNiH0pMvHvlMWA/4cw==", + "license": "MIT", + "dependencies": { + "dompurify": "3.1.7", + "marked": "14.0.0" + } }, "node_modules/monaco-editor-webpack-plugin": { "version": "7.1.0", @@ -22853,6 +23005,12 @@ "webpack": "^4.5.0 || 5.x" } }, + "node_modules/monaco-editor/node_modules/dompurify": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.7.tgz", + "integrity": "sha512-VaTstWtsneJY8xzy7DekmYWEOZcmzIe3Qb3zPd4STve1OBTa+e+WmS1ITQec1fZYXI3HCsOZZiSMpG6oxoWMWQ==", + "license": "(MPL-2.0 OR Apache-2.0)" + }, "node_modules/monaco-pyright-lsp": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/monaco-pyright-lsp/-/monaco-pyright-lsp-0.1.7.tgz", @@ -22862,6 +23020,12 @@ "vscode-languageserver": "^9.0.1" } }, + "node_modules/monaco-pyright-lsp/node_modules/monaco-editor": { + "version": "0.52.2", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz", + "integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==", + "license": "MIT" + }, "node_modules/mrmime": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.0.tgz", @@ -25010,16 +25174,16 @@ } }, "node_modules/react-apexcharts": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/react-apexcharts/-/react-apexcharts-1.4.1.tgz", - "integrity": "sha512-G14nVaD64Bnbgy8tYxkjuXEUp/7h30Q0U33xc3AwtGFijJB9nHqOt1a6eG0WBn055RgRg+NwqbKGtqPxy15d0Q==", - "license": "MIT", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/react-apexcharts/-/react-apexcharts-1.9.0.tgz", + "integrity": "sha512-DDBzQFuKdwyCEZnji1yIcjlnV8hRr4VDabS5Y3iuem/WcTq6n4VbjWPzbPm3aOwW4I+rf/gA3zWqhws4z9CwLw==", + "license": "SEE LICENSE IN LICENSE", "dependencies": { "prop-types": "^15.8.1" }, "peerDependencies": { - "apexcharts": "^3.41.0", - "react": ">=0.13" + "apexcharts": ">=4.0.0", + "react": ">=16.8.0" } }, "node_modules/react-dom": { @@ -25059,9 +25223,9 @@ } }, "node_modules/react-hook-form": { - "version": "7.58.1", - "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.58.1.tgz", - "integrity": "sha512-Lml/KZYEEFfPhUVgE0RdCVpnC4yhW+PndRhbiTtdvSlQTL8IfVR+iQkBjLIvmmc6+GGoVeM11z37ktKFPAb0FA==", + "version": "7.72.1", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.72.1.tgz", + "integrity": "sha512-RhwBoy2ygeVZje+C+bwJ8g0NjTdBmDlJvAUHTxRjTmSUKPYsKfMphkS2sgEMotsY03bP358yEYlnUeZy//D9Ig==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -25075,17 +25239,18 @@ } }, "node_modules/react-i18next": { - "version": "13.5.0", - "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-13.5.0.tgz", - "integrity": "sha512-CFJ5NDGJ2MUyBohEHxljOq/39NQ972rh1ajnadG9BjTk+UXbHLq4z5DKEbEQBDoIhUmmbuS/fIMJKo6VOax1HA==", + "version": "15.7.4", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.7.4.tgz", + "integrity": "sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.22.5", + "@babel/runtime": "^7.27.6", "html-parse-stringify": "^3.0.1" }, "peerDependencies": { - "i18next": ">= 23.2.3", - "react": ">= 16.8.0" + "i18next": ">= 23.4.0", + "react": ">= 16.8.0", + "typescript": "^5" }, "peerDependenciesMeta": { "react-dom": { @@ -25093,6 +25258,9 @@ }, "react-native": { "optional": true + }, + "typescript": { + "optional": true } } }, @@ -25197,13 +25365,13 @@ } }, "node_modules/react-resizable-panels": { - "version": "2.0.20", - "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-2.0.20.tgz", - "integrity": "sha512-aMbK3VF8U+VBICG+rwhE0Rr/eFZaRzmNq3akBRL1TrayIpLXz7Rbok0//kYeWj6SQRsjcQ3f4eRplJicM+oL6w==", + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-2.1.9.tgz", + "integrity": "sha512-z77+X08YDIrgAes4jl8xhnUu1LNIRp4+E7cv4xHmLOxxUPO/ML7PSrE813b90vj7xvQ1lcf7g2uA9GeMZonjhQ==", "license": "MIT", "peerDependencies": { - "react": "^16.14.0 || ^17.0.0 || ^18.0.0", - "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0" + "react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "node_modules/react-shallow-renderer": { @@ -27182,105 +27350,6 @@ "dev": true, "license": "MIT" }, - "node_modules/svg.draggable.js": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/svg.draggable.js/-/svg.draggable.js-2.2.2.tgz", - "integrity": "sha512-JzNHBc2fLQMzYCZ90KZHN2ohXL0BQJGQimK1kGk6AvSeibuKcIdDX9Kr0dT9+UJ5O8nYA0RB839Lhvk4CY4MZw==", - "license": "MIT", - "peer": true, - "dependencies": { - "svg.js": "^2.0.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/svg.easing.js": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/svg.easing.js/-/svg.easing.js-2.0.0.tgz", - "integrity": "sha512-//ctPdJMGy22YoYGV+3HEfHbm6/69LJUTAqI2/5qBvaNHZ9uUFVC82B0Pl299HzgH13rKrBgi4+XyXXyVWWthA==", - "license": "MIT", - "peer": true, - "dependencies": { - "svg.js": ">=2.3.x" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/svg.filter.js": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/svg.filter.js/-/svg.filter.js-2.0.2.tgz", - "integrity": "sha512-xkGBwU+dKBzqg5PtilaTb0EYPqPfJ9Q6saVldX+5vCRy31P6TlRCP3U9NxH3HEufkKkpNgdTLBJnmhDHeTqAkw==", - "license": "MIT", - "peer": true, - "dependencies": { - "svg.js": "^2.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/svg.js": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/svg.js/-/svg.js-2.7.1.tgz", - "integrity": "sha512-ycbxpizEQktk3FYvn/8BH+6/EuWXg7ZpQREJvgacqn46gIddG24tNNe4Son6omdXCnSOaApnpZw6MPCBA1dODA==", - "license": "MIT", - "peer": true - }, - "node_modules/svg.pathmorphing.js": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/svg.pathmorphing.js/-/svg.pathmorphing.js-0.1.3.tgz", - "integrity": "sha512-49HWI9X4XQR/JG1qXkSDV8xViuTLIWm/B/7YuQELV5KMOPtXjiwH4XPJvr/ghEDibmLQ9Oc22dpWpG0vUDDNww==", - "license": "MIT", - "peer": true, - "dependencies": { - "svg.js": "^2.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/svg.resize.js": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/svg.resize.js/-/svg.resize.js-1.4.3.tgz", - "integrity": "sha512-9k5sXJuPKp+mVzXNvxz7U0uC9oVMQrrf7cFsETznzUDDm0x8+77dtZkWdMfRlmbkEEYvUn9btKuZ3n41oNA+uw==", - "license": "MIT", - "peer": true, - "dependencies": { - "svg.js": "^2.6.5", - "svg.select.js": "^2.1.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/svg.resize.js/node_modules/svg.select.js": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/svg.select.js/-/svg.select.js-2.1.2.tgz", - "integrity": "sha512-tH6ABEyJsAOVAhwcCjF8mw4crjXSI1aa7j2VQR8ZuJ37H2MBUbyeqYr5nEO7sSN3cy9AR9DUwNg0t/962HlDbQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "svg.js": "^2.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/svg.select.js": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/svg.select.js/-/svg.select.js-3.0.1.tgz", - "integrity": "sha512-h5IS/hKkuVCbKSieR9uQCj9w+zLHoPh+ce19bBYyqF53g6mnPB8sAtIbe1s9dh2S2fCmYX2xel1Ln3PJBbK4kw==", - "license": "MIT", - "peer": true, - "dependencies": { - "svg.js": "^2.6.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/svgo": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.2.tgz", @@ -28723,16 +28792,16 @@ } }, "node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist/esm/bin/uuid" } }, "node_modules/v8-compile-cache-lib": { @@ -29623,15 +29692,18 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz", - "integrity": "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", "license": "ISC", "bin": { "yaml": "bin.mjs" }, "engines": { - "node": ">= 14" + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" } }, "node_modules/yargs": { diff --git a/package.json b/package.json index a9958b33e..682f985d1 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-menubar": "^1.1.15", "@radix-ui/react-popover": "^1.1.14", - "@radix-ui/react-portal": "^1.1.9", + "@radix-ui/react-portal": "^1.1.10", "@radix-ui/react-radio-group": "^1.3.7", "@radix-ui/react-scroll-area": "^1.0.5", "@radix-ui/react-select": "^2.2.5", @@ -52,14 +52,14 @@ "@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-toast": "^1.2.14", "@radix-ui/react-tooltip": "^1.2.7", - "@tailwindcss/forms": "^0.5.6", + "@tailwindcss/forms": "^0.5.10", "@tanstack/react-query": "^5.90.21", - "@tanstack/react-table": "^8.10.7", + "@tanstack/react-table": "^8.21.2", "@xyflow/react": "^12.0.1", "auto-zustand-selectors-hook": "^2.0.0", "avr8js": "0.20.0", "axios": "^1.13.6", - "clsx": "^2.0.0", + "clsx": "^2.1.1", "cva": "npm:class-variance-authority@^0.7.0", "dompurify": "^3.2.4", "electron-debug": "^3.2.0", @@ -67,31 +67,31 @@ "electron-store": "^8.1.0", "electron-updater": "^6.1.4", "embla-carousel-react": "^8.0.0-rc17", - "i18next": "^23.5.1", + "i18next": "^24.2.2", "immer": "^10.1.1", "lodash": "^4.17.21", "lucide-react": "^0.563.0", - "monaco-editor": "^0.52.2", + "monaco-editor": "^0.54.0", "monaco-editor-webpack-plugin": "^7.1.0", "monaco-pyright-lsp": "^0.1.7", "path-browserify": "^1.0.1", - "react": "^18.2.0", - "react-apexcharts": "^1.4.1", - "react-dom": "^18.2.0", + "react": "^18.3.1", + "react-apexcharts": "^1.7.0", + "react-dom": "^18.3.1", "react-draggable": "^4.4.6", - "react-hook-form": "^7.47.0", - "react-i18next": "^13.3.0", + "react-hook-form": "^7.66.0", + "react-i18next": "^15.4.0", "react-icons": "^4.11.0", "react-markdown": "^10.1.0", - "react-resizable-panels": "^2.0.3", + "react-resizable-panels": "^2.1.7", "socket.io-client": "^4.8.1", "tailwind-merge": "^2.1.0", "url": "^0.11.3", - "uuid": "^9.0.1", + "uuid": "^11.1.0", "vscode-languageserver": "^9.0.1", "winston": "^3.17.0", "xmlbuilder2": "^3.1.1", - "yaml": "^2.7.0", + "yaml": "^2.8.1", "zod": "^3.25.67", "zustand": "^5.0.5" }, diff --git a/src/App.tsx b/src/App.tsx index 3068e21b4..d3512da51 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,6 @@ import '@xyflow/react/dist/style.css' import 'tailwindcss/tailwind.css' -import './backend/styles/globals.css' +import './backend/shared/styles/globals.css' import { useEffect } from 'react' diff --git a/src/__architecture__/validate.ts b/src/__architecture__/validate.ts index 3c46834b0..ae9362d29 100644 --- a/src/__architecture__/validate.ts +++ b/src/__architecture__/validate.ts @@ -24,6 +24,7 @@ type LayerName = | 'provider' | 'adapters' | 'backend-shared' + | 'backend-web' | 'store' | 'services' | 'hooks' @@ -64,12 +65,16 @@ const LAYER_RULES: Record = { }, adapters: { name: 'Adapters (middleware/adapters/)', - allowedDeps: ['ports', 'provider', 'utils', 'backend-shared', 'store', 'assets'], + allowedDeps: ['ports', 'provider', 'utils', 'backend-shared', 'backend-web', 'store', 'assets'], }, 'backend-shared': { name: 'Backend Shared (backend/shared/)', allowedDeps: ['ports', 'utils', 'types'], }, + 'backend-web': { + name: 'Backend Web (backend/web/)', + allowedDeps: ['ports', 'utils', 'types', 'backend-shared'], + }, store: { name: 'Store (frontend/store/)', allowedDeps: ['ports', 'provider', 'store', 'utils', 'assets'], @@ -130,6 +135,7 @@ function getLayer(filePath: string): LayerName | null { // Backend layers if (rel.startsWith('backend/shared/')) return 'backend-shared' + if (rel.startsWith('backend/web/')) return 'backend-web' // Frontend layers if (rel.startsWith('frontend/store/')) return 'store' diff --git a/src/backend/editor/utils/path-picker.ts b/src/backend/editor/utils/path-picker.ts index 0222c88b8..85dc985f0 100644 --- a/src/backend/editor/utils/path-picker.ts +++ b/src/backend/editor/utils/path-picker.ts @@ -1,4 +1,6 @@ import { BrowserWindow, dialog } from 'electron' +import { promises } from 'fs' +import { join } from 'path' import { isEmptyDir } from './is-empty-dir' @@ -37,4 +39,39 @@ const getProjectPath = async (serviceManager: GetProjectPathProps) => { } } -export { getProjectPath } +const getOpenProjectPath = async (serviceManager: GetProjectPathProps) => { + const { canceled, filePaths } = await dialog.showOpenDialog(serviceManager, { + title: 'Select a PLC project to open', + properties: ['openDirectory'], + }) + if (canceled) { + return { + success: false, + error: { + title: 'Operation canceled', + description: 'Operation canceled by the user.', + }, + } + } + + const [filePath] = filePaths + + try { + await promises.access(join(filePath, 'project.json')) + } catch { + return { + success: false, + error: { + title: 'Invalid project', + description: 'The selected directory is not a valid OpenPLC project. No project.json file found.', + }, + } + } + + return { + success: true, + path: filePath, + } +} + +export { getOpenProjectPath, getProjectPath } diff --git a/src/backend/styles/globals.css b/src/backend/shared/styles/globals.css similarity index 89% rename from src/backend/styles/globals.css rename to src/backend/shared/styles/globals.css index 63ca18720..287908744 100644 --- a/src/backend/styles/globals.css +++ b/src/backend/shared/styles/globals.css @@ -153,6 +153,35 @@ } } +.sidebar-scroll { + overflow-y: auto; + scrollbar-gutter: stable; + direction: rtl; +} + +.sidebar-scroll > * { + direction: ltr; +} + +.sidebar-scroll::-webkit-scrollbar { + width: 6px; + background-color: transparent; +} + +.sidebar-scroll::-webkit-scrollbar-thumb { + background-color: transparent; + border-radius: 4px; + transition: background-color 0.2s; +} + +.sidebar-scroll:hover::-webkit-scrollbar-thumb { + background-color: rgba(180, 208, 254, 0.3); +} + +.sidebar-scroll:hover::-webkit-scrollbar-thumb:hover { + background-color: rgba(180, 208, 254, 0.6); +} + .apexcharts-yaxis-label tspan, .apexcharts-xaxis-label tspan { @apply fill-neutral-1000 !text-[10px] dark:fill-neutral-50; diff --git a/src/backend/shared/types/PLC/devices/configuration.ts b/src/backend/shared/types/PLC/devices/configuration.ts index f237a1b75..5e5d6a388 100644 --- a/src/backend/shared/types/PLC/devices/configuration.ts +++ b/src/backend/shared/types/PLC/devices/configuration.ts @@ -16,7 +16,7 @@ const MAC_ADDRESS_REGEX = /^([0-9A-Fa-f]{2})([:\-,])(?:[0-9A-Fa-f]{2}\2){4}[0-9A const BYTE_MAC_ADDRESS_REGEX = /^(?:0x[0-9a-f]{2}\s*,\s*){5}0x[0-9a-f]{2}$/i const deviceConfigurationSchema = z.object({ - deviceBoard: z.string().default('Simulator'), + deviceBoard: z.string().default('OpenPLC Simulator'), communicationPort: z.string().default(''), runtimeIpAddress: z.string().optional(), compileOnly: z.boolean().default(false), diff --git a/src/frontend/components/_atoms/buttons/activity-bar/index.tsx b/src/frontend/components/_atoms/buttons/activity-bar/index.tsx index 0e31231fd..c9d64844a 100644 --- a/src/frontend/components/_atoms/buttons/activity-bar/index.tsx +++ b/src/frontend/components/_atoms/buttons/activity-bar/index.tsx @@ -14,12 +14,12 @@ const ActivityBarButton = forwardRef + + + setShowSwitcher(false)} + onSelect={handleSelect} + onCreateNew={() => setShowCreate(true)} + onDelete={handleDelete} + /> + + setShowCreate(false)} /> + + { + setShowDelete(false) + setBranchToDelete(null) + }} + onDeleted={handleDeleted} + /> + + + + ) +} diff --git a/src/frontend/components/_features/[workspace]/branches/branch-switcher-modal.tsx b/src/frontend/components/_features/[workspace]/branches/branch-switcher-modal.tsx new file mode 100644 index 000000000..ecc857b01 --- /dev/null +++ b/src/frontend/components/_features/[workspace]/branches/branch-switcher-modal.tsx @@ -0,0 +1,155 @@ +import { useEffect, useMemo, useState } from 'react' + +import type { Branch } from '../../../../../middleware/shared/ports/version-control-port' +import { useVersionControl } from '../../../../../middleware/shared/providers' +import { cn } from '../../../../utils/cn' + +type BranchSwitcherModalProps = { + isOpen: boolean + projectId: string + currentBranchName: string + onClose: () => void + onSelect: (branch: Branch) => void + onCreateNew: () => void + onDelete: (branch: Branch) => void +} + +export function BranchSwitcherModal({ + isOpen, + projectId, + currentBranchName, + onClose, + onSelect, + onCreateNew, + onDelete, +}: BranchSwitcherModalProps) { + const versionControl = useVersionControl() + const [branches, setBranches] = useState([]) + const [isLoading, setIsLoading] = useState(false) + const [filter, setFilter] = useState('') + + useEffect(() => { + if (!isOpen || !versionControl) return + setFilter('') + setIsLoading(true) + versionControl + .listBranches(projectId) + .then(({ branches: b }) => setBranches(b)) + .catch(() => setBranches([])) + .finally(() => setIsLoading(false)) + }, [isOpen, projectId, versionControl]) + + useEffect(() => { + if (!isOpen) return + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [isOpen, onClose]) + + const filtered = useMemo(() => { + if (!filter.trim()) return branches + const lower = filter.toLowerCase() + return branches.filter((b) => b.name.toLowerCase().includes(lower)) + }, [branches, filter]) + + if (!isOpen) return null + + return ( +
+
+
+ {/* Search */} +
+ setFilter(e.target.value)} + placeholder='Search branches...' + autoFocus + className='w-full rounded-md border border-neutral-300 bg-white px-3 py-1.5 text-xs text-neutral-900 placeholder-neutral-400 outline-none focus:border-blue-500 dark:border-neutral-600 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder-neutral-500' + /> +
+ + {/* Branch list */} +
+ {isLoading && ( +

Loading...

+ )} + + {!isLoading && filtered.length === 0 && ( +

No branches found

+ )} + + {filtered.map((branch) => { + const isActive = branch.name === currentBranchName + return ( +
{ + onSelect(branch) + onClose() + }} + > + + + + {branch.name} + {isActive && ( + + + + )} + {branch.isDefault && ( + + default + + )} + {!branch.isDefault && ( + + )} +
+ ) + })} +
+ + {/* Create new branch */} +
+ +
+
+
+ ) +} diff --git a/src/frontend/components/_features/[workspace]/branches/create-branch-modal.tsx b/src/frontend/components/_features/[workspace]/branches/create-branch-modal.tsx new file mode 100644 index 000000000..9df0f895f --- /dev/null +++ b/src/frontend/components/_features/[workspace]/branches/create-branch-modal.tsx @@ -0,0 +1,117 @@ +import { useEffect, useState } from 'react' + +import { useVersionControl } from '../../../../../middleware/shared/providers' + +type CreateBranchModalProps = { + isOpen: boolean + projectId: string + onClose: () => void + onCreated?: (name: string) => void +} + +export function CreateBranchModal({ isOpen, projectId, onClose, onCreated }: CreateBranchModalProps) { + const versionControl = useVersionControl() + const [name, setName] = useState('') + const [error, setError] = useState('') + const [isPending, setIsPending] = useState(false) + + useEffect(() => { + if (!isOpen) return + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && !isPending) onClose() + } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [isOpen, isPending, onClose]) + + useEffect(() => { + if (isOpen) { + setName('') + setError('') + } + }, [isOpen]) + + if (!isOpen) return null + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + const trimmed = name.trim() + + if (!trimmed) { + setError('Branch name cannot be empty') + return + } + + if (/\s/.test(trimmed)) { + setError('Branch name cannot contain spaces') + return + } + + if (/[~^:?*[\]\\]/.test(trimmed)) { + setError('Branch name contains invalid characters') + return + } + + if (/(\.\.|\/\/|@\{|\.lock$|^\.|^\/|\/$)/.test(trimmed)) { + setError('Branch name has an invalid format') + return + } + + if (!versionControl) return + + setError('') + setIsPending(true) + versionControl + .createBranch(projectId, trimmed) + .then(() => { + onCreated?.(trimmed) + onClose() + }) + .catch((err: Error) => { + setError(err.message || 'Failed to create branch') + }) + .finally(() => setIsPending(false)) + } + + return ( +
+
+
+

Create Branch

+
+ setName(e.target.value)} + placeholder='feature/my-new-branch' + autoFocus + disabled={isPending} + className='w-full rounded-md border border-neutral-300 bg-white px-3 py-1.5 text-xs text-neutral-900 placeholder-neutral-400 outline-none focus:border-blue-500 dark:border-neutral-600 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder-neutral-500' + /> + {error ? ( +

{error}

+ ) : ( +

No spaces allowed

+ )} +
+ + +
+
+
+
+ ) +} diff --git a/src/frontend/components/_features/[workspace]/branches/delete-branch-modal.tsx b/src/frontend/components/_features/[workspace]/branches/delete-branch-modal.tsx new file mode 100644 index 000000000..4b6b91e3c --- /dev/null +++ b/src/frontend/components/_features/[workspace]/branches/delete-branch-modal.tsx @@ -0,0 +1,75 @@ +import { useEffect, useState } from 'react' + +import type { Branch } from '../../../../../middleware/shared/ports/version-control-port' +import { useVersionControl } from '../../../../../middleware/shared/providers' +import { toast } from '../../../../utils/toast' + +type DeleteBranchModalProps = { + isOpen: boolean + projectId: string + branch: Branch | null + onClose: () => void + onDeleted?: () => void +} + +export function DeleteBranchModal({ isOpen, projectId, branch, onClose, onDeleted }: DeleteBranchModalProps) { + const versionControl = useVersionControl() + const [isPending, setIsPending] = useState(false) + + useEffect(() => { + if (!isOpen) return + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && !isPending) onClose() + } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [isOpen, isPending, onClose]) + + if (!isOpen || !branch) return null + + const handleDelete = () => { + if (!versionControl) return + + setIsPending(true) + versionControl + .deleteBranch(projectId, branch.id) + .then(() => { + onDeleted?.() + onClose() + }) + .catch((err: Error) => { + toast({ title: 'Failed to delete branch', description: err.message || 'Unknown error', variant: 'fail' }) + }) + .finally(() => setIsPending(false)) + } + + return ( +
+
+
+

Delete Branch

+

+ Are you sure you want to delete{' '} + {branch.name}? This + action cannot be undone. +

+
+ + +
+
+
+ ) +} diff --git a/src/frontend/components/_features/[workspace]/branches/index.ts b/src/frontend/components/_features/[workspace]/branches/index.ts new file mode 100644 index 000000000..6ab4e597f --- /dev/null +++ b/src/frontend/components/_features/[workspace]/branches/index.ts @@ -0,0 +1,5 @@ +export { BranchStatusBar } from './branch-status-bar' +export { BranchSwitcherModal } from './branch-switcher-modal' +export { CreateBranchModal } from './create-branch-modal' +export { DeleteBranchModal } from './delete-branch-modal' +export { UnsavedChangesWarningModal } from './unsaved-changes-warning-modal' diff --git a/src/frontend/components/_features/[workspace]/branches/unsaved-changes-warning-modal.tsx b/src/frontend/components/_features/[workspace]/branches/unsaved-changes-warning-modal.tsx new file mode 100644 index 000000000..99b0a87c3 --- /dev/null +++ b/src/frontend/components/_features/[workspace]/branches/unsaved-changes-warning-modal.tsx @@ -0,0 +1,53 @@ +import { useEffect } from 'react' + +type UnsavedChangesWarningModalProps = { + isOpen: boolean + targetBranchName: string + onDiscard: () => void + onCancel: () => void +} + +export function UnsavedChangesWarningModal({ + isOpen, + targetBranchName, + onDiscard, + onCancel, +}: UnsavedChangesWarningModalProps) { + useEffect(() => { + if (!isOpen) return + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onCancel() + } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [isOpen, onCancel]) + + if (!isOpen) return null + + return ( +
+
+
+

Unsaved Changes

+

+ You have uncommitted changes. Switching to {targetBranchName} will discard them. This action + cannot be undone. +

+
+ + +
+
+
+ ) +} diff --git a/src/frontend/components/_features/[workspace]/create-element/element-card/index.tsx b/src/frontend/components/_features/[workspace]/create-element/element-card/index.tsx index 934974f7c..0054eec65 100644 --- a/src/frontend/components/_features/[workspace]/create-element/element-card/index.tsx +++ b/src/frontend/components/_features/[workspace]/create-element/element-card/index.tsx @@ -24,6 +24,7 @@ import { } from '../../../../../utils/device' import { ConvertToLangShortenedFormat } from '../../../../../utils/formatters/POU' import { useToast } from '../../../[app]/toast/use-toast' +import { validatePouOrDataTypeName } from '../hooks/use-name-validation' type ElementCardProps = { target: 'function' | 'function-block' | 'program' | 'data-type' | 'server' | 'remote-device' @@ -135,20 +136,19 @@ const ElementCard = (props: ElementCardProps): ReactNode => { const isArduinoTarget = checkIsArduinoTarget(currentBoardInfo) const isSimulator = isSimulatorTarget(currentBoardInfo) const isRuntimeV4 = isOpenPLCRuntimeV4Target(deviceBoard) - const allowServersAndRemoteDevices = isRuntimeV4 || isSimulator const handleCreatePou: SubmitHandler = (data) => { - try { - const pouWasCreated = create(data) - if (!pouWasCreated) throw new TypeError() - toast({ title: 'Pou created successfully', description: 'The POU has been created', variant: 'default' }) - closeContainer((prev) => !prev) - setIsOpen(false) - } catch (_error) { + const pouWasCreated = create(data) + if (!pouWasCreated.ok) { pouSetError('name', { type: 'already-exists', }) + toast({ title: 'Invalid Pou', description: "You can't create a Pou with this name.", variant: 'fail' }) + return } + toast({ title: 'Pou created successfully', description: 'The POU has been created', variant: 'default' }) + closeContainer((prev) => !prev) + setIsOpen(false) } const handleCancelCreateElement = () => { @@ -288,9 +288,11 @@ const ElementCard = (props: ElementCardProps): ReactNode => { * data type name already exists )} - - ** Name must be at least 3 characters - + {!datatypeErrors.name && ( + + ** Name must follow CamelCase, PascalCase, or snake_case + + )}
- {!allowServersAndRemoteDevices ? ( + {!(isRuntimeV4 || isSimulator) ? (

Server configuration is only available for OpenPLC Runtime v4 targets. @@ -509,7 +511,7 @@ const ElementCard = (props: ElementCardProps): ReactNode => {

- {!allowServersAndRemoteDevices ? ( + {!(isRuntimeV4 || isSimulator) ? (

Remote device configuration is only available for OpenPLC Runtime v4 targets. @@ -658,8 +660,8 @@ const ElementCard = (props: ElementCardProps): ReactNode => { { * POU name already exists )} - - ** Name must be at least 3 characters - + {pouErrors.name?.type === 'validate' && ( + + * {pouErrors.name.message} + + )} + {pouErrors.name?.type === 'required' && ( + + * {pouErrors.name.message} + + )} + {!pouErrors.name && ( + + ** Name must follow CamelCase, PascalCase, or snake_case + + )}