diff --git a/package-lock.json b/package-lock.json index cfe1eb350..d877fd2b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index efc8fedeb..a0cbd1976 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index fc4008690..be2256d79 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -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" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 18f8cec65..6ea617b72 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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 diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 5eae2a317..fc39ad1e3 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -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", diff --git a/src-tauri/gen/schemas/capabilities.json b/src-tauri/gen/schemas/capabilities.json index a50618471..39dc76af1 100644 --- a/src-tauri/gen/schemas/capabilities.json +++ b/src-tauri/gen/schemas/capabilities.json @@ -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}]}]}} \ No newline at end of file +{"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}]}]}} \ No newline at end of file diff --git a/src-tauri/src/app/bootstrap.rs b/src-tauri/src/app/bootstrap.rs index 0d90afa15..7f0be9d38 100644 --- a/src-tauri/src/app/bootstrap.rs +++ b/src-tauri/src/app/bootstrap.rs @@ -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>, pub shared_tokens: Arc>, @@ -219,6 +221,10 @@ pub fn init_states(config: &Config) -> Result { // 初始化终端管理器状态(延迟初始化,在 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 { 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, diff --git a/src-tauri/src/app/runner.rs b/src-tauri/src/app/runner.rs index 0219cf662..0bdb95a2c 100644 --- a/src-tauri/src/app/runner.rs +++ b/src-tauri/src/app/runner.rs @@ -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"); diff --git a/src-tauri/src/commands/connection_cmd.rs b/src-tauri/src/commands/connection_cmd.rs new file mode 100644 index 000000000..0d14008dc --- /dev/null +++ b/src-tauri/src/commands/connection_cmd.rs @@ -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, + /// 主机名(SSH) + pub host: Option, + /// 端口(SSH) + pub port: Option, + /// 身份文件 + pub identity_file: Option, + /// 跳板机 + pub proxy_jump: Option, + /// WSL 发行版 + pub wsl_distro: Option, +} + +/// 更新连接的请求参数 +#[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, +} + +impl ConnectionResponse { + pub fn ok() -> Self { + Self { + success: true, + error: None, + } + } + + pub fn err(msg: impl Into) -> Self { + Self { + success: false, + error: Some(msg.into()), + } + } +} + +/// 获取所有可用连接 +/// +/// 返回本地连接 + 用户配置 + SSH 配置中的 Host 列表 +#[tauri::command] +pub fn connection_list() -> Result, 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, 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 { + 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), + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 172b7d5d6..2d005cb2b 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -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; diff --git a/src-tauri/src/commands/terminal_cmd.rs b/src-tauri/src/commands/terminal_cmd.rs index 8d6cd8a0d..1fda8932d 100644 --- a/src-tauri/src/commands/terminal_cmd.rs +++ b/src-tauri/src/commands/terminal_cmd.rs @@ -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, ) -> Result { let guard = state.inner().0.read().await; let manager = guard .as_ref() .ok_or_else(|| "终端管理器未初始化".to_string())?; - let session_id = manager.create_session().await.map_err(|e| e.to_string())?; + let session_id = manager + .create_session_with_options(24, 80, cwd) + .await + .map_err(|e| e.to_string())?; Ok(CreateSessionResponse { session_id }) } diff --git a/src-tauri/src/commands/webview_cmd.rs b/src-tauri/src/commands/webview_cmd.rs new file mode 100644 index 000000000..249fcc9d9 --- /dev/null +++ b/src-tauri/src/commands/webview_cmd.rs @@ -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, +} + +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>); + +/// 创建嵌入式 webview 的请求参数 +#[derive(Debug, Deserialize)] +pub struct CreateWebviewRequest { + /// 面板 ID(唯一标识) + pub panel_id: String, + /// 要加载的 URL + pub url: String, + /// 面板标题 + pub title: Option, + /// 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, +} + +/// 创建独立的浏览器窗口 +/// +/// 使用 Tauri 2.x 的 WebviewWindow 创建独立的浏览器窗口。 +#[tauri::command] +pub async fn create_webview_panel( + app: AppHandle, + state: tauri::State<'_, WebviewManagerWrapper>, + request: CreateWebviewRequest, +) -> Result { + 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::() { + 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 { + 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 { + tracing::info!("[Webview] 导航窗口 {} 到: {}", panel_id, url); + + // 解析 URL + let parsed_url = url + .parse::() + .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 { + 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, 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 { + 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)) + } +} diff --git a/src-tauri/src/config/tests.rs b/src-tauri/src/config/tests.rs index 569d787fc..e9ad0733f 100644 --- a/src-tauri/src/config/tests.rs +++ b/src-tauri/src/config/tests.rs @@ -187,6 +187,7 @@ fn arb_config() -> impl Strategy { 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 { 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 { minimize_to_tray: true, models: crate::config::ModelsConfig::default(), agent: crate::config::NativeAgentConfig::default(), + language: "zh".to_string(), }; // 根据类型使配置无效 match invalid_type { diff --git a/src-tauri/src/config/types.rs b/src-tauri/src/config/types.rs index ef44bdb67..eb195f271 100644 --- a/src-tauri/src/config/types.rs +++ b/src-tauri/src/config/types.rs @@ -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(), } diff --git a/src-tauri/src/services/README.md b/src-tauri/src/services/README.md index cf492e7d9..4fa54aaca 100644 --- a/src-tauri/src/services/README.md +++ b/src-tauri/src/services/README.md @@ -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` - 模型注册表服务 ## 更新提醒 diff --git a/src-tauri/src/services/file_browser_service.rs b/src-tauri/src/services/file_browser_service.rs new file mode 100644 index 000000000..58cba1e52 --- /dev/null +++ b/src-tauri/src/services/file_browser_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, + /// 是否隐藏文件 + #[serde(rename = "isHidden")] + pub is_hidden: bool, + /// 文件权限字符串(如 -rw-r--r--) + #[serde(rename = "modeStr")] + pub mode_str: Option, + /// 文件权限数字(8进制) + pub mode: Option, + /// MIME 类型 + #[serde(rename = "mimeType")] + pub mime_type: Option, + /// 是否为符号链接 + #[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, + /// 文件列表 + pub entries: Vec, + /// 错误信息 + pub error: Option, +} + +/// 文件预览结果 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FilePreview { + /// 文件路径 + pub path: String, + /// 文件内容(文本) + pub content: Option, + /// 是否为二进制文件 + #[serde(rename = "isBinary")] + pub is_binary: bool, + /// 文件大小 + pub size: u64, + /// 错误信息 + pub error: Option, +} + +/// 获取文件扩展名 +fn get_file_extension(path: &Path) -> Option { + 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 = 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) -> 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 { + Ok(list_directory(&path)) +} + +/// Tauri 命令:读取文件预览 +#[tauri::command] +pub async fn read_file_preview_cmd( + path: String, + max_size: Option, +) -> Result { + Ok(read_file_preview(&path, max_size)) +} + +/// Tauri 命令:获取用户主目录 +#[tauri::command] +pub async fn get_home_dir() -> Result { + 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 { + 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)); + } +} diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 129f9af7c..785c7de0c 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -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; diff --git a/src-tauri/src/services/sysinfo_service.rs b/src-tauri/src/services/sysinfo_service.rs new file mode 100644 index 000000000..77cd05ab1 --- /dev/null +++ b/src-tauri/src/services/sysinfo_service.rs @@ -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, +} + +/// 系统信息服务状态 +pub struct SysinfoService { + /// 是否正在运行订阅 + running: AtomicBool, + /// 系统信息实例 + system: Mutex, +} + +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> = + once_cell::sync::Lazy::new(|| Arc::new(SysinfoService::new())); + +/// 获取全局系统信息服务 +pub fn get_sysinfo_service() -> Arc { + SYSINFO_SERVICE.clone() +} + +/// Tauri 命令:获取当前系统信息 +#[tauri::command] +pub async fn get_sysinfo() -> Result { + 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); + } +} diff --git a/src-tauri/src/terminal/connections/connection_config.rs b/src-tauri/src/terminal/connections/connection_config.rs new file mode 100644 index 000000000..995f5a06f --- /dev/null +++ b/src-tauri/src/terminal/connections/connection_config.rs @@ -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, + + /// SSH 主机名或 IP + #[serde(skip_serializing_if = "Option::is_none")] + pub host: Option, + + /// SSH 端口(默认 22) + #[serde(skip_serializing_if = "Option::is_none")] + pub port: Option, + + /// 身份文件路径(私钥) + #[serde(skip_serializing_if = "Option::is_none")] + pub identity_file: Option, + + /// 身份文件列表(多个私钥) + #[serde(skip_serializing_if = "Option::is_none")] + pub identity_files: Option>, + + /// 跳板机配置 (ProxyJump) + #[serde(skip_serializing_if = "Option::is_none")] + pub proxy_jump: Option, + + /// 显示顺序 + #[serde(skip_serializing_if = "Option::is_none")] + pub display_order: Option, + + /// 是否隐藏 + #[serde(skip_serializing_if = "Option::is_none")] + pub hidden: Option, + + /// WSL 发行版名称 + #[serde(skip_serializing_if = "Option::is_none")] + pub wsl_distro: Option, +} + +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) -> Self { + Self { + conn_type: ConnectionConfigType::Ssh, + host: Some(host.into()), + ..Default::default() + } + } + + /// 设置用户名 + pub fn with_user(mut self, user: impl Into) -> 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) -> Self { + self.identity_file = Some(path.into()); + self + } +} + +/// 连接配置文件结构 +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ConnectionsFile { + /// 连接配置映射(名称 -> 配置) + #[serde(default)] + pub connections: HashMap, +} + +impl ConnectionsFile { + /// 创建空的配置文件 + pub fn new() -> Self { + Self::default() + } + + /// 添加连接 + pub fn add(&mut self, name: impl Into, config: ConnectionConfig) { + self.connections.insert(name.into(), config); + } + + /// 移除连接 + pub fn remove(&mut self, name: &str) -> Option { + self.connections.remove(name) + } + + /// 获取连接 + pub fn get(&self, name: &str) -> Option<&ConnectionConfig> { + self.connections.get(name) + } + + /// 获取所有连接名称 + pub fn names(&self) -> Vec { + 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 { + 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 { + 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 { + 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 { + let mut hosts = Vec::new(); + let mut current_host: Option = 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, 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, + /// 用户名 + pub user: Option, + /// 端口 + pub port: Option, + /// 身份文件 + pub identity_file: Option, +} + +/// 连接来源 +#[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, + /// 用户名 + #[serde(skip_serializing_if = "Option::is_none")] + pub user: Option, + /// 端口 + #[serde(skip_serializing_if = "Option::is_none")] + pub port: Option, +} + +#[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()); + } +} diff --git a/src-tauri/src/terminal/connections/mod.rs b/src-tauri/src/terminal/connections/mod.rs index 837238591..d56a16db5 100644 --- a/src-tauri/src/terminal/connections/mod.rs +++ b/src-tauri/src/terminal/connections/mod.rs @@ -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::{ diff --git a/src-tauri/src/terminal/pty_session.rs b/src-tauri/src/terminal/pty_session.rs index 433652aec..8e71ab8a2 100644 --- a/src-tauri/src/terminal/pty_session.rs +++ b/src-tauri/src/terminal/pty_session.rs @@ -120,7 +120,35 @@ impl PtySession { cols: u16, app_handle: tauri::AppHandle, ) -> Result { - 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, + app_handle: tauri::AppHandle, + ) -> Result { + 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); } diff --git a/src-tauri/src/terminal/session_manager.rs b/src-tauri/src/terminal/session_manager.rs index c5788c812..08af74700 100644 --- a/src-tauri/src/terminal/session_manager.rs +++ b/src-tauri/src/terminal/session_manager.rs @@ -203,20 +203,52 @@ impl TerminalSessionManager { &self, rows: u16, cols: u16, + ) -> Result { + 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, ) -> Result { 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 { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 1d7f5fd06..29501dcd3 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ProxyCast", - "version": "0.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": { diff --git a/src/App.tsx b/src/App.tsx index d77c9c332..35f36043c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { setCurrentPage(page as Page)} /> ); case "terminal": - // 终端页面需要全屏显示 + // 终端工作区 - 分块布局(对齐 Waveterm TileLayout) + return ; + case "sysinfo": + // 系统监控页面 return ( - + + + ); + case "files": + // 文件浏览器页面 + return ( + + + + ); + case "web": + // 内嵌浏览器页面 + return ( + + ); case "tools": diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index 165d967fc..4c9708316 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -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; diff --git a/src/components/README.md b/src/components/README.md index 57c17a9bf..1d65bfa3e 100644 --- a/src/components/README.md +++ b/src/components/README.md @@ -27,6 +27,7 @@ React 组件层,包含 UI 组件和业务组件。 - `switch/` - 开关控制组件 - `terminal/` - 内置终端组件(使用 Tauri Commands) - `tools/` - 工具页面组件 +- `widgets/` - 右侧小部件栏组件(移植自 Waveterm) - `ui/` - 通用 UI 组件(按钮、输入框等) - `websocket/` - WebSocket 管理组件 - `AppSidebar.tsx` - 全局图标侧边栏(类似 cherry-studio) diff --git a/src/components/dev/ComponentDebugOverlay.tsx b/src/components/dev/ComponentDebugOverlay.tsx index 42b38902d..e69aea50e 100644 --- a/src/components/dev/ComponentDebugOverlay.tsx +++ b/src/components/dev/ComponentDebugOverlay.tsx @@ -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 & { __source?: DebugSource } | null; + memoizedProps: (Record & { __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) => void>( fn: T, - delay: number + delay: number, ): (...args: Parameters) => void { let lastCall = 0; return (...args: Parameters) => { @@ -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 = {}; - 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).$$typeof) { - safeProps[key] = "[ReactElement]"; + // 获取 props + const props = fiber.memoizedProps || {}; + const safeProps: Record = {}; + 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).$$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)[fiberKey]; + let fiber: FiberNode | null = ( + element as unknown as Record + )[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(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 = ; + const selectedHighlight = ( + + ); 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 ( <> -
e.stopPropagation()} - > - {/* 标题栏 */} -
-
- - 组件信息 -
- -
- - {/* 内容区域 */} -
- {/* 组件名称 */} -
- -
-
组件名称
-
- {componentInfo.name} - -
+
e.stopPropagation()} + > + {/* 标题栏 */} +
+
+ + 组件信息
+
- {/* 文件路径 */} -
- -
-
文件路径
-
- {componentInfo.filePath} - -
-
-
- - {/* HTML 标签 */} -
- -
-
DOM 元素
- <{componentInfo.tagName.toLowerCase()}> -
-
- - {/* 组件层级 */} -
- -
-
组件层级
- 第 {componentInfo.depth} 层 -
-
- - {/* Props */} - {propsEntries.length > 0 && ( -
-
Props
-
-
- {propsEntries.slice(0, DEBUG_CONFIG.MAX_PROPS_DISPLAY).map(([key, value]) => ( -
- {key}: - - {typeof value === "string" ? `"${value}"` : String(value)} - -
- ))} - {propsEntries.length > DEBUG_CONFIG.MAX_PROPS_DISPLAY && ( -
... 还有 {propsEntries.length - DEBUG_CONFIG.MAX_PROPS_DISPLAY} 个属性
- )} + {/* 内容区域 */} +
+ {/* 组件名称 */} +
+ +
+
组件名称
+
+ + {componentInfo.name} + +
- )} -
- {/* 底部操作栏 */} -
-

