diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3f7b057..6a688ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -238,10 +238,25 @@ jobs: xcrun notarytool submit "$DMG" --apple-id "$APPLE_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" --team-id "$APPLE_TEAM_ID" --wait xcrun stapler staple "$DMG" - - name: Package Linux AppImage, deb and rpm + - name: Package Linux AppImage, deb, pacman and rpm if: matrix.platform == 'linux' run: bash scripts/package-linux.sh build/bin/nodeshell build/bin "NodeShell-${{ env.APP_VERSION }}-linux-amd64" amd64 "${{ env.APP_VERSION }}" + - name: Verify Linux package artifacts + if: matrix.platform == 'linux' + shell: bash + run: | + set -euo pipefail + base="build/bin/NodeShell-${{ env.APP_VERSION }}-linux-amd64" + for ext in AppImage deb rpm pkg.tar.zst; do + test -s "$base.$ext" || { + echo "missing Linux package: $base.$ext" >&2 + exit 1 + } + done + echo "Linux packages present:" + ls -lh "$base".* + - name: Sign Windows installer if: matrix.platform == 'windows' && env.WINDOWS_CERT_BASE64 != '' && env.WINDOWS_CERT_PASSWORD != '' shell: pwsh diff --git a/.prettierignore b/.prettierignore index 9c6b791..95a690d 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,3 +4,6 @@ pnpm-lock.yaml LICENSE.md tsconfig.json tsconfig.*.json + +# Local git worktrees +.worktrees/ diff --git a/eslint.config.mjs b/eslint.config.mjs index 462eed2..ea5868c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -15,7 +15,7 @@ import eslintPluginReactRefresh from 'eslint-plugin-react-refresh' // and prettier/prettier as a warning backed by eslint-config-prettier. export default defineConfig( { - ignores: ['**/node_modules', '**/dist', '**/out', '**/wailsjs'] + ignores: ['**/node_modules', '**/dist', '**/out', '**/wailsjs', '**/.worktrees/**'] }, js.configs.recommended, { diff --git a/package-lock.json b/package-lock.json index 4f2fcf0..35309fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "2.1.0", "license": "MIT", "dependencies": { + "@base-ui/react": "^1.9.0", "@codemirror/commands": "^6.10.4", "@codemirror/lang-cpp": "^6.0.3", "@codemirror/lang-css": "^6.3.1", @@ -32,7 +33,6 @@ "@fortawesome/fontawesome-svg-core": "^7.3.1", "@fortawesome/free-solid-svg-icons": "^7.3.1", "@fortawesome/react-fontawesome": "^3.5.0", - "@radix-ui/react-select": "^2.3.3", "codemirror": "^6.0.2", "i18next": "^26.3.6", "react-i18next": "^17.0.9", @@ -380,6 +380,66 @@ "node": ">=6.9.0" } }, + "node_modules/@base-ui/react": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@base-ui/react/-/react-1.9.0.tgz", + "integrity": "sha512-S8CSYosASBKtpL8SWUfS78rFElfLijiCgA7oHOK22jCJ95FULsYyKwBNFOgudt0RA+/7yNc7F81bOvQnRgbBiQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7 || ^8.0.0", + "@base-ui/utils": "0.5.0", + "@floating-ui/react-dom": "^2.1.9", + "@floating-ui/utils": "^0.2.12", + "use-sync-external-store": "^1.7.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@date-fns/tz": "^1.2.0", + "@types/react": "^17 || ^18 || ^19", + "date-fns": "^4.0.0", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@date-fns/tz": { + "optional": true + }, + "@types/react": { + "optional": true + }, + "date-fns": { + "optional": true + } + } + }, + "node_modules/@base-ui/utils": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@base-ui/utils/-/utils-0.5.0.tgz", + "integrity": "sha512-dYGGcEttjkYiMoa5nHm8vLaJ2UcuTWxo3BFAwOYXeRg/q7UVVvA6cacEIXEs3FgBfK1CGtW1ptElqs7wsx+t9A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7 || ^8.0.0", + "@floating-ui/utils": "^0.2.12", + "reselect": "^5.3.0", + "use-sync-external-store": "^1.7.0" + }, + "peerDependencies": { + "@types/react": "^17 || ^18 || ^19", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmmirror.com/@bramus/specificity/-/specificity-2.4.2.tgz", @@ -1403,508 +1463,6 @@ "url": "https://opencollective.com/pkgr" } }, - "node_modules/@radix-ui/number": { - "version": "1.1.2", - "resolved": "https://registry.npmmirror.com/@radix-ui/number/-/number-1.1.2.tgz", - "integrity": "sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==", - "license": "MIT" - }, - "node_modules/@radix-ui/primitive": { - "version": "1.1.5", - "resolved": "https://registry.npmmirror.com/@radix-ui/primitive/-/primitive-1.1.5.tgz", - "integrity": "sha512-d86WIWFYNtGA0H/d8exstrTRTp7eWJYlYJbtNofxr/3ljupZYn6EFDG/Qgu/0Kc8v7yMUxySagqJsL1+PdYjWg==", - "license": "MIT" - }, - "node_modules/@radix-ui/react-arrow": { - "version": "1.1.11", - "resolved": "https://registry.npmmirror.com/@radix-ui/react-arrow/-/react-arrow-1.1.11.tgz", - "integrity": "sha512-Kdil9BB1rIFC/khmf4hC35bn8701AJcizTU7G7cUbEbk5XqqbjDuHW60uUfKqO5WojjZcbAW51Q7P0hRmMLw8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.7" - }, - "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-collection": { - "version": "1.1.12", - "resolved": "https://registry.npmmirror.com/@radix-ui/react-collection/-/react-collection-1.1.12.tgz", - "integrity": "sha512-nb67INpE0IahJKN7EYPp9m9YGwYeKlnzxT3MwXVkgCskaSJia97kG4T0ywpjNUSSnoJk/uvk12V8vbrEHEj+/Q==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-slot": "1.3.0" - }, - "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-compose-refs": { - "version": "1.1.3", - "resolved": "https://registry.npmmirror.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", - "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-context": { - "version": "1.2.0", - "resolved": "https://registry.npmmirror.com/@radix-ui/react-context/-/react-context-1.2.0.tgz", - "integrity": "sha512-fOE+JtN9rygNZkCnHRBEP0TAvLldlhyOxMsbwFvTP4nAs+nBmfnna+o/Zski2wkmY1YMrFC0aSzsHoLY47iLrg==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-direction": { - "version": "1.1.2", - "resolved": "https://registry.npmmirror.com/@radix-ui/react-direction/-/react-direction-1.1.2.tgz", - "integrity": "sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.15", - "resolved": "https://registry.npmmirror.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.15.tgz", - "integrity": "sha512-b0XaRlzn2QKuo10XyNgi2DAJDf5XC9d1nD3FJcuvCjbR7+4Ad28zmZsLsqx+hvDEzMnRuZaZxZm9gYObV6RmRA==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.5", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-effect-event": "0.0.3" - }, - "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-focus-guards": { - "version": "1.1.4", - "resolved": "https://registry.npmmirror.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.4.tgz", - "integrity": "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-focus-scope": { - "version": "1.1.12", - "resolved": "https://registry.npmmirror.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.12.tgz", - "integrity": "sha512-jjk/lqTeNL0azUx5ZYzVrl4NgaDIrdzTNE4mABV9yBFI7FQqN7pIgzV1bTleUezP2QiTGA1BFTqY8MegDgWX9A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-callback-ref": "1.1.2" - }, - "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-id": { - "version": "1.1.2", - "resolved": "https://registry.npmmirror.com/@radix-ui/react-id/-/react-id-1.1.2.tgz", - "integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popper": { - "version": "1.3.3", - "resolved": "https://registry.npmmirror.com/@radix-ui/react-popper/-/react-popper-1.3.3.tgz", - "integrity": "sha512-mS7dGpyjv6b+gsDjLF7e0ia1W4Im1B1hSCy2yuXlHuvnZxHKagfDaobt/KAKt27EpZMit2pss8eJBVyVjEWM+g==", - "license": "MIT", - "dependencies": { - "@floating-ui/react-dom": "^2.0.0", - "@radix-ui/react-arrow": "1.1.11", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-layout-effect": "1.1.2", - "@radix-ui/react-use-rect": "1.1.2", - "@radix-ui/react-use-size": "1.1.2", - "@radix-ui/rect": "1.1.2" - }, - "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-portal": { - "version": "1.1.13", - "resolved": "https://registry.npmmirror.com/@radix-ui/react-portal/-/react-portal-1.1.13.tgz", - "integrity": "sha512-z3oXfmaHLJTF1wktbjgD6cn9jiEbq3WSondB10LIuIt2m2Ym4iJlrW04/euMwENDdWDdE7z+OuY7Qyp1YpRSwA==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-layout-effect": "1.1.2" - }, - "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-presence": { - "version": "1.1.7", - "resolved": "https://registry.npmmirror.com/@radix-ui/react-presence/-/react-presence-1.1.7.tgz", - "integrity": "sha512-zBZ4QM5XG3JRanDmqXYf3MD6th4AFXFmgU6KNMFzUaV6F3uw9I5/zjMUvFriSEn5ewo1nxuibvyxJdmLlDcslA==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" - }, - "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-primitive": { - "version": "2.1.7", - "resolved": "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.7.tgz", - "integrity": "sha512-bC3NiwsprbxKjuon9l7X6BUTw7FPVzEYaL92MPEY5SCd/9hUTPXVFtVwRix7778wtRsVao+zE062gL79FZleeQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.3.0" - }, - "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": { - "version": "2.3.3", - "resolved": "https://registry.npmmirror.com/@radix-ui/react-select/-/react-select-2.3.3.tgz", - "integrity": "sha512-L5RQTXz6Anxsf9CCv+pTgiAsUpyVj7rJxsGtmhFaEOJ++cVfXucv4qWfsIO0AIB4NAhi3yovWGVMKKS1Xf1Wrg==", - "license": "MIT", - "dependencies": { - "@radix-ui/number": "1.1.2", - "@radix-ui/primitive": "1.1.5", - "@radix-ui/react-collection": "1.1.12", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.15", - "@radix-ui/react-focus-guards": "1.1.4", - "@radix-ui/react-focus-scope": "1.1.12", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-popper": "1.3.3", - "@radix-ui/react-portal": "1.1.13", - "@radix-ui/react-presence": "1.1.7", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-slot": "1.3.0", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-controllable-state": "1.2.3", - "@radix-ui/react-use-layout-effect": "1.1.2", - "@radix-ui/react-use-previous": "1.1.2", - "@radix-ui/react-visually-hidden": "1.2.7", - "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.7.2" - }, - "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-slot": { - "version": "1.3.0", - "resolved": "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.3.0.tgz", - "integrity": "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.3" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-callback-ref": { - "version": "1.1.2", - "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.2.tgz", - "integrity": "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.2.3", - "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.3.tgz", - "integrity": "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-effect-event": "0.0.3", - "@radix-ui/react-use-layout-effect": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-effect-event": { - "version": "0.0.3", - "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.3.tgz", - "integrity": "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.1.2", - "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", - "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-previous": { - "version": "1.1.2", - "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-previous/-/react-use-previous-1.1.2.tgz", - "integrity": "sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-rect": { - "version": "1.1.2", - "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-rect/-/react-use-rect-1.1.2.tgz", - "integrity": "sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw==", - "license": "MIT", - "dependencies": { - "@radix-ui/rect": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-size": { - "version": "1.1.2", - "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-size/-/react-use-size-1.1.2.tgz", - "integrity": "sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-visually-hidden": { - "version": "1.2.7", - "resolved": "https://registry.npmmirror.com/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.7.tgz", - "integrity": "sha512-1wNZBggTDK3GRuuQ6nP4k2yi7a6l7I5qbMPbZcRsrGsGVead/f/d5FhEzUvqFs0bcrDLx7n1zKQ3JvLR6whaaw==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.7" - }, - "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/rect": { - "version": "1.1.2", - "resolved": "https://registry.npmmirror.com/@radix-ui/rect/-/rect-1.1.2.tgz", - "integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==", - "license": "MIT" - }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "dev": true, @@ -2526,7 +2084,7 @@ }, "node_modules/@types/react-dom": { "version": "19.2.3", - "devOptional": true, + "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -2986,18 +2544,6 @@ "dev": true, "license": "Python-2.0" }, - "node_modules/aria-hidden": { - "version": "1.2.6", - "resolved": "https://registry.npmmirror.com/aria-hidden/-/aria-hidden-1.2.6.tgz", - "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/aria-query": { "version": "5.3.0", "resolved": "https://registry.npmmirror.com/aria-query/-/aria-query-5.3.0.tgz", @@ -3649,12 +3195,6 @@ "node": ">=6" } }, - "node_modules/detect-node-es": { - "version": "1.1.0", - "resolved": "https://registry.npmmirror.com/detect-node-es/-/detect-node-es-1.1.0.tgz", - "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", - "license": "MIT" - }, "node_modules/devlop": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/devlop/-/devlop-1.1.0.tgz", @@ -4454,15 +3994,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/get-nonce": { - "version": "1.0.1", - "resolved": "https://registry.npmmirror.com/get-nonce/-/get-nonce-1.0.1.tgz", - "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/get-proto": { "version": "1.0.1", "dev": true, @@ -6858,75 +6389,6 @@ "node": ">=0.10.0" } }, - "node_modules/react-remove-scroll": { - "version": "2.7.2", - "resolved": "https://registry.npmmirror.com/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", - "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", - "license": "MIT", - "dependencies": { - "react-remove-scroll-bar": "^2.3.7", - "react-style-singleton": "^2.2.3", - "tslib": "^2.1.0", - "use-callback-ref": "^1.3.3", - "use-sidecar": "^1.1.3" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/react-remove-scroll-bar": { - "version": "2.3.8", - "resolved": "https://registry.npmmirror.com/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", - "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", - "license": "MIT", - "dependencies": { - "react-style-singleton": "^2.2.2", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/react-style-singleton": { - "version": "2.2.3", - "resolved": "https://registry.npmmirror.com/react-style-singleton/-/react-style-singleton-2.2.3.tgz", - "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", - "license": "MIT", - "dependencies": { - "get-nonce": "^1.0.0", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmmirror.com/redent/-/redent-3.0.0.tgz", @@ -7057,6 +6519,12 @@ "node": ">=0.10.0" } }, + "node_modules/reselect": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.3.0.tgz", + "integrity": "sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==", + "license": "MIT" + }, "node_modules/resolve": { "version": "2.0.0-next.7", "dev": true, @@ -7698,10 +7166,6 @@ "typescript": ">=4.8.4" } }, - "node_modules/tslib": { - "version": "2.8.1", - "license": "0BSD" - }, "node_modules/type-check": { "version": "0.4.0", "dev": true, @@ -7973,53 +7437,10 @@ "punycode": "^2.1.0" } }, - "node_modules/use-callback-ref": { - "version": "1.3.3", - "resolved": "https://registry.npmmirror.com/use-callback-ref/-/use-callback-ref-1.3.3.tgz", - "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/use-sidecar": { - "version": "1.1.3", - "resolved": "https://registry.npmmirror.com/use-sidecar/-/use-sidecar-1.1.3.tgz", - "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", - "license": "MIT", - "dependencies": { - "detect-node-es": "^1.1.0", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/use-sync-external-store": { - "version": "1.6.0", - "resolved": "https://registry.npmmirror.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", - "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", "license": "MIT", "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" diff --git a/package.json b/package.json index aba6fec..3f25402 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "test:watch": "vitest" }, "dependencies": { + "@base-ui/react": "^1.9.0", "@codemirror/commands": "^6.10.4", "@codemirror/lang-cpp": "^6.0.3", "@codemirror/lang-css": "^6.3.1", @@ -41,7 +42,6 @@ "@fortawesome/fontawesome-svg-core": "^7.3.1", "@fortawesome/free-solid-svg-icons": "^7.3.1", "@fortawesome/react-fontawesome": "^3.5.0", - "@radix-ui/react-select": "^2.3.3", "codemirror": "^6.0.2", "i18next": "^26.3.6", "react-i18next": "^17.0.9", diff --git a/scripts/package-macos.sh b/scripts/package-macos.sh index b1bca16..daa17fc 100644 --- a/scripts/package-macos.sh +++ b/scripts/package-macos.sh @@ -13,5 +13,22 @@ fi # ZIP ditto -c -k --keepParent "$bin" "$out/$name.zip" # DMG -hdiutil create -volname "NodeShell" -srcfolder "$bin" -ov -format UDZO "$out/$name.dmg" -echo "Packaged $name: $out/$name.zip $out/$name.dmg" +# hdiutil occasionally reports "Resource busy" on shared macOS runners. +# Retry after detaching a stale mount and removing the partial image. +dmg="$out/$name.dmg" +created=0 +for attempt in 1 2 3; do + rm -f "$dmg" + if hdiutil create -volname "NodeShell" -srcfolder "$bin" -ov -format UDZO "$dmg"; then + created=1 + break + fi + echo "hdiutil create failed (attempt $attempt), retrying..." >&2 + hdiutil detach "/Volumes/NodeShell" 2>/dev/null || true + sleep 2 +done +if [ "$created" -ne 1 ]; then + echo "failed to create $dmg" >&2 + exit 1 +fi +echo "Packaged $name: $out/$name.zip $dmg" diff --git a/src/renderer/src/App.css b/src/renderer/src/App.css index ef3b79e..654bbac 100644 --- a/src/renderer/src/App.css +++ b/src/renderer/src/App.css @@ -12,6 +12,13 @@ body, overflow: hidden; } +/* Root stacking context: keeps portalled overlays (Base UI / Radix popups, + modals, menus) layered predictably above the app chrome. Portals render + into document.body, so they stay outside this context and above it. */ +#root { + isolation: isolate; +} + body { font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; font-size: 14px; @@ -61,20 +68,15 @@ select { font: inherit; } +/* Solid surface baseline. + Large backdrop blurs were intentionally removed: on WebKitGTK (and other + desktop renderers) a blurred backdrop forces an expensive offscreen snapshot + on every frame, which dominates paint time for panels, modals and popovers. + Surfaces are now opaque, which is both cheaper and easier to reason about. */ .glass { - background: var(--glass-bg); + background: var(--bg-panel); border: 1px solid var(--glass-border); box-shadow: var(--glass-highlight); - -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); - backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); -} - -@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) { - .glass { - background: var(--bg-panel); - -webkit-backdrop-filter: none; - backdrop-filter: none; - } } .app { @@ -137,8 +139,7 @@ button.icon-rail-btn[aria-pressed='true'] { transition: width var(--dur-sidebar, 240ms) var(--ease-sidebar, cubic-bezier(0.2, 0.8, 0.2, 1)), margin-right var(--dur-sidebar, 240ms) var(--ease-sidebar, cubic-bezier(0.2, 0.8, 0.2, 1)), - border-color var(--dur-sidebar, 240ms) var(--ease-sidebar, cubic-bezier(0.2, 0.8, 0.2, 1)), - box-shadow var(--dur-sidebar, 240ms) var(--ease-sidebar, cubic-bezier(0.2, 0.8, 0.2, 1)); + border-color var(--dur-sidebar, 240ms) var(--ease-sidebar, cubic-bezier(0.2, 0.8, 0.2, 1)); } .sidebar.is-collapsed { @@ -919,6 +920,9 @@ button.sftp-item-main { /* Password modal */ +/* Overlay is a solid dim layer — no backdrop blur (see the note above + `.glass`): blurring the whole viewport is the single most expensive paint + operation in the app. */ .modal-overlay { position: fixed; inset: 0; @@ -927,8 +931,6 @@ button.sftp-item-main { align-items: center; justify-content: center; z-index: 1100; - -webkit-backdrop-filter: blur(18px) saturate(140%); - backdrop-filter: blur(18px) saturate(140%); } .confirm-modal-overlay { @@ -942,25 +944,19 @@ button.sftp-item-main { /* ModalShell staged open/close — legacy overlays keep solid dim above */ .modal-overlay--animated { background: rgba(0, 0, 0, 0); - -webkit-backdrop-filter: none; - backdrop-filter: none; transition: background-color var(--dur-med) var(--ease-spring); } .modal-overlay--animated.is-open { background: var(--bg-overlay); - -webkit-backdrop-filter: blur(18px) saturate(140%); - backdrop-filter: blur(18px) saturate(140%); } .modal-overlay--animated.is-closing { background: rgba(0, 0, 0, 0); - -webkit-backdrop-filter: none; - backdrop-filter: none; } .modal { - background: var(--glass-bg); + background: var(--bg-elevated); border: 1px solid var(--glass-border); border-radius: var(--radius-sheet); padding: 20px 22px 18px; @@ -969,8 +965,6 @@ button.sftp-item-main { box-sizing: border-box; overflow-x: hidden; box-shadow: var(--shadow-modal), var(--glass-highlight); - -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); - backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); } .modal.password-modal, @@ -983,22 +977,6 @@ button.sftp-item-main { width: min(460px, calc(100vw - 40px)); } -@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) { - .modal, - .modal-overlay, - .modal-overlay--animated.is-open, - .app-select-content { - background: var(--bg-panel); - -webkit-backdrop-filter: none; - backdrop-filter: none; - } - - .modal-overlay, - .modal-overlay--animated.is-open { - background: var(--bg-overlay); - } -} - .modal.modal--animated { opacity: 0; transform: scale(0.96); @@ -2034,8 +2012,7 @@ button.model-id-editor-add-btn:disabled { transition: width var(--dur-sidebar, 240ms) var(--ease-sidebar, cubic-bezier(0.2, 0.8, 0.2, 1)), margin-left var(--dur-sidebar, 240ms) var(--ease-sidebar, cubic-bezier(0.2, 0.8, 0.2, 1)), - border-color var(--dur-sidebar, 240ms) var(--ease-sidebar, cubic-bezier(0.2, 0.8, 0.2, 1)), - box-shadow var(--dur-sidebar, 240ms) var(--ease-sidebar, cubic-bezier(0.2, 0.8, 0.2, 1)); + border-color var(--dur-sidebar, 240ms) var(--ease-sidebar, cubic-bezier(0.2, 0.8, 0.2, 1)); } .agent-dock.is-collapsed { @@ -4004,8 +3981,7 @@ button.agent-head-more:hover:not(:disabled) { transform var(--dur-fast) var(--ease-snappy); } -.app-select-trigger > span, -.app-select-trigger [data-radix-select-value] { +.app-select-trigger > span { color: inherit; } @@ -4014,7 +3990,6 @@ button.agent-head-more:hover:not(:disabled) { background: var(--bg-input-focus); } -.app-select-trigger[data-state='open'], .app-select-trigger:focus { outline: none; border-color: var(--text-muted); @@ -4045,22 +4020,13 @@ button.agent-head-more:hover:not(:disabled) { color: transparent; } -.app-select-trigger[data-state='open'] .app-select-chevron { - transform: rotate(180deg); - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8' fill='none'%3E%3Cpath d='M1.5 1.75L6 6.25L10.5 1.75' stroke='%23e0e0e0' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); -} - .app-select-content { z-index: 1300; overflow: hidden; - background: var(--glass-bg); + background: var(--bg-elevated); border: 1px solid var(--glass-border); border-radius: var(--radius-card); box-shadow: var(--shadow-modal); - -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); - backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); - width: var(--radix-select-trigger-width); - max-height: min(280px, var(--radix-select-content-available-height)); } .app-select-viewport { @@ -4109,15 +4075,6 @@ button.agent-head-more:hover:not(:disabled) { color: var(--text-bright); } -.app-select-option[data-state='checked'] { - color: var(--text-bright); - background: var(--bg-hover-strong); -} - -.app-select-option[data-state='checked'][data-highlighted] { - background: var(--accent-primary); -} - .app-select-option[data-disabled] { opacity: 0.45; pointer-events: none; @@ -4410,13 +4367,12 @@ button.session-transfer-toggle.is-active .session-transfer-badge { max-height: 480px; display: flex; flex-direction: column; - background: var(--bg-surface); + background: var(--bg-elevated); border: 1px solid var(--border-subtle); border-radius: var(--radius-modal, 14px); box-shadow: var(--shadow-modal); z-index: 10000; overflow: hidden; - backdrop-filter: blur(20px); } .transfer-center-header { diff --git a/src/renderer/src/components/AboutModal.tsx b/src/renderer/src/components/AboutModal.tsx index 3979ae9..4b37f5f 100644 --- a/src/renderer/src/components/AboutModal.tsx +++ b/src/renderer/src/components/AboutModal.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { ModalShell, useModalClose } from './ModalShell' +import { DialogTitle } from '../ui/dialog' import logoUrl from '../assets/logo.png' interface AboutModalProps { @@ -23,9 +24,9 @@ function AboutModalBody({ onBack }: { onBack?: () => void }): React.JSX.Element return ( <>
-

