chore: bump version to v0.36.0

- Update version in package.json, Cargo.toml, and tauri.conf.json
- Fix ESLint errors: add missing React imports
- Fix unused variable warnings
- Fix react-hooks/exhaustive-deps issues
- Fix process.env for Vite environment
- Suppress react-refresh/only-export-components warnings
- Fix missing 'language' field in Config tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
coso
2026-01-08 14:19:33 +08:00
co-authored by Claude Opus 4.5
parent 2cefb81b7e
commit 73c7906768
60 changed files with 9909 additions and 342 deletions
+507 -42
View File
@@ -1,14 +1,16 @@
{
"name": "proxycast",
"version": "0.34.0",
"version": "0.35.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "proxycast",
"version": "0.34.0",
"version": "0.35.0",
"dependencies": {
"@fabianlars/tauri-plugin-oauth": "^2",
"@floating-ui/react": "^0.27.16",
"@observablehq/plot": "^0.6.17",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.2",
@@ -37,6 +39,7 @@
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"dayjs": "^1.11.19",
"i18next": "^25.7.3",
"jotai": "^2.16.1",
"lodash-es": "^4.17.22",
@@ -192,7 +195,6 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -541,7 +543,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -585,7 +586,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -1306,6 +1306,21 @@
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/react": {
"version": "0.27.16",
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.16.tgz",
"integrity": "sha512-9O8N4SeG2z++TSM8QA/KTeKFBVCNEz/AGS7gWPJf6KFRzmRWixFRnCnkPHRDwSVZW6QPDO6uT0P2SpWNKCc9/g==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.1.6",
"@floating-ui/utils": "^0.2.10",
"tabbable": "^6.0.0"
},
"peerDependencies": {
"react": ">=17.0.0",
"react-dom": ">=17.0.0"
}
},
"node_modules/@floating-ui/react-dom": {
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz",
@@ -1465,6 +1480,20 @@
"node": ">= 8"
}
},
"node_modules/@observablehq/plot": {
"version": "0.6.17",
"resolved": "https://registry.npmjs.org/@observablehq/plot/-/plot-0.6.17.tgz",
"integrity": "sha512-/qaXP/7mc4MUS0s4cPPFASDRjtsWp85/TbfsciqDgU1HwYixbSbbytNuInD8AcTYC3xaxACgVX06agdfQy9W+g==",
"license": "ISC",
"dependencies": {
"d3": "^7.9.0",
"interval-tree-1d": "^1.0.0",
"isoformat": "^0.2.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@radix-ui/number": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
@@ -3082,7 +3111,6 @@
"integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/core": "^7.21.3",
"@svgr/babel-preset": "8.1.0",
@@ -3550,7 +3578,6 @@
"integrity": "sha512-LPM2G3Syo1GLzXLGJAKdqoU35XvrWzGJ21/7sgZTUpbkBaOasTj8tjwn6w+hCkqaa1TfJ/w67rJSwYItlJ2mYw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -3572,7 +3599,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@@ -3584,7 +3610,6 @@
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^18.0.0"
}
@@ -3657,7 +3682,6 @@
"integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.49.0",
"@typescript-eslint/types": "8.49.0",
@@ -4025,7 +4049,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -4231,6 +4254,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/binary-search-bounds": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/binary-search-bounds/-/binary-search-bounds-2.0.5.tgz",
"integrity": "sha512-H0ea4Fd3lS1+sTEB2TgcLoK21lLhwEJzlQv3IN47pJS976Gx4zoWe0ak3q+uYh60ppQxg9F16Ri4tS1sfD4+jA==",
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
@@ -4274,7 +4303,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -4665,6 +4693,416 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/d3": {
"version": "7.9.0",
"resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz",
"integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==",
"license": "ISC",
"dependencies": {
"d3-array": "3",
"d3-axis": "3",
"d3-brush": "3",
"d3-chord": "3",
"d3-color": "3",
"d3-contour": "4",
"d3-delaunay": "6",
"d3-dispatch": "3",
"d3-drag": "3",
"d3-dsv": "3",
"d3-ease": "3",
"d3-fetch": "3",
"d3-force": "3",
"d3-format": "3",
"d3-geo": "3",
"d3-hierarchy": "3",
"d3-interpolate": "3",
"d3-path": "3",
"d3-polygon": "3",
"d3-quadtree": "3",
"d3-random": "3",
"d3-scale": "4",
"d3-scale-chromatic": "3",
"d3-selection": "3",
"d3-shape": "3",
"d3-time": "3",
"d3-time-format": "4",
"d3-timer": "3",
"d3-transition": "3",
"d3-zoom": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-axis": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz",
"integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-brush": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz",
"integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "3",
"d3-transition": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-chord": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz",
"integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==",
"license": "ISC",
"dependencies": {
"d3-path": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-contour": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz",
"integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==",
"license": "ISC",
"dependencies": {
"d3-array": "^3.2.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-delaunay": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
"integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==",
"license": "ISC",
"dependencies": {
"delaunator": "5"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-drag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-selection": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dsv": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz",
"integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==",
"license": "ISC",
"dependencies": {
"commander": "7",
"iconv-lite": "0.6",
"rw": "1"
},
"bin": {
"csv2json": "bin/dsv2json.js",
"csv2tsv": "bin/dsv2dsv.js",
"dsv2dsv": "bin/dsv2dsv.js",
"dsv2json": "bin/dsv2json.js",
"json2csv": "bin/json2dsv.js",
"json2dsv": "bin/json2dsv.js",
"json2tsv": "bin/json2dsv.js",
"tsv2csv": "bin/dsv2dsv.js",
"tsv2json": "bin/dsv2json.js"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dsv/node_modules/commander": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
"license": "MIT",
"engines": {
"node": ">= 10"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-fetch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz",
"integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==",
"license": "ISC",
"dependencies": {
"d3-dsv": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-force": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
"integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-quadtree": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-geo": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2.5.0 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-hierarchy": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
"integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-polygon": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz",
"integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-quadtree": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
"integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-random": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz",
"integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale-chromatic": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
"integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-interpolate": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-transition": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-dispatch": "1 - 3",
"d3-ease": "1 - 3",
"d3-interpolate": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"d3-selection": "2 - 3"
}
},
"node_modules/d3-zoom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "2 - 3",
"d3-transition": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/data-urls": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz",
@@ -4689,6 +5127,12 @@
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/dayjs": {
"version": "1.11.19",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
"integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==",
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -4733,6 +5177,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/delaunator": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz",
"integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==",
"license": "ISC",
"dependencies": {
"robust-predicates": "^3.0.2"
}
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
@@ -4891,7 +5344,6 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -5853,7 +6305,6 @@
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
@@ -5905,6 +6356,24 @@
"integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
"license": "MIT"
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/interval-tree-1d": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/interval-tree-1d/-/interval-tree-1d-1.0.4.tgz",
"integrity": "sha512-wY8QJH+6wNI0uh4pDQzMvl+478Qh7Rl4qLmqiluxALlNvl+I+o5x38Pw3/z7mDPTPS1dQalZJXsmbvxx5gclhQ==",
"license": "MIT",
"dependencies": {
"binary-search-bounds": "^2.0.0"
}
},
"node_modules/is-alphabetical": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
@@ -6044,13 +6513,18 @@
"dev": true,
"license": "ISC"
},
"node_modules/isoformat": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/isoformat/-/isoformat-0.2.1.tgz",
"integrity": "sha512-tFLRAygk9NqrRPhJSnNGh7g7oaVWDwR0wKh/GM2LgmPa50Eg4UfyaCO4I8k6EqJHl1/uh2RAD6g06n5ygEnrjQ==",
"license": "ISC"
},
"node_modules/jiti": {
"version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@@ -6109,7 +6583,6 @@
"integrity": "sha512-GtldT42B8+jefDUC4yUKAvsaOrH7PDHmZxZXNgF2xMmymjUbRYJvpAybZAKEmXDGTM0mCsz8duOa4vTm5AY2Kg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@acemir/cssom": "^0.9.28",
"@asamuzakjp/dom-selector": "^6.7.6",
@@ -7659,7 +8132,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -7900,7 +8372,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -7913,7 +8384,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -8346,6 +8816,12 @@
"node": ">=0.10.0"
}
},
"node_modules/robust-predicates": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz",
"integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==",
"license": "Unlicense"
},
"node_modules/rollup": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz",
@@ -8412,11 +8888,16 @@
"queue-microtask": "^1.2.2"
}
},
"node_modules/rw": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
"license": "BSD-3-Clause"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true,
"license": "MIT"
},
"node_modules/saxes": {
@@ -8729,6 +9210,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/tabbable": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz",
"integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==",
"license": "MIT"
},
"node_modules/tailwind-merge": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz",
@@ -8858,7 +9345,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -8995,23 +9481,6 @@
"node": ">= 0.8.0"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/tsx/node_modules/@esbuild/aix-ppc64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
@@ -9742,7 +10211,6 @@
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
@@ -9818,7 +10286,6 @@
"integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/expect": "4.0.16",
"@vitest/mocker": "4.0.16",
@@ -10375,7 +10842,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -10389,7 +10855,6 @@
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
+4 -1
View File
@@ -1,7 +1,7 @@
{
"name": "proxycast",
"private": true,
"version": "0.35.0",
"version": "0.36.0",
"type": "module",
"repository": {
"type": "git",
@@ -24,6 +24,8 @@
},
"dependencies": {
"@fabianlars/tauri-plugin-oauth": "^2",
"@floating-ui/react": "^0.27.16",
"@observablehq/plot": "^0.6.17",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.2",
@@ -52,6 +54,7 @@
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"dayjs": "^1.11.19",
"i18next": "^25.7.3",
"jotai": "^2.16.1",
"lodash-es": "^4.17.22",
+99 -1
View File
@@ -933,6 +933,25 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
@@ -1254,6 +1273,12 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "embed-resource"
version = "3.0.6"
@@ -3000,6 +3025,15 @@ dependencies = [
"windows-sys 0.48.0",
]
[[package]]
name = "ntapi"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c70f219e21142367c70c0b30c6a9e3a14d55b4d12a204d897fbec83a0363f081"
dependencies = [
"winapi",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -3317,6 +3351,15 @@ version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]]
name = "openssl-src"
version = "300.5.4+3.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a507b3792995dae9b0df8a1c1e3771e8418b7c2d9f0baeba32e6fe8b06c7cb72"
dependencies = [
"cc",
]
[[package]]
name = "openssl-sys"
version = "0.9.111"
@@ -3325,6 +3368,7 @@ checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
dependencies = [
"cc",
"libc",
"openssl-src",
"pkg-config",
"vcpkg",
]
@@ -3810,7 +3854,7 @@ dependencies = [
[[package]]
name = "proxycast"
version = "0.35.0"
version = "0.36.0"
dependencies = [
"anyhow",
"arboard",
@@ -3835,6 +3879,7 @@ dependencies = [
"objc",
"once_cell",
"open",
"openssl",
"parking_lot",
"portable-pty",
"proptest",
@@ -3851,6 +3896,7 @@ dependencies = [
"sha2",
"ssh2",
"subtle",
"sysinfo",
"tar",
"tauri",
"tauri-build",
@@ -3871,6 +3917,7 @@ dependencies = [
"url",
"urlencoding",
"uuid",
"whoami",
"winapi",
"windows 0.56.0",
"winreg 0.52.0",
@@ -4047,6 +4094,26 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
[[package]]
name = "rayon"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -5003,6 +5070,20 @@ dependencies = [
"syn 2.0.113",
]
[[package]]
name = "sysinfo"
version = "0.32.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c33cd241af0f2e9e3b5c32163b873b29956890b5342e6745b917ce9d490f4af"
dependencies = [
"core-foundation-sys",
"libc",
"memchr",
"ntapi",
"rayon",
"windows 0.56.0",
]
[[package]]
name = "system-configuration"
version = "0.6.1"
@@ -6200,6 +6281,12 @@ dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
[[package]]
name = "wasm-bindgen"
version = "0.2.106"
@@ -6427,6 +6514,17 @@ version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]]
name = "whoami"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d"
dependencies = [
"libredox",
"wasite",
"web-sys",
]
[[package]]
name = "winapi"
version = "0.3.9"
+5 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "proxycast"
version = "0.35.0"
version = "0.36.0"
description = "AI API Proxy Desktop App"
authors = ["you"]
edition = "2021"
@@ -15,7 +15,7 @@ crate-type = ["lib", "cdylib", "staticlib"]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["tray-icon", "image-png"] }
tauri = { version = "2", features = ["tray-icon", "image-png", "unstable"] }
tauri-plugin-shell = "2"
tauri-plugin-autostart = "2"
tauri-plugin-dialog = "2"
@@ -69,6 +69,9 @@ hex = "0.4.3"
portable-pty = "0.8"
scopeguard = "1"
ssh2 = "0.9"
openssl = { version = "0.10", features = ["vendored"] }
sysinfo = "0.32"
whoami = "1"
# Platform specific dependencies for browser interceptor
+6
View File
@@ -5,6 +5,12 @@
"windows": ["main"],
"permissions": [
"core:default",
"core:webview:default",
"core:webview:allow-webview-close",
"core:webview:allow-webview-position",
"core:webview:allow-webview-size",
"core:webview:allow-set-webview-position",
"core:webview:allow-set-webview-size",
"shell:allow-open",
"shell:allow-spawn",
"shell:allow-execute",
+1 -1
View File
@@ -1 +1 @@
{"default":{"identifier":"default","description":"Default capabilities for ProxyCast","local":true,"windows":["main"],"permissions":["core:default","shell:allow-open","shell:allow-spawn","shell:allow-execute","shell:allow-kill","shell:allow-stdin-write","dialog:default",{"identifier":"shell:allow-execute","allow":[{"args":true,"name":"binaries/aster-server","sidecar":true}]},{"identifier":"shell:allow-spawn","allow":[{"args":true,"name":"binaries/aster-server","sidecar":true}]}]}}
{"default":{"identifier":"default","description":"Default capabilities for ProxyCast","local":true,"windows":["main"],"permissions":["core:default","core:webview:default","core:webview:allow-webview-close","core:webview:allow-webview-position","core:webview:allow-webview-size","core:webview:allow-set-webview-position","core:webview:allow-set-webview-size","shell:allow-open","shell:allow-spawn","shell:allow-execute","shell:allow-kill","shell:allow-stdin-write","dialog:default",{"identifier":"shell:allow-execute","allow":[{"args":true,"name":"binaries/aster-server","sidecar":true}]},{"identifier":"shell:allow-spawn","allow":[{"args":true,"name":"binaries/aster-server","sidecar":true}]}]}}
+7
View File
@@ -22,6 +22,7 @@ use crate::commands::provider_pool_cmd::{CredentialSyncServiceState, ProviderPoo
use crate::commands::resilience_cmd::ResilienceConfigState;
use crate::commands::skill_cmd::SkillServiceState;
use crate::commands::terminal_cmd::TerminalManagerState;
use crate::commands::webview_cmd::{WebviewManagerState, WebviewManagerWrapper};
use crate::config::{self, Config, ConfigManager, GlobalConfigManager, GlobalConfigManagerState};
use crate::database::{self, DbConnection};
use crate::flow_monitor::{
@@ -136,6 +137,7 @@ pub struct AppStates {
pub model_registry: ModelRegistryState,
pub global_config_manager: GlobalConfigManagerState,
pub terminal_manager: TerminalManagerState,
pub webview_manager: WebviewManagerWrapper,
// 用于 setup hook 的共享实例
pub shared_stats: Arc<parking_lot::RwLock<telemetry::StatsAggregator>>,
pub shared_tokens: Arc<parking_lot::RwLock<telemetry::TokenTracker>>,
@@ -219,6 +221,10 @@ pub fn init_states(config: &Config) -> Result<AppStates, String> {
// 初始化终端管理器状态(延迟初始化,在 setup hook 中完成)
let terminal_manager_state = TerminalManagerState(Arc::new(RwLock::new(None)));
// 初始化 Webview 管理器状态
let webview_manager_state =
WebviewManagerWrapper(Arc::new(RwLock::new(WebviewManagerState::new())));
// 初始化全局配置管理器
let config_path = ConfigManager::default_config_path();
let global_config_manager = GlobalConfigManager::new(config.clone(), config_path);
@@ -262,6 +268,7 @@ pub fn init_states(config: &Config) -> Result<AppStates, String> {
model_registry: model_registry_state,
global_config_manager: global_config_manager_state,
terminal_manager: terminal_manager_state,
webview_manager: webview_manager_state,
shared_stats,
shared_tokens,
shared_logger,
+35
View File
@@ -74,6 +74,7 @@ pub fn run() {
model_registry: model_registry_state,
global_config_manager: global_config_manager_state,
terminal_manager: terminal_manager_state,
webview_manager: webview_manager_state,
shared_stats,
shared_tokens,
shared_logger,
@@ -151,6 +152,7 @@ pub fn run() {
.manage(model_registry_state)
.manage(global_config_manager_state)
.manage(terminal_manager_state)
.manage(webview_manager_state)
.on_window_event(move |window, event| {
// 处理窗口关闭事件
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
@@ -1051,6 +1053,39 @@ pub fn run() {
commands::terminal_cmd::terminal_close,
commands::terminal_cmd::terminal_list_sessions,
commands::terminal_cmd::terminal_get_session,
// Connection commands
commands::connection_cmd::connection_list,
commands::connection_cmd::connection_add,
commands::connection_cmd::connection_update,
commands::connection_cmd::connection_delete,
commands::connection_cmd::connection_get,
commands::connection_cmd::connection_get_config_path,
commands::connection_cmd::connection_get_raw_config,
commands::connection_cmd::connection_save_raw_config,
commands::connection_cmd::connection_test,
commands::connection_cmd::connection_import_ssh_host,
// Sysinfo commands
crate::services::sysinfo_service::get_sysinfo,
crate::services::sysinfo_service::subscribe_sysinfo,
crate::services::sysinfo_service::unsubscribe_sysinfo,
// File browser commands
crate::services::file_browser_service::list_dir,
crate::services::file_browser_service::read_file_preview_cmd,
crate::services::file_browser_service::get_home_dir,
crate::services::file_browser_service::create_file,
crate::services::file_browser_service::create_directory,
crate::services::file_browser_service::delete_file,
crate::services::file_browser_service::rename_file,
crate::services::file_browser_service::get_file_name,
crate::services::file_browser_service::reveal_in_finder,
crate::services::file_browser_service::open_with_default_app,
// Webview commands
commands::webview_cmd::create_webview_panel,
commands::webview_cmd::close_webview_panel,
commands::webview_cmd::navigate_webview_panel,
commands::webview_cmd::resize_webview_panel,
commands::webview_cmd::get_webview_panels,
commands::webview_cmd::focus_webview_panel,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+344
View File
@@ -0,0 +1,344 @@
//! 连接管理 Tauri 命令
//!
//! 提供连接配置的 CRUD 操作接口。
//!
//! ## 命令列表
//! - `connection_list` - 获取所有连接(本地 + SSH 配置)
//! - `connection_add` - 添加新连接
//! - `connection_update` - 更新连接
//! - `connection_delete` - 删除连接
//! - `connection_get_config_path` - 获取配置文件路径
//! - `connection_get_raw_config` - 获取原始配置内容
//! - `connection_save_raw_config` - 保存原始配置内容
use crate::terminal::connections::{
ConnectionConfig, ConnectionConfigManager, ConnectionConfigType, ConnectionListEntry,
};
use serde::{Deserialize, Serialize};
/// 添加连接的请求参数
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AddConnectionRequest {
/// 连接名称
pub name: String,
/// 连接类型
#[serde(rename = "type")]
pub conn_type: ConnectionConfigType,
/// 用户名(SSH)
pub user: Option<String>,
/// 主机名(SSH)
pub host: Option<String>,
/// 端口(SSH)
pub port: Option<u16>,
/// 身份文件
pub identity_file: Option<String>,
/// 跳板机
pub proxy_jump: Option<String>,
/// WSL 发行版
pub wsl_distro: Option<String>,
}
/// 更新连接的请求参数
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateConnectionRequest {
/// 连接名称
pub name: String,
/// 连接配置
pub config: ConnectionConfig,
}
/// 通用操作响应
#[derive(Debug, Serialize)]
pub struct ConnectionResponse {
/// 是否成功
pub success: bool,
/// 错误信息
pub error: Option<String>,
}
impl ConnectionResponse {
pub fn ok() -> Self {
Self {
success: true,
error: None,
}
}
pub fn err(msg: impl Into<String>) -> Self {
Self {
success: false,
error: Some(msg.into()),
}
}
}
/// 获取所有可用连接
///
/// 返回本地连接 + 用户配置 + SSH 配置中的 Host 列表
#[tauri::command]
pub fn connection_list() -> Result<Vec<ConnectionListEntry>, String> {
let manager = ConnectionConfigManager::new();
manager.list_all_connections()
}
/// 添加新连接
#[tauri::command]
pub fn connection_add(request: AddConnectionRequest) -> ConnectionResponse {
let manager = ConnectionConfigManager::new();
// 加载现有配置
let mut config = match manager.load() {
Ok(c) => c,
Err(e) => return ConnectionResponse::err(e),
};
// 检查名称是否已存在
if config.connections.contains_key(&request.name) {
return ConnectionResponse::err(format!("连接 '{}' 已存在", request.name));
}
// 创建新连接配置
let conn_config = ConnectionConfig {
conn_type: request.conn_type,
user: request.user,
host: request.host,
port: request.port,
identity_file: request.identity_file,
identity_files: None,
proxy_jump: request.proxy_jump,
display_order: None,
hidden: None,
wsl_distro: request.wsl_distro,
};
// 添加并保存
config.add(request.name.clone(), conn_config);
match manager.save(&config) {
Ok(_) => {
tracing::info!("[Connection] 添加连接成功: {}", request.name);
ConnectionResponse::ok()
}
Err(e) => ConnectionResponse::err(e),
}
}
/// 更新连接
#[tauri::command]
pub fn connection_update(request: UpdateConnectionRequest) -> ConnectionResponse {
let manager = ConnectionConfigManager::new();
// 加载现有配置
let mut config = match manager.load() {
Ok(c) => c,
Err(e) => return ConnectionResponse::err(e),
};
// 检查连接是否存在
if !config.connections.contains_key(&request.name) {
return ConnectionResponse::err(format!("连接 '{}' 不存在", request.name));
}
// 更新配置
config
.connections
.insert(request.name.clone(), request.config);
match manager.save(&config) {
Ok(_) => {
tracing::info!("[Connection] 更新连接成功: {}", request.name);
ConnectionResponse::ok()
}
Err(e) => ConnectionResponse::err(e),
}
}
/// 删除连接
#[tauri::command]
pub fn connection_delete(name: String) -> ConnectionResponse {
let manager = ConnectionConfigManager::new();
// 加载现有配置
let mut config = match manager.load() {
Ok(c) => c,
Err(e) => return ConnectionResponse::err(e),
};
// 检查连接是否存在
if !config.connections.contains_key(&name) {
return ConnectionResponse::err(format!("连接 '{}' 不存在", name));
}
// 删除并保存
config.remove(&name);
match manager.save(&config) {
Ok(_) => {
tracing::info!("[Connection] 删除连接成功: {}", name);
ConnectionResponse::ok()
}
Err(e) => ConnectionResponse::err(e),
}
}
/// 获取连接配置
#[tauri::command]
pub fn connection_get(name: String) -> Result<Option<ConnectionConfig>, String> {
let manager = ConnectionConfigManager::new();
let config = manager.load()?;
Ok(config.get(&name).cloned())
}
/// 获取配置文件路径
#[tauri::command]
pub fn connection_get_config_path() -> String {
ConnectionConfigManager::default_config_path()
.to_string_lossy()
.to_string()
}
/// 获取原始配置文件内容(用于 JSON 编辑器)
#[tauri::command]
pub fn connection_get_raw_config() -> Result<String, String> {
let manager = ConnectionConfigManager::new();
manager.load_raw()
}
/// 保存原始配置文件内容(用于 JSON 编辑器)
#[tauri::command]
pub fn connection_save_raw_config(content: String) -> ConnectionResponse {
let manager = ConnectionConfigManager::new();
match manager.save_raw(&content) {
Ok(_) => {
tracing::info!("[Connection] 原始配置保存成功");
ConnectionResponse::ok()
}
Err(e) => ConnectionResponse::err(e),
}
}
/// 测试连接(异步)
#[tauri::command]
pub async fn connection_test(name: String) -> ConnectionResponse {
let manager = ConnectionConfigManager::new();
// 加载配置
let config = match manager.load() {
Ok(c) => c,
Err(e) => return ConnectionResponse::err(e),
};
// 获取连接
let conn = match config.get(&name) {
Some(c) => c,
None => return ConnectionResponse::err(format!("连接 '{}' 不存在", name)),
};
// 根据连接类型测试
match conn.conn_type {
ConnectionConfigType::Local => {
// 本地连接总是可用
ConnectionResponse::ok()
}
ConnectionConfigType::Ssh => {
// SSH 连接测试
let host = match &conn.host {
Some(h) => h,
None => return ConnectionResponse::err("SSH 连接缺少主机名"),
};
let port = conn.port.unwrap_or(22);
// 简单的 TCP 连接测试
match tokio::net::TcpStream::connect(format!("{}:{}", host, port)).await {
Ok(_) => {
tracing::info!("[Connection] SSH 连接测试成功: {}:{}", host, port);
ConnectionResponse::ok()
}
Err(e) => {
tracing::warn!("[Connection] SSH 连接测试失败: {} - {}", host, e);
ConnectionResponse::err(format!("连接失败: {}", e))
}
}
}
ConnectionConfigType::Wsl => {
// WSL 连接测试
#[cfg(target_os = "windows")]
{
// 检查 wsl.exe 是否可用
match std::process::Command::new("wsl")
.arg("--list")
.arg("--quiet")
.output()
{
Ok(output) => {
if output.status.success() {
ConnectionResponse::ok()
} else {
ConnectionResponse::err("WSL 命令执行失败")
}
}
Err(e) => ConnectionResponse::err(format!("WSL 不可用: {}", e)),
}
}
#[cfg(not(target_os = "windows"))]
{
ConnectionResponse::err("WSL 仅在 Windows 上可用")
}
}
}
}
/// 导入 SSH 配置中的 Host 到用户配置
#[tauri::command]
pub fn connection_import_ssh_host(host_name: String) -> ConnectionResponse {
let manager = ConnectionConfigManager::new();
// 加载 SSH hosts
let ssh_hosts = manager.load_ssh_hosts();
// 查找指定的 host
let ssh_host = match ssh_hosts.into_iter().find(|h| h.pattern == host_name) {
Some(h) => h,
None => return ConnectionResponse::err(format!("SSH Host '{}' 不存在", host_name)),
};
// 加载用户配置
let mut config = match manager.load() {
Ok(c) => c,
Err(e) => return ConnectionResponse::err(e),
};
// 检查是否已存在
if config.connections.contains_key(&host_name) {
return ConnectionResponse::err(format!("连接 '{}' 已存在", host_name));
}
// 创建连接配置
let conn_config = ConnectionConfig {
conn_type: ConnectionConfigType::Ssh,
user: ssh_host.user,
host: ssh_host.hostname.or(Some(ssh_host.pattern.clone())),
port: ssh_host.port,
identity_file: ssh_host.identity_file,
identity_files: None,
proxy_jump: None,
display_order: None,
hidden: None,
wsl_distro: None,
};
// 添加并保存
config.add(host_name.clone(), conn_config);
match manager.save(&config) {
Ok(_) => {
tracing::info!("[Connection] 导入 SSH Host 成功: {}", host_name);
ConnectionResponse::ok()
}
Err(e) => ConnectionResponse::err(e),
}
}
+2
View File
@@ -4,6 +4,7 @@ pub mod auto_fix_cmd;
pub mod browser_interceptor_cmd;
pub mod config_cmd;
pub mod connect_cmd;
pub mod connection_cmd;
pub mod flow_monitor_cmd;
pub mod injection_cmd;
pub mod kiro_local;
@@ -30,4 +31,5 @@ pub mod terminal_cmd;
pub mod tray_cmd;
pub mod usage_cmd;
pub mod websocket_cmd;
pub mod webview_cmd;
pub mod window_cmd;
+8 -1
View File
@@ -34,19 +34,26 @@ pub struct CreateSessionResponse {
///
/// PTY 使用默认大小 (24x80) 预创建,前端连接后通过 resize 同步实际大小。
///
/// # 参数
/// - `cwd`: 工作目录(可选)
///
/// # 返回
/// - `Ok(CreateSessionResponse)`: 包含会话 ID
/// - `Err(String)`: 错误信息
#[tauri::command]
pub async fn terminal_create_session(
state: State<'_, TerminalManagerState>,
cwd: Option<String>,
) -> 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())?;
let session_id = manager
.create_session_with_options(24, 80, cwd)
.await
.map_err(|e| e.to_string())?;
Ok(CreateSessionResponse { session_id })
}
+304
View File
@@ -0,0 +1,304 @@
//! Webview 管理命令
//!
//! 提供创建和管理独立浏览器窗口的功能。
//! 使用 Tauri 2.x 的 WebviewWindow 创建独立的浏览器窗口。
//!
//! ## 功能
//! - 创建独立的浏览器窗口显示外部 URL
//! - 管理窗口生命周期
//! - 控制窗口位置和大小
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder};
use tokio::sync::RwLock;
/// Webview 面板信息
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebviewPanelInfo {
/// 面板 ID
pub id: String,
/// 当前 URL
pub url: String,
/// 面板标题
pub title: String,
/// X 坐标
pub x: f64,
/// Y 坐标
pub y: f64,
/// 宽度
pub width: f64,
/// 高度
pub height: f64,
}
/// Webview 管理器状态
pub struct WebviewManagerState {
/// 活跃的 webview 面板
panels: HashMap<String, WebviewPanelInfo>,
}
impl WebviewManagerState {
pub fn new() -> Self {
Self {
panels: HashMap::new(),
}
}
}
impl Default for WebviewManagerState {
fn default() -> Self {
Self::new()
}
}
/// Webview 管理器状态包装
pub struct WebviewManagerWrapper(pub Arc<RwLock<WebviewManagerState>>);
/// 创建嵌入式 webview 的请求参数
#[derive(Debug, Deserialize)]
pub struct CreateWebviewRequest {
/// 面板 ID(唯一标识)
pub panel_id: String,
/// 要加载的 URL
pub url: String,
/// 面板标题
pub title: Option<String>,
/// X 坐标(相对于主窗口)
pub x: f64,
/// Y 坐标(相对于主窗口)
pub y: f64,
/// 宽度
pub width: f64,
/// 高度
pub height: f64,
}
/// 创建 webview 面板的响应
#[derive(Debug, Serialize)]
pub struct CreateWebviewResponse {
/// 是否成功
pub success: bool,
/// 面板 ID
pub panel_id: String,
/// 错误信息(如果有)
pub error: Option<String>,
}
/// 创建独立的浏览器窗口
///
/// 使用 Tauri 2.x 的 WebviewWindow 创建独立的浏览器窗口。
#[tauri::command]
pub async fn create_webview_panel(
app: AppHandle,
state: tauri::State<'_, WebviewManagerWrapper>,
request: CreateWebviewRequest,
) -> Result<CreateWebviewResponse, String> {
let panel_id = request.panel_id.clone();
let url = request.url.clone();
let title = request.title.unwrap_or_else(|| "Web Browser".to_string());
tracing::info!(
"[Webview] 创建独立窗口: id={}, url={}, size={}x{}",
panel_id,
url,
request.width,
request.height
);
// 检查是否已存在同 ID 的窗口
{
let manager = state.0.read().await;
if manager.panels.contains_key(&panel_id) {
return Ok(CreateWebviewResponse {
success: false,
panel_id,
error: Some("窗口已存在".to_string()),
});
}
}
// 解析 URL
let webview_url = match url.parse::<url::Url>() {
Ok(parsed_url) => WebviewUrl::External(parsed_url),
Err(e) => {
return Ok(CreateWebviewResponse {
success: false,
panel_id,
error: Some(format!("无效的 URL: {}", e)),
});
}
};
// 创建独立的 WebviewWindow
match WebviewWindowBuilder::new(&app, &panel_id, webview_url)
.title(&title)
.inner_size(request.width, request.height)
.min_inner_size(400.0, 300.0)
.resizable(true)
.center()
.build()
{
Ok(_window) => {
// 记录窗口信息
let mut manager = state.0.write().await;
manager.panels.insert(
panel_id.clone(),
WebviewPanelInfo {
id: panel_id.clone(),
url,
title,
x: 0.0,
y: 0.0,
width: request.width,
height: request.height,
},
);
tracing::info!("[Webview] 独立窗口创建成功: {}", panel_id);
Ok(CreateWebviewResponse {
success: true,
panel_id,
error: None,
})
}
Err(e) => {
tracing::error!("[Webview] 创建独立窗口失败: {}", e);
Ok(CreateWebviewResponse {
success: false,
panel_id,
error: Some(format!("创建窗口失败: {}", e)),
})
}
}
}
/// 关闭浏览器窗口
#[tauri::command]
pub async fn close_webview_panel(
app: AppHandle,
state: tauri::State<'_, WebviewManagerWrapper>,
panel_id: String,
) -> Result<bool, String> {
tracing::info!("[Webview] 尝试关闭窗口: {}", panel_id);
// 获取并关闭窗口
match app.get_webview_window(&panel_id) {
Some(window) => {
tracing::info!("[Webview] 找到窗口: {}", panel_id);
// 关闭窗口
match window.close() {
Ok(_) => {
tracing::info!("[Webview] 窗口已关闭: {}", panel_id);
}
Err(e) => {
tracing::error!("[Webview] 关闭窗口失败: {}", e);
}
}
}
None => {
tracing::warn!("[Webview] 未找到窗口: {}", panel_id);
}
}
// 从状态中移除
let mut manager = state.0.write().await;
manager.panels.remove(&panel_id);
tracing::info!("[Webview] 窗口已从状态中移除: {}", panel_id);
Ok(true)
}
/// 导航到新 URL
#[tauri::command]
pub async fn navigate_webview_panel(
app: AppHandle,
state: tauri::State<'_, WebviewManagerWrapper>,
panel_id: String,
url: String,
) -> Result<bool, String> {
tracing::info!("[Webview] 导航窗口 {} 到: {}", panel_id, url);
// 解析 URL
let parsed_url = url
.parse::<url::Url>()
.map_err(|e| format!("无效的 URL: {}", e))?;
// 获取窗口并导航
if let Some(window) = app.get_webview_window(&panel_id) {
// 使用 eval 来导航
let js = format!("window.location.href = '{}';", parsed_url);
window.eval(&js).map_err(|e| format!("导航失败: {}", e))?;
// 更新状态中的 URL
let mut manager = state.0.write().await;
if let Some(panel) = manager.panels.get_mut(&panel_id) {
panel.url = url;
}
Ok(true)
} else {
Err(format!("窗口不存在: {}", panel_id))
}
}
/// 调整窗口大小(独立窗口不需要位置参数)
#[tauri::command]
pub async fn resize_webview_panel(
app: AppHandle,
state: tauri::State<'_, WebviewManagerWrapper>,
panel_id: String,
_x: f64,
_y: f64,
width: f64,
height: f64,
) -> Result<bool, String> {
tracing::info!(
"[Webview] 调整窗口 {} 大小: size={}x{}",
panel_id,
width,
height
);
// 获取窗口
if let Some(window) = app.get_webview_window(&panel_id) {
// 设置大小
window
.set_size(tauri::LogicalSize::new(width, height))
.map_err(|e| format!("设置大小失败: {}", e))?;
// 更新状态
let mut manager = state.0.write().await;
if let Some(panel) = manager.panels.get_mut(&panel_id) {
panel.width = width;
panel.height = height;
}
Ok(true)
} else {
Err(format!("窗口不存在: {}", panel_id))
}
}
/// 获取所有活跃的浏览器窗口
#[tauri::command]
pub async fn get_webview_panels(
state: tauri::State<'_, WebviewManagerWrapper>,
) -> Result<Vec<WebviewPanelInfo>, String> {
let manager = state.0.read().await;
Ok(manager.panels.values().cloned().collect())
}
/// 聚焦指定的浏览器窗口
#[tauri::command]
pub async fn focus_webview_panel(app: AppHandle, panel_id: String) -> Result<bool, String> {
if let Some(window) = app.get_webview_window(&panel_id) {
window.set_focus().map_err(|e| format!("聚焦失败: {}", e))?;
Ok(true)
} else {
Err(format!("窗口不存在: {}", panel_id))
}
}
+3
View File
@@ -187,6 +187,7 @@ fn arb_config() -> impl Strategy<Value = Config> {
minimize_to_tray: true,
models: crate::config::ModelsConfig::default(),
agent: crate::config::NativeAgentConfig::default(),
language: "zh".to_string(),
})
}
@@ -423,6 +424,7 @@ fn arb_valid_config() -> impl Strategy<Value = Config> {
minimize_to_tray: true,
models: crate::config::ModelsConfig::default(),
agent: crate::config::NativeAgentConfig::default(),
language: "zh".to_string(),
})
}
@@ -470,6 +472,7 @@ fn arb_invalid_config() -> impl Strategy<Value = Config> {
minimize_to_tray: true,
models: crate::config::ModelsConfig::default(),
agent: crate::config::NativeAgentConfig::default(),
language: "zh".to_string(),
};
// 根据类型使配置无效
match invalid_type {
+1
View File
@@ -1176,6 +1176,7 @@ impl Default for Config {
ampcode: AmpConfig::default(),
endpoint_providers: EndpointProvidersConfig::default(),
minimize_to_tray: default_minimize_to_tray(),
language: default_language(),
models: ModelsConfig::default(),
agent: NativeAgentConfig::default(),
}
+6
View File
@@ -21,6 +21,12 @@
- `backup_service.rs` - 备份服务
- `live_sync.rs` - 实时同步服务
- `switch.rs` - 开关服务
- `sysinfo_service.rs` - 系统信息服务(CPU/内存监控)
- `file_browser_service.rs` - 文件浏览器服务(目录列表、文件预览)
- `api_key_provider_service.rs` - API Key Provider 服务
- `kiro_event_service.rs` - Kiro 事件服务
- `machine_id_service.rs` - 机器 ID 服务
- `model_registry_service.rs` - 模型注册表服务
## 更新提醒
@@ -0,0 +1,730 @@
//! 文件浏览器服务
//!
//! 提供文件系统浏览功能
//! 支持目录列表、文件预览等操作
//!
//! # 功能
//! - 列出目录内容
//! - 读取文件预览
//! - 获取文件元信息
//! - 获取文件权限和 MIME 类型
use serde::{Deserialize, Serialize};
use std::fs::{self, Metadata};
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::time::UNIX_EPOCH;
use tracing::{debug, error};
/// 文件条目
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileEntry {
/// 文件名
pub name: String,
/// 完整路径
pub path: String,
/// 是否为目录
#[serde(rename = "isDir")]
pub is_dir: bool,
/// 文件大小(字节)
pub size: u64,
/// 修改时间(Unix 时间戳毫秒)
#[serde(rename = "modifiedAt")]
pub modified_at: u64,
/// 文件类型/扩展名
#[serde(rename = "fileType")]
pub file_type: Option<String>,
/// 是否隐藏文件
#[serde(rename = "isHidden")]
pub is_hidden: bool,
/// 文件权限字符串(如 -rw-r--r--)
#[serde(rename = "modeStr")]
pub mode_str: Option<String>,
/// 文件权限数字(8进制)
pub mode: Option<u32>,
/// MIME 类型
#[serde(rename = "mimeType")]
pub mime_type: Option<String>,
/// 是否为符号链接
#[serde(rename = "isSymlink")]
pub is_symlink: bool,
}
/// 目录列表结果
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DirectoryListing {
/// 当前路径
pub path: String,
/// 父目录路径
#[serde(rename = "parentPath")]
pub parent_path: Option<String>,
/// 文件列表
pub entries: Vec<FileEntry>,
/// 错误信息
pub error: Option<String>,
}
/// 文件预览结果
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FilePreview {
/// 文件路径
pub path: String,
/// 文件内容(文本)
pub content: Option<String>,
/// 是否为二进制文件
#[serde(rename = "isBinary")]
pub is_binary: bool,
/// 文件大小
pub size: u64,
/// 错误信息
pub error: Option<String>,
}
/// 获取文件扩展名
fn get_file_extension(path: &Path) -> Option<String> {
path.extension()
.and_then(|ext| ext.to_str())
.map(|s| s.to_lowercase())
}
/// 判断是否为隐藏文件
fn is_hidden_file(name: &str) -> bool {
name.starts_with('.')
}
/// 将 Unix 文件模式转换为权限字符串(如 -rw-r--r--)
fn mode_to_string(mode: u32, is_dir: bool, is_symlink: bool) -> String {
let mut result = String::with_capacity(10);
// 文件类型
if is_symlink {
result.push('l');
} else if is_dir {
result.push('d');
} else {
result.push('-');
}
// 用户权限
result.push(if mode & 0o400 != 0 { 'r' } else { '-' });
result.push(if mode & 0o200 != 0 { 'w' } else { '-' });
result.push(if mode & 0o100 != 0 { 'x' } else { '-' });
// 组权限
result.push(if mode & 0o040 != 0 { 'r' } else { '-' });
result.push(if mode & 0o020 != 0 { 'w' } else { '-' });
result.push(if mode & 0o010 != 0 { 'x' } else { '-' });
// 其他用户权限
result.push(if mode & 0o004 != 0 { 'r' } else { '-' });
result.push(if mode & 0o002 != 0 { 'w' } else { '-' });
result.push(if mode & 0o001 != 0 { 'x' } else { '-' });
result
}
/// 根据文件扩展名和元数据获取 MIME 类型
fn get_mime_type(path: &Path, metadata: &Metadata) -> String {
// 特殊类型检测
if metadata.is_dir() {
return "directory".to_string();
}
#[cfg(unix)]
{
use std::os::unix::fs::FileTypeExt;
let ft = metadata.file_type();
if ft.is_symlink() {
return "symlink".to_string();
}
if ft.is_block_device() {
return "block-device".to_string();
}
if ft.is_char_device() {
return "char-device".to_string();
}
if ft.is_fifo() {
return "pipe".to_string();
}
if ft.is_socket() {
return "socket".to_string();
}
}
// 基于扩展名的 MIME 类型映射
let ext = path
.extension()
.and_then(|e| e.to_str())
.map(|s| s.to_lowercase());
match ext.as_deref() {
// 文本文件
Some("txt") => "text/plain",
Some("md" | "markdown") => "text/markdown",
Some("html" | "htm") => "text/html",
Some("css") => "text/css",
Some("xml") => "text/xml",
Some("csv") => "text/csv",
// 代码文件
Some("js" | "mjs" | "cjs") => "text/javascript",
Some("ts" | "mts" | "cts") => "text/typescript",
Some("tsx") => "text/tsx",
Some("jsx") => "text/jsx",
Some("json") => "application/json",
Some("yaml" | "yml") => "text/yaml",
Some("toml") => "text/toml",
Some("rs") => "text/x-rust",
Some("py") => "text/x-python",
Some("go") => "text/x-go",
Some("java") => "text/x-java",
Some("c") => "text/x-c",
Some("cpp" | "cc" | "cxx") => "text/x-c++",
Some("h" | "hpp") => "text/x-c-header",
Some("sh" | "bash" | "zsh") => "text/x-shellscript",
Some("sql") => "text/x-sql",
Some("vue") => "text/x-vue",
Some("svelte") => "text/x-svelte",
Some("swift") => "text/x-swift",
Some("kt" | "kts") => "text/x-kotlin",
Some("rb") => "text/x-ruby",
Some("php") => "text/x-php",
Some("lua") => "text/x-lua",
// 图片
Some("png") => "image/png",
Some("jpg" | "jpeg") => "image/jpeg",
Some("gif") => "image/gif",
Some("webp") => "image/webp",
Some("svg") => "image/svg+xml",
Some("ico") => "image/x-icon",
Some("bmp") => "image/bmp",
Some("tiff" | "tif") => "image/tiff",
// 音频
Some("mp3") => "audio/mpeg",
Some("wav") => "audio/wav",
Some("ogg") => "audio/ogg",
Some("flac") => "audio/flac",
Some("aac") => "audio/aac",
Some("m4a") => "audio/mp4",
// 视频
Some("mp4") => "video/mp4",
Some("webm") => "video/webm",
Some("avi") => "video/x-msvideo",
Some("mov") => "video/quicktime",
Some("mkv") => "video/x-matroska",
Some("wmv") => "video/x-ms-wmv",
// 压缩文件
Some("zip") => "application/zip",
Some("tar") => "application/x-tar",
Some("gz" | "gzip") => "application/gzip",
Some("bz2") => "application/x-bzip2",
Some("xz") => "application/x-xz",
Some("7z") => "application/x-7z-compressed",
Some("rar") => "application/vnd.rar",
// 文档
Some("pdf") => "application/pdf",
Some("doc") => "application/msword",
Some("docx") => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
Some("xls") => "application/vnd.ms-excel",
Some("xlsx") => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
Some("ppt") => "application/vnd.ms-powerpoint",
Some("pptx") => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
// 可执行文件
Some("exe") => "application/x-msdownload",
Some("dmg") => "application/x-apple-diskimage",
Some("app") => "application/x-apple-application",
Some("deb") => "application/x-debian-package",
Some("rpm") => "application/x-rpm",
// 字体
Some("ttf") => "font/ttf",
Some("otf") => "font/otf",
Some("woff") => "font/woff",
Some("woff2") => "font/woff2",
// 其他
Some("wasm") => "application/wasm",
_ => "application/octet-stream",
}
.to_string()
}
/// 判断是否为文本文件(基于扩展名)
fn is_text_file(extension: Option<&str>) -> bool {
match extension {
Some(ext) => matches!(
ext,
"txt"
| "md"
| "json"
| "yaml"
| "yml"
| "toml"
| "xml"
| "html"
| "htm"
| "css"
| "js"
| "ts"
| "tsx"
| "jsx"
| "rs"
| "py"
| "go"
| "java"
| "c"
| "cpp"
| "h"
| "hpp"
| "sh"
| "bash"
| "zsh"
| "fish"
| "sql"
| "graphql"
| "vue"
| "svelte"
| "astro"
| "log"
| "env"
| "gitignore"
| "dockerignore"
| "editorconfig"
| "prettierrc"
| "eslintrc"
| "babelrc"
| "conf"
| "cfg"
| "ini"
| "properties"
),
None => false,
}
}
/// 列出目录内容
pub fn list_directory(path: &str) -> DirectoryListing {
let path_buf = if path.is_empty() || path == "~" {
dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"))
} else if path.starts_with('~') {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"));
home.join(&path[2..])
} else {
PathBuf::from(path)
};
let canonical_path = match path_buf.canonicalize() {
Ok(p) => p,
Err(e) => {
error!("无法解析路径 {}: {}", path, e);
return DirectoryListing {
path: path.to_string(),
parent_path: None,
entries: vec![],
error: Some(format!("无法解析路径: {}", e)),
};
}
};
let parent_path = canonical_path
.parent()
.map(|p| p.to_string_lossy().to_string());
let entries = match fs::read_dir(&canonical_path) {
Ok(read_dir) => {
let mut entries: Vec<FileEntry> = read_dir
.filter_map(|entry| {
let entry = entry.ok()?;
// 先获取符号链接信息
let symlink_metadata = entry.metadata().ok();
let is_symlink = entry
.file_type()
.ok()
.map(|ft| ft.is_symlink())
.unwrap_or(false);
// 获取真实文件的元数据(解析符号链接)
let metadata = if is_symlink {
fs::metadata(entry.path()).ok().or(symlink_metadata)?
} else {
symlink_metadata?
};
let name = entry.file_name().to_string_lossy().to_string();
let path = entry.path();
let modified_at = metadata
.modified()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
let file_type = if metadata.is_dir() {
Some("folder".to_string())
} else {
get_file_extension(&path)
};
// 获取文件权限
let mode = metadata.permissions().mode();
let mode_str = mode_to_string(mode & 0o777, metadata.is_dir(), is_symlink);
// 获取 MIME 类型
let mime_type = get_mime_type(&path, &metadata);
Some(FileEntry {
name: name.clone(),
path: path.to_string_lossy().to_string(),
is_dir: metadata.is_dir(),
size: metadata.len(),
modified_at,
file_type,
is_hidden: is_hidden_file(&name),
mode_str: Some(mode_str),
mode: Some(mode & 0o777),
mime_type: Some(mime_type),
is_symlink,
})
})
.collect();
// 排序:目录在前,然后按名称排序
entries.sort_by(|a, b| match (a.is_dir, b.is_dir) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
});
entries
}
Err(e) => {
error!("无法读取目录 {}: {}", canonical_path.display(), e);
return DirectoryListing {
path: canonical_path.to_string_lossy().to_string(),
parent_path,
entries: vec![],
error: Some(format!("无法读取目录: {}", e)),
};
}
};
debug!(
"列出目录 {}: {} 个条目",
canonical_path.display(),
entries.len()
);
DirectoryListing {
path: canonical_path.to_string_lossy().to_string(),
parent_path,
entries,
error: None,
}
}
/// 读取文件预览
pub fn read_file_preview(path: &str, max_size: Option<usize>) -> FilePreview {
let max_size = max_size.unwrap_or(100 * 1024); // 默认 100KB
let path_buf = PathBuf::from(path);
let metadata = match fs::metadata(&path_buf) {
Ok(m) => m,
Err(e) => {
return FilePreview {
path: path.to_string(),
content: None,
is_binary: false,
size: 0,
error: Some(format!("无法读取文件元信息: {}", e)),
};
}
};
if metadata.is_dir() {
return FilePreview {
path: path.to_string(),
content: None,
is_binary: false,
size: 0,
error: Some("不能预览目录".to_string()),
};
}
let size = metadata.len();
let extension = get_file_extension(&path_buf);
let is_text = is_text_file(extension.as_deref());
if !is_text {
return FilePreview {
path: path.to_string(),
content: None,
is_binary: true,
size,
error: None,
};
}
// 读取文件内容
let content = match fs::read(&path_buf) {
Ok(bytes) => {
let bytes_to_read = bytes.len().min(max_size);
match String::from_utf8(bytes[..bytes_to_read].to_vec()) {
Ok(s) => Some(s),
Err(_) => {
return FilePreview {
path: path.to_string(),
content: None,
is_binary: true,
size,
error: None,
};
}
}
}
Err(e) => {
return FilePreview {
path: path.to_string(),
content: None,
is_binary: false,
size,
error: Some(format!("无法读取文件: {}", e)),
};
}
};
FilePreview {
path: path.to_string(),
content,
is_binary: false,
size,
error: None,
}
}
/// Tauri 命令:列出目录
#[tauri::command]
pub async fn list_dir(path: String) -> Result<DirectoryListing, String> {
Ok(list_directory(&path))
}
/// Tauri 命令:读取文件预览
#[tauri::command]
pub async fn read_file_preview_cmd(
path: String,
max_size: Option<usize>,
) -> Result<FilePreview, String> {
Ok(read_file_preview(&path, max_size))
}
/// Tauri 命令:获取用户主目录
#[tauri::command]
pub async fn get_home_dir() -> Result<String, String> {
dirs::home_dir()
.map(|p| p.to_string_lossy().to_string())
.ok_or_else(|| "无法获取主目录".to_string())
}
/// Tauri 命令:创建新文件
#[tauri::command]
pub async fn create_file(path: String) -> Result<(), String> {
let path_buf = PathBuf::from(&path);
// 检查文件是否已存在
if path_buf.exists() {
return Err("文件已存在".to_string());
}
// 确保父目录存在
if let Some(parent) = path_buf.parent() {
if !parent.exists() {
fs::create_dir_all(parent).map_err(|e| format!("无法创建父目录: {}", e))?;
}
}
// 创建空文件
fs::File::create(&path_buf).map_err(|e| format!("无法创建文件: {}", e))?;
debug!("创建文件: {}", path);
Ok(())
}
/// Tauri 命令:创建新目录
#[tauri::command]
pub async fn create_directory(path: String) -> Result<(), String> {
let path_buf = PathBuf::from(&path);
// 检查目录是否已存在
if path_buf.exists() {
return Err("目录已存在".to_string());
}
fs::create_dir_all(&path_buf).map_err(|e| format!("无法创建目录: {}", e))?;
debug!("创建目录: {}", path);
Ok(())
}
/// Tauri 命令:删除文件或目录
#[tauri::command]
pub async fn delete_file(path: String, recursive: bool) -> Result<(), String> {
let path_buf = PathBuf::from(&path);
if !path_buf.exists() {
return Err("文件或目录不存在".to_string());
}
if path_buf.is_dir() {
if recursive {
fs::remove_dir_all(&path_buf).map_err(|e| format!("无法删除目录: {}", e))?;
} else {
fs::remove_dir(&path_buf)
.map_err(|e| format!("无法删除目录(目录非空,需要递归删除): {}", e))?;
}
debug!("删除目录: {}", path);
} else {
fs::remove_file(&path_buf).map_err(|e| format!("无法删除文件: {}", e))?;
debug!("删除文件: {}", path);
}
Ok(())
}
/// Tauri 命令:重命名文件或目录
#[tauri::command]
pub async fn rename_file(old_path: String, new_path: String) -> Result<(), String> {
let old_path_buf = PathBuf::from(&old_path);
let new_path_buf = PathBuf::from(&new_path);
if !old_path_buf.exists() {
return Err("源文件或目录不存在".to_string());
}
if new_path_buf.exists() {
return Err("目标文件或目录已存在".to_string());
}
fs::rename(&old_path_buf, &new_path_buf).map_err(|e| format!("无法重命名: {}", e))?;
debug!("重命名: {} -> {}", old_path, new_path);
Ok(())
}
/// Tauri 命令:复制文件名到剪贴板(返回文件名供前端处理)
#[tauri::command]
pub async fn get_file_name(path: String) -> Result<String, String> {
let path_buf = PathBuf::from(&path);
path_buf
.file_name()
.and_then(|n| n.to_str())
.map(|s| s.to_string())
.ok_or_else(|| "无法获取文件名".to_string())
}
/// Tauri 命令:在 Finder 中显示文件
#[tauri::command]
pub async fn reveal_in_finder(path: String) -> Result<(), String> {
let path_buf = PathBuf::from(&path);
if !path_buf.exists() {
return Err("文件或目录不存在".to_string());
}
#[cfg(target_os = "macos")]
{
std::process::Command::new("open")
.args(["-R", &path])
.spawn()
.map_err(|e| format!("无法打开 Finder: {}", e))?;
}
#[cfg(target_os = "windows")]
{
std::process::Command::new("explorer")
.args(["/select,", &path])
.spawn()
.map_err(|e| format!("无法打开资源管理器: {}", e))?;
}
#[cfg(target_os = "linux")]
{
// 尝试使用 xdg-open 打开父目录
let parent = path_buf.parent().unwrap_or(&path_buf);
std::process::Command::new("xdg-open")
.arg(parent)
.spawn()
.map_err(|e| format!("无法打开文件管理器: {}", e))?;
}
Ok(())
}
/// Tauri 命令:使用默认应用打开文件
#[tauri::command]
pub async fn open_with_default_app(path: String) -> Result<(), String> {
let path_buf = PathBuf::from(&path);
if !path_buf.exists() {
return Err("文件不存在".to_string());
}
#[cfg(target_os = "macos")]
{
std::process::Command::new("open")
.arg(&path)
.spawn()
.map_err(|e| format!("无法打开文件: {}", e))?;
}
#[cfg(target_os = "windows")]
{
std::process::Command::new("cmd")
.args(["/C", "start", "", &path])
.spawn()
.map_err(|e| format!("无法打开文件: {}", e))?;
}
#[cfg(target_os = "linux")]
{
std::process::Command::new("xdg-open")
.arg(&path)
.spawn()
.map_err(|e| format!("无法打开文件: {}", e))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_list_home_directory() {
let result = list_directory("~");
assert!(result.error.is_none());
assert!(!result.entries.is_empty());
}
#[test]
fn test_is_hidden_file() {
assert!(is_hidden_file(".gitignore"));
assert!(is_hidden_file(".config"));
assert!(!is_hidden_file("readme.md"));
}
#[test]
fn test_is_text_file() {
assert!(is_text_file(Some("txt")));
assert!(is_text_file(Some("rs")));
assert!(is_text_file(Some("json")));
assert!(!is_text_file(Some("png")));
assert!(!is_text_file(Some("exe")));
assert!(!is_text_file(None));
}
}
+2
View File
@@ -1,5 +1,6 @@
pub mod api_key_provider_service;
pub mod backup_service;
pub mod file_browser_service;
pub mod kiro_event_service;
pub mod live_sync;
pub mod machine_id_service;
@@ -11,5 +12,6 @@ pub mod prompt_sync;
pub mod provider_pool_service;
pub mod skill_service;
pub mod switch;
pub mod sysinfo_service;
pub mod token_cache_service;
pub mod usage_service;
+201
View File
@@ -0,0 +1,201 @@
//! 系统信息服务
//!
//! 提供 CPU、内存等系统信息的获取和订阅功能
//! 用于系统监控视图的数据源
//!
//! # 功能
//! - 获取当前系统信息快照
//! - 订阅系统信息更新(每秒推送)
//!
//! # 依赖
//! - sysinfo crate
use serde::{Deserialize, Serialize};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use sysinfo::{CpuRefreshKind, MemoryRefreshKind, RefreshKind, System};
use tauri::{AppHandle, Emitter};
use tokio::sync::Mutex;
use tokio::time::{interval, Duration};
use tracing::{debug, error, info};
/// 系统信息数据点
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SysinfoData {
/// 时间戳(毫秒)
pub ts: u64,
/// 总 CPU 使用率(0-100)
pub cpu: f32,
/// 已用内存(GB)
#[serde(rename = "mem:used")]
pub mem_used: f64,
/// 总内存(GB)
#[serde(rename = "mem:total")]
pub mem_total: f64,
/// 各核心 CPU 使用率
#[serde(flatten)]
pub cpu_cores: std::collections::HashMap<String, f32>,
}
/// 系统信息服务状态
pub struct SysinfoService {
/// 是否正在运行订阅
running: AtomicBool,
/// 系统信息实例
system: Mutex<System>,
}
impl SysinfoService {
/// 创建新的系统信息服务
pub fn new() -> Self {
let system = System::new_with_specifics(
RefreshKind::new()
.with_cpu(CpuRefreshKind::everything())
.with_memory(MemoryRefreshKind::everything()),
);
Self {
running: AtomicBool::new(false),
system: Mutex::new(system),
}
}
/// 获取当前系统信息快照
pub async fn get_sysinfo(&self) -> SysinfoData {
let mut system = self.system.lock().await;
// 刷新 CPU 和内存信息
system.refresh_cpu_usage();
system.refresh_memory();
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
// 计算总 CPU 使用率
let cpu = system.global_cpu_usage();
// 内存信息(转换为 GB)
let mem_total = system.total_memory() as f64 / 1024.0 / 1024.0 / 1024.0;
let mem_used = system.used_memory() as f64 / 1024.0 / 1024.0 / 1024.0;
// 各核心 CPU 使用率
let mut cpu_cores = std::collections::HashMap::new();
for (i, cpu) in system.cpus().iter().enumerate() {
cpu_cores.insert(format!("cpu:{}", i), cpu.cpu_usage());
}
SysinfoData {
ts,
cpu,
mem_used,
mem_total,
cpu_cores,
}
}
/// 检查是否正在运行
pub fn is_running(&self) -> bool {
self.running.load(Ordering::SeqCst)
}
/// 停止订阅
pub fn stop(&self) {
self.running.store(false, Ordering::SeqCst);
info!("系统信息订阅已停止");
}
}
impl Default for SysinfoService {
fn default() -> Self {
Self::new()
}
}
/// 全局系统信息服务实例
static SYSINFO_SERVICE: once_cell::sync::Lazy<Arc<SysinfoService>> =
once_cell::sync::Lazy::new(|| Arc::new(SysinfoService::new()));
/// 获取全局系统信息服务
pub fn get_sysinfo_service() -> Arc<SysinfoService> {
SYSINFO_SERVICE.clone()
}
/// Tauri 命令:获取当前系统信息
#[tauri::command]
pub async fn get_sysinfo() -> Result<SysinfoData, String> {
let service = get_sysinfo_service();
Ok(service.get_sysinfo().await)
}
/// Tauri 命令:开始订阅系统信息
/// 每秒向前端发送 sysinfo 事件
#[tauri::command]
pub async fn subscribe_sysinfo(app: AppHandle) -> Result<(), String> {
let service = get_sysinfo_service();
// 如果已经在运行,直接返回
if service.is_running() {
debug!("系统信息订阅已在运行");
return Ok(());
}
service.running.store(true, Ordering::SeqCst);
info!("开始系统信息订阅");
// 启动后台任务
let service_clone = service.clone();
tokio::spawn(async move {
let mut ticker = interval(Duration::from_secs(1));
// 首次需要等待一下让 CPU 使用率计算准确
ticker.tick().await;
while service_clone.is_running() {
ticker.tick().await;
if !service_clone.is_running() {
break;
}
let data = service_clone.get_sysinfo().await;
if let Err(e) = app.emit("sysinfo", &data) {
error!("发送系统信息事件失败: {}", e);
}
}
info!("系统信息订阅任务结束");
});
Ok(())
}
/// Tauri 命令:停止订阅系统信息
#[tauri::command]
pub async fn unsubscribe_sysinfo() -> Result<(), String> {
let service = get_sysinfo_service();
service.stop();
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_get_sysinfo() {
let service = SysinfoService::new();
// 等待一下让 CPU 数据准确
tokio::time::sleep(Duration::from_millis(100)).await;
let data = service.get_sysinfo().await;
assert!(data.ts > 0);
assert!(data.cpu >= 0.0 && data.cpu <= 100.0);
assert!(data.mem_total > 0.0);
assert!(data.mem_used >= 0.0);
assert!(data.mem_used <= data.mem_total);
}
}
@@ -0,0 +1,588 @@
//! 连接配置管理
//!
//! 管理用户保存的连接配置,支持本地和 SSH 连接。
//! 配置存储在 `~/.config/proxycast/connections.json`。
//!
//! ## 功能
//! - 加载/保存连接配置文件
//! - 读取系统 SSH 配置 (~/.ssh/config) 中的 Host 列表
//! - 合并用户配置和系统 SSH 配置
//!
//! ## 配置文件格式
//! ```json
//! {
//! "connections": {
//! "my-server": {
//! "type": "ssh",
//! "user": "root",
//! "host": "192.168.1.100",
//! "port": 22,
//! "identityFile": "~/.ssh/id_rsa"
//! }
//! }
//! }
//! ```
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use super::SSHConfigParser;
/// 连接类型
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum ConnectionConfigType {
/// 本地终端
Local,
/// SSH 远程连接
Ssh,
/// WSL 连接
Wsl,
}
impl Default for ConnectionConfigType {
fn default() -> Self {
Self::Local
}
}
/// 单个连接配置
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConnectionConfig {
/// 连接类型
#[serde(rename = "type", default)]
pub conn_type: ConnectionConfigType,
/// SSH 用户名
#[serde(skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
/// SSH 主机名或 IP
#[serde(skip_serializing_if = "Option::is_none")]
pub host: Option<String>,
/// SSH 端口(默认 22)
#[serde(skip_serializing_if = "Option::is_none")]
pub port: Option<u16>,
/// 身份文件路径(私钥)
#[serde(skip_serializing_if = "Option::is_none")]
pub identity_file: Option<String>,
/// 身份文件列表(多个私钥)
#[serde(skip_serializing_if = "Option::is_none")]
pub identity_files: Option<Vec<String>>,
/// 跳板机配置 (ProxyJump)
#[serde(skip_serializing_if = "Option::is_none")]
pub proxy_jump: Option<String>,
/// 显示顺序
#[serde(skip_serializing_if = "Option::is_none")]
pub display_order: Option<i32>,
/// 是否隐藏
#[serde(skip_serializing_if = "Option::is_none")]
pub hidden: Option<bool>,
/// WSL 发行版名称
#[serde(skip_serializing_if = "Option::is_none")]
pub wsl_distro: Option<String>,
}
impl Default for ConnectionConfig {
fn default() -> Self {
Self {
conn_type: ConnectionConfigType::Local,
user: None,
host: None,
port: None,
identity_file: None,
identity_files: None,
proxy_jump: None,
display_order: None,
hidden: None,
wsl_distro: None,
}
}
}
impl ConnectionConfig {
/// 创建本地连接配置
pub fn local() -> Self {
Self {
conn_type: ConnectionConfigType::Local,
..Default::default()
}
}
/// 创建 SSH 连接配置
pub fn ssh(host: impl Into<String>) -> Self {
Self {
conn_type: ConnectionConfigType::Ssh,
host: Some(host.into()),
..Default::default()
}
}
/// 设置用户名
pub fn with_user(mut self, user: impl Into<String>) -> Self {
self.user = Some(user.into());
self
}
/// 设置端口
pub fn with_port(mut self, port: u16) -> Self {
self.port = Some(port);
self
}
/// 设置身份文件
pub fn with_identity_file(mut self, path: impl Into<String>) -> Self {
self.identity_file = Some(path.into());
self
}
}
/// 连接配置文件结构
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ConnectionsFile {
/// 连接配置映射(名称 -> 配置)
#[serde(default)]
pub connections: HashMap<String, ConnectionConfig>,
}
impl ConnectionsFile {
/// 创建空的配置文件
pub fn new() -> Self {
Self::default()
}
/// 添加连接
pub fn add(&mut self, name: impl Into<String>, config: ConnectionConfig) {
self.connections.insert(name.into(), config);
}
/// 移除连接
pub fn remove(&mut self, name: &str) -> Option<ConnectionConfig> {
self.connections.remove(name)
}
/// 获取连接
pub fn get(&self, name: &str) -> Option<&ConnectionConfig> {
self.connections.get(name)
}
/// 获取所有连接名称
pub fn names(&self) -> Vec<String> {
self.connections.keys().cloned().collect()
}
}
/// 连接配置管理器
pub struct ConnectionConfigManager {
/// 配置文件路径
config_path: PathBuf,
}
impl ConnectionConfigManager {
/// 创建配置管理器
pub fn new() -> Self {
Self {
config_path: Self::default_config_path(),
}
}
/// 使用自定义路径创建
pub fn with_path(path: PathBuf) -> Self {
Self { config_path: path }
}
/// 获取默认配置文件路径
pub fn default_config_path() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("proxycast")
.join("connections.json")
}
/// 获取配置文件路径
pub fn config_path(&self) -> &PathBuf {
&self.config_path
}
/// 加载连接配置
pub fn load(&self) -> Result<ConnectionsFile, String> {
if !self.config_path.exists() {
tracing::info!(
"[ConnectionConfig] 配置文件不存在,返回空配置: {:?}",
self.config_path
);
return Ok(ConnectionsFile::new());
}
let content = fs::read_to_string(&self.config_path)
.map_err(|e| format!("读取配置文件失败: {}", e))?;
serde_json::from_str(&content).map_err(|e| format!("解析配置文件失败: {}", e))
}
/// 保存连接配置
pub fn save(&self, config: &ConnectionsFile) -> Result<(), String> {
// 确保父目录存在
if let Some(parent) = self.config_path.parent() {
if !parent.exists() {
fs::create_dir_all(parent).map_err(|e| format!("创建配置目录失败: {}", e))?;
}
}
let content =
serde_json::to_string_pretty(config).map_err(|e| format!("序列化配置失败: {}", e))?;
fs::write(&self.config_path, content).map_err(|e| format!("写入配置文件失败: {}", e))?;
tracing::info!("[ConnectionConfig] 配置已保存: {:?}", self.config_path);
Ok(())
}
/// 保存原始 JSON 内容
pub fn save_raw(&self, content: &str) -> Result<(), String> {
// 先验证 JSON 格式
let _: ConnectionsFile =
serde_json::from_str(content).map_err(|e| format!("无效的 JSON 格式: {}", e))?;
// 确保父目录存在
if let Some(parent) = self.config_path.parent() {
if !parent.exists() {
fs::create_dir_all(parent).map_err(|e| format!("创建配置目录失败: {}", e))?;
}
}
fs::write(&self.config_path, content).map_err(|e| format!("写入配置文件失败: {}", e))?;
tracing::info!("[ConnectionConfig] 原始配置已保存: {:?}", self.config_path);
Ok(())
}
/// 获取原始配置文件内容
pub fn load_raw(&self) -> Result<String, String> {
if !self.config_path.exists() {
// 返回默认空配置
return Ok(r#"{
"connections": {}
}"#
.to_string());
}
fs::read_to_string(&self.config_path).map_err(|e| format!("读取配置文件失败: {}", e))
}
/// 从系统 SSH 配置读取 Host 列表
pub fn load_ssh_hosts(&self) -> Vec<SSHHostEntry> {
let config_path = match SSHConfigParser::default_config_path() {
Some(path) => path,
None => return vec![],
};
if !config_path.exists() {
return vec![];
}
let content = match fs::read_to_string(&config_path) {
Ok(c) => c,
Err(e) => {
tracing::warn!("[ConnectionConfig] 读取 SSH 配置失败: {}", e);
return vec![];
}
};
Self::parse_ssh_config_hosts(&content)
}
/// 解析 SSH 配置文件中的 Host 列表
fn parse_ssh_config_hosts(content: &str) -> Vec<SSHHostEntry> {
let mut hosts = Vec::new();
let mut current_host: Option<SSHHostEntry> = None;
for line in content.lines() {
let line = line.trim();
// 跳过空行和注释
if line.is_empty() || line.starts_with('#') {
continue;
}
// 分割 key value
let parts: Vec<&str> = line.splitn(2, char::is_whitespace).collect();
if parts.len() < 2 {
continue;
}
let key = parts[0].to_lowercase();
let value = parts[1].trim();
match key.as_str() {
"host" => {
// 保存之前的 Host
if let Some(host) = current_host.take() {
// 排除通配符 Host
if !host.pattern.contains('*') && !host.pattern.contains('?') {
hosts.push(host);
}
}
// 开始新的 Host
current_host = Some(SSHHostEntry {
pattern: value.to_string(),
hostname: None,
user: None,
port: None,
identity_file: None,
});
}
"hostname" => {
if let Some(ref mut host) = current_host {
host.hostname = Some(value.to_string());
}
}
"user" => {
if let Some(ref mut host) = current_host {
host.user = Some(value.to_string());
}
}
"port" => {
if let Some(ref mut host) = current_host {
host.port = value.parse().ok();
}
}
"identityfile" => {
if let Some(ref mut host) = current_host {
host.identity_file = Some(value.to_string());
}
}
_ => {}
}
}
// 保存最后一个 Host
if let Some(host) = current_host {
if !host.pattern.contains('*') && !host.pattern.contains('?') {
hosts.push(host);
}
}
hosts
}
/// 获取所有可用连接(用户配置 + SSH 配置)
pub fn list_all_connections(&self) -> Result<Vec<ConnectionListEntry>, String> {
let mut entries = Vec::new();
// 获取本地系统信息
let local_user = whoami::username();
let local_host = whoami::fallible::hostname().unwrap_or_else(|_| "localhost".to_string());
let local_label = format!("{}@{}", local_user, local_host);
// 添加本地连接
entries.push(ConnectionListEntry {
name: "local".to_string(),
conn_type: ConnectionConfigType::Local,
label: local_label,
source: ConnectionSource::BuiltIn,
host: Some(local_host),
user: Some(local_user),
port: None,
});
// 加载用户配置
let config = self.load()?;
for (name, conn) in config.connections {
if conn.hidden == Some(true) {
continue;
}
let label = match conn.conn_type {
ConnectionConfigType::Ssh => {
let user = conn.user.as_deref().unwrap_or("user");
let host = conn.host.as_deref().unwrap_or("unknown");
let port = conn.port.unwrap_or(22);
if port == 22 {
format!("{}@{}", user, host)
} else {
format!("{}@{}:{}", user, host, port)
}
}
ConnectionConfigType::Wsl => {
let distro = conn.wsl_distro.as_deref().unwrap_or("default");
format!("WSL: {}", distro)
}
ConnectionConfigType::Local => "Local".to_string(),
};
entries.push(ConnectionListEntry {
name: name.clone(),
conn_type: conn.conn_type.clone(),
label,
source: ConnectionSource::UserConfig,
host: conn.host,
user: conn.user,
port: conn.port,
});
}
// 加载 SSH 配置
let ssh_hosts = self.load_ssh_hosts();
for host in ssh_hosts {
// 检查是否已在用户配置中
let already_exists = entries.iter().any(|e| e.name == host.pattern);
if already_exists {
continue;
}
let label = if let Some(ref user) = host.user {
if let Some(ref hostname) = host.hostname {
format!("{}@{}", user, hostname)
} else {
format!("{}@{}", user, host.pattern)
}
} else if let Some(ref hostname) = host.hostname {
hostname.clone()
} else {
host.pattern.clone()
};
entries.push(ConnectionListEntry {
name: host.pattern.clone(),
conn_type: ConnectionConfigType::Ssh,
label,
source: ConnectionSource::SSHConfig,
host: host.hostname,
user: host.user,
port: host.port,
});
}
Ok(entries)
}
}
impl Default for ConnectionConfigManager {
fn default() -> Self {
Self::new()
}
}
/// SSH 配置中的 Host 条目
#[derive(Debug, Clone)]
pub struct SSHHostEntry {
/// Host 模式
pub pattern: String,
/// 实际主机名
pub hostname: Option<String>,
/// 用户名
pub user: Option<String>,
/// 端口
pub port: Option<u16>,
/// 身份文件
pub identity_file: Option<String>,
}
/// 连接来源
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum ConnectionSource {
/// 内置连接
BuiltIn,
/// 用户配置文件
UserConfig,
/// SSH 配置文件
SSHConfig,
}
/// 连接列表条目(用于前端显示)
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConnectionListEntry {
/// 连接名称/标识
pub name: String,
/// 连接类型
#[serde(rename = "type")]
pub conn_type: ConnectionConfigType,
/// 显示标签
pub label: String,
/// 配置来源
pub source: ConnectionSource,
/// 主机名
#[serde(skip_serializing_if = "Option::is_none")]
pub host: Option<String>,
/// 用户名
#[serde(skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
/// 端口
#[serde(skip_serializing_if = "Option::is_none")]
pub port: Option<u16>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_connection_config_serialization() {
let config = ConnectionConfig::ssh("192.168.1.100")
.with_user("root")
.with_port(22);
let json = serde_json::to_string(&config).unwrap();
assert!(json.contains("\"type\":\"ssh\""));
assert!(json.contains("\"user\":\"root\""));
assert!(json.contains("\"host\":\"192.168.1.100\""));
}
#[test]
fn test_connections_file() {
let mut file = ConnectionsFile::new();
file.add("test-server", ConnectionConfig::ssh("test.example.com"));
assert_eq!(file.names().len(), 1);
assert!(file.get("test-server").is_some());
file.remove("test-server");
assert!(file.get("test-server").is_none());
}
#[test]
fn test_parse_ssh_config_hosts() {
let content = r#"
Host my-server
HostName 192.168.1.100
User root
Port 22
Host dev-*
User developer
Host github.com
HostName github.com
User git
IdentityFile ~/.ssh/github_key
"#;
let hosts = ConnectionConfigManager::parse_ssh_config_hosts(content);
// 应该有 2 个主机(排除了通配符 dev-*)
assert_eq!(hosts.len(), 2);
let my_server = hosts.iter().find(|h| h.pattern == "my-server").unwrap();
assert_eq!(my_server.hostname.as_deref(), Some("192.168.1.100"));
assert_eq!(my_server.user.as_deref(), Some("root"));
assert_eq!(my_server.port, Some(22));
let github = hosts.iter().find(|h| h.pattern == "github.com").unwrap();
assert_eq!(github.user.as_deref(), Some("git"));
assert!(github.identity_file.is_some());
}
}
@@ -8,6 +8,7 @@
//! - `ssh_shell_proc` - SSH 远程 Shell 进程
//! - `wsl_connection` - WSL 连接(仅 Windows)
//! - `connection_router` - 连接类型路由
//! - `connection_config` - 连接配置持久化
//!
//! ## 功能
//! - 本地 PTY 进程管理
@@ -15,13 +16,19 @@
//! - SSH 远程 PTY 创建和数据转发
//! - WSL 发行版连接
//! - 连接类型自动路由
//! - 连接配置存储和管理
pub mod connection_config;
pub mod connection_router;
pub mod local_pty;
pub mod ssh_connection;
pub mod ssh_shell_proc;
pub mod wsl_connection;
pub use connection_config::{
ConnectionConfig, ConnectionConfigManager, ConnectionConfigType, ConnectionListEntry,
ConnectionSource, ConnectionsFile, SSHHostEntry,
};
pub use connection_router::{ConnectionInfo, ConnectionRouter, ConnectionType};
pub use local_pty::ShellProc;
pub use ssh_connection::{
+57 -3
View File
@@ -120,7 +120,35 @@ impl PtySession {
cols: u16,
app_handle: tauri::AppHandle,
) -> Result<Self, TerminalError> {
tracing::info!("[终端] 创建 PTY 会话 {}, 大小: {}x{}", id, cols, rows);
Self::with_size_and_cwd(id, rows, cols, None, app_handle)
}
/// 创建新的 PTY 会话(指定大小和工作目录)
///
/// # 参数
/// - `id`: 会话 ID
/// - `rows`: 终端行数
/// - `cols`: 终端列数
/// - `cwd`: 工作目录(可选,默认为用户主目录)
/// - `app_handle`: Tauri 应用句柄
///
/// # 返回
/// - `Ok(PtySession)`: 创建成功
/// - `Err(TerminalError)`: 创建失败
pub fn with_size_and_cwd(
id: String,
rows: u16,
cols: u16,
cwd: Option<String>,
app_handle: tauri::AppHandle,
) -> Result<Self, TerminalError> {
tracing::info!(
"[终端] 创建 PTY 会话 {}, 大小: {}x{}, cwd: {:?}",
id,
cols,
rows,
cwd
);
let pty_system = native_pty_system();
@@ -142,8 +170,34 @@ impl PtySession {
let mut cmd = CommandBuilder::new(&shell);
cmd.env("TERM", "xterm-256color");
// 设置工作目录为用户主目录
if let Some(home) = dirs::home_dir() {
// 设置工作目录
if let Some(dir) = cwd {
// 展开 ~ 为用户主目录
let expanded_dir = if dir.starts_with("~/") {
if let Some(home) = dirs::home_dir() {
home.join(&dir[2..])
} else {
std::path::PathBuf::from(&dir)
}
} else if dir == "~" {
dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(&dir))
} else {
std::path::PathBuf::from(&dir)
};
if expanded_dir.exists() && expanded_dir.is_dir() {
tracing::info!("[终端] 设置工作目录: {:?}", expanded_dir);
cmd.cwd(expanded_dir);
} else {
tracing::warn!(
"[终端] 工作目录不存在或不是目录: {:?}, 使用主目录",
expanded_dir
);
if let Some(home) = dirs::home_dir() {
cmd.cwd(home);
}
}
} else if let Some(home) = dirs::home_dir() {
cmd.cwd(home);
}
+35 -3
View File
@@ -203,20 +203,52 @@ impl TerminalSessionManager {
&self,
rows: u16,
cols: u16,
) -> Result<String, TerminalError> {
self.create_session_with_options(rows, cols, None).await
}
/// 创建新的终端会话(指定大小和工作目录)
///
/// # 参数
/// - `rows`: 终端行数
/// - `cols`: 终端列数
/// - `cwd`: 工作目录(可选)
///
/// # 返回
/// - `Ok(String)`: 会话 ID
/// - `Err(TerminalError)`: 创建失败
///
/// _Requirements: 3.1_
pub async fn create_session_with_options(
&self,
rows: u16,
cols: u16,
cwd: Option<String>,
) -> Result<String, TerminalError> {
let session_id = Uuid::new_v4().to_string();
let block_id = session_id.clone();
let tab_id = "default".to_string(); // TODO: 支持多标签页
tracing::info!("[终端] 创建会话 {}, 大小: {}x{}", session_id, cols, rows);
tracing::info!(
"[终端] 创建会话 {}, 大小: {}x{}, cwd: {:?}",
session_id,
cols,
rows,
cwd
);
// 创建块文件
let block_file = BlockFile::with_default_size(&block_id, &self.block_file_base_dir)?;
let block_file = Arc::new(block_file);
// 创建旧版 PTY 会话(兼容模式)
let pty_session =
PtySession::with_size(session_id.clone(), rows, cols, self.app_handle.clone())?;
let pty_session = PtySession::with_size_and_cwd(
session_id.clone(),
rows,
cols,
cwd,
self.app_handle.clone(),
)?;
// 创建会话元数据
let metadata = SessionMetadata {
+2 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ProxyCast",
"version": "0.34.0",
"version": "0.36.0",
"identifier": "com.proxycast.app",
"build": {
"beforeDevCommand": "npm run dev",
@@ -24,7 +24,7 @@
}
],
"security": {
"csp": "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self' http://localhost:* ws://localhost:* http://127.0.0.1:* ws://127.0.0.1:* http://[::1]:* ws://[::1]:*; frame-src 'none'; object-src 'none'"
"csp": "default-src 'self'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self' http://localhost:* ws://localhost:* http://127.0.0.1:* ws://127.0.0.1:* http://[::1]:* ws://[::1]:* https:; frame-src https: http:; object-src 'none'"
}
},
"bundle": {
+26 -22
View File
@@ -20,7 +20,12 @@ import { ToolsPage } from "./components/tools/ToolsPage";
import { AgentChatPage } from "./components/agent";
import { PluginUIRenderer } from "./components/plugins/PluginUIRenderer";
import { PluginsPage } from "./components/plugins/PluginsPage";
import { TerminalPage } from "./components/terminal/TerminalPage";
import {
TerminalWorkspace,
SysinfoView,
FileBrowserView,
WebView,
} from "./components/terminal";
import { flowEventManager } from "./lib/flowEventManager";
import { OnboardingWizard, useOnboardingState } from "./components/onboarding";
import { ConnectConfirmDialog } from "./components/connect";
@@ -29,25 +34,7 @@ import { useDeepLink } from "./hooks/useDeepLink";
import { useRelayRegistry } from "./hooks/useRelayRegistry";
import { ComponentDebugProvider } from "./contexts/ComponentDebugContext";
import { ComponentDebugOverlay } from "./components/dev";
/**
* 页面类型定义
*
* 支持静态页面和动态插件页面
* - 静态页面: 预定义的页面标识符
* - 动态插件页面: `plugin:${string}` 格式,如 "plugin:machine-id-tool"
*
* _需求: 2.2, 3.2_
*/
type Page =
| "provider-pool"
| "api-server"
| "agent"
| "tools"
| "plugins"
| "settings"
| "terminal"
| `plugin:${string}`;
import { Page } from "./types/page";
const AppContainer = styled.div`
display: flex;
@@ -189,10 +176,27 @@ function AppContent() {
<AgentChatPage onNavigate={(page) => setCurrentPage(page as Page)} />
);
case "terminal":
// 终端页面需要全屏显示
// 终端工作区 - 分块布局(对齐 Waveterm TileLayout)
return <TerminalWorkspace onNavigate={setCurrentPage} />;
case "sysinfo":
// 系统监控页面
return (
<FullscreenWrapper>
<TerminalPage />
<SysinfoView />
</FullscreenWrapper>
);
case "files":
// 文件浏览器页面
return (
<FullscreenWrapper>
<FileBrowserView />
</FullscreenWrapper>
);
case "web":
// 内嵌浏览器页面
return (
<FullscreenWrapper>
<WebView />
</FullscreenWrapper>
);
case "tools":
+1 -10
View File
@@ -27,16 +27,7 @@ import {
} from "lucide-react";
import * as LucideIcons from "lucide-react";
import { getPluginsForSurface, PluginUIInfo } from "@/lib/api/pluginUI";
type Page =
| "provider-pool"
| "api-server"
| "agent"
| "tools"
| "plugins"
| "settings"
| "terminal"
| `plugin:${string}`;
import { Page } from "@/types/page";
interface AppSidebarProps {
currentPage: Page;
+1
View File
@@ -27,6 +27,7 @@ React 组件层,包含 UI 组件和业务组件。
- `switch/` - 开关控制组件
- `terminal/` - 内置终端组件(使用 Tauri Commands)
- `tools/` - 工具页面组件
- `widgets/` - 右侧小部件栏组件(移植自 Waveterm)
- `ui/` - 通用 UI 组件(按钮、输入框等)
- `websocket/` - WebSocket 管理组件
- `AppSidebar.tsx` - 全局图标侧边栏(类似 cherry-studio)
+313 -206
View File
@@ -3,8 +3,20 @@
* @description 组件视图调试覆盖层 - Alt+悬浮显示轮廓,Alt+点击显示组件信息
*/
import React, { useEffect, useState, useMemo } from "react";
import { useComponentDebug, ComponentInfo } from "@/contexts/ComponentDebugContext";
import { X, Copy, Check, Component, FileCode, Layers, Hash, ChevronUp } from "lucide-react";
import {
useComponentDebug,
ComponentInfo,
} from "@/contexts/ComponentDebugContext";
import {
X,
Copy,
Check,
Component,
FileCode,
Layers,
Hash,
ChevronUp,
} from "lucide-react";
// ============================================================================
// 错误边界组件
@@ -45,13 +57,13 @@ class DebugErrorBoundary extends React.Component<
// 使用 setTimeout 在下一帧尝试恢复
setTimeout(() => {
if (this.state.hasError) {
this.setState((prev) => ({
hasError: false,
errorCount: prev.errorCount + 1
this.setState((prev) => ({
hasError: false,
errorCount: prev.errorCount + 1,
}));
}
}, 100);
// 如果错误次数过多,完全禁用
if (this.state.errorCount > 5) {
return null;
@@ -86,7 +98,7 @@ interface FiberType {
interface FiberNode {
type: FiberType | ((...args: unknown[]) => unknown) | null;
return: FiberNode | null;
memoizedProps: Record<string, unknown> & { __source?: DebugSource } | null;
memoizedProps: (Record<string, unknown> & { __source?: DebugSource }) | null;
_debugSource?: DebugSource;
_debugOwner?: FiberNode;
stateNode?: HTMLElement | null; // DOM 元素引用
@@ -114,38 +126,54 @@ function isStyledComponentName(name: string): boolean {
* 从 Fiber 类型中提取组件名称
* 支持 memo、forwardRef、styled-components 等包装组件
*/
function getComponentName(type: FiberType | ((...args: unknown[]) => unknown) | null): string {
function getComponentName(
type: FiberType | ((...args: unknown[]) => unknown) | null,
): string {
if (!type) return "Unknown";
// 直接函数组件
if (typeof type === "function") {
const funcType = type as { displayName?: string; name?: string; styledComponentId?: string; target?: unknown };
const funcType = type as {
displayName?: string;
name?: string;
styledComponentId?: string;
target?: unknown;
};
// styled-components 有 styledComponentId 属性,尝试获取更好的名称
if (funcType.styledComponentId) {
// 如果有 displayName 且不是 styled.xxx 格式,使用它
if (funcType.displayName && !isStyledComponentName(funcType.displayName)) {
if (
funcType.displayName &&
!isStyledComponentName(funcType.displayName)
) {
return funcType.displayName;
}
// 否则返回特殊标记,让调用者知道这是 styled-component
return `[styled]${funcType.displayName || funcType.name || "Component"}`;
}
return funcType.displayName || funcType.name || "Anonymous";
}
// 对象类型(memo、forwardRef 等)
if (typeof type === "object") {
const fiberType = type as FiberType & { styledComponentId?: string; target?: unknown };
const fiberType = type as FiberType & {
styledComponentId?: string;
target?: unknown;
};
// styled-components 检测
if (fiberType.styledComponentId) {
if (fiberType.displayName && !isStyledComponentName(fiberType.displayName)) {
if (
fiberType.displayName &&
!isStyledComponentName(fiberType.displayName)
) {
return fiberType.displayName;
}
return `[styled]${fiberType.displayName || fiberType.name || "Component"}`;
}
// 直接有 displayName 或 name
if (fiberType.displayName) return fiberType.displayName;
if (fiberType.name) return fiberType.name;
@@ -176,15 +204,15 @@ function isValidUserComponent(fiber: FiberNode): boolean {
if (typeof type !== "function" && typeof type !== "object") return false;
const name = getComponentName(type);
// 过滤掉匿名组件
if (name === "Anonymous" || name === "Unknown") return false;
// 过滤掉 styled-components(以 [styled] 开头的是我们标记的)
if (name.startsWith("[styled]") || isStyledComponentName(name)) return false;
// 过滤掉以下划线开头的内部组件
if (name.startsWith("_")) return false;
// 过滤掉 React 内置组件(如 Fragment、Suspense 等)
const reactInternals = ["Fragment", "Suspense", "StrictMode", "Profiler"];
if (reactInternals.includes(name)) return false;
@@ -202,9 +230,10 @@ function isValidUserComponent(fiber: FiberNode): boolean {
* @param delay 节流间隔(毫秒)
* @returns 节流后的函数
*/
// eslint-disable-next-line react-refresh/only-export-components
export function throttle<T extends (...args: Parameters<T>) => void>(
fn: T,
delay: number
delay: number,
): (...args: Parameters<T>) => void {
let lastCall = 0;
return (...args: Parameters<T>) => {
@@ -227,9 +256,9 @@ const DEBUG_CONFIG = {
// Props 显示限制
MAX_PROPS_DISPLAY: 8,
// 高亮颜色
HOVER_HIGHLIGHT_COLOR: "rgba(59, 130, 246, 0.8)", // 蓝色
HOVER_HIGHLIGHT_COLOR: "rgba(59, 130, 246, 0.8)", // 蓝色
HOVER_HIGHLIGHT_BG: "rgba(59, 130, 246, 0.1)",
SELECTED_HIGHLIGHT_COLOR: "rgba(34, 197, 94, 0.9)", // 绿色
SELECTED_HIGHLIGHT_COLOR: "rgba(34, 197, 94, 0.9)", // 绿色
SELECTED_HIGHLIGHT_BG: "rgba(34, 197, 94, 0.15)",
// 节流间隔
MOUSEMOVE_THROTTLE_MS: 16,
@@ -238,94 +267,100 @@ const DEBUG_CONFIG = {
/**
* 从 Fiber 节点提取组件信息
*/
function extractFiberInfo(fiber: FiberNode, element: HTMLElement, x: number, y: number): ComponentInfo | null {
function extractFiberInfo(
fiber: FiberNode,
element: HTMLElement,
x: number,
y: number,
): ComponentInfo | null {
try {
if (!fiber || !element) return null;
const name = getComponentName(fiber.type);
// 尝试多种方式获取文件路径
let filePath = "";
// 尝试多种方式获取文件路径
let filePath = "";
if (fiber._debugSource) {
const source = fiber._debugSource;
filePath = source.fileName || "";
if (source.lineNumber) {
filePath += `:${source.lineNumber}`;
if (source.columnNumber) {
filePath += `:${source.columnNumber}`;
if (fiber._debugSource) {
const source = fiber._debugSource;
filePath = source.fileName || "";
if (source.lineNumber) {
filePath += `:${source.lineNumber}`;
if (source.columnNumber) {
filePath += `:${source.columnNumber}`;
}
}
} else if (fiber.memoizedProps?.__source) {
const source = fiber.memoizedProps.__source;
filePath = source.fileName || "";
if (source.lineNumber) {
filePath += `:${source.lineNumber}`;
}
} else if (fiber._debugOwner?._debugSource) {
const source = fiber._debugOwner._debugSource;
filePath = source.fileName || "";
if (source.lineNumber) {
filePath += `:${source.lineNumber}`;
}
}
} else if (fiber.memoizedProps?.__source) {
const source = fiber.memoizedProps.__source;
filePath = source.fileName || "";
if (source.lineNumber) {
filePath += `:${source.lineNumber}`;
}
} else if (fiber._debugOwner?._debugSource) {
const source = fiber._debugOwner._debugSource;
filePath = source.fileName || "";
if (source.lineNumber) {
filePath += `:${source.lineNumber}`;
}
}
// 简化路径显示
if (filePath) {
const srcIndex = filePath.indexOf("/src/");
if (srcIndex !== -1) {
filePath = filePath.substring(srcIndex + 1);
// 简化路径显示
if (filePath) {
const srcIndex = filePath.indexOf("/src/");
if (srcIndex !== -1) {
filePath = filePath.substring(srcIndex + 1);
}
const srcIndexWin = filePath.indexOf("\\src\\");
if (srcIndexWin !== -1) {
filePath = filePath.substring(srcIndexWin + 1).replace(/\\/g, "/");
}
}
const srcIndexWin = filePath.indexOf("\\src\\");
if (srcIndexWin !== -1) {
filePath = filePath.substring(srcIndexWin + 1).replace(/\\/g, "/");
if (!filePath) {
filePath = "生产构建中不可用";
}
}
if (!filePath) {
filePath = "生产构建中不可用";
}
// 获取 props
const props = fiber.memoizedProps || {};
const safeProps: Record<string, unknown> = {};
for (const key of Object.keys(props)) {
if (key.startsWith("_") || key === "__source" || key === "__self") continue;
const value = props[key];
if (typeof value === "function") {
safeProps[key] = "[Function]";
} else if (typeof value === "object" && value !== null) {
if (Array.isArray(value)) {
safeProps[key] = `[Array(${value.length})]`;
} else if ((value as Record<string, unknown>).$$typeof) {
safeProps[key] = "[ReactElement]";
// 获取 props
const props = fiber.memoizedProps || {};
const safeProps: Record<string, unknown> = {};
for (const key of Object.keys(props)) {
if (key.startsWith("_") || key === "__source" || key === "__self")
continue;
const value = props[key];
if (typeof value === "function") {
safeProps[key] = "[Function]";
} else if (typeof value === "object" && value !== null) {
if (Array.isArray(value)) {
safeProps[key] = `[Array(${value.length})]`;
} else if ((value as Record<string, unknown>).$$typeof) {
safeProps[key] = "[ReactElement]";
} else {
safeProps[key] = "[Object]";
}
} else {
safeProps[key] = "[Object]";
safeProps[key] = value;
}
} else {
safeProps[key] = value;
}
}
// 计算深度
let depth = 0;
let tempFiber = fiber;
while (tempFiber.return) {
tempFiber = tempFiber.return;
depth++;
}
// 计算深度
let depth = 0;
let tempFiber = fiber;
while (tempFiber.return) {
tempFiber = tempFiber.return;
depth++;
}
return {
name,
filePath,
props: safeProps,
depth,
tagName: element.tagName,
x,
y,
element,
fiber,
};
return {
name,
filePath,
props: safeProps,
depth,
tagName: element.tagName,
x,
y,
element,
fiber,
};
} catch {
// 发生错误时返回 null,避免白屏
return null;
@@ -335,15 +370,23 @@ function extractFiberInfo(fiber: FiberNode, element: HTMLElement, x: number, y:
/**
* 从 React Fiber 节点获取组件信息
*/
function getReactFiberInfo(element: HTMLElement, x: number, y: number): ComponentInfo | null {
function getReactFiberInfo(
element: HTMLElement,
x: number,
y: number,
): ComponentInfo | null {
try {
const fiberKey = Object.keys(element).find(
(key) => key.startsWith("__reactFiber$") || key.startsWith("__reactInternalInstance$")
(key) =>
key.startsWith("__reactFiber$") ||
key.startsWith("__reactInternalInstance$"),
);
if (!fiberKey) return null;
let fiber: FiberNode | null = (element as unknown as Record<string, FiberNode>)[fiberKey];
let fiber: FiberNode | null = (
element as unknown as Record<string, FiberNode>
)[fiberKey];
if (!fiber) return null;
// 遍历 Fiber 树找到最近的用户组件
@@ -366,19 +409,24 @@ function getReactFiberInfo(element: HTMLElement, x: number, y: number): Componen
*/
function findDomElement(fiber: FiberNode | null): HTMLElement | null {
if (!fiber) return null;
// 如果当前节点有 stateNode 且是 DOM 元素
if (fiber.stateNode instanceof HTMLElement) {
return fiber.stateNode;
}
return null;
}
/**
* 获取父组件信息
*/
function getParentComponentInfo(currentFiber: FiberNode | unknown, x: number, y: number, fallbackElement: HTMLElement): ComponentInfo | null {
function getParentComponentInfo(
currentFiber: FiberNode | unknown,
x: number,
y: number,
fallbackElement: HTMLElement,
): ComponentInfo | null {
try {
if (!currentFiber) return null;
@@ -432,7 +480,7 @@ function SelectedHighlight({ element }: { element: HTMLElement | undefined }) {
const observer = new MutationObserver(() => {
updateRect();
});
try {
observer.observe(document.body, { childList: true, subtree: true });
} catch {
@@ -466,14 +514,15 @@ function SelectedHighlight({ element }: { element: HTMLElement | undefined }) {
/** 组件信息弹窗 */
function ComponentInfoPopup() {
const { componentInfo, hideComponentInfo, showComponentInfo } = useComponentDebug();
const { componentInfo, hideComponentInfo, showComponentInfo } =
useComponentDebug();
const [copiedField, setCopiedField] = useState<string | null>(null);
// 使用 useMemo 缓存 propsEntries 计算结果 - 必须在所有条件返回之前调用
const propsEntries = useMemo(() => {
if (!componentInfo?.props) return [];
return Object.entries(componentInfo.props).filter(
([key]) => key !== "children"
([key]) => key !== "children",
);
}, [componentInfo?.props]);
@@ -481,7 +530,9 @@ function ComponentInfoPopup() {
if (!componentInfo) return null;
// 渲染选中高亮边框
const selectedHighlight = <SelectedHighlight element={componentInfo.element} />;
const selectedHighlight = (
<SelectedHighlight element={componentInfo.element} />
);
const handleCopy = async (text: string, field: string) => {
await navigator.clipboard.writeText(text);
@@ -496,7 +547,7 @@ function ComponentInfoPopup() {
componentInfo.fiber,
componentInfo.x,
componentInfo.y,
componentInfo.element || document.body
componentInfo.element || document.body,
);
if (parentInfo) {
@@ -509,112 +560,155 @@ function ComponentInfoPopup() {
return (
<>
<div
className="fixed z-[99999] rounded-lg shadow-xl min-w-[320px] max-w-[450px] border border-gray-200 bg-white text-gray-900"
style={{
left: Math.min(componentInfo.x, window.innerWidth - DEBUG_CONFIG.POPUP_WIDTH_OFFSET),
top: Math.min(componentInfo.y, window.innerHeight - DEBUG_CONFIG.POPUP_HEIGHT_OFFSET),
}}
onClick={(e) => e.stopPropagation()}
>
{/* 标题栏 */}
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-200 rounded-t-lg bg-gray-50">
<div className="flex items-center gap-2">
<Component className="w-4 h-4 text-blue-500" />
<span className="font-semibold text-sm">组件信息</span>
</div>
<button onClick={hideComponentInfo} className="p-1 hover:bg-gray-200 rounded transition-colors">
<X className="w-4 h-4" />
</button>
</div>
{/* 内容区域 */}
<div className="p-3 space-y-3">
{/* 组件名称 */}
<div className="flex items-start gap-2">
<Component className="w-4 h-4 text-blue-500 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-xs text-gray-500 mb-0.5">组件名称</div>
<div className="flex items-center gap-2">
<code className="font-mono text-sm text-blue-600 font-medium">{componentInfo.name}</code>
<button onClick={() => handleCopy(componentInfo.name, "name")} className="p-0.5 hover:bg-gray-100 rounded shrink-0" title="复制名称">
{copiedField === "name" ? <Check className="w-3 h-3 text-green-500" /> : <Copy className="w-3 h-3 text-gray-400" />}
</button>
</div>
<div
className="fixed z-[99999] rounded-lg shadow-xl min-w-[320px] max-w-[450px] border border-gray-200 bg-white text-gray-900"
style={{
left: Math.min(
componentInfo.x,
window.innerWidth - DEBUG_CONFIG.POPUP_WIDTH_OFFSET,
),
top: Math.min(
componentInfo.y,
window.innerHeight - DEBUG_CONFIG.POPUP_HEIGHT_OFFSET,
),
}}
onClick={(e) => e.stopPropagation()}
>
{/* 标题栏 */}
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-200 rounded-t-lg bg-gray-50">
<div className="flex items-center gap-2">
<Component className="w-4 h-4 text-blue-500" />
<span className="font-semibold text-sm">组件信息</span>
</div>
<button
onClick={hideComponentInfo}
className="p-1 hover:bg-gray-200 rounded transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
{/* 文件路径 */}
<div className="flex items-start gap-2">
<FileCode className="w-4 h-4 text-orange-500 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-xs text-gray-500 mb-0.5">文件路径</div>
<div className="flex items-center gap-2">
<code className="text-xs bg-gray-100 px-2 py-1 rounded truncate flex-1 block text-gray-700">{componentInfo.filePath}</code>
<button onClick={() => handleCopy(componentInfo.filePath, "path")} className="p-0.5 hover:bg-gray-100 rounded shrink-0" title="复制路径">
{copiedField === "path" ? <Check className="w-3 h-3 text-green-500" /> : <Copy className="w-3 h-3 text-gray-400" />}
</button>
</div>
</div>
</div>
{/* HTML 标签 */}
<div className="flex items-start gap-2">
<Hash className="w-4 h-4 text-purple-500 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-xs text-gray-500 mb-0.5">DOM 元素</div>
<code className="text-xs text-gray-600">&lt;{componentInfo.tagName.toLowerCase()}&gt;</code>
</div>
</div>
{/* 组件层级 */}
<div className="flex items-start gap-2">
<Layers className="w-4 h-4 text-green-500 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-xs text-gray-500 mb-0.5">组件层级</div>
<span className="text-xs text-gray-700">第 {componentInfo.depth} 层</span>
</div>
</div>
{/* Props */}
{propsEntries.length > 0 && (
<div className="border-t border-gray-200 pt-3">
<div className="text-xs text-gray-500 mb-2">Props</div>
<div className="bg-gray-50 rounded p-2 max-h-[120px] overflow-auto">
<div className="space-y-1">
{propsEntries.slice(0, DEBUG_CONFIG.MAX_PROPS_DISPLAY).map(([key, value]) => (
<div key={key} className="flex items-start gap-2 text-xs">
<span className="text-blue-500 font-mono shrink-0">{key}:</span>
<span className="text-gray-600 font-mono truncate">
{typeof value === "string" ? `"${value}"` : String(value)}
</span>
</div>
))}
{propsEntries.length > DEBUG_CONFIG.MAX_PROPS_DISPLAY && (
<div className="text-xs text-gray-400">... 还有 {propsEntries.length - DEBUG_CONFIG.MAX_PROPS_DISPLAY} 个属性</div>
)}
{/* 内容区域 */}
<div className="p-3 space-y-3">
{/* 组件名称 */}
<div className="flex items-start gap-2">
<Component className="w-4 h-4 text-blue-500 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-xs text-gray-500 mb-0.5">组件名称</div>
<div className="flex items-center gap-2">
<code className="font-mono text-sm text-blue-600 font-medium">
{componentInfo.name}
</code>
<button
onClick={() => handleCopy(componentInfo.name, "name")}
className="p-0.5 hover:bg-gray-100 rounded shrink-0"
title="复制名称"
>
{copiedField === "name" ? (
<Check className="w-3 h-3 text-green-500" />
) : (
<Copy className="w-3 h-3 text-gray-400" />
)}
</button>
</div>
</div>
</div>
)}
</div>
{/* 底部操作栏 */}
<div className="px-3 py-2 border-t border-gray-200 rounded-b-lg bg-gray-50 flex items-center justify-between">
<p className="text-[10px] text-gray-400">按 Esc 关闭</p>
{hasParent && (
<button
onClick={handleSelectParent}
className="flex items-center gap-1 px-2 py-1 text-xs bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
>
<ChevronUp className="w-3 h-3" />
选择父组件
</button>
)}
{/* 文件路径 */}
<div className="flex items-start gap-2">
<FileCode className="w-4 h-4 text-orange-500 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-xs text-gray-500 mb-0.5">文件路径</div>
<div className="flex items-center gap-2">
<code className="text-xs bg-gray-100 px-2 py-1 rounded truncate flex-1 block text-gray-700">
{componentInfo.filePath}
</code>
<button
onClick={() => handleCopy(componentInfo.filePath, "path")}
className="p-0.5 hover:bg-gray-100 rounded shrink-0"
title="复制路径"
>
{copiedField === "path" ? (
<Check className="w-3 h-3 text-green-500" />
) : (
<Copy className="w-3 h-3 text-gray-400" />
)}
</button>
</div>
</div>
</div>
{/* HTML 标签 */}
<div className="flex items-start gap-2">
<Hash className="w-4 h-4 text-purple-500 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-xs text-gray-500 mb-0.5">DOM 元素</div>
<code className="text-xs text-gray-600">
&lt;{componentInfo.tagName.toLowerCase()}&gt;
</code>
</div>
</div>
{/* 组件层级 */}
<div className="flex items-start gap-2">
<Layers className="w-4 h-4 text-green-500 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-xs text-gray-500 mb-0.5">组件层级</div>
<span className="text-xs text-gray-700">
第 {componentInfo.depth} 层
</span>
</div>
</div>
{/* Props */}
{propsEntries.length > 0 && (
<div className="border-t border-gray-200 pt-3">
<div className="text-xs text-gray-500 mb-2">Props</div>
<div className="bg-gray-50 rounded p-2 max-h-[120px] overflow-auto">
<div className="space-y-1">
{propsEntries
.slice(0, DEBUG_CONFIG.MAX_PROPS_DISPLAY)
.map(([key, value]) => (
<div key={key} className="flex items-start gap-2 text-xs">
<span className="text-blue-500 font-mono shrink-0">
{key}:
</span>
<span className="text-gray-600 font-mono truncate">
{typeof value === "string"
? `"${value}"`
: String(value)}
</span>
</div>
))}
{propsEntries.length > DEBUG_CONFIG.MAX_PROPS_DISPLAY && (
<div className="text-xs text-gray-400">
... 还有{" "}
{propsEntries.length - DEBUG_CONFIG.MAX_PROPS_DISPLAY}{" "}
个属性
</div>
)}
</div>
</div>
</div>
)}
</div>
{/* 底部操作栏 */}
<div className="px-3 py-2 border-t border-gray-200 rounded-b-lg bg-gray-50 flex items-center justify-between">
<p className="text-[10px] text-gray-400">按 Esc 关闭</p>
{hasParent && (
<button
onClick={handleSelectParent}
className="flex items-center gap-1 px-2 py-1 text-xs bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
>
<ChevronUp className="w-3 h-3" />
选择父组件
</button>
)}
</div>
</div>
</div>
{/* 选中高亮边框 - 渲染在弹窗外部 */}
{selectedHighlight}
{/* 选中高亮边框 - 渲染在弹窗外部 */}
{selectedHighlight}
</>
);
}
@@ -623,7 +717,9 @@ function ComponentInfoPopup() {
function DebugInteractionHandler() {
const { enabled, showComponentInfo, hideComponentInfo } = useComponentDebug();
const [altPressed, setAltPressed] = useState(false);
const [hoveredElement, setHoveredElement] = useState<HTMLElement | null>(null);
const [hoveredElement, setHoveredElement] = useState<HTMLElement | null>(
null,
);
useEffect(() => {
if (!enabled) return;
@@ -696,7 +792,11 @@ function DebugInteractionHandler() {
e.preventDefault();
e.stopPropagation();
const info = getReactFiberInfo(target, e.clientX + 10, e.clientY + 10);
const info = getReactFiberInfo(
target,
e.clientX + 10,
e.clientY + 10,
);
if (info) {
showComponentInfo(info);
} else {
@@ -768,7 +868,7 @@ export function ComponentDebugOverlay() {
const { enabled } = useComponentDebug();
// 仅在开发环境启用
if (process.env.NODE_ENV !== "development") return null;
if (!import.meta.env.DEV) return null;
if (!enabled) return null;
// 使用错误边界包装,防止任何错误导致白屏
@@ -779,5 +879,12 @@ export function ComponentDebugOverlay() {
);
}
/* eslint-disable react-refresh/only-export-components */
// 导出用于测试
export { SelectedHighlight, getComponentName, isValidUserComponent, DEBUG_CONFIG };
export {
SelectedHighlight,
getComponentName,
isValidUserComponent,
DEBUG_CONFIG,
};
/* eslint-enable react-refresh/only-export-components */
@@ -0,0 +1,234 @@
/**
* 连接管理设置组件
*
* 提供 Waveterm 风格的 connections.json 编辑界面。
* 支持直接编辑 JSON 配置文件。
*
* @module components/settings/ConnectionsSettings
*/
import { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Save,
RefreshCw,
FolderOpen,
AlertCircle,
CheckCircle,
} from "lucide-react";
import {
getConnectionConfigPath,
getRawConnectionConfig,
saveRawConnectionConfig,
} from "@/lib/connection-api";
export function ConnectionsSettings() {
const [configPath, setConfigPath] = useState<string>("");
const [configContent, setConfigContent] = useState<string>("");
const [originalContent, setOriginalContent] = useState<string>("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
// 加载配置
const loadConfig = useCallback(async () => {
setLoading(true);
setError(null);
setSuccess(null);
try {
const [path, content] = await Promise.all([
getConnectionConfigPath(),
getRawConnectionConfig(),
]);
setConfigPath(path);
setConfigContent(content);
setOriginalContent(content);
} catch (err) {
console.error("[ConnectionsSettings] 加载配置失败:", err);
setError(String(err));
} finally {
setLoading(false);
}
}, []);
// 首次加载
useEffect(() => {
loadConfig();
}, [loadConfig]);
// 保存配置
const handleSave = async () => {
setSaving(true);
setError(null);
setSuccess(null);
try {
// 先验证 JSON 格式
JSON.parse(configContent);
const result = await saveRawConnectionConfig(configContent);
if (result.success) {
setOriginalContent(configContent);
setSuccess("配置已保存");
setTimeout(() => setSuccess(null), 3000);
} else {
setError(result.error || "保存失败");
}
} catch (err) {
if (err instanceof SyntaxError) {
setError(`JSON 格式错误: ${err.message}`);
} else {
setError(String(err));
}
} finally {
setSaving(false);
}
};
// 检查是否有未保存的修改
const hasChanges = configContent !== originalContent;
// 格式化 JSON
const handleFormat = () => {
try {
const parsed = JSON.parse(configContent);
setConfigContent(JSON.stringify(parsed, null, 2));
} catch {
setError("JSON 格式错误,无法格式化");
}
};
return (
<div className="space-y-6 max-w-4xl">
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>连接配置</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={loadConfig}
disabled={loading}
>
<RefreshCw
className={`w-4 h-4 mr-2 ${loading ? "animate-spin" : ""}`}
/>
刷新
</Button>
<Button
variant="outline"
size="sm"
onClick={handleFormat}
disabled={loading}
>
格式化
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={saving || !hasChanges}
>
<Save className="w-4 h-4 mr-2" />
{saving ? "保存中..." : "保存"}
</Button>
</div>
</CardTitle>
<CardDescription className="flex items-center gap-2">
<FolderOpen className="w-4 h-4" />
<code className="text-xs bg-muted px-2 py-1 rounded">
{configPath}
</code>
</CardDescription>
</CardHeader>
<CardContent>
{/* 状态提示 */}
{error && (
<div className="flex items-center gap-2 p-3 mb-4 text-sm text-red-500 bg-red-500/10 rounded-md">
<AlertCircle className="w-4 h-4" />
{error}
</div>
)}
{success && (
<div className="flex items-center gap-2 p-3 mb-4 text-sm text-green-500 bg-green-500/10 rounded-md">
<CheckCircle className="w-4 h-4" />
{success}
</div>
)}
{/* JSON 编辑器 */}
<div className="relative">
<textarea
className="w-full h-[400px] p-4 font-mono text-sm bg-zinc-900 text-zinc-100 border border-zinc-700 rounded-md resize-none focus:outline-none focus:ring-2 focus:ring-primary"
value={configContent}
onChange={(e) => setConfigContent(e.target.value)}
placeholder={loading ? "加载中..." : "// 在此编辑连接配置"}
disabled={loading}
spellCheck={false}
/>
{hasChanges && (
<div className="absolute top-2 right-2 px-2 py-1 text-xs bg-yellow-500/20 text-yellow-500 rounded">
未保存
</div>
)}
</div>
{/* 帮助信息 */}
<div className="mt-4 p-4 bg-muted/50 rounded-md">
<h4 className="font-medium mb-2">配置格式说明</h4>
<pre className="text-xs text-muted-foreground overflow-x-auto">
{`{
"connections": {
"my-server": {
"type": "ssh",
"user": "root",
"host": "192.168.1.100",
"port": 22,
"identityFile": "~/.ssh/id_rsa"
},
"dev-server": {
"type": "ssh",
"user": "developer",
"host": "dev.example.com"
}
}
}`}
</pre>
<p className="mt-2 text-xs text-muted-foreground">
支持的字段: <code>type</code> (ssh/local/wsl), <code>user</code>,{" "}
<code>host</code>, <code>port</code>, <code>identityFile</code>,{" "}
<code>proxyJump</code>
</p>
</div>
</CardContent>
</Card>
{/* SSH 配置提示 */}
<Card>
<CardHeader>
<CardTitle className="text-base">SSH 配置</CardTitle>
<CardDescription>
系统 SSH 配置文件 (~/.ssh/config) 中的 Host 也会自动显示在连接列表中
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
你可以在 ~/.ssh/config 中配置 SSH
连接,这些配置会自动被识别并显示在终端连接选择器中。
如果需要自定义配置或添加不在 SSH 配置中的连接,请使用上方的 JSON
编辑器。
</p>
</CardContent>
</Card>
</div>
);
}
+11 -1
View File
@@ -8,11 +8,20 @@ import { QuotaSettings } from "./QuotaSettings";
import { RemoteManagementSettings } from "./RemoteManagementSettings";
import { ExtensionsSettings } from "./ExtensionsSettings";
import { DeveloperSettings } from "./DeveloperSettings";
import { ConnectionsSettings } from "./ConnectionsSettings";
type SettingsTab = "general" | "security" | "advanced" | "extensions" | "developer" | "about";
type SettingsTab =
| "general"
| "connections"
| "security"
| "advanced"
| "extensions"
| "developer"
| "about";
const tabs: { id: SettingsTab; label: string; experimental?: boolean }[] = [
{ id: "general", label: "通用" },
{ id: "connections", label: "连接" },
{ id: "security", label: "安全" },
{ id: "advanced", label: "高级" },
{ id: "extensions", label: "扩展", experimental: true },
@@ -57,6 +66,7 @@ export function SettingsPage() {
{/* 内容区域 */}
<div className="flex-1 overflow-auto">
{activeTab === "general" && <GeneralSettings />}
{activeTab === "connections" && <ConnectionsSettings />}
{activeTab === "security" && (
<div className="space-y-6 max-w-2xl">
<TlsSettings />
@@ -0,0 +1,518 @@
/**
* 连接选择器组件
*
* 提供 Waveterm 风格的连接下拉选择器。
* 支持本地连接、SSH 远程连接和 WSL 连接。
*
* @module components/terminal/ConnectionSelector
*/
import {
useState,
useEffect,
useCallback,
useRef,
type KeyboardEvent,
} from "react";
import styled from "styled-components";
import {
Monitor,
Server,
Terminal,
Search,
Settings,
Loader2,
Check,
} from "lucide-react";
import {
listConnections,
connectionToSessionString,
type ConnectionListEntry,
type ConnectionType,
} from "@/lib/connection-api";
// ============================================================================
// 样式组件
// ============================================================================
const SelectorContainer = styled.div`
position: relative;
display: inline-flex;
`;
const TriggerButton = styled.button<{ $isOpen?: boolean }>`
display: flex;
align-items: center;
gap: 6px;
padding: 2px 8px;
border: none;
border-radius: 4px;
background: transparent;
color: #e0e0e0;
font-size: 12px;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: rgba(255, 255, 255, 0.1);
}
svg {
width: 14px;
height: 14px;
opacity: 0.7;
}
`;
const TriggerLabel = styled.span`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 200px;
`;
const DropdownPanel = styled.div<{ $visible: boolean }>`
position: absolute;
top: calc(100% + 4px);
left: 0;
min-width: 320px;
max-height: 450px;
background: #222222;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
z-index: 1000;
overflow: hidden;
display: ${({ $visible }) => ($visible ? "flex" : "none")};
flex-direction: column;
`;
const SearchBox = styled.div`
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
background: #2a2a2a;
svg {
width: 16px;
height: 16px;
color: #666;
}
`;
const SearchInput = styled.input`
flex: 1;
border: none;
background: transparent;
color: #e0e0e0;
font-size: 14px;
outline: none;
&::placeholder {
color: #666;
}
`;
const ConnectionList = styled.div`
flex: 1;
overflow-y: auto;
padding: 6px 0;
`;
const GroupHeader = styled.div`
padding: 8px 14px 4px;
font-size: 11px;
font-weight: 600;
color: #888;
text-transform: uppercase;
letter-spacing: 0.5px;
`;
const ConnectionItem = styled.button<{
$selected?: boolean;
$focused?: boolean;
}>`
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 8px 14px;
border: none;
background: ${({ $selected, $focused }) =>
$selected
? "rgba(88, 193, 66, 0.15)"
: $focused
? "rgba(255, 255, 255, 0.05)"
: "transparent"};
color: #e0e0e0;
font-size: 13px;
text-align: left;
cursor: pointer;
transition: background 0.1s ease;
&:hover {
background: ${({ $selected }) =>
$selected ? "rgba(88, 193, 66, 0.2)" : "rgba(255, 255, 255, 0.08)"};
}
`;
const ConnectionIcon = styled.div<{ $type: string }>`
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
color: ${({ $type }) => ($type === "local" ? "#58c142" : "#888")};
svg {
width: 16px;
height: 16px;
}
`;
const ConnectionInfo = styled.div`
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const CheckMark = styled.div<{ $visible: boolean }>`
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
color: #58c142;
visibility: ${({ $visible }) => ($visible ? "visible" : "hidden")};
svg {
width: 16px;
height: 16px;
}
`;
const FooterActions = styled.div`
display: flex;
align-items: center;
padding: 8px 14px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
background: #2a2a2a;
`;
const FooterButton = styled.button`
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border: none;
border-radius: 4px;
background: transparent;
color: #888;
font-size: 12px;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: rgba(255, 255, 255, 0.1);
color: #e0e0e0;
}
svg {
width: 14px;
height: 14px;
}
`;
const LoadingIndicator = styled.div`
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
color: #666;
svg {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
`;
const EmptyState = styled.div`
padding: 24px;
text-align: center;
color: #666;
font-size: 13px;
`;
// ============================================================================
// 辅助函数
// ============================================================================
function getConnectionIcon(type: ConnectionType) {
switch (type) {
case "local":
return <Monitor />;
case "ssh":
return <Server />;
case "wsl":
return <Terminal />;
default:
return <Terminal />;
}
}
// ============================================================================
// 主组件
// ============================================================================
interface ConnectionSelectorProps {
/** 当前连接名称 */
currentConnection?: string;
/** 选择连接回调 */
onSelect: (connection: ConnectionListEntry) => void;
/** 编辑连接回调 - 打开连接编辑器模态窗口 */
onEditConnections?: () => void;
/** 打开设置回调(已弃用,改用 onEditConnections) */
onOpenSettings?: () => void;
}
export function ConnectionSelector({
currentConnection,
onSelect,
onEditConnections,
onOpenSettings,
}: ConnectionSelectorProps) {
const [isOpen, setIsOpen] = useState(false);
const [connections, setConnections] = useState<ConnectionListEntry[]>([]);
const [loading, setLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [focusedIndex, setFocusedIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
// 加载连接列表
const loadConnections = useCallback(async () => {
setLoading(true);
try {
const list = await listConnections();
setConnections(list);
} catch (err) {
console.error("[ConnectionSelector] 加载连接列表失败:", err);
} finally {
setLoading(false);
}
}, []);
// 首次挂载加载连接列表
useEffect(() => {
loadConnections();
}, [loadConnections]);
// 打开时刷新并聚焦
useEffect(() => {
if (isOpen) {
loadConnections();
setSearchQuery("");
setFocusedIndex(-1);
setTimeout(() => {
searchInputRef.current?.focus();
}, 50);
}
}, [isOpen, loadConnections]);
// 点击外部关闭
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
return () =>
document.removeEventListener("mousedown", handleClickOutside);
}
}, [isOpen]);
// 过滤连接
const filteredConnections = connections.filter((conn) => {
if (!searchQuery) return true;
const query = searchQuery.toLowerCase();
return (
conn.name.toLowerCase().includes(query) ||
conn.label.toLowerCase().includes(query) ||
(conn.host && conn.host.toLowerCase().includes(query))
);
});
// 分组
const localConnections = filteredConnections.filter(
(c) => c.type === "local",
);
const remoteConnections = filteredConnections.filter(
(c) => c.type !== "local",
);
// 扁平化列表(用于键盘导航)
const flatList = [...localConnections, ...remoteConnections];
// 获取当前选中的连接
const currentConn =
connections.find((c) => c.name === currentConnection) ||
connections.find((c) => c.type === "local");
// 键盘导航
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setFocusedIndex((prev) => Math.min(prev + 1, flatList.length - 1));
break;
case "ArrowUp":
e.preventDefault();
setFocusedIndex((prev) => Math.max(prev - 1, 0));
break;
case "Enter":
e.preventDefault();
if (focusedIndex >= 0 && focusedIndex < flatList.length) {
handleSelect(flatList[focusedIndex]);
}
break;
case "Escape":
e.preventDefault();
setIsOpen(false);
break;
}
};
// 选择连接
const handleSelect = (connection: ConnectionListEntry) => {
onSelect(connection);
setIsOpen(false);
};
return (
<SelectorContainer ref={containerRef}>
<TriggerButton $isOpen={isOpen} onClick={() => setIsOpen(!isOpen)}>
<Terminal />
<TriggerLabel>{currentConn?.label || "Local"}</TriggerLabel>
</TriggerButton>
<DropdownPanel $visible={isOpen} onKeyDown={handleKeyDown}>
<SearchBox>
<Search />
<SearchInput
ref={searchInputRef}
placeholder="Connect to (username@host)..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</SearchBox>
<ConnectionList>
{loading ? (
<LoadingIndicator>
<Loader2 size={20} />
</LoadingIndicator>
) : filteredConnections.length === 0 ? (
<EmptyState>
{searchQuery
? "No matching connections"
: "No connections available"}
</EmptyState>
) : (
<>
{localConnections.length > 0 && (
<>
<GroupHeader>Local</GroupHeader>
{localConnections.map((conn) => {
const isSelected = currentConn?.name === conn.name;
return (
<ConnectionItem
key={conn.name}
$selected={isSelected}
$focused={flatList.indexOf(conn) === focusedIndex}
onClick={() => handleSelect(conn)}
>
<ConnectionIcon $type={conn.type}>
{getConnectionIcon(conn.type)}
</ConnectionIcon>
<ConnectionInfo>{conn.label}</ConnectionInfo>
<CheckMark $visible={isSelected}>
<Check />
</CheckMark>
</ConnectionItem>
);
})}
</>
)}
{remoteConnections.length > 0 && (
<>
<GroupHeader>Remote</GroupHeader>
{remoteConnections.map((conn) => {
const isSelected = currentConn?.name === conn.name;
return (
<ConnectionItem
key={conn.name}
$selected={isSelected}
$focused={flatList.indexOf(conn) === focusedIndex}
onClick={() => handleSelect(conn)}
>
<ConnectionIcon $type={conn.type}>
{getConnectionIcon(conn.type)}
</ConnectionIcon>
<ConnectionInfo>{conn.label}</ConnectionInfo>
<CheckMark $visible={isSelected}>
<Check />
</CheckMark>
</ConnectionItem>
);
})}
</>
)}
</>
)}
</ConnectionList>
{(onEditConnections || onOpenSettings) && (
<FooterActions>
<FooterButton
onClick={() => {
if (onEditConnections) {
onEditConnections();
} else if (onOpenSettings) {
onOpenSettings();
}
setIsOpen(false);
}}
>
<Settings />
Edit Connections
</FooterButton>
</FooterActions>
)}
</DropdownPanel>
</SelectorContainer>
);
}
// eslint-disable-next-line react-refresh/only-export-components
export { connectionToSessionString };
export type { ConnectionListEntry };
@@ -0,0 +1,502 @@
/**
* 连接配置编辑器模态窗口
*
* 提供 Waveterm 风格的 JSON 配置编辑界面。
* 用于编辑 connections.json 配置文件。
*
* @module components/terminal/ConnectionsEditorModal
*/
import { useState, useEffect, useCallback } from "react";
import styled from "styled-components";
import {
X,
Save,
RefreshCw,
FileJson,
Check,
AlertCircle,
Folder,
} from "lucide-react";
import { createPortal } from "react-dom";
import {
getRawConnectionConfig,
saveRawConnectionConfig,
getConnectionConfigPath,
} from "@/lib/connection-api";
// ============================================================================
// 样式组件
// ============================================================================
const ModalOverlay = styled.div`
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
`;
const ModalContainer = styled.div`
width: 90vw;
max-width: 900px;
height: 80vh;
max-height: 700px;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 12px;
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
overflow: hidden;
`;
const ModalHeader = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid #333;
background: #222;
`;
const HeaderTitle = styled.div`
display: flex;
align-items: center;
gap: 10px;
color: #e0e0e0;
font-size: 16px;
font-weight: 600;
svg {
width: 20px;
height: 20px;
color: #58c142;
}
`;
const HeaderActions = styled.div`
display: flex;
align-items: center;
gap: 8px;
`;
const IconButton = styled.button<{
$variant?: "default" | "primary" | "danger";
}>`
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
${({ $variant }) => {
switch ($variant) {
case "primary":
return `
background: #58c142;
color: white;
&:hover { background: #4aa838; }
&:disabled { background: #2a2a2a; color: #666; }
`;
case "danger":
return `
background: transparent;
color: #808080;
&:hover { background: rgba(255, 100, 100, 0.1); color: #ff6464; }
`;
default:
return `
background: transparent;
color: #808080;
&:hover { background: rgba(255, 255, 255, 0.1); color: #e0e0e0; }
`;
}
}}
svg {
width: 16px;
height: 16px;
}
`;
const ModalBody = styled.div`
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
`;
const ConfigPathBar = styled.div`
display: flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
background: #1e1e1e;
border-bottom: 1px solid #2a2a2a;
color: #888;
font-size: 12px;
font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace;
svg {
width: 14px;
height: 14px;
flex-shrink: 0;
}
span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
`;
const EditorContainer = styled.div`
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
`;
const TextEditor = styled.textarea`
flex: 1;
width: 100%;
padding: 16px 20px;
background: #1a1a1a;
border: none;
color: #e0e0e0;
font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace;
font-size: 13px;
line-height: 1.6;
resize: none;
outline: none;
tab-size: 2;
&::placeholder {
color: #555;
}
&:focus {
background: #1c1c1c;
}
`;
const StatusBar = styled.div<{ $type?: "success" | "error" | "info" }>`
display: flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
border-top: 1px solid #2a2a2a;
background: #1e1e1e;
font-size: 12px;
${({ $type }) => {
switch ($type) {
case "success":
return `color: #58c142;`;
case "error":
return `color: #ff6464;`;
default:
return `color: #888;`;
}
}}
svg {
width: 14px;
height: 14px;
}
`;
const ModalFooter = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 20px;
border-top: 1px solid #333;
background: #222;
`;
const FooterInfo = styled.div`
font-size: 12px;
color: #666;
`;
const FooterActions = styled.div`
display: flex;
align-items: center;
gap: 10px;
`;
const Button = styled.button<{ $variant?: "default" | "primary" }>`
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border: none;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
${({ $variant }) =>
$variant === "primary"
? `
background: #58c142;
color: white;
&:hover { background: #4aa838; }
&:disabled { background: #2a2a2a; color: #666; cursor: not-allowed; }
`
: `
background: #333;
color: #e0e0e0;
&:hover { background: #404040; }
`}
svg {
width: 14px;
height: 14px;
}
`;
const HelpText = styled.div`
padding: 16px 20px;
background: #1e1e1e;
border-bottom: 1px solid #2a2a2a;
font-size: 12px;
color: #888;
line-height: 1.5;
code {
background: #333;
padding: 2px 6px;
border-radius: 4px;
font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace;
color: #58c142;
}
`;
// ============================================================================
// 主组件
// ============================================================================
interface ConnectionsEditorModalProps {
/** 是否打开 */
isOpen: boolean;
/** 关闭回调 */
onClose: () => void;
/** 保存成功回调 */
onSaved?: () => void;
}
export function ConnectionsEditorModal({
isOpen,
onClose,
onSaved,
}: ConnectionsEditorModalProps) {
const [content, setContent] = useState("");
const [originalContent, setOriginalContent] = useState("");
const [configPath, setConfigPath] = useState("");
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [status, setStatus] = useState<{
type: "success" | "error" | "info";
message: string;
} | null>(null);
// 加载配置
const loadConfig = useCallback(async () => {
setLoading(true);
setStatus(null);
try {
const [rawConfig, path] = await Promise.all([
getRawConnectionConfig(),
getConnectionConfigPath(),
]);
setContent(rawConfig);
setOriginalContent(rawConfig);
setConfigPath(path);
} catch (err) {
console.error("[ConnectionsEditorModal] 加载配置失败:", err);
setStatus({
type: "error",
message: `加载配置失败: ${err instanceof Error ? err.message : String(err)}`,
});
} finally {
setLoading(false);
}
}, []);
// 保存配置
const handleSave = useCallback(async () => {
setSaving(true);
setStatus(null);
try {
// 验证 JSON 格式
JSON.parse(content);
const result = await saveRawConnectionConfig(content);
if (result.success) {
setOriginalContent(content);
setStatus({ type: "success", message: "配置已保存" });
onSaved?.();
} else {
setStatus({
type: "error",
message: result.error || "保存失败",
});
}
} catch (err) {
if (err instanceof SyntaxError) {
setStatus({
type: "error",
message: `JSON 格式错误: ${err.message}`,
});
} else {
setStatus({
type: "error",
message: `保存失败: ${err instanceof Error ? err.message : String(err)}`,
});
}
} finally {
setSaving(false);
}
}, [content, onSaved]);
// 首次打开时加载
useEffect(() => {
if (isOpen) {
loadConfig();
}
}, [isOpen, loadConfig]);
// ESC 键关闭
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
// Ctrl/Cmd + S 保存
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
handleSave();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose, handleSave]);
// 检查是否有未保存的更改
const hasChanges = content !== originalContent;
// 格式化 JSON
const formatJson = () => {
try {
const parsed = JSON.parse(content);
setContent(JSON.stringify(parsed, null, 2));
setStatus({ type: "info", message: "JSON 已格式化" });
} catch {
setStatus({ type: "error", message: "JSON 格式错误,无法格式化" });
}
};
if (!isOpen) return null;
return createPortal(
<ModalOverlay onClick={(e) => e.target === e.currentTarget && onClose()}>
<ModalContainer onClick={(e) => e.stopPropagation()}>
<ModalHeader>
<HeaderTitle>
<FileJson />
Wave Config - connections.json
</HeaderTitle>
<HeaderActions>
<IconButton onClick={loadConfig} title="刷新" disabled={loading}>
<RefreshCw className={loading ? "animate-spin" : ""} />
</IconButton>
<IconButton onClick={onClose} $variant="danger" title="关闭">
<X />
</IconButton>
</HeaderActions>
</ModalHeader>
<ModalBody>
<ConfigPathBar>
<Folder />
<span>{configPath || "加载中..."}</span>
</ConfigPathBar>
<HelpText>
配置 SSH 连接。格式:{" "}
<code>{`"connection_name": { "type": "ssh", "user": "root", "host": "192.168.1.1", "port": 22 }`}</code>
</HelpText>
<EditorContainer>
{loading ? (
<div style={{ padding: 20, color: "#888" }}>加载中...</div>
) : (
<TextEditor
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder='{\n "connections": {\n "my-server": {\n "type": "ssh",\n "user": "root",\n "host": "192.168.1.100",\n "port": 22\n }\n }\n}'
spellCheck={false}
/>
)}
</EditorContainer>
{status && (
<StatusBar $type={status.type}>
{status.type === "success" ? (
<Check />
) : status.type === "error" ? (
<AlertCircle />
) : null}
{status.message}
</StatusBar>
)}
</ModalBody>
<ModalFooter>
<FooterInfo>
{hasChanges ? "有未保存的更改" : "无更改"}
{" | "}按 Ctrl+S 保存
</FooterInfo>
<FooterActions>
<Button onClick={formatJson}>格式化 JSON</Button>
<Button onClick={onClose}>取消</Button>
<Button
$variant="primary"
onClick={handleSave}
disabled={saving || !hasChanges}
>
{saving ? (
<>
<RefreshCw className="animate-spin" />
保存中...
</>
) : (
<>
<Save />
保存
</>
)}
</Button>
</FooterActions>
</ModalFooter>
</ModalContainer>
</ModalOverlay>,
document.body,
);
}
export default ConnectionsEditorModal;
+31 -36
View File
@@ -47,11 +47,15 @@
- **Jotai 状态管理**: 使用 TermViewModel 管理终端状态
- **VDOM 模式**: 支持在终端内嵌入 React 组件
- **贴纸系统**: 支持在终端上显示可定位的贴纸标注
- **分块布局**: 支持在主终端旁添加附加面板(对齐 Waveterm TileLayout)
- **右侧小部件栏**: 快速添加 Terminal/Files/Web/Sysinfo 面板
## 文件索引
- `index.ts` - 模块导出
- `TerminalPage.tsx` - 终端页面组件(多标签页管理)
- `TerminalWorkspace.tsx` - 终端工作区组件(分块布局 + 小部件栏)
- `TerminalPanel.tsx` - 独立终端面板组件(用于分块布局)
- `TerminalView.tsx` - 终端视图组件(使用 Jotai 原子状态)
- `TerminalSearch.tsx` - 终端搜索组件
- `TerminalContextMenu.tsx` - 终端上下文菜单组件
@@ -65,6 +69,23 @@
- `termwrap.ts` - 终端封装类(连接模式,WebGL/Unicode11 支持)
- `fitaddon.ts` - 自定义 FitAddon
- `terminal.css` - 终端样式(Tokyo Night 主题)
- `widgets/` - 小部件系统子目录
## widgets 子目录
小部件系统,包含右侧工具栏和各种面板视图:
- `types.ts` - 类型定义
- `constants.ts` - 常量配置
- `context.ts` - React Context 定义
- `WidgetContext.tsx` - Provider 组件和状态持久化
- `useWidgets.ts` - 小部件相关 Hooks
- `Widget.tsx` - 单个小部件按钮组件
- `WidgetsSidebar.tsx` - 右侧小部件栏组件
- `SettingsFloatingMenu.tsx` - 设置浮动菜单
- `SysinfoView.tsx` - 系统信息监控视图(CPU/内存图表)
- `FileBrowserView.tsx` - 文件浏览器视图
- `WebView.tsx` - 内嵌浏览器视图
## 依赖
@@ -75,6 +96,10 @@
- `@xterm/addon-webgl` - WebGL 渲染加速
- `@xterm/addon-unicode11` - Unicode 11 宽字符支持
- `@tauri-apps/plugin-shell` - Tauri Shell 插件(URL 打开)
- `@floating-ui/react` - 浮动菜单定位
- `@observablehq/plot` - 系统信息图表
- `lucide-react` - 图标
- `styled-components` - 样式
- `jotai` - 原子化状态管理
- `@/lib/terminal-api` - Tauri 终端 API
- `@/lib/terminal/themes` - 终端主题配置
@@ -84,44 +109,14 @@
## 使用方式
作为内置插件在 `PluginUIRenderer` 中注册:
```typescript
const builtinPluginComponents = {
"terminal-plugin": TerminalPage,
};
```
### VDOM 模式使用
```tsx
import { TerminalView } from "@/components/terminal";
import { TerminalWorkspace } from "@/components/terminal";
// 启用 VDOM 模式切换
<TerminalView
blockId="session-1"
tabId="tab-1"
showModeSwitch={true}
initialTermMode="term"
onModeChange={(mode) => console.log("模式切换:", mode)}
/>
```
### 贴纸系统使用
```tsx
import { useSetAtom } from "jotai";
import { addStickerAtom } from "@/lib/terminal/stickers";
// 添加贴纸
const addSticker = useSetAtom(addStickerAtom);
addSticker({
blockId: "session-1",
position: { row: 5, col: 10 },
contentType: "text",
text: "重要标记",
draggable: true,
});
function App() {
return (
<TerminalWorkspace onNavigate={(page) => setCurrentPage(page)} />
);
}
```
## 更新提醒
+348
View File
@@ -0,0 +1,348 @@
/**
* @file TerminalPanel.tsx
* @description 独立终端面板组件 - 用于分块布局中的附加终端
* @module components/terminal/TerminalPanel
*
* 简化版终端组件,用于在分块布局中显示独立的终端实例。
* 每个面板有自己的会话 ID 和 TermWrap 实例。
* 借鉴 Waveterm 的右键菜单功能。
*/
import React, { useEffect, useRef, useCallback, useState } from "react";
import "@xterm/xterm/css/xterm.css";
import {
createTerminalSession,
closeTerminal,
type SessionStatus,
} from "@/lib/terminal-api";
import { TermWrap } from "./termwrap";
import {
loadThemePreference,
loadFontSizePreference,
saveFontSizePreference,
saveThemePreference,
getTheme,
type ThemeName,
} from "@/lib/terminal/themes";
import {
TerminalContextMenu,
type ContextMenuPosition,
} from "./widgets/TerminalContextMenu";
import "./terminal.css";
interface TerminalPanelProps {
/** 面板 ID */
panelId: string;
/** 工作目录(可选) */
cwd?: string;
/** 会话创建完成回调 */
onSessionCreated?: (sessionId: string) => void;
/** 状态变化回调 */
onStatusChange?: (status: SessionStatus) => void;
/** 水平分割回调 */
onSplitHorizontal?: () => void;
/** 垂直分割回调 */
onSplitVertical?: () => void;
}
/**
* 独立终端面板组件
*/
export function TerminalPanel({
panelId,
cwd,
onSessionCreated,
onStatusChange,
onSplitHorizontal,
onSplitVertical,
}: TerminalPanelProps) {
const [sessionId, setSessionId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false);
// 右键菜单状态
const [contextMenu, setContextMenu] = useState<{
position: ContextMenuPosition;
} | null>(null);
// 终端设置状态
const [fontSize, setFontSize] = useState(loadFontSizePreference());
const [themeName, setThemeName] = useState(loadThemePreference());
const connectElemRef = useRef<HTMLDivElement>(null);
const termWrapRef = useRef<TermWrap | null>(null);
// 创建终端会话
const createSession = useCallback(async () => {
if (isCreating || sessionId) return;
setIsCreating(true);
setError(null);
try {
const newSessionId = await createTerminalSession(cwd);
console.log(
`[TerminalPanel ${panelId}] 会话已创建:`,
newSessionId,
cwd ? `(cwd: ${cwd})` : "",
);
setSessionId(newSessionId);
onSessionCreated?.(newSessionId);
} catch (err) {
console.error(`[TerminalPanel ${panelId}] 创建终端失败:`, err);
setError("创建终端会话失败");
} finally {
setIsCreating(false);
}
}, [isCreating, sessionId, panelId, cwd, onSessionCreated]);
// 首次挂载时创建会话
useEffect(() => {
createSession();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 键盘事件处理器
const handleTerminalKeydown = useCallback((e: KeyboardEvent): boolean => {
if (e.type !== "keydown") return true;
const termWrap = termWrapRef.current;
if (!termWrap) return true;
const isMac = /mac/i.test(navigator.userAgent);
// Shift+End - 滚动到底部
if (
e.shiftKey &&
!e.ctrlKey &&
!e.altKey &&
!e.metaKey &&
e.key === "End"
) {
termWrap.terminal.scrollToBottom();
e.preventDefault();
e.stopPropagation();
return false;
}
// Shift+Home - 滚动到顶部
if (
e.shiftKey &&
!e.ctrlKey &&
!e.altKey &&
!e.metaKey &&
e.key === "Home"
) {
termWrap.terminal.scrollToLine(0);
e.preventDefault();
e.stopPropagation();
return false;
}
// Cmd+End (macOS) - 滚动到底部
if (
isMac &&
e.metaKey &&
!e.ctrlKey &&
!e.altKey &&
!e.shiftKey &&
e.key === "End"
) {
termWrap.terminal.scrollToBottom();
e.preventDefault();
e.stopPropagation();
return false;
}
// Cmd+Home (macOS) - 滚动到顶部
if (
isMac &&
e.metaKey &&
!e.ctrlKey &&
!e.altKey &&
!e.shiftKey &&
e.key === "Home"
) {
termWrap.terminal.scrollToLine(0);
e.preventDefault();
e.stopPropagation();
return false;
}
return true;
}, []);
// 当 sessionId 变化时,创建 TermWrap
useEffect(() => {
const container = connectElemRef.current;
if (!container || !sessionId) {
if (termWrapRef.current) {
termWrapRef.current.dispose();
termWrapRef.current = null;
}
return;
}
// 销毁旧的 TermWrap
if (termWrapRef.current) {
termWrapRef.current.dispose();
termWrapRef.current = null;
}
// 清空容器
container.innerHTML = "";
// 创建新的 TermWrap
const termWrap = new TermWrap(sessionId, container, {
onStatusChange: (status) => onStatusChange?.(status),
themeName: loadThemePreference(),
fontSize: loadFontSizePreference(),
keydownHandler: handleTerminalKeydown,
});
termWrapRef.current = termWrap;
// 设置 ResizeObserver
const rszObs = new ResizeObserver(() => {
termWrap.handleResize_debounced();
});
rszObs.observe(container);
// 异步初始化终端
termWrap.initTerminal().catch(console.error);
// 自动聚焦
setTimeout(() => termWrap.focus(), 10);
return () => {
termWrap.dispose();
rszObs.disconnect();
};
}, [sessionId, handleTerminalKeydown, onStatusChange]);
// 组件卸载时关闭会话
useEffect(() => {
return () => {
if (sessionId) {
closeTerminal(sessionId).catch(console.error);
}
};
}, [sessionId]);
// 右键菜单处理
const handleContextMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({
position: { x: e.clientX, y: e.clientY },
});
}, []);
// 关闭右键菜单
const closeContextMenu = useCallback(() => {
setContextMenu(null);
}, []);
// 复制选中文本
const handleCopy = useCallback(() => {
const selection = termWrapRef.current?.terminal?.getSelection();
if (selection) {
navigator.clipboard.writeText(selection);
}
}, []);
// 粘贴
const handlePaste = useCallback(async () => {
try {
const text = await navigator.clipboard.readText();
if (text && termWrapRef.current) {
// 通过终端发送粘贴的文本
termWrapRef.current.sendData(text);
}
} catch (e) {
console.error("[TerminalPanel] 粘贴失败:", e);
}
}, []);
// 清屏
const handleClear = useCallback(() => {
termWrapRef.current?.terminal?.clear();
}, []);
// 获取选中文本
const getSelection = useCallback(() => {
return termWrapRef.current?.terminal?.getSelection() || null;
}, []);
// 检查是否有选中文本
const hasSelection = useCallback(() => {
return termWrapRef.current?.terminal?.hasSelection() || false;
}, []);
// 字体大小变化
const handleFontSizeChange = useCallback((size: number) => {
setFontSize(size);
saveFontSizePreference(size);
if (termWrapRef.current?.terminal) {
termWrapRef.current.terminal.options.fontSize = size;
termWrapRef.current.handleResize_debounced();
}
}, []);
// 主题变化
const handleThemeChange = useCallback((theme: ThemeName) => {
setThemeName(theme);
saveThemePreference(theme);
if (termWrapRef.current?.terminal) {
const themeConfig = getTheme(theme);
termWrapRef.current.terminal.options.theme = themeConfig;
}
}, []);
if (error) {
return (
<div className="flex items-center justify-center h-full bg-[#1a1a1a] text-red-400 text-sm">
{error}
</div>
);
}
if (isCreating || !sessionId) {
return (
<div className="flex items-center justify-center h-full bg-[#1a1a1a] text-gray-400 text-sm">
正在创建终端...
</div>
);
}
return (
<div
ref={connectElemRef}
className="term-connectelem"
style={{ height: "100%", width: "100%" }}
onClick={() => termWrapRef.current?.focus()}
onContextMenu={handleContextMenu}
>
{/* 右键菜单 */}
{contextMenu && (
<TerminalContextMenu
position={contextMenu.position}
onClose={closeContextMenu}
hasSelection={hasSelection()}
getSelection={getSelection}
onCopy={handleCopy}
onPaste={handlePaste}
onClear={handleClear}
onFontSizeChange={handleFontSizeChange}
currentFontSize={fontSize}
onThemeChange={handleThemeChange}
currentTheme={themeName}
onSplitHorizontal={onSplitHorizontal}
onSplitVertical={onSplitVertical}
/>
)}
</div>
);
}
export default TerminalPanel;
@@ -0,0 +1,404 @@
/**
* @file TerminalWorkspace.tsx
* @description 终端工作区组件 - 支持分块布局
* @module components/terminal/TerminalWorkspace
*
* 管理终端页面的分块布局,支持在主终端旁边添加附加面板。
* 对齐 Waveterm 的 TileLayout 风格,所有面板水平排列。
* 包含右侧小部件栏(WidgetsSidebar)。
*
* ## 功能
* - 主终端区域(左侧)
* - 附加面板区域(右侧,水平排列)
* - 支持 Terminal/Files/Web/Sysinfo 面板类型
* - Terminal 类型支持多实例
* - 右侧小部件栏
*/
import { useState, useCallback } from "react";
import styled from "styled-components";
import { TerminalPanel } from "./TerminalPanel";
import {
SysinfoView,
FileBrowserView,
WebView,
WidgetsSidebar,
WidgetProvider,
WidgetType,
} from "./widgets";
import {
ConnectionSelector,
type ConnectionListEntry,
} from "./ConnectionSelector";
import { ConnectionsEditorModal } from "./ConnectionsEditorModal";
import { Page } from "@/types/page";
// ============================================================================
// 类型定义
// ============================================================================
/** 附加面板类型 */
export type SidePanelType = "terminal" | "files" | "web" | "sysinfo";
/** 附加面板配置 */
export interface SidePanel {
id: string;
type: SidePanelType;
title: string;
/** 终端工作目录(仅 terminal 类型使用) */
cwd?: string;
/** 连接配置(仅 terminal 类型使用) */
connection?: ConnectionListEntry;
}
// ============================================================================
// 样式组件
// ============================================================================
/** 终端工作区外层容器 - 包含内容区和右侧小部件栏 */
const WorkspaceOuterContainer = styled.div`
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: row;
`;
/**
* 终端工作区内容容器 - 对齐 Waveterm TileLayout
* 所有面板平等分布,使用 flex: 1 实现均分
*/
const WorkspaceContainer = styled.div`
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: row;
gap: 3px;
padding: 3px;
background: #0a0a0a;
`;
/**
* 单个面板块 - Block 样式(对齐 Waveterm)
* 所有面板使用相同的 flex: 1,实现平等分布
*/
const PanelBlock = styled.div<{ $focused?: boolean }>`
flex: 1 1 0;
min-width: 150px;
min-height: 0;
display: flex;
flex-direction: column;
background: #1a1a1a;
border-radius: 6px;
border: 2px solid ${({ $focused }) => ($focused ? "#58a6ff" : "#2a2a2a")};
overflow: hidden;
`;
/** 面板头部 - 对齐 Waveterm */
const PanelHeader = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 10px;
background: #1a1a1a;
border-bottom: 1px solid #2a2a2a;
min-height: 28px;
max-height: 28px;
`;
const PanelTitle = styled.span`
font-size: 12px;
font-weight: 500;
color: #e0e0e0;
display: flex;
align-items: center;
gap: 6px;
svg {
width: 14px;
height: 14px;
opacity: 0.6;
}
`;
const PanelCloseButton = styled.button`
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border: none;
border-radius: 4px;
background: transparent;
color: #808080;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: #333;
color: #e0e0e0;
}
svg {
width: 12px;
height: 12px;
}
`;
const PanelContent = styled.div`
flex: 1;
min-height: 0;
overflow: hidden;
background: #1a1a1a;
`;
// ============================================================================
// 图标组件
// ============================================================================
const TerminalIcon = () => (
<svg 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>
);
const FilesIcon = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
</svg>
);
const WebIcon = () => (
<svg 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>
);
const SysinfoIcon = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
</svg>
);
const CloseIcon = () => (
<svg 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>
);
// ============================================================================
// 主组件
// ============================================================================
interface TerminalWorkspaceProps {
/** 页面导航回调 */
onNavigate: (page: Page) => void;
}
/**
* 终端工作区组件
*/
export function TerminalWorkspace({ onNavigate }: TerminalWorkspaceProps) {
// 面板状态管理 - 初始包含主终端
const [panels, setPanels] = useState<SidePanel[]>([
{ id: "main-terminal", type: "terminal", title: "Terminal" },
]);
// 连接编辑器模态窗口状态
const [isConnectionsEditorOpen, setIsConnectionsEditorOpen] = useState(false);
// 添加面板 - 所有类型都允许多开
const addPanel = useCallback(
(
type: SidePanelType,
options?: { cwd?: string; connection?: ConnectionListEntry },
) => {
setPanels((prev) => {
const titles: Record<SidePanelType, string> = {
terminal: "Terminal",
files: "Files",
web: "Web",
sysinfo: "Sysinfo",
};
// 如果有连接配置,使用连接标签作为标题
const title = options?.connection
? options.connection.label
: titles[type];
return [
...prev,
{
id: `panel-${Date.now()}`,
type,
title,
cwd: options?.cwd,
connection: options?.connection,
},
];
});
},
[],
);
// 移除面板
const removePanel = useCallback((id: string) => {
setPanels((prev) => prev.filter((p) => p.id !== id));
}, []);
// 更新面板连接
const updatePanelConnection = useCallback(
(id: string, connection: ConnectionListEntry) => {
setPanels((prev) =>
prev.map((p) => {
if (p.id === id && p.type === "terminal") {
return {
...p,
title: connection.label,
connection,
};
}
return p;
}),
);
},
[],
);
// 渲染面板图标
const renderPanelIcon = (type: SidePanelType) => {
switch (type) {
case "terminal":
return <TerminalIcon />;
case "files":
return <FilesIcon />;
case "web":
return <WebIcon />;
case "sysinfo":
return <SysinfoIcon />;
default:
return null;
}
};
// 在文件浏览器中打开终端的回调
const handleOpenTerminalFromFiles = useCallback(
(path: string) => {
addPanel("terminal", { cwd: path });
},
[addPanel],
);
// 渲染面板内容
const renderPanelContent = (panel: SidePanel) => {
switch (panel.type) {
case "terminal":
return <TerminalPanel panelId={panel.id} cwd={panel.cwd} />;
case "files":
return <FileBrowserView onOpenTerminal={handleOpenTerminalFromFiles} />;
case "web":
return <WebView />;
case "sysinfo":
return <SysinfoView />;
default:
return null;
}
};
/**
* 处理右侧小部件点击
* 添加面板或导航到其他页面
*/
const handleWidgetClick = useCallback(
(type: WidgetType) => {
switch (type) {
case "terminal":
addPanel("terminal");
break;
case "files":
addPanel("files");
break;
case "web":
addPanel("web");
break;
case "sysinfo":
addPanel("sysinfo");
break;
case "settings":
onNavigate("settings");
break;
case "tips":
console.log("提示功能待实现");
break;
case "secrets":
onNavigate("settings");
break;
case "help":
console.log("帮助功能待实现");
break;
}
},
[addPanel, onNavigate],
);
return (
<WidgetProvider>
<WorkspaceOuterContainer>
<WorkspaceContainer>
{/* 所有面板统一渲染,都可以关闭和多开 */}
{panels.map((panel) => (
<PanelBlock key={panel.id}>
<PanelHeader>
{panel.type === "terminal" ? (
<ConnectionSelector
currentConnection={panel.connection?.name}
onSelect={(conn) => updatePanelConnection(panel.id, conn)}
onEditConnections={() => setIsConnectionsEditorOpen(true)}
/>
) : (
<PanelTitle>
{renderPanelIcon(panel.type)}
{panel.title}
</PanelTitle>
)}
<PanelCloseButton onClick={() => removePanel(panel.id)}>
<CloseIcon />
</PanelCloseButton>
</PanelHeader>
<PanelContent>{renderPanelContent(panel)}</PanelContent>
</PanelBlock>
))}
{/* 没有面板时显示提示 */}
{panels.length === 0 && (
<PanelBlock>
<PanelContent
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#666",
}}
>
点击右侧图标添加面板
</PanelContent>
</PanelBlock>
)}
</WorkspaceContainer>
<WidgetsSidebar onWidgetClick={handleWidgetClick} />
</WorkspaceOuterContainer>
{/* 连接配置编辑器模态窗口 */}
<ConnectionsEditorModal
isOpen={isConnectionsEditorOpen}
onClose={() => setIsConnectionsEditorOpen(false)}
/>
</WidgetProvider>
);
}
export default TerminalWorkspace;
+8
View File
@@ -14,6 +14,14 @@ export { ConnectionStatusIndicator } from "./ConnectionStatusIndicator";
export { MultiInputIndicator } from "./MultiInputIndicator";
export { TermWrap } from "./termwrap";
// 分块布局组件
export { TerminalWorkspace } from "./TerminalWorkspace";
export type { SidePanelType, SidePanel } from "./TerminalWorkspace";
export { TerminalPanel } from "./TerminalPanel";
// 小部件系统(供外部使用,如独立页面)
export { SysinfoView, FileBrowserView, WebView } from "./widgets";
// VDOM 组件
// _Requirements: 14.1, 14.2, 14.3, 14.4, 14.5_
export { VDomModeSwitch, VDomModeToggle } from "./VDomModeSwitch";
+13
View File
@@ -610,6 +610,19 @@ export class TermWrap {
this.terminal.write(data);
}
/**
* 发送数据到后端 PTY(用于粘贴等外部输入)
*
* @param data - 要发送的文本数据
*/
sendData(data: string): void {
if (!this.loaded) {
return;
}
const base64 = encodeBase64(data);
writeToTerminalRaw(this.sessionId, base64).catch(console.error);
}
/**
* 清空终端
*/
@@ -0,0 +1,244 @@
/**
* 文件/文件夹名称编辑覆盖层
*
* 借鉴 Waveterm 的 EntryManagerOverlay 组件
* 用于新建文件、新建文件夹、重命名等操作
*
* @module widgets/EntryManagerOverlay
*/
import React, { memo, useState, useCallback, useEffect, useRef } from "react";
import styled from "styled-components";
// ============================================================================
// 类型定义
// ============================================================================
/** 操作类型 */
// eslint-disable-next-line react-refresh/only-export-components
export enum EntryManagerType {
NewFile = "新建文件",
NewFolder = "新建文件夹",
Rename = "重命名",
}
/** 组件属性 */
export interface EntryManagerOverlayProps {
/** 操作类型 */
type: EntryManagerType;
/** 初始值(重命名时使用) */
initialValue?: string;
/** 保存回调 */
onSave: (value: string) => void;
/** 取消回调 */
onCancel: () => void;
}
// ============================================================================
// 样式组件
// ============================================================================
const Overlay = styled.div`
position: fixed;
inset: 0;
z-index: 9998;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(2px);
`;
const Dialog = styled.div`
min-width: 320px;
padding: 20px;
background: rgb(30, 30, 30);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
`;
const Title = styled.h3`
margin: 0 0 16px 0;
font-size: 16px;
font-weight: 500;
color: #f0f0f0;
`;
const Input = styled.input`
width: 100%;
padding: 10px 12px;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
background: rgba(0, 0, 0, 0.3);
color: #f0f0f0;
font-size: 14px;
outline: none;
transition: border-color 0.15s ease;
&:focus {
border-color: rgb(88, 193, 66);
}
&::placeholder {
color: #606060;
}
`;
const ButtonGroup = styled.div`
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 16px;
`;
const Button = styled.button<{ $primary?: boolean }>`
padding: 8px 16px;
border: 1px solid
${({ $primary }) =>
$primary ? "rgb(88, 193, 66)" : "rgba(255, 255, 255, 0.15)"};
border-radius: 6px;
background: ${({ $primary }) =>
$primary ? "rgb(88, 193, 66)" : "transparent"};
color: ${({ $primary }) => ($primary ? "#000" : "#e0e0e0")};
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: ${({ $primary }) =>
$primary ? "rgb(98, 203, 76)" : "rgba(255, 255, 255, 0.08)"};
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`;
const ErrorMessage = styled.div`
margin-top: 8px;
padding: 8px 12px;
border-radius: 4px;
background: rgba(229, 77, 46, 0.15);
color: #e54d2e;
font-size: 12px;
`;
// ============================================================================
// 主组件
// ============================================================================
/**
* 文件/文件夹名称编辑覆盖层
*/
export const EntryManagerOverlay = memo(function EntryManagerOverlay({
type,
initialValue = "",
onSave,
onCancel,
}: EntryManagerOverlayProps) {
const [value, setValue] = useState(initialValue);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
// 自动聚焦输入框
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus();
// 重命名时选中文件名(不包括扩展名)
if (type === EntryManagerType.Rename && initialValue) {
const dotIndex = initialValue.lastIndexOf(".");
if (dotIndex > 0) {
inputRef.current.setSelectionRange(0, dotIndex);
} else {
inputRef.current.select();
}
}
}
}, [type, initialValue]);
// 验证文件名
const validateName = useCallback((name: string): string | null => {
if (!name.trim()) {
return "名称不能为空";
}
if (name.includes("/") || name.includes("\\")) {
return "名称不能包含 / 或 \\";
}
if (name === "." || name === "..") {
return "名称不能是 . 或 ..";
}
return null;
}, []);
// 处理保存
const handleSave = useCallback(() => {
const trimmedValue = value.trim();
const validationError = validateName(trimmedValue);
if (validationError) {
setError(validationError);
return;
}
onSave(trimmedValue);
}, [value, validateName, onSave]);
// 处理键盘事件
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
handleSave();
} else if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
},
[handleSave, onCancel],
);
// 处理输入变化
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value);
setError(null);
}, []);
// 点击背景关闭
const handleOverlayClick = useCallback(
(e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onCancel();
}
},
[onCancel],
);
// 获取占位符文本
const placeholder =
type === EntryManagerType.NewFolder ? "文件夹名称" : "文件名称";
return (
<Overlay onClick={handleOverlayClick}>
<Dialog>
<Title>{type}</Title>
<Input
ref={inputRef}
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder={placeholder}
/>
{error && <ErrorMessage>{error}</ErrorMessage>}
<ButtonGroup>
<Button onClick={onCancel}>取消</Button>
<Button $primary onClick={handleSave} disabled={!value.trim()}>
确定
</Button>
</ButtonGroup>
</Dialog>
</Overlay>
);
});
export default EntryManagerOverlay;
@@ -0,0 +1,789 @@
/**
* 文件浏览器视图
*
* 显示文件系统目录结构,支持导航和文件预览
* 通过 Tauri 命令与后端交互
* 借鉴 Waveterm 的右键菜单功能
*
* @module widgets/FileBrowserView
*/
import React, { memo, useEffect, useState, useCallback } from "react";
import styled from "styled-components";
import { invoke } from "@tauri-apps/api/core";
import {
Folder,
File,
FileText,
FileCode,
FileJson,
Image,
Music,
Video,
Archive,
ChevronRight,
ChevronUp,
Home,
RefreshCw,
Eye,
EyeOff,
} from "lucide-react";
import { FileEntry } from "./types";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { FileContextMenu, ContextMenuPosition } from "./FileContextMenu";
import { EntryManagerOverlay, EntryManagerType } from "./EntryManagerOverlay";
// ============================================================================
// 类型定义
// ============================================================================
/** 组件属性 */
export interface FileBrowserViewProps {
/** 在新终端中打开目录的回调 */
onOpenTerminal?: (path: string) => void;
}
interface DirectoryListing {
path: string;
parentPath: string | null;
entries: FileEntry[];
error: string | null;
}
interface FilePreview {
path: string;
content: string | null;
isBinary: boolean;
size: number;
error: string | null;
}
const Container = styled.div`
display: flex;
flex-direction: column;
height: 100%;
background: #1a1a1a;
color: #e0e0e0;
`;
const Toolbar = styled.div`
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid #2a2a2a;
background: #1a1a1a;
`;
const PathBreadcrumb = styled.div`
display: flex;
align-items: center;
flex: 1;
gap: 4px;
font-size: 12px;
color: #808080;
overflow: hidden;
`;
const PathSegment = styled.button`
padding: 2px 6px;
border: none;
border-radius: 4px;
background: transparent;
color: #e0e0e0;
font-size: 12px;
cursor: pointer;
white-space: nowrap;
&:hover {
background: #333;
}
`;
const IconButton = styled.button`
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 4px;
background: transparent;
color: #808080;
cursor: pointer;
&:hover {
background: #333;
color: #e0e0e0;
}
svg {
width: 16px;
height: 16px;
}
`;
// ============================================================================
// 表格布局样式(对齐 Waveterm)
// ============================================================================
const TableContainer = styled.div`
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
`;
const TableHead = styled.div`
display: flex;
align-items: center;
padding: 6px 8px;
border-bottom: 1px solid #2a2a2a;
background: #1e1e1e;
font-size: 11px;
font-weight: 600;
color: #888;
text-transform: uppercase;
letter-spacing: 0.5px;
user-select: none;
`;
const TableHeadCell = styled.div<{ $width?: string; $align?: string }>`
flex: ${({ $width }) => ($width ? `0 0 ${$width}` : "1")};
min-width: ${({ $width }) => $width || "auto"};
text-align: ${({ $align }) => $align || "left"};
padding: 0 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const TableBody = styled.div`
flex: 1;
overflow: auto;
`;
const TableRow = styled.div<{ $selected?: boolean }>`
display: flex;
align-items: center;
padding: 4px 8px;
cursor: pointer;
background: ${({ $selected }) =>
$selected ? "rgba(88, 166, 255, 0.15)" : "transparent"};
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
&:hover {
background: ${({ $selected }) =>
$selected ? "rgba(88, 166, 255, 0.2)" : "#252525"};
}
`;
const TableCell = styled.div<{ $width?: string; $align?: string }>`
flex: ${({ $width }) => ($width ? `0 0 ${$width}` : "1")};
min-width: ${({ $width }) => $width || "auto"};
text-align: ${({ $align }) => $align || "left"};
padding: 0 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
`;
const FileIcon = styled.div<{ $color?: string }>`
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
color: ${({ $color }) => $color || "#808080"};
flex-shrink: 0;
svg {
width: 16px;
height: 16px;
}
`;
const FileName = styled.span<{ $isHidden?: boolean; $isSymlink?: boolean }>`
font-size: 13px;
font-weight: 500;
color: ${({ $isHidden, $isSymlink }) =>
$isHidden ? "#606060" : $isSymlink ? "#7aa2f7" : "#e0e0e0"};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;
const ModeStr = styled.span`
font-family: "SF Mono", Monaco, "Cascadia Code", monospace;
font-size: 11px;
color: #888;
letter-spacing: 0.5px;
`;
const FileSize = styled.span`
font-size: 12px;
color: #888;
font-family: "SF Mono", Monaco, "Cascadia Code", monospace;
`;
const FileDate = styled.span`
font-size: 12px;
color: #888;
`;
const FileType = styled.span`
font-size: 11px;
color: #666;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const PreviewPanel = styled.div`
border-top: 1px solid #2a2a2a;
max-height: 200px;
overflow: auto;
background: #1a1a1a;
`;
const PreviewContent = styled.pre`
margin: 0;
padding: 12px;
font-size: 12px;
font-family: "SF Mono", Monaco, "Cascadia Code", monospace;
white-space: pre-wrap;
word-break: break-all;
color: #e0e0e0;
`;
const PreviewMessage = styled.div`
padding: 12px;
font-size: 12px;
color: #808080;
text-align: center;
`;
const LoadingMessage = styled.div`
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #808080;
`;
const ErrorMessage = styled.div`
padding: 16px;
color: #ff6b6b;
text-align: center;
`;
/**
* 格式化文件大小(对齐 Waveterm)
* @param bytes - 文件大小(字节)
* @param sigfig - 有效数字位数
*/
function formatFileSize(bytes: number, sigfig: number = 3): string {
if (bytes < 0) return "-"; // 目录
if (bytes === 0) return "0";
const k = 1024;
const units = ["", "k", "m", "g", "t", "p"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
if (i === 0) return bytes.toString();
const value = bytes / Math.pow(k, i);
// 根据数值大小调整小数位数
let decimals = sigfig - Math.floor(Math.log10(value)) - 1;
decimals = Math.max(0, Math.min(decimals, sigfig - 1));
return value.toFixed(decimals) + units[i];
}
/**
* 清理 MIME 类型显示
*/
function cleanMimeType(mimeType: string | undefined): string {
if (!mimeType) return "-";
// 移除 charset 等参数
const cleaned = mimeType.split(";")[0].trim();
// 简化显示
if (cleaned === "directory") return "Directory";
if (cleaned === "symlink") return "Symlink";
if (cleaned === "application/octet-stream") return "Binary";
// 对于常见类型,只显示子类型
const parts = cleaned.split("/");
if (parts.length === 2) {
const mainType = parts[0];
const subType = parts[1];
// 移除 x- 前缀
const cleanSubType = subType.replace(/^x-/, "").replace(/^vnd\./, "");
// 对于 text 类型,显示更友好的名称
if (mainType === "text") {
return cleanSubType.charAt(0).toUpperCase() + cleanSubType.slice(1);
}
return cleanSubType;
}
return cleaned;
}
/**
* 格式化日期
*/
function formatDate(timestamp: number): string {
if (!timestamp) return "";
const date = new Date(timestamp);
return date.toLocaleDateString("zh-CN", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
/**
* 获取文件图标
*/
function getFileIcon(entry: FileEntry) {
if (entry.isDir) {
return { icon: Folder, color: "hsl(var(--warning))" };
}
const ext = entry.fileType?.toLowerCase();
switch (ext) {
case "js":
case "ts":
case "tsx":
case "jsx":
case "rs":
case "py":
case "go":
case "java":
case "c":
case "cpp":
case "h":
return { icon: FileCode, color: "hsl(var(--info))" };
case "json":
case "yaml":
case "yml":
case "toml":
return { icon: FileJson, color: "hsl(var(--warning))" };
case "md":
case "txt":
case "log":
return { icon: FileText, color: "hsl(var(--muted-foreground))" };
case "png":
case "jpg":
case "jpeg":
case "gif":
case "svg":
case "webp":
return { icon: Image, color: "hsl(var(--success))" };
case "mp3":
case "wav":
case "flac":
case "ogg":
return { icon: Music, color: "hsl(var(--primary))" };
case "mp4":
case "mov":
case "avi":
case "mkv":
return { icon: Video, color: "hsl(var(--destructive))" };
case "zip":
case "tar":
case "gz":
case "rar":
case "7z":
return { icon: Archive, color: "hsl(var(--muted-foreground))" };
default:
return { icon: File, color: "hsl(var(--muted-foreground))" };
}
}
/**
* 文件浏览器视图
*/
export const FileBrowserView = memo(function FileBrowserView({
onOpenTerminal,
}: FileBrowserViewProps) {
const [currentPath, setCurrentPath] = useState<string>("~");
const [listing, setListing] = useState<DirectoryListing | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedFile, setSelectedFile] = useState<FileEntry | null>(null);
const [preview, setPreview] = useState<FilePreview | null>(null);
const [showHidden, setShowHidden] = useState(false);
// 右键菜单状态
const [contextMenu, setContextMenu] = useState<{
position: ContextMenuPosition;
file: FileEntry | null;
} | null>(null);
// 文件/文件夹名称编辑状态
const [entryManager, setEntryManager] = useState<{
type: EntryManagerType;
initialValue?: string;
targetFile?: FileEntry;
} | null>(null);
// 加载目录
const loadDirectory = useCallback(async (path: string) => {
setLoading(true);
setError(null);
setSelectedFile(null);
setPreview(null);
try {
const result = await invoke<DirectoryListing>("list_dir", { path });
setListing(result);
setCurrentPath(result.path);
if (result.error) {
setError(result.error);
}
} catch (e) {
setError(String(e));
} finally {
setLoading(false);
}
}, []);
// 初始加载
useEffect(() => {
loadDirectory("~");
}, [loadDirectory]);
// 加载文件预览
const loadPreview = useCallback(async (file: FileEntry) => {
if (file.isDir) return;
try {
const result = await invoke<FilePreview>("read_file_preview_cmd", {
path: file.path,
maxSize: 50000, // 50KB
});
setPreview(result);
} catch (e) {
setPreview({
path: file.path,
content: null,
isBinary: false,
size: file.size,
error: String(e),
});
}
}, []);
// 处理文件点击
const handleFileClick = useCallback(
(entry: FileEntry) => {
setSelectedFile(entry);
if (!entry.isDir) {
loadPreview(entry);
}
},
[loadPreview],
);
// 处理双击
const handleFileDoubleClick = useCallback(
(entry: FileEntry) => {
if (entry.isDir) {
loadDirectory(entry.path);
}
},
[loadDirectory],
);
// 处理右键菜单
const handleContextMenu = useCallback(
(e: React.MouseEvent, entry: FileEntry | null) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({
position: { x: e.clientX, y: e.clientY },
file: entry,
});
if (entry) {
setSelectedFile(entry);
}
},
[],
);
// 关闭右键菜单
const closeContextMenu = useCallback(() => {
setContextMenu(null);
}, []);
// 刷新目录
const refresh = useCallback(() => {
loadDirectory(currentPath);
}, [currentPath, loadDirectory]);
// 新建文件
const handleNewFile = useCallback(() => {
setEntryManager({ type: EntryManagerType.NewFile });
}, []);
// 新建文件夹
const handleNewFolder = useCallback(() => {
setEntryManager({ type: EntryManagerType.NewFolder });
}, []);
// 重命名
const handleRename = useCallback((file: FileEntry) => {
setEntryManager({
type: EntryManagerType.Rename,
initialValue: file.name,
targetFile: file,
});
}, []);
// 保存文件/文件夹名称
const handleEntryManagerSave = useCallback(
async (value: string) => {
if (!entryManager) return;
try {
if (entryManager.type === EntryManagerType.NewFile) {
const newPath = `${currentPath}/${value}`;
await invoke("create_file", { path: newPath });
} else if (entryManager.type === EntryManagerType.NewFolder) {
const newPath = `${currentPath}/${value}`;
await invoke("create_directory", { path: newPath });
} else if (
entryManager.type === EntryManagerType.Rename &&
entryManager.targetFile
) {
const oldPath = entryManager.targetFile.path;
const parentPath = oldPath.substring(0, oldPath.lastIndexOf("/"));
const newPath = `${parentPath}/${value}`;
await invoke("rename_file", { oldPath, newPath });
}
refresh();
} catch (e) {
console.error("[FileBrowserView] 操作失败:", e);
alert(`操作失败: ${e}`);
}
setEntryManager(null);
},
[entryManager, currentPath, refresh],
);
// 取消编辑
const handleEntryManagerCancel = useCallback(() => {
setEntryManager(null);
}, []);
// 导航到父目录
const goToParent = useCallback(() => {
if (listing?.parentPath) {
loadDirectory(listing.parentPath);
}
}, [listing, loadDirectory]);
// 导航到主目录
const goToHome = useCallback(() => {
loadDirectory("~");
}, [loadDirectory]);
// 解析路径为面包屑
const pathSegments = currentPath.split("/").filter(Boolean);
// 过滤隐藏文件
const visibleEntries = listing?.entries.filter(
(e) => showHidden || !e.isHidden,
);
if (loading && !listing) {
return (
<Container>
<LoadingMessage>正在加载...</LoadingMessage>
</Container>
);
}
return (
<Container onContextMenu={(e) => handleContextMenu(e, null)}>
<Toolbar>
<Tooltip>
<TooltipTrigger asChild>
<IconButton onClick={goToHome}>
<Home />
</IconButton>
</TooltipTrigger>
<TooltipContent>主目录</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<IconButton onClick={goToParent} disabled={!listing?.parentPath}>
<ChevronUp />
</IconButton>
</TooltipTrigger>
<TooltipContent>上级目录</TooltipContent>
</Tooltip>
<PathBreadcrumb>
<PathSegment onClick={goToHome}>/</PathSegment>
{pathSegments.map((segment, idx) => (
<span key={idx} style={{ display: "flex", alignItems: "center" }}>
<ChevronRight
size={12}
style={{ color: "hsl(var(--muted-foreground))" }}
/>
<PathSegment
onClick={() => {
const path = "/" + pathSegments.slice(0, idx + 1).join("/");
loadDirectory(path);
}}
>
{segment}
</PathSegment>
</span>
))}
</PathBreadcrumb>
<Tooltip>
<TooltipTrigger asChild>
<IconButton onClick={() => setShowHidden(!showHidden)}>
{showHidden ? <Eye /> : <EyeOff />}
</IconButton>
</TooltipTrigger>
<TooltipContent>
{showHidden ? "隐藏隐藏文件" : "显示隐藏文件"}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<IconButton onClick={refresh}>
<RefreshCw />
</IconButton>
</TooltipTrigger>
<TooltipContent>刷新</TooltipContent>
</Tooltip>
</Toolbar>
{error && <ErrorMessage>{error}</ErrorMessage>}
<TableContainer>
{/* 表头 */}
<TableHead>
<TableHeadCell $width="24px"></TableHeadCell>
<TableHeadCell>Name</TableHeadCell>
<TableHeadCell $width="90px">Perm</TableHeadCell>
<TableHeadCell $width="100px">Modified</TableHeadCell>
<TableHeadCell $width="60px" $align="right">
Size
</TableHeadCell>
<TableHeadCell $width="100px">Type</TableHeadCell>
</TableHead>
{/* 表体 */}
<TableBody>
{visibleEntries?.map((entry) => {
const { icon: Icon, color } = getFileIcon(entry);
const isSelected = selectedFile?.path === entry.path;
return (
<TableRow
key={entry.path}
$selected={isSelected}
onClick={() => handleFileClick(entry)}
onDoubleClick={() => handleFileDoubleClick(entry)}
onContextMenu={(e) => handleContextMenu(e, entry)}
>
{/* 图标 */}
<TableCell $width="24px">
<FileIcon $color={color}>
<Icon />
</FileIcon>
</TableCell>
{/* 文件名 */}
<TableCell>
<FileName
$isHidden={entry.isHidden}
$isSymlink={entry.isSymlink}
>
{entry.name}
{entry.isSymlink && " →"}
</FileName>
</TableCell>
{/* 权限 */}
<TableCell $width="90px">
<ModeStr>{entry.modeStr || "-"}</ModeStr>
</TableCell>
{/* 修改时间 */}
<TableCell $width="100px">
<FileDate>{formatDate(entry.modifiedAt)}</FileDate>
</TableCell>
{/* 文件大小 */}
<TableCell $width="60px" $align="right">
<FileSize>
{entry.isDir ? "-" : formatFileSize(entry.size)}
</FileSize>
</TableCell>
{/* 文件类型 */}
<TableCell $width="100px">
<FileType>{cleanMimeType(entry.mimeType)}</FileType>
</TableCell>
</TableRow>
);
})}
</TableBody>
</TableContainer>
{preview && (
<PreviewPanel>
{preview.error ? (
<PreviewMessage>{preview.error}</PreviewMessage>
) : preview.isBinary ? (
<PreviewMessage>二进制文件,无法预览</PreviewMessage>
) : preview.content ? (
<PreviewContent>{preview.content}</PreviewContent>
) : (
<PreviewMessage>无内容</PreviewMessage>
)}
</PreviewPanel>
)}
{/* 右键菜单 */}
{contextMenu && (
<FileContextMenu
position={contextMenu.position}
file={contextMenu.file}
currentPath={currentPath}
onClose={closeContextMenu}
onRefresh={refresh}
onNewFile={handleNewFile}
onNewFolder={handleNewFolder}
onRename={handleRename}
onOpenTerminal={onOpenTerminal}
/>
)}
{/* 文件/文件夹名称编辑对话框 */}
{entryManager && (
<EntryManagerOverlay
type={entryManager.type}
initialValue={entryManager.initialValue}
onSave={handleEntryManagerSave}
onCancel={handleEntryManagerCancel}
/>
)}
</Container>
);
});
@@ -0,0 +1,482 @@
/**
* 文件浏览器右键菜单组件
*
* 借鉴 Waveterm 的右键菜单功能,提供文件操作菜单
* 包括新建、重命名、复制、删除、在 Finder 中显示等功能
*
* @module widgets/FileContextMenu
*/
import React, { memo, useCallback, useMemo, useEffect, useRef } from "react";
import styled from "styled-components";
import { invoke } from "@tauri-apps/api/core";
import {
FilePlus,
FolderPlus,
Pencil,
Copy,
Clipboard,
ExternalLink,
FolderOpen,
Terminal,
Trash2,
} from "lucide-react";
import { FileEntry } from "./types";
// ============================================================================
// 类型定义
// ============================================================================
/** 菜单位置 */
export interface ContextMenuPosition {
x: number;
y: number;
}
/** 组件属性 */
export interface FileContextMenuProps {
/** 菜单位置 */
position: ContextMenuPosition;
/** 选中的文件 */
file: FileEntry | null;
/** 当前目录路径 */
currentPath: string;
/** 关闭回调 */
onClose: () => void;
/** 刷新目录回调 */
onRefresh: () => void;
/** 新建文件回调 */
onNewFile: () => void;
/** 新建文件夹回调 */
onNewFolder: () => void;
/** 重命名回调 */
onRename: (file: FileEntry) => void;
/** 在新终端中打开回调 */
onOpenTerminal?: (path: string) => void;
}
/** 菜单项 */
interface MenuItem {
id: string;
label: string;
icon: React.ReactNode;
shortcut?: string;
disabled?: boolean;
danger?: boolean;
onClick: () => void;
}
/** 菜单分隔线 */
interface MenuDivider {
id: string;
type: "divider";
}
type MenuItemOrDivider = MenuItem | MenuDivider;
// ============================================================================
// 样式组件
// ============================================================================
const MenuContainer = styled.div`
position: fixed;
z-index: 9999;
min-width: 200px;
padding: 4px 0;
background: rgb(30, 30, 30);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
`;
const MenuItemButton = styled.button<{
$danger?: boolean;
$disabled?: boolean;
}>`
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 8px 12px;
border: none;
background: transparent;
color: ${({ $danger, $disabled }) =>
$disabled ? "#505050" : $danger ? "#e54d2e" : "#e0e0e0"};
font-size: 13px;
text-align: left;
cursor: ${({ $disabled }) => ($disabled ? "not-allowed" : "pointer")};
transition: background 0.1s ease;
&:hover:not(:disabled) {
background: ${({ $danger }) =>
$danger ? "rgba(229, 77, 46, 0.15)" : "rgba(255, 255, 255, 0.08)"};
}
svg {
width: 16px;
height: 16px;
opacity: 0.8;
}
`;
const MenuItemLabel = styled.span`
flex: 1;
`;
const MenuItemShortcut = styled.span`
font-size: 11px;
color: #606060;
`;
const MenuDividerLine = styled.div`
height: 1px;
margin: 4px 8px;
background: rgba(255, 255, 255, 0.1);
`;
// ============================================================================
// 工具函数
// ============================================================================
/**
* Shell 引用文件名
*/
function shellQuote(str: string): string {
// 简单的 shell 引用实现
if (/^[a-zA-Z0-9._/-]+$/.test(str)) {
return str;
}
return `'${str.replace(/'/g, "'\\''")}'`;
}
// ============================================================================
// 主组件
// ============================================================================
/**
* 文件浏览器右键菜单
*/
export const FileContextMenu = memo(function FileContextMenu({
position,
file,
currentPath,
onClose,
onRefresh,
onNewFile,
onNewFolder,
onRename,
onOpenTerminal,
}: FileContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
// 点击外部关闭菜单
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose();
}
};
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
};
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
};
}, [onClose]);
// 调整菜单位置,确保不超出视口
useEffect(() => {
if (menuRef.current) {
const menu = menuRef.current;
const rect = menu.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let x = position.x;
let y = position.y;
if (x + rect.width > viewportWidth) {
x = viewportWidth - rect.width - 8;
}
if (y + rect.height > viewportHeight) {
y = viewportHeight - rect.height - 8;
}
menu.style.left = `${x}px`;
menu.style.top = `${y}px`;
}
}, [position]);
// 复制文件名
const handleCopyFileName = useCallback(async () => {
if (!file) return;
try {
await navigator.clipboard.writeText(file.name);
} catch (e) {
console.error("[FileContextMenu] 复制文件名失败:", e);
}
onClose();
}, [file, onClose]);
// 复制完整路径
const handleCopyFullPath = useCallback(async () => {
if (!file) return;
try {
await navigator.clipboard.writeText(file.path);
} catch (e) {
console.error("[FileContextMenu] 复制完整路径失败:", e);
}
onClose();
}, [file, onClose]);
// 复制文件名(Shell 引用)
const handleCopyFileNameQuoted = useCallback(async () => {
if (!file) return;
try {
await navigator.clipboard.writeText(shellQuote(file.name));
} catch (e) {
console.error("[FileContextMenu] 复制文件名失败:", e);
}
onClose();
}, [file, onClose]);
// 复制完整路径(Shell 引用)
const handleCopyFullPathQuoted = useCallback(async () => {
if (!file) return;
try {
await navigator.clipboard.writeText(shellQuote(file.path));
} catch (e) {
console.error("[FileContextMenu] 复制完整路径失败:", e);
}
onClose();
}, [file, onClose]);
// 在 Finder 中显示
const handleRevealInFinder = useCallback(async () => {
const targetPath = file?.path || currentPath;
try {
await invoke("reveal_in_finder", { path: targetPath });
} catch (e) {
console.error("[FileContextMenu] 在 Finder 中显示失败:", e);
}
onClose();
}, [file, currentPath, onClose]);
// 使用默认应用打开
const handleOpenWithDefault = useCallback(async () => {
if (!file || file.isDir) return;
try {
await invoke("open_with_default_app", { path: file.path });
} catch (e) {
console.error("[FileContextMenu] 打开文件失败:", e);
}
onClose();
}, [file, onClose]);
// 在新终端中打开
const handleOpenTerminal = useCallback(() => {
const targetPath = file?.isDir ? file.path : currentPath;
onOpenTerminal?.(targetPath);
onClose();
}, [file, currentPath, onOpenTerminal, onClose]);
// 删除文件
const handleDelete = useCallback(async () => {
if (!file) return;
const confirmMessage = file.isDir
? `确定要删除文件夹 "${file.name}" 及其所有内容吗?`
: `确定要删除文件 "${file.name}" 吗?`;
if (!window.confirm(confirmMessage)) {
onClose();
return;
}
try {
await invoke("delete_file", {
path: file.path,
recursive: file.isDir,
});
onRefresh();
} catch (e) {
console.error("[FileContextMenu] 删除失败:", e);
alert(`删除失败: ${e}`);
}
onClose();
}, [file, onRefresh, onClose]);
// 构建菜单项
const menuItems: MenuItemOrDivider[] = useMemo(() => {
const items: MenuItemOrDivider[] = [];
// 新建文件
items.push({
id: "new-file",
label: "新建文件",
icon: <FilePlus />,
onClick: () => {
onNewFile();
onClose();
},
});
// 新建文件夹
items.push({
id: "new-folder",
label: "新建文件夹",
icon: <FolderPlus />,
onClick: () => {
onNewFolder();
onClose();
},
});
// 重命名(仅当选中文件时)
if (file && file.name !== "..") {
items.push({
id: "rename",
label: "重命名",
icon: <Pencil />,
onClick: () => {
onRename(file);
onClose();
},
});
}
items.push({ id: "divider-1", type: "divider" });
// 复制相关(仅当选中文件时)
if (file && file.name !== "..") {
items.push({
id: "copy-name",
label: "复制文件名",
icon: <Copy />,
onClick: handleCopyFileName,
});
items.push({
id: "copy-path",
label: "复制完整路径",
icon: <Clipboard />,
onClick: handleCopyFullPath,
});
items.push({
id: "copy-name-quoted",
label: "复制文件名 (Shell 引用)",
icon: <Copy />,
onClick: handleCopyFileNameQuoted,
});
items.push({
id: "copy-path-quoted",
label: "复制完整路径 (Shell 引用)",
icon: <Clipboard />,
onClick: handleCopyFullPathQuoted,
});
items.push({ id: "divider-2", type: "divider" });
}
// 在 Finder 中显示
items.push({
id: "reveal-finder",
label: "在 Finder 中显示",
icon: <FolderOpen />,
onClick: handleRevealInFinder,
});
// 使用默认应用打开(仅文件)
if (file && !file.isDir && file.name !== "..") {
items.push({
id: "open-default",
label: "使用默认应用打开",
icon: <ExternalLink />,
onClick: handleOpenWithDefault,
});
}
// 在新终端中打开
if (onOpenTerminal) {
items.push({
id: "open-terminal",
label: "在新终端中打开",
icon: <Terminal />,
onClick: handleOpenTerminal,
});
}
// 删除(仅当选中文件且不是 ".." 时)
if (file && file.name !== "..") {
items.push({ id: "divider-3", type: "divider" });
items.push({
id: "delete",
label: "删除",
icon: <Trash2 />,
danger: true,
onClick: handleDelete,
});
}
return items;
}, [
file,
onNewFile,
onNewFolder,
onRename,
onOpenTerminal,
onClose,
handleCopyFileName,
handleCopyFullPath,
handleCopyFileNameQuoted,
handleCopyFullPathQuoted,
handleRevealInFinder,
handleOpenWithDefault,
handleOpenTerminal,
handleDelete,
]);
return (
<MenuContainer
ref={menuRef}
style={{
left: position.x,
top: position.y,
}}
>
{menuItems.map((item) =>
"type" in item && item.type === "divider" ? (
<MenuDividerLine key={item.id} />
) : (
<MenuItemButton
key={item.id}
$danger={(item as MenuItem).danger}
$disabled={(item as MenuItem).disabled}
onClick={(item as MenuItem).onClick}
disabled={(item as MenuItem).disabled}
>
{(item as MenuItem).icon}
<MenuItemLabel>{(item as MenuItem).label}</MenuItemLabel>
{(item as MenuItem).shortcut && (
<MenuItemShortcut>{(item as MenuItem).shortcut}</MenuItemShortcut>
)}
</MenuItemButton>
),
)}
</MenuContainer>
);
});
export default FileContextMenu;
+96
View File
@@ -0,0 +1,96 @@
# 小部件系统 (Widgets)
右侧小部件栏组件,移植自 Waveterm 的 widgets 系统。
## 功能概述
- 右侧垂直工具栏,固定宽度 48px
- 响应式显示模式(normal/compact/supercompact)
- 设置浮动菜单
- 小部件配置持久化
- 分块布局支持(水平排列多个面板)
- 文件浏览器右键菜单(借鉴 Waveterm)
## 文件索引
| 文件 | 说明 |
|------|------|
| `types.ts` | 类型定义 |
| `constants.ts` | 常量配置 |
| `context.ts` | React Context 定义 |
| `WidgetContext.tsx` | Provider 组件和状态持久化 |
| `useWidgets.ts` | 小部件相关 Hooks |
| `Widget.tsx` | 单个小部件按钮组件 |
| `WidgetsSidebar.tsx` | 主容器组件 |
| `SettingsFloatingMenu.tsx` | 设置浮动菜单 |
| `SysinfoView.tsx` | 系统信息监控视图(CPU/内存图表) |
| `FileBrowserView.tsx` | 文件浏览器视图 |
| `FileContextMenu.tsx` | 文件浏览器右键菜单组件 |
| `EntryManagerOverlay.tsx` | 文件/文件夹名称编辑对话框 |
| `WebView.tsx` | 内嵌浏览器视图(使用 Tauri 原生 webview) |
| `index.ts` | 模块导出 |
## 右键菜单功能
借鉴 Waveterm 的文件浏览器右键菜单,支持以下功能:
| 功能 | 说明 |
|------|------|
| 新建文件 | 在当前目录创建新文件 |
| 新建文件夹 | 在当前目录创建新文件夹 |
| 重命名 | 重命名选中的文件或文件夹 |
| 复制文件名 | 复制文件名到剪贴板 |
| 复制完整路径 | 复制完整路径到剪贴板 |
| 复制文件名 (Shell 引用) | 复制 Shell 转义后的文件名 |
| 复制完整路径 (Shell 引用) | 复制 Shell 转义后的完整路径 |
| 在 Finder 中显示 | 在系统文件管理器中显示 |
| 使用默认应用打开 | 使用系统默认应用打开文件 |
| 在新终端中打开 | 在新终端中打开目录 |
| 删除 | 删除文件或文件夹 |
## 使用方式
```tsx
import { WidgetProvider, WidgetsSidebar, WidgetType } from "@/components/terminal/widgets";
function App() {
const handleWidgetClick = (type: WidgetType) => {
// 处理小部件点击
};
return (
<WidgetProvider>
<div className="flex">
<main>{/* 主内容 */}</main>
<WidgetsSidebar onWidgetClick={handleWidgetClick} />
</div>
</WidgetProvider>
);
}
```
## 显示模式
| 模式 | 触发条件 | 显示内容 |
|------|----------|----------|
| normal | 高度充足 | 图标 + 标签 |
| compact | 高度有限 | 仅图标 |
| supercompact | 高度非常有限 | 2列网格,仅图标 |
## 分块布局
在终端页面,点击右侧小部件图标可以添加附加面板:
- 点击 Terminal 图标:添加新的终端面板(支持多个)
- 点击 Files 图标:添加文件浏览器面板
- 点击 Web 图标:添加内嵌浏览器面板
- 点击 Sysinfo 图标:添加系统监控面板
所有面板水平排列,对齐 Waveterm 的 TileLayout 风格。
## 依赖
- `@floating-ui/react` - 浮动菜单定位
- `lucide-react` - 图标
- `styled-components` - 样式
- `@tauri-apps/plugin-shell` - 外部浏览器打开
- `@/lib/webview-api` - Tauri webview 管理 API
@@ -0,0 +1,142 @@
/**
* 设置浮动菜单组件
*
* 点击设置按钮后显示的浮动菜单
* 包含 Settings、Tips、Secrets、Help 菜单项
* 使用 @floating-ui/react 进行定位
*
* @module widgets/SettingsFloatingMenu
*/
import { memo } from "react";
import styled from "styled-components";
import {
useFloating,
useDismiss,
useInteractions,
offset,
shift,
autoUpdate,
FloatingPortal,
} from "@floating-ui/react";
import * as LucideIcons from "lucide-react";
import { LucideIcon } from "lucide-react";
import { WidgetType } from "./types";
import { SETTINGS_MENU_ITEMS } from "./constants";
interface SettingsFloatingMenuProps {
/** 是否打开 */
isOpen: boolean;
/** 关闭回调 */
onClose: () => void;
/** 参考元素(触发按钮) */
referenceElement: HTMLElement | null;
/** 菜单项点击回调 */
onMenuItemClick: (type: WidgetType) => void;
}
const MenuContainer = styled.div`
background: hsl(var(--popover));
border: 1px solid hsl(var(--border));
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
padding: 6px;
z-index: 50;
min-width: 140px;
`;
const MenuItem = styled.button`
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 8px 12px;
border: none;
border-radius: 6px;
background: transparent;
color: hsl(var(--muted-foreground));
cursor: pointer;
transition: all 0.15s ease;
font-size: 13px;
&:hover {
background: hsl(var(--muted));
color: hsl(var(--foreground));
}
svg {
width: 16px;
height: 16px;
flex-shrink: 0;
}
`;
const MenuLabel = styled.span`
white-space: nowrap;
`;
/**
* 根据图标名称获取 Lucide 图标组件
*/
function getIconByName(iconName: string): LucideIcon {
const IconComponent = (
LucideIcons as unknown as Record<string, LucideIcon | undefined>
)[iconName];
return IconComponent || LucideIcons.Settings;
}
/**
* 设置浮动菜单
*/
export const SettingsFloatingMenu = memo(function SettingsFloatingMenu({
isOpen,
onClose,
referenceElement,
onMenuItemClick,
}: SettingsFloatingMenuProps) {
const { refs, floatingStyles, context } = useFloating({
open: isOpen,
onOpenChange: (open) => {
if (!open) onClose();
},
placement: "left-start",
middleware: [offset(-2), shift({ padding: 12 })],
whileElementsMounted: autoUpdate,
elements: {
reference: referenceElement,
},
});
const dismiss = useDismiss(context);
const { getFloatingProps } = useInteractions([dismiss]);
if (!isOpen || !referenceElement) {
return null;
}
return (
<FloatingPortal>
<MenuContainer
ref={refs.setFloating}
style={floatingStyles}
{...getFloatingProps()}
>
{SETTINGS_MENU_ITEMS.map((item) => {
const Icon = getIconByName(item.icon);
return (
<MenuItem
key={item.id}
onClick={() => {
onMenuItemClick(item.type);
onClose();
}}
>
<Icon />
<MenuLabel>{item.label}</MenuLabel>
</MenuItem>
);
})}
</MenuContainer>
</FloatingPortal>
);
});
@@ -0,0 +1,374 @@
/**
* 系统信息监控视图
*
* 显示 CPU 和内存使用率的实时图表
* 使用 @observablehq/plot 绘制折线图
* 通过 Tauri 命令获取系统信息
*
* @module widgets/SysinfoView
*/
import { memo, useEffect, useRef, useState, useCallback } from "react";
import styled from "styled-components";
import { invoke } from "@tauri-apps/api/core";
import { listen, UnlistenFn } from "@tauri-apps/api/event";
import * as Plot from "@observablehq/plot";
import dayjs from "dayjs";
import { SysinfoDataPoint, PlotType, TimeSeriesMeta } from "./types";
import {
DEFAULT_NUM_POINTS,
DEFAULT_PLOT_META,
PLOT_TYPE_METRICS,
PLOT_COLORS,
} from "./constants";
interface SysinfoViewProps {
/** 图表类型 */
plotType?: PlotType;
/** 是否显示标题 */
showTitle?: boolean;
}
const Container = styled.div`
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
padding: 16px;
overflow: auto;
background: #1a1a1a;
color: #e0e0e0;
`;
const ChartContainer = styled.div`
flex: 1;
min-height: 150px;
width: 100%;
/* Observable Plot 样式覆盖 */
svg {
font-family: inherit;
}
[aria-label="x-axis tick label"],
[aria-label="y-axis tick label"] {
fill: #808080;
font-size: 10px;
}
[aria-label="x-axis tick"],
[aria-label="y-axis tick"] {
stroke: #2a2a2a;
}
[aria-label="x-grid"],
[aria-label="y-grid"] {
stroke: #2a2a2a;
stroke-opacity: 0.5;
}
`;
const ChartTitle = styled.div`
font-size: 12px;
color: #808080;
margin-bottom: 8px;
`;
const PlotTypeSelector = styled.div`
display: flex;
gap: 8px;
margin-bottom: 16px;
`;
const PlotTypeButton = styled.button<{ $active?: boolean }>`
padding: 4px 12px;
border-radius: 4px;
border: 1px solid ${({ $active }) => ($active ? "#58a6ff" : "#2a2a2a")};
background: ${({ $active }) => ($active ? "#58a6ff" : "transparent")};
color: ${({ $active }) => ($active ? "#000" : "#e0e0e0")};
font-size: 12px;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: ${({ $active }) => ($active ? "#58a6ff" : "#333")};
}
`;
const ChartsGrid = styled.div<{ $cols?: number }>`
display: grid;
grid-template-columns: ${({ $cols }) =>
$cols && $cols > 1 ? `repeat(${$cols}, 1fr)` : "1fr"};
gap: 16px;
flex: 1;
`;
const LoadingMessage = styled.div`
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #808080;
`;
/**
* 单个折线图组件
*/
const SingleLinePlot = memo(function SingleLinePlot({
data,
yKey,
meta,
color,
showTitle,
}: {
data: SysinfoDataPoint[];
yKey: string;
meta: TimeSeriesMeta;
color: string;
showTitle?: boolean;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
// 监听容器尺寸变化
useEffect(() => {
if (!containerRef.current) return;
const resizeObserver = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
setDimensions({
width: entry.contentRect.width,
height: entry.contentRect.height,
});
}
});
resizeObserver.observe(containerRef.current);
return () => resizeObserver.disconnect();
}, []);
// 渲染图表
useEffect(() => {
if (!containerRef.current || dimensions.width === 0 || data.length === 0)
return;
// 清除旧图表
containerRef.current.innerHTML = "";
// 解析 maxy
let maxY = 100;
if (typeof meta.maxy === "number") {
maxY = meta.maxy;
} else if (typeof meta.maxy === "string" && data.length > 0) {
const lastPoint = data[data.length - 1];
maxY = (lastPoint as unknown as Record<string, number>)[meta.maxy] || 100;
}
// 计算时间范围
const latestTs = data[data.length - 1]?.ts || Date.now();
const minX = latestTs - DEFAULT_NUM_POINTS * 1000;
const maxX = latestTs;
// 创建图表
const plot = Plot.plot({
width: dimensions.width,
height: dimensions.height - (showTitle ? 24 : 0),
marginLeft: 40,
marginRight: 10,
marginTop: 10,
marginBottom: 30,
x: {
type: "linear",
domain: [minX, maxX],
tickFormat: (d) => dayjs(d as number).format("HH:mm:ss"),
grid: true,
},
y: {
domain: [meta.miny, maxY],
label: meta.label,
grid: true,
},
marks: [
// 渐变填充区域
Plot.areaY(data, {
x: "ts",
y: yKey,
fill: color,
fillOpacity: 0.2,
}),
// 折线
Plot.lineY(data, {
x: "ts",
y: yKey,
stroke: color,
strokeWidth: 2,
}),
// 交互提示
Plot.tip(
data,
Plot.pointerX({
x: "ts",
y: yKey,
title: (d: SysinfoDataPoint) => {
const value = (d as unknown as Record<string, number>)[yKey];
return `${dayjs(d.ts).format("HH:mm:ss")}\n${value?.toFixed(meta.decimalPlaces)}${meta.label}`;
},
}),
),
// 指示点
Plot.dot(
data,
Plot.pointerX({
x: "ts",
y: yKey,
fill: color,
r: 4,
}),
),
],
});
containerRef.current.appendChild(plot);
return () => {
plot.remove();
};
}, [data, yKey, meta, color, dimensions, showTitle]);
return (
<div style={{ height: "100%", display: "flex", flexDirection: "column" }}>
{showTitle && <ChartTitle>{meta.name}</ChartTitle>}
<ChartContainer ref={containerRef} />
</div>
);
});
/**
* 系统信息监控视图
*/
export const SysinfoView = memo(function SysinfoView({
plotType: initialPlotType = "CPU",
showTitle = true,
}: SysinfoViewProps) {
const [data, setData] = useState<SysinfoDataPoint[]>([]);
const [plotType, setPlotType] = useState<PlotType>(initialPlotType);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 添加新数据点
const addDataPoint = useCallback((point: SysinfoDataPoint) => {
setData((prev) => {
const newData = [...prev, point];
// 保持最近 DEFAULT_NUM_POINTS 个数据点
if (newData.length > DEFAULT_NUM_POINTS + 1) {
return newData.slice(-DEFAULT_NUM_POINTS - 1);
}
return newData;
});
}, []);
// 订阅系统信息
useEffect(() => {
let unlisten: UnlistenFn | null = null;
let mounted = true;
const setup = async () => {
try {
// 监听 sysinfo 事件
unlisten = await listen<SysinfoDataPoint>("sysinfo", (event) => {
if (mounted) {
addDataPoint(event.payload);
setLoading(false);
}
});
// 开始订阅
await invoke("subscribe_sysinfo");
} catch (e) {
console.error("订阅系统信息失败:", e);
if (mounted) {
setError(String(e));
setLoading(false);
}
}
};
setup();
return () => {
mounted = false;
if (unlisten) {
unlisten();
}
// 取消订阅
invoke("unsubscribe_sysinfo").catch(console.error);
};
}, [addDataPoint]);
// 获取当前图表类型的指标
const metrics =
data.length > 0
? PLOT_TYPE_METRICS[plotType](data[data.length - 1])
: ["cpu"];
// 是否显示多列
const showMultiColumn = metrics.length > 2;
if (error) {
return (
<Container>
<LoadingMessage>加载失败: {error}</LoadingMessage>
</Container>
);
}
if (loading) {
return (
<Container>
<LoadingMessage>正在加载系统信息...</LoadingMessage>
</Container>
);
}
return (
<Container>
<PlotTypeSelector>
{(Object.keys(PLOT_TYPE_METRICS) as PlotType[]).map((type) => (
<PlotTypeButton
key={type}
$active={plotType === type}
onClick={() => setPlotType(type)}
>
{type}
</PlotTypeButton>
))}
</PlotTypeSelector>
<ChartsGrid $cols={showMultiColumn ? 2 : 1}>
{metrics.map((metric, idx) => {
const meta = DEFAULT_PLOT_META[metric] || {
name: metric,
label: "%",
miny: 0,
maxy: 100,
color: PLOT_COLORS[idx % PLOT_COLORS.length],
decimalPlaces: 1,
};
const color = meta.color || PLOT_COLORS[idx % PLOT_COLORS.length];
return (
<SingleLinePlot
key={metric}
data={data}
yKey={metric}
meta={meta}
color={color}
showTitle={showTitle && metrics.length > 1}
/>
);
})}
</ChartsGrid>
</Container>
);
});
@@ -0,0 +1,584 @@
/**
* 终端右键菜单组件
*
* 借鉴 Waveterm 的终端右键菜单功能
* 包括复制、粘贴、清屏、字体大小、主题等功能
*
* @module widgets/TerminalContextMenu
*/
import React, {
memo,
useCallback,
useMemo,
useEffect,
useRef,
useState,
} from "react";
import styled from "styled-components";
import {
Copy,
Clipboard,
Trash2,
Type,
Palette,
ChevronRight,
Maximize2,
Minimize2,
RotateCcw,
SplitSquareHorizontal,
SplitSquareVertical,
ExternalLink,
Globe,
} from "lucide-react";
import { type ThemeName } from "@/lib/terminal/themes";
// ============================================================================
// 类型定义
// ============================================================================
/** 菜单位置 */
export interface ContextMenuPosition {
x: number;
y: number;
}
/** 组件属性 */
export interface TerminalContextMenuProps {
/** 菜单位置 */
position: ContextMenuPosition;
/** 关闭回调 */
onClose: () => void;
/** 是否有选中文本 */
hasSelection: boolean;
/** 获取选中文本 */
getSelection: () => string | null;
/** 复制回调 */
onCopy: () => void;
/** 粘贴回调 */
onPaste: () => void;
/** 清屏回调 */
onClear: () => void;
/** 字体大小变化回调 */
onFontSizeChange: (size: number) => void;
/** 当前字体大小 */
currentFontSize: number;
/** 主题变化回调 */
onThemeChange: (theme: ThemeName) => void;
/** 当前主题 */
currentTheme: ThemeName;
/** 水平分割回调 */
onSplitHorizontal?: () => void;
/** 垂直分割回调 */
onSplitVertical?: () => void;
/** 放大/缩小回调 */
onToggleMagnify?: () => void;
/** 是否已放大 */
isMagnified?: boolean;
/** 重启终端回调 */
onRestart?: () => void;
}
/** 菜单项 */
interface MenuItem {
id: string;
label: string;
icon?: React.ReactNode;
shortcut?: string;
disabled?: boolean;
danger?: boolean;
checked?: boolean;
onClick?: () => void;
subItems?: MenuItem[];
}
/** 菜单分隔线 */
interface MenuDivider {
id: string;
type: "divider";
}
type MenuItemOrDivider = MenuItem | MenuDivider;
// ============================================================================
// 样式组件
// ============================================================================
const MenuContainer = styled.div`
position: fixed;
z-index: 9999;
min-width: 200px;
padding: 4px 0;
background: rgb(30, 30, 30);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
`;
const MenuItemButton = styled.button<{
$danger?: boolean;
$disabled?: boolean;
}>`
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 8px 12px;
border: none;
background: transparent;
color: ${({ $danger, $disabled }) =>
$disabled ? "#505050" : $danger ? "#e54d2e" : "#e0e0e0"};
font-size: 13px;
text-align: left;
cursor: ${({ $disabled }) => ($disabled ? "not-allowed" : "pointer")};
transition: background 0.1s ease;
&:hover:not(:disabled) {
background: ${({ $danger }) =>
$danger ? "rgba(229, 77, 46, 0.15)" : "rgba(255, 255, 255, 0.08)"};
}
svg {
width: 16px;
height: 16px;
opacity: 0.8;
}
`;
const MenuItemLabel = styled.span`
flex: 1;
`;
const MenuItemShortcut = styled.span`
font-size: 11px;
color: #606060;
`;
const MenuDividerLine = styled.div`
height: 1px;
margin: 4px 8px;
background: rgba(255, 255, 255, 0.1);
`;
const SubMenuContainer = styled.div`
position: absolute;
min-width: 160px;
padding: 4px 0;
background: rgb(30, 30, 30);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
`;
const CheckMark = styled.span`
width: 16px;
display: flex;
align-items: center;
justify-content: center;
color: #58a6ff;
`;
// ============================================================================
// 可用主题列表
// ============================================================================
const AVAILABLE_THEMES: Array<{ id: ThemeName; name: string }> = [
{ id: "tokyo-night", name: "Tokyo Night" },
{ id: "dracula", name: "Dracula" },
{ id: "monokai", name: "Monokai" },
{ id: "nord", name: "Nord" },
{ id: "solarized-dark", name: "Solarized Dark" },
{ id: "one-dark", name: "One Dark" },
{ id: "github-dark", name: "GitHub Dark" },
{ id: "gruvbox-dark", name: "Gruvbox Dark" },
];
// ============================================================================
// 可用字体大小
// ============================================================================
const FONT_SIZES = [10, 11, 12, 13, 14, 15, 16, 18, 20];
// ============================================================================
// 主组件
// ============================================================================
/**
* 终端右键菜单
*/
export const TerminalContextMenu = memo(function TerminalContextMenu({
position,
onClose,
hasSelection,
getSelection,
onCopy,
onPaste,
onClear,
onFontSizeChange,
currentFontSize,
onThemeChange,
currentTheme,
onSplitHorizontal,
onSplitVertical,
onToggleMagnify,
isMagnified,
onRestart,
}: TerminalContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
const [activeSubMenu, setActiveSubMenu] = useState<string | null>(null);
const [subMenuPosition, setSubMenuPosition] = useState<{
top: number;
left: number;
} | null>(null);
// 点击外部关闭菜单
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose();
}
};
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
};
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
};
}, [onClose]);
// 调整菜单位置,确保不超出视口
useEffect(() => {
if (menuRef.current) {
const menu = menuRef.current;
const rect = menu.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let x = position.x;
let y = position.y;
if (x + rect.width > viewportWidth) {
x = viewportWidth - rect.width - 8;
}
if (y + rect.height > viewportHeight) {
y = viewportHeight - rect.height - 8;
}
menu.style.left = `${x}px`;
menu.style.top = `${y}px`;
}
}, [position]);
// 处理子菜单悬停
const handleSubMenuHover = useCallback(
(itemId: string, event: React.MouseEvent) => {
const target = event.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
setActiveSubMenu(itemId);
setSubMenuPosition({
top: rect.top,
left: rect.right + 2,
});
},
[],
);
// 处理复制
const handleCopy = useCallback(() => {
onCopy();
onClose();
}, [onCopy, onClose]);
// 处理粘贴
const handlePaste = useCallback(() => {
onPaste();
onClose();
}, [onPaste, onClose]);
// 处理清屏
const handleClear = useCallback(() => {
onClear();
onClose();
}, [onClear, onClose]);
// 处理在浏览器中打开 URL
const handleOpenUrl = useCallback(async () => {
const selection = getSelection();
if (selection) {
try {
const trimmed = selection.trim();
const url = new URL(trimmed);
if (url.protocol.startsWith("http")) {
window.open(url.toString(), "_blank");
}
} catch {
// 不是有效 URL
}
}
onClose();
}, [getSelection, onClose]);
// 检查选中文本是否是 URL
const selectionUrl = useMemo(() => {
if (!hasSelection) return null;
const selection = getSelection();
if (!selection) return null;
try {
const trimmed = selection.trim();
const url = new URL(trimmed);
if (url.protocol.startsWith("http")) {
return url;
}
} catch {
// 不是有效 URL
}
return null;
}, [hasSelection, getSelection]);
// 构建菜单项
const menuItems: MenuItemOrDivider[] = useMemo(() => {
const items: MenuItemOrDivider[] = [];
// 复制(仅当有选中文本时启用)
items.push({
id: "copy",
label: "复制",
icon: <Copy />,
shortcut: "⌘C",
disabled: !hasSelection,
onClick: handleCopy,
});
// 粘贴
items.push({
id: "paste",
label: "粘贴",
icon: <Clipboard />,
shortcut: "⌘V",
onClick: handlePaste,
});
items.push({ id: "divider-1", type: "divider" });
// 如果选中的是 URL,显示打开选项
if (selectionUrl) {
items.push({
id: "open-url",
label: `打开 URL (${selectionUrl.hostname})`,
icon: <Globe />,
onClick: handleOpenUrl,
});
items.push({
id: "open-url-external",
label: "在外部浏览器中打开",
icon: <ExternalLink />,
onClick: handleOpenUrl,
});
items.push({ id: "divider-url", type: "divider" });
}
// 分割面板
if (onSplitHorizontal) {
items.push({
id: "split-horizontal",
label: "水平分割",
icon: <SplitSquareHorizontal />,
onClick: () => {
onSplitHorizontal();
onClose();
},
});
}
if (onSplitVertical) {
items.push({
id: "split-vertical",
label: "垂直分割",
icon: <SplitSquareVertical />,
onClick: () => {
onSplitVertical();
onClose();
},
});
}
if (onSplitHorizontal || onSplitVertical) {
items.push({ id: "divider-split", type: "divider" });
}
// 放大/缩小
if (onToggleMagnify) {
items.push({
id: "magnify",
label: isMagnified ? "取消放大" : "放大面板",
icon: isMagnified ? <Minimize2 /> : <Maximize2 />,
onClick: () => {
onToggleMagnify();
onClose();
},
});
}
// 清屏
items.push({
id: "clear",
label: "清屏",
icon: <Trash2 />,
shortcut: "⌘K",
onClick: handleClear,
});
// 重启终端
if (onRestart) {
items.push({
id: "restart",
label: "重启终端",
icon: <RotateCcw />,
onClick: () => {
onRestart();
onClose();
},
});
}
items.push({ id: "divider-2", type: "divider" });
// 字体大小子菜单
items.push({
id: "font-size",
label: "字体大小",
icon: <Type />,
subItems: FONT_SIZES.map((size) => ({
id: `font-${size}`,
label: `${size}px`,
checked: currentFontSize === size,
onClick: () => {
onFontSizeChange(size);
onClose();
},
})),
});
// 主题子菜单
items.push({
id: "theme",
label: "主题",
icon: <Palette />,
subItems: AVAILABLE_THEMES.map((theme) => ({
id: `theme-${theme.id}`,
label: theme.name,
checked: currentTheme === theme.id,
onClick: () => {
onThemeChange(theme.id);
onClose();
},
})),
});
return items;
}, [
hasSelection,
selectionUrl,
currentFontSize,
currentTheme,
isMagnified,
onSplitHorizontal,
onSplitVertical,
onToggleMagnify,
onRestart,
handleCopy,
handlePaste,
handleClear,
handleOpenUrl,
onFontSizeChange,
onThemeChange,
onClose,
]);
// 渲染子菜单
const renderSubMenu = (items: MenuItem[]) => {
if (!subMenuPosition) return null;
return (
<SubMenuContainer
style={{
top: subMenuPosition.top,
left: subMenuPosition.left,
position: "fixed",
}}
>
{items.map((item) => (
<MenuItemButton
key={item.id}
onClick={item.onClick}
$disabled={item.disabled}
>
<CheckMark>{item.checked ? "✓" : ""}</CheckMark>
<MenuItemLabel>{item.label}</MenuItemLabel>
</MenuItemButton>
))}
</SubMenuContainer>
);
};
return (
<MenuContainer
ref={menuRef}
style={{
left: position.x,
top: position.y,
}}
onMouseLeave={() => setActiveSubMenu(null)}
>
{menuItems.map((item) =>
"type" in item && item.type === "divider" ? (
<MenuDividerLine key={item.id} />
) : (
<div key={item.id}>
<MenuItemButton
$danger={(item as MenuItem).danger}
$disabled={(item as MenuItem).disabled}
onClick={
(item as MenuItem).subItems
? undefined
: (item as MenuItem).onClick
}
disabled={(item as MenuItem).disabled}
onMouseEnter={
(item as MenuItem).subItems
? (e) => handleSubMenuHover(item.id, e)
: () => setActiveSubMenu(null)
}
>
{(item as MenuItem).icon}
<MenuItemLabel>{(item as MenuItem).label}</MenuItemLabel>
{(item as MenuItem).shortcut && (
<MenuItemShortcut>
{(item as MenuItem).shortcut}
</MenuItemShortcut>
)}
{(item as MenuItem).subItems && <ChevronRight size={14} />}
</MenuItemButton>
{activeSubMenu === item.id &&
(item as MenuItem).subItems &&
renderSubMenu((item as MenuItem).subItems!)}
</div>
),
)}
</MenuContainer>
);
});
export default TerminalContextMenu;
+455
View File
@@ -0,0 +1,455 @@
/**
* Web 浏览器面板
*
* 点击打开按钮后,创建独立的浏览器窗口显示网页。
* 使用 Tauri WebviewWindow 实现,支持访问任何网站。
*
* @module widgets/WebView
*/
import { memo, useState, useCallback, type KeyboardEvent } from "react";
import styled from "styled-components";
import { open } from "@tauri-apps/plugin-shell";
import { Globe, ExternalLink, Info, Rocket, X } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { createWebviewPanel, generatePanelId } from "@/lib/webview-api";
// ============================================================================
// 样式组件 - Waveterm 风格
// ============================================================================
const Container = styled.div`
display: flex;
flex-direction: column;
height: 100%;
background: rgb(34, 34, 34);
color: #f7f7f7;
`;
const Toolbar = styled.div`
display: flex;
align-items: center;
gap: 4px;
padding: 8px 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.16);
background: rgb(34, 34, 34);
flex-shrink: 0;
min-height: 44px;
box-sizing: border-box;
`;
const IconButton = styled.button<{ $disabled?: boolean }>`
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 4px;
background: transparent;
color: ${({ $disabled }) => ($disabled ? "#404040" : "#808080")};
cursor: ${({ $disabled }) => ($disabled ? "not-allowed" : "pointer")};
transition: all 0.15s ease;
&:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.1);
color: #f7f7f7;
}
svg {
width: 16px;
height: 16px;
}
`;
const UrlInputWrapper = styled.div`
flex: 1;
display: flex;
align-items: center;
gap: 8px;
padding: 4px 12px;
border-radius: 6px;
background: rgba(0, 0, 0, 0.5);
border: 1px solid transparent;
transition: all 0.15s ease;
&:focus-within {
border-color: rgb(88, 193, 66);
background: rgba(0, 0, 0, 0.7);
}
`;
const UrlIcon = styled.div`
display: flex;
align-items: center;
color: #808080;
svg {
width: 14px;
height: 14px;
}
`;
const UrlInput = styled.input`
flex: 1;
border: none;
background: transparent;
font-size: 13px;
color: #f7f7f7;
outline: none;
&::placeholder {
color: #666;
}
`;
const OpenButton = styled.button`
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border: none;
border-radius: 4px;
background: rgb(88, 193, 66);
color: #000;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.15s ease;
&:hover {
opacity: 0.9;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
svg {
width: 14px;
height: 14px;
}
`;
const ContentArea = styled.div`
flex: 1;
min-height: 0;
position: relative;
display: flex;
flex-direction: column;
background: rgba(0, 0, 0, 0.5);
`;
const WelcomePage = styled.div`
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 24px;
background: rgb(34, 34, 34);
color: #808080;
padding: 32px;
`;
const WelcomeIcon = styled.div`
display: flex;
align-items: center;
justify-content: center;
width: 80px;
height: 80px;
border-radius: 50%;
background: rgba(88, 193, 66, 0.1);
color: rgb(88, 193, 66);
svg {
width: 40px;
height: 40px;
}
`;
const WelcomeTitle = styled.h2`
font-size: 20px;
font-weight: 600;
color: #f7f7f7;
margin: 0;
`;
const WelcomeText = styled.p`
font-size: 14px;
text-align: center;
max-width: 400px;
margin: 0;
line-height: 1.6;
`;
const InfoBox = styled.div`
display: flex;
align-items: flex-start;
gap: 12px;
padding: 16px;
border-radius: 8px;
background: rgba(88, 193, 66, 0.1);
border: 1px solid rgba(88, 193, 66, 0.2);
max-width: 450px;
svg {
width: 20px;
height: 20px;
color: rgb(88, 193, 66);
flex-shrink: 0;
margin-top: 2px;
}
`;
const InfoText = styled.span`
font-size: 13px;
color: rgb(195, 200, 194);
line-height: 1.5;
`;
const QuickLinks = styled.div`
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
`;
const QuickLink = styled.button`
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 6px;
background: rgba(0, 0, 0, 0.5);
color: #f7f7f7;
font-size: 13px;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgb(88, 193, 66);
}
svg {
width: 14px;
height: 14px;
}
`;
// ============================================================================
// 工具函数
// ============================================================================
/**
* 确保 URL 有协议前缀
*/
function ensureUrlScheme(url: string): string {
if (!url) return "";
url = url.trim();
// 已有协议
if (/^(https?|file):\/\//i.test(url)) {
return url;
}
// 本地地址
if (/^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?/i.test(url)) {
return `http://${url}`;
}
// 看起来像域名
if (/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z]{2,})+/i.test(url)) {
return `https://${url}`;
}
// 默认当作搜索
return `https://www.google.com/search?q=${encodeURIComponent(url)}`;
}
// ============================================================================
// 主组件
// ============================================================================
/**
* Web 浏览器面板
*
* 点击打开按钮后创建独立的浏览器窗口
*/
export const WebView = memo(function WebView() {
const [inputValue, setInputValue] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
/**
* 在独立窗口中打开 URL
*/
const openInWebview = useCallback(async (url: string) => {
const finalUrl = ensureUrlScheme(url);
if (!finalUrl) return;
setIsLoading(true);
setError(null);
try {
const panelId = generatePanelId();
const hostname = new URL(finalUrl).hostname;
const result = await createWebviewPanel({
panel_id: panelId,
url: finalUrl,
title: hostname,
x: 0,
y: 0,
width: 1024,
height: 768,
});
if (result.success) {
setInputValue("");
console.log("[WebView] 独立窗口创建成功:", panelId);
} else {
console.error("[WebView] 创建窗口失败:", result.error);
setError(result.error || "创建失败");
}
} catch (err) {
console.error("[WebView] 打开窗口异常:", err);
setError(String(err));
} finally {
setIsLoading(false);
}
}, []);
/**
* 处理 URL 输入提交
*/
const handleUrlSubmit = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && inputValue.trim()) {
openInWebview(inputValue);
}
},
[inputValue, openInWebview],
);
/**
* 在系统浏览器中打开
*/
const openExternal = useCallback(async (url: string) => {
try {
await open(ensureUrlScheme(url));
} catch (e) {
console.error("[WebView] 打开外部浏览器失败:", e);
}
}, []);
return (
<Container>
<Toolbar>
<UrlInputWrapper>
<UrlIcon>
<Globe />
</UrlIcon>
<UrlInput
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleUrlSubmit}
placeholder="输入网址,按 Enter 打开..."
/>
{inputValue && (
<IconButton
onClick={() => setInputValue("")}
style={{ width: 20, height: 20 }}
>
<X size={12} />
</IconButton>
)}
</UrlInputWrapper>
<OpenButton
onClick={() => openInWebview(inputValue)}
disabled={!inputValue.trim() || isLoading}
>
<Rocket />
{isLoading ? "打开中..." : "打开"}
</OpenButton>
<Tooltip>
<TooltipTrigger asChild>
<IconButton
onClick={() => {
const url = inputValue ? ensureUrlScheme(inputValue) : null;
if (url) openExternal(url);
}}
>
<ExternalLink />
</IconButton>
</TooltipTrigger>
<TooltipContent>在系统浏览器中打开</TooltipContent>
</Tooltip>
</Toolbar>
<ContentArea>
<WelcomePage>
<WelcomeIcon>
<Globe />
</WelcomeIcon>
<WelcomeTitle>Web 浏览器</WelcomeTitle>
<WelcomeText>
输入网址后点击"打开",将创建独立的浏览器窗口。 支持访问任何网站,无
iframe 限制。
</WelcomeText>
{error && (
<InfoBox
style={{
background: "rgba(229, 77, 46, 0.1)",
borderColor: "rgba(229, 77, 46, 0.2)",
}}
>
<Info style={{ color: "rgb(229, 77, 46)" }} />
<InfoText style={{ color: "rgb(229, 77, 46)" }}>
错误: {error}
</InfoText>
</InfoBox>
)}
<InfoBox>
<Info />
<InfoText>
使用 Tauri 原生窗口技术,网页在独立窗口中打开, 可以正常访问
Google、GitHub 等所有网站。
</InfoText>
</InfoBox>
<QuickLinks>
<QuickLink onClick={() => openInWebview("https://www.google.com")}>
<Globe />
Google
</QuickLink>
<QuickLink onClick={() => openInWebview("https://github.com")}>
<Globe />
GitHub
</QuickLink>
<QuickLink onClick={() => openInWebview("https://chat.openai.com")}>
<Globe />
ChatGPT
</QuickLink>
<QuickLink onClick={() => openExternal("https://www.google.com")}>
<ExternalLink />
系统浏览器
</QuickLink>
</QuickLinks>
</WelcomePage>
</ContentArea>
</Container>
);
});
+142
View File
@@ -0,0 +1,142 @@
/**
* 单个小部件按钮组件
*
* 渲染小部件图标和标签,支持三种显示模式
* 包含 hover 效果和 tooltip 提示
*
* @module widgets/Widget
*/
import { useRef, useState, useEffect, memo } from "react";
import styled from "styled-components";
import * as LucideIcons from "lucide-react";
import { LucideIcon } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { WidgetConfig, WidgetDisplayMode } from "./types";
interface WidgetProps {
/** 小部件配置 */
config: WidgetConfig;
/** 显示模式 */
mode: WidgetDisplayMode;
/** 点击回调 */
onClick: () => void;
/** 是否激活 */
isActive?: boolean;
}
const WidgetButton = styled.button<{
$mode: WidgetDisplayMode;
$isActive?: boolean;
$color?: string;
}>`
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%;
padding: ${({ $mode }) => ($mode === "supercompact" ? "6px 2px" : "8px 2px")};
border: none;
border-radius: 6px;
background: ${({ $isActive }) =>
$isActive ? "hsl(var(--primary) / 0.15)" : "transparent"};
color: hsl(var(--muted-foreground));
cursor: pointer;
transition: all 0.15s ease;
overflow: hidden;
&:hover {
background: hsl(var(--muted));
color: hsl(var(--foreground));
}
svg {
width: ${({ $mode }) => ($mode === "supercompact" ? "16px" : "20px")};
height: ${({ $mode }) => ($mode === "supercompact" ? "16px" : "20px")};
color: ${({ $color, $isActive }) =>
$isActive ? $color || "hsl(var(--primary))" : "currentColor"};
transition: color 0.15s ease;
}
&:hover svg {
color: ${({ $color }) => $color || "hsl(var(--foreground))"};
}
`;
const WidgetLabel = styled.span`
font-size: 10px;
margin-top: 2px;
width: 100%;
padding: 0 2px;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;
/**
* 根据图标名称获取 Lucide 图标组件
*/
function getIconByName(iconName: string): LucideIcon {
const IconComponent = (
LucideIcons as unknown as Record<string, LucideIcon | undefined>
)[iconName];
return IconComponent || LucideIcons.Activity;
}
/**
* 单个小部件按钮
*/
export const Widget = memo(function Widget({
config,
mode,
onClick,
isActive,
}: WidgetProps) {
const [isTruncated, setIsTruncated] = useState(false);
const labelRef = useRef<HTMLSpanElement>(null);
const Icon = getIconByName(config.icon);
// 检测标签是否被截断
useEffect(() => {
if (mode === "normal" && labelRef.current) {
const element = labelRef.current;
setIsTruncated(element.scrollWidth > element.clientWidth);
}
}, [mode, config.label]);
// 只有在 normal 模式下标签被截断时才禁用 tooltip
const shouldShowTooltip = mode !== "normal" || isTruncated;
const tooltipContent = config.description || config.label;
const button = (
<WidgetButton
$mode={mode}
$isActive={isActive}
$color={config.color}
onClick={onClick}
>
<Icon />
{mode === "normal" && config.label && (
<WidgetLabel ref={labelRef}>{config.label}</WidgetLabel>
)}
</WidgetButton>
);
if (!shouldShowTooltip) {
return button;
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent side="left">
<span className="whitespace-nowrap">{tooltipContent}</span>
</TooltipContent>
</Tooltip>
);
});
@@ -0,0 +1,98 @@
/**
* 小部件上下文管理
*
* 提供小部件配置的全局状态管理
* 支持配置持久化到 localStorage
*
* @module widgets/WidgetContext
*/
import { useState, useEffect, useCallback, ReactNode } from "react";
import { WidgetConfig, WidgetType, WidgetContextValue } from "./types";
import { DEFAULT_WIDGETS, STORAGE_KEYS } from "./constants";
import { WidgetContext } from "./context";
export { WidgetContext };
interface WidgetProviderProps {
children: ReactNode;
}
/**
* 从 localStorage 加载小部件配置
*/
function loadWidgetConfig(): WidgetConfig[] {
try {
const stored = localStorage.getItem(STORAGE_KEYS.WIDGET_CONFIG);
if (stored) {
const parsed = JSON.parse(stored);
// 合并默认配置和存储的配置
return DEFAULT_WIDGETS.map((defaultWidget) => {
const storedWidget = parsed.find(
(w: WidgetConfig) => w.id === defaultWidget.id,
);
return storedWidget
? { ...defaultWidget, ...storedWidget }
: defaultWidget;
});
}
} catch (e) {
console.error("加载小部件配置失败:", e);
}
return DEFAULT_WIDGETS;
}
/**
* 保存小部件配置到 localStorage
*/
function saveWidgetConfig(widgets: WidgetConfig[]): void {
try {
localStorage.setItem(STORAGE_KEYS.WIDGET_CONFIG, JSON.stringify(widgets));
} catch (e) {
console.error("保存小部件配置失败:", e);
}
}
/**
* 小部件上下文 Provider
*/
export function WidgetProvider({ children }: WidgetProviderProps) {
const [widgets, setWidgets] = useState<WidgetConfig[]>(loadWidgetConfig);
const [activeWidget, setActiveWidget] = useState<WidgetType | null>(null);
// 配置变化时保存到 localStorage
useEffect(() => {
saveWidgetConfig(widgets);
}, [widgets]);
const updateWidget = useCallback(
(id: string, config: Partial<WidgetConfig>) => {
setWidgets((prev) =>
prev.map((widget) =>
widget.id === id ? { ...widget, ...config } : widget,
),
);
},
[],
);
const toggleWidgetVisibility = useCallback((id: string) => {
setWidgets((prev) =>
prev.map((widget) =>
widget.id === id ? { ...widget, hidden: !widget.hidden } : widget,
),
);
}, []);
const value: WidgetContextValue = {
widgets,
updateWidget,
toggleWidgetVisibility,
activeWidget,
setActiveWidget,
};
return (
<WidgetContext.Provider value={value}>{children}</WidgetContext.Provider>
);
}
@@ -0,0 +1,254 @@
/**
* 右侧小部件栏组件
*
* 显示在主内容区右侧的垂直工具栏
* 支持响应式模式切换(normal/compact/supercompact)
* 包含小部件按钮和设置菜单
*
* @module widgets/WidgetsSidebar
*/
import { memo, useRef, useState, useEffect, useCallback } from "react";
import styled from "styled-components";
import { Settings } from "lucide-react";
import {
TooltipProvider,
Tooltip,
TooltipTrigger,
TooltipContent,
} from "@/components/ui/tooltip";
import { Widget } from "./Widget";
import { SettingsFloatingMenu } from "./SettingsFloatingMenu";
import { useVisibleWidgets, useWidgetContext } from "./useWidgets";
import { WidgetDisplayMode, WidgetType } from "./types";
import {
WIDGETS_SIDEBAR_WIDTH,
MODE_SWITCH_GRACE_PERIOD,
MIN_HEIGHT_PER_WIDGET,
} from "./constants";
interface WidgetsSidebarProps {
/** 小部件点击回调 */
onWidgetClick: (type: WidgetType) => void;
}
const Container = styled.div`
display: flex;
flex-direction: column;
width: ${WIDGETS_SIDEBAR_WIDTH}px;
min-width: ${WIDGETS_SIDEBAR_WIDTH}px;
height: 100%;
padding: 8px 4px;
background: hsl(var(--card));
border-left: 1px solid hsl(var(--border));
overflow: hidden;
user-select: none;
`;
const WidgetsContainer = styled.div<{ $mode: WidgetDisplayMode }>`
display: ${({ $mode }) => ($mode === "supercompact" ? "grid" : "flex")};
flex-direction: column;
grid-template-columns: ${({ $mode }) =>
$mode === "supercompact" ? "repeat(2, 1fr)" : "none"};
gap: 2px;
flex: 1;
overflow: hidden;
`;
const Spacer = styled.div`
flex: 1;
`;
const BottomSection = styled.div<{ $mode: WidgetDisplayMode }>`
display: ${({ $mode }) => ($mode === "supercompact" ? "grid" : "flex")};
flex-direction: column;
grid-template-columns: ${({ $mode }) =>
$mode === "supercompact" ? "repeat(2, 1fr)" : "none"};
gap: 2px;
padding-top: 8px;
border-top: 1px solid hsl(var(--border));
margin-top: 8px;
`;
const SettingsButton = styled.button<{
$mode: WidgetDisplayMode;
$isOpen?: boolean;
}>`
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%;
padding: ${({ $mode }) => ($mode === "supercompact" ? "6px 2px" : "8px 2px")};
border: none;
border-radius: 6px;
background: ${({ $isOpen }) =>
$isOpen ? "hsl(var(--muted))" : "transparent"};
color: hsl(var(--muted-foreground));
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: hsl(var(--muted));
color: hsl(var(--foreground));
}
svg {
width: ${({ $mode }) => ($mode === "supercompact" ? "16px" : "20px")};
height: ${({ $mode }) => ($mode === "supercompact" ? "16px" : "20px")};
}
`;
const SettingsLabel = styled.span`
font-size: 10px;
margin-top: 2px;
`;
/** 隐藏的测量容器,用于计算 normal 模式所需高度 */
const MeasurementContainer = styled.div`
position: absolute;
z-index: -1;
opacity: 0;
pointer-events: none;
display: flex;
flex-direction: column;
width: ${WIDGETS_SIDEBAR_WIDTH}px;
padding: 8px 4px;
`;
/**
* 右侧小部件栏
*/
export const WidgetsSidebar = memo(function WidgetsSidebar({
onWidgetClick,
}: WidgetsSidebarProps) {
const widgets = useVisibleWidgets();
const { activeWidget } = useWidgetContext();
const [mode, setMode] = useState<WidgetDisplayMode>("normal");
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const measurementRef = useRef<HTMLDivElement>(null);
const settingsButtonRef = useRef<HTMLButtonElement>(null);
/**
* 检查并更新显示模式
*/
const checkModeNeeded = useCallback(() => {
if (!containerRef.current || !measurementRef.current) return;
const containerHeight = containerRef.current.clientHeight;
const normalHeight = measurementRef.current.scrollHeight;
let newMode: WidgetDisplayMode = "normal";
// 如果 normal 模式高度超过容器高度,切换到 compact
if (normalHeight > containerHeight - MODE_SWITCH_GRACE_PERIOD) {
newMode = "compact";
// 计算 supercompact 模式阈值
const totalWidgets = widgets.length + 1; // +1 for settings
const requiredHeight = totalWidgets * MIN_HEIGHT_PER_WIDGET;
if (requiredHeight > containerHeight) {
newMode = "supercompact";
}
}
if (newMode !== mode) {
setMode(newMode);
}
}, [mode, widgets.length]);
// 监听容器尺寸变化
useEffect(() => {
const resizeObserver = new ResizeObserver(() => {
checkModeNeeded();
});
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
return () => {
resizeObserver.disconnect();
};
}, [checkModeNeeded]);
// 小部件数量变化时重新检查
useEffect(() => {
checkModeNeeded();
}, [widgets, checkModeNeeded]);
const handleSettingsMenuItemClick = useCallback(
(type: WidgetType) => {
onWidgetClick(type);
},
[onWidgetClick],
);
return (
<TooltipProvider>
<Container ref={containerRef}>
<WidgetsContainer $mode={mode}>
{widgets.map((widget) => (
<Widget
key={widget.id}
config={widget}
mode={mode}
onClick={() => onWidgetClick(widget.type)}
isActive={activeWidget === widget.type}
/>
))}
</WidgetsContainer>
{mode !== "supercompact" && <Spacer />}
<BottomSection $mode={mode}>
<Tooltip>
<TooltipTrigger asChild>
<SettingsButton
ref={settingsButtonRef}
$mode={mode}
$isOpen={isSettingsOpen}
onClick={() => setIsSettingsOpen(!isSettingsOpen)}
>
<Settings />
{mode === "normal" && <SettingsLabel>设置</SettingsLabel>}
</SettingsButton>
</TooltipTrigger>
{!isSettingsOpen && (
<TooltipContent side="left">
<span className="whitespace-nowrap">设置与帮助</span>
</TooltipContent>
)}
</Tooltip>
</BottomSection>
<SettingsFloatingMenu
isOpen={isSettingsOpen}
onClose={() => setIsSettingsOpen(false)}
referenceElement={settingsButtonRef.current}
onMenuItemClick={handleSettingsMenuItemClick}
/>
{/* 隐藏的测量容器 */}
<MeasurementContainer ref={measurementRef}>
{widgets.map((widget) => (
<Widget
key={`measure-${widget.id}`}
config={widget}
mode="normal"
onClick={() => {}}
/>
))}
<Spacer />
<SettingsButton $mode="normal">
<Settings />
<SettingsLabel>设置</SettingsLabel>
</SettingsButton>
</MeasurementContainer>
</Container>
</TooltipProvider>
);
});
@@ -0,0 +1,187 @@
/**
* 小部件系统常量配置
*
* 定义默认小部件配置、颜色、图表元数据等
*
* @module widgets/constants
*/
import {
WidgetConfig,
TimeSeriesMeta,
PlotType,
SysinfoDataPoint,
} from "./types";
/**
* 默认小部件配置
*/
export const DEFAULT_WIDGETS: WidgetConfig[] = [
{
id: "terminal",
label: "终端",
icon: "Terminal",
color: "var(--accent-color)",
description: "打开终端",
displayOrder: 1,
type: "terminal",
},
{
id: "files",
label: "文件",
icon: "FolderOpen",
color: "var(--sysinfo-mem-color, #FFC107)",
description: "浏览文件",
displayOrder: 2,
type: "files",
},
{
id: "web",
label: "浏览器",
icon: "Globe",
color: "var(--info-color, #2196F3)",
description: "内嵌浏览器",
displayOrder: 3,
type: "web",
},
{
id: "sysinfo",
label: "系统",
icon: "Activity",
color: "var(--sysinfo-cpu-color, #58C142)",
description: "系统监控",
displayOrder: 4,
type: "sysinfo",
},
];
/**
* 设置菜单项配置
*/
export const SETTINGS_MENU_ITEMS = [
{
id: "settings",
label: "设置",
icon: "Settings",
type: "settings" as const,
},
{ id: "tips", label: "提示", icon: "Lightbulb", type: "tips" as const },
{ id: "secrets", label: "密钥", icon: "Lock", type: "secrets" as const },
{ id: "help", label: "帮助", icon: "HelpCircle", type: "help" as const },
];
/**
* 小部件栏宽度(像素)
*/
export const WIDGETS_SIDEBAR_WIDTH = 48;
/**
* 模式切换缓冲区(像素)
* 防止在边界值附近频繁切换
*/
export const MODE_SWITCH_GRACE_PERIOD = 10;
/**
* 每个小部件的最小高度(像素)
* 用于计算 supercompact 模式切换阈值
*/
export const MIN_HEIGHT_PER_WIDGET = 32;
/**
* 系统信息数据点数量
* 120 点 = 2 分钟历史(每秒 1 点)
*/
export const DEFAULT_NUM_POINTS = 120;
/**
* 系统信息更新间隔(毫秒)
*/
export const SYSINFO_UPDATE_INTERVAL = 1000;
/**
* 默认 CPU 图表元数据
*/
export function defaultCpuMeta(name: string): TimeSeriesMeta {
return {
name,
label: "%",
miny: 0,
maxy: 100,
color: "var(--sysinfo-cpu-color, #58C142)",
decimalPlaces: 0,
};
}
/**
* 默认内存图表元数据
*/
export function defaultMemMeta(name: string, maxY: string): TimeSeriesMeta {
return {
name,
label: "GB",
miny: 0,
maxy: maxY,
color: "var(--sysinfo-mem-color, #FFC107)",
decimalPlaces: 1,
};
}
/**
* 默认图表元数据映射
*/
export const DEFAULT_PLOT_META: Record<string, TimeSeriesMeta> = {
cpu: defaultCpuMeta("CPU %"),
"mem:total": defaultMemMeta("内存总量", "mem:total"),
"mem:used": defaultMemMeta("已用内存", "mem:total"),
"mem:free": defaultMemMeta("可用内存", "mem:total"),
"mem:available": defaultMemMeta("可用内存", "mem:total"),
};
// 添加 CPU 核心元数据(最多 32 核)
for (let i = 0; i < 32; i++) {
DEFAULT_PLOT_META[`cpu:${i}`] = defaultCpuMeta(`核心 ${i}`);
}
/**
* 图表类型到指标的映射函数
*/
export const PLOT_TYPE_METRICS: Record<
PlotType,
(data: SysinfoDataPoint) => string[]
> = {
CPU: () => ["cpu"],
Mem: () => ["mem:used"],
"CPU + Mem": () => ["cpu", "mem:used"],
"All CPU": (data) => {
if (!data) return ["cpu"];
return Object.keys(data)
.filter((key) => key.startsWith("cpu:"))
.sort((a, b) => {
const valA = parseInt(a.replace("cpu:", ""));
const valB = parseInt(b.replace("cpu:", ""));
return valA - valB;
});
},
};
/**
* 图表颜色列表
*/
export const PLOT_COLORS = [
"#58C142",
"#FFC107",
"#FF5722",
"#2196F3",
"#9C27B0",
"#00BCD4",
"#FFEB3B",
"#795548",
];
/**
* localStorage 键名
*/
export const STORAGE_KEYS = {
WIDGET_CONFIG: "proxycast-widget-config",
ACTIVE_WIDGET: "proxycast-active-widget",
};
@@ -0,0 +1,10 @@
/**
* 小部件上下文定义
*
* @module widgets/context
*/
import { createContext } from "react";
import { WidgetContextValue } from "./types";
export const WidgetContext = createContext<WidgetContextValue | null>(null);
+18
View File
@@ -0,0 +1,18 @@
/**
* 小部件系统模块导出
*
* @module terminal/widgets
*/
export * from "./types";
export * from "./constants";
export * from "./WidgetContext";
export * from "./useWidgets";
export * from "./Widget";
export * from "./WidgetsSidebar";
export * from "./SettingsFloatingMenu";
export * from "./SysinfoView";
export * from "./FileBrowserView";
export * from "./WebView";
export * from "./FileContextMenu";
export * from "./EntryManagerOverlay";
+150
View File
@@ -0,0 +1,150 @@
/**
* 小部件系统类型定义
*
* 定义 WidgetsSidebar 相关的所有类型
* 包括小部件配置、显示模式、系统信息数据等
*
* @module widgets/types
*/
import { LucideIcon } from "lucide-react";
/**
* 小部件类型枚举
*/
export type WidgetType =
| "terminal"
| "files"
| "web"
| "sysinfo"
| "settings"
| "tips"
| "help"
| "secrets";
/**
* 小部件显示模式
* - normal: 显示图标和标签
* - compact: 仅显示图标
* - supercompact: 2列网格布局,仅图标
*/
export type WidgetDisplayMode = "normal" | "compact" | "supercompact";
/**
* 小部件配置
*/
export interface WidgetConfig {
/** 唯一标识符 */
id: string;
/** 显示标签 */
label: string;
/** 图标名称(Lucide 图标) */
icon: string;
/** 图标颜色 */
color?: string;
/** 描述文本(用于 tooltip) */
description?: string;
/** 显示顺序 */
displayOrder: number;
/** 是否隐藏 */
hidden?: boolean;
/** 小部件类型 */
type: WidgetType;
}
/**
* 系统信息数据点
*/
export interface SysinfoDataPoint {
/** 时间戳(毫秒) */
ts: number;
/** CPU 使用率(0-100) */
cpu: number;
/** 已用内存(GB) */
"mem:used": number;
/** 总内存(GB) */
"mem:total": number;
/** 各核心 CPU 使用率 */
[key: `cpu:${number}`]: number;
}
/**
* 时间序列元数据
*/
export interface TimeSeriesMeta {
/** 名称 */
name: string;
/** Y轴标签 */
label: string;
/** Y轴最小值 */
miny: number;
/** Y轴最大值(可以是数字或数据字段名) */
maxy: number | string;
/** 线条颜色 */
color: string;
/** 小数位数 */
decimalPlaces: number;
}
/**
* 图表类型
*/
export type PlotType = "CPU" | "Mem" | "CPU + Mem" | "All CPU";
/**
* 文件条目
*/
export interface FileEntry {
/** 文件名 */
name: string;
/** 完整路径 */
path: string;
/** 是否为目录 */
isDir: boolean;
/** 文件大小(字节) */
size: number;
/** 修改时间(时间戳) */
modifiedAt: number;
/** 文件权限(已废弃,使用 modeStr) */
permissions?: string;
/** 文件类型/扩展名 */
fileType?: string;
/** 是否为隐藏文件 */
isHidden?: boolean;
/** 文件权限字符串(如 -rw-r--r--) */
modeStr?: string;
/** 文件权限数字(8进制) */
mode?: number;
/** MIME 类型 */
mimeType?: string;
/** 是否为符号链接 */
isSymlink?: boolean;
}
/**
* 设置菜单项
*/
export interface SettingsMenuItem {
/** 图标 */
icon: LucideIcon;
/** 标签 */
label: string;
/** 点击处理 */
onClick: () => void;
}
/**
* 小部件上下文值
*/
export interface WidgetContextValue {
/** 小部件配置列表 */
widgets: WidgetConfig[];
/** 更新小部件配置 */
updateWidget: (id: string, config: Partial<WidgetConfig>) => void;
/** 切换小部件显示/隐藏 */
toggleWidgetVisibility: (id: string) => void;
/** 当前激活的小部件 */
activeWidget: WidgetType | null;
/** 设置激活的小部件 */
setActiveWidget: (type: WidgetType | null) => void;
}
@@ -0,0 +1,32 @@
/**
* 小部件相关 Hooks
*
* 提供小部件上下文访问和工具函数
*
* @module widgets/useWidgets
*/
import { useContext } from "react";
import { WidgetContext } from "./context";
import { WidgetConfig, WidgetContextValue } from "./types";
/**
* 使用小部件上下文的 Hook
*/
export function useWidgetContext(): WidgetContextValue {
const context = useContext(WidgetContext);
if (!context) {
throw new Error("useWidgetContext must be used within a WidgetProvider");
}
return context;
}
/**
* 获取可见的小部件列表(按显示顺序排序)
*/
export function useVisibleWidgets(): WidgetConfig[] {
const { widgets } = useWidgetContext();
return widgets
.filter((w) => !w.hidden)
.sort((a, b) => a.displayOrder - b.displayOrder);
}
+17 -5
View File
@@ -2,7 +2,13 @@
* @file ComponentDebugContext.tsx
* @description 组件视图调试上下文 - 提供全局组件轮廓显示和信息查看功能
*/
import { createContext, useContext, useState, useCallback, ReactNode } from "react";
import {
createContext,
useContext,
useState,
useCallback,
ReactNode,
} from "react";
export interface ComponentInfo {
name: string;
@@ -15,7 +21,6 @@ export interface ComponentInfo {
/** 当前 DOM 元素引用,用于查找父组件 */
element?: HTMLElement;
/** 当前 Fiber 节点,用于向上遍历 */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fiber?: any;
}
@@ -32,7 +37,9 @@ interface ComponentDebugContextType {
hideComponentInfo: () => void;
}
const ComponentDebugContext = createContext<ComponentDebugContextType | null>(null);
const ComponentDebugContext = createContext<ComponentDebugContextType | null>(
null,
);
const STORAGE_KEY = "component-debug-enabled";
@@ -41,7 +48,9 @@ export function ComponentDebugProvider({ children }: { children: ReactNode }) {
const stored = localStorage.getItem(STORAGE_KEY);
return stored === "true";
});
const [componentInfo, setComponentInfo] = useState<ComponentInfo | null>(null);
const [componentInfo, setComponentInfo] = useState<ComponentInfo | null>(
null,
);
const setEnabled = useCallback((value: boolean) => {
setEnabledState(value);
@@ -71,10 +80,13 @@ export function ComponentDebugProvider({ children }: { children: ReactNode }) {
);
}
// eslint-disable-next-line react-refresh/only-export-components
export function useComponentDebug() {
const context = useContext(ComponentDebugContext);
if (!context) {
throw new Error("useComponentDebug must be used within ComponentDebugProvider");
throw new Error(
"useComponentDebug must be used within ComponentDebugProvider",
);
}
return context;
}
+14 -5
View File
@@ -1,16 +1,25 @@
// Safe Tauri invoke wrapper for web mode compatibility
const safeInvoke = async (cmd: string, args?: any): Promise<any> => {
// Check if Tauri is available
if (typeof window !== "undefined" && (window as any).__TAURI__) {
// Check if Tauri is available via window.__TAURI__
if (
typeof window !== "undefined" &&
(window as any).__TAURI__?.core?.invoke
) {
return (window as any).__TAURI__.core.invoke(cmd, args);
}
// Legacy check for older Tauri versions
if (typeof window !== "undefined" && (window as any).__TAURI__?.invoke) {
return (window as any).__TAURI__.invoke(cmd, args);
}
// Try to use real Tauri API
// Try to use real Tauri API (dynamic import)
try {
const { invoke } = await import("@tauri-apps/api/core");
return invoke(cmd, args);
} catch (e) {
console.error(`[useTauri] Failed to invoke ${cmd}:`, e);
} catch (_e) {
// Not in Tauri environment, return mock data for development
console.warn(`[useTauri] Tauri API not available for command: ${cmd}`);
throw new Error(`Tauri API not available. Command: ${cmd}`);
}
};
+24
View File
@@ -11,6 +11,7 @@
* - Loads language config from Tauri backend
* - Handles loading state
* - Applies fade-in transition to prevent text flashing
* - Falls back to default language in non-Tauri environments
*/
import React, { useEffect, useState } from "react";
@@ -19,6 +20,15 @@ import { I18nPatchProvider } from "./I18nPatchProvider";
import { Language } from "./text-map";
import { replaceTextInDOM } from "./dom-replacer";
/**
* 检查是否在 Tauri 环境中运行
*/
function isTauriEnvironment(): boolean {
if (typeof window === "undefined") return false;
const w = window as any;
return !!(w.__TAURI__?.core?.invoke || w.__TAURI__?.invoke);
}
interface WithI18nPatchOptions {
/** Fade-in duration in milliseconds (default: 150ms) */
fadeInDuration?: number;
@@ -42,6 +52,18 @@ export function withI18nPatch<P extends object>(
const [isReady, setIsReady] = useState(false);
useEffect(() => {
// 如果不在 Tauri 环境,使用默认配置
if (!isTauriEnvironment()) {
console.warn("[i18n] Not in Tauri environment, using default language");
const defaultConfig = { language: "zh" } as Config;
setConfig(defaultConfig);
replaceTextInDOM("zh");
requestAnimationFrame(() => {
setIsReady(true);
});
return;
}
getConfig()
.then((c) => {
setConfig(c);
@@ -56,6 +78,8 @@ export function withI18nPatch<P extends object>(
.catch((err) => {
console.error("[i18n] Failed to load config:", err);
// Use default language on error
const defaultConfig = { language: "zh" } as Config;
setConfig(defaultConfig);
replaceTextInDOM("zh");
requestAnimationFrame(() => {
setIsReady(true);
+1
View File
@@ -34,6 +34,7 @@
- `flowEventManager.ts` - 流量事件管理器
- `notificationService.ts` - 通知服务
- `terminal-api.ts` - 终端核心能力 API 封装(Terminal Core)
- `webview-api.ts` - Webview 管理 API(Tauri 2.x multiwebview)
- `utils.ts` - 通用工具函数
## 更新提醒
+221
View File
@@ -0,0 +1,221 @@
/**
* 连接管理 API
*
* 提供与 Tauri 后端连接命令交互的 TypeScript 接口。
*
* @module lib/connection-api
*/
import { invoke } from "@tauri-apps/api/core";
/**
* 连接类型
*/
export type ConnectionType = "local" | "ssh" | "wsl";
/**
* 连接来源
*/
export type ConnectionSource = "builtIn" | "userConfig" | "sshConfig";
/**
* 连接列表条目
*/
export interface ConnectionListEntry {
/** 连接名称/标识 */
name: string;
/** 连接类型 */
type: ConnectionType;
/** 显示标签 */
label: string;
/** 配置来源 */
source: ConnectionSource;
/** 主机名 */
host?: string;
/** 用户名 */
user?: string;
/** 端口 */
port?: number;
}
/**
* 连接配置
*/
export interface ConnectionConfig {
/** 连接类型 */
type: ConnectionType;
/** 用户名 */
user?: string;
/** 主机名 */
host?: string;
/** 端口 */
port?: number;
/** 身份文件 */
identityFile?: string;
/** 身份文件列表 */
identityFiles?: string[];
/** 跳板机 */
proxyJump?: string;
/** 显示顺序 */
displayOrder?: number;
/** 是否隐藏 */
hidden?: boolean;
/** WSL 发行版 */
wslDistro?: string;
}
/**
* 添加连接请求
*/
export interface AddConnectionRequest {
/** 连接名称 */
name: string;
/** 连接类型 */
type: ConnectionType;
/** 用户名 */
user?: string;
/** 主机名 */
host?: string;
/** 端口 */
port?: number;
/** 身份文件 */
identityFile?: string;
/** 跳板机 */
proxyJump?: string;
/** WSL 发行版 */
wslDistro?: string;
}
/**
* 通用响应
*/
export interface ConnectionResponse {
/** 是否成功 */
success: boolean;
/** 错误信息 */
error?: string;
}
/**
* 获取所有可用连接
*/
export async function listConnections(): Promise<ConnectionListEntry[]> {
return invoke<ConnectionListEntry[]>("connection_list");
}
/**
* 添加新连接
*/
export async function addConnection(
request: AddConnectionRequest,
): Promise<ConnectionResponse> {
return invoke<ConnectionResponse>("connection_add", { request });
}
/**
* 更新连接
*/
export async function updateConnection(
name: string,
config: ConnectionConfig,
): Promise<ConnectionResponse> {
return invoke<ConnectionResponse>("connection_update", {
request: { name, config },
});
}
/**
* 删除连接
*/
export async function deleteConnection(
name: string,
): Promise<ConnectionResponse> {
return invoke<ConnectionResponse>("connection_delete", { name });
}
/**
* 获取连接配置
*/
export async function getConnection(
name: string,
): Promise<ConnectionConfig | null> {
return invoke<ConnectionConfig | null>("connection_get", { name });
}
/**
* 获取配置文件路径
*/
export async function getConnectionConfigPath(): Promise<string> {
return invoke<string>("connection_get_config_path");
}
/**
* 获取原始配置内容
*/
export async function getRawConnectionConfig(): Promise<string> {
return invoke<string>("connection_get_raw_config");
}
/**
* 保存原始配置内容
*/
export async function saveRawConnectionConfig(
content: string,
): Promise<ConnectionResponse> {
return invoke<ConnectionResponse>("connection_save_raw_config", { content });
}
/**
* 测试连接
*/
export async function testConnection(
name: string,
): Promise<ConnectionResponse> {
return invoke<ConnectionResponse>("connection_test", { name });
}
/**
* 导入 SSH Host 到用户配置
*/
export async function importSSHHost(
hostName: string,
): Promise<ConnectionResponse> {
return invoke<ConnectionResponse>("connection_import_ssh_host", {
host_name: hostName,
});
}
/**
* 将连接名称转换为 terminal 会话的 connection 字符串
*
* @param entry - 连接列表条目
* @returns 用于创建终端会话的连接字符串
*/
export function connectionToSessionString(entry: ConnectionListEntry): string {
if (entry.type === "local") {
return "";
}
if (entry.type === "ssh") {
// 如果来自 SSH 配置,直接使用名称
if (entry.source === "sshConfig") {
return entry.name;
}
// 用户配置的 SSH 连接
const user = entry.user || "root";
const host = entry.host || entry.name;
const port = entry.port;
if (port && port !== 22) {
return `${user}@${host}:${port}`;
}
return `${user}@${host}`;
}
if (entry.type === "wsl") {
return `wsl://${entry.name}`;
}
return "";
}
+3 -1
View File
@@ -93,11 +93,13 @@ export const TERMINAL_STATUS_EVENT = "terminal:status";
*
* PTY 使用默认大小 (24x80) 预创建,前端连接后通过 resizeTerminal 同步实际大小。
*
* @param cwd - 工作目录(可选)
* @returns 会话 ID
*/
export async function createTerminalSession(): Promise<string> {
export async function createTerminalSession(cwd?: string): Promise<string> {
const response = await invoke<CreateSessionResponse>(
"terminal_create_session",
{ cwd },
);
return response.session_id;
}
+186
View File
@@ -0,0 +1,186 @@
/**
* Webview 管理 API
*
* 提供与 Tauri 后端 webview 命令交互的 TypeScript 接口。
* 使用 Tauri 2.x 的 multiwebview 功能创建独立的浏览器窗口。
*
* @module lib/webview-api
*/
import { invoke } from "@tauri-apps/api/core";
import { Webview } from "@tauri-apps/api/webview";
/**
* Webview 面板信息
*/
export interface WebviewPanelInfo {
/** 面板 ID */
id: string;
/** 当前 URL */
url: string;
/** 面板标题 */
title: string;
/** X 坐标 */
x: number;
/** Y 坐标 */
y: number;
/** 宽度 */
width: number;
/** 高度 */
height: number;
}
/**
* 创建 webview 面板的请求参数
*/
export interface CreateWebviewRequest {
/** 面板 ID(唯一标识) */
panel_id: string;
/** 要加载的 URL */
url: string;
/** 面板标题(可选) */
title?: string;
/** X 坐标(相对于主窗口) */
x: number;
/** Y 坐标(相对于主窗口) */
y: number;
/** 宽度 */
width: number;
/** 高度 */
height: number;
}
/**
* 创建 webview 面板的响应
*/
export interface CreateWebviewResponse {
/** 是否成功 */
success: boolean;
/** 面板 ID */
panel_id: string;
/** 错误信息(如果有) */
error?: string;
}
/**
* 创建一个新的 webview 窗口来显示外部 URL
*
* @param request - 创建请求参数
* @returns 创建结果
*/
export async function createWebviewPanel(
request: CreateWebviewRequest,
): Promise<CreateWebviewResponse> {
return invoke<CreateWebviewResponse>("create_webview_panel", { request });
}
/**
* 关闭 webview 面板
*
* 尝试多种方法关闭 webview:
* 1. 使用 Tauri JavaScript API 直接关闭
* 2. 使用后端命令关闭
*
* @param panelId - 面板 ID
* @returns 是否成功
*/
export async function closeWebviewPanel(panelId: string): Promise<boolean> {
console.log("[webview-api] 尝试关闭 webview:", panelId);
// 方法 1: 尝试使用 Tauri JavaScript API 直接关闭
try {
const webview = await Webview.getByLabel(panelId);
if (webview) {
console.log("[webview-api] 找到 webview,尝试关闭");
await webview.close();
console.log("[webview-api] Tauri API 关闭成功");
// 也调用后端清理状态
await invoke<boolean>("close_webview_panel", { panel_id: panelId }).catch(
() => {},
);
return true;
}
} catch (e) {
console.warn("[webview-api] Tauri API 关闭失败:", e);
}
// 方法 2: 使用后端命令关闭
try {
const result = await invoke<boolean>("close_webview_panel", {
panel_id: panelId,
});
console.log("[webview-api] 后端命令关闭结果:", result);
return result;
} catch (e) {
console.error("[webview-api] 后端命令关闭失败:", e);
return false;
}
}
/**
* 导航到新 URL
*
* @param panelId - 面板 ID
* @param url - 新 URL
* @returns 是否成功
*/
export async function navigateWebviewPanel(
panelId: string,
url: string,
): Promise<boolean> {
return invoke<boolean>("navigate_webview_panel", { panel_id: panelId, url });
}
/**
* 获取所有活跃的 webview 面板
*
* @returns 面板列表
*/
export async function getWebviewPanels(): Promise<WebviewPanelInfo[]> {
return invoke<WebviewPanelInfo[]>("get_webview_panels");
}
/**
* 聚焦指定的 webview 面板
*
* @param panelId - 面板 ID
* @returns 是否成功
*/
export async function focusWebviewPanel(panelId: string): Promise<boolean> {
return invoke<boolean>("focus_webview_panel", { panel_id: panelId });
}
/**
* 调整 webview 面板大小和位置
*
* @param panelId - 面板 ID
* @param x - 新的 X 坐标
* @param y - 新的 Y 坐标
* @param width - 新的宽度
* @param height - 新的高度
* @returns 是否成功
*/
export async function resizeWebviewPanel(
panelId: string,
x: number,
y: number,
width: number,
height: number,
): Promise<boolean> {
return invoke<boolean>("resize_webview_panel", {
panel_id: panelId,
x,
y,
width,
height,
});
}
/**
* 生成唯一的面板 ID
*
* @returns 唯一 ID
*/
export function generatePanelId(): string {
return `webview-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
}
+22
View File
@@ -0,0 +1,22 @@
/**
* 页面类型定义
*
* 支持静态页面和动态插件页面
* - 静态页面: 预定义的页面标识符
* - 动态插件页面: `plugin:${string}` 格式,如 "plugin:machine-id-tool"
*
* @module types/page
*/
export type Page =
| "provider-pool"
| "api-server"
| "agent"
| "tools"
| "plugins"
| "settings"
| "terminal"
| "sysinfo"
| "files"
| "web"
| `plugin:${string}`;