Merge branch 'main' into main

This commit is contained in:
justMe
2026-01-08 04:36:09 +07:00
committed by GitHub
55 changed files with 7244 additions and 85 deletions
+130 -35
View File
@@ -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
View File
@@ -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",
+137 -5
View File
@@ -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",
+2 -1
View File
@@ -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
+1
View File
@@ -29,6 +29,7 @@ Tauri 后端核心代码,处理系统级功能和 API 服务。
- `services/` - 业务服务层
- `streaming/` - 流式响应处理
- `telemetry/` - 遥测和统计
- `terminal/` - 终端核心模块(PTY 管理、会话管理)
- `tray/` - 系统托盘
- `websocket/` - WebSocket 支持
- `lib.rs` - 库入口
-1
View File
@@ -1,7 +1,6 @@
//! Agent 工具系统模块
//!
//! 提供工具定义、注册、执行的核心框架
//! 参考 Claude Code 和 goose 项目的工具系统设计
//!
//! ## 模块结构
//! - `types`: 工具类型定义(ToolDefinition, ToolCall, ToolResult 等)
+6
View File
@@ -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,
+20
View File
@@ -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");
+1
View File
@@ -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;
+193 -36
View File
@@ -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))
}
}
}
+148
View File
@@ -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)
}
+4
View File
@@ -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)?;
+1
View File
@@ -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)),
})
+60
View File
@@ -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 支持
## 更新提醒
任何文件变更后,请更新此文档和相关的上级文档。
+57
View File
@@ -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())
}
}
+63
View File
@@ -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";
}
+32
View File
@@ -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};
+324
View File
@@ -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(())
}
}
+208
View File
@@ -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()
}
}
+210
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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() {
+1
View File
@@ -25,6 +25,7 @@ React 组件层,包含 UI 组件和业务组件。
- `settings/` - 设置页面组件
- `skills/` - 技能管理组件
- `switch/` - 开关控制组件
- `terminal/` - 内置终端组件(使用 Tauri Commands)
- `tools/` - 工具页面组件
- `ui/` - 通用 UI 组件(按钮、输入框等)
- `websocket/` - WebSocket 管理组件
+242
View File
@@ -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;
+175
View File
@@ -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;
+113
View File
@@ -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;
+220
View File
@@ -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;
+206
View File
@@ -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;
+296
View File
@@ -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} />}
/>
);
+156
View File
@@ -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;
+369
View File
@@ -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;
}
+67
View File
@@ -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,
};
```
## 更新提醒
任何文件变更后,请更新此文档和相关的上级文档。
+674
View File
@@ -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;
+259
View File
@@ -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;
+151
View File
@@ -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;
}
}
+7
View File
@@ -0,0 +1,7 @@
/**
* @file index.ts
* @description 终端组件导出
* @module components/terminal
*/
export { TerminalPage } from "./TerminalPage";
+528
View File
@@ -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);
}
+360
View File
@@ -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;
}
}
+4
View File
@@ -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 */
+1
View File
@@ -33,6 +33,7 @@
- `syntaxHighlight.ts` - 语法高亮工具
- `flowEventManager.ts` - 流量事件管理器
- `notificationService.ts` - 通知服务
- `terminal-api.ts` - 终端核心能力 API 封装(Terminal Core)
- `utils.ts` - 通用工具函数
## 更新提醒
+338
View File
@@ -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;
}
+64
View File
@@ -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" },
},
});
}
+92
View File
@@ -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);
}
+188
View File
@@ -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";
};
};
+2 -2
View File
@@ -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>
}
+36
View File
@@ -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);
+50
View File
@@ -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
*
+300
View File
@@ -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);
}
+317
View File
@@ -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;
}
+68
View File
@@ -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;
}
+281
View File
@@ -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
View File
@@ -14,8 +14,8 @@ import "./i18n/config";
import "./lib/plugin-components/global";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<>
<App />
<Toaster />
</React.StrictMode>,
</>,
);