+ {t('about.title')} -

+ - -
- - + + + + + + + {title} + + + {message} + +
+ + {cancelLabel ?? t('common.cancel')} + + +
+
+
+
+
+
) } diff --git a/src/renderer/src/components/HostForm.tsx b/src/renderer/src/components/HostForm.tsx index ebfee67..95b4c8c 100644 --- a/src/renderer/src/components/HostForm.tsx +++ b/src/renderer/src/components/HostForm.tsx @@ -3,6 +3,8 @@ import { useTranslation } from 'react-i18next' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faFolderOpen } from '@fortawesome/free-solid-svg-icons' import type { AuthMethod, HostInput } from '../../../shared/types' +import { FormField } from '../ui/field' +import { Input } from '../ui/input' import { PasswordField } from './PasswordField' import { Select } from './Select' @@ -91,9 +93,8 @@ export function HostForm({ initial, onSubmit, onCancel }: HostFormProps): React.

{t('hostsPicker.basicInfo')}

- +
- + - +
- +

{t('hostsPicker.authConfig')}

-
- {t('form.auth')} + { @@ -203,7 +199,7 @@ export function HostForm({ initial, onSubmit, onCancel }: HostFormProps): React. {t('form.browse')}
- + )}
@@ -215,20 +211,11 @@ export function HostForm({ initial, onSubmit, onCancel }: HostFormProps): React.
-
diff --git a/src/renderer/src/components/ModalShell.tsx b/src/renderer/src/components/ModalShell.tsx index 42d8dc3..55a2f73 100644 --- a/src/renderer/src/components/ModalShell.tsx +++ b/src/renderer/src/components/ModalShell.tsx @@ -1,12 +1,13 @@ -import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react' -import { createPortal } from 'react-dom' - -const CLOSE_FALLBACK_MS = 400 -const CLOSE_FALLBACK_SIMPLE_MS = 180 - -type Phase = 'preenter' | 'open' | 'closing' - -export type ModalMotion = 'default' | 'simple' | 'none' +import { createContext, useCallback, useContext, useLayoutEffect, useRef, useState } from 'react' +import { + DialogBackdrop, + DialogPopup, + DialogPortal, + DialogRoot, + DialogViewport, + type DialogChangeDetails, + type DialogPartState +} from '../ui/dialog' const ModalCloseContext = createContext<() => void>(() => {}) @@ -14,10 +15,14 @@ export function useModalClose(): () => void { return useContext(ModalCloseContext) } +export type ModalMotion = 'default' | 'simple' | 'none' + interface ModalShellProps { onClose: () => void children: React.ReactNode dialogClassName?: string + /** Extra class for the dimmed overlay (e.g. a higher stacking order). */ + overlayClassName?: string labelledBy?: string /** Default true. Set false while a nested form owns Escape, or during busy connect. */ closeOnEscape?: boolean @@ -25,142 +30,130 @@ interface ModalShellProps { closeOnOverlayClick?: boolean /** Modal animation variant: 'default' (scale+fade), 'simple' (fade only), 'none' (instant). */ motion?: ModalMotion + /** Element focused when the modal opens. Defaults to the first focusable child. */ + initialFocus?: React.RefObject } function prefersReducedMotion(): boolean { return window.matchMedia('(prefers-reduced-motion: reduce)').matches } +/** + * Maps the Base UI transition phase onto the legacy visual states that + * App.css animates: `is-open` while the dialog is fully open, `is-closing` + * while it plays the exit transition and nothing while it enters. + */ +function transitionClass(state: DialogPartState): string { + if (state.transitionStatus === 'ending') return 'is-closing' + if (state.transitionStatus === 'starting') return '' + return 'is-open' +} + export function ModalShell({ onClose, children, dialogClassName = '', + overlayClassName = '', labelledBy, closeOnEscape = true, closeOnOverlayClick = true, - motion = 'default' + motion = 'default', + initialFocus }: ModalShellProps): React.JSX.Element { - const isNoneMotion = motion === 'none' || prefersReducedMotion() - const [phase, setPhase] = useState(() => (isNoneMotion ? 'open' : 'preenter')) - const closingRef = useRef(false) + // Reduced motion users get the dialog without an enter transition, everyone + // else mounts closed and opens on the next layout tick so Base UI runs the + // `data-starting-style` phase. + const [open, setOpen] = useState(() => prefersReducedMotion()) const onCloseRef = useRef(onClose) - const timeoutRef = useRef(null) const closeOnEscapeRef = useRef(closeOnEscape) + const closeOnOverlayClickRef = useRef(closeOnOverlayClick) onCloseRef.current = onClose closeOnEscapeRef.current = closeOnEscape + closeOnOverlayClickRef.current = closeOnOverlayClick - useEffect(() => { - if (isNoneMotion) { - setPhase('open') - return - } - const id = requestAnimationFrame(() => { - requestAnimationFrame(() => setPhase('open')) - }) - return () => cancelAnimationFrame(id) - }, [isNoneMotion]) - - const finishClose = useCallback(() => { - if (!closingRef.current) return - closingRef.current = false - if (timeoutRef.current != null) { - window.clearTimeout(timeoutRef.current) - timeoutRef.current = null - } - onCloseRef.current() + useLayoutEffect(() => { + setOpen(true) }, []) const requestClose = useCallback(() => { - if (closingRef.current) return - closingRef.current = true - if (isNoneMotion) { - onCloseRef.current() - closingRef.current = false + setOpen(false) + }, []) + + const handleOpenChange = useCallback((nextOpen: boolean, details: DialogChangeDetails) => { + if (nextOpen) { + setOpen(true) return } - setPhase('closing') - const fallback = motion === 'simple' ? CLOSE_FALLBACK_SIMPLE_MS : CLOSE_FALLBACK_MS - timeoutRef.current = window.setTimeout(finishClose, fallback) - }, [finishClose, isNoneMotion, motion]) - - useEffect(() => { - const onKey = (e: KeyboardEvent): void => { - if (e.key === 'Escape' && closeOnEscapeRef.current) requestClose() + if (details.reason === 'escape-key' && !closeOnEscapeRef.current) { + // A nested form owns Escape (SFTP editor, host picker form): keep the + // key event alive so the window-level handler still sees it. + details.cancel() + details.allowPropagation() + return } - window.addEventListener('keydown', onKey) - return () => window.removeEventListener('keydown', onKey) - }, [requestClose]) - - useEffect(() => { - return () => { - if (timeoutRef.current != null) window.clearTimeout(timeoutRef.current) + if (details.reason === 'outside-press' && !closeOnOverlayClickRef.current) { + details.cancel() + return } + setOpen(false) }, []) - const onDialogTransitionEnd = (e: React.TransitionEvent): void => { - if (e.target !== e.currentTarget) return - if (phase !== 'closing') return - if (e.propertyName !== 'opacity') return - finishClose() - } - - const overlayClass = [ - 'modal-overlay', - motion === 'default' ? 'modal-overlay--animated' : '', - motion === 'simple' ? 'modal-overlay--simple' : '', - phase === 'open' ? 'is-open' : '', - phase === 'closing' ? 'is-closing' : '' - ] - .filter(Boolean) - .join(' ') - - const dialogClass = [ - 'modal', - motion === 'default' ? 'modal--animated' : '', - motion === 'simple' ? 'modal-motion-simple' : '', - dialogClassName, - phase === 'open' ? 'is-open' : '', - phase === 'closing' ? 'is-closing' : '' - ] - .filter(Boolean) - .join(' ') - - const overlayPointerDownRef = useRef(false) + const handleOpenChangeComplete = useCallback((nextOpen: boolean) => { + // The exit transition finished — now hand control back to the owner. + if (!nextOpen) onCloseRef.current() + }, []) - const onOverlayPointerDown = (e: React.PointerEvent): void => { - // Only count presses that start on the dimmed backdrop, not the dialog. - overlayPointerDownRef.current = e.target === e.currentTarget - } + const overlayClass = useCallback( + (state: DialogPartState) => + [ + 'modal-overlay', + overlayClassName, + motion === 'default' ? 'modal-overlay--animated' : '', + motion === 'simple' ? 'modal-overlay--simple' : '', + transitionClass(state) + ] + .filter(Boolean) + .join(' '), + [motion, overlayClassName] + ) - const onOverlayClick = (e: React.MouseEvent): void => { - if (!closeOnOverlayClick) return - // Ignore click if the gesture started inside the dialog (e.g. text selection - // drag that ends on the backdrop) — that used to close the host picker. - if (!overlayPointerDownRef.current) return - if (e.target !== e.currentTarget) return - requestClose() - } + const dialogClass = useCallback( + (state: DialogPartState) => + [ + 'modal', + motion === 'default' ? 'modal--animated' : '', + motion === 'simple' ? 'modal-motion-simple' : '', + dialogClassName, + transitionClass(state) + ] + .filter(Boolean) + .join(' '), + [dialogClassName, motion] + ) - return createPortal( + return ( -
-
e.stopPropagation()} - onTransitionEnd={onDialogTransitionEnd} - > - {children} -
-
-
, - document.body + + + + + {children} + + + + + + ) } diff --git a/src/renderer/src/components/ModelIdEditor.tsx b/src/renderer/src/components/ModelIdEditor.tsx index faa750b..789e1a6 100644 --- a/src/renderer/src/components/ModelIdEditor.tsx +++ b/src/renderer/src/components/ModelIdEditor.tsx @@ -2,6 +2,9 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faPlus, faXmark } from '@fortawesome/free-solid-svg-icons' +import { Button } from '../ui/button' +import { FormFieldRoot } from '../ui/field' +import { Input } from '../ui/input' export const DEFAULT_MAX_ITEMS = 32 export const DEFAULT_MAX_ITEM_LENGTH = 512 @@ -115,39 +118,39 @@ export function ModelIdEditor({ const isLimitReached = value.length >= maxItems return ( -
-
+
+ {value.length > 0 && (
{value.map((model, index) => ( - + {model} - + ))}
)}
- { @@ -159,8 +162,7 @@ export function ModelIdEditor({ onPaste={handlePaste} spellCheck={false} /> - +
-
+
diff --git a/src/renderer/src/components/PermissionModal.tsx b/src/renderer/src/components/PermissionModal.tsx index 7d0bbbb..1ef3282 100644 --- a/src/renderer/src/components/PermissionModal.tsx +++ b/src/renderer/src/components/PermissionModal.tsx @@ -1,6 +1,7 @@ -import { useEffect, useRef } from 'react' +import { useRef } from 'react' import { useTranslation } from 'react-i18next' import type { PermissionAskEvent, PermissionDecision } from '../../../shared/types' +import { ModalShell } from './ModalShell' interface PermissionModalProps { request: PermissionAskEvent @@ -32,40 +33,15 @@ export function PermissionModal({ variant = 'modal' }: PermissionModalProps): React.JSX.Element { const { t } = useTranslation() - const overlayPointerDownRef = useRef(false) const inline = variant === 'inline' - - useEffect(() => { - if (inline) return - const onKey = (e: KeyboardEvent): void => { - if (e.key === 'Escape') onDecide('deny') - } - window.addEventListener('keydown', onKey) - return () => window.removeEventListener('keydown', onKey) - }, [inline, onDecide]) + const denyRef = useRef(null) const source = request.source === 'mcp' ? t('permission.sourceMcp') : t('permission.sourceAgent') const btnClass = inline ? 'btn-sm' : undefined - const dialog = ( -
e.stopPropagation()} - onKeyDown={ - inline - ? (e) => { - if (e.key !== 'Escape') return - e.preventDefault() - e.stopPropagation() - onDecide('deny') - } - : undefined - } - > + const body = ( + <>

{t('permission.title')}

@@ -86,6 +62,7 @@ export function PermissionModal({
-
+ ) - if (inline) return dialog + if (inline) { + return ( +
{ + if (e.key !== 'Escape') return + e.preventDefault() + e.stopPropagation() + onDecide('deny') + }} + > + {body} +
+ ) + } return ( -
{ - overlayPointerDownRef.current = e.target === e.currentTarget - }} - onClick={(e) => { - if (!overlayPointerDownRef.current) return - if (e.target !== e.currentTarget) return - onDecide('deny') - }} + onDecide('deny')} + dialogClassName="confirm-modal permission-modal" + overlayClassName="permission-modal-overlay" + labelledBy="permission-modal-title" + motion="none" + initialFocus={denyRef} > - {dialog} -
+ {body} + ) } diff --git a/src/renderer/src/components/Select.tsx b/src/renderer/src/components/Select.tsx index b672c39..0fbf69d 100644 --- a/src/renderer/src/components/Select.tsx +++ b/src/renderer/src/components/Select.tsx @@ -1,78 +1,2 @@ -import type { ReactNode } from 'react' -import * as RadixSelect from '@radix-ui/react-select' - -export interface SelectOption { - value: string - label: string -} - -export interface SelectGroup { - label: string - options: SelectOption[] -} - -interface SelectProps { - value: string - options?: SelectOption[] - groups?: SelectGroup[] - onChange: (value: string) => void - disabled?: boolean - placeholder?: string - 'aria-label'?: string - id?: string - className?: string -} - -function renderOptions(options: SelectOption[]): ReactNode { - return options.map((opt) => ( - - {opt.label} - - )) -} - -export function Select({ - value, - options = [], - groups, - onChange, - disabled = false, - placeholder, - id, - className, - 'aria-label': ariaLabel -}: SelectProps): React.JSX.Element { - const triggerClass = className ? `app-select-trigger ${className}` : 'app-select-trigger' - return ( - - - - - - - - - - e.stopPropagation()} - > - ▴ - - {groups && groups.length > 0 - ? groups.map((group, i) => ( - - {group.label} - {renderOptions(group.options)} - - )) - : renderOptions(options)} - - ▾ - - - - ) -} +export { Select } from '../ui/select' +export type { SelectGroup, SelectOption, SelectProps } from '../ui/select' diff --git a/src/renderer/src/components/SettingsModal.tsx b/src/renderer/src/components/SettingsModal.tsx index b7d8acb..4ced746 100644 --- a/src/renderer/src/components/SettingsModal.tsx +++ b/src/renderer/src/components/SettingsModal.tsx @@ -1,59 +1,16 @@ -import { useEffect, useState } from 'react' +import { useState } from 'react' import { useTranslation } from 'react-i18next' import type { - AgentConfigStatus, - AgentContextCompression, - AgentProviderStatus, - AgentThinkingStrength, - AppSettings, LanguageCode, - McpManualConfig, McpPermissionMode, - McpRegistrationTargetStatus, - McpSnippetFormat, PermissionPolicy, ThemePreference } from '../../../shared/types' -import { ModelIdEditor } from './ModelIdEditor' import { ModalShell, useModalClose } from './ModalShell' -import { Select } from './Select' - -const MCP_SNIPPET_FORMATS: McpSnippetFormat[] = ['standard', 'vscode', 'opencode', 'codex'] - -/** Lower bound of the agent context budget in tokens (0 = unlimited). */ -const AGENT_CONTEXT_WINDOW_MIN = 4096 -/** Upper bound of the agent context budget in tokens. */ -const AGENT_CONTEXT_WINDOW_MAX = 1048576 - -/** - * Clamp the estimated context budget the same way the settings store does: - * 0 stays "unlimited", everything else is clamped into - * [AGENT_CONTEXT_WINDOW_MIN, AGENT_CONTEXT_WINDOW_MAX]. - */ -function clampAgentContextWindow(value: number): number { - if (!Number.isFinite(value)) return 0 - const rounded = Math.round(value) - if (rounded === 0) return 0 - return Math.min(AGENT_CONTEXT_WINDOW_MAX, Math.max(AGENT_CONTEXT_WINDOW_MIN, rounded)) -} - -function normalizeThinkingStrength(value: string | undefined): AgentThinkingStrength { - if (value === 'low' || value === 'medium' || value === 'high') return value - return 'auto' -} - -function normalizeContextCompression(value: string | undefined): AgentContextCompression { - return value === 'auto' ? 'auto' : 'off' -} - -function formatMcpLaunchCommand(command: string, args: string[]): string { - const quote = (s: string): string => { - if (s === '') return '""' - if (/[\s"]/.test(s)) return `"${s.replace(/"/g, '\\"')}"` - return s - } - return [command, ...args].map(quote).join(' ') -} +import { AgentRuntimeSettingsSection } from './settings/AgentRuntimeSettingsSection' +import { AgentSettingsSection } from './settings/AgentSettingsSection' +import { GeneralSettingsSection } from './settings/GeneralSettingsSection' +import { McpSettingsSection } from './settings/McpSettingsSection' interface SettingsModalProps { language: LanguageCode @@ -75,873 +32,8 @@ interface SettingsModalProps { onClose: () => void } -type ProviderDraft = { - key: string - id: string - name: string - baseUrl: string - models: string[] - apiKey: string - hasKey: boolean -} - -let draftSeq = 0 -function nextDraftKey(): string { - draftSeq += 1 - return `draft-${draftSeq}` -} - -function draftsFromStatus(status: AgentConfigStatus): ProviderDraft[] { - return status.providers.map((p) => ({ - key: p.id, - id: p.id, - name: p.name, - baseUrl: p.baseUrl, - models: [...p.models], - apiKey: '', - hasKey: p.hasKey - })) -} - -function applyStatusToDrafts(prev: ProviderDraft[], status: AgentConfigStatus): ProviderDraft[] { - const byId = new Map(status.providers.map((p) => [p.id, p])) - const next: ProviderDraft[] = [] - const seen = new Set() - for (const draft of prev) { - if (!draft.id) { - next.push(draft) - continue - } - const p = byId.get(draft.id) - if (!p) continue - seen.add(p.id) - next.push({ - ...draft, - name: p.name, - baseUrl: p.baseUrl, - models: [...p.models], - apiKey: '', - hasKey: p.hasKey - }) - } - for (const p of status.providers) { - if (seen.has(p.id)) continue - next.push({ - key: p.id, - id: p.id, - name: p.name, - baseUrl: p.baseUrl, - models: [...p.models], - apiKey: '', - hasKey: p.hasKey - }) - } - return next -} - -/** - * Agent provider settings. API keys are write-only: the backend stores them - * in the OS keyring and reports back only whether one exists, so the renderer - * never holds the secret and the field starts empty on every open. The - * section is skipped entirely when the running bridge has no agent. - */ -function AgentSettingsSection(): React.JSX.Element | null { - const { t } = useTranslation() - const agent = window.api.agent - const [drafts, setDrafts] = useState([]) - const [expanded, setExpanded] = useState([]) - const [busy, setBusy] = useState(false) - const [ready, setReady] = useState(false) - const [message, setMessage] = useState(null) - - const patchDraft = (key: string, patch: Partial): void => { - setDrafts((prev) => prev.map((d) => (d.key === key ? { ...d, ...patch } : d))) - } - - const toggleExpanded = (key: string): void => { - setExpanded((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key])) - } - - useEffect(() => { - if (!agent) return - void (async () => { - try { - const status = await agent.status() - setDrafts(draftsFromStatus(status)) - } catch (err) { - setMessage(err instanceof Error ? err.message : String(err)) - } finally { - setReady(true) - } - })() - }, [agent]) - - if (!agent) return null - - const save = async (draft: ProviderDraft): Promise => { - setBusy(true) - setMessage(null) - try { - let status = await agent.upsertProvider({ - ...(draft.id ? { id: draft.id } : {}), - name: draft.name, - baseUrl: draft.baseUrl, - models: draft.models - }) - const saved = - draft.id !== '' - ? status.providers.find((p) => p.id === draft.id) - : newestProvider(status.providers, drafts) - if (draft.apiKey !== '' && saved) { - status = await agent.setProviderKey(saved.id, draft.apiKey) - } - setDrafts((prev) => applyStatusToDrafts(prev.filter((d) => d.key !== draft.key || d.id !== ''), status)) - setExpanded((prev) => prev.filter((k) => k !== draft.key && k !== saved?.id)) - setMessage(t('settings.agentSaved')) - } catch (err) { - setMessage(err instanceof Error ? err.message : String(err)) - } finally { - setBusy(false) - } - } - - const clearKey = async (draft: ProviderDraft): Promise => { - if (!draft.id) return - setBusy(true) - setMessage(null) - try { - const status = await agent.setProviderKey(draft.id, '') - setDrafts((prev) => applyStatusToDrafts(prev, status)) - setMessage(t('settings.agentKeyCleared')) - } catch (err) { - setMessage(err instanceof Error ? err.message : String(err)) - } finally { - setBusy(false) - } - } - - const remove = async (draft: ProviderDraft): Promise => { - setBusy(true) - setMessage(null) - try { - if (!draft.id) { - setDrafts((prev) => prev.filter((d) => d.key !== draft.key)) - setExpanded((prev) => prev.filter((k) => k !== draft.key)) - } else { - const status = await agent.deleteProvider(draft.id) - setDrafts((prev) => applyStatusToDrafts(prev, status)) - setExpanded((prev) => prev.filter((k) => k !== draft.key && k !== draft.id)) - setMessage(t('settings.agentProviderDeleted')) - } - } catch (err) { - setMessage(err instanceof Error ? err.message : String(err)) - } finally { - setBusy(false) - } - } - - const addProvider = (): void => { - const key = nextDraftKey() - setDrafts((prev) => [ - ...prev, - { - key, - id: '', - name: '', - baseUrl: '', - models: [], - apiKey: '', - hasKey: false - } - ]) - setExpanded((prev) => [...prev, key]) - } - - return ( -
- {t('settings.agent')} -

{t('settings.agentHint')}

- - {drafts.map((draft, index) => { - const open = expanded.includes(draft.key) - const title = draft.name.trim() || t('settings.agentNewProvider') - return ( -
- -
-
-
-
- {t('settings.agentName')} - patchDraft(draft.key, { name: e.target.value })} - /> -
-
- {t('settings.agentBaseUrl')} - patchDraft(draft.key, { baseUrl: e.target.value })} - /> -
-
- {t('settings.agentModels')} - patchDraft(draft.key, { models })} - /> -
-
- {t('settings.agentApiKey')} - patchDraft(draft.key, { apiKey: e.target.value })} - /> -
-
- - - -
-
-
-
-
- ) - })} - -
- -
- {message &&

{message}

} -
- ) -} - -function newestProvider( - providers: AgentProviderStatus[], - drafts: ProviderDraft[] -): AgentProviderStatus | undefined { - const known = new Set(drafts.map((d) => d.id).filter(Boolean)) - return providers.find((p) => !known.has(p.id)) ?? providers[providers.length - 1] -} - -/** - * Agent runtime settings. These are global (not per provider), so they live - * in settings.json and are read and written through the existing settings - * API — the provider CRUD below stays untouched. - */ -function AgentRuntimeSettingsSection(): React.JSX.Element { - const { t } = useTranslation() - const [contextWindow, setContextWindow] = useState('0') - const [savedContextWindow, setSavedContextWindow] = useState(0) - const [thinkingStrength, setThinkingStrength] = useState('auto') - const [compression, setCompression] = useState('off') - const [ready, setReady] = useState(false) - const [busy, setBusy] = useState(false) - const [message, setMessage] = useState(null) - - useEffect(() => { - void (async () => { - try { - const settings = await window.api.settings.get() - const tokens = settings?.agentContextWindow ?? 0 - setSavedContextWindow(tokens) - setContextWindow(String(tokens)) - setThinkingStrength(normalizeThinkingStrength(settings?.agentThinkingStrength)) - setCompression(normalizeContextCompression(settings?.agentContextCompression)) - } catch (err) { - setMessage(err instanceof Error ? err.message : String(err)) - } finally { - setReady(true) - } - })() - }, []) - - /** Persist one field; returns false when the backend rejected the write. */ - const persist = async (patch: Partial): Promise => { - setBusy(true) - setMessage(null) - try { - await window.api.settings.set(patch) - setMessage(t('settings.agentRuntimeSaved')) - return true - } catch (err) { - setMessage(err instanceof Error ? err.message : String(err)) - return false - } finally { - setBusy(false) - } - } - - const commitContextWindow = async (): Promise => { - const raw = contextWindow.trim() - const parsed = raw === '' ? Number.NaN : Number(raw) - if (!Number.isFinite(parsed)) { - setContextWindow(String(savedContextWindow)) - return - } - const next = clampAgentContextWindow(parsed) - if (next === savedContextWindow) { - setContextWindow(String(next)) - return - } - const saved = await persist({ agentContextWindow: next }) - setSavedContextWindow(saved ? next : savedContextWindow) - setContextWindow(String(saved ? next : savedContextWindow)) - } - - return ( -
- {t('settings.agentRuntime')} -

{t('settings.agentRuntimeHint')}

- -
- {t('settings.agentContextWindow')} - setContextWindow(e.target.value)} - onBlur={() => void commitContextWindow()} - onKeyDown={(e) => { - if (e.key === 'Enter') void commitContextWindow() - }} - /> -
-

{t('settings.agentContextWindowHint')}

- -
- {t('settings.agentThinkingStrength')} - { - const next = normalizeContextCompression(v) - setCompression(next) - void persist({ agentContextCompression: next }) - }} - options={[ - { value: 'off', label: t('settings.agentCompressionOff') }, - { value: 'auto', label: t('settings.agentCompressionAuto') } - ]} - /> -
- - {message &&

{message}

} -
- ) -} - type SettingsPage = 'index' | 'general' | 'agent' | 'mcp' -function GeneralSettingsSection({ - language, - themePreference, - terminalFontFamily, - terminalFontSize, - permissionPolicy, - onLanguageChange, - onThemePreferenceChange, - onTerminalFontFamilyChange, - onTerminalFontSizeChange, - onPermissionPolicyChange -}: Pick< - SettingsModalProps, - | 'language' - | 'themePreference' - | 'terminalFontFamily' - | 'terminalFontSize' - | 'permissionPolicy' - | 'onLanguageChange' - | 'onThemePreferenceChange' - | 'onTerminalFontFamilyChange' - | 'onTerminalFontSizeChange' - | 'onPermissionPolicyChange' ->): React.JSX.Element { - const { t } = useTranslation() - const [fonts, setFonts] = useState([]) - - useEffect(() => { - void (async () => { - try { - const list = await window.api.fonts.list() - setFonts(list) - } catch { - setFonts([]) - } - })() - }, []) - - const fontOptions = - fonts.includes(terminalFontFamily) || !terminalFontFamily - ? fonts - : [terminalFontFamily, ...fonts] - const fontSelectOptions = - fontOptions.length === 0 && terminalFontFamily - ? [{ value: terminalFontFamily, label: terminalFontFamily }] - : fontOptions.map((name) => ({ value: name, label: name })) - const fontSize = Math.min(24, Math.max(10, Math.round(terminalFontSize) || 14)) - const fontSizeOptions = Array.from({ length: 15 }, (_, i) => { - const size = 10 + i - return { value: String(size), label: String(size) } - }) - - return ( -
-
- {t('common.language')} - onThemePreferenceChange(v as ThemePreference)} - aria-label={t('settings.theme')} - options={[ - { value: 'system', label: t('settings.themeSystem') }, - { value: 'light', label: t('settings.themeLight') }, - { value: 'dark', label: t('settings.themeDark') } - ]} - /> -
- - -
- {t('settings.terminal')} -
- {t('settings.fontFamily')} - { - const n = Number(v) - if (!Number.isFinite(n)) return - onTerminalFontSizeChange(n) - }} - aria-label={t('settings.fontSize')} - options={fontSizeOptions} - /> -
-
- -
- {t('settings.permissions')} -

{t('settings.permissionsHint')}

-
- {t('settings.permissionPolicy')} - onMcpPermissionModeChange(v as McpPermissionMode)} - aria-label={t('settings.mcpPermissionMode')} - options={[ - { value: 'external', label: t('settings.mcpPermissionExternal') }, - { value: 'local', label: t('settings.mcpPermissionLocal') } - ]} - /> -
-

- {mcpPermissionMode === 'local' - ? t('settings.mcpPermissionLocalHint') - : t('settings.mcpPermissionExternalHint')} -

-
-
- {t('settings.mcpIdleTimeout')} - { - const n = Number(v) - if (!Number.isFinite(n)) return - onMcpMaxSessionsChange(n) - }} - aria-label={t('settings.mcpMaxSessions')} - options={maxSessionOptions} - /> -
-
- -
-
{t('settings.mcpRegisterTitle')}
-

{t('settings.mcpRegisterHint')}

-
- -
-
    - {mcpTargets.map((row) => { - const statusLabel = row.registered - ? t('settings.mcpStatusRegistered') - : row.stale - ? t('settings.mcpStatusStale') - : t('settings.mcpStatusMissing') - return ( -
  • -
    - {row.label} - {row.configPath ? ( - - {row.configPath} - - ) : null} - - {statusLabel} - -
    - -
  • - ) - })} -
-
- -
-
{t('settings.mcpOtherTitle')}
-

{t('settings.mcpOtherHint')}

-
- - -
-
- {MCP_SNIPPET_FORMATS.map((format) => ( - - ))} -
-