按 Esc 关闭

- {hasParent && ( - - )} + {/* 文件路径 */} +
+ +
+
文件路径
+
+ + {componentInfo.filePath} + + +
+
+
+ + {/* HTML 标签 */} +
+ +
+
DOM 元素
+ + <{componentInfo.tagName.toLowerCase()}> + +
+
+ + {/* 组件层级 */} +
+ +
+
组件层级
+ + 第 {componentInfo.depth} 层 + +
+
+ + {/* Props */} + {propsEntries.length > 0 && ( +
+
Props
+
+
+ {propsEntries + .slice(0, DEBUG_CONFIG.MAX_PROPS_DISPLAY) + .map(([key, value]) => ( +
+ + {key}: + + + {typeof value === "string" + ? `"${value}"` + : String(value)} + +
+ ))} + {propsEntries.length > DEBUG_CONFIG.MAX_PROPS_DISPLAY && ( +
+ ... 还有{" "} + {propsEntries.length - DEBUG_CONFIG.MAX_PROPS_DISPLAY}{" "} + 个属性 +
+ )} +
+
+
+ )} +
+ + {/* 底部操作栏 */} +
+

按 Esc 关闭

+ {hasParent && ( + + )} +
-
- {/* 选中高亮边框 - 渲染在弹窗外部 */} - {selectedHighlight} + {/* 选中高亮边框 - 渲染在弹窗外部 */} + {selectedHighlight} ); } @@ -623,7 +717,9 @@ function ComponentInfoPopup() { function DebugInteractionHandler() { const { enabled, showComponentInfo, hideComponentInfo } = useComponentDebug(); const [altPressed, setAltPressed] = useState(false); - const [hoveredElement, setHoveredElement] = useState(null); + const [hoveredElement, setHoveredElement] = useState( + 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 */ diff --git a/src/components/settings/ConnectionsSettings.tsx b/src/components/settings/ConnectionsSettings.tsx new file mode 100644 index 000000000..b6d24ba2c --- /dev/null +++ b/src/components/settings/ConnectionsSettings.tsx @@ -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(""); + const [configContent, setConfigContent] = useState(""); + const [originalContent, setOriginalContent] = useState(""); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(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 ( +
+ + + + 连接配置 +
+ + + +
+
+ + + + {configPath} + + +
+ + {/* 状态提示 */} + {error && ( +
+ + {error} +
+ )} + {success && ( +
+ + {success} +
+ )} + + {/* JSON 编辑器 */} +
+