mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
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:
Generated
+507
-42
@@ -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
@@ -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",
|
||||
|
||||
Generated
+99
-1
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 @@
|
||||
{"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}]}]}}
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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":
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -27,6 +27,7 @@ React 组件层,包含 UI 组件和业务组件。
|
||||
- `switch/` - 开关控制组件
|
||||
- `terminal/` - 内置终端组件(使用 Tauri Commands)
|
||||
- `tools/` - 工具页面组件
|
||||
- `widgets/` - 右侧小部件栏组件(移植自 Waveterm)
|
||||
- `ui/` - 通用 UI 组件(按钮、输入框等)
|
||||
- `websocket/` - WebSocket 管理组件
|
||||
- `AppSidebar.tsx` - 全局图标侧边栏(类似 cherry-studio)
|
||||
|
||||
@@ -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"><{componentInfo.tagName.toLowerCase()}></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">
|
||||
<{componentInfo.tagName.toLowerCase()}>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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)} />
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 更新提醒
|
||||
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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` - 通用工具函数
|
||||
|
||||
## 更新提醒
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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}`;
|
||||
Reference in New Issue
Block a user