diff --git a/package-lock.json b/package-lock.json index 08218b183..3348605f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,16 +26,24 @@ "@tauri-apps/plugin-deep-link": "^2.4.5", "@tauri-apps/plugin-dialog": "^2.4.2", "@tauri-apps/plugin-shell": "^2.0.0", + "@types/lodash-es": "^4.17.12", "@types/styled-components": "^5.1.36", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-search": "^0.16.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "date-fns": "^4.1.0", "i18next": "^25.7.3", + "jotai": "^2.16.1", + "lodash-es": "^4.17.22", "lucide-react": "^0.460.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-i18next": "^16.5.1", "react-markdown": "^10.1.0", + "react-resizable-panels": "^4.3.0", "react-router-dom": "^7.11.0", "react-syntax-highlighter": "^16.1.0", "rehype-katex": "^7.0.1", @@ -154,7 +162,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", @@ -169,7 +177,7 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -179,7 +187,7 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", @@ -210,7 +218,7 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.28.5", @@ -227,7 +235,7 @@ "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/compat-data": "^7.27.2", @@ -244,7 +252,7 @@ "version": "7.28.0", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -254,7 +262,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/traverse": "^7.27.1", @@ -268,7 +276,7 @@ "version": "7.28.3", "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.27.1", @@ -296,7 +304,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -306,7 +314,7 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -316,7 +324,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -326,7 +334,7 @@ "version": "7.28.4", "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/template": "^7.27.2", @@ -340,7 +348,7 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/types": "^7.28.5" @@ -397,7 +405,7 @@ "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", @@ -412,7 +420,7 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", @@ -431,7 +439,7 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -1367,7 +1375,7 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -1378,7 +1386,7 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -1389,7 +1397,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -1399,14 +1407,14 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -3499,6 +3507,21 @@ "integrity": "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==", "license": "MIT" }, + "node_modules/@types/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-FOvQ0YPD5NOfPgMzJihoT+Za5pdkDJWcbpuj1DjaKZIr/gxodQjY/uWEFlTNqW2ugXHUiL8lRQgw63dzKHZdeQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -3946,6 +3969,33 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "license": "MIT" + }, + "node_modules/@xterm/addon-search": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0.tgz", + "integrity": "sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA==", + "license": "MIT" + }, + "node_modules/@xterm/addon-web-links": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz", + "integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==", + "license": "MIT" + }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -4128,7 +4178,7 @@ "version": "2.9.7", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.7.tgz", "integrity": "sha512-k9xFKplee6KIio3IDbwj+uaCLpqzOwakOgmqzPezM0sFJlFKcg30vk2wOiAJtkTSfx0SSQDSe8q+mWA/fSH5Zg==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.js" @@ -4184,7 +4234,7 @@ "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", - "dev": true, + "devOptional": true, "funding": [ { "type": "opencollective", @@ -4260,7 +4310,7 @@ "version": "1.0.30001760", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001760.tgz", "integrity": "sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==", - "dev": true, + "devOptional": true, "funding": [ { "type": "opencollective", @@ -4464,7 +4514,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/cookie": { @@ -4715,7 +4765,7 @@ "version": "1.5.267", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/entities": { @@ -4791,7 +4841,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -5295,7 +5345,7 @@ "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -5978,6 +6028,35 @@ "jiti": "bin/jiti.js" } }, + "node_modules/jotai": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/jotai/-/jotai-2.16.1.tgz", + "integrity": "sha512-vrHcAbo3P7Br37C8Bv6JshMtlKMPqqmx0DDREtTjT4nf3QChDrYdbH+4ik/9V0cXA57dK28RkJ5dctYvavcIlg==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0", + "@babel/template": ">=7.0.0", + "@types/react": ">=17.0.0", + "react": ">=17.0.0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@babel/template": { + "optional": true + }, + "@types/react": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -6041,7 +6120,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "jsesc": "bin/jsesc" @@ -6082,7 +6161,7 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "json5": "lib/cli.js" @@ -6176,6 +6255,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash-es": { + "version": "4.17.22", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.22.tgz", + "integrity": "sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -6233,7 +6318,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "yallist": "^3.0.2" @@ -7256,7 +7341,7 @@ "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/normalize-path": { @@ -7923,6 +8008,16 @@ } } }, + "node_modules/react-resizable-panels": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-4.3.0.tgz", + "integrity": "sha512-F8LGxzK3tq5N4m1oSvTZSDTH+QUhr5qPLuFhPyxVrOPqCFGZ1j2KOSx2K96aiKC4UI4kGR3/NKHczCumTgxVxA==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, "node_modules/react-router": { "version": "7.11.0", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.11.0.tgz", @@ -8319,7 +8414,7 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, + "devOptional": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -9460,7 +9555,7 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz", "integrity": "sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==", - "dev": true, + "devOptional": true, "funding": [ { "type": "opencollective", @@ -10482,7 +10577,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/yocto-queue": { diff --git a/package.json b/package.json index 1a10a80ec..f5fc7baea 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "proxycast", "private": true, - "version": "0.33.0", + "version": "0.34.0", "type": "module", "repository": { "type": "git", @@ -41,16 +41,24 @@ "@tauri-apps/plugin-deep-link": "^2.4.5", "@tauri-apps/plugin-dialog": "^2.4.2", "@tauri-apps/plugin-shell": "^2.0.0", + "@types/lodash-es": "^4.17.12", "@types/styled-components": "^5.1.36", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-search": "^0.16.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "date-fns": "^4.1.0", "i18next": "^25.7.3", + "jotai": "^2.16.1", + "lodash-es": "^4.17.22", "lucide-react": "^0.460.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-i18next": "^16.5.1", "react-markdown": "^10.1.0", + "react-resizable-panels": "^4.3.0", "react-router-dom": "^7.11.0", "react-syntax-highlighter": "^16.1.0", "rehype-katex": "^7.0.1", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index fba29324d..eda8d98df 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1428,10 +1428,21 @@ version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" dependencies = [ - "memoffset", + "memoffset 0.9.1", "rustc_version", ] +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "filetime" version = "0.2.26" @@ -2422,6 +2433,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "ioctl-rs" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7970510895cee30b3e9128319f2cefd4bde883a39f38baa279567ba3a7eb97d" +dependencies = [ + "libc", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -2762,6 +2782,15 @@ version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "memoffset" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" +dependencies = [ + "autocfg", +] + [[package]] name = "memoffset" version = "0.9.1" @@ -2894,6 +2923,20 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f346ff70e7dbfd675fe90590b92d59ef2de15a8779ae305ebcbfd3f0caf59be4" +dependencies = [ + "autocfg", + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset 0.6.5", + "pin-utils", +] + [[package]] name = "nix" version = "0.30.1" @@ -2904,7 +2947,7 @@ dependencies = [ "cfg-if", "cfg_aliases", "libc", - "memoffset", + "memoffset 0.9.1", ] [[package]] @@ -3601,6 +3644,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "portable-pty" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "806ee80c2a03dbe1a9fb9534f8d19e4c0546b790cde8fd1fea9d6390644cb0be" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix 0.25.1", + "serial", + "shared_library", + "shell-words", + "winapi", + "winreg 0.10.1", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -3720,7 +3784,7 @@ dependencies = [ [[package]] name = "proxycast" -version = "0.33.0" +version = "0.34.0" dependencies = [ "anyhow", "arboard", @@ -3746,6 +3810,7 @@ dependencies = [ "once_cell", "open", "parking_lot", + "portable-pty", "proptest", "rand 0.8.5", "regex", @@ -4539,6 +4604,48 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "serial" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1237a96570fc377c13baa1b88c7589ab66edced652e43ffb17088f003db3e86" +dependencies = [ + "serial-core", + "serial-unix", + "serial-windows", +] + +[[package]] +name = "serial-core" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f46209b345401737ae2125fe5b19a77acce90cd53e1658cda928e4fe9a64581" +dependencies = [ + "libc", +] + +[[package]] +name = "serial-unix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f03fbca4c9d866e24a459cbca71283f545a37f8e3e002ad8c70593871453cab7" +dependencies = [ + "ioctl-rs", + "libc", + "serial-core", + "termios", +] + +[[package]] +name = "serial-windows" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15c6d3b776267a75d31bbdfd5d36c0ca051251caafc285827052bc53bcdc8162" +dependencies = [ + "libc", + "serial-core", +] + [[package]] name = "serialize-to-javascript" version = "0.1.2" @@ -4613,6 +4720,22 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "1.3.0" @@ -5310,6 +5433,15 @@ dependencies = [ "utf-8", ] +[[package]] +name = "termios" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5d9cf598a6d7ce700a4e6a9199da127e6819a61e64b68609683cc9a01b5683a" +dependencies = [ + "libc", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -5804,7 +5936,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" dependencies = [ - "memoffset", + "memoffset 0.9.1", "tempfile", "winapi", ] @@ -7025,7 +7157,7 @@ dependencies = [ "futures-core", "futures-lite", "hex", - "nix", + "nix 0.30.1", "ordered-stream", "serde", "serde_repr", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f7552d693..9147a4fb4 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "proxycast" -version = "0.33.0" +version = "0.34.0" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" @@ -66,6 +66,7 @@ tokio-util = "0.7" arboard = "3" glob = "0.3.3" hex = "0.4.3" +portable-pty = "0.8" # Platform specific dependencies for browser interceptor diff --git a/src-tauri/src/README.md b/src-tauri/src/README.md index 14f9bbfd5..3c3701ec4 100644 --- a/src-tauri/src/README.md +++ b/src-tauri/src/README.md @@ -29,6 +29,7 @@ Tauri 后端核心代码,处理系统级功能和 API 服务。 - `services/` - 业务服务层 - `streaming/` - 流式响应处理 - `telemetry/` - 遥测和统计 +- `terminal/` - 终端核心模块(PTY 管理、会话管理) - `tray/` - 系统托盘 - `websocket/` - WebSocket 支持 - `lib.rs` - 库入口 diff --git a/src-tauri/src/agent/tools/mod.rs b/src-tauri/src/agent/tools/mod.rs index 2b0dba6ab..9d7a04433 100644 --- a/src-tauri/src/agent/tools/mod.rs +++ b/src-tauri/src/agent/tools/mod.rs @@ -1,7 +1,6 @@ //! Agent 工具系统模块 //! //! 提供工具定义、注册、执行的核心框架 -//! 参考 Claude Code 和 goose 项目的工具系统设计 //! //! ## 模块结构 //! - `types`: 工具类型定义(ToolDefinition, ToolCall, ToolResult 等) diff --git a/src-tauri/src/app/bootstrap.rs b/src-tauri/src/app/bootstrap.rs index 40a1e0e1b..0d90afa15 100644 --- a/src-tauri/src/app/bootstrap.rs +++ b/src-tauri/src/app/bootstrap.rs @@ -21,6 +21,7 @@ use crate::commands::plugin_install_cmd::PluginInstallerState; use crate::commands::provider_pool_cmd::{CredentialSyncServiceState, ProviderPoolServiceState}; use crate::commands::resilience_cmd::ResilienceConfigState; use crate::commands::skill_cmd::SkillServiceState; +use crate::commands::terminal_cmd::TerminalManagerState; use crate::config::{self, Config, ConfigManager, GlobalConfigManager, GlobalConfigManagerState}; use crate::database::{self, DbConnection}; use crate::flow_monitor::{ @@ -134,6 +135,7 @@ pub struct AppStates { pub connect_state: ConnectStateWrapper, pub model_registry: ModelRegistryState, pub global_config_manager: GlobalConfigManagerState, + pub terminal_manager: TerminalManagerState, // 用于 setup hook 的共享实例 pub shared_stats: Arc>, pub shared_tokens: Arc>, @@ -214,6 +216,9 @@ pub fn init_states(config: &Config) -> Result { // 初始化 Model Registry 状态(延迟初始化,在 setup hook 中完成) let model_registry_state: ModelRegistryState = Arc::new(RwLock::new(None)); + // 初始化终端管理器状态(延迟初始化,在 setup hook 中完成) + let terminal_manager_state = TerminalManagerState(Arc::new(RwLock::new(None))); + // 初始化全局配置管理器 let config_path = ConfigManager::default_config_path(); let global_config_manager = GlobalConfigManager::new(config.clone(), config_path); @@ -256,6 +261,7 @@ pub fn init_states(config: &Config) -> Result { connect_state, model_registry: model_registry_state, global_config_manager: global_config_manager_state, + terminal_manager: terminal_manager_state, shared_stats, shared_tokens, shared_logger, diff --git a/src-tauri/src/app/runner.rs b/src-tauri/src/app/runner.rs index b1a05bcf2..55b92d93e 100644 --- a/src-tauri/src/app/runner.rs +++ b/src-tauri/src/app/runner.rs @@ -73,6 +73,7 @@ pub fn run() { connect_state: connect_state, model_registry: model_registry_state, global_config_manager: global_config_manager_state, + terminal_manager: terminal_manager_state, shared_stats, shared_tokens, shared_logger, @@ -149,6 +150,7 @@ pub fn run() { .manage(connect_state) .manage(model_registry_state) .manage(global_config_manager_state) + .manage(terminal_manager_state) .on_window_event(move |window, event| { // 处理窗口关闭事件 if let tauri::WindowEvent::CloseRequested { api, .. } = event { @@ -262,6 +264,17 @@ pub fn run() { }); } + // 初始化终端会话管理器 + { + let app_handle = app.handle().clone(); + let terminal_manager = crate::terminal::TerminalSessionManager::new(app_handle.clone()); + if let Some(state) = app_handle.try_state::() { + let mut guard = state.inner().0.blocking_write(); + *guard = Some(terminal_manager); + tracing::info!("[启动] 终端会话管理器初始化成功"); + } + } + // 注册 Deep Link 事件处理器(仅 macOS) // _Requirements: 1.4_ #[cfg(target_os = "macos")] @@ -1029,6 +1042,13 @@ pub fn run() { commands::model_registry_cmd::get_models_by_tier, commands::model_registry_cmd::get_provider_alias_config, commands::model_registry_cmd::get_all_alias_configs, + // Terminal commands + commands::terminal_cmd::terminal_create_session, + commands::terminal_cmd::terminal_write, + commands::terminal_cmd::terminal_resize, + commands::terminal_cmd::terminal_close, + commands::terminal_cmd::terminal_list_sessions, + commands::terminal_cmd::terminal_get_session, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index c79946bb2..172b7d5d6 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -26,6 +26,7 @@ pub mod route_cmd; pub mod skill_cmd; pub mod switch_cmd; pub mod telemetry_cmd; +pub mod terminal_cmd; pub mod tray_cmd; pub mod usage_cmd; pub mod websocket_cmd; diff --git a/src-tauri/src/commands/plugin_rpc_cmd.rs b/src-tauri/src/commands/plugin_rpc_cmd.rs index dc417d5cf..329301140 100644 --- a/src-tauri/src/commands/plugin_rpc_cmd.rs +++ b/src-tauri/src/commands/plugin_rpc_cmd.rs @@ -5,17 +5,21 @@ //! - plugin_rpc_disconnect: 关闭插件进程 //! - plugin_rpc_call: 发送 RPC 请求并等待响应 //! +//! 支持异步通知:后端进程可以发送 JSON-RPC 通知,通过 Tauri 事件转发到前端。 +//! //! _需求: 插件 RPC 通信_ use crate::commands::plugin_install_cmd::PluginInstallerState; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashMap; -use std::io::{BufRead, BufReader, Write}; -use std::process::{Child, Command, Stdio}; +use std::process::Stdio; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; -use tokio::sync::{Mutex, RwLock}; +use tauri::Emitter; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::{Child, ChildStdin, Command}; +use tokio::sync::{mpsc, oneshot, Mutex, RwLock}; /// RPC 请求 ID 生成器 static REQUEST_ID: AtomicU64 = AtomicU64::new(1); @@ -30,18 +34,34 @@ struct JsonRpcRequest { } /// JSON-RPC 响应 -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Clone)] struct JsonRpcResponse { #[allow(dead_code)] jsonrpc: String, result: Option, error: Option, - #[allow(dead_code)] id: Option, } -/// JSON-RPC 错误 +/// JSON-RPC 通知(无 id 字段) +#[derive(Debug, Deserialize, Clone)] +struct JsonRpcNotification { + #[allow(dead_code)] + jsonrpc: String, + method: String, + params: Option, +} + +/// JSON-RPC 消息(可能是响应或通知) #[derive(Debug, Deserialize)] +#[serde(untagged)] +enum JsonRpcMessage { + Response(JsonRpcResponse), + Notification(JsonRpcNotification), +} + +/// JSON-RPC 错误 +#[derive(Debug, Deserialize, Clone)] struct JsonRpcError { code: i32, message: String, @@ -49,11 +69,26 @@ struct JsonRpcError { data: Option, } +/// RPC 通知事件 payload +#[derive(Debug, Clone, Serialize)] +struct RpcNotificationPayload { + plugin_id: String, + method: String, + params: Option, +} + +/// 待处理的 RPC 请求 +struct PendingRequest { + response_tx: oneshot::Sender>, +} + /// 插件进程信息 struct PluginProcess { - child: Child, #[allow(dead_code)] - plugin_id: String, + child: Child, + stdin: Arc>, + pending_requests: Arc>>, + shutdown_tx: Option>, } /// 插件 RPC 管理器状态 @@ -80,6 +115,7 @@ impl Default for PluginRpcManagerState { #[tauri::command] pub async fn plugin_rpc_connect( plugin_id: String, + app_handle: tauri::AppHandle, installer_state: tauri::State<'_, PluginInstallerState>, rpc_state: tauri::State<'_, PluginRpcManagerState>, ) -> Result<(), String> { @@ -107,7 +143,7 @@ pub async fn plugin_rpc_connect( .map_err(|e| format!("解析 manifest 失败: {}", e))?; // 获取二进制文件路径 - let binary_name = manifest["binary"]["binary_name"] + let _binary_name = manifest["binary"]["binary_name"] .as_str() .ok_or("manifest 中缺少 binary.binary_name")?; @@ -130,8 +166,8 @@ pub async fn plugin_rpc_connect( return Err(format!("二进制文件不存在: {:?}", binary_path)); } - // 启动进程 - let child = Command::new(&binary_path) + // 启动进程(使用 tokio::process::Command) + let mut child = Command::new(&binary_path) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -140,9 +176,106 @@ pub async fn plugin_rpc_connect( tracing::info!("插件 {} 进程已启动, PID: {:?}", plugin_id, child.id()); + // 获取 stdin 和 stdout + let stdin = child.stdin.take().ok_or("无法获取进程 stdin")?; + let stdout = child.stdout.take().ok_or("无法获取进程 stdout")?; + + let stdin = Arc::new(Mutex::new(stdin)); + let pending_requests: Arc>> = + Arc::new(Mutex::new(HashMap::new())); + + // 创建 shutdown channel + let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1); + + // 启动 stdout 读取任务 + let plugin_id_clone = plugin_id.clone(); + let pending_requests_clone = pending_requests.clone(); + let app_handle_clone = app_handle.clone(); + + tokio::spawn(async move { + let mut reader = BufReader::new(stdout); + let mut line = String::new(); + + loop { + line.clear(); + + tokio::select! { + // 检查 shutdown 信号 + _ = shutdown_rx.recv() => { + tracing::info!("插件 {} stdout 读取任务收到关闭信号", plugin_id_clone); + break; + } + // 读取一行 + result = reader.read_line(&mut line) => { + match result { + Ok(0) => { + // EOF + tracing::info!("插件 {} stdout EOF", plugin_id_clone); + break; + } + Ok(_) => { + let line_trimmed = line.trim(); + if line_trimmed.is_empty() { + continue; + } + + // 尝试解析为 JSON-RPC 消息 + match serde_json::from_str::(line_trimmed) { + Ok(JsonRpcMessage::Response(response)) => { + // 这是一个响应,找到对应的 pending request + if let Some(id) = response.id { + let mut pending = pending_requests_clone.lock().await; + if let Some(req) = pending.remove(&id) { + let result = if let Some(error) = response.error { + Err(format!("RPC 错误 [{}]: {}", error.code, error.message)) + } else { + Ok(response.result.unwrap_or(Value::Null)) + }; + let _ = req.response_tx.send(result); + } + } + } + Ok(JsonRpcMessage::Notification(notification)) => { + // 这是一个通知,发送 Tauri 事件 + let payload = RpcNotificationPayload { + plugin_id: plugin_id_clone.clone(), + method: notification.method.clone(), + params: notification.params, + }; + tracing::debug!( + "插件 {} 发送通知: {}", + plugin_id_clone, + notification.method + ); + if let Err(e) = app_handle_clone.emit("plugin-rpc-notification", &payload) { + tracing::error!("发送 Tauri 事件失败: {}", e); + } + } + Err(e) => { + tracing::warn!( + "插件 {} 收到无效 JSON: {} - {}", + plugin_id_clone, + e, + line_trimmed + ); + } + } + } + Err(e) => { + tracing::error!("插件 {} 读取 stdout 失败: {}", plugin_id_clone, e); + break; + } + } + } + } + } + }); + let process = PluginProcess { child, - plugin_id: plugin_id.clone(), + stdin, + pending_requests, + shutdown_tx: Some(shutdown_tx), }; // 保存进程 @@ -162,7 +295,14 @@ pub async fn plugin_rpc_disconnect( if let Some(process_arc) = processes.remove(&plugin_id) { let mut process = process_arc.lock().await; - if let Err(e) = process.child.kill() { + + // 发送 shutdown 信号 + if let Some(tx) = process.shutdown_tx.take() { + let _ = tx.send(()).await; + } + + // 终止进程 + if let Err(e) = process.child.kill().await { tracing::warn!("关闭插件 {} 进程失败: {}", plugin_id, e); } tracing::info!("插件 {} 进程已关闭", plugin_id); @@ -186,13 +326,13 @@ pub async fn plugin_rpc_call( .clone(); drop(processes); - let mut process = process_arc.lock().await; + let process = process_arc.lock().await; // 构建请求 let request_id = REQUEST_ID.fetch_add(1, Ordering::SeqCst); let request = JsonRpcRequest { jsonrpc: "2.0", - method, + method: method.clone(), params, id: request_id, }; @@ -200,28 +340,45 @@ pub async fn plugin_rpc_call( let request_json = serde_json::to_string(&request).map_err(|e| format!("序列化请求失败: {}", e))?; - // 发送请求 - let stdin = process.child.stdin.as_mut().ok_or("无法获取进程 stdin")?; - writeln!(stdin, "{}", request_json).map_err(|e| format!("发送请求失败: {}", e))?; - stdin - .flush() - .map_err(|e| format!("刷新 stdin 失败: {}", e))?; + // 创建响应 channel + let (response_tx, response_rx) = oneshot::channel(); - // 读取响应 - let stdout = process.child.stdout.as_mut().ok_or("无法获取进程 stdout")?; - let mut reader = BufReader::new(stdout); - let mut response_line = String::new(); - reader - .read_line(&mut response_line) - .map_err(|e| format!("读取响应失败: {}", e))?; - - // 解析响应 - let response: JsonRpcResponse = - serde_json::from_str(&response_line).map_err(|e| format!("解析响应失败: {}", e))?; - - if let Some(error) = response.error { - return Err(format!("RPC 错误 [{}]: {}", error.code, error.message)); + // 注册 pending request + { + let mut pending = process.pending_requests.lock().await; + pending.insert(request_id, PendingRequest { response_tx }); } - Ok(response.result.unwrap_or(Value::Null)) + // 发送请求 + { + let mut stdin = process.stdin.lock().await; + stdin + .write_all(request_json.as_bytes()) + .await + .map_err(|e| format!("发送请求失败: {}", e))?; + stdin + .write_all(b"\n") + .await + .map_err(|e| format!("发送换行失败: {}", e))?; + stdin + .flush() + .await + .map_err(|e| format!("刷新 stdin 失败: {}", e))?; + } + + // 释放 process lock,让 stdout 读取任务可以处理响应 + drop(process); + + // 等待响应(带超时) + match tokio::time::timeout(std::time::Duration::from_secs(30), response_rx).await { + Ok(Ok(result)) => result, + Ok(Err(_)) => Err("响应 channel 已关闭".to_string()), + Err(_) => { + // 超时,清理 pending request + let process = process_arc.lock().await; + let mut pending = process.pending_requests.lock().await; + pending.remove(&request_id); + Err(format!("RPC 调用 {} 超时", method)) + } + } } diff --git a/src-tauri/src/commands/terminal_cmd.rs b/src-tauri/src/commands/terminal_cmd.rs new file mode 100644 index 000000000..8d6cd8a0d --- /dev/null +++ b/src-tauri/src/commands/terminal_cmd.rs @@ -0,0 +1,148 @@ +//! 终端 Tauri 命令 +//! +//! 提供终端核心能力的 Tauri 命令接口。 +//! +//! ## 架构说明 +//! PTY 在后端预创建,使用默认大小 (24x80)。前端连接后通过 resize 同步实际大小。 +//! +//! ## 命令列表 +//! - `terminal_create_session` - 创建终端会话(使用默认大小) +//! - `terminal_write` - 向终端发送输入 +//! - `terminal_resize` - 调整终端大小 +//! - `terminal_close` - 关闭终端会话 +//! - `terminal_list_sessions` - 获取所有会话列表 + +use std::sync::Arc; + +use serde::Serialize; +use tauri::State; +use tokio::sync::RwLock; + +use crate::terminal::{SessionMetadata, TerminalSessionManager}; + +/// 终端会话管理器状态包装 +pub struct TerminalManagerState(pub Arc>>); + +/// 创建终端会话响应 +#[derive(Debug, Serialize)] +pub struct CreateSessionResponse { + /// 会话 ID + pub session_id: String, +} + +/// 创建终端会话(使用默认大小) +/// +/// PTY 使用默认大小 (24x80) 预创建,前端连接后通过 resize 同步实际大小。 +/// +/// # 返回 +/// - `Ok(CreateSessionResponse)`: 包含会话 ID +/// - `Err(String)`: 错误信息 +#[tauri::command] +pub async fn terminal_create_session( + state: State<'_, TerminalManagerState>, +) -> Result { + let guard = state.inner().0.read().await; + let manager = guard + .as_ref() + .ok_or_else(|| "终端管理器未初始化".to_string())?; + + let session_id = manager.create_session().await.map_err(|e| e.to_string())?; + + Ok(CreateSessionResponse { session_id }) +} + +/// 向终端发送输入 +/// +/// # 参数 +/// - `session_id`: 会话 ID +/// - `data`: Base64 编码的输入数据 +#[tauri::command] +pub async fn terminal_write( + state: State<'_, TerminalManagerState>, + session_id: String, + data: String, +) -> Result<(), String> { + let guard = state.inner().0.read().await; + let manager = guard + .as_ref() + .ok_or_else(|| "终端管理器未初始化".to_string())?; + + manager + .write_to_session_base64(&session_id, &data) + .await + .map_err(|e| e.to_string()) +} + +/// 调整终端大小 +/// +/// # 参数 +/// - `session_id`: 会话 ID +/// - `rows`: 新的行数 +/// - `cols`: 新的列数 +#[tauri::command] +pub async fn terminal_resize( + state: State<'_, TerminalManagerState>, + session_id: String, + rows: u16, + cols: u16, +) -> Result<(), String> { + let guard = state.inner().0.read().await; + let manager = guard + .as_ref() + .ok_or_else(|| "终端管理器未初始化".to_string())?; + + manager + .resize_session(&session_id, rows, cols) + .await + .map_err(|e| e.to_string()) +} + +/// 关闭终端会话 +/// +/// # 参数 +/// - `session_id`: 会话 ID +#[tauri::command] +pub async fn terminal_close( + state: State<'_, TerminalManagerState>, + session_id: String, +) -> Result<(), String> { + let guard = state.inner().0.read().await; + let manager = guard + .as_ref() + .ok_or_else(|| "终端管理器未初始化".to_string())?; + + manager + .close_session(&session_id) + .await + .map_err(|e| e.to_string()) +} + +/// 获取所有终端会话 +#[tauri::command] +pub async fn terminal_list_sessions( + state: State<'_, TerminalManagerState>, +) -> Result, String> { + let guard = state.inner().0.read().await; + let manager = guard + .as_ref() + .ok_or_else(|| "终端管理器未初始化".to_string())?; + + Ok(manager.list_sessions().await) +} + +/// 获取单个终端会话信息 +/// +/// # 参数 +/// - `session_id`: 会话 ID +#[tauri::command] +pub async fn terminal_get_session( + state: State<'_, TerminalManagerState>, + session_id: String, +) -> Result, String> { + let guard = state.inner().0.read().await; + let manager = guard + .as_ref() + .ok_or_else(|| "终端管理器未初始化".to_string())?; + + Ok(manager.get_session(&session_id).await) +} diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index f068fafcb..3e9bfa3f5 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -23,6 +23,10 @@ pub fn init_database() -> Result { let db_path = get_db_path()?; let conn = Connection::open(&db_path).map_err(|e| e.to_string())?; + // 设置 busy_timeout 为 5 秒,避免 "database is locked" 错误 + conn.busy_timeout(std::time::Duration::from_secs(5)) + .map_err(|e| format!("设置 busy_timeout 失败: {}", e))?; + // 创建表结构 schema::create_tables(&conn).map_err(|e| e.to_string())?; migration::migrate_from_json(&conn)?; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a9b793006..68760d463 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -23,6 +23,7 @@ pub mod services; pub mod stream; pub mod streaming; pub mod telemetry; +pub mod terminal; pub mod translator; pub mod tray; pub mod websocket; diff --git a/src-tauri/src/plugin/installer/registry.rs b/src-tauri/src/plugin/installer/registry.rs index ee30d3276..ad627a829 100644 --- a/src-tauri/src/plugin/installer/registry.rs +++ b/src-tauri/src/plugin/installer/registry.rs @@ -25,6 +25,11 @@ impl PluginRegistry { pub fn from_path(db_path: &Path) -> Result { let conn = Connection::open(db_path).map_err(|e| InstallError::DatabaseError(e.to_string()))?; + + // 设置 busy_timeout 为 5 秒,避免 "database is locked" 错误 + conn.busy_timeout(std::time::Duration::from_secs(5)) + .map_err(|e| InstallError::DatabaseError(format!("设置 busy_timeout 失败: {}", e)))?; + Ok(Self { conn: Arc::new(Mutex::new(conn)), }) diff --git a/src-tauri/src/terminal/README.md b/src-tauri/src/terminal/README.md new file mode 100644 index 000000000..0ce034e8b --- /dev/null +++ b/src-tauri/src/terminal/README.md @@ -0,0 +1,60 @@ +# terminal + + + +## 架构说明 + +终端核心模块,采用**后端预创建 PTY**架构(参考 WaveTerm)。 + +**核心原则:** +- 后端是会话的唯一真相来源 +- PTY 使用默认大小 (24x80) 预创建 +- 前端连接后通过 resize 同步实际大小 +- 通过 Tauri Commands 和 Events 暴露给前端 + +## 核心功能 + +- **PTY 管理**: 创建和管理伪终端进程(默认大小预创建) +- **会话管理**: 多会话支持,生命周期管理 +- **实时输出**: 通过 Tauri Events 推送终端输出 +- **状态通知**: 会话状态变化事件 + +## 文件索引 + +- `mod.rs` - 模块入口和类型导出 +- `error.rs` - 错误类型定义 +- `events.rs` - Tauri 事件定义(terminal:output, terminal:status) +- `pty_session.rs` - PTY 会话封装(支持默认大小创建) +- `session_manager.rs` - 会话管理器 +- `tests.rs` - 单元测试 + +## 命令接口 + +| 命令 | 描述 | 参数 | +|------|------|------| +| `terminal_create_session` | 创建终端会话(默认大小) | 无 | +| `terminal_write` | 向终端发送输入 | `session_id`, `data` | +| `terminal_resize` | 调整终端大小 | `session_id`, `rows`, `cols` | +| `terminal_close` | 关闭终端会话 | `session_id` | +| `terminal_list_sessions` | 获取所有会话列表 | 无 | +| `terminal_get_session` | 获取单个会话信息 | `session_id` | + +## 事件定义 + +| 事件名 | 描述 | 数据结构 | +|--------|------|----------| +| `terminal:output` | 终端输出数据 | `{ session_id, data }` | +| `terminal:status` | 会话状态变化 | `{ session_id, status, exit_code?, error? }` | + +## 常量 + +- `DEFAULT_ROWS`: 默认终端行数 (24) +- `DEFAULT_COLS`: 默认终端列数 (80) + +## 依赖 + +- `portable-pty` - 跨平台 PTY 支持 + +## 更新提醒 + +任何文件变更后,请更新此文档和相关的上级文档。 diff --git a/src-tauri/src/terminal/error.rs b/src-tauri/src/terminal/error.rs new file mode 100644 index 000000000..def1c8ab9 --- /dev/null +++ b/src-tauri/src/terminal/error.rs @@ -0,0 +1,57 @@ +//! 终端模块错误类型 +//! +//! 定义终端核心能力相关的错误类型。 +//! +//! ## 功能 +//! - 会话管理错误 +//! - PTY 操作错误 +//! - 序列化支持 + +use thiserror::Error; + +/// 终端错误类型 +#[derive(Debug, Error)] +pub enum TerminalError { + /// 会话不存在 + #[error("会话不存在: {0}")] + SessionNotFound(String), + + /// PTY 创建失败 + #[error("PTY 创建失败: {0}")] + PtyCreationFailed(String), + + /// 写入失败 + #[error("写入失败: {0}")] + WriteFailed(String), + + /// 调整大小失败 + #[error("调整大小失败: {0}")] + ResizeFailed(String), + + /// 会话已关闭 + #[error("会话已关闭")] + SessionClosed, + + /// Base64 解码失败 + #[error("Base64 解码失败: {0}")] + Base64DecodeFailed(String), + + /// 内部错误 + #[error("内部错误: {0}")] + Internal(String), +} + +impl From for String { + fn from(err: TerminalError) -> Self { + err.to_string() + } +} + +impl serde::Serialize for TerminalError { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} diff --git a/src-tauri/src/terminal/events.rs b/src-tauri/src/terminal/events.rs new file mode 100644 index 000000000..6a2bdc1c8 --- /dev/null +++ b/src-tauri/src/terminal/events.rs @@ -0,0 +1,63 @@ +//! 终端事件定义 +//! +//! 定义终端模块使用的 Tauri 事件类型。 +//! +//! ## 事件列表 +//! - `terminal:output` - 终端输出数据 +//! - `terminal:status` - 终端状态变化 + +use serde::{Deserialize, Serialize}; + +/// 会话状态 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SessionStatus { + /// 正在连接 + Connecting, + /// 运行中 + Running, + /// 已结束 + Done, + /// 错误 + Error, +} + +impl Default for SessionStatus { + fn default() -> Self { + Self::Connecting + } +} + +/// 终端输出事件 +/// +/// Event name: `terminal:output` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TerminalOutputEvent { + /// 会话 ID + pub session_id: String, + /// 输出数据(Base64 编码) + pub data: String, +} + +/// 终端状态事件 +/// +/// Event name: `terminal:status` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TerminalStatusEvent { + /// 会话 ID + pub session_id: String, + /// 会话状态 + pub status: SessionStatus, + /// 退出码(仅当状态为 Done 时有效) + pub exit_code: Option, + /// 错误信息(仅当状态为 Error 时有效) + pub error: Option, +} + +/// 事件名称常量 +pub mod event_names { + /// 终端输出事件名 + pub const TERMINAL_OUTPUT: &str = "terminal:output"; + /// 终端状态事件名 + pub const TERMINAL_STATUS: &str = "terminal:status"; +} diff --git a/src-tauri/src/terminal/mod.rs b/src-tauri/src/terminal/mod.rs new file mode 100644 index 000000000..6df06c15d --- /dev/null +++ b/src-tauri/src/terminal/mod.rs @@ -0,0 +1,32 @@ +//! 终端核心模块 +//! +//! 提供 PTY 管理和会话管理能力,通过 Tauri Commands 和 Events 暴露给前端。 +//! +//! ## 模块结构 +//! - `error` - 错误类型定义 +//! - `events` - Tauri 事件定义 +//! - `pty_session` - PTY 会话封装 +//! - `session_manager` - 会话管理器 +//! +//! ## 使用示例 +//! ```ignore +//! use proxycast_lib::terminal::{TerminalSessionManager, SessionStatus}; +//! +//! let manager = TerminalSessionManager::new(app_handle); +//! let session_id = manager.create_session(24, 80).await?; +//! manager.write_to_session(&session_id, b"ls -la\n").await?; +//! ``` + +pub mod error; +pub mod events; +pub mod pty_session; +pub mod session_manager; + +#[cfg(test)] +mod tests; + +// 重新导出常用类型 +pub use error::TerminalError; +pub use events::{SessionStatus, TerminalOutputEvent, TerminalStatusEvent}; +pub use pty_session::{PtySession, DEFAULT_COLS, DEFAULT_ROWS}; +pub use session_manager::{SessionMetadata, TerminalSessionManager}; diff --git a/src-tauri/src/terminal/pty_session.rs b/src-tauri/src/terminal/pty_session.rs new file mode 100644 index 000000000..433652aec --- /dev/null +++ b/src-tauri/src/terminal/pty_session.rs @@ -0,0 +1,324 @@ +//! PTY 会话封装 +//! +//! 封装单个 PTY 进程,处理输入输出和生命周期管理。 +//! +//! ## 功能 +//! - 创建和管理 PTY 子进程(使用默认大小预创建) +//! - 异步读取 PTY 输出并通过 Tauri Event 推送 +//! - 处理 PTY 输入写入 +//! - 监控进程退出状态 +//! - 保存输出历史(循环缓冲区) +//! +//! ## 架构说明 +//! PTY 在后端预创建,使用默认大小 (24x80)。前端连接后通过 resize 同步实际大小。 +//! 输出历史保存在循环缓冲区中,前端连接时可以获取历史数据。 + +use std::io::{Read, Write}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; +use parking_lot::Mutex; +use portable_pty::{native_pty_system, CommandBuilder, PtySize}; +use tauri::Emitter; +use tokio::sync::RwLock; + +use super::error::TerminalError; +use super::events::{event_names, SessionStatus, TerminalOutputEvent, TerminalStatusEvent}; + +/// 默认终端行数 +pub const DEFAULT_ROWS: u16 = 24; +/// 默认终端列数 +pub const DEFAULT_COLS: u16 = 80; +/// 输出历史缓冲区最大大小 (1MB) +const OUTPUT_BUFFER_MAX_SIZE: usize = 1024 * 1024; + +/// 循环缓冲区,用于存储终端输出历史 +struct CircularBuffer { + data: Vec, + max_size: usize, +} + +impl CircularBuffer { + fn new(max_size: usize) -> Self { + Self { + data: Vec::with_capacity(max_size), + max_size, + } + } + + fn append(&mut self, new_data: &[u8]) { + // 如果新数据超过最大大小,只保留最后 max_size 字节 + if new_data.len() >= self.max_size { + self.data.clear(); + self.data + .extend_from_slice(&new_data[new_data.len() - self.max_size..]); + return; + } + + // 追加新数据 + self.data.extend_from_slice(new_data); + + // 如果超过最大大小,移除开头的数据 + if self.data.len() > self.max_size { + let excess = self.data.len() - self.max_size; + self.data.drain(0..excess); + } + } + + fn get_all(&self) -> Vec { + self.data.clone() + } +} + +/// PTY 会话 +pub struct PtySession { + /// 会话 ID + id: String, + /// PTY 写入器(使用 Mutex 保证线程安全) + writer: Arc>>, + /// PTY Master(用于调整大小,使用 Mutex 保证线程安全) + master: Arc>>, + /// 会话状态 + status: Arc>, + /// 关闭标志 + shutdown_flag: Arc, + /// 输出历史缓冲区 + output_buffer: Arc>, +} + +impl PtySession { + /// 创建新的 PTY 会话(使用默认大小) + /// + /// PTY 使用默认大小 (24x80) 预创建,前端连接后通过 resize 同步实际大小。 + /// + /// # 参数 + /// - `id`: 会话 ID + /// - `app_handle`: Tauri 应用句柄 + /// + /// # 返回 + /// - `Ok(PtySession)`: 创建成功 + /// - `Err(TerminalError)`: 创建失败 + pub fn new(id: String, app_handle: tauri::AppHandle) -> Result { + Self::with_size(id, DEFAULT_ROWS, DEFAULT_COLS, app_handle) + } + + /// 创建新的 PTY 会话(指定大小) + /// + /// # 参数 + /// - `id`: 会话 ID + /// - `rows`: 终端行数 + /// - `cols`: 终端列数 + /// - `app_handle`: Tauri 应用句柄 + /// + /// # 返回 + /// - `Ok(PtySession)`: 创建成功 + /// - `Err(TerminalError)`: 创建失败 + pub fn with_size( + id: String, + rows: u16, + cols: u16, + app_handle: tauri::AppHandle, + ) -> Result { + tracing::info!("[终端] 创建 PTY 会话 {}, 大小: {}x{}", id, cols, rows); + + let pty_system = native_pty_system(); + + // 创建 PTY + let pair = pty_system + .openpty(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + }) + .map_err(|e| TerminalError::PtyCreationFailed(e.to_string()))?; + + // 获取用户默认 shell + let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string()); + tracing::info!("[终端] 使用 shell: {}", shell); + + // 构建命令 + let mut cmd = CommandBuilder::new(&shell); + cmd.env("TERM", "xterm-256color"); + + // 设置工作目录为用户主目录 + if let Some(home) = dirs::home_dir() { + cmd.cwd(home); + } + + // 启动子进程 + let _child = pair + .slave + .spawn_command(cmd) + .map_err(|e| TerminalError::PtyCreationFailed(e.to_string()))?; + + // 获取写入器 + let writer = pair + .master + .take_writer() + .map_err(|e| TerminalError::PtyCreationFailed(e.to_string()))?; + + // 获取读取器 + let mut reader = pair + .master + .try_clone_reader() + .map_err(|e| TerminalError::PtyCreationFailed(e.to_string()))?; + + let status = Arc::new(RwLock::new(SessionStatus::Running)); + let status_clone = status.clone(); + let id_clone = id.clone(); + + // 创建关闭标志 + let shutdown_flag = Arc::new(AtomicBool::new(false)); + let shutdown_flag_clone = shutdown_flag.clone(); + + // 创建输出缓冲区 + let output_buffer = Arc::new(Mutex::new(CircularBuffer::new(OUTPUT_BUFFER_MAX_SIZE))); + let output_buffer_clone = output_buffer.clone(); + + // 获取当前 tokio runtime handle(在主线程中获取) + let runtime_handle = tokio::runtime::Handle::current(); + + // 启动输出读取任务(使用独立线程) + std::thread::spawn(move || { + let mut buffer = [0u8; 4096]; + + loop { + // 检查关闭标志 + if shutdown_flag_clone.load(Ordering::Relaxed) { + tracing::debug!("[终端] 会话 {} 收到关闭信号", id_clone); + break; + } + + // 读取输出 + match reader.read(&mut buffer) { + Ok(0) => { + // EOF,进程已退出 + tracing::info!("[终端] 会话 {} 进程已退出", id_clone); + runtime_handle.block_on(async { + *status_clone.write().await = SessionStatus::Done; + }); + + // 发送状态事件 + let _ = app_handle.emit( + event_names::TERMINAL_STATUS, + TerminalStatusEvent { + session_id: id_clone.clone(), + status: SessionStatus::Done, + exit_code: Some(0), + error: None, + }, + ); + break; + } + Ok(n) => { + let output_data = &buffer[..n]; + + // 保存到输出缓冲区 + output_buffer_clone.lock().append(output_data); + + // 发送输出事件 + let data = BASE64.encode(output_data); + let _ = app_handle.emit( + event_names::TERMINAL_OUTPUT, + TerminalOutputEvent { + session_id: id_clone.clone(), + data, + }, + ); + } + Err(e) => { + // 检查是否是因为关闭导致的错误 + if shutdown_flag_clone.load(Ordering::Relaxed) { + break; + } + + tracing::error!("[终端] 会话 {} 读取错误: {}", id_clone, e); + runtime_handle.block_on(async { + *status_clone.write().await = SessionStatus::Error; + }); + + let _ = app_handle.emit( + event_names::TERMINAL_STATUS, + TerminalStatusEvent { + session_id: id_clone.clone(), + status: SessionStatus::Error, + exit_code: None, + error: Some(e.to_string()), + }, + ); + break; + } + } + } + }); + + tracing::info!("[终端] 会话 {} 已创建 ({}x{})", id, cols, rows); + + Ok(Self { + id, + writer: Arc::new(Mutex::new(writer)), + master: Arc::new(Mutex::new(pair.master)), + status, + shutdown_flag, + output_buffer, + }) + } + + /// 获取会话 ID + pub fn id(&self) -> &str { + &self.id + } + + /// 写入数据到 PTY + pub fn write(&self, data: &[u8]) -> Result<(), TerminalError> { + let mut writer = self.writer.lock(); + writer + .write_all(data) + .map_err(|e| TerminalError::WriteFailed(e.to_string()))?; + writer + .flush() + .map_err(|e| TerminalError::WriteFailed(e.to_string()))?; + Ok(()) + } + + /// 调整 PTY 大小 + pub fn resize(&self, rows: u16, cols: u16) -> Result<(), TerminalError> { + let master = self.master.lock(); + master + .resize(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + }) + .map_err(|e| TerminalError::ResizeFailed(e.to_string()))?; + tracing::debug!("[终端] 会话 {} 调整大小为 {}x{}", self.id, cols, rows); + Ok(()) + } + + /// 获取当前状态 + pub async fn status(&self) -> SessionStatus { + *self.status.read().await + } + + /// 获取输出历史数据(Base64 编码) + pub fn get_output_history(&self) -> String { + let buffer = self.output_buffer.lock(); + let data = buffer.get_all(); + BASE64.encode(&data) + } + + /// 关闭会话 + pub async fn close(&self) -> Result<(), TerminalError> { + // 设置关闭标志 + self.shutdown_flag.store(true, Ordering::Relaxed); + + // 更新状态 + *self.status.write().await = SessionStatus::Done; + + tracing::info!("[终端] 会话 {} 已关闭", self.id); + Ok(()) + } +} diff --git a/src-tauri/src/terminal/session_manager.rs b/src-tauri/src/terminal/session_manager.rs new file mode 100644 index 000000000..86c0ed0e7 --- /dev/null +++ b/src-tauri/src/terminal/session_manager.rs @@ -0,0 +1,208 @@ +//! 终端会话管理器 +//! +//! 管理所有终端会话的生命周期,提供会话的创建、查询、销毁功能。 +//! +//! ## 功能 +//! - 维护活跃会话的 HashMap +//! - 生成唯一的会话 ID +//! - 提供线程安全的会话访问 + +use std::collections::HashMap; +use std::sync::Arc; + +use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use tokio::sync::RwLock; +use uuid::Uuid; + +use super::error::TerminalError; +use super::events::SessionStatus; +use super::pty_session::{PtySession, DEFAULT_COLS, DEFAULT_ROWS}; + +/// 会话元数据 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionMetadata { + /// 会话 ID + pub id: String, + /// 会话状态 + pub status: SessionStatus, + /// 创建时间(Unix 时间戳,毫秒) + pub created_at: i64, + /// 终端行数 + pub rows: u16, + /// 终端列数 + pub cols: u16, +} + +/// 内部会话数据 +struct SessionData { + session: PtySession, + metadata: SessionMetadata, +} + +/// 终端会话管理器 +pub struct TerminalSessionManager { + /// 会话映射表 + sessions: Arc>>, + /// Tauri 应用句柄 + app_handle: tauri::AppHandle, +} + +impl TerminalSessionManager { + /// 创建新的会话管理器 + pub fn new(app_handle: tauri::AppHandle) -> Self { + tracing::info!("[终端] 会话管理器已初始化"); + Self { + sessions: Arc::new(RwLock::new(HashMap::new())), + app_handle, + } + } + + /// 创建新的终端会话(使用默认大小) + /// + /// PTY 使用默认大小 (24x80) 预创建,前端连接后通过 resize 同步实际大小。 + /// + /// # 返回 + /// - `Ok(String)`: 会话 ID + /// - `Err(TerminalError)`: 创建失败 + pub async fn create_session(&self) -> Result { + let session_id = Uuid::new_v4().to_string(); + + let session = PtySession::new(session_id.clone(), self.app_handle.clone())?; + + let metadata = SessionMetadata { + id: session_id.clone(), + status: SessionStatus::Running, + created_at: Utc::now().timestamp_millis(), + rows: DEFAULT_ROWS, + cols: DEFAULT_COLS, + }; + + let data = SessionData { session, metadata }; + + self.sessions.write().await.insert(session_id.clone(), data); + + tracing::info!( + "[终端] 创建会话: {} (默认大小 {}x{})", + session_id, + DEFAULT_COLS, + DEFAULT_ROWS + ); + Ok(session_id) + } + + /// 向会话发送输入 + /// + /// # 参数 + /// - `session_id`: 会话 ID + /// - `data`: 输入数据 + pub async fn write_to_session( + &self, + session_id: &str, + data: &[u8], + ) -> Result<(), TerminalError> { + let sessions = self.sessions.read().await; + let session_data = sessions + .get(session_id) + .ok_or_else(|| TerminalError::SessionNotFound(session_id.to_string()))?; + + session_data.session.write(data) + } + + /// 向会话发送 Base64 编码的输入 + /// + /// # 参数 + /// - `session_id`: 会话 ID + /// - `data_base64`: Base64 编码的输入数据 + pub async fn write_to_session_base64( + &self, + session_id: &str, + data_base64: &str, + ) -> Result<(), TerminalError> { + let data = BASE64 + .decode(data_base64) + .map_err(|e| TerminalError::Base64DecodeFailed(e.to_string()))?; + self.write_to_session(session_id, &data).await + } + + /// 调整会话大小 + /// + /// # 参数 + /// - `session_id`: 会话 ID + /// - `rows`: 新的行数 + /// - `cols`: 新的列数 + pub async fn resize_session( + &self, + session_id: &str, + rows: u16, + cols: u16, + ) -> Result<(), TerminalError> { + let mut sessions = self.sessions.write().await; + let session_data = sessions + .get_mut(session_id) + .ok_or_else(|| TerminalError::SessionNotFound(session_id.to_string()))?; + + session_data.session.resize(rows, cols)?; + session_data.metadata.rows = rows; + session_data.metadata.cols = cols; + + Ok(()) + } + + /// 关闭会话 + /// + /// # 参数 + /// - `session_id`: 会话 ID + pub async fn close_session(&self, session_id: &str) -> Result<(), TerminalError> { + let mut sessions = self.sessions.write().await; + let session_data = sessions + .remove(session_id) + .ok_or_else(|| TerminalError::SessionNotFound(session_id.to_string()))?; + + session_data.session.close().await?; + + tracing::info!("[终端] 关闭会话: {}", session_id); + Ok(()) + } + + /// 获取会话的输出历史数据(Base64 编码) + /// + /// # 参数 + /// - `session_id`: 会话 ID + /// + /// # 返回 + /// - `Ok(String)`: Base64 编码的输出历史 + /// - `Err(TerminalError)`: 会话不存在 + pub async fn get_session_history(&self, session_id: &str) -> Result { + let sessions = self.sessions.read().await; + let session_data = sessions + .get(session_id) + .ok_or_else(|| TerminalError::SessionNotFound(session_id.to_string()))?; + + Ok(session_data.session.get_output_history()) + } + + /// 获取所有会话列表 + pub async fn list_sessions(&self) -> Vec { + let sessions = self.sessions.read().await; + sessions + .values() + .map(|data| data.metadata.clone()) + .collect() + } + + /// 获取单个会话信息 + /// + /// # 参数 + /// - `session_id`: 会话 ID + pub async fn get_session(&self, session_id: &str) -> Option { + let sessions = self.sessions.read().await; + sessions.get(session_id).map(|data| data.metadata.clone()) + } + + /// 获取活跃会话数量 + pub async fn session_count(&self) -> usize { + self.sessions.read().await.len() + } +} diff --git a/src-tauri/src/terminal/tests.rs b/src-tauri/src/terminal/tests.rs new file mode 100644 index 000000000..db43a3a76 --- /dev/null +++ b/src-tauri/src/terminal/tests.rs @@ -0,0 +1,210 @@ +//! 终端模块单元测试 +//! +//! 测试终端核心能力的各个组件。 +//! +//! ## 测试覆盖 +//! - 错误类型序列化 +//! - 会话状态转换 +//! - 事件结构序列化 + +#[cfg(test)] +mod tests { + use super::super::error::TerminalError; + use super::super::events::{SessionStatus, TerminalOutputEvent, TerminalStatusEvent}; + + // ======================================================================== + // 错误类型测试 + // ======================================================================== + + #[test] + fn test_terminal_error_session_not_found() { + let err = TerminalError::SessionNotFound("test-session-123".to_string()); + assert_eq!(err.to_string(), "会话不存在: test-session-123"); + } + + #[test] + fn test_terminal_error_pty_creation_failed() { + let err = TerminalError::PtyCreationFailed("spawn failed".to_string()); + assert_eq!(err.to_string(), "PTY 创建失败: spawn failed"); + } + + #[test] + fn test_terminal_error_write_failed() { + let err = TerminalError::WriteFailed("broken pipe".to_string()); + assert_eq!(err.to_string(), "写入失败: broken pipe"); + } + + #[test] + fn test_terminal_error_resize_failed() { + let err = TerminalError::ResizeFailed("invalid size".to_string()); + assert_eq!(err.to_string(), "调整大小失败: invalid size"); + } + + #[test] + fn test_terminal_error_session_closed() { + let err = TerminalError::SessionClosed; + assert_eq!(err.to_string(), "会话已关闭"); + } + + #[test] + fn test_terminal_error_base64_decode_failed() { + let err = TerminalError::Base64DecodeFailed("invalid base64".to_string()); + assert_eq!(err.to_string(), "Base64 解码失败: invalid base64"); + } + + #[test] + fn test_terminal_error_internal() { + let err = TerminalError::Internal("unexpected error".to_string()); + assert_eq!(err.to_string(), "内部错误: unexpected error"); + } + + #[test] + fn test_terminal_error_to_string_conversion() { + let err = TerminalError::SessionNotFound("abc".to_string()); + let s: String = err.into(); + assert_eq!(s, "会话不存在: abc"); + } + + #[test] + fn test_terminal_error_serialize() { + let err = TerminalError::SessionNotFound("test".to_string()); + let json = serde_json::to_string(&err).unwrap(); + assert_eq!(json, "\"会话不存在: test\""); + } + + // ======================================================================== + // 会话状态测试 + // ======================================================================== + + #[test] + fn test_session_status_default() { + let status = SessionStatus::default(); + assert_eq!(status, SessionStatus::Connecting); + } + + #[test] + fn test_session_status_serialize() { + assert_eq!( + serde_json::to_string(&SessionStatus::Connecting).unwrap(), + "\"connecting\"" + ); + assert_eq!( + serde_json::to_string(&SessionStatus::Running).unwrap(), + "\"running\"" + ); + assert_eq!( + serde_json::to_string(&SessionStatus::Done).unwrap(), + "\"done\"" + ); + assert_eq!( + serde_json::to_string(&SessionStatus::Error).unwrap(), + "\"error\"" + ); + } + + #[test] + fn test_session_status_deserialize() { + assert_eq!( + serde_json::from_str::("\"connecting\"").unwrap(), + SessionStatus::Connecting + ); + assert_eq!( + serde_json::from_str::("\"running\"").unwrap(), + SessionStatus::Running + ); + assert_eq!( + serde_json::from_str::("\"done\"").unwrap(), + SessionStatus::Done + ); + assert_eq!( + serde_json::from_str::("\"error\"").unwrap(), + SessionStatus::Error + ); + } + + #[test] + fn test_session_status_equality() { + assert_eq!(SessionStatus::Running, SessionStatus::Running); + assert_ne!(SessionStatus::Running, SessionStatus::Done); + } + + #[test] + fn test_session_status_clone() { + let status = SessionStatus::Running; + let cloned = status; + assert_eq!(status, cloned); + } + + // ======================================================================== + // 事件结构测试 + // ======================================================================== + + #[test] + fn test_terminal_output_event_serialize() { + let event = TerminalOutputEvent { + session_id: "session-123".to_string(), + data: "SGVsbG8gV29ybGQ=".to_string(), // "Hello World" in Base64 + }; + + let json = serde_json::to_string(&event).unwrap(); + assert!(json.contains("\"session_id\":\"session-123\"")); + assert!(json.contains("\"data\":\"SGVsbG8gV29ybGQ=\"")); + } + + #[test] + fn test_terminal_output_event_deserialize() { + let json = r#"{"session_id":"abc","data":"dGVzdA=="}"#; + let event: TerminalOutputEvent = serde_json::from_str(json).unwrap(); + assert_eq!(event.session_id, "abc"); + assert_eq!(event.data, "dGVzdA=="); + } + + #[test] + fn test_terminal_status_event_serialize_done() { + let event = TerminalStatusEvent { + session_id: "session-456".to_string(), + status: SessionStatus::Done, + exit_code: Some(0), + error: None, + }; + + let json = serde_json::to_string(&event).unwrap(); + assert!(json.contains("\"status\":\"done\"")); + assert!(json.contains("\"exit_code\":0")); + } + + #[test] + fn test_terminal_status_event_serialize_error() { + let event = TerminalStatusEvent { + session_id: "session-789".to_string(), + status: SessionStatus::Error, + exit_code: None, + error: Some("Connection refused".to_string()), + }; + + let json = serde_json::to_string(&event).unwrap(); + assert!(json.contains("\"status\":\"error\"")); + assert!(json.contains("\"error\":\"Connection refused\"")); + } + + #[test] + fn test_terminal_status_event_deserialize() { + let json = r#"{"session_id":"test","status":"running","exit_code":null,"error":null}"#; + let event: TerminalStatusEvent = serde_json::from_str(json).unwrap(); + assert_eq!(event.session_id, "test"); + assert_eq!(event.status, SessionStatus::Running); + assert!(event.exit_code.is_none()); + assert!(event.error.is_none()); + } + + // ======================================================================== + // 事件名称常量测试 + // ======================================================================== + + #[test] + fn test_event_names() { + use super::super::events::event_names; + assert_eq!(event_names::TERMINAL_OUTPUT, "terminal:output"); + assert_eq!(event_names::TERMINAL_STATUS, "terminal:status"); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 8c380a6aa..1d7f5fd06 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ProxyCast", - "version": "0.33.0", + "version": "0.34.0", "identifier": "com.proxycast.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.tsx b/src/App.tsx index 5d13a5a0e..fcb691883 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -55,9 +55,10 @@ const AppContainer = styled.div` const MainContent = styled.main` flex: 1; - overflow: auto; + overflow: hidden; display: flex; flex-direction: column; + min-height: 0; `; const PageWrapper = styled.div` @@ -72,9 +73,11 @@ const PageWrapper = styled.div` */ const FullscreenWrapper = styled.div` flex: 1; + min-height: 0; overflow: hidden; display: flex; flex-direction: column; + position: relative; `; function AppContent() { diff --git a/src/components/README.md b/src/components/README.md index 9bf69860d..57c17a9bf 100644 --- a/src/components/README.md +++ b/src/components/README.md @@ -25,6 +25,7 @@ React 组件层,包含 UI 组件和业务组件。 - `settings/` - 设置页面组件 - `skills/` - 技能管理组件 - `switch/` - 开关控制组件 +- `terminal/` - 内置终端组件(使用 Tauri Commands) - `tools/` - 工具页面组件 - `ui/` - 通用 UI 组件(按钮、输入框等) - `websocket/` - WebSocket 管理组件 diff --git a/src/components/blocks/BlockFrame.tsx b/src/components/blocks/BlockFrame.tsx new file mode 100644 index 000000000..15fe50d24 --- /dev/null +++ b/src/components/blocks/BlockFrame.tsx @@ -0,0 +1,242 @@ +/** + * @file BlockFrame.tsx + * @description 块框架组件 + * @module components/blocks/BlockFrame + * + * 提供块的通用框架,包括标题栏、操作按钮等。 + */ + +import React, { useCallback } from "react"; +import type { Block, BlockViewModel } from "@/lib/blocks/types"; +import { getBlockDisplayName, getBlockIcon } from "@/lib/blocks/blockStore"; + +/** 块框架属性 */ +export interface BlockFrameProps { + /** 块实例 */ + block: Block; + /** 视图模型 */ + viewModel: BlockViewModel; + /** 子组件 */ + children: React.ReactNode; + /** 是否显示标题栏 */ + showHeader?: boolean; + /** 自定义标题 */ + title?: string; + /** 自定义操作按钮 */ + actions?: React.ReactNode; +} + +/** 图标组件 */ +const BlockIcon: React.FC<{ type: string; className?: string }> = ({ + type, + className, +}) => { + const iconMap: Record = { + terminal: ( + + + + + ), + file: ( + + + + + ), + globe: ( + + + + + + ), + sparkles: ( + + + + + + ), + "file-text": ( + + + + + + + + ), + image: ( + + + + + + ), + square: ( + + + + ), + }; + + return <>{iconMap[type] ?? iconMap.square}; +}; + +/** 关闭按钮 */ +const CloseButton: React.FC<{ onClick: () => void }> = ({ onClick }) => ( + +); + +/** 最大化按钮 */ +const MaximizeButton: React.FC<{ + isMagnified: boolean; + onClick: () => void; +}> = ({ isMagnified, onClick }) => ( + +); + +/** + * 块框架组件 + */ +export const BlockFrame: React.FC = ({ + block, + viewModel, + children, + showHeader = true, + title, + actions, +}) => { + const handleClose = useCallback(() => { + viewModel.close(); + }, [viewModel]); + + const handleMaximize = useCallback(() => { + viewModel.toggleMagnify(); + }, [viewModel]); + + const handleFocus = useCallback(() => { + viewModel.focus(); + }, [viewModel]); + + const displayTitle = + title ?? block.meta.title ?? getBlockDisplayName(block.type); + const iconType = block.meta.icon ?? getBlockIcon(block.type); + + return ( +
+ {showHeader && ( +
+
+ + {displayTitle} +
+
+ {actions} + + +
+
+ )} +
{children}
+
+ ); +}; + +export default BlockFrame; diff --git a/src/components/blocks/PreviewBlock.tsx b/src/components/blocks/PreviewBlock.tsx new file mode 100644 index 000000000..44887d600 --- /dev/null +++ b/src/components/blocks/PreviewBlock.tsx @@ -0,0 +1,175 @@ +/** + * @file PreviewBlock.tsx + * @description 文件预览块组件 + * @module components/blocks/PreviewBlock + * + * 支持多种文件类型的预览。 + */ + +import React, { useState, useEffect } from "react"; +import type { BlockComponentProps } from "@/lib/blocks/types"; +import { BlockFrame } from "./BlockFrame"; +import { MarkdownPreview } from "@/components/preview/MarkdownPreview"; +import { ImagePreview } from "@/components/preview/ImagePreview"; + +/** 文件类型 */ +type FileType = "markdown" | "image" | "text" | "code" | "unknown"; + +/** 根据文件扩展名获取文件类型 */ +function getFileType(filePath: string): FileType { + const ext = filePath.split(".").pop()?.toLowerCase() ?? ""; + + const imageExts = ["png", "jpg", "jpeg", "gif", "webp", "svg", "ico", "bmp"]; + const markdownExts = ["md", "markdown", "mdx"]; + const codeExts = [ + "js", + "ts", + "jsx", + "tsx", + "py", + "rs", + "go", + "java", + "c", + "cpp", + "h", + "hpp", + "css", + "scss", + "html", + "json", + "yaml", + "yml", + "toml", + "xml", + ]; + const textExts = ["txt", "log", "csv"]; + + if (imageExts.includes(ext)) return "image"; + if (markdownExts.includes(ext)) return "markdown"; + if (codeExts.includes(ext)) return "code"; + if (textExts.includes(ext)) return "text"; + + return "unknown"; +} + +/** 获取文件名 */ +function getFileName(filePath: string): string { + return filePath.split("/").pop() ?? filePath; +} + +/** + * 文件预览块组件 + */ +export const PreviewBlock: React.FC = ({ + block, + viewModel, + visible = true, +}) => { + const [content, setContent] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const filePath = block.config.filePath; + const fileType = filePath ? getFileType(filePath) : "unknown"; + const fileName = filePath ? getFileName(filePath) : "未知文件"; + + // 加载文件内容 + useEffect(() => { + if (!filePath) { + setError("未指定文件路径"); + return; + } + + // 图片类型不需要加载内容 + if (fileType === "image") { + return; + } + + setLoading(true); + setError(null); + + // TODO: 调用后端 API 读取文件内容 + // 这里暂时使用模拟数据 + setTimeout(() => { + setContent( + `# ${fileName}\n\n这是文件预览的占位内容。\n\n实际实现需要调用后端 API 读取文件。`, + ); + setLoading(false); + }, 500); + }, [filePath, fileType, fileName]); + + // 渲染内容 + const renderContent = () => { + if (loading) { + return ( +
+
+ 加载中... +
+ ); + } + + if (error) { + return ( +
+ + + + + + {error} +
+ ); + } + + switch (fileType) { + case "image": + return ; + + case "markdown": + return ; + + case "code": + case "text": + return ( +
+            {content}
+          
+ ); + + default: + return ( +
+ + + + + 不支持预览此文件类型 +
+ ); + } + }; + + return ( + +
+ {renderContent()} +
+
+ ); +}; + +export default PreviewBlock; diff --git a/src/components/blocks/TerminalBlock.tsx b/src/components/blocks/TerminalBlock.tsx new file mode 100644 index 000000000..84d429160 --- /dev/null +++ b/src/components/blocks/TerminalBlock.tsx @@ -0,0 +1,113 @@ +/** + * @file TerminalBlock.tsx + * @description 终端块组件 + * @module components/blocks/TerminalBlock + * + * 将终端封装为块系统的一部分。 + */ + +import React, { useEffect, useRef, useCallback } from "react"; +import { useAtomValue } from "jotai"; +import type { BlockComponentProps } from "@/lib/blocks/types"; +import { themeAtom } from "@/lib/blocks/blockStore"; +import { TermWrap } from "@/components/terminal/termwrap"; +import { type ThemeName } from "@/lib/terminal/themes"; +import { BlockFrame } from "./BlockFrame"; +import "@xterm/xterm/css/xterm.css"; + +/** + * 终端块组件 + */ +export const TerminalBlock: React.FC = ({ + block, + viewModel, + visible = true, +}) => { + const containerRef = useRef(null); + const termWrapRef = useRef(null); + const resizeObserverRef = useRef(null); + const currentTheme = useAtomValue(themeAtom) as ThemeName; + + // 获取会话 ID + const sessionId = block.config.sessionId; + + useEffect(() => { + const container = containerRef.current; + if (!container || !sessionId) return; + + // 创建 TermWrap 实例 + const termWrap = new TermWrap(sessionId, container, { + themeName: currentTheme, + }); + + termWrapRef.current = termWrap; + + // 设置 ResizeObserver + const rszObs = new ResizeObserver(() => { + termWrap.handleResize_debounced(); + }); + rszObs.observe(container); + resizeObserverRef.current = rszObs; + + return () => { + termWrap.dispose(); + rszObs.disconnect(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sessionId]); + + // 主题变化时更新 + useEffect(() => { + if (termWrapRef.current && currentTheme) { + termWrapRef.current.setTheme(currentTheme); + } + }, [currentTheme]); + + // 可见性变化时处理 + useEffect(() => { + if (visible && termWrapRef.current) { + termWrapRef.current.handleResize_debounced(); + if (viewModel.isFocused) { + termWrapRef.current.focus(); + } + } + }, [visible, viewModel.isFocused]); + + // 聚焦时聚焦终端 + useEffect(() => { + if (viewModel.isFocused && termWrapRef.current) { + termWrapRef.current.focus(); + } + }, [viewModel.isFocused]); + + const handleContainerClick = useCallback(() => { + termWrapRef.current?.focus(); + }, []); + + if (!sessionId) { + return ( + +
+ 无效的终端会话 +
+
+ ); + } + + return ( + +
+ + ); +}; + +export default TerminalBlock; diff --git a/src/components/blocks/WebBlock.tsx b/src/components/blocks/WebBlock.tsx new file mode 100644 index 000000000..132ad211a --- /dev/null +++ b/src/components/blocks/WebBlock.tsx @@ -0,0 +1,220 @@ +/** + * @file WebBlock.tsx + * @description Web 视图块组件 + * @module components/blocks/WebBlock + * + * 内嵌 Web 页面的块组件。 + */ + +import React, { useState, useCallback, useRef } from "react"; +import type { BlockComponentProps } from "@/lib/blocks/types"; +import { BlockFrame } from "./BlockFrame"; + +/** + * Web 视图块组件 + */ +export const WebBlock: React.FC = ({ + block, + viewModel, + visible = true, +}) => { + const [url, setUrl] = useState(block.config.url ?? ""); + const [inputUrl, setInputUrl] = useState(block.config.url ?? ""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const iframeRef = useRef(null); + + // 导航到 URL + const handleNavigate = useCallback(() => { + if (!inputUrl) return; + + let finalUrl = inputUrl; + // 自动添加协议 + if (!finalUrl.startsWith("http://") && !finalUrl.startsWith("https://")) { + finalUrl = "https://" + finalUrl; + } + + setUrl(finalUrl); + setLoading(true); + setError(null); + }, [inputUrl]); + + // 键盘事件 + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + handleNavigate(); + } + }, + [handleNavigate], + ); + + // iframe 加载完成 + const handleLoad = useCallback(() => { + setLoading(false); + }, []); + + // iframe 加载错误 + const handleError = useCallback(() => { + setLoading(false); + setError("无法加载页面"); + }, []); + + // 刷新 + const handleRefresh = useCallback(() => { + if (iframeRef.current) { + setLoading(true); + iframeRef.current.src = url; + } + }, [url]); + + // 后退 + const handleBack = useCallback(() => { + if (iframeRef.current?.contentWindow) { + iframeRef.current.contentWindow.history.back(); + } + }, []); + + // 前进 + const handleForward = useCallback(() => { + if (iframeRef.current?.contentWindow) { + iframeRef.current.contentWindow.history.forward(); + } + }, []); + + // 自定义操作按钮 + const actions = ( + <> + + + + + ); + + return ( + +
+ {/* 地址栏 */} +
+ setInputUrl(e.target.value)} + onKeyDown={handleKeyDown} + /> + +
+ + {/* 内容区 */} +
+ {loading && ( +
+
+ 加载中... +
+ )} + + {error && ( +
+ + + + + + {error} +
+ )} + + {!url && !loading && !error && ( +
+ + + + + + 输入 URL 开始浏览 +
+ )} + + {url && ( +