mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
Merge branch 'main' into main
This commit is contained in:
Generated
+130
-35
@@ -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": {
|
||||
|
||||
+9
-1
@@ -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",
|
||||
|
||||
Generated
+137
-5
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ Tauri 后端核心代码,处理系统级功能和 API 服务。
|
||||
- `services/` - 业务服务层
|
||||
- `streaming/` - 流式响应处理
|
||||
- `telemetry/` - 遥测和统计
|
||||
- `terminal/` - 终端核心模块(PTY 管理、会话管理)
|
||||
- `tray/` - 系统托盘
|
||||
- `websocket/` - WebSocket 支持
|
||||
- `lib.rs` - 库入口
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
//! Agent 工具系统模块
|
||||
//!
|
||||
//! 提供工具定义、注册、执行的核心框架
|
||||
//! 参考 Claude Code 和 goose 项目的工具系统设计
|
||||
//!
|
||||
//! ## 模块结构
|
||||
//! - `types`: 工具类型定义(ToolDefinition, ToolCall, ToolResult 等)
|
||||
|
||||
@@ -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<parking_lot::RwLock<telemetry::StatsAggregator>>,
|
||||
pub shared_tokens: Arc<parking_lot::RwLock<telemetry::TokenTracker>>,
|
||||
@@ -214,6 +216,9 @@ pub fn init_states(config: &Config) -> Result<AppStates, String> {
|
||||
// 初始化 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<AppStates, String> {
|
||||
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,
|
||||
|
||||
@@ -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::<crate::commands::terminal_cmd::TerminalManagerState>() {
|
||||
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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Value>,
|
||||
error: Option<JsonRpcError>,
|
||||
#[allow(dead_code)]
|
||||
id: Option<u64>,
|
||||
}
|
||||
|
||||
/// JSON-RPC 错误
|
||||
/// JSON-RPC 通知(无 id 字段)
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
struct JsonRpcNotification {
|
||||
#[allow(dead_code)]
|
||||
jsonrpc: String,
|
||||
method: String,
|
||||
params: Option<Value>,
|
||||
}
|
||||
|
||||
/// 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<Value>,
|
||||
}
|
||||
|
||||
/// RPC 通知事件 payload
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct RpcNotificationPayload {
|
||||
plugin_id: String,
|
||||
method: String,
|
||||
params: Option<Value>,
|
||||
}
|
||||
|
||||
/// 待处理的 RPC 请求
|
||||
struct PendingRequest {
|
||||
response_tx: oneshot::Sender<Result<Value, String>>,
|
||||
}
|
||||
|
||||
/// 插件进程信息
|
||||
struct PluginProcess {
|
||||
child: Child,
|
||||
#[allow(dead_code)]
|
||||
plugin_id: String,
|
||||
child: Child,
|
||||
stdin: Arc<Mutex<ChildStdin>>,
|
||||
pending_requests: Arc<Mutex<HashMap<u64, PendingRequest>>>,
|
||||
shutdown_tx: Option<mpsc::Sender<()>>,
|
||||
}
|
||||
|
||||
/// 插件 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<Mutex<HashMap<u64, PendingRequest>>> =
|
||||
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::<JsonRpcMessage>(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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<RwLock<Option<TerminalSessionManager>>>);
|
||||
|
||||
/// 创建终端会话响应
|
||||
#[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<CreateSessionResponse, String> {
|
||||
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<Vec<SessionMetadata>, 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<Option<SessionMetadata>, 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)
|
||||
}
|
||||
@@ -23,6 +23,10 @@ pub fn init_database() -> Result<DbConnection, String> {
|
||||
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)?;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -25,6 +25,11 @@ impl PluginRegistry {
|
||||
pub fn from_path(db_path: &Path) -> Result<Self, InstallError> {
|
||||
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)),
|
||||
})
|
||||
|
||||
@@ -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 支持
|
||||
|
||||
## 更新提醒
|
||||
|
||||
任何文件变更后,请更新此文档和相关的上级文档。
|
||||
@@ -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<TerminalError> for String {
|
||||
fn from(err: TerminalError) -> Self {
|
||||
err.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl serde::Serialize for TerminalError {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
serializer.serialize_str(&self.to_string())
|
||||
}
|
||||
}
|
||||
@@ -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<i32>,
|
||||
/// 错误信息(仅当状态为 Error 时有效)
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// 事件名称常量
|
||||
pub mod event_names {
|
||||
/// 终端输出事件名
|
||||
pub const TERMINAL_OUTPUT: &str = "terminal:output";
|
||||
/// 终端状态事件名
|
||||
pub const TERMINAL_STATUS: &str = "terminal:status";
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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<u8>,
|
||||
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<u8> {
|
||||
self.data.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// PTY 会话
|
||||
pub struct PtySession {
|
||||
/// 会话 ID
|
||||
id: String,
|
||||
/// PTY 写入器(使用 Mutex 保证线程安全)
|
||||
writer: Arc<Mutex<Box<dyn Write + Send>>>,
|
||||
/// PTY Master(用于调整大小,使用 Mutex 保证线程安全)
|
||||
master: Arc<Mutex<Box<dyn portable_pty::MasterPty + Send>>>,
|
||||
/// 会话状态
|
||||
status: Arc<RwLock<SessionStatus>>,
|
||||
/// 关闭标志
|
||||
shutdown_flag: Arc<AtomicBool>,
|
||||
/// 输出历史缓冲区
|
||||
output_buffer: Arc<Mutex<CircularBuffer>>,
|
||||
}
|
||||
|
||||
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, TerminalError> {
|
||||
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<Self, TerminalError> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -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<RwLock<HashMap<String, SessionData>>>,
|
||||
/// 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<String, TerminalError> {
|
||||
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<String, TerminalError> {
|
||||
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<SessionMetadata> {
|
||||
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<SessionMetadata> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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::<SessionStatus>("\"connecting\"").unwrap(),
|
||||
SessionStatus::Connecting
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<SessionStatus>("\"running\"").unwrap(),
|
||||
SessionStatus::Running
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<SessionStatus>("\"done\"").unwrap(),
|
||||
SessionStatus::Done
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<SessionStatus>("\"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");
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
+4
-1
@@ -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() {
|
||||
|
||||
@@ -25,6 +25,7 @@ React 组件层,包含 UI 组件和业务组件。
|
||||
- `settings/` - 设置页面组件
|
||||
- `skills/` - 技能管理组件
|
||||
- `switch/` - 开关控制组件
|
||||
- `terminal/` - 内置终端组件(使用 Tauri Commands)
|
||||
- `tools/` - 工具页面组件
|
||||
- `ui/` - 通用 UI 组件(按钮、输入框等)
|
||||
- `websocket/` - WebSocket 管理组件
|
||||
|
||||
@@ -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<string, React.ReactNode> = {
|
||||
terminal: (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="4 17 10 11 4 5" />
|
||||
<line x1="12" y1="19" x2="20" y2="19" />
|
||||
</svg>
|
||||
),
|
||||
file: (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
),
|
||||
globe: (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="2" y1="12" x2="22" y2="12" />
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
|
||||
</svg>
|
||||
),
|
||||
sparkles: (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M12 3l1.5 4.5L18 9l-4.5 1.5L12 15l-1.5-4.5L6 9l4.5-1.5L12 3z" />
|
||||
<path d="M5 19l1 3 1-3 3-1-3-1-1-3-1 3-3 1 3 1z" />
|
||||
<path d="M19 12l1 2 1-2 2-1-2-1-1-2-1 2-2 1 2 1z" />
|
||||
</svg>
|
||||
),
|
||||
"file-text": (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
<polyline points="10 9 9 9 8 9" />
|
||||
</svg>
|
||||
),
|
||||
image: (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<polyline points="21 15 16 10 5 21" />
|
||||
</svg>
|
||||
),
|
||||
square: (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
return <>{iconMap[type] ?? iconMap.square}</>;
|
||||
};
|
||||
|
||||
/** 关闭按钮 */
|
||||
const CloseButton: React.FC<{ onClick: () => void }> = ({ onClick }) => (
|
||||
<button
|
||||
className="block-frame-btn block-frame-close"
|
||||
onClick={onClick}
|
||||
title="关闭"
|
||||
>
|
||||
<svg
|
||||
className="w-3.5 h-3.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
|
||||
/** 最大化按钮 */
|
||||
const MaximizeButton: React.FC<{
|
||||
isMagnified: boolean;
|
||||
onClick: () => void;
|
||||
}> = ({ isMagnified, onClick }) => (
|
||||
<button
|
||||
className="block-frame-btn"
|
||||
onClick={onClick}
|
||||
title={isMagnified ? "还原" : "最大化"}
|
||||
>
|
||||
{isMagnified ? (
|
||||
<svg
|
||||
className="w-3.5 h-3.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="4 14 10 14 10 20" />
|
||||
<polyline points="20 10 14 10 14 4" />
|
||||
<line x1="14" y1="10" x2="21" y2="3" />
|
||||
<line x1="3" y1="21" x2="10" y2="14" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className="w-3.5 h-3.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="15 3 21 3 21 9" />
|
||||
<polyline points="9 21 3 21 3 15" />
|
||||
<line x1="21" y1="3" x2="14" y2="10" />
|
||||
<line x1="3" y1="21" x2="10" y2="14" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
/**
|
||||
* 块框架组件
|
||||
*/
|
||||
export const BlockFrame: React.FC<BlockFrameProps> = ({
|
||||
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 (
|
||||
<div
|
||||
className={`block-frame ${viewModel.isFocused ? "focused" : ""} ${viewModel.isMagnified ? "magnified" : ""}`}
|
||||
onClick={handleFocus}
|
||||
>
|
||||
{showHeader && (
|
||||
<div className="block-frame-header">
|
||||
<div className="block-frame-title">
|
||||
<BlockIcon type={iconType} className="block-frame-icon" />
|
||||
<span className="block-frame-title-text">{displayTitle}</span>
|
||||
</div>
|
||||
<div className="block-frame-actions">
|
||||
{actions}
|
||||
<MaximizeButton
|
||||
isMagnified={viewModel.isMagnified}
|
||||
onClick={handleMaximize}
|
||||
/>
|
||||
<CloseButton onClick={handleClose} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="block-frame-content">{children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlockFrame;
|
||||
@@ -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<BlockComponentProps> = ({
|
||||
block,
|
||||
viewModel,
|
||||
visible = true,
|
||||
}) => {
|
||||
const [content, setContent] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="preview-loading">
|
||||
<div className="preview-spinner" />
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="preview-error">
|
||||
<svg
|
||||
className="w-12 h-12 mb-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
switch (fileType) {
|
||||
case "image":
|
||||
return <ImagePreview src={filePath!} alt={fileName} />;
|
||||
|
||||
case "markdown":
|
||||
return <MarkdownPreview content={content ?? ""} />;
|
||||
|
||||
case "code":
|
||||
case "text":
|
||||
return (
|
||||
<pre className="preview-code">
|
||||
<code>{content}</code>
|
||||
</pre>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="preview-unsupported">
|
||||
<svg
|
||||
className="w-12 h-12 mb-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
<span>不支持预览此文件类型</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<BlockFrame block={block} viewModel={viewModel} title={fileName}>
|
||||
<div className={`preview-block ${visible ? "" : "hidden"}`}>
|
||||
{renderContent()}
|
||||
</div>
|
||||
</BlockFrame>
|
||||
);
|
||||
};
|
||||
|
||||
export default PreviewBlock;
|
||||
@@ -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<BlockComponentProps> = ({
|
||||
block,
|
||||
viewModel,
|
||||
visible = true,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const termWrapRef = useRef<TermWrap | null>(null);
|
||||
const resizeObserverRef = useRef<ResizeObserver | null>(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 (
|
||||
<BlockFrame block={block} viewModel={viewModel}>
|
||||
<div className="flex items-center justify-center h-full text-gray-500">
|
||||
无效的终端会话
|
||||
</div>
|
||||
</BlockFrame>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BlockFrame
|
||||
block={block}
|
||||
viewModel={viewModel}
|
||||
title={block.meta.title ?? "终端"}
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`terminal-block-container ${visible ? "" : "hidden"}`}
|
||||
onClick={handleContainerClick}
|
||||
style={{ height: "100%", width: "100%" }}
|
||||
/>
|
||||
</BlockFrame>
|
||||
);
|
||||
};
|
||||
|
||||
export default TerminalBlock;
|
||||
@@ -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<BlockComponentProps> = ({
|
||||
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<string | null>(null);
|
||||
const iframeRef = useRef<HTMLIFrameElement>(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 = (
|
||||
<>
|
||||
<button className="block-frame-btn" onClick={handleBack} title="后退">
|
||||
<svg
|
||||
className="w-3.5 h-3.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="15 18 9 12 15 6" />
|
||||
</svg>
|
||||
</button>
|
||||
<button className="block-frame-btn" onClick={handleForward} title="前进">
|
||||
<svg
|
||||
className="w-3.5 h-3.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
</button>
|
||||
<button className="block-frame-btn" onClick={handleRefresh} title="刷新">
|
||||
<svg
|
||||
className="w-3.5 h-3.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="23 4 23 10 17 10" />
|
||||
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
|
||||
</svg>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<BlockFrame
|
||||
block={block}
|
||||
viewModel={viewModel}
|
||||
title="Web"
|
||||
actions={actions}
|
||||
>
|
||||
<div className={`web-block ${visible ? "" : "hidden"}`}>
|
||||
{/* 地址栏 */}
|
||||
<div className="web-block-addressbar">
|
||||
<input
|
||||
type="text"
|
||||
className="web-block-url-input"
|
||||
placeholder="输入 URL..."
|
||||
value={inputUrl}
|
||||
onChange={(e) => setInputUrl(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<button
|
||||
className="web-block-go-btn"
|
||||
onClick={handleNavigate}
|
||||
disabled={!inputUrl}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
<polyline points="12 5 19 12 12 19" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 内容区 */}
|
||||
<div className="web-block-content">
|
||||
{loading && (
|
||||
<div className="web-block-loading">
|
||||
<div className="preview-spinner" />
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="web-block-error">
|
||||
<svg
|
||||
className="w-12 h-12 mb-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!url && !loading && !error && (
|
||||
<div className="web-block-empty">
|
||||
<svg
|
||||
className="w-12 h-12 mb-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="2" y1="12" x2="22" y2="12" />
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
|
||||
</svg>
|
||||
<span>输入 URL 开始浏览</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{url && (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={url}
|
||||
className="web-block-iframe"
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</BlockFrame>
|
||||
);
|
||||
};
|
||||
|
||||
export default WebBlock;
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* @file PanelLayout.tsx
|
||||
* @description 分屏布局组件
|
||||
* @module components/layout/PanelLayout
|
||||
*
|
||||
* 使用 react-resizable-panels 实现可调整的分屏布局。
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
Panel,
|
||||
Group as ResizablePanelGroup,
|
||||
Separator as ResizableHandle,
|
||||
} from "react-resizable-panels";
|
||||
import type { PanelNode, Block, BlockViewModel } from "@/lib/blocks/types";
|
||||
import { blockRegistry } from "@/lib/blocks/registry";
|
||||
|
||||
/** 布局属性 */
|
||||
export interface PanelLayoutProps {
|
||||
/** 根节点 */
|
||||
rootNode: PanelNode;
|
||||
/** 块映射 */
|
||||
blocks: Map<string, Block>;
|
||||
/** 活动块 ID */
|
||||
activeBlockId?: string;
|
||||
/** 最大化块 ID */
|
||||
magnifiedBlockId?: string;
|
||||
/** 块聚焦回调 */
|
||||
onBlockFocus?: (blockId: string) => void;
|
||||
/** 块关闭回调 */
|
||||
onBlockClose?: (blockId: string) => void;
|
||||
/** 块最大化回调 */
|
||||
onBlockMagnify?: (blockId: string) => void;
|
||||
/** 面板大小变化回调 */
|
||||
onPanelResize?: (nodeId: string, size: number) => void;
|
||||
}
|
||||
|
||||
/** 调整手柄组件 */
|
||||
const ResizeHandle: React.FC<{ orientation: "horizontal" | "vertical" }> = ({
|
||||
orientation,
|
||||
}) => (
|
||||
<ResizableHandle
|
||||
className={`panel-resize-handle ${orientation === "horizontal" ? "horizontal" : "vertical"}`}
|
||||
>
|
||||
<div className="panel-resize-handle-inner" />
|
||||
</ResizableHandle>
|
||||
);
|
||||
|
||||
/** 块渲染器 */
|
||||
const BlockRenderer: React.FC<{
|
||||
block: Block;
|
||||
viewModel: BlockViewModel;
|
||||
visible: boolean;
|
||||
}> = ({ block, viewModel, visible }) => {
|
||||
const Component = blockRegistry.getComponent(block.type);
|
||||
|
||||
if (!Component) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-gray-500">
|
||||
未知的块类型: {block.type}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <Component block={block} viewModel={viewModel} visible={visible} />;
|
||||
};
|
||||
|
||||
/** 面板节点渲染器 */
|
||||
const PanelNodeRenderer: React.FC<{
|
||||
node: PanelNode;
|
||||
blocks: Map<string, Block>;
|
||||
activeBlockId?: string;
|
||||
magnifiedBlockId?: string;
|
||||
onBlockFocus?: (blockId: string) => void;
|
||||
onBlockClose?: (blockId: string) => void;
|
||||
onBlockMagnify?: (blockId: string) => void;
|
||||
onPanelResize?: (nodeId: string, size: number) => void;
|
||||
}> = ({
|
||||
node,
|
||||
blocks,
|
||||
activeBlockId,
|
||||
magnifiedBlockId,
|
||||
onBlockFocus,
|
||||
onBlockClose,
|
||||
onBlockMagnify,
|
||||
onPanelResize,
|
||||
}) => {
|
||||
// 块节点
|
||||
if (node.type === "block" && node.blockId) {
|
||||
const block = blocks.get(node.blockId);
|
||||
if (!block) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-gray-500">
|
||||
块不存在
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const viewModel: BlockViewModel = {
|
||||
blockId: block.id,
|
||||
isFocused: activeBlockId === block.id,
|
||||
isMagnified: magnifiedBlockId === block.id,
|
||||
focus: () => onBlockFocus?.(block.id),
|
||||
toggleMagnify: () => onBlockMagnify?.(block.id),
|
||||
close: () => onBlockClose?.(block.id),
|
||||
};
|
||||
|
||||
return (
|
||||
<BlockRenderer
|
||||
block={block}
|
||||
viewModel={viewModel}
|
||||
visible={!magnifiedBlockId || magnifiedBlockId === block.id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 组节点
|
||||
if (node.type === "group" && node.children && node.children.length > 0) {
|
||||
const orientation = node.direction ?? "horizontal";
|
||||
|
||||
return (
|
||||
<ResizablePanelGroup orientation={orientation} className="panel-group">
|
||||
{node.children.map((child, index) => (
|
||||
<React.Fragment key={child.id}>
|
||||
{index > 0 && <ResizeHandle orientation={orientation} />}
|
||||
<Panel
|
||||
defaultSize={child.size ?? 100 / node.children!.length}
|
||||
minSize={10}
|
||||
onResize={(size) => onPanelResize?.(child.id, size.asPercentage)}
|
||||
>
|
||||
<PanelNodeRenderer
|
||||
node={child}
|
||||
blocks={blocks}
|
||||
activeBlockId={activeBlockId}
|
||||
magnifiedBlockId={magnifiedBlockId}
|
||||
onBlockFocus={onBlockFocus}
|
||||
onBlockClose={onBlockClose}
|
||||
onBlockMagnify={onBlockMagnify}
|
||||
onPanelResize={onPanelResize}
|
||||
/>
|
||||
</Panel>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</ResizablePanelGroup>
|
||||
);
|
||||
}
|
||||
|
||||
// 空节点
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-gray-500">
|
||||
空面板
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 分屏布局组件
|
||||
*/
|
||||
export const PanelLayout: React.FC<PanelLayoutProps> = ({
|
||||
rootNode,
|
||||
blocks,
|
||||
activeBlockId,
|
||||
magnifiedBlockId,
|
||||
onBlockFocus,
|
||||
onBlockClose,
|
||||
onBlockMagnify,
|
||||
onPanelResize,
|
||||
}) => {
|
||||
// 如果有最大化的块,只显示该块
|
||||
if (magnifiedBlockId) {
|
||||
const block = blocks.get(magnifiedBlockId);
|
||||
if (block) {
|
||||
const viewModel: BlockViewModel = {
|
||||
blockId: block.id,
|
||||
isFocused: true,
|
||||
isMagnified: true,
|
||||
focus: () => onBlockFocus?.(block.id),
|
||||
toggleMagnify: () => onBlockMagnify?.(block.id),
|
||||
close: () => onBlockClose?.(block.id),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="panel-layout magnified">
|
||||
<BlockRenderer block={block} viewModel={viewModel} visible={true} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="panel-layout">
|
||||
<PanelNodeRenderer
|
||||
node={rootNode}
|
||||
blocks={blocks}
|
||||
activeBlockId={activeBlockId}
|
||||
magnifiedBlockId={magnifiedBlockId}
|
||||
onBlockFocus={onBlockFocus}
|
||||
onBlockClose={onBlockClose}
|
||||
onBlockMagnify={onBlockMagnify}
|
||||
onPanelResize={onPanelResize}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PanelLayout;
|
||||
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* @file layout.css
|
||||
* @description 布局组件样式
|
||||
* @module components/layout
|
||||
*/
|
||||
|
||||
/* ============================================================================
|
||||
* 面板布局
|
||||
* ============================================================================ */
|
||||
|
||||
.panel-layout {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-layout.magnified {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.panel-group {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 调整手柄 */
|
||||
.panel-resize-handle {
|
||||
position: relative;
|
||||
background-color: transparent;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.panel-resize-handle.horizontal {
|
||||
width: 4px;
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
.panel-resize-handle.vertical {
|
||||
height: 4px;
|
||||
cursor: row-resize;
|
||||
}
|
||||
|
||||
.panel-resize-handle:hover,
|
||||
.panel-resize-handle[data-resize-handle-active] {
|
||||
background-color: var(--terminal-accent, #7aa2f7);
|
||||
}
|
||||
|
||||
.panel-resize-handle-inner {
|
||||
position: absolute;
|
||||
background-color: var(--terminal-border, #3b4261);
|
||||
}
|
||||
|
||||
.panel-resize-handle.horizontal .panel-resize-handle-inner {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: 1px;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.panel-resize-handle.vertical .panel-resize-handle-inner {
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
height: 1px;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* 块框架
|
||||
* ============================================================================ */
|
||||
|
||||
.block-frame {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background-color: var(--terminal-bg, #1a1b26);
|
||||
border: 1px solid var(--terminal-border, #3b4261);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.block-frame.focused {
|
||||
border-color: var(--terminal-accent, #7aa2f7);
|
||||
}
|
||||
|
||||
.block-frame.magnified {
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* 块标题栏 */
|
||||
.block-frame-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 32px;
|
||||
padding: 0 8px;
|
||||
background-color: var(--terminal-tab-bg, #24283b);
|
||||
border-bottom: 1px solid var(--terminal-border, #3b4261);
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.block-frame-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--terminal-fg, #c0caf5);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.block-frame-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
color: var(--terminal-muted, #565f89);
|
||||
}
|
||||
|
||||
.block-frame-title-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.block-frame-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.block-frame-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
color: var(--terminal-muted, #565f89);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.block-frame-btn:hover {
|
||||
background-color: var(--terminal-tab-hover-bg, #414868);
|
||||
color: var(--terminal-fg, #c0caf5);
|
||||
}
|
||||
|
||||
.block-frame-close:hover {
|
||||
background-color: rgba(248, 81, 73, 0.2);
|
||||
color: var(--terminal-error, #f7768e);
|
||||
}
|
||||
|
||||
/* 块内容区 */
|
||||
.block-frame-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* 终端块
|
||||
* ============================================================================ */
|
||||
|
||||
.terminal-block-container {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.terminal-block-container.hidden {
|
||||
visibility: hidden;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* 工作区标签栏
|
||||
* ============================================================================ */
|
||||
|
||||
.workspace-tabbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: var(--terminal-tab-bg, #24283b);
|
||||
border-bottom: 1px solid var(--terminal-border, #3b4261);
|
||||
height: 36px;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.workspace-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
overflow-x: auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.workspace-tabs::-webkit-scrollbar {
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.workspace-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
height: 100%;
|
||||
min-width: 100px;
|
||||
max-width: 180px;
|
||||
border-right: 1px solid var(--terminal-border, #3b4261);
|
||||
color: var(--terminal-muted, #565f89);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.workspace-tab:hover {
|
||||
background-color: var(--terminal-tab-hover-bg, #414868);
|
||||
color: var(--terminal-fg, #c0caf5);
|
||||
}
|
||||
|
||||
.workspace-tab.active {
|
||||
background-color: var(--terminal-bg, #1a1b26);
|
||||
color: var(--terminal-fg, #c0caf5);
|
||||
border-bottom: 2px solid var(--terminal-accent, #7aa2f7);
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
.workspace-tab-title {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workspace-tab-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-left: 4px;
|
||||
border-radius: 4px;
|
||||
color: var(--terminal-muted, #565f89);
|
||||
opacity: 0;
|
||||
transition: all 0.15s ease;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.workspace-tab:hover .workspace-tab-close,
|
||||
.workspace-tab.active .workspace-tab-close {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.workspace-tab-close:hover {
|
||||
background-color: rgba(248, 81, 73, 0.2);
|
||||
color: var(--terminal-error, #f7768e);
|
||||
}
|
||||
|
||||
.workspace-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.workspace-action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 4px;
|
||||
color: var(--terminal-muted, #565f89);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.workspace-action-btn:hover {
|
||||
background-color: var(--terminal-tab-hover-bg, #414868);
|
||||
color: var(--terminal-fg, #c0caf5);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { MachineIdTool } from "@/components/tools/machine-id/MachineIdTool";
|
||||
import { BrowserInterceptorTool } from "@/components/tools/browser-interceptor/BrowserInterceptorTool";
|
||||
import { FlowMonitorPage } from "@/pages";
|
||||
import { ConfigManagementPage } from "@/components/config/ConfigManagementPage";
|
||||
import { TerminalPage } from "@/components/terminal";
|
||||
import { PluginUIRenderer as DynamicPluginRenderer } from "@/lib/plugin-loader/PluginUIRenderer";
|
||||
import { usePluginSDK } from "@/lib/plugin-sdk";
|
||||
|
||||
@@ -115,6 +116,7 @@ const builtinPluginComponents: Record<
|
||||
"browser-interception": BrowserInterceptorTool,
|
||||
"flow-monitor": FlowMonitorPage,
|
||||
"config-switch": ConfigManagementPage,
|
||||
"terminal-plugin": TerminalPage,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -149,6 +151,7 @@ function DynamicPluginUIRenderer({
|
||||
pluginId={pluginId}
|
||||
uiEntry={uiEntry || "dist/index.js"}
|
||||
sdk={sdk}
|
||||
className="h-full w-full"
|
||||
fallback={<PluginNotFound pluginId={pluginId} />}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* @file ImagePreview.tsx
|
||||
* @description 图片预览组件
|
||||
* @module components/preview/ImagePreview
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback } from "react";
|
||||
|
||||
/** 图片预览属性 */
|
||||
export interface ImagePreviewProps {
|
||||
/** 图片 URL 或 Base64 */
|
||||
src: string;
|
||||
/** 图片 alt 文本 */
|
||||
alt?: string;
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片预览组件
|
||||
*/
|
||||
export const ImagePreview: React.FC<ImagePreviewProps> = ({
|
||||
src,
|
||||
alt,
|
||||
className,
|
||||
}) => {
|
||||
const [scale, setScale] = useState(1);
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
||||
|
||||
// 缩放
|
||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? -0.1 : 0.1;
|
||||
setScale((prev) => Math.max(0.1, Math.min(5, prev + delta)));
|
||||
}, []);
|
||||
|
||||
// 拖拽开始
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (scale > 1) {
|
||||
setIsDragging(true);
|
||||
setDragStart({ x: e.clientX - position.x, y: e.clientY - position.y });
|
||||
}
|
||||
},
|
||||
[scale, position],
|
||||
);
|
||||
|
||||
// 拖拽中
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (isDragging) {
|
||||
setPosition({
|
||||
x: e.clientX - dragStart.x,
|
||||
y: e.clientY - dragStart.y,
|
||||
});
|
||||
}
|
||||
},
|
||||
[isDragging, dragStart],
|
||||
);
|
||||
|
||||
// 拖拽结束
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
// 重置
|
||||
const handleReset = useCallback(() => {
|
||||
setScale(1);
|
||||
setPosition({ x: 0, y: 0 });
|
||||
}, []);
|
||||
|
||||
// 放大
|
||||
const handleZoomIn = useCallback(() => {
|
||||
setScale((prev) => Math.min(5, prev + 0.25));
|
||||
}, []);
|
||||
|
||||
// 缩小
|
||||
const handleZoomOut = useCallback(() => {
|
||||
setScale((prev) => Math.max(0.1, prev - 0.25));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={`image-preview ${className ?? ""}`}>
|
||||
{/* 工具栏 */}
|
||||
<div className="image-preview-toolbar">
|
||||
<button onClick={handleZoomOut} title="缩小">
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
<line x1="8" y1="11" x2="14" y2="11" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="image-preview-scale">{Math.round(scale * 100)}%</span>
|
||||
<button onClick={handleZoomIn} title="放大">
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
<line x1="11" y1="8" x2="11" y2="14" />
|
||||
<line x1="8" y1="11" x2="14" y2="11" />
|
||||
</svg>
|
||||
</button>
|
||||
<button onClick={handleReset} title="重置">
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="1 4 1 10 7 10" />
|
||||
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 图片容器 */}
|
||||
<div
|
||||
className="image-preview-container"
|
||||
onWheel={handleWheel}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
style={{
|
||||
cursor: scale > 1 ? (isDragging ? "grabbing" : "grab") : "default",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
alt={alt ?? "预览图片"}
|
||||
style={{
|
||||
transform: `translate(${position.x}px, ${position.y}px) scale(${scale})`,
|
||||
transition: isDragging ? "none" : "transform 0.1s ease",
|
||||
}}
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImagePreview;
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* @file MarkdownPreview.tsx
|
||||
* @description Markdown 预览组件
|
||||
* @module components/preview/MarkdownPreview
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
|
||||
/** Markdown 预览属性 */
|
||||
export interface MarkdownPreviewProps {
|
||||
/** Markdown 内容 */
|
||||
content: string;
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown 预览组件
|
||||
*/
|
||||
export const MarkdownPreview: React.FC<MarkdownPreviewProps> = ({
|
||||
content,
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<div className={`markdown-preview ${className ?? ""}`}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
code({ className, children, ...props }) {
|
||||
const match = /language-(\w+)/.exec(className || "");
|
||||
const isInline = !match;
|
||||
return !isInline && match ? (
|
||||
<SyntaxHighlighter
|
||||
style={oneDark as Record<string, React.CSSProperties>}
|
||||
language={match[1]}
|
||||
PreTag="div"
|
||||
>
|
||||
{String(children).replace(/\n$/, "")}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarkdownPreview;
|
||||
@@ -0,0 +1,369 @@
|
||||
/**
|
||||
* @file preview.css
|
||||
* @description 预览组件样式
|
||||
* @module components/preview
|
||||
*/
|
||||
|
||||
/* ============================================================================
|
||||
* 通用预览样式
|
||||
* ============================================================================ */
|
||||
|
||||
.preview-block {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
background-color: var(--terminal-bg, #1a1b26);
|
||||
}
|
||||
|
||||
.preview-block.hidden {
|
||||
visibility: hidden;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.preview-loading,
|
||||
.preview-error,
|
||||
.preview-unsupported {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--terminal-muted, #565f89);
|
||||
}
|
||||
|
||||
.preview-spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid var(--terminal-border, #3b4261);
|
||||
border-top-color: var(--terminal-accent, #7aa2f7);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.preview-error {
|
||||
color: var(--terminal-error, #f7768e);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* Markdown 预览
|
||||
* ============================================================================ */
|
||||
|
||||
.markdown-preview {
|
||||
padding: 16px 24px;
|
||||
color: var(--terminal-fg, #c0caf5);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.markdown-preview h1,
|
||||
.markdown-preview h2,
|
||||
.markdown-preview h3,
|
||||
.markdown-preview h4,
|
||||
.markdown-preview h5,
|
||||
.markdown-preview h6 {
|
||||
margin-top: 24px;
|
||||
margin-bottom: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
color: var(--terminal-fg, #c0caf5);
|
||||
}
|
||||
|
||||
.markdown-preview h1 {
|
||||
font-size: 2em;
|
||||
border-bottom: 1px solid var(--terminal-border, #3b4261);
|
||||
padding-bottom: 0.3em;
|
||||
}
|
||||
|
||||
.markdown-preview h2 {
|
||||
font-size: 1.5em;
|
||||
border-bottom: 1px solid var(--terminal-border, #3b4261);
|
||||
padding-bottom: 0.3em;
|
||||
}
|
||||
|
||||
.markdown-preview h3 {
|
||||
font-size: 1.25em;
|
||||
}
|
||||
|
||||
.markdown-preview p {
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.markdown-preview a {
|
||||
color: var(--terminal-accent, #7aa2f7);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.markdown-preview a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.markdown-preview code {
|
||||
padding: 0.2em 0.4em;
|
||||
margin: 0;
|
||||
font-size: 85%;
|
||||
background-color: rgba(110, 118, 129, 0.4);
|
||||
border-radius: 6px;
|
||||
font-family: "Hack", "Menlo", monospace;
|
||||
}
|
||||
|
||||
.markdown-preview pre {
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
font-size: 85%;
|
||||
line-height: 1.45;
|
||||
background-color: var(--terminal-tab-bg, #24283b);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.markdown-preview pre code {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-size: 100%;
|
||||
background-color: transparent;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.markdown-preview blockquote {
|
||||
padding: 0 1em;
|
||||
color: var(--terminal-muted, #565f89);
|
||||
border-left: 0.25em solid var(--terminal-border, #3b4261);
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
|
||||
.markdown-preview ul,
|
||||
.markdown-preview ol {
|
||||
padding-left: 2em;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.markdown-preview li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.markdown-preview table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.markdown-preview th,
|
||||
.markdown-preview td {
|
||||
padding: 6px 13px;
|
||||
border: 1px solid var(--terminal-border, #3b4261);
|
||||
}
|
||||
|
||||
.markdown-preview th {
|
||||
font-weight: 600;
|
||||
background-color: var(--terminal-tab-bg, #24283b);
|
||||
}
|
||||
|
||||
.markdown-preview tr:nth-child(2n) {
|
||||
background-color: rgba(110, 118, 129, 0.1);
|
||||
}
|
||||
|
||||
.markdown-preview hr {
|
||||
height: 0.25em;
|
||||
padding: 0;
|
||||
margin: 24px 0;
|
||||
background-color: var(--terminal-border, #3b4261);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.markdown-preview img {
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* 图片预览
|
||||
* ============================================================================ */
|
||||
|
||||
.image-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background-color: var(--terminal-bg, #1a1b26);
|
||||
}
|
||||
|
||||
.image-preview-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
background-color: var(--terminal-tab-bg, #24283b);
|
||||
border-bottom: 1px solid var(--terminal-border, #3b4261);
|
||||
}
|
||||
|
||||
.image-preview-toolbar button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 4px;
|
||||
color: var(--terminal-muted, #565f89);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.image-preview-toolbar button:hover {
|
||||
background-color: var(--terminal-tab-hover-bg, #414868);
|
||||
color: var(--terminal-fg, #c0caf5);
|
||||
}
|
||||
|
||||
.image-preview-scale {
|
||||
min-width: 50px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--terminal-muted, #565f89);
|
||||
}
|
||||
|
||||
.image-preview-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
background-color: var(--terminal-bg, #1a1b26);
|
||||
background-image:
|
||||
linear-gradient(45deg, #2a2b36 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #2a2b36 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #2a2b36 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #2a2b36 75%);
|
||||
background-size: 20px 20px;
|
||||
background-position:
|
||||
0 0,
|
||||
0 10px,
|
||||
10px -10px,
|
||||
-10px 0px;
|
||||
}
|
||||
|
||||
.image-preview-container img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* 代码预览
|
||||
* ============================================================================ */
|
||||
|
||||
.preview-code {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
background-color: var(--terminal-bg, #1a1b26);
|
||||
color: var(--terminal-fg, #c0caf5);
|
||||
font-family: "Hack", "Menlo", monospace;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* Web 视图块
|
||||
* ============================================================================ */
|
||||
|
||||
.web-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background-color: var(--terminal-bg, #1a1b26);
|
||||
}
|
||||
|
||||
.web-block.hidden {
|
||||
visibility: hidden;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.web-block-addressbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
background-color: var(--terminal-tab-bg, #24283b);
|
||||
border-bottom: 1px solid var(--terminal-border, #3b4261);
|
||||
}
|
||||
|
||||
.web-block-url-input {
|
||||
flex: 1;
|
||||
padding: 6px 12px;
|
||||
background-color: var(--terminal-bg, #1a1b26);
|
||||
border: 1px solid var(--terminal-border, #3b4261);
|
||||
border-radius: 6px;
|
||||
color: var(--terminal-fg, #c0caf5);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.web-block-url-input:focus {
|
||||
border-color: var(--terminal-accent, #7aa2f7);
|
||||
}
|
||||
|
||||
.web-block-url-input::placeholder {
|
||||
color: var(--terminal-muted, #565f89);
|
||||
}
|
||||
|
||||
.web-block-go-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
background-color: var(--terminal-accent, #7aa2f7);
|
||||
color: var(--terminal-bg, #1a1b26);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.web-block-go-btn:hover:not(:disabled) {
|
||||
background-color: #89b4fa;
|
||||
}
|
||||
|
||||
.web-block-go-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.web-block-content {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.web-block-loading,
|
||||
.web-block-error,
|
||||
.web-block-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--terminal-muted, #565f89);
|
||||
}
|
||||
|
||||
.web-block-error {
|
||||
color: var(--terminal-error, #f7768e);
|
||||
}
|
||||
|
||||
.web-block-iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
background-color: white;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
# terminal
|
||||
|
||||
<!-- 一旦我所属的文件夹有所变化,请更新我 -->
|
||||
|
||||
## 架构说明
|
||||
|
||||
内置终端组件,采用**后端预创建 PTY**架构(参考 WaveTerm)。
|
||||
|
||||
**核心原则:**
|
||||
- 后端是会话的唯一真相来源
|
||||
- PTY 在后端预创建,使用默认大小 (24x80)
|
||||
- 前端只是"连接"到会话,不负责创建
|
||||
- resize 只是同步大小,不触发创建
|
||||
|
||||
**数据流:**
|
||||
```
|
||||
[用户点击新建终端]
|
||||
↓
|
||||
[前端调用 terminal_create_session]
|
||||
↓
|
||||
[后端创建 PTY(默认 24x80)]
|
||||
↓
|
||||
[返回 session_id 给前端]
|
||||
↓
|
||||
[前端创建 TermWrap,连接到 session_id]
|
||||
↓
|
||||
[TermWrap 初始化 xterm,监听事件]
|
||||
↓
|
||||
[首次 fit 后,同步实际大小到后端]
|
||||
```
|
||||
|
||||
## 核心功能
|
||||
|
||||
- **PTY 会话管理**: 后端预创建,前端连接
|
||||
- **实时输入输出**: 通过 Tauri Events 实现
|
||||
- **自适应大小**: 自动调整终端尺寸,同步到后端
|
||||
- **xterm.js 渲染**: 高性能终端渲染
|
||||
- **多标签页**: 支持多个终端会话
|
||||
|
||||
## 文件索引
|
||||
|
||||
- `index.ts` - 模块导出
|
||||
- `TerminalPage.tsx` - 终端页面组件(多标签页管理)
|
||||
- `termwrap.ts` - 终端封装类(连接模式)
|
||||
- `fitaddon.ts` - 自定义 FitAddon
|
||||
- `terminal.css` - 终端样式(Tokyo Night 主题)
|
||||
|
||||
## 依赖
|
||||
|
||||
- `@xterm/xterm` - 终端渲染
|
||||
- `@xterm/addon-fit` - 自适应大小
|
||||
- `@xterm/addon-web-links` - 链接支持
|
||||
- `@/lib/terminal-api` - Tauri 终端 API
|
||||
|
||||
## 使用方式
|
||||
|
||||
作为内置插件在 `PluginUIRenderer` 中注册:
|
||||
|
||||
```typescript
|
||||
const builtinPluginComponents = {
|
||||
"terminal-plugin": TerminalPage,
|
||||
};
|
||||
```
|
||||
|
||||
## 更新提醒
|
||||
|
||||
任何文件变更后,请更新此文档和相关的上级文档。
|
||||
@@ -0,0 +1,674 @@
|
||||
/**
|
||||
* @file TerminalPage.tsx
|
||||
* @description 内置终端页面组件 - 后端预创建架构
|
||||
* @module components/terminal/TerminalPage
|
||||
*
|
||||
* ## 架构说明
|
||||
* PTY 在后端预创建,前端只负责连接。
|
||||
* 新建终端时先调用后端创建会话,成功后再创建 UI 组件。
|
||||
*
|
||||
* ## 功能特性
|
||||
* - 多标签页管理
|
||||
* - 终端搜索 (Ctrl+F)
|
||||
* - 主题切换
|
||||
*/
|
||||
|
||||
import React, { useEffect, useRef, useState, useCallback } from "react";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import {
|
||||
createTerminalSession,
|
||||
closeTerminal,
|
||||
type SessionStatus,
|
||||
} from "@/lib/terminal-api";
|
||||
import { TermWrap } from "./termwrap";
|
||||
import { TerminalSearch } from "./TerminalSearch";
|
||||
import {
|
||||
type ThemeName,
|
||||
getThemeList,
|
||||
saveThemePreference,
|
||||
loadThemePreference,
|
||||
} from "@/lib/terminal/themes";
|
||||
import "./terminal.css";
|
||||
|
||||
// ============================================================================
|
||||
// 类型定义
|
||||
// ============================================================================
|
||||
|
||||
/** 标签页数据 */
|
||||
interface Tab {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
title: string;
|
||||
status: SessionStatus;
|
||||
isSSH?: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 子组件
|
||||
// ============================================================================
|
||||
|
||||
/** 状态指示器 */
|
||||
const StatusIndicator: React.FC<{ status: SessionStatus }> = ({ status }) => {
|
||||
const statusClasses: Record<SessionStatus, string> = {
|
||||
connecting: "connecting",
|
||||
running: "running",
|
||||
done: "done",
|
||||
error: "error",
|
||||
};
|
||||
return (
|
||||
<span
|
||||
className={`terminal-status-indicator ${statusClasses[status] || ""}`}
|
||||
title={status}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/** 终端图标 */
|
||||
const TerminalIcon: React.FC<{ isSSH?: boolean; className?: string }> = ({
|
||||
isSSH,
|
||||
className,
|
||||
}) => (
|
||||
<svg
|
||||
className={className || "w-4 h-4 mr-2 flex-shrink-0"}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
{isSSH ? (
|
||||
<>
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<polyline points="4 17 10 11 4 5" />
|
||||
<line x1="12" y1="19" x2="20" y2="19" />
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** 关闭按钮 */
|
||||
const CloseButton: React.FC<{ onClick: (e: React.MouseEvent) => void }> = ({
|
||||
onClick,
|
||||
}) => (
|
||||
<button className="terminal-close-btn" onClick={onClick} title="关闭标签页">
|
||||
<svg
|
||||
className="w-3.5 h-3.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
|
||||
/** 新建标签按钮 */
|
||||
const NewTabButton: React.FC<{ onClick: () => void; disabled?: boolean }> = ({
|
||||
onClick,
|
||||
disabled,
|
||||
}) => (
|
||||
<button
|
||||
className="terminal-new-tab-btn"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
title="新建终端"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
|
||||
/** 搜索按钮 */
|
||||
const SearchButton: React.FC<{ onClick: () => void }> = ({ onClick }) => (
|
||||
<button
|
||||
className="terminal-new-tab-btn"
|
||||
onClick={onClick}
|
||||
title="搜索 (Ctrl+F)"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
|
||||
/** 主题选择器 */
|
||||
const ThemeSelector: React.FC<{
|
||||
currentTheme: ThemeName;
|
||||
onThemeChange: (theme: ThemeName) => void;
|
||||
}> = ({ currentTheme, onThemeChange }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const themes = getThemeList();
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
className="terminal-new-tab-btn"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
title="切换主题"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="5" />
|
||||
<line x1="12" y1="1" x2="12" y2="3" />
|
||||
<line x1="12" y1="21" x2="12" y2="23" />
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
|
||||
<line x1="1" y1="12" x2="3" y2="12" />
|
||||
<line x1="21" y1="12" x2="23" y2="12" />
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
|
||||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
|
||||
</svg>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-10"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
<div className="terminal-theme-dropdown">
|
||||
{themes.map((theme) => (
|
||||
<div
|
||||
key={theme.name}
|
||||
className={`terminal-theme-item ${theme.name === currentTheme ? "active" : ""}`}
|
||||
onClick={() => {
|
||||
onThemeChange(theme.name);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="terminal-theme-preview"
|
||||
style={{ backgroundColor: theme.theme.background }}
|
||||
/>
|
||||
{theme.displayName}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** 标签页组件 */
|
||||
const TabItem: React.FC<{
|
||||
tab: Tab;
|
||||
isActive: boolean;
|
||||
onSelect: () => void;
|
||||
onClose: () => void;
|
||||
}> = ({ tab, isActive, onSelect, onClose }) => {
|
||||
const handleClose = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`terminal-tab ${isActive ? "active" : ""}`}
|
||||
onClick={onSelect}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
>
|
||||
<StatusIndicator status={tab.status} />
|
||||
<TerminalIcon isSSH={tab.isSSH} />
|
||||
<span className="truncate flex-1 text-left">{tab.title}</span>
|
||||
<CloseButton onClick={handleClose} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** 标签栏组件 */
|
||||
const TerminalTabs: React.FC<{
|
||||
tabs: Tab[];
|
||||
activeTabId: string | null;
|
||||
onTabSelect: (id: string) => void;
|
||||
onTabClose: (id: string) => void;
|
||||
onNewTab: () => void;
|
||||
onSearchClick: () => void;
|
||||
currentTheme: ThemeName;
|
||||
onThemeChange: (theme: ThemeName) => void;
|
||||
isCreating?: boolean;
|
||||
}> = ({
|
||||
tabs,
|
||||
activeTabId,
|
||||
onTabSelect,
|
||||
onTabClose,
|
||||
onNewTab,
|
||||
onSearchClick,
|
||||
currentTheme,
|
||||
onThemeChange,
|
||||
isCreating,
|
||||
}) => (
|
||||
<div className="terminal-tabbar" role="tablist">
|
||||
<div className="flex items-center flex-1 overflow-x-auto h-full">
|
||||
{tabs.map((tab) => (
|
||||
<TabItem
|
||||
key={tab.id}
|
||||
tab={tab}
|
||||
isActive={tab.id === activeTabId}
|
||||
onSelect={() => onTabSelect(tab.id)}
|
||||
onClose={() => onTabClose(tab.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<SearchButton onClick={onSearchClick} />
|
||||
<ThemeSelector currentTheme={currentTheme} onThemeChange={onThemeChange} />
|
||||
<NewTabButton onClick={onNewTab} disabled={isCreating} />
|
||||
</div>
|
||||
);
|
||||
|
||||
/** 空状态占位符 */
|
||||
const EmptyTabsPlaceholder: React.FC<{
|
||||
onNewTab: () => void;
|
||||
isCreating?: boolean;
|
||||
}> = ({ onNewTab, isCreating }) => (
|
||||
<div className="terminal-empty-state">
|
||||
<TerminalIcon className="terminal-empty-state-icon" />
|
||||
<p className="terminal-empty-state-text">没有打开的终端</p>
|
||||
<button
|
||||
className="terminal-empty-state-btn"
|
||||
onClick={onNewTab}
|
||||
disabled={isCreating}
|
||||
>
|
||||
{isCreating ? "创建中..." : "新建终端"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// 终端视图组件 - 连接模式
|
||||
// ============================================================================
|
||||
|
||||
interface TerminalViewProps {
|
||||
/** 会话 ID(必须) */
|
||||
sessionId: string;
|
||||
/** 状态变化回调 */
|
||||
onStatusChange: (status: SessionStatus) => void;
|
||||
/** 是否自动聚焦 */
|
||||
autoFocus?: boolean;
|
||||
/** 是否可见(用于多标签页切换) */
|
||||
visible?: boolean;
|
||||
/** 主题名称 */
|
||||
themeName?: ThemeName;
|
||||
/** TermWrap 引用回调 */
|
||||
onTermWrapRef?: (termWrap: TermWrap | null) => void;
|
||||
}
|
||||
|
||||
const TerminalView: React.FC<TerminalViewProps> = ({
|
||||
sessionId,
|
||||
onStatusChange,
|
||||
autoFocus,
|
||||
visible = true,
|
||||
themeName,
|
||||
onTermWrapRef,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const termWrapRef = useRef<TermWrap | null>(null);
|
||||
const resizeObserverRef = useRef<ResizeObserver | null>(null);
|
||||
const callbacksRef = useRef({ onStatusChange, onTermWrapRef });
|
||||
callbacksRef.current = { onStatusChange, onTermWrapRef };
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
// 创建 TermWrap 实例
|
||||
const termWrap = new TermWrap(sessionId, container, {
|
||||
onStatusChange: (status) => callbacksRef.current.onStatusChange(status),
|
||||
themeName,
|
||||
});
|
||||
|
||||
termWrapRef.current = termWrap;
|
||||
callbacksRef.current.onTermWrapRef?.(termWrap);
|
||||
|
||||
// 设置 ResizeObserver
|
||||
const rszObs = new ResizeObserver(() => {
|
||||
termWrap.handleResize_debounced();
|
||||
});
|
||||
rszObs.observe(container);
|
||||
resizeObserverRef.current = rszObs;
|
||||
|
||||
// 自动聚焦
|
||||
if (autoFocus) {
|
||||
setTimeout(() => termWrap.focus(), 10);
|
||||
}
|
||||
|
||||
return () => {
|
||||
termWrap.dispose();
|
||||
rszObs.disconnect();
|
||||
callbacksRef.current.onTermWrapRef?.(null);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sessionId]);
|
||||
|
||||
// 主题变化时更新
|
||||
useEffect(() => {
|
||||
if (themeName && termWrapRef.current) {
|
||||
termWrapRef.current.setTheme(themeName);
|
||||
}
|
||||
}, [themeName]);
|
||||
|
||||
// 当可见性变化时,触发 resize 和聚焦
|
||||
useEffect(() => {
|
||||
if (visible && termWrapRef.current) {
|
||||
termWrapRef.current.handleResize_debounced();
|
||||
if (autoFocus) {
|
||||
termWrapRef.current.focus();
|
||||
}
|
||||
}
|
||||
}, [visible, autoFocus, themeName]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`terminal-container ${visible ? "" : "terminal-hidden"}`}
|
||||
onClick={() => termWrapRef.current?.focus()}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 主组件
|
||||
// ============================================================================
|
||||
|
||||
export function TerminalPage() {
|
||||
const [tabs, setTabs] = useState<Tab[]>([]);
|
||||
const [activeTabId, setActiveTabId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
const [currentTheme, setCurrentTheme] = useState<ThemeName>(
|
||||
loadThemePreference(),
|
||||
);
|
||||
const tabIdCounter = useRef(0);
|
||||
const termWrapRefs = useRef<Map<string, TermWrap>>(new Map());
|
||||
const pageRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 调试:打印布局链高度
|
||||
useEffect(() => {
|
||||
if (pageRef.current) {
|
||||
let el: HTMLElement | null = pageRef.current;
|
||||
const heights: string[] = [];
|
||||
while (el) {
|
||||
const style = window.getComputedStyle(el);
|
||||
heights.push(
|
||||
`${el.className?.slice(0, 30) || el.tagName}: ${style.height}`,
|
||||
);
|
||||
el = el.parentElement;
|
||||
}
|
||||
console.log("[TerminalPage] 布局链高度:", heights);
|
||||
}
|
||||
}, [tabs.length]);
|
||||
|
||||
// 获取当前活动的 TermWrap
|
||||
const getActiveTermWrap = useCallback(() => {
|
||||
if (!activeTabId) return null;
|
||||
const tab = tabs.find((t) => t.id === activeTabId);
|
||||
if (!tab) return null;
|
||||
return termWrapRefs.current.get(tab.sessionId) ?? null;
|
||||
}, [activeTabId, tabs]);
|
||||
|
||||
// 处理 TermWrap 引用
|
||||
const handleTermWrapRef = useCallback(
|
||||
(sessionId: string, termWrap: TermWrap | null) => {
|
||||
if (termWrap) {
|
||||
termWrapRefs.current.set(sessionId, termWrap);
|
||||
} else {
|
||||
termWrapRefs.current.delete(sessionId);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// 创建新终端
|
||||
const handleNewTerminal = useCallback(async () => {
|
||||
if (isCreating) return;
|
||||
|
||||
setIsCreating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// 先调用后端创建会话
|
||||
const sessionId = await createTerminalSession();
|
||||
console.log("[TerminalPage] 会话已创建:", sessionId);
|
||||
|
||||
// 创建成功后添加标签页
|
||||
const newTabId = `tab-${++tabIdCounter.current}`;
|
||||
const newTab: Tab = {
|
||||
id: newTabId,
|
||||
sessionId,
|
||||
title: "Terminal",
|
||||
status: "running",
|
||||
isSSH: false,
|
||||
};
|
||||
|
||||
setTabs((prev) => [...prev, newTab]);
|
||||
setActiveTabId(newTabId);
|
||||
} catch (err) {
|
||||
console.error("[TerminalPage] 创建终端失败:", err);
|
||||
setError("创建终端会话失败");
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
}, [isCreating]);
|
||||
|
||||
// 状态变化
|
||||
const handleStatusChange = useCallback(
|
||||
(tabId: string, status: SessionStatus) => {
|
||||
setTabs((prev) =>
|
||||
prev.map((tab) => (tab.id === tabId ? { ...tab, status } : tab)),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// 关闭会话
|
||||
const handleCloseTab = useCallback(
|
||||
async (tabId: string) => {
|
||||
const tab = tabs.find((t) => t.id === tabId);
|
||||
if (tab) {
|
||||
try {
|
||||
await closeTerminal(tab.sessionId);
|
||||
} catch (err) {
|
||||
console.error("[TerminalPage] 关闭终端会话失败:", err);
|
||||
}
|
||||
}
|
||||
|
||||
setTabs((prev) => {
|
||||
const remaining = prev.filter((t) => t.id !== tabId);
|
||||
// 如果关闭的是当前活动标签,切换到最后一个
|
||||
if (activeTabId === tabId && remaining.length > 0) {
|
||||
setActiveTabId(remaining[remaining.length - 1].id);
|
||||
} else if (remaining.length === 0) {
|
||||
setActiveTabId(null);
|
||||
}
|
||||
return remaining;
|
||||
});
|
||||
},
|
||||
[tabs, activeTabId],
|
||||
);
|
||||
|
||||
// 主题变化
|
||||
const handleThemeChange = useCallback((theme: ThemeName) => {
|
||||
setCurrentTheme(theme);
|
||||
saveThemePreference(theme);
|
||||
// 更新所有终端的主题
|
||||
termWrapRefs.current.forEach((termWrap) => {
|
||||
termWrap.setTheme(theme);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 搜索功能
|
||||
const handleSearch = useCallback(
|
||||
(term: string, options: import("@xterm/addon-search").ISearchOptions) => {
|
||||
const termWrap = getActiveTermWrap();
|
||||
if (!termWrap) return false;
|
||||
return termWrap.search(term, options);
|
||||
},
|
||||
[getActiveTermWrap],
|
||||
);
|
||||
|
||||
const handleSearchNext = useCallback(
|
||||
(term: string, options: import("@xterm/addon-search").ISearchOptions) => {
|
||||
const termWrap = getActiveTermWrap();
|
||||
if (!termWrap) return false;
|
||||
return termWrap.searchNext(term, options);
|
||||
},
|
||||
[getActiveTermWrap],
|
||||
);
|
||||
|
||||
const handleSearchPrevious = useCallback(
|
||||
(term: string, options: import("@xterm/addon-search").ISearchOptions) => {
|
||||
const termWrap = getActiveTermWrap();
|
||||
if (!termWrap) return false;
|
||||
return termWrap.searchPrevious(term, options);
|
||||
},
|
||||
[getActiveTermWrap],
|
||||
);
|
||||
|
||||
const handleClearSearch = useCallback(() => {
|
||||
const termWrap = getActiveTermWrap();
|
||||
if (termWrap) {
|
||||
termWrap.clearSearch();
|
||||
}
|
||||
}, [getActiveTermWrap]);
|
||||
|
||||
// 键盘快捷键
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Ctrl+F 打开搜索
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "f") {
|
||||
e.preventDefault();
|
||||
setShowSearch(true);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, []);
|
||||
|
||||
// 清除错误
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
const timer = setTimeout(() => setError(null), 5000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
// 没有标签页时显示空状态
|
||||
if (tabs.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col h-full w-full min-h-0 terminal-bg">
|
||||
<TerminalTabs
|
||||
tabs={[]}
|
||||
activeTabId={null}
|
||||
onTabSelect={() => {}}
|
||||
onTabClose={() => {}}
|
||||
onNewTab={handleNewTerminal}
|
||||
onSearchClick={() => setShowSearch(true)}
|
||||
currentTheme={currentTheme}
|
||||
onThemeChange={handleThemeChange}
|
||||
isCreating={isCreating}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<EmptyTabsPlaceholder
|
||||
onNewTab={handleNewTerminal}
|
||||
isCreating={isCreating}
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="absolute bottom-4 right-4 bg-red-600/90 text-white px-4 py-2 rounded-lg shadow-lg text-sm z-50">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={pageRef}
|
||||
className="flex flex-col w-full h-full overflow-hidden relative terminal-bg"
|
||||
>
|
||||
{/* 标签栏 */}
|
||||
<TerminalTabs
|
||||
tabs={tabs}
|
||||
activeTabId={activeTabId}
|
||||
onTabSelect={setActiveTabId}
|
||||
onTabClose={handleCloseTab}
|
||||
onNewTab={handleNewTerminal}
|
||||
onSearchClick={() => setShowSearch(true)}
|
||||
currentTheme={currentTheme}
|
||||
onThemeChange={handleThemeChange}
|
||||
isCreating={isCreating}
|
||||
/>
|
||||
|
||||
{/* 搜索栏 */}
|
||||
<TerminalSearch
|
||||
visible={showSearch}
|
||||
onClose={() => setShowSearch(false)}
|
||||
onSearch={handleSearch}
|
||||
onSearchNext={handleSearchNext}
|
||||
onSearchPrevious={handleSearchPrevious}
|
||||
onClearSearch={handleClearSearch}
|
||||
/>
|
||||
|
||||
{/* 终端视图 - 只渲染当前活动的终端(参考 waveterm) */}
|
||||
<div className="flex-1 min-h-0 relative flex flex-col">
|
||||
{tabs
|
||||
.filter((tab) => tab.id === activeTabId)
|
||||
.map((tab) => (
|
||||
<TerminalView
|
||||
key={tab.id}
|
||||
sessionId={tab.sessionId}
|
||||
onStatusChange={(status) => handleStatusChange(tab.id, status)}
|
||||
visible={true}
|
||||
autoFocus={true}
|
||||
themeName={currentTheme}
|
||||
onTermWrapRef={(termWrap) =>
|
||||
handleTermWrapRef(tab.sessionId, termWrap)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div className="absolute bottom-4 right-4 bg-red-600/90 text-white px-4 py-2 rounded-lg shadow-lg text-sm z-50">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TerminalPage;
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* @file TerminalSearch.tsx
|
||||
* @description 终端搜索组件
|
||||
* @module components/terminal/TerminalSearch
|
||||
*
|
||||
* 提供终端内搜索功能的 UI 组件。
|
||||
* 支持正则表达式、大小写敏感、全词匹配等选项。
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useRef, useEffect } from "react";
|
||||
import type { ISearchOptions } from "@xterm/addon-search";
|
||||
|
||||
/** 搜索组件属性 */
|
||||
export interface TerminalSearchProps {
|
||||
/** 是否显示 */
|
||||
visible: boolean;
|
||||
/** 关闭回调 */
|
||||
onClose: () => void;
|
||||
/** 搜索回调 */
|
||||
onSearch: (term: string, options: ISearchOptions) => boolean;
|
||||
/** 搜索下一个 */
|
||||
onSearchNext: (term: string, options: ISearchOptions) => boolean;
|
||||
/** 搜索上一个 */
|
||||
onSearchPrevious: (term: string, options: ISearchOptions) => boolean;
|
||||
/** 清除搜索 */
|
||||
onClearSearch: () => void;
|
||||
}
|
||||
|
||||
/** 搜索图标 */
|
||||
const SearchIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||
<svg
|
||||
className={className || "w-4 h-4"}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** 关闭图标 */
|
||||
const CloseIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||
<svg
|
||||
className={className || "w-4 h-4"}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** 上箭头图标 */
|
||||
const ChevronUpIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||
<svg
|
||||
className={className || "w-4 h-4"}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="18 15 12 9 6 15" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** 下箭头图标 */
|
||||
const ChevronDownIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||
<svg
|
||||
className={className || "w-4 h-4"}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* 终端搜索组件
|
||||
*/
|
||||
export const TerminalSearch: React.FC<TerminalSearchProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
onSearch,
|
||||
onSearchNext,
|
||||
onSearchPrevious,
|
||||
onClearSearch,
|
||||
}) => {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [caseSensitive, setCaseSensitive] = useState(false);
|
||||
const [wholeWord, setWholeWord] = useState(false);
|
||||
const [regex, setRegex] = useState(false);
|
||||
const [hasResults, setHasResults] = useState<boolean | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 构建搜索选项
|
||||
const getSearchOptions = useCallback(
|
||||
(): ISearchOptions => ({
|
||||
caseSensitive,
|
||||
wholeWord,
|
||||
regex,
|
||||
incremental: true,
|
||||
}),
|
||||
[caseSensitive, wholeWord, regex],
|
||||
);
|
||||
|
||||
// 执行搜索
|
||||
const doSearch = useCallback(() => {
|
||||
if (!searchTerm) {
|
||||
onClearSearch();
|
||||
setHasResults(null);
|
||||
return;
|
||||
}
|
||||
const found = onSearch(searchTerm, getSearchOptions());
|
||||
setHasResults(found);
|
||||
}, [searchTerm, getSearchOptions, onSearch, onClearSearch]);
|
||||
|
||||
// 搜索下一个
|
||||
const handleNext = useCallback(() => {
|
||||
if (!searchTerm) return;
|
||||
const found = onSearchNext(searchTerm, getSearchOptions());
|
||||
setHasResults(found);
|
||||
}, [searchTerm, getSearchOptions, onSearchNext]);
|
||||
|
||||
// 搜索上一个
|
||||
const handlePrevious = useCallback(() => {
|
||||
if (!searchTerm) return;
|
||||
const found = onSearchPrevious(searchTerm, getSearchOptions());
|
||||
setHasResults(found);
|
||||
}, [searchTerm, getSearchOptions, onSearchPrevious]);
|
||||
|
||||
// 关闭搜索
|
||||
const handleClose = useCallback(() => {
|
||||
onClearSearch();
|
||||
setSearchTerm("");
|
||||
setHasResults(null);
|
||||
onClose();
|
||||
}, [onClose, onClearSearch]);
|
||||
|
||||
// 键盘事件处理
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
handleClose();
|
||||
} else if (e.key === "Enter") {
|
||||
if (e.shiftKey) {
|
||||
handlePrevious();
|
||||
} else {
|
||||
handleNext();
|
||||
}
|
||||
}
|
||||
},
|
||||
[handleClose, handleNext, handlePrevious],
|
||||
);
|
||||
|
||||
// 搜索词变化时自动搜索
|
||||
useEffect(() => {
|
||||
doSearch();
|
||||
}, [searchTerm, caseSensitive, wholeWord, regex, doSearch]);
|
||||
|
||||
// 显示时聚焦输入框
|
||||
useEffect(() => {
|
||||
if (visible && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div className="terminal-search-bar">
|
||||
<div className="terminal-search-input-wrapper">
|
||||
<SearchIcon className="terminal-search-icon" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className={`terminal-search-input ${hasResults === false ? "no-results" : ""}`}
|
||||
placeholder="搜索..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
{searchTerm && hasResults === false && (
|
||||
<span className="terminal-search-no-results">无结果</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 搜索选项 */}
|
||||
<div className="terminal-search-options">
|
||||
<button
|
||||
className={`terminal-search-option ${caseSensitive ? "active" : ""}`}
|
||||
onClick={() => setCaseSensitive(!caseSensitive)}
|
||||
title="区分大小写 (Alt+C)"
|
||||
>
|
||||
Aa
|
||||
</button>
|
||||
<button
|
||||
className={`terminal-search-option ${wholeWord ? "active" : ""}`}
|
||||
onClick={() => setWholeWord(!wholeWord)}
|
||||
title="全词匹配 (Alt+W)"
|
||||
>
|
||||
W
|
||||
</button>
|
||||
<button
|
||||
className={`terminal-search-option ${regex ? "active" : ""}`}
|
||||
onClick={() => setRegex(!regex)}
|
||||
title="正则表达式 (Alt+R)"
|
||||
>
|
||||
.*
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 导航按钮 */}
|
||||
<div className="terminal-search-nav">
|
||||
<button
|
||||
className="terminal-search-nav-btn"
|
||||
onClick={handlePrevious}
|
||||
disabled={!searchTerm}
|
||||
title="上一个 (Shift+Enter)"
|
||||
>
|
||||
<ChevronUpIcon />
|
||||
</button>
|
||||
<button
|
||||
className="terminal-search-nav-btn"
|
||||
onClick={handleNext}
|
||||
disabled={!searchTerm}
|
||||
title="下一个 (Enter)"
|
||||
>
|
||||
<ChevronDownIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 关闭按钮 */}
|
||||
<button
|
||||
className="terminal-search-close"
|
||||
onClick={handleClose}
|
||||
title="关闭 (Esc)"
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TerminalSearch;
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* @file fitaddon.ts
|
||||
* @description 自定义 FitAddon - 复制自 waveterm
|
||||
* @module components/terminal/fitaddon
|
||||
*
|
||||
* 修改自 xterm.js 官方 FitAddon,主要改动:
|
||||
* - 在 resize 前清除渲染服务
|
||||
* - 支持禁用滚动条宽度计算(macOS)
|
||||
*/
|
||||
|
||||
import type { ITerminalAddon, Terminal } from "@xterm/xterm";
|
||||
|
||||
interface ITerminalDimensions {
|
||||
rows: number;
|
||||
cols: number;
|
||||
}
|
||||
|
||||
const MINIMUM_COLS = 2;
|
||||
const MINIMUM_ROWS = 1;
|
||||
|
||||
export class FitAddon implements ITerminalAddon {
|
||||
private _terminal: Terminal | undefined;
|
||||
/** 是否禁用滚动条宽度计算(macOS 上设为 true) */
|
||||
public noScrollbar: boolean = false;
|
||||
|
||||
public activate(terminal: Terminal): void {
|
||||
this._terminal = terminal;
|
||||
}
|
||||
|
||||
public dispose(): void {}
|
||||
|
||||
public fit(): void {
|
||||
const dims = this.proposeDimensions();
|
||||
if (!dims || !this._terminal || isNaN(dims.cols) || isNaN(dims.rows)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 访问 xterm 内部 API
|
||||
const core = (this._terminal as any)._core;
|
||||
|
||||
// 安全检查:确保内部 API 可用
|
||||
if (!core || !core._renderService) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 关键:如果大小变化,先清除渲染再 resize
|
||||
if (
|
||||
this._terminal.rows !== dims.rows ||
|
||||
this._terminal.cols !== dims.cols
|
||||
) {
|
||||
core._renderService.clear();
|
||||
this._terminal.resize(dims.cols, dims.rows);
|
||||
}
|
||||
}
|
||||
|
||||
public proposeDimensions(): ITerminalDimensions | undefined {
|
||||
if (!this._terminal) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!this._terminal.element || !this._terminal.element.parentElement) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const core = (this._terminal as any)._core;
|
||||
|
||||
if (!core || !core._renderService) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const dims = core._renderService.dimensions;
|
||||
|
||||
if (
|
||||
!dims ||
|
||||
!dims.css ||
|
||||
!dims.css.cell ||
|
||||
dims.css.cell.width === 0 ||
|
||||
dims.css.cell.height === 0
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// 计算滚动条宽度
|
||||
let scrollbarWidth = 0;
|
||||
if (
|
||||
core.viewport &&
|
||||
core.viewport._viewportElement &&
|
||||
core.viewport._scrollArea
|
||||
) {
|
||||
const measuredScrollBarWidth =
|
||||
core.viewport._viewportElement.offsetWidth -
|
||||
core.viewport._scrollArea.offsetWidth;
|
||||
scrollbarWidth =
|
||||
this._terminal.options.scrollback === 0 ? 0 : measuredScrollBarWidth;
|
||||
}
|
||||
if (this.noScrollbar) {
|
||||
scrollbarWidth = 0;
|
||||
}
|
||||
|
||||
// Use getBoundingClientRect for more accurate measurement including fractional pixels
|
||||
const parentRect =
|
||||
this._terminal.element.parentElement.getBoundingClientRect();
|
||||
const parentElementHeight = parentRect.height;
|
||||
const parentElementWidth = parentRect.width;
|
||||
|
||||
console.log("[FitAddon Debug] Measuring parent (Rect):", {
|
||||
width: parentElementWidth,
|
||||
height: parentElementHeight,
|
||||
top: parentRect.top,
|
||||
bottom: parentRect.bottom,
|
||||
});
|
||||
|
||||
const elementStyle = window.getComputedStyle(this._terminal.element);
|
||||
const elementPadding = {
|
||||
top: parseInt(elementStyle.getPropertyValue("padding-top")) || 0,
|
||||
bottom: parseInt(elementStyle.getPropertyValue("padding-bottom")) || 0,
|
||||
right: parseInt(elementStyle.getPropertyValue("padding-right")) || 0,
|
||||
left: parseInt(elementStyle.getPropertyValue("padding-left")) || 0,
|
||||
};
|
||||
|
||||
const elementPaddingVer = elementPadding.top + elementPadding.bottom;
|
||||
const elementPaddingHor = elementPadding.right + elementPadding.left;
|
||||
|
||||
const availableHeight = parentElementHeight - elementPaddingVer;
|
||||
const availableWidth =
|
||||
parentElementWidth - elementPaddingHor - scrollbarWidth;
|
||||
|
||||
const geometry = {
|
||||
cols: Math.max(
|
||||
MINIMUM_COLS,
|
||||
Math.floor(availableWidth / dims.css.cell.width),
|
||||
),
|
||||
rows: Math.max(
|
||||
MINIMUM_ROWS,
|
||||
Math.floor(availableHeight / dims.css.cell.height),
|
||||
),
|
||||
};
|
||||
|
||||
console.log("[FitAddon] proposeDimensions:", {
|
||||
parentElementHeight,
|
||||
parentElementWidth,
|
||||
availableHeight,
|
||||
availableWidth,
|
||||
cellHeight: dims.css.cell.height,
|
||||
cellWidth: dims.css.cell.width,
|
||||
geometry,
|
||||
});
|
||||
|
||||
return geometry;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* @file index.ts
|
||||
* @description 终端组件导出
|
||||
* @module components/terminal
|
||||
*/
|
||||
|
||||
export { TerminalPage } from "./TerminalPage";
|
||||
@@ -0,0 +1,528 @@
|
||||
/**
|
||||
* @file terminal.css
|
||||
* @description 内置终端组件样式
|
||||
* @module components/terminal
|
||||
*
|
||||
* 参考 waveterm 设计风格
|
||||
*/
|
||||
|
||||
/* CSS 变量 */
|
||||
:root {
|
||||
--terminal-bg: #1a1b26;
|
||||
--terminal-fg: #c0caf5;
|
||||
--terminal-border: #3b4261;
|
||||
--terminal-tab-bg: #24283b;
|
||||
--terminal-tab-active-bg: #1a1b26;
|
||||
--terminal-tab-hover-bg: #414868;
|
||||
--terminal-accent: #7aa2f7;
|
||||
--terminal-success: #9ece6a;
|
||||
--terminal-warning: #e0af68;
|
||||
--terminal-error: #f7768e;
|
||||
--terminal-scrollbar-bg: transparent;
|
||||
--terminal-scrollbar-thumb: #565f89;
|
||||
--terminal-scrollbar-thumb-hover: #7aa2f7;
|
||||
--terminal-muted: #565f89;
|
||||
}
|
||||
|
||||
/* 终端背景 */
|
||||
.terminal-bg {
|
||||
background-color: var(--terminal-bg);
|
||||
}
|
||||
|
||||
/* 终端容器样式 - 完全对齐 waveterm 的 .term-connectelem */
|
||||
.terminal-container {
|
||||
flex-grow: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
line-height: 1;
|
||||
margin: 5px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
/* 隐藏的终端 - 使用 visibility 而不是 display:none,保持布局稳定 */
|
||||
.terminal-container.terminal-hidden {
|
||||
visibility: hidden;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.terminal-container .xterm-viewport {
|
||||
overflow-y: auto;
|
||||
background-color: var(--terminal-bg) !important;
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
.terminal-container .xterm-viewport::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.terminal-container .xterm-viewport::-webkit-scrollbar-track {
|
||||
background-color: var(--terminal-scrollbar-bg);
|
||||
}
|
||||
|
||||
.terminal-container .xterm-viewport::-webkit-scrollbar-thumb {
|
||||
display: none;
|
||||
background-color: var(--terminal-scrollbar-thumb);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.terminal-container .xterm-viewport::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--terminal-scrollbar-thumb-hover);
|
||||
}
|
||||
|
||||
.terminal-container:hover .xterm-viewport::-webkit-scrollbar-thumb {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 标签栏样式 */
|
||||
.terminal-tabbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: var(--terminal-tab-bg);
|
||||
border-bottom: 1px solid var(--terminal-border);
|
||||
height: 40px;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.terminal-tabbar button {
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* 标签页样式 */
|
||||
.terminal-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 14px;
|
||||
height: 100%;
|
||||
min-width: 140px;
|
||||
max-width: 220px;
|
||||
border-right: 1px solid var(--terminal-border);
|
||||
color: var(--terminal-muted);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.terminal-tab:hover {
|
||||
background-color: var(--terminal-tab-hover-bg);
|
||||
color: var(--terminal-fg);
|
||||
}
|
||||
|
||||
.terminal-tab.active {
|
||||
background-color: var(--terminal-tab-active-bg);
|
||||
color: var(--terminal-fg);
|
||||
border-bottom: 2px solid var(--terminal-accent);
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
/* 状态指示器 */
|
||||
.terminal-status-indicator {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.terminal-status-indicator.running {
|
||||
background-color: var(--terminal-success);
|
||||
}
|
||||
|
||||
.terminal-status-indicator.connecting {
|
||||
background-color: var(--terminal-warning);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.terminal-status-indicator.error {
|
||||
background-color: var(--terminal-error);
|
||||
}
|
||||
|
||||
.terminal-status-indicator.done {
|
||||
background-color: #6e7681;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
/* 新建标签按钮 */
|
||||
.terminal-new-tab-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 100%;
|
||||
color: var(--terminal-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.terminal-new-tab-btn:hover {
|
||||
background-color: var(--terminal-tab-hover-bg);
|
||||
color: var(--terminal-accent);
|
||||
}
|
||||
|
||||
/* 关闭按钮 */
|
||||
.terminal-close-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-left: auto;
|
||||
border-radius: 4px;
|
||||
color: #6e7681;
|
||||
opacity: 0;
|
||||
transition: all 0.15s ease;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.terminal-tab:hover .terminal-close-btn,
|
||||
.terminal-tab.active .terminal-close-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.terminal-close-btn:hover {
|
||||
background-color: rgba(248, 81, 73, 0.2);
|
||||
color: var(--terminal-error);
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.terminal-empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
background-color: var(--terminal-bg);
|
||||
color: var(--terminal-fg);
|
||||
}
|
||||
|
||||
.terminal-empty-state-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin-bottom: 20px;
|
||||
color: var(--terminal-accent);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.terminal-empty-state-text {
|
||||
font-size: 18px;
|
||||
margin-bottom: 20px;
|
||||
color: var(--terminal-muted);
|
||||
}
|
||||
|
||||
.terminal-empty-state-btn {
|
||||
padding: 10px 24px;
|
||||
background-color: var(--terminal-accent);
|
||||
color: #1a1b26;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.terminal-empty-state-btn:hover {
|
||||
background-color: #89b4fa;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* 对话框样式 */
|
||||
.terminal-dialog-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.terminal-dialog {
|
||||
background-color: #161b22;
|
||||
border: 1px solid var(--terminal-border);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
width: 420px;
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.terminal-dialog-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--terminal-fg);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* 按钮 */
|
||||
.terminal-btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.terminal-btn-primary {
|
||||
background-color: var(--terminal-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.terminal-btn-primary:hover {
|
||||
background-color: #4c9aed;
|
||||
}
|
||||
|
||||
.terminal-btn-secondary {
|
||||
background-color: transparent;
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
.terminal-btn-secondary:hover {
|
||||
color: var(--terminal-fg);
|
||||
}
|
||||
|
||||
/* 对话框底部按钮 */
|
||||
.terminal-dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* 搜索栏样式
|
||||
* ============================================================================ */
|
||||
|
||||
.terminal-search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background-color: var(--terminal-tab-bg);
|
||||
border-bottom: 1px solid var(--terminal-border);
|
||||
position: absolute;
|
||||
top: 40px;
|
||||
/* 标签栏高度 */
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
border-radius: 0 0 0 8px;
|
||||
box-shadow: -2px 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.terminal-search-input-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: var(--terminal-bg);
|
||||
border: 1px solid var(--terminal-border);
|
||||
border-radius: 6px;
|
||||
padding: 0 8px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.terminal-search-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: var(--terminal-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.terminal-search-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--terminal-fg);
|
||||
font-size: 13px;
|
||||
padding: 6px 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.terminal-search-input::placeholder {
|
||||
color: var(--terminal-muted);
|
||||
}
|
||||
|
||||
.terminal-search-input.no-results {
|
||||
color: var(--terminal-error);
|
||||
}
|
||||
|
||||
.terminal-search-no-results {
|
||||
font-size: 11px;
|
||||
color: var(--terminal-error);
|
||||
white-space: nowrap;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
/* 搜索选项按钮 */
|
||||
.terminal-search-options {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.terminal-search-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--terminal-muted);
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.terminal-search-option:hover {
|
||||
background-color: var(--terminal-tab-hover-bg);
|
||||
color: var(--terminal-fg);
|
||||
}
|
||||
|
||||
.terminal-search-option.active {
|
||||
background-color: var(--terminal-accent);
|
||||
color: var(--terminal-bg);
|
||||
border-color: var(--terminal-accent);
|
||||
}
|
||||
|
||||
/* 导航按钮 */
|
||||
.terminal-search-nav {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.terminal-search-nav-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
color: var(--terminal-muted);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.terminal-search-nav-btn:hover:not(:disabled) {
|
||||
background-color: var(--terminal-tab-hover-bg);
|
||||
color: var(--terminal-fg);
|
||||
}
|
||||
|
||||
.terminal-search-nav-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 关闭按钮 */
|
||||
.terminal-search-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
color: var(--terminal-muted);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.terminal-search-close:hover {
|
||||
background-color: rgba(248, 81, 73, 0.2);
|
||||
color: var(--terminal-error);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* 主题选择器样式
|
||||
* ============================================================================ */
|
||||
|
||||
.terminal-theme-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.terminal-theme-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--terminal-muted);
|
||||
background: transparent;
|
||||
border: 1px solid var(--terminal-border);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.terminal-theme-btn:hover {
|
||||
background-color: var(--terminal-tab-hover-bg);
|
||||
color: var(--terminal-fg);
|
||||
}
|
||||
|
||||
.terminal-theme-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
background-color: var(--terminal-tab-bg);
|
||||
border: 1px solid var(--terminal-border);
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
min-width: 160px;
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3);
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.terminal-theme-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--terminal-fg);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.terminal-theme-item:hover {
|
||||
background-color: var(--terminal-tab-hover-bg);
|
||||
}
|
||||
|
||||
.terminal-theme-item.active {
|
||||
background-color: var(--terminal-accent);
|
||||
color: var(--terminal-bg);
|
||||
}
|
||||
|
||||
.terminal-theme-preview {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--terminal-border);
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
/**
|
||||
* @file termwrap.ts
|
||||
* @description 终端封装类 - 连接模式
|
||||
* @module components/terminal/termwrap
|
||||
*
|
||||
* 封装 xterm.js 终端实例,管理终端生命周期、大小同步、输入输出。
|
||||
*
|
||||
* ## 架构说明
|
||||
* PTY 在后端预创建,TermWrap 只负责"连接"到已存在的会话。
|
||||
* 构造函数接收 sessionId(必须),立即设置事件监听和输入处理。
|
||||
* resize 只负责同步大小到后端,不触发创建。
|
||||
*
|
||||
* ## 功能特性
|
||||
* - 搜索功能:集成 @xterm/addon-search
|
||||
* - 主题系统:支持多种终端主题切换
|
||||
* - Web 链接:自动识别并可点击 URL
|
||||
*/
|
||||
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import { SearchAddon, type ISearchOptions } from "@xterm/addon-search";
|
||||
import { FitAddon } from "./fitaddon";
|
||||
import {
|
||||
resizeTerminal,
|
||||
writeToTerminalRaw,
|
||||
onSessionOutput,
|
||||
onSessionStatus,
|
||||
decodeBytes,
|
||||
encodeBase64,
|
||||
type SessionStatus,
|
||||
} from "@/lib/terminal-api";
|
||||
import {
|
||||
type ThemeName,
|
||||
getTheme,
|
||||
loadThemePreference,
|
||||
} from "@/lib/terminal/themes";
|
||||
|
||||
/** 简单的 debounce 实现 */
|
||||
function debounce<T extends (...args: unknown[]) => void>(
|
||||
fn: T,
|
||||
delay: number,
|
||||
): T {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
return ((...args: unknown[]) => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => fn(...args), delay);
|
||||
}) as T;
|
||||
}
|
||||
|
||||
/** 终端配置选项 */
|
||||
export interface TermWrapOptions {
|
||||
/** 终端字体大小 */
|
||||
fontSize?: number;
|
||||
/** 终端字体 */
|
||||
fontFamily?: string;
|
||||
/** 主题名称 */
|
||||
themeName?: ThemeName;
|
||||
/** 状态变化回调 */
|
||||
onStatusChange?: (status: SessionStatus) => void;
|
||||
}
|
||||
|
||||
/** 搜索结果回调 */
|
||||
export interface SearchCallbacks {
|
||||
/** 搜索结果变化 */
|
||||
onSearchResults?: (results: {
|
||||
resultIndex: number;
|
||||
resultCount: number;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 终端封装类 - 连接模式
|
||||
*
|
||||
* PTY 在后端预创建,TermWrap 只负责"连接"到已存在的会话。
|
||||
*
|
||||
* 使用方式:
|
||||
* 1. 先调用 createTerminalSession() 获取 sessionId
|
||||
* 2. 创建 TermWrap 实例,传入 sessionId
|
||||
* 3. TermWrap 自动连接并设置事件监听
|
||||
* 4. 首次 fit 后自动同步实际大小到后端
|
||||
*/
|
||||
export class TermWrap {
|
||||
/** 会话 ID(必须) */
|
||||
readonly sessionId: string;
|
||||
/** xterm 终端实例 */
|
||||
terminal: Terminal;
|
||||
/** 连接的 DOM 元素 */
|
||||
connectElem: HTMLDivElement;
|
||||
/** FitAddon 实例 */
|
||||
fitAddon: FitAddon;
|
||||
/** SearchAddon 实例 */
|
||||
searchAddon: SearchAddon;
|
||||
/** 是否已连接(事件监听已设置) */
|
||||
connected: boolean = false;
|
||||
/** 防抖的 resize 处理函数 */
|
||||
handleResize_debounced: () => void;
|
||||
/** 配置选项 */
|
||||
private options: TermWrapOptions;
|
||||
/** 当前主题名称 */
|
||||
private currentTheme: ThemeName;
|
||||
/** 搜索回调 */
|
||||
private searchCallbacks: SearchCallbacks = {};
|
||||
/** 需要清理的资源 */
|
||||
private toDispose: Array<{ dispose: () => void }> = [];
|
||||
/** 事件监听器清理函数 */
|
||||
private unlistenOutput?: () => void;
|
||||
private unlistenStatus?: () => void;
|
||||
/** 上次同步到后端的大小 */
|
||||
private lastSyncedSize: { rows: number; cols: number } | null = null;
|
||||
|
||||
/**
|
||||
* 创建终端封装实例
|
||||
*
|
||||
* @param sessionId - 已创建的会话 ID(必须)
|
||||
* @param connectElem - 要挂载终端的 DOM 元素
|
||||
* @param options - 配置选项
|
||||
*/
|
||||
constructor(
|
||||
sessionId: string,
|
||||
connectElem: HTMLDivElement,
|
||||
options: TermWrapOptions = {},
|
||||
) {
|
||||
this.sessionId = sessionId;
|
||||
this.connectElem = connectElem;
|
||||
this.options = options;
|
||||
|
||||
// 加载主题
|
||||
this.currentTheme = options.themeName ?? loadThemePreference();
|
||||
const theme = getTheme(this.currentTheme);
|
||||
|
||||
// 创建终端实例
|
||||
this.terminal = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: options.fontSize ?? 14,
|
||||
fontFamily:
|
||||
options.fontFamily ?? 'Hack, Menlo, Monaco, "Courier New", monospace',
|
||||
theme,
|
||||
allowProposedApi: true,
|
||||
allowTransparency: true,
|
||||
scrollback: 5000,
|
||||
drawBoldTextInBrightColors: false,
|
||||
fontWeight: "normal",
|
||||
fontWeightBold: "bold",
|
||||
});
|
||||
|
||||
// 加载插件
|
||||
this.fitAddon = new FitAddon();
|
||||
// macOS 上禁用滚动条宽度计算(参考 waveterm)
|
||||
const isMac = /mac/i.test(navigator.userAgent);
|
||||
this.fitAddon.noScrollbar = isMac;
|
||||
|
||||
this.searchAddon = new SearchAddon();
|
||||
const webLinksAddon = new WebLinksAddon();
|
||||
|
||||
this.terminal.loadAddon(this.fitAddon);
|
||||
this.terminal.loadAddon(this.searchAddon);
|
||||
this.terminal.loadAddon(webLinksAddon);
|
||||
|
||||
// 打开终端
|
||||
this.terminal.open(this.connectElem);
|
||||
|
||||
// 设置防抖的 resize 处理(参考 waveterm 使用 50ms,增加到 100ms 以避免 TUI 初始化时的竞态)
|
||||
this.handleResize_debounced = debounce(this.handleResize.bind(this), 100);
|
||||
|
||||
// 立即连接到会话
|
||||
this.connect();
|
||||
|
||||
// 参考 waveterm:构造函数中调用 handleResize
|
||||
// 但需要等待 xterm 完全初始化(viewport 需要时间创建)
|
||||
this.handleResize();
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接到会话
|
||||
*
|
||||
* 设置输入处理和事件监听。
|
||||
*/
|
||||
private async connect(): Promise<void> {
|
||||
if (this.connected) return;
|
||||
|
||||
console.log(`[TermWrap] 连接到会话: ${this.sessionId}`);
|
||||
|
||||
try {
|
||||
// 设置输入处理
|
||||
const onDataDisposable = this.terminal.onData((data) => {
|
||||
const base64 = encodeBase64(data);
|
||||
writeToTerminalRaw(this.sessionId, base64).catch(console.error);
|
||||
});
|
||||
this.toDispose.push(onDataDisposable);
|
||||
|
||||
// 监听输出
|
||||
this.unlistenOutput = await onSessionOutput(this.sessionId, (data) => {
|
||||
this.terminal.write(decodeBytes(data));
|
||||
});
|
||||
|
||||
// 监听状态
|
||||
this.unlistenStatus = await onSessionStatus(this.sessionId, (event) => {
|
||||
this.options.onStatusChange?.(event.status);
|
||||
});
|
||||
|
||||
this.connected = true;
|
||||
console.log(`[TermWrap] 已连接到会话: ${this.sessionId}`);
|
||||
} catch (err) {
|
||||
console.error("[TermWrap] 连接失败:", err);
|
||||
this.options.onStatusChange?.("error");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理终端大小变化
|
||||
*
|
||||
* 调用 fitAddon.fit() 计算新大小,然后同步到后端。
|
||||
*/
|
||||
handleResize(): void {
|
||||
// 安全检查:确保终端已初始化
|
||||
if (!this.terminal || !this.terminal.element) {
|
||||
console.log("[TermWrap] handleResize: 终端未初始化,跳过");
|
||||
return;
|
||||
}
|
||||
|
||||
const oldRows = this.terminal.rows;
|
||||
const oldCols = this.terminal.cols;
|
||||
|
||||
// 调用 fit 计算新大小
|
||||
try {
|
||||
this.fitAddon.fit();
|
||||
} catch (err) {
|
||||
console.warn("[TermWrap] fit() 失败:", err);
|
||||
return;
|
||||
}
|
||||
|
||||
const { rows, cols } = this.terminal;
|
||||
|
||||
console.log(
|
||||
`[TermWrap] handleResize: ${cols}x${rows} (was ${oldCols}x${oldRows})`,
|
||||
);
|
||||
|
||||
// 同步大小到后端
|
||||
this.syncSizeToBackend(rows, cols);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步大小到后端
|
||||
*/
|
||||
private syncSizeToBackend(rows: number, cols: number): void {
|
||||
// 检查是否与上次同步的大小相同
|
||||
if (
|
||||
this.lastSyncedSize &&
|
||||
this.lastSyncedSize.rows === rows &&
|
||||
this.lastSyncedSize.cols === cols
|
||||
) {
|
||||
return; // 大小没变,不需要同步
|
||||
}
|
||||
|
||||
console.log(`[TermWrap] 同步 resize 到后端: ${cols}x${rows}`);
|
||||
this.lastSyncedSize = { rows, cols };
|
||||
resizeTerminal(this.sessionId, rows, cols).catch(console.error);
|
||||
}
|
||||
|
||||
/**
|
||||
* 聚焦终端
|
||||
*/
|
||||
focus(): void {
|
||||
this.terminal.focus();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 搜索功能
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 设置搜索回调
|
||||
*/
|
||||
setSearchCallbacks(callbacks: SearchCallbacks): void {
|
||||
this.searchCallbacks = callbacks;
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索文本
|
||||
* @param term - 搜索词
|
||||
* @param options - 搜索选项
|
||||
* @returns 是否找到匹配
|
||||
*/
|
||||
search(term: string, options?: ISearchOptions): boolean {
|
||||
if (!term) {
|
||||
this.clearSearch();
|
||||
return false;
|
||||
}
|
||||
const found = this.searchAddon.findNext(term, options);
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索下一个
|
||||
*/
|
||||
searchNext(term: string, options?: ISearchOptions): boolean {
|
||||
if (!term) return false;
|
||||
return this.searchAddon.findNext(term, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索上一个
|
||||
*/
|
||||
searchPrevious(term: string, options?: ISearchOptions): boolean {
|
||||
if (!term) return false;
|
||||
return this.searchAddon.findPrevious(term, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除搜索高亮
|
||||
*/
|
||||
clearSearch(): void {
|
||||
this.searchAddon.clearDecorations();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 主题功能
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 设置主题
|
||||
*/
|
||||
setTheme(themeName: ThemeName): void {
|
||||
this.currentTheme = themeName;
|
||||
const theme = getTheme(themeName);
|
||||
this.terminal.options.theme = theme;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前主题名称
|
||||
*/
|
||||
getThemeName(): ThemeName {
|
||||
return this.currentTheme;
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁终端
|
||||
*/
|
||||
dispose(): void {
|
||||
console.log(`[TermWrap] 销毁终端: ${this.sessionId}`);
|
||||
|
||||
// 清理事件监听
|
||||
this.unlistenOutput?.();
|
||||
this.unlistenStatus?.();
|
||||
|
||||
// 清理其他资源
|
||||
this.toDispose.forEach((d) => {
|
||||
try {
|
||||
d.dispose();
|
||||
} catch {
|
||||
// ignore dispose errors
|
||||
}
|
||||
});
|
||||
|
||||
// 销毁终端
|
||||
this.terminal.dispose();
|
||||
|
||||
this.connected = false;
|
||||
}
|
||||
}
|
||||
@@ -45,11 +45,15 @@
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
html {
|
||||
overflow: hidden;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
|
||||
Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Custom scrollbar styles */
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
- `syntaxHighlight.ts` - 语法高亮工具
|
||||
- `flowEventManager.ts` - 流量事件管理器
|
||||
- `notificationService.ts` - 通知服务
|
||||
- `terminal-api.ts` - 终端核心能力 API 封装(Terminal Core)
|
||||
- `utils.ts` - 通用工具函数
|
||||
|
||||
## 更新提醒
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* @file blockStore.ts
|
||||
* @description 块状态管理
|
||||
* @module lib/blocks/blockStore
|
||||
*
|
||||
* 使用 Jotai 进行原子化状态管理。
|
||||
*/
|
||||
|
||||
import { atom } from "jotai";
|
||||
import { atomWithStorage } from "jotai/utils";
|
||||
import type {
|
||||
Block,
|
||||
BlockConfig,
|
||||
Tab,
|
||||
Workspace,
|
||||
PanelNode,
|
||||
BlockType,
|
||||
} from "./types";
|
||||
|
||||
// ============================================================================
|
||||
// 工具函数
|
||||
// ============================================================================
|
||||
|
||||
/** 生成唯一 ID */
|
||||
export function generateId(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
/** 创建块实例 */
|
||||
export function createBlock(config: BlockConfig): Block {
|
||||
const now = Date.now();
|
||||
return {
|
||||
id: generateId(),
|
||||
type: config.type,
|
||||
status: "idle",
|
||||
meta: config.meta ?? {},
|
||||
config,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
/** 创建面板节点 */
|
||||
export function createPanelNode(blockId: string): PanelNode {
|
||||
return {
|
||||
id: generateId(),
|
||||
type: "block",
|
||||
blockId,
|
||||
size: 100,
|
||||
};
|
||||
}
|
||||
|
||||
/** 创建标签页 */
|
||||
export function createTab(title: string, rootBlockConfig?: BlockConfig): Tab {
|
||||
const block = rootBlockConfig ? createBlock(rootBlockConfig) : null;
|
||||
return {
|
||||
id: generateId(),
|
||||
title,
|
||||
rootNode: block
|
||||
? createPanelNode(block.id)
|
||||
: {
|
||||
id: generateId(),
|
||||
type: "group",
|
||||
children: [],
|
||||
direction: "horizontal",
|
||||
},
|
||||
activeBlockId: block?.id,
|
||||
};
|
||||
}
|
||||
|
||||
/** 创建工作区 */
|
||||
export function createWorkspace(name: string): Workspace {
|
||||
const now = Date.now();
|
||||
return {
|
||||
id: generateId(),
|
||||
name,
|
||||
tabs: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 原子状态
|
||||
// ============================================================================
|
||||
|
||||
/** 所有块的映射 */
|
||||
export const blocksAtom = atom<Map<string, Block>>(new Map());
|
||||
|
||||
/** 所有标签页的映射 */
|
||||
export const tabsAtom = atom<Map<string, Tab>>(new Map());
|
||||
|
||||
/** 当前活动标签页 ID */
|
||||
export const activeTabIdAtom = atom<string | null>(null);
|
||||
|
||||
/** 当前活动块 ID */
|
||||
export const activeBlockIdAtom = atom<string | null>(null);
|
||||
|
||||
/** 最大化的块 ID */
|
||||
export const magnifiedBlockIdAtom = atom<string | null>(null);
|
||||
|
||||
/** 当前主题 */
|
||||
export const themeAtom = atomWithStorage<string>(
|
||||
"terminal-theme",
|
||||
"tokyo-night",
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// 派生原子
|
||||
// ============================================================================
|
||||
|
||||
/** 获取当前活动标签页 */
|
||||
export const activeTabAtom = atom((get) => {
|
||||
const activeTabId = get(activeTabIdAtom);
|
||||
if (!activeTabId) return null;
|
||||
return get(tabsAtom).get(activeTabId) ?? null;
|
||||
});
|
||||
|
||||
/** 获取当前活动块 */
|
||||
export const activeBlockAtom = atom((get) => {
|
||||
const activeBlockId = get(activeBlockIdAtom);
|
||||
if (!activeBlockId) return null;
|
||||
return get(blocksAtom).get(activeBlockId) ?? null;
|
||||
});
|
||||
|
||||
/** 获取所有标签页列表 */
|
||||
export const tabListAtom = atom((get) => {
|
||||
return Array.from(get(tabsAtom).values());
|
||||
});
|
||||
|
||||
/** 获取所有块列表 */
|
||||
export const blockListAtom = atom((get) => {
|
||||
return Array.from(get(blocksAtom).values());
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// 操作原子
|
||||
// ============================================================================
|
||||
|
||||
/** 添加块 */
|
||||
export const addBlockAtom = atom(null, (get, set, config: BlockConfig) => {
|
||||
const block = createBlock(config);
|
||||
const blocks = new Map(get(blocksAtom));
|
||||
blocks.set(block.id, block);
|
||||
set(blocksAtom, blocks);
|
||||
return block;
|
||||
});
|
||||
|
||||
/** 更新块 */
|
||||
export const updateBlockAtom = atom(
|
||||
null,
|
||||
(get, set, { id, updates }: { id: string; updates: Partial<Block> }) => {
|
||||
const blocks = new Map(get(blocksAtom));
|
||||
const block = blocks.get(id);
|
||||
if (block) {
|
||||
blocks.set(id, { ...block, ...updates, updatedAt: Date.now() });
|
||||
set(blocksAtom, blocks);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/** 删除块 */
|
||||
export const removeBlockAtom = atom(null, (get, set, id: string) => {
|
||||
const blocks = new Map(get(blocksAtom));
|
||||
blocks.delete(id);
|
||||
set(blocksAtom, blocks);
|
||||
|
||||
// 如果删除的是活动块,清除活动状态
|
||||
if (get(activeBlockIdAtom) === id) {
|
||||
set(activeBlockIdAtom, null);
|
||||
}
|
||||
if (get(magnifiedBlockIdAtom) === id) {
|
||||
set(magnifiedBlockIdAtom, null);
|
||||
}
|
||||
});
|
||||
|
||||
/** 添加标签页 */
|
||||
export const addTabAtom = atom(
|
||||
null,
|
||||
(
|
||||
get,
|
||||
set,
|
||||
{ title, blockConfig }: { title: string; blockConfig?: BlockConfig },
|
||||
) => {
|
||||
// 如果有块配置,先创建块
|
||||
let block: Block | null = null;
|
||||
if (blockConfig) {
|
||||
const blocks = new Map(get(blocksAtom));
|
||||
block = createBlock(blockConfig);
|
||||
blocks.set(block.id, block);
|
||||
set(blocksAtom, blocks);
|
||||
}
|
||||
|
||||
// 创建标签页
|
||||
const tab: Tab = {
|
||||
id: generateId(),
|
||||
title,
|
||||
rootNode: block
|
||||
? createPanelNode(block.id)
|
||||
: {
|
||||
id: generateId(),
|
||||
type: "group",
|
||||
children: [],
|
||||
direction: "horizontal",
|
||||
},
|
||||
activeBlockId: block?.id,
|
||||
};
|
||||
|
||||
const tabs = new Map(get(tabsAtom));
|
||||
tabs.set(tab.id, tab);
|
||||
set(tabsAtom, tabs);
|
||||
set(activeTabIdAtom, tab.id);
|
||||
|
||||
if (block) {
|
||||
set(activeBlockIdAtom, block.id);
|
||||
}
|
||||
|
||||
return tab;
|
||||
},
|
||||
);
|
||||
|
||||
/** 删除标签页 */
|
||||
export const removeTabAtom = atom(null, (get, set, tabId: string) => {
|
||||
const tabs = new Map(get(tabsAtom));
|
||||
const tab = tabs.get(tabId);
|
||||
|
||||
if (tab) {
|
||||
// 删除标签页中的所有块
|
||||
const blocks = new Map(get(blocksAtom));
|
||||
const blockIds = collectBlockIds(tab.rootNode);
|
||||
blockIds.forEach((id) => blocks.delete(id));
|
||||
set(blocksAtom, blocks);
|
||||
|
||||
// 删除标签页
|
||||
tabs.delete(tabId);
|
||||
set(tabsAtom, tabs);
|
||||
|
||||
// 如果删除的是活动标签页,切换到其他标签页
|
||||
if (get(activeTabIdAtom) === tabId) {
|
||||
const remainingTabs = Array.from(tabs.values());
|
||||
set(
|
||||
activeTabIdAtom,
|
||||
remainingTabs.length > 0
|
||||
? remainingTabs[remainingTabs.length - 1].id
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/** 设置活动标签页 */
|
||||
export const setActiveTabAtom = atom(null, (get, set, tabId: string) => {
|
||||
set(activeTabIdAtom, tabId);
|
||||
const tab = get(tabsAtom).get(tabId);
|
||||
if (tab?.activeBlockId) {
|
||||
set(activeBlockIdAtom, tab.activeBlockId);
|
||||
}
|
||||
});
|
||||
|
||||
/** 设置活动块 */
|
||||
export const setActiveBlockAtom = atom(null, (get, set, blockId: string) => {
|
||||
set(activeBlockIdAtom, blockId);
|
||||
|
||||
// 更新标签页的活动块
|
||||
const activeTabId = get(activeTabIdAtom);
|
||||
if (activeTabId) {
|
||||
const tabs = new Map(get(tabsAtom));
|
||||
const tab = tabs.get(activeTabId);
|
||||
if (tab) {
|
||||
tabs.set(activeTabId, { ...tab, activeBlockId: blockId });
|
||||
set(tabsAtom, tabs);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/** 切换块最大化 */
|
||||
export const toggleMagnifyAtom = atom(null, (get, set, blockId: string) => {
|
||||
const currentMagnified = get(magnifiedBlockIdAtom);
|
||||
set(magnifiedBlockIdAtom, currentMagnified === blockId ? null : blockId);
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// 辅助函数
|
||||
// ============================================================================
|
||||
|
||||
/** 收集面板节点中的所有块 ID */
|
||||
function collectBlockIds(node: PanelNode): string[] {
|
||||
if (node.type === "block" && node.blockId) {
|
||||
return [node.blockId];
|
||||
}
|
||||
if (node.type === "group" && node.children) {
|
||||
return node.children.flatMap(collectBlockIds);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** 在面板节点中查找块 */
|
||||
export function findBlockInPanel(
|
||||
node: PanelNode,
|
||||
blockId: string,
|
||||
): PanelNode | null {
|
||||
if (node.type === "block" && node.blockId === blockId) {
|
||||
return node;
|
||||
}
|
||||
if (node.type === "group" && node.children) {
|
||||
for (const child of node.children) {
|
||||
const found = findBlockInPanel(child, blockId);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 获取块类型的图标 */
|
||||
export function getBlockIcon(type: BlockType): string {
|
||||
const icons: Record<BlockType, string> = {
|
||||
terminal: "terminal",
|
||||
preview: "file",
|
||||
web: "globe",
|
||||
ai: "sparkles",
|
||||
markdown: "file-text",
|
||||
image: "image",
|
||||
};
|
||||
return icons[type] ?? "square";
|
||||
}
|
||||
|
||||
/** 获取块类型的显示名称 */
|
||||
export function getBlockDisplayName(type: BlockType): string {
|
||||
const names: Record<BlockType, string> = {
|
||||
terminal: "终端",
|
||||
preview: "预览",
|
||||
web: "Web",
|
||||
ai: "AI",
|
||||
markdown: "Markdown",
|
||||
image: "图片",
|
||||
};
|
||||
return names[type] ?? type;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* @file index.ts
|
||||
* @description 块系统入口
|
||||
* @module lib/blocks
|
||||
*
|
||||
* 导出块系统的所有类型和函数,并注册内置块类型。
|
||||
*/
|
||||
|
||||
// 导出类型
|
||||
export * from "./types";
|
||||
|
||||
// 导出注册表
|
||||
export * from "./registry";
|
||||
|
||||
// 导出状态管理
|
||||
export * from "./blockStore";
|
||||
|
||||
// 注册内置块类型
|
||||
import { registerBlockType } from "./registry";
|
||||
import { TerminalBlock } from "@/components/blocks/TerminalBlock";
|
||||
import { PreviewBlock } from "@/components/blocks/PreviewBlock";
|
||||
import { WebBlock } from "@/components/blocks/WebBlock";
|
||||
|
||||
/** 初始化块系统 */
|
||||
export function initBlockSystem(): void {
|
||||
// 注册终端块
|
||||
registerBlockType({
|
||||
type: "terminal",
|
||||
displayName: "终端",
|
||||
icon: "terminal",
|
||||
component: TerminalBlock,
|
||||
canCreate: true,
|
||||
defaultConfig: {
|
||||
type: "terminal",
|
||||
meta: { title: "终端" },
|
||||
},
|
||||
});
|
||||
|
||||
// 注册预览块
|
||||
registerBlockType({
|
||||
type: "preview",
|
||||
displayName: "预览",
|
||||
icon: "file",
|
||||
component: PreviewBlock,
|
||||
canCreate: true,
|
||||
defaultConfig: {
|
||||
type: "preview",
|
||||
meta: { title: "预览" },
|
||||
},
|
||||
});
|
||||
|
||||
// 注册 Web 块
|
||||
registerBlockType({
|
||||
type: "web",
|
||||
displayName: "Web",
|
||||
icon: "globe",
|
||||
component: WebBlock,
|
||||
canCreate: true,
|
||||
defaultConfig: {
|
||||
type: "web",
|
||||
meta: { title: "Web" },
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* @file registry.ts
|
||||
* @description 块注册表
|
||||
* @module lib/blocks/registry
|
||||
*
|
||||
* 管理块类型的注册和查找。
|
||||
*/
|
||||
|
||||
import type React from "react";
|
||||
import type {
|
||||
BlockType,
|
||||
BlockRegistryEntry,
|
||||
BlockComponentProps,
|
||||
} from "./types";
|
||||
|
||||
/** 块注册表 */
|
||||
class BlockRegistry {
|
||||
private entries: Map<BlockType, BlockRegistryEntry> = new Map();
|
||||
|
||||
/**
|
||||
* 注册块类型
|
||||
*/
|
||||
register(entry: BlockRegistryEntry): void {
|
||||
this.entries.set(entry.type, entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取块类型配置
|
||||
*/
|
||||
get(type: BlockType): BlockRegistryEntry | undefined {
|
||||
return this.entries.get(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有可创建的块类型
|
||||
*/
|
||||
getCreatableTypes(): BlockRegistryEntry[] {
|
||||
return Array.from(this.entries.values()).filter(
|
||||
(entry) => entry.canCreate !== false,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有块类型
|
||||
*/
|
||||
getAll(): BlockRegistryEntry[] {
|
||||
return Array.from(this.entries.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查块类型是否已注册
|
||||
*/
|
||||
has(type: BlockType): boolean {
|
||||
return this.entries.has(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取块组件
|
||||
*/
|
||||
getComponent(
|
||||
type: BlockType,
|
||||
): React.ComponentType<BlockComponentProps> | undefined {
|
||||
return this.entries.get(type)?.component;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取块图标
|
||||
*/
|
||||
getIcon(type: BlockType): string {
|
||||
return this.entries.get(type)?.icon ?? "square";
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取块显示名称
|
||||
*/
|
||||
getDisplayName(type: BlockType): string {
|
||||
return this.entries.get(type)?.displayName ?? type;
|
||||
}
|
||||
}
|
||||
|
||||
/** 全局块注册表实例 */
|
||||
export const blockRegistry = new BlockRegistry();
|
||||
|
||||
/** 注册块类型的便捷函数 */
|
||||
export function registerBlockType(entry: BlockRegistryEntry): void {
|
||||
blockRegistry.register(entry);
|
||||
}
|
||||
|
||||
/** 获取块类型配置的便捷函数 */
|
||||
export function getBlockType(type: BlockType): BlockRegistryEntry | undefined {
|
||||
return blockRegistry.get(type);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* @file types.ts
|
||||
* @description 块系统类型定义
|
||||
* @module lib/blocks/types
|
||||
*
|
||||
* 参考 waveterm 的块系统设计,定义块的核心类型和接口。
|
||||
*/
|
||||
|
||||
import type React from "react";
|
||||
|
||||
/** 块类型枚举 */
|
||||
export type BlockType =
|
||||
| "terminal"
|
||||
| "preview"
|
||||
| "web"
|
||||
| "ai"
|
||||
| "markdown"
|
||||
| "image";
|
||||
|
||||
/** 块状态 */
|
||||
export type BlockStatus = "idle" | "loading" | "active" | "error";
|
||||
|
||||
/** 块元数据 */
|
||||
export interface BlockMeta {
|
||||
/** 块标题 */
|
||||
title?: string;
|
||||
/** 块图标 */
|
||||
icon?: string;
|
||||
/** 自定义数据 */
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** 块配置 */
|
||||
export interface BlockConfig {
|
||||
/** 块类型 */
|
||||
type: BlockType;
|
||||
/** 块元数据 */
|
||||
meta?: BlockMeta;
|
||||
/** 终端会话 ID(仅终端块) */
|
||||
sessionId?: string;
|
||||
/** 文件路径(仅预览块) */
|
||||
filePath?: string;
|
||||
/** URL(仅 Web 块) */
|
||||
url?: string;
|
||||
}
|
||||
|
||||
/** 块实例 */
|
||||
export interface Block {
|
||||
/** 块唯一 ID */
|
||||
id: string;
|
||||
/** 块类型 */
|
||||
type: BlockType;
|
||||
/** 块状态 */
|
||||
status: BlockStatus;
|
||||
/** 块元数据 */
|
||||
meta: BlockMeta;
|
||||
/** 块配置 */
|
||||
config: BlockConfig;
|
||||
/** 创建时间 */
|
||||
createdAt: number;
|
||||
/** 更新时间 */
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/** 块视图模型接口 */
|
||||
export interface BlockViewModel {
|
||||
/** 块 ID */
|
||||
blockId: string;
|
||||
/** 是否聚焦 */
|
||||
isFocused: boolean;
|
||||
/** 是否最大化 */
|
||||
isMagnified: boolean;
|
||||
/** 聚焦块 */
|
||||
focus: () => void;
|
||||
/** 切换最大化 */
|
||||
toggleMagnify: () => void;
|
||||
/** 关闭块 */
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
/** 块组件属性 */
|
||||
export interface BlockComponentProps {
|
||||
/** 块实例 */
|
||||
block: Block;
|
||||
/** 视图模型 */
|
||||
viewModel: BlockViewModel;
|
||||
/** 是否可见 */
|
||||
visible?: boolean;
|
||||
}
|
||||
|
||||
/** 块注册表项 */
|
||||
export interface BlockRegistryEntry {
|
||||
/** 块类型 */
|
||||
type: BlockType;
|
||||
/** 显示名称 */
|
||||
displayName: string;
|
||||
/** 图标名称 */
|
||||
icon: string;
|
||||
/** 组件 */
|
||||
component: React.ComponentType<BlockComponentProps>;
|
||||
/** 是否可创建 */
|
||||
canCreate?: boolean;
|
||||
/** 创建块的默认配置 */
|
||||
defaultConfig?: Partial<BlockConfig>;
|
||||
}
|
||||
|
||||
/** 面板方向 */
|
||||
export type PanelDirection = "horizontal" | "vertical";
|
||||
|
||||
/** 面板节点类型 */
|
||||
export type PanelNodeType = "block" | "group";
|
||||
|
||||
/** 面板节点 */
|
||||
export interface PanelNode {
|
||||
/** 节点 ID */
|
||||
id: string;
|
||||
/** 节点类型 */
|
||||
type: PanelNodeType;
|
||||
/** 块 ID(仅 block 类型) */
|
||||
blockId?: string;
|
||||
/** 子节点(仅 group 类型) */
|
||||
children?: PanelNode[];
|
||||
/** 分割方向(仅 group 类型) */
|
||||
direction?: PanelDirection;
|
||||
/** 面板大小百分比 */
|
||||
size?: number;
|
||||
}
|
||||
|
||||
/** 标签页 */
|
||||
export interface Tab {
|
||||
/** 标签页 ID */
|
||||
id: string;
|
||||
/** 标签页标题 */
|
||||
title: string;
|
||||
/** 根面板节点 */
|
||||
rootNode: PanelNode;
|
||||
/** 活动块 ID */
|
||||
activeBlockId?: string;
|
||||
/** 是否固定 */
|
||||
isPinned?: boolean;
|
||||
}
|
||||
|
||||
/** 工作区 */
|
||||
export interface Workspace {
|
||||
/** 工作区 ID */
|
||||
id: string;
|
||||
/** 工作区名称 */
|
||||
name: string;
|
||||
/** 标签页列表 */
|
||||
tabs: Tab[];
|
||||
/** 活动标签页 ID */
|
||||
activeTabId?: string;
|
||||
/** 创建时间 */
|
||||
createdAt: number;
|
||||
/** 更新时间 */
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/** 块操作类型 */
|
||||
export type BlockAction =
|
||||
| { type: "CREATE"; payload: BlockConfig }
|
||||
| { type: "UPDATE"; payload: { id: string; updates: Partial<Block> } }
|
||||
| { type: "DELETE"; payload: { id: string } }
|
||||
| { type: "FOCUS"; payload: { id: string } }
|
||||
| { type: "MAGNIFY"; payload: { id: string } }
|
||||
| {
|
||||
type: "SPLIT";
|
||||
payload: {
|
||||
blockId: string;
|
||||
direction: PanelDirection;
|
||||
newBlockConfig: BlockConfig;
|
||||
};
|
||||
};
|
||||
|
||||
/** 布局操作类型 */
|
||||
export type LayoutAction =
|
||||
| { type: "SPLIT_HORIZONTAL"; payload: { nodeId: string } }
|
||||
| { type: "SPLIT_VERTICAL"; payload: { nodeId: string } }
|
||||
| { type: "CLOSE_PANEL"; payload: { nodeId: string } }
|
||||
| { type: "RESIZE_PANEL"; payload: { nodeId: string; size: number } }
|
||||
| {
|
||||
type: "MOVE_PANEL";
|
||||
payload: {
|
||||
sourceId: string;
|
||||
targetId: string;
|
||||
position: "before" | "after";
|
||||
};
|
||||
};
|
||||
@@ -165,10 +165,10 @@ export function PluginUIRenderer({
|
||||
const PluginComponent = module.default;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className={`h-full w-full ${className || ""}`}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="flex items-center justify-center p-8 h-full">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -126,6 +126,38 @@ function executeScript(code: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 已加载的 CSS 缓存
|
||||
*/
|
||||
const loadedStyles = new Set<string>();
|
||||
|
||||
/**
|
||||
* 加载插件 CSS 样式
|
||||
*/
|
||||
async function loadPluginStyles(cssPath: string): Promise<void> {
|
||||
// 检查是否已加载
|
||||
if (loadedStyles.has(cssPath)) {
|
||||
console.log(`[PluginLoader] CSS 已加载: ${cssPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const cssContent = await readPluginFile(cssPath);
|
||||
|
||||
// 创建 style 标签
|
||||
const style = document.createElement("style");
|
||||
style.setAttribute("data-plugin-css", cssPath);
|
||||
style.textContent = cssContent;
|
||||
document.head.appendChild(style);
|
||||
|
||||
loadedStyles.add(cssPath);
|
||||
console.log(`[PluginLoader] CSS 加载成功: ${cssPath}`);
|
||||
} catch (error) {
|
||||
console.warn(`[PluginLoader] CSS 加载失败 (可能不存在): ${cssPath}`, error);
|
||||
// CSS 加载失败不阻止插件加载
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载插件 UI 组件
|
||||
*
|
||||
@@ -144,6 +176,10 @@ export async function loadPluginUI(
|
||||
}
|
||||
|
||||
try {
|
||||
// 尝试加载 CSS 文件(与 JS 同目录的 styles.css)
|
||||
const cssPath = pluginPath.replace(/\/[^/]+\.js$/, "/styles.css");
|
||||
await loadPluginStyles(cssPath);
|
||||
|
||||
// 读取插件文件内容
|
||||
const content = await readPluginFile(pluginPath);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
import type {
|
||||
ProxyCastPluginSDK,
|
||||
PluginId,
|
||||
@@ -538,6 +539,55 @@ function getRpcNotificationManager(pluginId: PluginId): RpcNotificationManager {
|
||||
// 连接状态跟踪
|
||||
const rpcConnectionStatus = new Map<PluginId, boolean>();
|
||||
|
||||
// Tauri 事件监听器(全局单例)
|
||||
let _tauriEventUnlisten: UnlistenFn | null = null;
|
||||
let tauriEventInitialized = false;
|
||||
|
||||
/**
|
||||
* RPC 通知事件 payload 类型
|
||||
*/
|
||||
interface RpcNotificationPayload {
|
||||
plugin_id: string;
|
||||
method: string;
|
||||
params: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化 Tauri 事件监听器
|
||||
* 监听来自后端的 RPC 通知并分发到对应的插件
|
||||
*/
|
||||
async function initTauriEventListener(): Promise<void> {
|
||||
if (tauriEventInitialized) {
|
||||
return;
|
||||
}
|
||||
tauriEventInitialized = true;
|
||||
|
||||
try {
|
||||
_tauriEventUnlisten = await listen<RpcNotificationPayload>(
|
||||
"plugin-rpc-notification",
|
||||
(event) => {
|
||||
const { plugin_id, method, params } = event.payload;
|
||||
console.log(`[RPC] 收到通知: ${plugin_id} -> ${method}`, params);
|
||||
|
||||
// 分发到对应插件的通知管理器
|
||||
const manager = rpcNotificationManagers.get(plugin_id);
|
||||
if (manager) {
|
||||
manager.emit(method, params);
|
||||
} else {
|
||||
console.warn(`[RPC] 未找到插件 ${plugin_id} 的通知管理器`);
|
||||
}
|
||||
},
|
||||
);
|
||||
console.log("[RPC] Tauri 事件监听器已初始化");
|
||||
} catch (error) {
|
||||
console.error("[RPC] 初始化 Tauri 事件监听器失败:", error);
|
||||
tauriEventInitialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 自动初始化事件监听器
|
||||
initTauriEventListener();
|
||||
|
||||
/**
|
||||
* 创建 RPC API
|
||||
*
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* @file terminal-api.ts
|
||||
* @description 终端核心能力 API 封装
|
||||
* @module lib/terminal-api
|
||||
*
|
||||
* 提供终端会话管理的 Tauri 命令封装和事件监听。
|
||||
*
|
||||
* ## 架构说明
|
||||
* PTY 在后端预创建,使用默认大小 (24x80)。前端连接后通过 resize 同步实际大小。
|
||||
*
|
||||
* ## 功能
|
||||
* - 创建/关闭终端会话
|
||||
* - 发送输入到终端
|
||||
* - 调整终端大小
|
||||
* - 监听终端输出和状态事件
|
||||
*
|
||||
* ## 使用示例
|
||||
* ```typescript
|
||||
* import { createTerminalSession, writeToTerminal, resizeTerminal } from '@/lib/terminal-api';
|
||||
*
|
||||
* // 创建会话(使用默认大小)
|
||||
* const sessionId = await createTerminalSession();
|
||||
* // 同步实际大小
|
||||
* await resizeTerminal(sessionId, 40, 120);
|
||||
* // 发送输入
|
||||
* await writeToTerminal(sessionId, 'ls -la\n');
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen, UnlistenFn } from "@tauri-apps/api/event";
|
||||
|
||||
// ============================================================================
|
||||
// 类型定义
|
||||
// ============================================================================
|
||||
|
||||
/** 会话状态 */
|
||||
export type SessionStatus = "connecting" | "running" | "done" | "error";
|
||||
|
||||
/** 创建会话响应 */
|
||||
export interface CreateSessionResponse {
|
||||
/** 会话 ID */
|
||||
session_id: string;
|
||||
}
|
||||
|
||||
/** 会话元数据 */
|
||||
export interface SessionMetadata {
|
||||
/** 会话 ID */
|
||||
id: string;
|
||||
/** 会话状态 */
|
||||
status: SessionStatus;
|
||||
/** 创建时间(Unix 时间戳,毫秒) */
|
||||
created_at: number;
|
||||
/** 终端行数 */
|
||||
rows: number;
|
||||
/** 终端列数 */
|
||||
cols: number;
|
||||
}
|
||||
|
||||
/** 终端输出事件 */
|
||||
export interface TerminalOutputEvent {
|
||||
/** 会话 ID */
|
||||
session_id: string;
|
||||
/** 输出数据(Base64 编码) */
|
||||
data: string;
|
||||
}
|
||||
|
||||
/** 终端状态事件 */
|
||||
export interface TerminalStatusEvent {
|
||||
/** 会话 ID */
|
||||
session_id: string;
|
||||
/** 会话状态 */
|
||||
status: SessionStatus;
|
||||
/** 退出码 */
|
||||
exit_code?: number;
|
||||
/** 错误信息 */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 事件名称
|
||||
// ============================================================================
|
||||
|
||||
export const TERMINAL_OUTPUT_EVENT = "terminal:output";
|
||||
export const TERMINAL_STATUS_EVENT = "terminal:status";
|
||||
|
||||
// ============================================================================
|
||||
// API 函数
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 创建终端会话(使用默认大小)
|
||||
*
|
||||
* PTY 使用默认大小 (24x80) 预创建,前端连接后通过 resizeTerminal 同步实际大小。
|
||||
*
|
||||
* @returns 会话 ID
|
||||
*/
|
||||
export async function createTerminalSession(): Promise<string> {
|
||||
const response = await invoke<CreateSessionResponse>(
|
||||
"terminal_create_session",
|
||||
);
|
||||
return response.session_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 向终端发送输入
|
||||
*
|
||||
* @param sessionId - 会话 ID
|
||||
* @param data - 输入数据(字符串,会自动 Base64 编码)
|
||||
*/
|
||||
export async function writeToTerminal(
|
||||
sessionId: string,
|
||||
data: string,
|
||||
): Promise<void> {
|
||||
// 将字符串转换为 Base64
|
||||
const encoder = new TextEncoder();
|
||||
const bytes = encoder.encode(data);
|
||||
const base64 = btoa(String.fromCharCode(...bytes));
|
||||
|
||||
await invoke("terminal_write", {
|
||||
sessionId,
|
||||
data: base64,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 向终端发送原始字节数据
|
||||
*
|
||||
* @param sessionId - 会话 ID
|
||||
* @param data - Base64 编码的数据
|
||||
*/
|
||||
export async function writeToTerminalRaw(
|
||||
sessionId: string,
|
||||
data: string,
|
||||
): Promise<void> {
|
||||
await invoke("terminal_write", {
|
||||
sessionId,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 调整终端大小
|
||||
*
|
||||
* @param sessionId - 会话 ID
|
||||
* @param rows - 新的行数
|
||||
* @param cols - 新的列数
|
||||
*/
|
||||
export async function resizeTerminal(
|
||||
sessionId: string,
|
||||
rows: number,
|
||||
cols: number,
|
||||
): Promise<void> {
|
||||
await invoke("terminal_resize", {
|
||||
sessionId,
|
||||
rows,
|
||||
cols,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭终端会话
|
||||
*
|
||||
* @param sessionId - 会话 ID
|
||||
*/
|
||||
export async function closeTerminal(sessionId: string): Promise<void> {
|
||||
await invoke("terminal_close", {
|
||||
sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有终端会话
|
||||
*
|
||||
* @returns 会话元数据列表
|
||||
*/
|
||||
export async function listTerminalSessions(): Promise<SessionMetadata[]> {
|
||||
return invoke<SessionMetadata[]>("terminal_list_sessions");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个终端会话信息
|
||||
*
|
||||
* @param sessionId - 会话 ID
|
||||
* @returns 会话元数据,如果不存在则返回 null
|
||||
*/
|
||||
export async function getTerminalSession(
|
||||
sessionId: string,
|
||||
): Promise<SessionMetadata | null> {
|
||||
return invoke<SessionMetadata | null>("terminal_get_session", {
|
||||
sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 事件监听
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 监听终端输出事件
|
||||
*
|
||||
* @param callback - 回调函数,接收输出事件
|
||||
* @returns 取消监听函数
|
||||
*/
|
||||
export async function onTerminalOutput(
|
||||
callback: (event: TerminalOutputEvent) => void,
|
||||
): Promise<UnlistenFn> {
|
||||
return listen<TerminalOutputEvent>(TERMINAL_OUTPUT_EVENT, (event) => {
|
||||
callback(event.payload);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听终端状态事件
|
||||
*
|
||||
* @param callback - 回调函数,接收状态事件
|
||||
* @returns 取消监听函数
|
||||
*/
|
||||
export async function onTerminalStatus(
|
||||
callback: (event: TerminalStatusEvent) => void,
|
||||
): Promise<UnlistenFn> {
|
||||
return listen<TerminalStatusEvent>(TERMINAL_STATUS_EVENT, (event) => {
|
||||
callback(event.payload);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听特定会话的输出事件
|
||||
*
|
||||
* @param sessionId - 会话 ID
|
||||
* @param callback - 回调函数,接收解码后的输出数据
|
||||
* @returns 取消监听函数
|
||||
*/
|
||||
export async function onSessionOutput(
|
||||
sessionId: string,
|
||||
callback: (data: Uint8Array) => void,
|
||||
): Promise<UnlistenFn> {
|
||||
return listen<TerminalOutputEvent>(TERMINAL_OUTPUT_EVENT, (event) => {
|
||||
if (event.payload.session_id === sessionId) {
|
||||
// 解码 Base64 数据
|
||||
const binaryString = atob(event.payload.data);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
callback(bytes);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听特定会话的状态事件
|
||||
*
|
||||
* @param sessionId - 会话 ID
|
||||
* @param callback - 回调函数,接收状态事件
|
||||
* @returns 取消监听函数
|
||||
*/
|
||||
export async function onSessionStatus(
|
||||
sessionId: string,
|
||||
callback: (event: TerminalStatusEvent) => void,
|
||||
): Promise<UnlistenFn> {
|
||||
return listen<TerminalStatusEvent>(TERMINAL_STATUS_EVENT, (event) => {
|
||||
if (event.payload.session_id === sessionId) {
|
||||
callback(event.payload);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 工具函数
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 将字符串编码为 Base64
|
||||
*/
|
||||
export function encodeBase64(str: string): string {
|
||||
const encoder = new TextEncoder();
|
||||
const bytes = encoder.encode(str);
|
||||
return btoa(String.fromCharCode(...bytes));
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Base64 解码为 Uint8Array
|
||||
*/
|
||||
export function decodeBase64(base64: string): Uint8Array {
|
||||
const binaryString = atob(base64);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Uint8Array 解码为字符串
|
||||
*/
|
||||
export function decodeBytes(bytes: Uint8Array): string {
|
||||
const decoder = new TextDecoder();
|
||||
return decoder.decode(bytes);
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* @file themes.ts
|
||||
* @description 终端主题配置
|
||||
* @module lib/terminal/themes
|
||||
*
|
||||
* 提供多种终端主题,参考 waveterm 的主题系统。
|
||||
*/
|
||||
|
||||
import type { ITheme } from "@xterm/xterm";
|
||||
|
||||
/** 主题名称类型 */
|
||||
export type ThemeName =
|
||||
| "tokyo-night"
|
||||
| "dracula"
|
||||
| "one-dark"
|
||||
| "github-dark"
|
||||
| "monokai"
|
||||
| "nord"
|
||||
| "solarized-dark"
|
||||
| "gruvbox-dark";
|
||||
|
||||
/** 主题配置接口 */
|
||||
export interface TerminalTheme {
|
||||
name: ThemeName;
|
||||
displayName: string;
|
||||
theme: ITheme;
|
||||
}
|
||||
|
||||
/** Tokyo Night 主题 */
|
||||
const tokyoNight: ITheme = {
|
||||
background: "#1a1b26",
|
||||
foreground: "#c0caf5",
|
||||
cursor: "#c0caf5",
|
||||
cursorAccent: "#1a1b26",
|
||||
selectionBackground: "#364a82",
|
||||
selectionForeground: "#c0caf5",
|
||||
selectionInactiveBackground: "#364a8266",
|
||||
black: "#15161e",
|
||||
red: "#f7768e",
|
||||
green: "#9ece6a",
|
||||
yellow: "#e0af68",
|
||||
blue: "#7aa2f7",
|
||||
magenta: "#bb9af7",
|
||||
cyan: "#7dcfff",
|
||||
white: "#a9b1d6",
|
||||
brightBlack: "#414868",
|
||||
brightRed: "#f7768e",
|
||||
brightGreen: "#9ece6a",
|
||||
brightYellow: "#e0af68",
|
||||
brightBlue: "#7aa2f7",
|
||||
brightMagenta: "#bb9af7",
|
||||
brightCyan: "#7dcfff",
|
||||
brightWhite: "#c0caf5",
|
||||
};
|
||||
|
||||
/** Dracula 主题 */
|
||||
const dracula: ITheme = {
|
||||
background: "#282a36",
|
||||
foreground: "#f8f8f2",
|
||||
cursor: "#f8f8f2",
|
||||
cursorAccent: "#282a36",
|
||||
selectionBackground: "#44475a",
|
||||
selectionForeground: "#f8f8f2",
|
||||
selectionInactiveBackground: "#44475a66",
|
||||
black: "#21222c",
|
||||
red: "#ff5555",
|
||||
green: "#50fa7b",
|
||||
yellow: "#f1fa8c",
|
||||
blue: "#bd93f9",
|
||||
magenta: "#ff79c6",
|
||||
cyan: "#8be9fd",
|
||||
white: "#f8f8f2",
|
||||
brightBlack: "#6272a4",
|
||||
brightRed: "#ff6e6e",
|
||||
brightGreen: "#69ff94",
|
||||
brightYellow: "#ffffa5",
|
||||
brightBlue: "#d6acff",
|
||||
brightMagenta: "#ff92df",
|
||||
brightCyan: "#a4ffff",
|
||||
brightWhite: "#ffffff",
|
||||
};
|
||||
|
||||
/** One Dark 主题 */
|
||||
const oneDark: ITheme = {
|
||||
background: "#282c34",
|
||||
foreground: "#abb2bf",
|
||||
cursor: "#528bff",
|
||||
cursorAccent: "#282c34",
|
||||
selectionBackground: "#3e4451",
|
||||
selectionForeground: "#abb2bf",
|
||||
selectionInactiveBackground: "#3e445166",
|
||||
black: "#1e2127",
|
||||
red: "#e06c75",
|
||||
green: "#98c379",
|
||||
yellow: "#d19a66",
|
||||
blue: "#61afef",
|
||||
magenta: "#c678dd",
|
||||
cyan: "#56b6c2",
|
||||
white: "#abb2bf",
|
||||
brightBlack: "#5c6370",
|
||||
brightRed: "#e06c75",
|
||||
brightGreen: "#98c379",
|
||||
brightYellow: "#d19a66",
|
||||
brightBlue: "#61afef",
|
||||
brightMagenta: "#c678dd",
|
||||
brightCyan: "#56b6c2",
|
||||
brightWhite: "#ffffff",
|
||||
};
|
||||
|
||||
/** GitHub Dark 主题 */
|
||||
const githubDark: ITheme = {
|
||||
background: "#0d1117",
|
||||
foreground: "#c9d1d9",
|
||||
cursor: "#58a6ff",
|
||||
cursorAccent: "#0d1117",
|
||||
selectionBackground: "#264f78",
|
||||
selectionForeground: "#c9d1d9",
|
||||
selectionInactiveBackground: "#264f7866",
|
||||
black: "#484f58",
|
||||
red: "#ff7b72",
|
||||
green: "#3fb950",
|
||||
yellow: "#d29922",
|
||||
blue: "#58a6ff",
|
||||
magenta: "#bc8cff",
|
||||
cyan: "#39c5cf",
|
||||
white: "#b1bac4",
|
||||
brightBlack: "#6e7681",
|
||||
brightRed: "#ffa198",
|
||||
brightGreen: "#56d364",
|
||||
brightYellow: "#e3b341",
|
||||
brightBlue: "#79c0ff",
|
||||
brightMagenta: "#d2a8ff",
|
||||
brightCyan: "#56d4dd",
|
||||
brightWhite: "#f0f6fc",
|
||||
};
|
||||
|
||||
/** Monokai 主题 */
|
||||
const monokai: ITheme = {
|
||||
background: "#272822",
|
||||
foreground: "#f8f8f2",
|
||||
cursor: "#f8f8f0",
|
||||
cursorAccent: "#272822",
|
||||
selectionBackground: "#49483e",
|
||||
selectionForeground: "#f8f8f2",
|
||||
selectionInactiveBackground: "#49483e66",
|
||||
black: "#272822",
|
||||
red: "#f92672",
|
||||
green: "#a6e22e",
|
||||
yellow: "#f4bf75",
|
||||
blue: "#66d9ef",
|
||||
magenta: "#ae81ff",
|
||||
cyan: "#a1efe4",
|
||||
white: "#f8f8f2",
|
||||
brightBlack: "#75715e",
|
||||
brightRed: "#f92672",
|
||||
brightGreen: "#a6e22e",
|
||||
brightYellow: "#f4bf75",
|
||||
brightBlue: "#66d9ef",
|
||||
brightMagenta: "#ae81ff",
|
||||
brightCyan: "#a1efe4",
|
||||
brightWhite: "#f9f8f5",
|
||||
};
|
||||
|
||||
/** Nord 主题 */
|
||||
const nord: ITheme = {
|
||||
background: "#2e3440",
|
||||
foreground: "#d8dee9",
|
||||
cursor: "#d8dee9",
|
||||
cursorAccent: "#2e3440",
|
||||
selectionBackground: "#434c5e",
|
||||
selectionForeground: "#d8dee9",
|
||||
selectionInactiveBackground: "#434c5e66",
|
||||
black: "#3b4252",
|
||||
red: "#bf616a",
|
||||
green: "#a3be8c",
|
||||
yellow: "#ebcb8b",
|
||||
blue: "#81a1c1",
|
||||
magenta: "#b48ead",
|
||||
cyan: "#88c0d0",
|
||||
white: "#e5e9f0",
|
||||
brightBlack: "#4c566a",
|
||||
brightRed: "#bf616a",
|
||||
brightGreen: "#a3be8c",
|
||||
brightYellow: "#ebcb8b",
|
||||
brightBlue: "#81a1c1",
|
||||
brightMagenta: "#b48ead",
|
||||
brightCyan: "#8fbcbb",
|
||||
brightWhite: "#eceff4",
|
||||
};
|
||||
|
||||
/** Solarized Dark 主题 */
|
||||
const solarizedDark: ITheme = {
|
||||
background: "#002b36",
|
||||
foreground: "#839496",
|
||||
cursor: "#839496",
|
||||
cursorAccent: "#002b36",
|
||||
selectionBackground: "#073642",
|
||||
selectionForeground: "#93a1a1",
|
||||
selectionInactiveBackground: "#07364266",
|
||||
black: "#073642",
|
||||
red: "#dc322f",
|
||||
green: "#859900",
|
||||
yellow: "#b58900",
|
||||
blue: "#268bd2",
|
||||
magenta: "#d33682",
|
||||
cyan: "#2aa198",
|
||||
white: "#eee8d5",
|
||||
brightBlack: "#002b36",
|
||||
brightRed: "#cb4b16",
|
||||
brightGreen: "#586e75",
|
||||
brightYellow: "#657b83",
|
||||
brightBlue: "#839496",
|
||||
brightMagenta: "#6c71c4",
|
||||
brightCyan: "#93a1a1",
|
||||
brightWhite: "#fdf6e3",
|
||||
};
|
||||
|
||||
/** Gruvbox Dark 主题 */
|
||||
const gruvboxDark: ITheme = {
|
||||
background: "#282828",
|
||||
foreground: "#ebdbb2",
|
||||
cursor: "#ebdbb2",
|
||||
cursorAccent: "#282828",
|
||||
selectionBackground: "#504945",
|
||||
selectionForeground: "#ebdbb2",
|
||||
selectionInactiveBackground: "#50494566",
|
||||
black: "#282828",
|
||||
red: "#cc241d",
|
||||
green: "#98971a",
|
||||
yellow: "#d79921",
|
||||
blue: "#458588",
|
||||
magenta: "#b16286",
|
||||
cyan: "#689d6a",
|
||||
white: "#a89984",
|
||||
brightBlack: "#928374",
|
||||
brightRed: "#fb4934",
|
||||
brightGreen: "#b8bb26",
|
||||
brightYellow: "#fabd2f",
|
||||
brightBlue: "#83a598",
|
||||
brightMagenta: "#d3869b",
|
||||
brightCyan: "#8ec07c",
|
||||
brightWhite: "#ebdbb2",
|
||||
};
|
||||
|
||||
/** 所有主题配置 */
|
||||
export const TERMINAL_THEMES: Record<ThemeName, TerminalTheme> = {
|
||||
"tokyo-night": {
|
||||
name: "tokyo-night",
|
||||
displayName: "Tokyo Night",
|
||||
theme: tokyoNight,
|
||||
},
|
||||
dracula: {
|
||||
name: "dracula",
|
||||
displayName: "Dracula",
|
||||
theme: dracula,
|
||||
},
|
||||
"one-dark": {
|
||||
name: "one-dark",
|
||||
displayName: "One Dark",
|
||||
theme: oneDark,
|
||||
},
|
||||
"github-dark": {
|
||||
name: "github-dark",
|
||||
displayName: "GitHub Dark",
|
||||
theme: githubDark,
|
||||
},
|
||||
monokai: {
|
||||
name: "monokai",
|
||||
displayName: "Monokai",
|
||||
theme: monokai,
|
||||
},
|
||||
nord: {
|
||||
name: "nord",
|
||||
displayName: "Nord",
|
||||
theme: nord,
|
||||
},
|
||||
"solarized-dark": {
|
||||
name: "solarized-dark",
|
||||
displayName: "Solarized Dark",
|
||||
theme: solarizedDark,
|
||||
},
|
||||
"gruvbox-dark": {
|
||||
name: "gruvbox-dark",
|
||||
displayName: "Gruvbox Dark",
|
||||
theme: gruvboxDark,
|
||||
},
|
||||
};
|
||||
|
||||
/** 默认主题 */
|
||||
export const DEFAULT_THEME: ThemeName = "tokyo-night";
|
||||
|
||||
/** 获取主题配置 */
|
||||
export function getTheme(name: ThemeName): ITheme {
|
||||
return TERMINAL_THEMES[name]?.theme ?? TERMINAL_THEMES[DEFAULT_THEME].theme;
|
||||
}
|
||||
|
||||
/** 获取所有主题列表 */
|
||||
export function getThemeList(): TerminalTheme[] {
|
||||
return Object.values(TERMINAL_THEMES);
|
||||
}
|
||||
|
||||
/** 主题存储键 */
|
||||
const THEME_STORAGE_KEY = "terminal-theme";
|
||||
|
||||
/** 保存主题到本地存储 */
|
||||
export function saveThemePreference(name: ThemeName): void {
|
||||
localStorage.setItem(THEME_STORAGE_KEY, name);
|
||||
}
|
||||
|
||||
/** 从本地存储加载主题 */
|
||||
export function loadThemePreference(): ThemeName {
|
||||
const saved = localStorage.getItem(THEME_STORAGE_KEY);
|
||||
if (saved && saved in TERMINAL_THEMES) {
|
||||
return saved as ThemeName;
|
||||
}
|
||||
return DEFAULT_THEME;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* @file types.ts
|
||||
* @description 工作区类型定义
|
||||
* @module lib/workspace/types
|
||||
*/
|
||||
|
||||
import type { Workspace, BlockConfig } from "@/lib/blocks/types";
|
||||
|
||||
/** 工作区配置 */
|
||||
export interface WorkspaceConfig {
|
||||
/** 工作区名称 */
|
||||
name: string;
|
||||
/** 初始标签页配置 */
|
||||
initialTabs?: TabConfig[];
|
||||
}
|
||||
|
||||
/** 标签页配置 */
|
||||
export interface TabConfig {
|
||||
/** 标签页标题 */
|
||||
title: string;
|
||||
/** 初始块配置 */
|
||||
blockConfig?: BlockConfig;
|
||||
/** 是否固定 */
|
||||
isPinned?: boolean;
|
||||
}
|
||||
|
||||
/** 工作区操作类型 */
|
||||
export type WorkspaceAction =
|
||||
| { type: "CREATE_WORKSPACE"; payload: WorkspaceConfig }
|
||||
| { type: "DELETE_WORKSPACE"; payload: { id: string } }
|
||||
| { type: "RENAME_WORKSPACE"; payload: { id: string; name: string } }
|
||||
| { type: "SWITCH_WORKSPACE"; payload: { id: string } }
|
||||
| { type: "CREATE_TAB"; payload: { workspaceId: string; config: TabConfig } }
|
||||
| { type: "DELETE_TAB"; payload: { workspaceId: string; tabId: string } }
|
||||
| { type: "SWITCH_TAB"; payload: { workspaceId: string; tabId: string } }
|
||||
| {
|
||||
type: "MOVE_TAB";
|
||||
payload: { workspaceId: string; tabId: string; newIndex: number };
|
||||
}
|
||||
| {
|
||||
type: "PIN_TAB";
|
||||
payload: { workspaceId: string; tabId: string; isPinned: boolean };
|
||||
};
|
||||
|
||||
/** 工作区状态 */
|
||||
export interface WorkspaceState {
|
||||
/** 所有工作区 */
|
||||
workspaces: Map<string, Workspace>;
|
||||
/** 当前活动工作区 ID */
|
||||
activeWorkspaceId: string | null;
|
||||
}
|
||||
|
||||
/** 工作区存储数据 */
|
||||
export interface WorkspaceStorageData {
|
||||
workspaces: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
tabs: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
isPinned?: boolean;
|
||||
}>;
|
||||
activeTabId?: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}>;
|
||||
activeWorkspaceId: string | null;
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* @file workspaceStore.ts
|
||||
* @description 工作区状态管理
|
||||
* @module lib/workspace/workspaceStore
|
||||
*/
|
||||
|
||||
import { atom } from "jotai";
|
||||
import type { Workspace, Tab, PanelNode } from "@/lib/blocks/types";
|
||||
import { generateId } from "@/lib/blocks/blockStore";
|
||||
import type { WorkspaceConfig, TabConfig } from "./types";
|
||||
|
||||
// ============================================================================
|
||||
// 工具函数
|
||||
// ============================================================================
|
||||
|
||||
/** 创建标签页 */
|
||||
export function createTab(config: TabConfig): { tab: Tab; blockId?: string } {
|
||||
const tabId = generateId();
|
||||
let blockId: string | undefined;
|
||||
let rootNode: PanelNode;
|
||||
|
||||
if (config.blockConfig) {
|
||||
blockId = generateId();
|
||||
rootNode = {
|
||||
id: generateId(),
|
||||
type: "block",
|
||||
blockId,
|
||||
size: 100,
|
||||
};
|
||||
} else {
|
||||
rootNode = {
|
||||
id: generateId(),
|
||||
type: "group",
|
||||
children: [],
|
||||
direction: "horizontal",
|
||||
};
|
||||
}
|
||||
|
||||
const tab: Tab = {
|
||||
id: tabId,
|
||||
title: config.title,
|
||||
rootNode,
|
||||
activeBlockId: blockId,
|
||||
isPinned: config.isPinned,
|
||||
};
|
||||
|
||||
return { tab, blockId };
|
||||
}
|
||||
|
||||
/** 创建工作区 */
|
||||
export function createWorkspace(config: WorkspaceConfig): Workspace {
|
||||
const now = Date.now();
|
||||
const workspace: Workspace = {
|
||||
id: generateId(),
|
||||
name: config.name,
|
||||
tabs: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
return workspace;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 原子状态
|
||||
// ============================================================================
|
||||
|
||||
/** 所有工作区 */
|
||||
export const workspacesAtom = atom<Map<string, Workspace>>(new Map());
|
||||
|
||||
/** 当前活动工作区 ID */
|
||||
export const activeWorkspaceIdAtom = atom<string | null>(null);
|
||||
|
||||
// ============================================================================
|
||||
// 派生原子
|
||||
// ============================================================================
|
||||
|
||||
/** 获取当前活动工作区 */
|
||||
export const activeWorkspaceAtom = atom((get) => {
|
||||
const activeId = get(activeWorkspaceIdAtom);
|
||||
if (!activeId) return null;
|
||||
return get(workspacesAtom).get(activeId) ?? null;
|
||||
});
|
||||
|
||||
/** 获取所有工作区列表 */
|
||||
export const workspaceListAtom = atom((get) => {
|
||||
return Array.from(get(workspacesAtom).values());
|
||||
});
|
||||
|
||||
/** 获取当前工作区的标签页列表 */
|
||||
export const currentTabsAtom = atom((get) => {
|
||||
const workspace = get(activeWorkspaceAtom);
|
||||
return workspace?.tabs ?? [];
|
||||
});
|
||||
|
||||
/** 获取当前活动标签页 */
|
||||
export const currentActiveTabAtom = atom((get) => {
|
||||
const workspace = get(activeWorkspaceAtom);
|
||||
if (!workspace?.activeTabId) return null;
|
||||
return workspace.tabs.find((t) => t.id === workspace.activeTabId) ?? null;
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// 操作原子
|
||||
// ============================================================================
|
||||
|
||||
/** 添加工作区 */
|
||||
export const addWorkspaceAtom = atom(
|
||||
null,
|
||||
(get, set, config: WorkspaceConfig) => {
|
||||
const workspace = createWorkspace(config);
|
||||
const workspaces = new Map(get(workspacesAtom));
|
||||
workspaces.set(workspace.id, workspace);
|
||||
set(workspacesAtom, workspaces);
|
||||
set(activeWorkspaceIdAtom, workspace.id);
|
||||
return workspace;
|
||||
},
|
||||
);
|
||||
|
||||
/** 删除工作区 */
|
||||
export const removeWorkspaceAtom = atom(
|
||||
null,
|
||||
(get, set, workspaceId: string) => {
|
||||
const workspaces = new Map(get(workspacesAtom));
|
||||
workspaces.delete(workspaceId);
|
||||
set(workspacesAtom, workspaces);
|
||||
|
||||
// 如果删除的是当前工作区,切换到其他工作区
|
||||
if (get(activeWorkspaceIdAtom) === workspaceId) {
|
||||
const remaining = Array.from(workspaces.values());
|
||||
set(activeWorkspaceIdAtom, remaining.length > 0 ? remaining[0].id : null);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/** 切换工作区 */
|
||||
export const switchWorkspaceAtom = atom(
|
||||
null,
|
||||
(get, set, workspaceId: string) => {
|
||||
set(activeWorkspaceIdAtom, workspaceId);
|
||||
},
|
||||
);
|
||||
|
||||
/** 添加标签页到当前工作区 */
|
||||
export const addTabToWorkspaceAtom = atom(
|
||||
null,
|
||||
(get, set, config: TabConfig) => {
|
||||
const activeId = get(activeWorkspaceIdAtom);
|
||||
if (!activeId) return null;
|
||||
|
||||
const workspaces = new Map(get(workspacesAtom));
|
||||
const workspace = workspaces.get(activeId);
|
||||
if (!workspace) return null;
|
||||
|
||||
const { tab } = createTab(config);
|
||||
const updatedWorkspace: Workspace = {
|
||||
...workspace,
|
||||
tabs: [...workspace.tabs, tab],
|
||||
activeTabId: tab.id,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
workspaces.set(activeId, updatedWorkspace);
|
||||
set(workspacesAtom, workspaces);
|
||||
|
||||
return tab;
|
||||
},
|
||||
);
|
||||
|
||||
/** 删除标签页 */
|
||||
export const removeTabFromWorkspaceAtom = atom(
|
||||
null,
|
||||
(get, set, tabId: string) => {
|
||||
const activeId = get(activeWorkspaceIdAtom);
|
||||
if (!activeId) return;
|
||||
|
||||
const workspaces = new Map(get(workspacesAtom));
|
||||
const workspace = workspaces.get(activeId);
|
||||
if (!workspace) return;
|
||||
|
||||
const tabIndex = workspace.tabs.findIndex((t) => t.id === tabId);
|
||||
if (tabIndex === -1) return;
|
||||
|
||||
const newTabs = workspace.tabs.filter((t) => t.id !== tabId);
|
||||
let newActiveTabId = workspace.activeTabId;
|
||||
|
||||
// 如果删除的是当前标签页,切换到相邻标签页
|
||||
if (workspace.activeTabId === tabId) {
|
||||
if (newTabs.length > 0) {
|
||||
const newIndex = Math.min(tabIndex, newTabs.length - 1);
|
||||
newActiveTabId = newTabs[newIndex].id;
|
||||
} else {
|
||||
newActiveTabId = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const updatedWorkspace: Workspace = {
|
||||
...workspace,
|
||||
tabs: newTabs,
|
||||
activeTabId: newActiveTabId,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
workspaces.set(activeId, updatedWorkspace);
|
||||
set(workspacesAtom, workspaces);
|
||||
},
|
||||
);
|
||||
|
||||
/** 切换标签页 */
|
||||
export const switchTabAtom = atom(null, (get, set, tabId: string) => {
|
||||
const activeId = get(activeWorkspaceIdAtom);
|
||||
if (!activeId) return;
|
||||
|
||||
const workspaces = new Map(get(workspacesAtom));
|
||||
const workspace = workspaces.get(activeId);
|
||||
if (!workspace) return;
|
||||
|
||||
const updatedWorkspace: Workspace = {
|
||||
...workspace,
|
||||
activeTabId: tabId,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
workspaces.set(activeId, updatedWorkspace);
|
||||
set(workspacesAtom, workspaces);
|
||||
});
|
||||
|
||||
/** 重命名标签页 */
|
||||
export const renameTabAtom = atom(
|
||||
null,
|
||||
(get, set, { tabId, title }: { tabId: string; title: string }) => {
|
||||
const activeId = get(activeWorkspaceIdAtom);
|
||||
if (!activeId) return;
|
||||
|
||||
const workspaces = new Map(get(workspacesAtom));
|
||||
const workspace = workspaces.get(activeId);
|
||||
if (!workspace) return;
|
||||
|
||||
const updatedTabs = workspace.tabs.map((tab) =>
|
||||
tab.id === tabId ? { ...tab, title } : tab,
|
||||
);
|
||||
|
||||
const updatedWorkspace: Workspace = {
|
||||
...workspace,
|
||||
tabs: updatedTabs,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
workspaces.set(activeId, updatedWorkspace);
|
||||
set(workspacesAtom, workspaces);
|
||||
},
|
||||
);
|
||||
|
||||
/** 移动标签页 */
|
||||
export const moveTabAtom = atom(
|
||||
null,
|
||||
(get, set, { tabId, newIndex }: { tabId: string; newIndex: number }) => {
|
||||
const activeId = get(activeWorkspaceIdAtom);
|
||||
if (!activeId) return;
|
||||
|
||||
const workspaces = new Map(get(workspacesAtom));
|
||||
const workspace = workspaces.get(activeId);
|
||||
if (!workspace) return;
|
||||
|
||||
const currentIndex = workspace.tabs.findIndex((t) => t.id === tabId);
|
||||
if (currentIndex === -1 || currentIndex === newIndex) return;
|
||||
|
||||
const newTabs = [...workspace.tabs];
|
||||
const [removed] = newTabs.splice(currentIndex, 1);
|
||||
newTabs.splice(newIndex, 0, removed);
|
||||
|
||||
const updatedWorkspace: Workspace = {
|
||||
...workspace,
|
||||
tabs: newTabs,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
workspaces.set(activeId, updatedWorkspace);
|
||||
set(workspacesAtom, workspaces);
|
||||
},
|
||||
);
|
||||
+2
-2
@@ -14,8 +14,8 @@ import "./i18n/config";
|
||||
import "./lib/plugin-components/global";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<>
|
||||
<App />
|
||||
<Toaster />
|
||||
</React.StrictMode>,
|
||||
</>,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user