From f8e9216270cc8cf7e6516b71c4f45cbab2b8a517 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Mon, 26 May 2025 16:24:19 +0800 Subject: [PATCH 1/7] refactor: remove early return for empty MCP servers in MCPToolsButton - Eliminated the conditional return for empty active MCP servers to streamline the component rendering logic. --- src/renderer/src/pages/home/Inputbar/MCPToolsButton.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/renderer/src/pages/home/Inputbar/MCPToolsButton.tsx b/src/renderer/src/pages/home/Inputbar/MCPToolsButton.tsx index e7826b7e20..b022377f18 100644 --- a/src/renderer/src/pages/home/Inputbar/MCPToolsButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/MCPToolsButton.tsx @@ -450,10 +450,6 @@ const MCPToolsButton: FC = ({ ref, setInputValue, resizeTextArea, Toolbar openResourcesList })) - if (activedMcpServers.length === 0) { - return null - } - return ( From a05a7e45cceab6a9b81205c290ed67c6ecd581d7 Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Mon, 26 May 2025 13:50:27 +0800 Subject: [PATCH 2/7] chore: update electron-builder configuration and package dependencies - Modified electron-builder.yml to refine file inclusion/exclusion patterns. - Removed and re-added dependencies in package.json for consistency and updated yarn.lock to reflect these changes. - Cleaned up unnecessary entries in yarn.lock to streamline the dependency tree. --- electron-builder.yml | 17 +++- package.json | 5 +- yarn.lock | 233 ++----------------------------------------- 3 files changed, 23 insertions(+), 232 deletions(-) diff --git a/electron-builder.yml b/electron-builder.yml index bc250df519..12c9da99cb 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -12,9 +12,10 @@ electronLanguages: directories: buildResources: build files: - - '!{.vscode,.yarn,.github}' + - '**/*' + - '!{.vscode,.yarn,.yarn-lock,.github,.cursorrules,.prettierrc}' - '!electron.vite.config.{js,ts,mjs,cjs}' - - '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}' + - '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,eslint.config.mjs,dev-app-update.yml,CHANGELOG.md,README.md}' - '!{.env,.env.*,.npmrc,pnpm-lock.yaml}' - '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}' - '!src' @@ -22,15 +23,21 @@ files: - '!local' - '!docs' - '!packages' + - '!.swc' + - '!.bin' + - '!._*' + - '!*.log' - '!stats.html' - '!*.md' + - '!**/*.{iml,o,hprof,orig,pyc,pyo,rbc,swp,csproj,sln,xproj}' - '!**/*.{map,ts,tsx,jsx,less,scss,sass,css.d.ts,d.cts,d.mts,md,markdown,yaml,yml}' - - '!**/{test,tests,__tests__,coverage}/**' + - '!**/{test,tests,__tests__,powered-test,coverage}/**' + - '!**/{example,examples}/**' - '!**/*.{spec,test}.{js,jsx,ts,tsx}' - '!**/*.min.*.map' - '!**/*.d.ts' - - '!**/{.DS_Store,Thumbs.db}' - - '!**/{LICENSE,LICENSE.txt,LICENSE-MIT.txt,*.LICENSE.txt,NOTICE.txt,README.md,CHANGELOG.md}' + - '!**/{.DS_Store,Thumbs.db,thumbs.db,__pycache__}' + - '!**/{LICENSE,LICENSE.txt,LICENSE-MIT.txt,*.LICENSE.txt,NOTICE.txt,README.md,readme.md,CHANGELOG.md}' - '!node_modules/rollup-plugin-visualizer' - '!node_modules/js-tiktoken' - '!node_modules/@tavily/core/node_modules/js-tiktoken' diff --git a/package.json b/package.json index f647733196..b8435d624e 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,6 @@ "@types/react-infinite-scroll-component": "^5.0.0", "archiver": "^7.0.1", "async-mutex": "^0.5.0", - "color": "^5.0.0", "diff": "^7.0.0", "docx": "^9.0.2", "electron-log": "^5.1.5", @@ -84,11 +83,9 @@ "electron-updater": "6.6.4", "electron-window-state": "^5.0.3", "epub": "patch:epub@npm%3A1.3.0#~/.yarn/patches/epub-npm-1.3.0-8325494ffe.patch", - "fast-diff": "^1.3.0", "fast-xml-parser": "^5.2.0", "fetch-socks": "^1.3.2", "fs-extra": "^11.2.0", - "got-scraping": "^4.1.1", "jsdom": "^26.0.0", "markdown-it": "^14.1.0", "node-stream-zip": "^1.15.0", @@ -148,6 +145,7 @@ "antd": "^5.22.5", "axios": "^1.7.3", "browser-image-compression": "^2.0.2", + "color": "^5.0.0", "dayjs": "^1.11.11", "dexie": "^4.0.8", "dexie-react-hooks": "^1.1.7", @@ -163,6 +161,7 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-unused-imports": "^4.1.4", + "fast-diff": "^1.3.0", "html-to-image": "^1.11.13", "husky": "^9.1.7", "i18next": "^23.11.5", diff --git a/yarn.lock b/yarn.lock index 1bb52aafa3..cc628bc2e6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3969,13 +3969,6 @@ __metadata: languageName: node linkType: hard -"@sec-ant/readable-stream@npm:^0.4.1": - version: 0.4.1 - resolution: "@sec-ant/readable-stream@npm:0.4.1" - checksum: 10c0/64e9e9cf161e848067a5bf60cdc04d18495dc28bb63a8d9f8993e4dd99b91ad34e4b563c85de17d91ffb177ec17a0664991d2e115f6543e73236a906068987af - languageName: node - linkType: hard - "@selderee/plugin-htmlparser2@npm:^0.11.0": version: 0.11.0 resolution: "@selderee/plugin-htmlparser2@npm:0.11.0" @@ -4069,27 +4062,20 @@ __metadata: languageName: node linkType: hard -"@sindresorhus/is@npm:^4.0.0, @sindresorhus/is@npm:^4.2.0": +"@sindresorhus/is@npm:^4.0.0": version: 4.6.0 resolution: "@sindresorhus/is@npm:4.6.0" checksum: 10c0/33b6fb1d0834ec8dd7689ddc0e2781c2bfd8b9c4e4bacbcb14111e0ae00621f2c264b8a7d36541799d74888b5dccdf422a891a5cb5a709ace26325eedc81e22e languageName: node linkType: hard -"@sindresorhus/is@npm:^5.2.0, @sindresorhus/is@npm:^5.3.0": +"@sindresorhus/is@npm:^5.2.0": version: 5.6.0 resolution: "@sindresorhus/is@npm:5.6.0" checksum: 10c0/66727344d0c92edde5760b5fd1f8092b717f2298a162a5f7f29e4953e001479927402d9d387e245fb9dc7d3b37c72e335e93ed5875edfc5203c53be8ecba1b52 languageName: node linkType: hard -"@sindresorhus/is@npm:^7.0.1": - version: 7.0.1 - resolution: "@sindresorhus/is@npm:7.0.1" - checksum: 10c0/6d43a916d70d9b64066394c272883869b22faf21f4748aaf399c1b691ea704ea607d1668ff2eb5704e5be8809c4a7faafe16be048ce5e1a2ba6e8928b8e3461c - languageName: node - linkType: hard - "@strongtz/win32-arm64-msvc@npm:^0.4.7": version: 0.4.7 resolution: "@strongtz/win32-arm64-msvc@npm:0.4.7" @@ -4670,7 +4656,7 @@ __metadata: languageName: node linkType: hard -"@types/http-cache-semantics@npm:*, @types/http-cache-semantics@npm:^4.0.2, @types/http-cache-semantics@npm:^4.0.4": +"@types/http-cache-semantics@npm:*, @types/http-cache-semantics@npm:^4.0.2": version: 4.0.4 resolution: "@types/http-cache-semantics@npm:4.0.4" checksum: 10c0/51b72568b4b2863e0fe8d6ce8aad72a784b7510d72dc866215642da51d84945a9459fa89f49ec48f1e9a1752e6a78e85a4cda0ded06b1c73e727610c925f9ce6 @@ -5822,7 +5808,6 @@ __metadata: fast-xml-parser: "npm:^5.2.0" fetch-socks: "npm:^1.3.2" fs-extra: "npm:^11.2.0" - got-scraping: "npm:^4.1.1" html-to-image: "npm:^1.11.13" husky: "npm:^9.1.7" i18next: "npm:^23.11.5" @@ -5934,13 +5919,6 @@ __metadata: languageName: node linkType: hard -"adm-zip@npm:^0.5.9": - version: 0.5.16 - resolution: "adm-zip@npm:0.5.16" - checksum: 10c0/6f10119d4570c7ba76dcf428abb8d3f69e63f92e51f700a542b43d4c0130373dd2ddfc8f85059f12d4a843703a90c3970cfd17876844b4f3f48bf042bfa6b49f - languageName: node - linkType: hard - "agent-base@npm:6, agent-base@npm:^6.0.2": version: 6.0.2 resolution: "agent-base@npm:6.0.2" @@ -6636,20 +6614,6 @@ __metadata: languageName: node linkType: hard -"browserslist@npm:^4.21.1": - version: 4.24.5 - resolution: "browserslist@npm:4.24.5" - dependencies: - caniuse-lite: "npm:^1.0.30001716" - electron-to-chromium: "npm:^1.5.149" - node-releases: "npm:^2.0.19" - update-browserslist-db: "npm:^1.1.3" - bin: - browserslist: cli.js - checksum: 10c0/f4c1ce1a7d8fdfab5e5b88bb6e93d09e8a883c393f86801537a252da0362dbdcde4dbd97b318246c5d84c6607b2f6b47af732c1b000d6a8a881ee024bad29204 - languageName: node - linkType: hard - "browserslist@npm:^4.24.0": version: 4.24.4 resolution: "browserslist@npm:4.24.4" @@ -6854,21 +6818,6 @@ __metadata: languageName: node linkType: hard -"cacheable-request@npm:^12.0.1": - version: 12.0.1 - resolution: "cacheable-request@npm:12.0.1" - dependencies: - "@types/http-cache-semantics": "npm:^4.0.4" - get-stream: "npm:^9.0.1" - http-cache-semantics: "npm:^4.1.1" - keyv: "npm:^4.5.4" - mimic-response: "npm:^4.0.0" - normalize-url: "npm:^8.0.1" - responselike: "npm:^3.0.0" - checksum: 10c0/3ccc26519c8dd0821fcb21fa00781e55f05ab6e1da1487fbbee9c8c03435a3cf72c29a710a991cebe398fb9a5274e2a772fc488546d402db8dc21310764ed83a - languageName: node - linkType: hard - "cacheable-request@npm:^7.0.2": version: 7.0.4 resolution: "cacheable-request@npm:7.0.4" @@ -6904,20 +6853,13 @@ __metadata: languageName: node linkType: hard -"callsites@npm:^3.0.0, callsites@npm:^3.1.0": +"callsites@npm:^3.0.0": version: 3.1.0 resolution: "callsites@npm:3.1.0" checksum: 10c0/fff92277400eb06c3079f9e74f3af120db9f8ea03bad0e84d9aede54bbe2d44a56cccb5f6cf12211f93f52306df87077ecec5b712794c5a9b5dac6d615a3f301 languageName: node linkType: hard -"callsites@npm:^4.0.0": - version: 4.2.0 - resolution: "callsites@npm:4.2.0" - checksum: 10c0/8f7e269ec09fc0946bb22d838a8bc7932e1909ab4a833b964749f4d0e8bdeaa1f253287c4f911f61781f09620b6925ccd19a5ea4897489c4e59442c660c312a3 - languageName: node - linkType: hard - "camelcase@npm:5.0.0": version: 5.0.0 resolution: "camelcase@npm:5.0.0" @@ -6953,13 +6895,6 @@ __metadata: languageName: node linkType: hard -"caniuse-lite@npm:^1.0.30001716": - version: 1.0.30001718 - resolution: "caniuse-lite@npm:1.0.30001718" - checksum: 10c0/67f9ad09bc16443e28d14f265d6e468480cd8dc1900d0d8b982222de80c699c4f2306599c3da8a3fa7139f110d4b30d49dbac78f215470f479abb6ffe141d5d3 - languageName: node - linkType: hard - "caseless@npm:~0.12.0": version: 0.12.0 resolution: "caseless@npm:0.12.0" @@ -8681,15 +8616,6 @@ __metadata: languageName: node linkType: hard -"dot-prop@npm:^7.2.0": - version: 7.2.0 - resolution: "dot-prop@npm:7.2.0" - dependencies: - type-fest: "npm:^2.11.2" - checksum: 10c0/2621702a01e7a47730e3a8e2938a406afc79b62fbb77bd1394e786ff13776673904bf0a4fc6b812eb9849ec71034e9fc1019a9e0bbe91f84010d8a8088cd41a9 - languageName: node - linkType: hard - "dotenv-cli@npm:^7.4.2": version: 7.4.4 resolution: "dotenv-cli@npm:7.4.4" @@ -8870,13 +8796,6 @@ __metadata: languageName: node linkType: hard -"electron-to-chromium@npm:^1.5.149": - version: 1.5.155 - resolution: "electron-to-chromium@npm:1.5.155" - checksum: 10c0/aee32a0b03282e488352370f6a910de37788b814031020a0e244943450e844e8a41f741d6e5ec70d553dfa4382ef80088034ddc400b48f45de95de331b9ec178 - languageName: node - linkType: hard - "electron-to-chromium@npm:^1.5.73": version: 1.5.137 resolution: "electron-to-chromium@npm:1.5.137" @@ -10296,13 +10215,6 @@ __metadata: languageName: node linkType: hard -"form-data-encoder@npm:^4.0.2": - version: 4.0.2 - resolution: "form-data-encoder@npm:4.0.2" - checksum: 10c0/559d3130e265316452434eaf68d68560fb36392ff4d04614683419de4fb43c3dbe152dc303599fae382ce24d3451a6d3d289d3bcc182ae3d8ad32e7ce8e35e53 - languageName: node - linkType: hard - "form-data@npm:^4.0.0": version: 4.0.2 resolution: "form-data@npm:4.0.2" @@ -10542,16 +10454,6 @@ __metadata: languageName: node linkType: hard -"generative-bayesian-network@npm:^2.1.66": - version: 2.1.66 - resolution: "generative-bayesian-network@npm:2.1.66" - dependencies: - adm-zip: "npm:^0.5.9" - tslib: "npm:^2.4.0" - checksum: 10c0/ea8089492fe447aefc595ce5196d91474c8295d4592b4bcda8fedd2209d30ed897122ff1dc9efefcd7046e210f12cf06f2b6ec36e31f64646323ad3336a83b24 - languageName: node - linkType: hard - "gensync@npm:^1.0.0-beta.2": version: 1.0.0-beta.2 resolution: "gensync@npm:1.0.0-beta.2" @@ -10641,16 +10543,6 @@ __metadata: languageName: node linkType: hard -"get-stream@npm:^9.0.1": - version: 9.0.1 - resolution: "get-stream@npm:9.0.1" - dependencies: - "@sec-ant/readable-stream": "npm:^0.4.1" - is-stream: "npm:^4.0.1" - checksum: 10c0/d70e73857f2eea1826ac570c3a912757dcfbe8a718a033fa0c23e12ac8e7d633195b01710e0559af574cbb5af101009b42df7b6f6b29ceec8dbdf7291931b948 - languageName: node - linkType: hard - "get-uri@npm:^6.0.1": version: 6.0.4 resolution: "get-uri@npm:6.0.4" @@ -10853,21 +10745,6 @@ __metadata: languageName: node linkType: hard -"got-scraping@npm:^4.1.1": - version: 4.1.1 - resolution: "got-scraping@npm:4.1.1" - dependencies: - got: "npm:^14.2.1" - header-generator: "npm:^2.1.41" - http2-wrapper: "npm:^2.2.0" - mimic-response: "npm:^4.0.0" - ow: "npm:^1.1.1" - quick-lru: "npm:^7.0.0" - tslib: "npm:^2.6.2" - checksum: 10c0/66b9bd88fea1c7a1248fec6e9c9757300b70e6039d2b2e0cf1c70e44e88be80f02a26e2e36d5f9c3acb4ec963558d72b0d236a7f11a7a6c87b39b5615afcf7db - languageName: node - linkType: hard - "got@npm:13.0.0": version: 13.0.0 resolution: "got@npm:13.0.0" @@ -10906,25 +10783,6 @@ __metadata: languageName: node linkType: hard -"got@npm:^14.2.1": - version: 14.4.7 - resolution: "got@npm:14.4.7" - dependencies: - "@sindresorhus/is": "npm:^7.0.1" - "@szmarczak/http-timer": "npm:^5.0.1" - cacheable-lookup: "npm:^7.0.0" - cacheable-request: "npm:^12.0.1" - decompress-response: "npm:^6.0.0" - form-data-encoder: "npm:^4.0.2" - http2-wrapper: "npm:^2.2.1" - lowercase-keys: "npm:^3.0.0" - p-cancelable: "npm:^4.0.1" - responselike: "npm:^3.0.0" - type-fest: "npm:^4.26.1" - checksum: 10c0/9b5b8dbc0642c78dbc64ab5ff6f12f6edab3e0cb80e89a3a69623a79ba3986f0ff0066a116fba47c0aacce4b0ba1eccf72f923f7fac13a31ce852bf9e2cb8f81 - languageName: node - linkType: hard - "graceful-fs@npm:^4.1.10, graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.1.9, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6": version: 4.2.11 resolution: "graceful-fs@npm:4.2.11" @@ -11231,18 +11089,6 @@ __metadata: languageName: node linkType: hard -"header-generator@npm:^2.1.41": - version: 2.1.66 - resolution: "header-generator@npm:2.1.66" - dependencies: - browserslist: "npm:^4.21.1" - generative-bayesian-network: "npm:^2.1.66" - ow: "npm:^0.28.1" - tslib: "npm:^2.4.0" - checksum: 10c0/86354e65a047b6519b204adc2a4193dff6bb0782ca2c9d69f1c0320319cbd1a46d3eff31e4a32fe9b3dc0e7910351af5ca8ee2d50baade97b6e7dffd813145be - languageName: node - linkType: hard - "hoist-non-react-statics@npm:^3.3.0, hoist-non-react-statics@npm:^3.3.2": version: 3.3.2 resolution: "hoist-non-react-statics@npm:3.3.2" @@ -11408,7 +11254,7 @@ __metadata: languageName: node linkType: hard -"http2-wrapper@npm:^2.1.10, http2-wrapper@npm:^2.2.0, http2-wrapper@npm:^2.2.1": +"http2-wrapper@npm:^2.1.10": version: 2.2.1 resolution: "http2-wrapper@npm:2.2.1" dependencies: @@ -11937,13 +11783,6 @@ __metadata: languageName: node linkType: hard -"is-stream@npm:^4.0.1": - version: 4.0.1 - resolution: "is-stream@npm:4.0.1" - checksum: 10c0/2706c7f19b851327ba374687bc4a3940805e14ca496dc672b9629e744d143b1ad9c6f1b162dece81c7bfbc0f83b32b61ccc19ad2e05aad2dd7af347408f60c7f - languageName: node - linkType: hard - "is-typedarray@npm:~1.0.0": version: 1.0.0 resolution: "is-typedarray@npm:1.0.0" @@ -14612,7 +14451,7 @@ __metadata: languageName: node linkType: hard -"normalize-url@npm:^8.0.0, normalize-url@npm:^8.0.1": +"normalize-url@npm:^8.0.0": version: 8.0.1 resolution: "normalize-url@npm:8.0.1" checksum: 10c0/eb439231c4b84430f187530e6fdac605c5048ef4ec556447a10c00a91fc69b52d8d8298d9d608e68d3e0f7dc2d812d3455edf425e0f215993667c3183bcab1ef @@ -14937,32 +14776,6 @@ __metadata: languageName: node linkType: hard -"ow@npm:^0.28.1": - version: 0.28.2 - resolution: "ow@npm:0.28.2" - dependencies: - "@sindresorhus/is": "npm:^4.2.0" - callsites: "npm:^3.1.0" - dot-prop: "npm:^6.0.1" - lodash.isequal: "npm:^4.5.0" - vali-date: "npm:^1.0.0" - checksum: 10c0/8d0de10fd3aa1ab69dd844ace087718c31ceb1a25cf79d38a5be4d0a5da46f960b6bc15a95405747899b882fb51dcf5a502d7e6508005d1c57e157d12fa17cdd - languageName: node - linkType: hard - -"ow@npm:^1.1.1": - version: 1.1.1 - resolution: "ow@npm:1.1.1" - dependencies: - "@sindresorhus/is": "npm:^5.3.0" - callsites: "npm:^4.0.0" - dot-prop: "npm:^7.2.0" - lodash.isequal: "npm:^4.5.0" - vali-date: "npm:^1.0.0" - checksum: 10c0/3973f9d6245f2e468a0f1d614ece96f1289632f7425094e8b266b50ddbe79471f2e6cba447b80e90b54bbeb13c20e83671edfb5ef4c0b13c15546ba0710554e1 - languageName: node - linkType: hard - "p-cancelable@npm:^2.0.0": version: 2.1.1 resolution: "p-cancelable@npm:2.1.1" @@ -14977,13 +14790,6 @@ __metadata: languageName: node linkType: hard -"p-cancelable@npm:^4.0.1": - version: 4.0.1 - resolution: "p-cancelable@npm:4.0.1" - checksum: 10c0/12636623f46784ba962b6fe7a1f34d021f1d9a2cc12c43e270baa715ea872d5c8c7d9f086ed420b8b9817e91d9bbe92c14c90e5dddd4a9968c81a2a7aef7089d - languageName: node - linkType: hard - "p-finally@npm:^1.0.0": version: 1.0.0 resolution: "p-finally@npm:1.0.0" @@ -15878,13 +15684,6 @@ __metadata: languageName: node linkType: hard -"quick-lru@npm:^7.0.0": - version: 7.0.1 - resolution: "quick-lru@npm:7.0.1" - checksum: 10c0/631d031d9aba116311b1db57fbf8637874f2b72731f435a9d015cc0405aae5d18206336953563627ca7c9ed971a3824f11cb4dc1575d03283252a8cea22ac8e1 - languageName: node - linkType: hard - "raf-schd@npm:^4.0.3": version: 4.0.3 resolution: "raf-schd@npm:4.0.3" @@ -18769,7 +18568,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2.0.1, tslib@npm:^2.1.0, tslib@npm:^2.4.0, tslib@npm:^2.6.2, tslib@npm:^2.8.1": +"tslib@npm:^2.0.1, tslib@npm:^2.1.0, tslib@npm:^2.4.0, tslib@npm:^2.8.1": version: 2.8.1 resolution: "tslib@npm:2.8.1" checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62 @@ -18824,20 +18623,13 @@ __metadata: languageName: node linkType: hard -"type-fest@npm:^2.11.2, type-fest@npm:^2.17.0": +"type-fest@npm:^2.17.0": version: 2.19.0 resolution: "type-fest@npm:2.19.0" checksum: 10c0/a5a7ecf2e654251613218c215c7493574594951c08e52ab9881c9df6a6da0aeca7528c213c622bc374b4e0cb5c443aa3ab758da4e3c959783ce884c3194e12cb languageName: node linkType: hard -"type-fest@npm:^4.26.1": - version: 4.41.0 - resolution: "type-fest@npm:4.41.0" - checksum: 10c0/f5ca697797ed5e88d33ac8f1fec21921839871f808dc59345c9cf67345bfb958ce41bd821165dbf3ae591cedec2bf6fe8882098dfdd8dc54320b859711a2c1e4 - languageName: node - linkType: hard - "type-fest@npm:^4.39.1": version: 4.40.0 resolution: "type-fest@npm:4.40.0" @@ -19131,7 +18923,7 @@ __metadata: languageName: node linkType: hard -"update-browserslist-db@npm:^1.1.1, update-browserslist-db@npm:^1.1.3": +"update-browserslist-db@npm:^1.1.1": version: 1.1.3 resolution: "update-browserslist-db@npm:1.1.3" dependencies: @@ -19264,13 +19056,6 @@ __metadata: languageName: node linkType: hard -"vali-date@npm:^1.0.0": - version: 1.0.0 - resolution: "vali-date@npm:1.0.0" - checksum: 10c0/5755215f6734caab535f60af0a32bbbf2052c61b1a40668d773df78fd3754e4fe9da2ea5466731505f3e0a599acc209d5578c4b70488ed120fb03f0c2ab06449 - languageName: node - linkType: hard - "validate-npm-package-license@npm:^3.0.1": version: 3.0.4 resolution: "validate-npm-package-license@npm:3.0.4" From 665a62080bd671b41aa633f63e6bacb5f6e2cbc9 Mon Sep 17 00:00:00 2001 From: one Date: Mon, 26 May 2025 16:50:26 +0800 Subject: [PATCH 3/7] test: more unit tests (#5130) * test: more unit tests - Adjust vitest configuration to handle main process and renderer process tests separately - Add unit tests for main process utils - Add unit tests for the renderer process - Add three component tests to verify vitest usage: `DragableList`, `Scrollbar`, `QuickPanelView` - Add an e2e startup test to verify playwright usage - Extract `splitApiKeyString` and add tests for it - Add and format some comments * fix: mock individual properties * test: add tests for CustomTag * test: add tests for ExpandableText * test: conditional rendering tooltip of tag * chore: update dependencies --- .gitignore | 7 +- package.json | 27 +- playwright.config.ts | 42 ++ src/main/utils/__tests__/aes.test.ts | 71 ++ src/main/utils/__tests__/file.test.ts | 243 +++++++ src/main/utils/__tests__/zip.test.ts | 61 ++ src/main/utils/zip.ts | 6 +- src/renderer/__tests__/setup.ts | 49 -- .../src/components/QuickPanel/view.tsx | 3 +- .../src/components/Scrollbar/index.tsx | 13 +- .../components/__tests__/CustomTag.test.tsx | 44 ++ .../__tests__/DragableList.test.tsx | 282 ++++++++ .../__tests__/ExpandableText.test.tsx | 33 + .../__tests__/QuickPanelView.test.tsx | 188 ++++++ .../components/__tests__/Scrollbar.test.tsx | 191 ++++++ .../__snapshots__/DragableList.test.tsx.snap | 74 +++ .../__snapshots__/Scrollbar.test.tsx.snap | 45 ++ .../ProviderSettings/ProviderSetting.tsx | 13 +- .../__snapshots__/markdown.test.ts.snap | 124 ---- src/renderer/src/utils/__tests__/api.test.ts | 58 +- .../src/utils/__tests__/index.test.ts | 129 +++- .../src/utils/__tests__/markdown.test.ts | 43 +- .../src/utils/__tests__/prompt.test.ts | 24 +- src/renderer/src/utils/api.ts | 37 +- src/renderer/src/utils/export.ts | 6 +- src/renderer/src/utils/extract.ts | 4 +- src/renderer/src/utils/file.ts | 24 +- src/renderer/src/utils/image.ts | 10 +- src/renderer/src/utils/index.ts | 84 ++- src/renderer/src/utils/json.ts | 9 +- src/renderer/src/utils/linkConverter.ts | 55 +- src/renderer/src/utils/markdown.ts | 22 +- src/renderer/src/utils/naming.ts | 52 +- src/renderer/src/utils/sort.ts | 22 +- src/renderer/src/utils/style.ts | 20 +- tests/e2e/launch.test.tsx | 13 + tests/renderer.setup.ts | 46 ++ vitest.config.ts | 57 +- yarn.lock | 616 +++++++++++++++--- 39 files changed, 2366 insertions(+), 481 deletions(-) create mode 100644 playwright.config.ts create mode 100644 src/main/utils/__tests__/aes.test.ts create mode 100644 src/main/utils/__tests__/file.test.ts create mode 100644 src/main/utils/__tests__/zip.test.ts delete mode 100644 src/renderer/__tests__/setup.ts create mode 100644 src/renderer/src/components/__tests__/CustomTag.test.tsx create mode 100644 src/renderer/src/components/__tests__/DragableList.test.tsx create mode 100644 src/renderer/src/components/__tests__/ExpandableText.test.tsx create mode 100644 src/renderer/src/components/__tests__/QuickPanelView.test.tsx create mode 100644 src/renderer/src/components/__tests__/Scrollbar.test.tsx create mode 100644 src/renderer/src/components/__tests__/__snapshots__/DragableList.test.tsx.snap create mode 100644 src/renderer/src/components/__tests__/__snapshots__/Scrollbar.test.tsx.snap delete mode 100644 src/renderer/src/utils/__tests__/__snapshots__/markdown.test.ts.snap create mode 100644 tests/e2e/launch.test.tsx create mode 100644 tests/renderer.setup.ts diff --git a/.gitignore b/.gitignore index 68ea0f203f..23d8a8531a 100644 --- a/.gitignore +++ b/.gitignore @@ -47,8 +47,13 @@ local .cursorrules .cursor/rules -# test +# vitest coverage .vitest-cache vitest.config.*.timestamp-* + +# playwright +playwright-report +test-results + YOUR_MEMORY_FILE_PATH diff --git a/package.json b/package.json index b8435d624e..ac7809e3ed 100644 --- a/package.json +++ b/package.json @@ -45,12 +45,13 @@ "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", "typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false", "check:i18n": "node scripts/check-i18n.js", - "test": "yarn test:renderer", - "test:coverage": "yarn test:renderer:coverage", - "test:node": "npx -y tsx --test src/**/*.test.ts", - "test:renderer": "vitest run", - "test:renderer:ui": "vitest --ui", - "test:renderer:coverage": "vitest run --coverage", + "test": "vitest run --silent", + "test:main": "vitest run --project main", + "test:renderer": "vitest run --project renderer", + "test:coverage": "vitest run --coverage --silent", + "test:ui": "vitest --ui", + "test:watch": "vitest", + "test:e2e": "yarn playwright test", "test:lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts", "format": "prettier --write .", "lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix", @@ -118,9 +119,13 @@ "@modelcontextprotocol/sdk": "^1.11.4", "@mozilla/readability": "^0.6.0", "@notionhq/client": "^2.2.15", + "@playwright/test": "^1.52.0", "@reduxjs/toolkit": "^2.2.5", "@shikijs/markdown-it": "^3.4.2", "@swc/plugin-styled-components": "^7.1.5", + "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.3.0", "@tryfabric/martian": "^1.2.4", "@types/diff": "^7", "@types/fs-extra": "^11", @@ -139,8 +144,10 @@ "@uiw/codemirror-themes-all": "^4.23.12", "@uiw/react-codemirror": "^4.23.12", "@vitejs/plugin-react-swc": "^3.9.0", - "@vitest/ui": "^3.1.1", - "@vitest/web-worker": "^3.1.3", + "@vitest/browser": "^3.1.4", + "@vitest/coverage-v8": "^3.1.4", + "@vitest/ui": "^3.1.4", + "@vitest/web-worker": "^3.1.4", "@xyflow/react": "^12.4.4", "antd": "^5.22.5", "axios": "^1.7.3", @@ -165,6 +172,7 @@ "html-to-image": "^1.11.13", "husky": "^9.1.7", "i18next": "^23.11.5", + "jest-styled-components": "^7.2.0", "lint-staged": "^15.5.0", "lodash": "^4.17.21", "lru-cache": "^11.1.0", @@ -175,6 +183,7 @@ "npx-scope-finder": "^1.2.0", "openai": "patch:openai@npm%3A4.96.0#~/.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch", "p-queue": "^8.1.0", + "playwright": "^1.52.0", "prettier": "^3.5.3", "rc-virtual-list": "^3.18.6", "react": "^19.0.0", @@ -206,7 +215,7 @@ "typescript": "^5.6.2", "uuid": "^10.0.0", "vite": "6.2.6", - "vitest": "^3.1.1" + "vitest": "^3.1.4" }, "resolutions": { "pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000000..e12ce7ab6d --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,42 @@ +import { defineConfig, devices } from '@playwright/test' + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + // Look for test files, relative to this configuration file. + testDir: './tests/e2e', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + // baseURL: 'http://localhost:3000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry' + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] } + } + ] + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'npm run start', + // url: 'http://localhost:3000', + // reuseExistingServer: !process.env.CI, + // }, +}) diff --git a/src/main/utils/__tests__/aes.test.ts b/src/main/utils/__tests__/aes.test.ts new file mode 100644 index 0000000000..59fb1d42d3 --- /dev/null +++ b/src/main/utils/__tests__/aes.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' + +import { decrypt, encrypt } from '../aes' + +const key = '12345678901234567890123456789012' // 32字节 +const iv = '1234567890abcdef1234567890abcdef' // 32字节hex,实际应16字节hex + +function getIv16() { + // 取前16字节作为 hex + return iv.slice(0, 32) +} + +describe('aes utils', () => { + it('should encrypt and decrypt normal string', () => { + const text = 'hello world' + const { iv: outIv, encryptedData } = encrypt(text, key, getIv16()) + expect(typeof encryptedData).toBe('string') + expect(outIv).toBe(getIv16()) + const decrypted = decrypt(encryptedData, getIv16(), key) + expect(decrypted).toBe(text) + }) + + it('should support unicode and special chars', () => { + const text = '你好,世界!🌟🚀' + const { encryptedData } = encrypt(text, key, getIv16()) + const decrypted = decrypt(encryptedData, getIv16(), key) + expect(decrypted).toBe(text) + }) + + it('should handle empty string', () => { + const text = '' + const { encryptedData } = encrypt(text, key, getIv16()) + const decrypted = decrypt(encryptedData, getIv16(), key) + expect(decrypted).toBe(text) + }) + + it('should encrypt and decrypt long string', () => { + const text = 'a'.repeat(100_000) + const { encryptedData } = encrypt(text, key, getIv16()) + const decrypted = decrypt(encryptedData, getIv16(), key) + expect(decrypted).toBe(text) + }) + + it('should throw error for wrong key', () => { + const text = 'test' + const { encryptedData } = encrypt(text, key, getIv16()) + expect(() => decrypt(encryptedData, getIv16(), 'wrongkeywrongkeywrongkeywrongkey')).toThrow() + }) + + it('should throw error for wrong iv', () => { + const text = 'test' + const { encryptedData } = encrypt(text, key, getIv16()) + expect(() => decrypt(encryptedData, 'abcdefabcdefabcdefabcdefabcdefab', key)).toThrow() + }) + + it('should throw error for invalid key/iv length', () => { + expect(() => encrypt('test', 'shortkey', getIv16())).toThrow() + expect(() => encrypt('test', key, 'shortiv')).toThrow() + }) + + it('should throw error for invalid encrypted data', () => { + expect(() => decrypt('nothexdata', getIv16(), key)).toThrow() + }) + + it('should throw error for non-string input', () => { + // @ts-expect-error purposely pass wrong type to test error branch + expect(() => encrypt(null, key, getIv16())).toThrow() + // @ts-expect-error purposely pass wrong type to test error branch + expect(() => decrypt(null, getIv16(), key)).toThrow() + }) +}) diff --git a/src/main/utils/__tests__/file.test.ts b/src/main/utils/__tests__/file.test.ts new file mode 100644 index 0000000000..aae00e85d4 --- /dev/null +++ b/src/main/utils/__tests__/file.test.ts @@ -0,0 +1,243 @@ +import * as fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import { FileTypes } from '@types' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { getAllFiles, getAppConfigDir, getConfigDir, getFilesDir, getFileType, getTempDir } from '../file' + +// Mock dependencies +vi.mock('node:fs') +vi.mock('node:os') +vi.mock('node:path') +vi.mock('uuid', () => ({ + v4: () => 'mock-uuid' +})) +vi.mock('electron', () => ({ + app: { + getPath: vi.fn((key) => { + if (key === 'temp') return '/mock/temp' + if (key === 'userData') return '/mock/userData' + return '/mock/unknown' + }) + } +})) + +describe('file', () => { + beforeEach(() => { + vi.clearAllMocks() + + // Mock path.extname + vi.mocked(path.extname).mockImplementation((file) => { + const parts = file.split('.') + return parts.length > 1 ? `.${parts[parts.length - 1]}` : '' + }) + + // Mock path.basename + vi.mocked(path.basename).mockImplementation((file) => { + const parts = file.split('/') + return parts[parts.length - 1] + }) + + // Mock path.join + vi.mocked(path.join).mockImplementation((...args) => args.join('/')) + + // Mock os.homedir + vi.mocked(os.homedir).mockReturnValue('/mock/home') + }) + + afterEach(() => { + vi.resetAllMocks() + }) + + describe('getFileType', () => { + it('should return IMAGE for image extensions', () => { + expect(getFileType('.jpg')).toBe(FileTypes.IMAGE) + expect(getFileType('.jpeg')).toBe(FileTypes.IMAGE) + expect(getFileType('.png')).toBe(FileTypes.IMAGE) + expect(getFileType('.gif')).toBe(FileTypes.IMAGE) + expect(getFileType('.webp')).toBe(FileTypes.IMAGE) + expect(getFileType('.bmp')).toBe(FileTypes.IMAGE) + }) + + it('should return VIDEO for video extensions', () => { + expect(getFileType('.mp4')).toBe(FileTypes.VIDEO) + expect(getFileType('.avi')).toBe(FileTypes.VIDEO) + expect(getFileType('.mov')).toBe(FileTypes.VIDEO) + expect(getFileType('.mkv')).toBe(FileTypes.VIDEO) + expect(getFileType('.flv')).toBe(FileTypes.VIDEO) + }) + + it('should return AUDIO for audio extensions', () => { + expect(getFileType('.mp3')).toBe(FileTypes.AUDIO) + expect(getFileType('.wav')).toBe(FileTypes.AUDIO) + expect(getFileType('.ogg')).toBe(FileTypes.AUDIO) + expect(getFileType('.flac')).toBe(FileTypes.AUDIO) + expect(getFileType('.aac')).toBe(FileTypes.AUDIO) + }) + + it('should return TEXT for text extensions', () => { + expect(getFileType('.txt')).toBe(FileTypes.TEXT) + expect(getFileType('.md')).toBe(FileTypes.TEXT) + expect(getFileType('.html')).toBe(FileTypes.TEXT) + expect(getFileType('.json')).toBe(FileTypes.TEXT) + expect(getFileType('.js')).toBe(FileTypes.TEXT) + expect(getFileType('.ts')).toBe(FileTypes.TEXT) + expect(getFileType('.css')).toBe(FileTypes.TEXT) + expect(getFileType('.java')).toBe(FileTypes.TEXT) + expect(getFileType('.py')).toBe(FileTypes.TEXT) + }) + + it('should return DOCUMENT for document extensions', () => { + expect(getFileType('.pdf')).toBe(FileTypes.DOCUMENT) + expect(getFileType('.pptx')).toBe(FileTypes.DOCUMENT) + expect(getFileType('.docx')).toBe(FileTypes.DOCUMENT) + expect(getFileType('.xlsx')).toBe(FileTypes.DOCUMENT) + expect(getFileType('.odt')).toBe(FileTypes.DOCUMENT) + }) + + it('should return OTHER for unknown extensions', () => { + expect(getFileType('.unknown')).toBe(FileTypes.OTHER) + expect(getFileType('')).toBe(FileTypes.OTHER) + expect(getFileType('.')).toBe(FileTypes.OTHER) + expect(getFileType('...')).toBe(FileTypes.OTHER) + expect(getFileType('.123')).toBe(FileTypes.OTHER) + }) + + it('should handle case-insensitive extensions', () => { + expect(getFileType('.JPG')).toBe(FileTypes.IMAGE) + expect(getFileType('.PDF')).toBe(FileTypes.DOCUMENT) + expect(getFileType('.Mp3')).toBe(FileTypes.AUDIO) + expect(getFileType('.HtMl')).toBe(FileTypes.TEXT) + expect(getFileType('.Xlsx')).toBe(FileTypes.DOCUMENT) + }) + + it('should handle extensions without leading dot', () => { + expect(getFileType('jpg')).toBe(FileTypes.OTHER) + expect(getFileType('pdf')).toBe(FileTypes.OTHER) + expect(getFileType('mp3')).toBe(FileTypes.OTHER) + }) + + it('should handle extreme cases', () => { + expect(getFileType('.averylongfileextensionname')).toBe(FileTypes.OTHER) + expect(getFileType('.tar.gz')).toBe(FileTypes.OTHER) + expect(getFileType('.文件')).toBe(FileTypes.OTHER) + expect(getFileType('.файл')).toBe(FileTypes.OTHER) + }) + }) + + describe('getAllFiles', () => { + it('should return all valid files recursively', () => { + // Mock file system + // @ts-ignore - override type for testing + vi.spyOn(fs, 'readdirSync').mockImplementation((dirPath) => { + if (dirPath === '/test') { + return ['file1.txt', 'file2.pdf', 'subdir'] + } else if (dirPath === '/test/subdir') { + return ['file3.md', 'file4.docx'] + } + return [] + }) + + vi.mocked(fs.statSync).mockImplementation((filePath) => { + const isDir = String(filePath).endsWith('subdir') + return { + isDirectory: () => isDir, + size: 1024 + } as fs.Stats + }) + + const result = getAllFiles('/test') + + expect(result).toHaveLength(4) + expect(result[0].id).toBe('mock-uuid') + expect(result[0].name).toBe('file1.txt') + expect(result[0].type).toBe(FileTypes.TEXT) + expect(result[1].name).toBe('file2.pdf') + expect(result[1].type).toBe(FileTypes.DOCUMENT) + }) + + it('should skip hidden files', () => { + // @ts-ignore - override type for testing + vi.spyOn(fs, 'readdirSync').mockReturnValue(['.hidden', 'visible.txt']) + vi.mocked(fs.statSync).mockReturnValue({ + isDirectory: () => false, + size: 1024 + } as fs.Stats) + + const result = getAllFiles('/test') + + expect(result).toHaveLength(1) + expect(result[0].name).toBe('visible.txt') + }) + + it('should skip unsupported file types', () => { + // @ts-ignore - override type for testing + vi.spyOn(fs, 'readdirSync').mockReturnValue(['image.jpg', 'video.mp4', 'audio.mp3', 'document.pdf']) + vi.mocked(fs.statSync).mockReturnValue({ + isDirectory: () => false, + size: 1024 + } as fs.Stats) + + const result = getAllFiles('/test') + + // Should only include document.pdf as the others are excluded types + expect(result).toHaveLength(1) + expect(result[0].name).toBe('document.pdf') + expect(result[0].type).toBe(FileTypes.DOCUMENT) + }) + + it('should return empty array for empty directory', () => { + // @ts-ignore - override type for testing + vi.spyOn(fs, 'readdirSync').mockReturnValue([]) + + const result = getAllFiles('/empty') + + expect(result).toHaveLength(0) + }) + + it('should handle file system errors', () => { + // @ts-ignore - override type for testing + vi.spyOn(fs, 'readdirSync').mockImplementation(() => { + throw new Error('Directory not found') + }) + + // Since the function doesn't have error handling, we expect it to propagate + expect(() => getAllFiles('/nonexistent')).toThrow('Directory not found') + }) + }) + + describe('getTempDir', () => { + it('should return correct temp directory path', () => { + const tempDir = getTempDir() + expect(tempDir).toBe('/mock/temp/CherryStudio') + }) + }) + + describe('getFilesDir', () => { + it('should return correct files directory path', () => { + const filesDir = getFilesDir() + expect(filesDir).toBe('/mock/userData/Data/Files') + }) + }) + + describe('getConfigDir', () => { + it('should return correct config directory path', () => { + const configDir = getConfigDir() + expect(configDir).toBe('/mock/home/.cherrystudio/config') + }) + }) + + describe('getAppConfigDir', () => { + it('should return correct app config directory path', () => { + const appConfigDir = getAppConfigDir('test-app') + expect(appConfigDir).toBe('/mock/home/.cherrystudio/config/test-app') + }) + + it('should handle empty app name', () => { + const appConfigDir = getAppConfigDir('') + expect(appConfigDir).toBe('/mock/home/.cherrystudio/config/') + }) + }) +}) diff --git a/src/main/utils/__tests__/zip.test.ts b/src/main/utils/__tests__/zip.test.ts new file mode 100644 index 0000000000..6c84b16e93 --- /dev/null +++ b/src/main/utils/__tests__/zip.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' + +import { compress, decompress } from '../zip' + +const jsonStr = JSON.stringify({ foo: 'bar', num: 42, arr: [1, 2, 3] }) + +// 辅助函数:生成大字符串 +function makeLargeString(size: number) { + return 'a'.repeat(size) +} + +describe('zip', () => { + describe('compress & decompress', () => { + it('should compress and decompress a normal JSON string', async () => { + const compressed = await compress(jsonStr) + expect(compressed).toBeInstanceOf(Buffer) + + const decompressed = await decompress(compressed) + expect(decompressed).toBe(jsonStr) + }) + + it('should handle empty string', async () => { + const compressed = await compress('') + expect(compressed).toBeInstanceOf(Buffer) + const decompressed = await decompress(compressed) + expect(decompressed).toBe('') + }) + + it('should handle large string', async () => { + const largeStr = makeLargeString(100_000) + const compressed = await compress(largeStr) + expect(compressed).toBeInstanceOf(Buffer) + expect(compressed.length).toBeLessThan(largeStr.length) + const decompressed = await decompress(compressed) + expect(decompressed).toBe(largeStr) + }) + + it('should throw error when decompressing invalid buffer', async () => { + const invalidBuffer = Buffer.from('not a valid gzip', 'utf-8') + await expect(decompress(invalidBuffer)).rejects.toThrow() + }) + + it('should throw error when compress input is not string', async () => { + // @ts-expect-error purposely pass wrong type to test error branch + await expect(compress(null)).rejects.toThrow() + // @ts-expect-error purposely pass wrong type to test error branch + await expect(compress(undefined)).rejects.toThrow() + // @ts-expect-error purposely pass wrong type to test error branch + await expect(compress(123)).rejects.toThrow() + }) + + it('should throw error when decompress input is not buffer', async () => { + // @ts-expect-error purposely pass wrong type to test error branch + await expect(decompress(null)).rejects.toThrow() + // @ts-expect-error purposely pass wrong type to test error branch + await expect(decompress(undefined)).rejects.toThrow() + // @ts-expect-error purposely pass wrong type to test error branch + await expect(decompress('string')).rejects.toThrow() + }) + }) +}) diff --git a/src/main/utils/zip.ts b/src/main/utils/zip.ts index 177ccba7fe..b2762f7a98 100644 --- a/src/main/utils/zip.ts +++ b/src/main/utils/zip.ts @@ -9,10 +9,10 @@ const gunzipPromise = util.promisify(zlib.gunzip) /** * 压缩字符串 + * @param {string} str 要压缩的 JSON 字符串 * @returns {Promise} 压缩后的 Buffer - * @param str */ -export async function compress(str) { +export async function compress(str: string): Promise { try { const buffer = Buffer.from(str, 'utf-8') return await gzipPromise(buffer) @@ -27,7 +27,7 @@ export async function compress(str) { * @param {Buffer} compressedBuffer - 压缩的 Buffer * @returns {Promise} 解压缩后的 JSON 字符串 */ -export async function decompress(compressedBuffer) { +export async function decompress(compressedBuffer: Buffer): Promise { try { const buffer = await gunzipPromise(compressedBuffer) return buffer.toString('utf-8') diff --git a/src/renderer/__tests__/setup.ts b/src/renderer/__tests__/setup.ts deleted file mode 100644 index 70b9cd70b0..0000000000 --- a/src/renderer/__tests__/setup.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { vi } from 'vitest' - -vi.mock('electron-log/renderer', () => { - return { - default: { - info: console.log, - error: console.error, - warn: console.warn, - debug: console.debug, - verbose: console.log, - silly: console.log, - log: console.log, - transports: { - console: { - level: 'info' - } - } - } - } -}) - -vi.stubGlobal('window', { - electron: { - ipcRenderer: { - on: vi.fn(), // Mocking ipcRenderer.on - send: vi.fn() // Mocking ipcRenderer.send - } - }, - api: { - file: { - read: vi.fn().mockResolvedValue('[]'), // Mock file.read to return an empty array (you can customize this) - writeWithId: vi.fn().mockResolvedValue(undefined) // Mock file.writeWithId to do nothing - } - } -}) - -vi.mock('axios', () => ({ - default: { - get: vi.fn().mockResolvedValue({ data: {} }), // Mocking axios GET request - post: vi.fn().mockResolvedValue({ data: {} }) // Mocking axios POST request - // You can add other axios methods like put, delete etc. as needed - } -})) - -vi.stubGlobal('window', { - ...global.window, // Copy other global properties - addEventListener: vi.fn(), // Mock addEventListener - removeEventListener: vi.fn() // You can also mock removeEventListener if needed -}) diff --git a/src/renderer/src/components/QuickPanel/view.tsx b/src/renderer/src/components/QuickPanel/view.tsx index 9da3d6ea34..390087d91d 100644 --- a/src/renderer/src/components/QuickPanel/view.tsx +++ b/src/renderer/src/components/QuickPanel/view.tsx @@ -434,7 +434,8 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { $pageSize={ctx.pageSize} $selectedColor={selectedColor} $selectedColorHover={selectedColorHover} - className={ctx.isVisible ? 'visible' : ''}> + className={ctx.isVisible ? 'visible' : ''} + data-testid="quick-panel"> diff --git a/src/renderer/src/components/Scrollbar/index.tsx b/src/renderer/src/components/Scrollbar/index.tsx index 857a8404e2..86f8c4b42a 100644 --- a/src/renderer/src/components/Scrollbar/index.tsx +++ b/src/renderer/src/components/Scrollbar/index.tsx @@ -3,12 +3,12 @@ import { FC, useCallback, useEffect, useRef, useState } from 'react' import styled from 'styled-components' interface Props extends Omit, 'onScroll'> { - right?: boolean ref?: React.RefObject + right?: boolean onScroll?: () => void // Custom onScroll prop for useScrollPosition's handleScroll } -const Scrollbar: FC = ({ ref: passedRef, children, onScroll: externalOnScroll, ...htmlProps }) => { +const Scrollbar: FC = ({ ref: passedRef, right, children, onScroll: externalOnScroll, ...htmlProps }) => { const [isScrolling, setIsScrolling] = useState(false) const timeoutRef = useRef(null) @@ -43,7 +43,8 @@ const Scrollbar: FC = ({ ref: passedRef, children, onScroll: externalOnSc return ( {children} @@ -51,15 +52,15 @@ const Scrollbar: FC = ({ ref: passedRef, children, onScroll: externalOnSc ) } -const Container = styled.div<{ isScrolling: boolean; right?: boolean }>` +const Container = styled.div<{ $isScrolling: boolean; $right?: boolean }>` overflow-y: auto; &::-webkit-scrollbar-thumb { transition: background 2s ease; background: ${(props) => - props.isScrolling ? `var(--color-scrollbar-thumb${props.right ? '-right' : ''})` : 'transparent'}; + props.$isScrolling ? `var(--color-scrollbar-thumb${props.$right ? '-right' : ''})` : 'transparent'}; &:hover { background: ${(props) => - props.isScrolling ? `var(--color-scrollbar-thumb${props.right ? '-right' : ''}-hover)` : 'transparent'}; + props.$isScrolling ? `var(--color-scrollbar-thumb${props.$right ? '-right' : ''}-hover)` : 'transparent'}; } } ` diff --git a/src/renderer/src/components/__tests__/CustomTag.test.tsx b/src/renderer/src/components/__tests__/CustomTag.test.tsx new file mode 100644 index 0000000000..a306fa6494 --- /dev/null +++ b/src/renderer/src/components/__tests__/CustomTag.test.tsx @@ -0,0 +1,44 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it } from 'vitest' + +import CustomTag from '../CustomTag' + +const COLOR = '#ff0000' + +describe('CustomTag', () => { + it('should render children text', () => { + render(content) + expect(screen.getByText('content')).toBeInTheDocument() + }) + + it('should render icon if provided', () => { + render( + cherry}> + content + + ) + expect(screen.getByTestId('icon')).toBeInTheDocument() + expect(screen.getByText('content')).toBeInTheDocument() + }) + + it('should show tooltip if tooltip prop is set', async () => { + render( + + reasoning + + ) + // 鼠标悬停触发 Tooltip + await userEvent.hover(screen.getByText('reasoning')) + expect(await screen.findByText('reasoning model')).toBeInTheDocument() + }) + + it('should not render Tooltip when tooltip is not set', () => { + render(no tooltip) + + expect(screen.getByText('no tooltip')).toBeInTheDocument() + // 不应有 tooltip 相关内容 + expect(document.querySelector('.ant-tooltip')).toBeNull() + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/components/__tests__/DragableList.test.tsx b/src/renderer/src/components/__tests__/DragableList.test.tsx new file mode 100644 index 0000000000..d40849fff8 --- /dev/null +++ b/src/renderer/src/components/__tests__/DragableList.test.tsx @@ -0,0 +1,282 @@ +/// + +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +import DragableList from '../DragableList' + +// mock @hello-pangea/dnd 组件 +vi.mock('@hello-pangea/dnd', () => { + return { + __esModule: true, + DragDropContext: ({ children, onDragEnd }: any) => { + // 挂载到 window 以便测试用例直接调用 + window.triggerOnDragEnd = (result = { source: { index: 0 }, destination: { index: 1 } }, provided = {}) => { + onDragEnd && onDragEnd(result, provided) + } + return
{children}
+ }, + Droppable: ({ children }: any) => ( +
+ {children({ droppableProps: {}, innerRef: () => {}, placeholder:
})} +
+ ), + Draggable: ({ children, draggableId, index }: any) => ( +
+ {children({ draggableProps: {}, dragHandleProps: {}, innerRef: () => {} })} +
+ ) + } +}) + +// mock VirtualList 只做简单渲染 +vi.mock('rc-virtual-list', () => ({ + __esModule: true, + default: ({ data, itemKey, children }: any) => ( +
+ {data.map((item: any, idx: number) => ( +
+ {children(item, idx)} +
+ ))} +
+ ) +})) + +declare global { + interface Window { + triggerOnDragEnd: (result?: any, provided?: any) => void + } +} + +describe('DragableList', () => { + describe('rendering', () => { + it('should render all list items', () => { + const list = [ + { id: 'a', name: 'A' }, + { id: 'b', name: 'B' }, + { id: 'c', name: 'C' } + ] + render( + {}}> + {(item) =>
{item.name}
} +
+ ) + const items = screen.getAllByTestId('item') + expect(items.length).toBe(3) + expect(items[0].textContent).toBe('A') + expect(items[1].textContent).toBe('B') + expect(items[2].textContent).toBe('C') + }) + + it('should render with custom style and listStyle', () => { + const list = [{ id: 'a', name: 'A' }] + const style = { background: 'red' } + const listStyle = { color: 'blue' } + render( + {}}> + {(item) =>
{item.name}
} +
+ ) + // 检查 style 是否传递到外层容器 + const virtualList = screen.getByTestId('virtual-list') + expect(virtualList.parentElement).toHaveStyle({ background: 'red' }) + }) + + it('should render nothing when list is empty', () => { + render( + {}}> + {(item) =>
{item.name}
} +
+ ) + // 虚拟列表存在但无内容 + const items = screen.queryAllByTestId('item') + expect(items.length).toBe(0) + }) + }) + + describe('drag and drop', () => { + it('should call onUpdate with new order after drag end', () => { + const list = [ + { id: 'a', name: 'A' }, + { id: 'b', name: 'B' }, + { id: 'c', name: 'C' } + ] + const newOrder = [list[1], list[2], list[0]] + const onUpdate = vi.fn() + + render( + + {(item) =>
{item.name}
} +
+ ) + + // 直接调用 window.triggerOnDragEnd 模拟拖拽结束 + window.triggerOnDragEnd({ source: { index: 0 }, destination: { index: 2 } }, {}) + + expect(onUpdate).toHaveBeenCalledWith(newOrder) + expect(onUpdate).toHaveBeenCalledTimes(1) + }) + + it('should call onDragStart and onDragEnd', () => { + const list = [ + { id: 'a', name: 'A' }, + { id: 'b', name: 'B' }, + { id: 'c', name: 'C' } + ] + const onDragStart = vi.fn() + const onDragEnd = vi.fn() + + render( + {}} onDragStart={onDragStart} onDragEnd={onDragEnd}> + {(item) =>
{item.name}
} +
+ ) + + // 先手动调用 onDragStart + onDragStart() + // 再模拟拖拽结束 + window.triggerOnDragEnd({ source: { index: 0 }, destination: { index: 1 } }, {}) + expect(onDragStart).toHaveBeenCalledTimes(1) + expect(onDragEnd).toHaveBeenCalledTimes(1) + }) + + it('should not call onUpdate if dropped at same position', () => { + const list = [ + { id: 'a', name: 'A' }, + { id: 'b', name: 'B' }, + { id: 'c', name: 'C' } + ] + const onUpdate = vi.fn() + + render( + + {(item) =>
{item.name}
} +
+ ) + + // 模拟拖拽到自身 + window.triggerOnDragEnd({ source: { index: 1 }, destination: { index: 1 } }, {}) + expect(onUpdate).toHaveBeenCalledTimes(1) + expect(onUpdate.mock.calls[0][0]).toEqual(list) + }) + }) + + describe('edge cases', () => { + it('should work with single item', () => { + const list = [{ id: 'a', name: 'A' }] + const onUpdate = vi.fn() + + render( + + {(item) =>
{item.name}
} +
+ ) + + // 拖拽自身 + window.triggerOnDragEnd({ source: { index: 0 }, destination: { index: 0 } }, {}) + expect(onUpdate).toHaveBeenCalledTimes(1) + expect(onUpdate.mock.calls[0][0]).toEqual(list) + }) + + it('should not crash if callbacks are undefined', () => { + const list = [ + { id: 'a', name: 'A' }, + { id: 'b', name: 'B' } + ] + + // 不传 onDragStart/onDragEnd + expect(() => { + render( + {}}> + {(item) =>
{item.name}
} +
+ ) + window.triggerOnDragEnd({ source: { index: 0 }, destination: { index: 1 } }, {}) + }).not.toThrow() + }) + + it('should handle items without id', () => { + const list = ['A', 'B', 'C'] + const onUpdate = vi.fn() + + render( + + {(item) =>
{item}
} +
+ ) + + // 拖拽第0项到第2项 + window.triggerOnDragEnd({ source: { index: 0 }, destination: { index: 2 } }, {}) + expect(onUpdate).toHaveBeenCalledTimes(1) + expect(onUpdate.mock.calls[0][0]).toEqual(['B', 'C', 'A']) + }) + }) + + describe('interaction', () => { + it('should show placeholder during drag', () => { + const list = [ + { id: 'a', name: 'A' }, + { id: 'b', name: 'B' }, + { id: 'c', name: 'C' } + ] + + render( + {}}> + {(item) =>
{item.name}
} +
+ ) + + // placeholder 应该在初始渲染时就存在 + const placeholder = screen.getByTestId('placeholder') + expect(placeholder).toBeInTheDocument() + }) + + it('should reorder correctly when dragged to first/last', () => { + const list = [ + { id: 'a', name: 'A' }, + { id: 'b', name: 'B' }, + { id: 'c', name: 'C' } + ] + const onUpdate = vi.fn() + render( + + {(item) =>
{item.name}
} +
+ ) + + // 拖拽第2项到第0项 + window.triggerOnDragEnd({ source: { index: 2 }, destination: { index: 0 } }, {}) + expect(onUpdate).toHaveBeenCalledWith([ + { id: 'c', name: 'C' }, + { id: 'a', name: 'A' }, + { id: 'b', name: 'B' } + ]) + + // 拖拽第0项到第2项 + onUpdate.mockClear() + window.triggerOnDragEnd({ source: { index: 0 }, destination: { index: 2 } }, {}) + expect(onUpdate).toHaveBeenCalledWith([ + { id: 'b', name: 'B' }, + { id: 'c', name: 'C' }, + { id: 'a', name: 'A' } + ]) + }) + }) + + describe('snapshot', () => { + it('should match snapshot', () => { + const list = [ + { id: 'a', name: 'A' }, + { id: 'b', name: 'B' }, + { id: 'c', name: 'C' } + ] + const { container } = render( + {}}> + {(item) =>
{item.name}
} +
+ ) + expect(container).toMatchSnapshot() + }) + }) +}) diff --git a/src/renderer/src/components/__tests__/ExpandableText.test.tsx b/src/renderer/src/components/__tests__/ExpandableText.test.tsx new file mode 100644 index 0000000000..b2f5a64d76 --- /dev/null +++ b/src/renderer/src/components/__tests__/ExpandableText.test.tsx @@ -0,0 +1,33 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' + +import ExpandableText from '../ExpandableText' + +// mock i18n +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (k: string) => k }) +})) + +describe('ExpandableText', () => { + const TEXT = 'This is a long text for testing.' + + it('should render text and expand button', () => { + render() + expect(screen.getByText(TEXT)).toBeInTheDocument() + expect(screen.getByRole('button')).toHaveTextContent('common.expand') + }) + + it('should toggle expand/collapse when button is clicked', async () => { + render() + const button = screen.getByRole('button') + // 初始为收起状态 + expect(button).toHaveTextContent('common.expand') + // 点击展开 + await userEvent.click(button) + expect(button).toHaveTextContent('common.collapse') + // 再次点击收起 + await userEvent.click(button) + expect(button).toHaveTextContent('common.expand') + }) +}) diff --git a/src/renderer/src/components/__tests__/QuickPanelView.test.tsx b/src/renderer/src/components/__tests__/QuickPanelView.test.tsx new file mode 100644 index 0000000000..100f951005 --- /dev/null +++ b/src/renderer/src/components/__tests__/QuickPanelView.test.tsx @@ -0,0 +1,188 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useEffect } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { QuickPanelListItem, QuickPanelProvider, QuickPanelView, useQuickPanel } from '../QuickPanel' + +function createList(length: number, prefix = 'Item', extra: Partial = {}) { + return Array.from({ length }, (_, i) => ({ + label: `${prefix} ${i + 1}`, + description: `${prefix} Description ${i + 1}`, + icon: `${prefix} Icon ${i + 1}`, + action: () => {}, + ...extra + })) +} + +type KeyStep = { + key: string + ctrlKey?: boolean + expected: string | ((text: string) => boolean) +} + +const PAGE_SIZE = 7 + +// 用于测试 open 行为的组件 +function OpenPanelOnMount({ list }: { list: QuickPanelListItem[] }) { + const quickPanel = useQuickPanel() + useEffect(() => { + quickPanel.open({ + title: 'Test Panel', + list, + symbol: 'test', + pageSize: PAGE_SIZE + }) + }, [list, quickPanel]) + return null +} + +describe('QuickPanelView', () => { + beforeEach(() => { + // 添加一个假的 .inputbar textarea 到 document.body + const inputbar = document.createElement('div') + inputbar.className = 'inputbar' + const textarea = document.createElement('textarea') + inputbar.appendChild(textarea) + document.body.appendChild(inputbar) + }) + + afterEach(() => { + const inputbar = document.querySelector('.inputbar') + if (inputbar) inputbar.remove() + }) + + describe('rendering', () => { + it('should render without crashing when wrapped in QuickPanelProvider', () => { + render( + + + + ) + + // 检查面板容器是否存在且初始不可见 + const panel = screen.getByTestId('quick-panel') + expect(panel.classList.contains('visible')).toBe(false) + }) + + it('should render list after open', async () => { + const list = createList(100) + + render( + + + + + ) + + // 检查面板可见 + const panel = screen.getByTestId('quick-panel') + expect(panel.classList.contains('visible')).toBe(true) + // 检查第一个 item 是否渲染 + expect(screen.getByText('Item 1')).toBeInTheDocument() + }) + }) + + describe('focusing', () => { + // 执行一系列按键,检查 focused item 是否正确 + async function runKeySequenceAndCheck(panel: HTMLElement, sequence: KeyStep[]) { + const user = userEvent.setup() + for (const { key, ctrlKey, expected } of sequence) { + let keyString = '' + if (ctrlKey) keyString += '{Control>}' + keyString += key.length === 1 ? key : `{${key}}` + if (ctrlKey) keyString += '{/Control}' + await user.keyboard(keyString) + + // 检查是否只有一个 focused item + const focused = panel.querySelectorAll('.focused') + expect(focused.length).toBe(1) + // 检查 focused item 是否包含预期文本 + const text = focused[0].textContent || '' + if (typeof expected === 'string') { + expect(text).toContain(expected) + } else { + expect(expected(text)).toBe(true) + } + } + } + + it('should focus on the first item after panel open', () => { + const list = createList(100) + + render( + + + + + ) + + // 检查第一个 item 是否有 focused + const item1 = screen.getByText('Item 1') + const focused = item1.closest('.focused') + expect(focused).not.toBeNull() + expect(item1).toBeInTheDocument() + }) + + it('should focus on the right item using ArrowUp, ArrowDown', async () => { + const list = createList(100, 'Item') + + render( + + + + + ) + + const keySequence = [ + { key: 'ArrowUp', expected: 'Item 100' }, + { key: 'ArrowUp', expected: 'Item 99' }, + { key: 'ArrowDown', expected: 'Item 100' }, + { key: 'ArrowDown', expected: 'Item 1' } + ] + + await runKeySequenceAndCheck(screen.getByTestId('quick-panel'), keySequence) + }) + + it('should focus on the right item using PageUp, PageDown', async () => { + const list = createList(100, 'Item') + + render( + + + + + ) + + const keySequence = [ + { key: 'PageUp', expected: 'Item 1' }, // 停留在顶部 + { key: 'ArrowUp', expected: 'Item 100' }, + { key: 'PageDown', expected: 'Item 100' }, // 停留在底部 + { key: 'PageUp', expected: `Item ${100 - PAGE_SIZE}` }, + { key: 'PageDown', expected: 'Item 100' } + ] + + await runKeySequenceAndCheck(screen.getByTestId('quick-panel'), keySequence) + }) + + it('should focus on the right item using Ctrl+ArrowUp, Ctrl+ArrowDown', async () => { + const list = createList(100, 'Item') + + render( + + + + + ) + + const keySequence = [ + { key: 'ArrowDown', ctrlKey: true, expected: `Item ${PAGE_SIZE + 1}` }, + { key: 'ArrowUp', ctrlKey: true, expected: 'Item 1' }, + { key: 'ArrowUp', ctrlKey: true, expected: 'Item 100' }, + { key: 'ArrowDown', ctrlKey: true, expected: 'Item 1' } + ] + + await runKeySequenceAndCheck(screen.getByTestId('quick-panel'), keySequence) + }) + }) +}) diff --git a/src/renderer/src/components/__tests__/Scrollbar.test.tsx b/src/renderer/src/components/__tests__/Scrollbar.test.tsx new file mode 100644 index 0000000000..2848404f26 --- /dev/null +++ b/src/renderer/src/components/__tests__/Scrollbar.test.tsx @@ -0,0 +1,191 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { act } from 'react' +import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest' + +import Scrollbar from '../Scrollbar' + +// Mock lodash throttle +vi.mock('lodash', async () => { + const actual = await import('lodash') + return { + ...actual, + throttle: vi.fn((fn) => { + // 简单地直接返回函数,不实际执行节流 + const throttled = (...args: any[]) => fn(...args) + throttled.cancel = vi.fn() + return throttled + }) + } +}) + +describe('Scrollbar', () => { + beforeEach(() => { + // 使用 fake timers + vi.useFakeTimers() + }) + + afterEach(() => { + // 恢复真实的 timers + vi.restoreAllMocks() + vi.useRealTimers() + }) + + describe('rendering', () => { + it('should render children correctly', () => { + render( + +
测试内容
+
+ ) + + const child = screen.getByTestId('child') + expect(child).toBeDefined() + expect(child.textContent).toBe('测试内容') + }) + + it('should pass custom props to container', () => { + render( + + 内容 + + ) + + const scrollbar = screen.getByTestId('scrollbar') + expect(scrollbar.className).toContain('custom-class') + }) + + it('should match default styled snapshot', () => { + const { container } = render(内容) + expect(container.firstChild).toMatchSnapshot() + }) + }) + + describe('scrolling behavior', () => { + it('should update isScrolling state when scrolled', () => { + render(内容) + + const scrollbar = screen.getByTestId('scrollbar') + + // 初始状态下应该不是滚动状态 + expect(scrollbar.getAttribute('isScrolling')).toBeFalsy() + + // 触发滚动 + fireEvent.scroll(scrollbar) + + // 由于 isScrolling 是组件内部状态,不直接反映在 DOM 属性上 + // 但可以检查模拟的事件处理是否被调用 + expect(scrollbar).toBeDefined() + }) + + it('should reset isScrolling after timeout', () => { + render(内容) + + const scrollbar = screen.getByTestId('scrollbar') + + // 触发滚动 + fireEvent.scroll(scrollbar) + + // 前进时间但不超过timeout + act(() => { + vi.advanceTimersByTime(1000) + }) + + // 前进超过timeout + act(() => { + vi.advanceTimersByTime(600) + }) + + // 不测试样式,这里只检查组件是否存在 + expect(scrollbar).toBeDefined() + }) + + it('should reset timeout on continuous scrolling', () => { + const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout') + + render(内容) + + const scrollbar = screen.getByTestId('scrollbar') + + // 第一次滚动 + fireEvent.scroll(scrollbar) + + // 前进一部分时间 + act(() => { + vi.advanceTimersByTime(800) + }) + + // 再次滚动 + fireEvent.scroll(scrollbar) + + // clearTimeout 应该被调用,因为在第二次滚动时会清除之前的定时器 + expect(clearTimeoutSpy).toHaveBeenCalled() + }) + }) + + describe('throttling', () => { + it('should use throttled scroll handler', async () => { + const { throttle } = await import('lodash') + + render(内容) + + // 验证 throttle 被调用 + expect(throttle).toHaveBeenCalled() + // 验证 throttle 调用时使用了 200ms 延迟 + expect(throttle).toHaveBeenCalledWith(expect.any(Function), 200) + }) + }) + + describe('cleanup', () => { + it('should clear timeout and cancel throttle on unmount', async () => { + const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout') + + const { unmount } = render(内容) + + const scrollbar = screen.getByTestId('scrollbar') + + // 触发滚动设置定时器 + fireEvent.scroll(scrollbar) + + // 卸载组件 + unmount() + + // 验证 clearTimeout 被调用 + expect(clearTimeoutSpy).toHaveBeenCalled() + + // 验证 throttle.cancel 被调用 + const { throttle } = await import('lodash') + const throttledFunction = (throttle as unknown as Mock).mock.results[0].value + expect(throttledFunction.cancel).toHaveBeenCalled() + }) + }) + + describe('props handling', () => { + it('should handle right prop correctly', () => { + const { container } = render( + + 内容 + + ) + + const scrollbar = screen.getByTestId('scrollbar') + + // 验证 right 属性被正确传递 + expect(scrollbar).toBeDefined() + // snapshot 测试 styled-components 样式 + expect(container.firstChild).toMatchSnapshot() + }) + + it('should handle ref forwarding', () => { + const ref = { current: null } + + render( + + 内容 + + ) + + // 验证 ref 被正确设置 + expect(ref.current).not.toBeNull() + }) + }) +}) diff --git a/src/renderer/src/components/__tests__/__snapshots__/DragableList.test.tsx.snap b/src/renderer/src/components/__tests__/__snapshots__/DragableList.test.tsx.snap new file mode 100644 index 0000000000..a7ddaaaf11 --- /dev/null +++ b/src/renderer/src/components/__tests__/__snapshots__/DragableList.test.tsx.snap @@ -0,0 +1,74 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`DragableList > snapshot > should match snapshot 1`] = ` +
+
+
+
+
+
+
+
+
+ A +
+
+
+
+
+
+
+
+ B +
+
+
+
+
+
+
+
+ C +
+
+
+
+
+
+
+
+
+
+`; diff --git a/src/renderer/src/components/__tests__/__snapshots__/Scrollbar.test.tsx.snap b/src/renderer/src/components/__tests__/__snapshots__/Scrollbar.test.tsx.snap new file mode 100644 index 0000000000..63a08d758d --- /dev/null +++ b/src/renderer/src/components/__tests__/__snapshots__/Scrollbar.test.tsx.snap @@ -0,0 +1,45 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Scrollbar > props handling > should handle right prop correctly 1`] = ` +.c0 { + overflow-y: auto; +} + +.c0::-webkit-scrollbar-thumb { + transition: background 2s ease; + background: transparent; +} + +.c0::-webkit-scrollbar-thumb:hover { + background: transparent; +} + +
+ 内容 +
+`; + +exports[`Scrollbar > rendering > should match default styled snapshot 1`] = ` +.c0 { + overflow-y: auto; +} + +.c0::-webkit-scrollbar-thumb { + transition: background 2s ease; + background: transparent; +} + +.c0::-webkit-scrollbar-thumb:hover { + background: transparent; +} + +
+ 内容 +
+`; diff --git a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx index 2eb2cbeacf..493d0ac4f7 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx @@ -12,7 +12,7 @@ import { checkApi, formatApiKeys } from '@renderer/services/ApiService' import { checkModelsHealth, getModelCheckSummary } from '@renderer/services/HealthCheckService' import { isProviderSupportAuth } from '@renderer/services/ProviderService' import { Provider } from '@renderer/types' -import { formatApiHost } from '@renderer/utils/api' +import { formatApiHost, splitApiKeyString } from '@renderer/utils/api' import { lightbulbVariants } from '@renderer/utils/motionVariants' import { Button, Divider, Flex, Input, Space, Switch, Tooltip } from 'antd' import Link from 'antd/es/typography/Link' @@ -127,10 +127,7 @@ const ProviderSetting: FC = ({ provider: _provider }) => { return } - const keys = apiKey - .split(',') - .map((k) => k.trim()) - .filter((k) => k) + const keys = splitApiKeyString(apiKey) // Add an empty key to enable health checks for local models. // Error messages will be shown for each model if a valid key is needed. @@ -215,11 +212,7 @@ const ProviderSetting: FC = ({ provider: _provider }) => { } if (apiKey.includes(',')) { - const keys = apiKey - .split(/(? k.trim()) - .map((k) => k.replace(/\\,/g, ',')) - .filter((k) => k) + const keys = splitApiKeyString(apiKey) const result = await ApiCheckPopup.show({ title: t('settings.provider.check_multiple_keys'), diff --git a/src/renderer/src/utils/__tests__/__snapshots__/markdown.test.ts.snap b/src/renderer/src/utils/__tests__/__snapshots__/markdown.test.ts.snap deleted file mode 100644 index 3765a190f1..0000000000 --- a/src/renderer/src/utils/__tests__/__snapshots__/markdown.test.ts.snap +++ /dev/null @@ -1,124 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`markdown > markdown configuration constants > sanitizeSchema matches snapshot 1`] = ` -{ - "attributes": { - "*": [ - "className", - "style", - "id", - "title", - ], - "a": [ - "href", - "target", - "rel", - ], - "circle": [ - "cx", - "cy", - "r", - "fill", - "stroke", - ], - "g": [ - "transform", - "fill", - "stroke", - ], - "line": [ - "x1", - "y1", - "x2", - "y2", - "stroke", - ], - "path": [ - "d", - "fill", - "stroke", - "strokeWidth", - "strokeLinecap", - "strokeLinejoin", - ], - "polygon": [ - "points", - "fill", - "stroke", - ], - "polyline": [ - "points", - "fill", - "stroke", - ], - "rect": [ - "x", - "y", - "width", - "height", - "fill", - "stroke", - ], - "svg": [ - "viewBox", - "width", - "height", - "xmlns", - "fill", - "stroke", - ], - "text": [ - "x", - "y", - "fill", - "textAnchor", - "dominantBaseline", - ], - }, - "tagNames": [ - "style", - "p", - "div", - "span", - "b", - "i", - "strong", - "em", - "ul", - "ol", - "li", - "table", - "tr", - "td", - "th", - "thead", - "tbody", - "h1", - "h2", - "h3", - "h4", - "h5", - "h6", - "blockquote", - "pre", - "code", - "br", - "hr", - "svg", - "path", - "circle", - "rect", - "line", - "polyline", - "polygon", - "text", - "g", - "defs", - "title", - "desc", - "tspan", - "sub", - "sup", - ], -} -`; diff --git a/src/renderer/src/utils/__tests__/api.test.ts b/src/renderer/src/utils/__tests__/api.test.ts index 3b613da131..ee91e2ad2b 100644 --- a/src/renderer/src/utils/__tests__/api.test.ts +++ b/src/renderer/src/utils/__tests__/api.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { formatApiHost, maskApiKey } from '../api' +import { formatApiHost, maskApiKey, splitApiKeyString } from '../api' describe('api', () => { describe('formatApiHost', () => { @@ -67,4 +67,60 @@ describe('api', () => { expect(maskApiKey('12345678')).toBe('12345678') }) }) + + describe('splitApiKeyString', () => { + it('should split comma-separated keys', () => { + const input = 'key1,key2,key3' + const result = splitApiKeyString(input) + expect(result).toEqual(['key1', 'key2', 'key3']) + }) + + it('should trim spaces around keys', () => { + const input = ' key1 , key2 ,key3 ' + const result = splitApiKeyString(input) + expect(result).toEqual(['key1', 'key2', 'key3']) + }) + + it('should handle escaped commas', () => { + const input = 'key1,key2\\,withcomma,key3' + const result = splitApiKeyString(input) + expect(result).toEqual(['key1', 'key2,withcomma', 'key3']) + }) + + it('should handle multiple escaped commas', () => { + const input = 'key1\\,withcomma1,key2\\,withcomma2' + const result = splitApiKeyString(input) + expect(result).toEqual(['key1,withcomma1', 'key2,withcomma2']) + }) + + it('should ignore empty keys', () => { + const input = 'key1,,key2, ,key3' + const result = splitApiKeyString(input) + expect(result).toEqual(['key1', 'key2', 'key3']) + }) + + it('should return empty array for empty string', () => { + const input = '' + const result = splitApiKeyString(input) + expect(result).toEqual([]) + }) + + it('should handle only one key', () => { + const input = 'singlekey' + const result = splitApiKeyString(input) + expect(result).toEqual(['singlekey']) + }) + + it('should handle only escaped comma', () => { + const input = 'key\\,withcomma' + const result = splitApiKeyString(input) + expect(result).toEqual(['key,withcomma']) + }) + + it('should handle all keys with spaces and escaped commas', () => { + const input = ' key1 , key2\\,withcomma , key3 ' + const result = splitApiKeyString(input) + expect(result).toEqual(['key1', 'key2,withcomma', 'key3']) + }) + }) }) diff --git a/src/renderer/src/utils/__tests__/index.test.ts b/src/renderer/src/utils/__tests__/index.test.ts index 8d3d65e15b..84c2b1d769 100644 --- a/src/renderer/src/utils/__tests__/index.test.ts +++ b/src/renderer/src/utils/__tests__/index.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' -import { delay, runAsyncFunction } from '../index' +import { runAsyncFunction } from '../index' +import { compareVersions, hasPath, isFreeModel, isValidProxyUrl, removeQuotes, removeSpecialCharacters } from '../index' describe('Unclassified Utils', () => { describe('runAsyncFunction', () => { @@ -23,24 +24,118 @@ describe('Unclassified Utils', () => { }) }) - describe('delay', () => { - it('should resolve after specified seconds', async () => { - // 验证指定时间后返回 - const start = Date.now() - await delay(0.01) - const end = Date.now() - // In JavaScript, the delay time of setTimeout is not always precise - // and may be slightly shorter than specified. Make it more lenient: - const lenientRatio = 0.8 - expect(end - start).toBeGreaterThanOrEqual(10 * lenientRatio) + describe('isFreeModel', () => { + const base = { provider: '', group: '' } + it('should return true if id or name contains "free" (case-insensitive)', () => { + expect(isFreeModel({ id: 'free-model', name: 'test', ...base })).toBe(true) + expect(isFreeModel({ id: 'model', name: 'FreePlan', ...base })).toBe(true) + expect(isFreeModel({ id: 'model', name: 'notfree', ...base })).toBe(true) + expect(isFreeModel({ id: 'model', name: 'test', ...base })).toBe(false) }) - it('should resolve immediately for zero delay', async () => { - // 验证零延迟立即返回 - const start = Date.now() - await delay(0) - const end = Date.now() - expect(end - start).toBeLessThan(100) + it('should handle empty id or name', () => { + expect(isFreeModel({ id: '', name: 'free', ...base })).toBe(true) + expect(isFreeModel({ id: 'free', name: '', ...base })).toBe(true) + expect(isFreeModel({ id: '', name: '', ...base })).toBe(false) + }) + }) + + describe('removeQuotes', () => { + it('should remove all single and double quotes', () => { + expect(removeQuotes('"hello"')).toBe('hello') + expect(removeQuotes("'hello'")).toBe('hello') + expect(removeQuotes('"hello"')).toBe('hello') + expect(removeQuotes('noquotes')).toBe('noquotes') + }) + + it('should handle empty string', () => { + expect(removeQuotes('')).toBe('') + }) + + it('should handle string with only quotes', () => { + expect(removeQuotes('""')).toBe('') + expect(removeQuotes("''")).toBe('') + }) + }) + + describe('removeSpecialCharacters', () => { + it('should remove newlines, quotes, and special characters', () => { + expect(removeSpecialCharacters('hello\nworld!')).toBe('helloworld') + expect(removeSpecialCharacters('"hello, world!"')).toBe('hello world') + expect(removeSpecialCharacters('你好,世界!')).toBe('你好世界') + }) + + it('should handle empty string', () => { + expect(removeSpecialCharacters('')).toBe('') + }) + + it('should handle string with only special characters', () => { + expect(removeSpecialCharacters('"\n!,.')).toBe('') + }) + }) + + describe('isValidProxyUrl', () => { + it('should return true for string containing "://"', () => { + expect(isValidProxyUrl('http://localhost')).toBe(true) + expect(isValidProxyUrl('socks5://127.0.0.1:1080')).toBe(true) + }) + + it('should return false for string not containing "://"', () => { + expect(isValidProxyUrl('localhost')).toBe(false) + expect(isValidProxyUrl('127.0.0.1:1080')).toBe(false) + }) + + it('should handle empty string', () => { + expect(isValidProxyUrl('')).toBe(false) + }) + + it('should return true for only "://"', () => { + expect(isValidProxyUrl('://')).toBe(true) + }) + }) + + describe('hasPath', () => { + it('should return true if url has path', () => { + expect(hasPath('http://a.com/path')).toBe(true) + expect(hasPath('http://a.com/path/to')).toBe(true) + }) + + it('should return false if url has no path or only root', () => { + expect(hasPath('http://a.com/')).toBe(false) + expect(hasPath('http://a.com')).toBe(false) + }) + + it('should return false for invalid url', () => { + expect(hasPath('not a url')).toBe(false) + expect(hasPath('')).toBe(false) + }) + }) + + describe('compareVersions', () => { + it('should return 1 if v1 > v2', () => { + expect(compareVersions('1.2.3', '1.2.2')).toBe(1) + expect(compareVersions('2.0.0', '1.9.9')).toBe(1) + expect(compareVersions('1.2.0', '1.1.9')).toBe(1) + expect(compareVersions('1.2.3', '1.2')).toBe(1) + }) + + it('should return -1 if v1 < v2', () => { + expect(compareVersions('1.2.2', '1.2.3')).toBe(-1) + expect(compareVersions('1.9.9', '2.0.0')).toBe(-1) + expect(compareVersions('1.1.9', '1.2.0')).toBe(-1) + expect(compareVersions('1.2', '1.2.3')).toBe(-1) + }) + + it('should return 0 if v1 == v2', () => { + expect(compareVersions('1.2.3', '1.2.3')).toBe(0) + expect(compareVersions('1.2', '1.2.0')).toBe(0) + expect(compareVersions('1.0.0', '1')).toBe(0) + }) + + it('should handle non-numeric and empty string', () => { + expect(compareVersions('', '')).toBe(0) + expect(compareVersions('a.b.c', '1.2.3')).toBe(-1) + expect(compareVersions('1.2.3', 'a.b.c')).toBe(1) }) }) }) diff --git a/src/renderer/src/utils/__tests__/markdown.test.ts b/src/renderer/src/utils/__tests__/markdown.test.ts index b46ff077b9..2b6af62cef 100644 --- a/src/renderer/src/utils/__tests__/markdown.test.ts +++ b/src/renderer/src/utils/__tests__/markdown.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest' import { convertMathFormula, + encodeHTML, findCitationInChildren, getCodeBlockId, removeTrailingDoubleSpaces, @@ -14,8 +15,8 @@ import { describe('markdown', () => { describe('findCitationInChildren', () => { it('returns null when children is null or undefined', () => { - expect(findCitationInChildren(null)).toBeNull() - expect(findCitationInChildren(undefined)).toBeNull() + expect(findCitationInChildren(null)).toBe('') + expect(findCitationInChildren(undefined)).toBe('') }) it('finds citation in direct child element', () => { @@ -36,7 +37,7 @@ describe('markdown', () => { it('returns null when no citation is found', () => { const children = [{ props: { foo: 'bar' } }, { props: { children: [{ props: { baz: 'qux' } }] } }] - expect(findCitationInChildren(children)).toBeNull() + expect(findCitationInChildren(children)).toBe('') }) it('handles single child object (non-array)', () => { @@ -107,6 +108,7 @@ describe('markdown', () => { it('should return input if null or empty', () => { // 验证空输入或 null 输入时返回原值 expect(convertMathFormula('')).toBe('') + // @ts-expect-error purposely pass wrong type to test error branch expect(convertMathFormula(null)).toBe(null) }) }) @@ -141,6 +143,41 @@ describe('markdown', () => { }) }) + describe('encodeHTML', () => { + it('should encode all special HTML characters', () => { + const input = `Tom & Jerry's "cat" ` + const result = encodeHTML(input) + expect(result).toBe('Tom & Jerry's "cat" <dog>') + }) + + it('should return the same string if no special characters', () => { + const input = 'Hello World!' + const result = encodeHTML(input) + expect(result).toBe('Hello World!') + }) + + it('should return empty string if input is empty', () => { + const input = '' + const result = encodeHTML(input) + expect(result).toBe('') + }) + + it('should encode single special character', () => { + expect(encodeHTML('&')).toBe('&') + expect(encodeHTML('<')).toBe('<') + expect(encodeHTML('>')).toBe('>') + expect(encodeHTML('"')).toBe('"') + expect(encodeHTML("'")).toBe(''') + }) + + it('should throw if input is not a string', () => { + // @ts-expect-error purposely pass wrong type to test error branch + expect(() => encodeHTML(null)).toThrow() + // @ts-expect-error purposely pass wrong type to test error branch + expect(() => encodeHTML(undefined)).toThrow() + }) + }) + describe('getCodeBlockId', () => { it('should generate ID from position information', () => { // 从位置信息生成ID diff --git a/src/renderer/src/utils/__tests__/prompt.test.ts b/src/renderer/src/utils/__tests__/prompt.test.ts index fa77124d13..45886e8c7d 100644 --- a/src/renderer/src/utils/__tests__/prompt.test.ts +++ b/src/renderer/src/utils/__tests__/prompt.test.ts @@ -1,22 +1,14 @@ -import { MCPTool } from '@renderer/types' +import { type MCPTool } from '@renderer/types' import { describe, expect, it } from 'vitest' import { AvailableTools, buildSystemPrompt } from '../prompt' describe('prompt', () => { - // 辅助函数:创建符合 MCPTool 类型的工具对象 - const createMcpTool = (id: string, description: string, inputSchema: any): MCPTool => ({ - id, - description, - inputSchema, - serverId: 'test-server-id', - serverName: 'test-server', - name: id - }) - describe('AvailableTools', () => { it('should generate XML format for tools', () => { - const tools = [createMcpTool('test-tool', 'Test tool description', { type: 'object' })] + const tools = [ + { id: 'test-tool', description: 'Test tool description', inputSchema: { type: 'object' } } as MCPTool + ] const result = AvailableTools(tools) expect(result).toContain('') @@ -39,7 +31,9 @@ describe('prompt', () => { describe('buildSystemPrompt', () => { it('should build prompt with tools', () => { const userPrompt = 'Custom user system prompt' - const tools = [createMcpTool('test-tool', 'Test tool description', { type: 'object' })] + const tools = [ + { id: 'test-tool', description: 'Test tool description', inputSchema: { type: 'object' } } as MCPTool + ] const result = buildSystemPrompt(userPrompt, tools) expect(result).toContain(userPrompt) @@ -55,7 +49,9 @@ describe('prompt', () => { }) it('should handle null or undefined user prompt', () => { - const tools = [createMcpTool('test-tool', 'Test tool description', { type: 'object' })] + const tools = [ + { id: 'test-tool', description: 'Test tool description', inputSchema: { type: 'object' } } as MCPTool + ] // 测试 userPrompt 为 null 的情况 const resultNull = buildSystemPrompt(null as any, tools) diff --git a/src/renderer/src/utils/api.ts b/src/renderer/src/utils/api.ts index 91022a255c..9d868a51a0 100644 --- a/src/renderer/src/utils/api.ts +++ b/src/renderer/src/utils/api.ts @@ -1,4 +1,14 @@ -export function formatApiHost(host: string) { +/** + * 格式化 API 主机地址。 + * + * 根据传入的 host 判断是否需要在其末尾加 `/v1/`。 + * - 不加:host 以 `/` 结尾,或以 `volces.com/api/v3` 结尾。 + * - 要加:其余情况。 + * + * @param {string} host - 需要格式化的 API 主机地址。 + * @returns {string} 格式化后的 API 主机地址。 + */ +export function formatApiHost(host: string): string { const forceUseOriginalHost = () => { if (host.endsWith('/')) { return true @@ -10,6 +20,17 @@ export function formatApiHost(host: string) { return forceUseOriginalHost() ? host : `${host}/v1/` } +/** + * API key 脱敏函数。仅保留部分前后字符,中间用星号代替。 + * + * - 长度大于 24,保留前、后 8 位。 + * - 长度大于 16,保留前、后 4 位。 + * - 长度大于 8,保留前、后 2 位。 + * - 其余情况,返回原始密钥。 + * + * @param {string} key - 需要脱敏的 API 密钥。 + * @returns {string} 脱敏后的密钥字符串。 + */ export function maskApiKey(key: string): string { if (!key) return '' @@ -23,3 +44,17 @@ export function maskApiKey(key: string): string { return key } } + +/** + * 将 API key 字符串转换为 key 数组。 + * + * @param {string} keyStr - 包含 API key 的逗号分隔字符串。 + * @returns {string[]} 转换后的数组,每个元素为 API key。 + */ +export function splitApiKeyString(keyStr: string): string[] { + return keyStr + .split(/(? k.trim()) + .map((k) => k.replace(/\\,/g, ',')) + .filter((k) => k) +} diff --git a/src/renderer/src/utils/export.ts b/src/renderer/src/utils/export.ts index 44c444bd6c..cc49168574 100644 --- a/src/renderer/src/utils/export.ts +++ b/src/renderer/src/utils/export.ts @@ -15,9 +15,9 @@ import dayjs from 'dayjs' /** * 从消息内容中提取标题,限制长度并处理换行和标点符号。用于导出功能。 - * @param str 输入字符串 - * @param length 标题最大长度,默认为 80 - * @returns string 提取的标题 + * @param {string} str 输入字符串 + * @param {number} [length=80] 标题最大长度,默认为 80 + * @returns {string} 提取的标题 */ export function getTitleFromString(str: string, length: number = 80) { let title = str.trimStart().split('\n')[0] diff --git a/src/renderer/src/utils/extract.ts b/src/renderer/src/utils/extract.ts index 2c71345255..372f2a38aa 100644 --- a/src/renderer/src/utils/extract.ts +++ b/src/renderer/src/utils/extract.ts @@ -17,8 +17,8 @@ export interface KnowledgeExtractResults { /** * 从带有XML标签的文本中提取信息 * @public - * @param text 包含XML标签的文本 - * @returns 提取的信息对象 + * @param {string} text 包含XML标签的文本 + * @returns {ExtractResults} 提取的信息对象 * @throws */ export const extractInfoFromXML = (text: string): ExtractResults => { diff --git a/src/renderer/src/utils/file.ts b/src/renderer/src/utils/file.ts index 5628963b8e..0c0f5039a6 100644 --- a/src/renderer/src/utils/file.ts +++ b/src/renderer/src/utils/file.ts @@ -2,10 +2,10 @@ import { KB, MB } from '@shared/config/constant' /** * 从文件路径中提取目录路径。 - * @param filePath 文件路径 - * @returns string 目录路径 + * @param {string} filePath 文件路径 + * @returns {string} 目录路径 */ -export function getFileDirectory(filePath: string) { +export function getFileDirectory(filePath: string): string { const parts = filePath.split('/') const directory = parts.slice(0, -1).join('/') return directory @@ -13,10 +13,10 @@ export function getFileDirectory(filePath: string) { /** * 从文件路径中提取文件扩展名。 - * @param filePath 文件路径 - * @returns string 文件扩展名(小写),如果没有则返回 '.' + * @param {string} filePath 文件路径 + * @returns {string} 文件扩展名(小写),如果没有则返回 '.' */ -export function getFileExtension(filePath: string) { +export function getFileExtension(filePath: string): string { const parts = filePath.split('.') if (parts.length > 1) { const extension = parts.slice(-1)[0].toLowerCase() @@ -27,10 +27,10 @@ export function getFileExtension(filePath: string) { /** * 格式化文件大小,根据大小返回以 MB 或 KB 为单位的字符串。 - * @param size 文件大小(字节) - * @returns string 格式化后的文件大小字符串 + * @param {number} size 文件大小(字节) + * @returns {string} 格式化后的文件大小字符串 */ -export function formatFileSize(size: number) { +export function formatFileSize(size: number): string { if (size >= MB) { return (size / MB).toFixed(1) + ' MB' } @@ -46,10 +46,10 @@ export function formatFileSize(size: number) { * 从文件名中移除特殊字符: * - 替换非法字符为下划线 * - 替换换行符为空格。 - * @param str 输入字符串 - * @returns string 处理后的文件名字符串 + * @param {string} str 输入字符串 + * @returns {string} 处理后的文件名字符串 */ -export function removeSpecialCharactersForFileName(str: string) { +export function removeSpecialCharactersForFileName(str: string): string { return str .replace(/[<>:"/\\|?*.]/g, '_') .replace(/[\r\n]+/g, ' ') diff --git a/src/renderer/src/utils/image.ts b/src/renderer/src/utils/image.ts index 49a381507b..ee52739b7c 100644 --- a/src/renderer/src/utils/image.ts +++ b/src/renderer/src/utils/image.ts @@ -4,8 +4,8 @@ import * as htmlToImage from 'html-to-image' /** * 将文件转换为 Base64 编码的字符串或 ArrayBuffer。 - * @param file 要转换的文件 - * @returns Promise 转换后的 Base64 编码数据,如果出错则返回 null + * @param {File} file 要转换的文件 + * @returns {Promise} 转换后的 Base64 编码数据,如果出错则返回 null */ export const convertToBase64 = (file: File): Promise => { return new Promise((resolve, reject) => { @@ -18,10 +18,10 @@ export const convertToBase64 = (file: File): Promise 压缩后的图像文件 + * @param {File} file 要压缩的图像文件 + * @returns {Promise} 压缩后的图像文件 */ -export const compressImage = async (file: File) => { +export const compressImage = async (file: File): Promise => { return await imageCompression(file, { maxSizeMB: 1, maxWidthOrHeight: 300, diff --git a/src/renderer/src/utils/index.ts b/src/renderer/src/utils/index.ts index 07410cc246..ecf0890933 100644 --- a/src/renderer/src/utils/index.ts +++ b/src/renderer/src/utils/index.ts @@ -6,19 +6,19 @@ import { v4 as uuidv4 } from 'uuid' /** * 异步执行一个函数。 - * @param fn 要执行的函数 - * @returns Promise 执行结果 + * @param {() => void} fn 要执行的函数 + * @returns {Promise} 执行结果 */ -export const runAsyncFunction = async (fn: () => void) => { +export const runAsyncFunction = async (fn: () => void): Promise => { await fn() } /** * 创建一个延迟的 Promise,在指定秒数后解析。 - * @param seconds 延迟的秒数 - * @returns Promise 在指定秒数后解析的 Promise + * @param {number} seconds 延迟的秒数 + * @returns {Promise} 在指定秒数后解析的 Promise */ -export const delay = (seconds: number) => { +export const delay = (seconds: number): Promise => { return new Promise((resolve) => { setTimeout(() => { resolve(true) @@ -27,9 +27,17 @@ export const delay = (seconds: number) => { } /** - * Waiting fn return true - **/ -export const waitAsyncFunction = (fn: () => Promise, interval = 200, stopTimeout = 60000) => { + * 等待异步函数返回 true。 + * @param {() => Promise} fn 要等待的异步函数 + * @param {number} [interval=200] 检查间隔时间(毫秒) + * @param {number} [stopTimeout=60000] 停止等待的超时时间(毫秒) + * @returns {Promise} 异步函数返回 true 后的 Promise + */ +export const waitAsyncFunction = ( + fn: () => Promise, + interval: number = 200, + stopTimeout: number = 60000 +): Promise => { let timeout = false const timer = setTimeout(() => (timeout = true), stopTimeout) @@ -63,10 +71,10 @@ export async function isDev() { /** * 从错误对象中提取错误信息。 - * @param error 错误对象或字符串 - * @returns string 提取的错误信息,如果没有则返回空字符串 + * @param {any} error 错误对象或字符串 + * @returns {string} 提取的错误信息,如果没有则返回空字符串 */ -export function getErrorMessage(error: any) { +export function getErrorMessage(error: any): string { if (!error) { return '' } @@ -86,21 +94,31 @@ export function getErrorMessage(error: any) { return '' } -export function removeQuotes(str) { +/** + * 移除字符串中的引号。 + * @param {string} str 输入字符串 + * @returns {string} 新字符串 + */ +export function removeQuotes(str: string): string { return str.replace(/['"]+/g, '') } -export function removeSpecialCharacters(str: string) { +/** + * 移除字符串中的特殊字符。 + * @param {string} str 输入字符串 + * @returns {string} 新字符串 + */ +export function removeSpecialCharacters(str: string): string { // First remove newlines and quotes, then remove other special characters return str.replace(/[\n"]/g, '').replace(/[\p{M}\p{P}]/gu, '') } /** - * is valid proxy url - * @param url proxy url - * @returns boolean + * 检查 URL 是否是有效的代理 URL。 + * @param {string} url 代理 URL + * @returns {boolean} 是否有效 */ -export const isValidProxyUrl = (url: string) => { +export const isValidProxyUrl = (url: string): boolean => { return url.includes('://') } @@ -124,8 +142,8 @@ export function loadScript(url: string) { /** * 检查 URL 是否包含路径部分。 - * @param url 输入 URL 字符串 - * @returns boolean 如果 URL 包含路径则返回 true,否则返回 false + * @param {string} url 输入 URL 字符串 + * @returns {boolean} 如果 URL 包含路径则返回 true,否则返回 false */ export function hasPath(url: string): boolean { try { @@ -139,9 +157,9 @@ export function hasPath(url: string): boolean { /** * 比较两个版本号字符串。 - * @param v1 第一个版本号 - * @param v2 第二个版本号 - * @returns number 比较结果,1 表示 v1 大于 v2,-1 表示 v1 小于 v2,0 表示相等 + * @param {string} v1 第一个版本号 + * @param {string} v2 第二个版本号 + * @returns {number} 比较结果,1 表示 v1 大于 v2,-1 表示 v1 小于 v2,0 表示相等 */ export const compareVersions = (v1: string, v2: string): number => { const v1Parts = v1.split('.').map(Number) @@ -158,10 +176,10 @@ export const compareVersions = (v1: string, v2: string): number => { /** * 显示确认模态框。 - * @param params 模态框参数 - * @returns Promise 用户确认返回 true,取消返回 false + * @param {ModalFuncProps} params 模态框参数 + * @returns {Promise} 用户确认返回 true,取消返回 false */ -export function modalConfirm(params: ModalFuncProps) { +export function modalConfirm(params: ModalFuncProps): Promise { return new Promise((resolve) => { window.modal.confirm({ centered: true, @@ -174,11 +192,11 @@ export function modalConfirm(params: ModalFuncProps) { /** * 检查对象是否包含特定键。 - * @param obj 输入对象 - * @param key 要检查的键 - * @returns boolean 包含该键则返回 true,否则返回 false + * @param {any} obj 输入对象 + * @param {string} key 要检查的键 + * @returns {boolean} 包含该键则返回 true,否则返回 false */ -export function hasObjectKey(obj: any, key: string) { +export function hasObjectKey(obj: any, key: string): boolean { if (typeof obj !== 'object' || obj === null) { return false } @@ -188,10 +206,10 @@ export function hasObjectKey(obj: any, key: string) { /** * 从npm readme中提取 npx mcp config - * @param readme readme字符串 - * @returns mcp config sample + * @param {string} readme readme字符串 + * @returns {Record | null} mcp config sample */ -export function getMcpConfigSampleFromReadme(readme: string) { +export function getMcpConfigSampleFromReadme(readme: string): Record | null { if (readme) { try { const regex = /"mcpServers"\s*:\s*({(?:[^{}]*|{(?:[^{}]*|{[^{}]*})*})*})/g diff --git a/src/renderer/src/utils/json.ts b/src/renderer/src/utils/json.ts index 11babebbe2..c5f0cf9d58 100644 --- a/src/renderer/src/utils/json.ts +++ b/src/renderer/src/utils/json.ts @@ -1,6 +1,7 @@ /** * 判断字符串是否是 json 字符串 - * @param str 字符串 + * @param {any} str 字符串 + * @returns {boolean} 是否为 json 字符串 */ export function isJSON(str: any): boolean { if (typeof str !== 'string') { @@ -16,10 +17,10 @@ export function isJSON(str: any): boolean { /** * 尝试解析 JSON 字符串,如果解析失败则返回 null。 - * @param str 要解析的字符串 - * @returns 解析后的对象,如果解析失败则返回 null + * @param {string} str 要解析的字符串 + * @returns {any | null} 解析后的对象,解析失败返回 null */ -export function parseJSON(str: string) { +export function parseJSON(str: string): any | null { try { return JSON.parse(str) } catch (e) { diff --git a/src/renderer/src/utils/linkConverter.ts b/src/renderer/src/utils/linkConverter.ts index ac45cf96a3..258b85c9fd 100644 --- a/src/renderer/src/utils/linkConverter.ts +++ b/src/renderer/src/utils/linkConverter.ts @@ -7,8 +7,8 @@ let urlToCounterMap: Map = new Map() /** * Determines if a string looks like a host/URL - * @param text The text to check - * @returns Boolean indicating if the text is likely a host + * @param {string} text The text to check + * @returns {boolean} Boolean indicating if the text is likely a host */ function isHost(text: string): boolean { // Basic check for URL-like patterns @@ -18,11 +18,11 @@ function isHost(text: string): boolean { /** * Converts Markdown links in the text to numbered links based on the rules:s * [ref_N] -> [N] - * @param text The current chunk of text to process - * @param resetCounter Whether to reset the counter and buffer - * @returns Processed text with complete links converted + * @param {string} text The current chunk of text to process + * @param {boolean} resetCounter Whether to reset the counter and buffer + * @returns {string} Processed text with complete links converted */ -export function convertLinksToZhipu(text: string, resetCounter = false): string { +export function convertLinksToZhipu(text: string, resetCounter: boolean = false): string { if (resetCounter) { linkCounter = 1 buffer = '' @@ -57,7 +57,16 @@ export function convertLinksToZhipu(text: string, resetCounter = false): string }) } -export function convertLinksToHunyuan(text: string, webSearch: any[], resetCounter = false): string { +/** + * Converts Markdown links in the text to numbered links based on the rules: + * [N](@ref) -> [N]() + * [N,M,...](@ref) -> [N]() [M]() ... + * @param {string} text The current chunk of text to process + * @param {any[]} webSearch webSearch results + * @param {boolean} resetCounter Whether to reset the counter and buffer + * @returns {string} Processed text with complete links converted + */ +export function convertLinksToHunyuan(text: string, webSearch: any[], resetCounter: boolean = false): string { if (resetCounter) { linkCounter = 1 buffer = '' @@ -115,11 +124,11 @@ export function convertLinksToHunyuan(text: string, webSearch: any[], resetCount * 2. [host](url) -> [cnt](url) * 3. [any text except url](url)-> any text [cnt](url) * - * @param text The current chunk of text to process - * @param resetCounter Whether to reset the counter and buffer - * @returns Processed text with complete links converted + * @param {string} text The current chunk of text to process + * @param {boolean} resetCounter Whether to reset the counter and buffer + * @returns {string} Processed text with complete links converted */ -export function convertLinks(text: string, resetCounter = false): string { +export function convertLinks(text: string, resetCounter: boolean = false): string { if (resetCounter) { linkCounter = 1 buffer = '' @@ -235,9 +244,9 @@ export function convertLinks(text: string, resetCounter = false): string { * Converts Markdown links in the text to numbered links based on the rules: * 1. [host](url) -> [cnt](url) * - * @param text The current chunk of text to process - * @param resetCounter Whether to reset the counter and buffer - * @returns Processed text with complete links converted + * @param {string} text The current chunk of text to process + * @param {boolean} resetCounter Whether to reset the counter and buffer + * @returns {string} Processed text with complete links converted */ export function convertLinksToOpenRouter(text: string, resetCounter = false): string { if (resetCounter) { @@ -292,9 +301,9 @@ export function convertLinksToOpenRouter(text: string, resetCounter = false): st /** * 根据webSearch结果补全链接,将[num]()转换为[num](webSearch[num-1].url) - * @param text 原始文本 - * @param webSearch webSearch结果 - * @returns 补全后的文本 + * @param {string} text 原始文本 + * @param {any[]} webSearch webSearch结果 + * @returns {string} 补全后的文本 */ export function completeLinks(text: string, webSearch: any[]): string { // 使用正则表达式匹配形如 [num]() 的链接 @@ -316,8 +325,8 @@ export function completeLinks(text: string, webSearch: any[]): string { * 2. [num](url) * 3. ([text](url)) * - * @param text Markdown格式的文本 - * @returns 提取到的URL数组,去重后的结果 + * @param {string} text Markdown格式的文本 + * @returns {string[]} 提取到的URL数组,去重后的结果 */ export function extractUrlsFromMarkdown(text: string): string[] { const urlSet = new Set() @@ -338,8 +347,8 @@ export function extractUrlsFromMarkdown(text: string): string[] { /** * 验证字符串是否是有效的URL - * @param url 要验证的URL字符串 - * @returns 是否是有效的URL + * @param {string} url 要验证的URL字符串 + * @returns {boolean} 是否是有效的URL */ function isValidUrl(url: string): boolean { try { @@ -353,8 +362,8 @@ function isValidUrl(url: string): boolean { /** * 清理 Markdown 链接之间的逗号 * 例如: [text](url),[text](url) -> [text](url) [text](url) - * @param text 包含 Markdown 链接的文本 - * @returns 清理后的文本 + * @param {string} text 包含 Markdown 链接的文本 + * @returns {string} 清理后的文本 */ export function cleanLinkCommas(text: string): string { // 匹配两个 Markdown 链接之间的英文逗号(可能包含空格) diff --git a/src/renderer/src/utils/markdown.ts b/src/renderer/src/utils/markdown.ts index eed07fef24..05e9c8c39c 100644 --- a/src/renderer/src/utils/markdown.ts +++ b/src/renderer/src/utils/markdown.ts @@ -3,9 +3,13 @@ import remarkStringify from 'remark-stringify' import { unified } from 'unified' import { visit } from 'unist-util-visit' -// 更彻底的查找方法,递归搜索所有子元素 -export const findCitationInChildren = (children) => { - if (!children) return null +/** + * 更彻底的查找方法,递归搜索所有子元素 + * @param {any} children 子元素 + * @returns {string} 找到的 citation 或 '' + */ +export const findCitationInChildren = (children: any): string => { + if (!children) return '' // 直接搜索子元素 for (const child of Array.isArray(children) ? children : [children]) { @@ -20,17 +24,17 @@ export const findCitationInChildren = (children) => { } } - return null + return '' } /** * 转换数学公式格式: * - 将 LaTeX 格式的 '\\[' 和 '\\]' 转换为 '$$$$'。 * - 将 LaTeX 格式的 '\\(' 和 '\\)' 转换为 '$$'。 - * @param input 输入字符串 - * @returns string 转换后的字符串 + * @param {string} input 输入字符串 + * @returns {string} 转换后的字符串 */ -export function convertMathFormula(input) { +export function convertMathFormula(input: string): string { if (!input) return input let result = input @@ -41,8 +45,8 @@ export function convertMathFormula(input) { /** * 移除 Markdown 文本中每行末尾的两个空格。 - * @param markdown 输入的 Markdown 文本 - * @returns string 处理后的文本 + * @param {string} markdown 输入的 Markdown 文本 + * @returns {string} 处理后的文本 */ export function removeTrailingDoubleSpaces(markdown: string): string { // 使用正则表达式匹配末尾的两个空格,并替换为空字符串 diff --git a/src/renderer/src/utils/naming.ts b/src/renderer/src/utils/naming.ts index 6645862a38..d27b1da30b 100644 --- a/src/renderer/src/utils/naming.ts +++ b/src/renderer/src/utils/naming.ts @@ -12,11 +12,11 @@ * - 'deepseek-r1' => 'deepseek-r1' * - 'o3' => 'o3' * - * @param id 模型 ID 字符串 - * @param provider 提供商 ID 字符串 - * @returns string 提取的组名 + * @param {string} id 模型 ID 字符串 + * @param {string} [provider] 提供商 ID 字符串 + * @returns {string} 提取的组名 */ -export const getDefaultGroupName = (id: string, provider?: string) => { +export const getDefaultGroupName = (id: string, provider?: string): string => { const str = id.toLowerCase() // 定义分隔符 @@ -48,8 +48,8 @@ export const getDefaultGroupName = (id: string, provider?: string) => { /** * 用于获取 avatar 名字的辅助函数,会取出字符串的第一个字符,支持表情符号。 - * @param str 输入字符串 - * @returns string 第一个字符,或者返回空字符串 + * @param {string} str 输入字符串 + * @returns {string} 第一个字符,或者返回空字符串 */ export function firstLetter(str: string): string { const match = str?.match(/\p{L}\p{M}*|\p{Emoji_Presentation}|\p{Emoji}\uFE0F/u) @@ -58,8 +58,8 @@ export function firstLetter(str: string): string { /** * 移除字符串开头的表情符号。 - * @param str 输入字符串 - * @returns string 移除开头表情符号后的字符串 + * @param {string} str 输入字符串 + * @returns {string} 移除开头表情符号后的字符串 */ export function removeLeadingEmoji(str: string): string { const emojiRegex = /^(\p{Emoji_Presentation}|\p{Emoji}\uFE0F)+/u @@ -68,8 +68,8 @@ export function removeLeadingEmoji(str: string): string { /** * 提取字符串开头的表情符号。 - * @param str 输入字符串 - * @returns string 开头的表情符号,如果没有则返回空字符串 + * @param {string} str 输入字符串 + * @returns {string} 开头的表情符号,如果没有则返回空字符串 */ export function getLeadingEmoji(str: string): string { const emojiRegex = /^(\p{Emoji_Presentation}|\p{Emoji}\uFE0F)+/u @@ -79,8 +79,8 @@ export function getLeadingEmoji(str: string): string { /** * 检查字符串是否为纯表情符号。 - * @param str 输入字符串 - * @returns boolean 如果字符串是纯表情符号则返回 true,否则返回 false + * @param {string} str 输入字符串 + * @returns {boolean} 如果字符串是纯表情符号则返回 true,否则返回 false */ export function isEmoji(str: string): boolean { if (str.startsWith('data:')) { @@ -97,19 +97,19 @@ export function isEmoji(str: string): boolean { /** * 从话题名称中移除特殊字符: * - 替换换行符为空格。 - * @param str 输入字符串 - * @returns string 处理后的字符串 + * @param {string} str 输入字符串 + * @returns {string} 处理后的字符串 */ -export function removeSpecialCharactersForTopicName(str: string) { +export function removeSpecialCharactersForTopicName(str: string): string { return str.replace(/[\r\n]+/g, ' ').trim() } /** * 根据字符生成颜色代码,用于 avatar。 - * @param char 输入字符 - * @returns string 十六进制颜色字符串 + * @param {string} char 输入字符 + * @returns {string} 十六进制颜色字符串 */ -export function generateColorFromChar(char: string) { +export function generateColorFromChar(char: string): string { // 使用字符的Unicode值作为随机种子 const seed = char.charCodeAt(0) @@ -134,23 +134,23 @@ export function generateColorFromChar(char: string) { /** * 获取字符串的第一个字符。 - * @param str 输入字符串 - * @returns string 第一个字符,或者空字符串 + * @param {string} str 输入字符串 + * @returns {string} 第一个字符,或者空字符串 */ -export function getFirstCharacter(str) { - if (str.length === 0) return '' - +export function getFirstCharacter(str: string): string { // 使用 for...of 循环来获取第一个字符 for (const char of str) { return char } + + return '' } /** * 用于简化文本。按照给定长度限制截断文本,考虑语义边界。 - * @param text 输入文本 - * @param maxLength 最大长度,默认为 50 - * @returns string 处理后的简短文本 + * @param {string} text 输入文本 + * @param {number} [maxLength=50] 最大长度,默认为 50 + * @returns {string} 处理后的简短文本 */ export function getBriefInfo(text: string, maxLength: number = 50): string { // 去除空行 diff --git a/src/renderer/src/utils/sort.ts b/src/renderer/src/utils/sort.ts index d26a86c14f..6d7c71fc62 100644 --- a/src/renderer/src/utils/sort.ts +++ b/src/renderer/src/utils/sort.ts @@ -1,13 +1,13 @@ /** * 用于 dnd 列表的元素重新排序方法。支持多元素"拖动"排序。 - * @template T 列表元素的类型 - * @param list 要重新排序的列表 - * @param sourceIndex 起始元素索引 - * @param destIndex 目标元素索引 - * @param len 要移动的元素数量,默认为 1 - * @returns T[] 重新排序后的列表 + * @template {T} 列表元素的类型 + * @param {T[]} list 要重新排序的列表 + * @param {number} sourceIndex 起始元素索引 + * @param {number} destIndex 目标元素索引 + * @param {number} [len=1] 要移动的元素数量,默认为 1 + * @returns {T[]} 重新排序后的列表 */ -export function droppableReorder(list: T[], sourceIndex: number, destIndex: number, len = 1) { +export function droppableReorder(list: T[], sourceIndex: number, destIndex: number, len: number = 1): T[] { const result = Array.from(list) const removed = result.splice(sourceIndex, len) @@ -21,11 +21,11 @@ export function droppableReorder(list: T[], sourceIndex: number, destIndex: n /** * 首字母为英文的字符串排在前面。 - * @param a 字符串 - * @param b 字符串 - * @returns 排序后的字符串 + * @param {string} a 字符串 + * @param {string} b 字符串 + * @returns {number} 排序后的字符串 */ -export function sortByEnglishFirst(a: string, b: string) { +export function sortByEnglishFirst(a: string, b: string): number { const isAEnglish = /^[a-zA-Z]/.test(a) const isBEnglish = /^[a-zA-Z]/.test(b) if (isAEnglish && !isBEnglish) return -1 diff --git a/src/renderer/src/utils/style.ts b/src/renderer/src/utils/style.ts index 66e8e5c9d0..4c9f3df735 100644 --- a/src/renderer/src/utils/style.ts +++ b/src/renderer/src/utils/style.ts @@ -6,18 +6,18 @@ interface ClassDictionary { interface ClassArray extends Array {} -// Example: -// classNames('foo', 'bar'); // => 'foo bar' -// classNames('foo', { bar: true }); // => 'foo bar' -// classNames({ foo: true, bar: false }); // => 'foo' -// classNames(['foo', 'bar']); // => 'foo bar' -// classNames('foo', null, 'bar'); // => 'foo bar' -// classNames({ message: true, 'message-assistant': true }); // => 'message message-assistant' - /** * 生成 class 字符串 - * @param args - * @returns + * + * Examples: + * classNames('foo', 'bar'); // => 'foo bar' + * classNames('foo', { bar: true }); // => 'foo bar' + * classNames({ foo: true, bar: false }); // => 'foo' + * classNames(['foo', 'bar']); // => 'foo bar' + * classNames('foo', null, 'bar'); // => 'foo bar' + * classNames({ message: true, 'message-assistant': true }); // => 'message message-assistant' + * @param {ClassValue[]} args + * @returns {string} */ export function classNames(...args: ClassValue[]): string { const classes: string[] = [] diff --git a/tests/e2e/launch.test.tsx b/tests/e2e/launch.test.tsx new file mode 100644 index 0000000000..8636c01695 --- /dev/null +++ b/tests/e2e/launch.test.tsx @@ -0,0 +1,13 @@ +import { _electron as electron, expect, test } from '@playwright/test' + +let electronApp: any +let window: any + +test.describe('App Launch', () => { + test('should launch and close the main application', async () => { + electronApp = await electron.launch({ args: ['.'] }) + window = await electronApp.firstWindow() + expect(window).toBeDefined() + await electronApp.close() + }) +}) diff --git a/tests/renderer.setup.ts b/tests/renderer.setup.ts new file mode 100644 index 0000000000..cafad80a31 --- /dev/null +++ b/tests/renderer.setup.ts @@ -0,0 +1,46 @@ +import '@testing-library/jest-dom/vitest' + +import { styleSheetSerializer } from 'jest-styled-components/serializer' +import { expect, vi } from 'vitest' + +expect.addSnapshotSerializer(styleSheetSerializer) + +vi.mock('electron-log/renderer', () => { + return { + default: { + info: console.log, + error: console.error, + warn: console.warn, + debug: console.debug, + verbose: console.log, + silly: console.log, + log: console.log, + transports: { + console: { + level: 'info' + } + } + } + } +}) + +vi.mock('axios', () => ({ + default: { + get: vi.fn().mockResolvedValue({ data: {} }), // Mocking axios GET request + post: vi.fn().mockResolvedValue({ data: {} }) // Mocking axios POST request + // You can add other axios methods like put, delete etc. as needed + } +})) + +vi.stubGlobal('electron', { + ipcRenderer: { + on: vi.fn(), + send: vi.fn() + } +}) +vi.stubGlobal('api', { + file: { + read: vi.fn().mockResolvedValue('[]'), + writeWithId: vi.fn().mockResolvedValue(undefined) + } +}) diff --git a/vitest.config.ts b/vitest.config.ts index b0e9ebd67a..43f577eb1c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,35 +2,54 @@ import { defineConfig } from 'vitest/config' import electronViteConfig from './electron.vite.config' -const rendererConfig = electronViteConfig.renderer +const mainConfig = (electronViteConfig as any).main +const rendererConfig = (electronViteConfig as any).renderer export default defineConfig({ - // 复用 renderer 插件和路径别名 - // @ts-ignore plugins 类型 - plugins: rendererConfig?.plugins, - resolve: { - // @ts-ignore alias 类型 - alias: rendererConfig?.resolve.alias - }, test: { - environment: 'jsdom', - globals: true, - setupFiles: ['@vitest/web-worker', './src/renderer/__tests__/setup.ts'], - include: [ - // 只测试渲染进程 - 'src/renderer/**/*.{test,spec}.{ts,tsx}', - 'src/renderer/**/__tests__/**/*.{test,spec}.{ts,tsx}' + workspace: [ + // 主进程单元测试配置 + { + extends: true, + plugins: mainConfig.plugins, + resolve: { + alias: mainConfig.resolve.alias + }, + test: { + name: 'main', + environment: 'node', + include: ['src/main/**/*.{test,spec}.{ts,tsx}', 'src/main/**/__tests__/**/*.{test,spec}.{ts,tsx}'] + } + }, + // 渲染进程单元测试配置 + { + extends: true, + plugins: rendererConfig.plugins, + resolve: { + alias: rendererConfig.resolve.alias + }, + test: { + name: 'renderer', + environment: 'jsdom', + setupFiles: ['@vitest/web-worker', 'tests/renderer.setup.ts'], + include: ['src/renderer/**/*.{test,spec}.{ts,tsx}', 'src/renderer/**/__tests__/**/*.{test,spec}.{ts,tsx}'] + } + } ], - exclude: ['**/node_modules/**', '**/dist/**', '**/out/**', '**/build/**', '**/src/renderer/__tests__/setup.ts'], + // 全局共享配置 + globals: true, + setupFiles: [], + exclude: ['**/node_modules/**', '**/dist/**', '**/out/**', '**/build/**'], coverage: { provider: 'v8', - reporter: ['text', 'json', 'html', 'lcov'], + reporter: ['text', 'json', 'html', 'lcov', 'text-summary'], exclude: [ '**/node_modules/**', '**/dist/**', '**/out/**', '**/build/**', '**/coverage/**', + '**/tests/**', '**/.yarn/**', '**/.cursor/**', '**/.vscode/**', @@ -40,9 +59,7 @@ export default defineConfig({ '**/types/**', '**/__tests__/**', '**/*.{test,spec}.{ts,tsx}', - '**/*.config.{js,ts}', - '**/electron.vite.config.ts', - '**/vitest.config.ts' + '**/*.config.{js,ts}' ] }, testTimeout: 20000, diff --git a/yarn.lock b/yarn.lock index cc628bc2e6..6f2d2f1e28 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12,6 +12,13 @@ __metadata: languageName: node linkType: hard +"@adobe/css-tools@npm:^4.0.1, @adobe/css-tools@npm:^4.4.0": + version: 4.4.2 + resolution: "@adobe/css-tools@npm:4.4.2" + checksum: 10c0/19433666ad18536b0ed05d4b53fbb3dd6ede266996796462023ec77a90b484890ad28a3e528cdf3ab8a65cb2fcdff5d8feb04db6bc6eed6ca307c40974239c94 + languageName: node + linkType: hard + "@agentic/core@npm:7.6.4": version: 7.6.4 resolution: "@agentic/core@npm:7.6.4" @@ -67,7 +74,7 @@ __metadata: languageName: node linkType: hard -"@ampproject/remapping@npm:^2.2.0": +"@ampproject/remapping@npm:^2.2.0, @ampproject/remapping@npm:^2.3.0": version: 2.3.0 resolution: "@ampproject/remapping@npm:2.3.0" dependencies: @@ -221,6 +228,17 @@ __metadata: languageName: node linkType: hard +"@babel/code-frame@npm:^7.10.4": + version: 7.27.1 + resolution: "@babel/code-frame@npm:7.27.1" + dependencies: + "@babel/helper-validator-identifier": "npm:^7.27.1" + js-tokens: "npm:^4.0.0" + picocolors: "npm:^1.1.1" + checksum: 10c0/5dd9a18baa5fce4741ba729acc3a3272c49c25cb8736c4b18e113099520e7ef7b545a4096a26d600e4416157e63e87d66db46aa3fbf0a5f2286da2705c12da00 + languageName: node + linkType: hard + "@babel/code-frame@npm:^7.26.2": version: 7.26.2 resolution: "@babel/code-frame@npm:7.26.2" @@ -325,6 +343,13 @@ __metadata: languageName: node linkType: hard +"@babel/helper-string-parser@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/helper-string-parser@npm:7.27.1" + checksum: 10c0/8bda3448e07b5583727c103560bcf9c4c24b3c1051a4c516d4050ef69df37bb9a4734a585fe12725b8c2763de0a265aa1e909b485a4e3270b7cfd3e4dbe4b602 + languageName: node + linkType: hard + "@babel/helper-validator-identifier@npm:^7.25.9": version: 7.25.9 resolution: "@babel/helper-validator-identifier@npm:7.25.9" @@ -332,6 +357,13 @@ __metadata: languageName: node linkType: hard +"@babel/helper-validator-identifier@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/helper-validator-identifier@npm:7.27.1" + checksum: 10c0/c558f11c4871d526498e49d07a84752d1800bf72ac0d3dad100309a2eaba24efbf56ea59af5137ff15e3a00280ebe588560534b0e894a4750f8b1411d8f78b84 + languageName: node + linkType: hard + "@babel/helper-validator-option@npm:^7.25.9": version: 7.25.9 resolution: "@babel/helper-validator-option@npm:7.25.9" @@ -349,6 +381,17 @@ __metadata: languageName: node linkType: hard +"@babel/parser@npm:^7.25.4": + version: 7.27.2 + resolution: "@babel/parser@npm:7.27.2" + dependencies: + "@babel/types": "npm:^7.27.1" + bin: + parser: ./bin/babel-parser.js + checksum: 10c0/3c06692768885c2f58207fc8c2cbdb4a44df46b7d93135a083f6eaa49310f7ced490ce76043a2a7606cdcc13f27e3d835e141b692f2f6337a2e7f43c1dbb04b4 + languageName: node + linkType: hard + "@babel/parser@npm:^7.26.10, @babel/parser@npm:^7.27.0": version: 7.27.0 resolution: "@babel/parser@npm:7.27.0" @@ -371,7 +414,7 @@ __metadata: languageName: node linkType: hard -"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.18.6": +"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.18.6": version: 7.27.1 resolution: "@babel/runtime@npm:7.27.1" checksum: 10c0/530a7332f86ac5a7442250456823a930906911d895c0b743bf1852efc88a20a016ed4cd26d442d0ca40ae6d5448111e02a08dd638a4f1064b47d080e2875dc05 @@ -413,6 +456,16 @@ __metadata: languageName: node linkType: hard +"@babel/types@npm:^7.25.4, @babel/types@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/types@npm:7.27.1" + dependencies: + "@babel/helper-string-parser": "npm:^7.27.1" + "@babel/helper-validator-identifier": "npm:^7.27.1" + checksum: 10c0/ed736f14db2fdf0d36c539c8e06b6bb5e8f9649a12b5c0e1c516fed827f27ef35085abe08bf4d1302a4e20c9a254e762eed453bce659786d4a6e01ba26a91377 + languageName: node + linkType: hard + "@babel/types@npm:^7.25.9, @babel/types@npm:^7.26.10, @babel/types@npm:^7.27.0": version: 7.27.0 resolution: "@babel/types@npm:7.27.0" @@ -423,6 +476,13 @@ __metadata: languageName: node linkType: hard +"@bcoe/v8-coverage@npm:^1.0.2": + version: 1.0.2 + resolution: "@bcoe/v8-coverage@npm:1.0.2" + checksum: 10c0/1eb1dc93cc17fb7abdcef21a6e7b867d6aa99a7ec88ec8207402b23d9083ab22a8011213f04b2cf26d535f1d22dc26139b7929e6c2134c254bd1e14ba5e678c3 + languageName: node + linkType: hard + "@braintree/sanitize-url@npm:^7.0.4": version: 7.1.1 resolution: "@braintree/sanitize-url@npm:7.1.1" @@ -2063,6 +2123,13 @@ __metadata: languageName: node linkType: hard +"@istanbuljs/schema@npm:^0.1.2": + version: 0.1.3 + resolution: "@istanbuljs/schema@npm:0.1.3" + checksum: 10c0/61c5286771676c9ca3eb2bd8a7310a9c063fb6e0e9712225c8471c582d157392c88f5353581c8c9adbe0dff98892317d2fdfc56c3499aa42e0194405206a963a + languageName: node + linkType: hard + "@jimp/bmp@npm:^0.16.13": version: 0.16.13 resolution: "@jimp/bmp@npm:0.16.13" @@ -2519,7 +2586,7 @@ __metadata: languageName: node linkType: hard -"@jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.25": +"@jridgewell/trace-mapping@npm:^0.3.23, @jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.25": version: 0.3.25 resolution: "@jridgewell/trace-mapping@npm:0.3.25" dependencies: @@ -3614,6 +3681,17 @@ __metadata: languageName: node linkType: hard +"@playwright/test@npm:^1.52.0": + version: 1.52.0 + resolution: "@playwright/test@npm:1.52.0" + dependencies: + playwright: "npm:1.52.0" + bin: + playwright: cli.js + checksum: 10c0/1c428b421593eb4f79b7c99783a389c3ab3526c9051ec772749f4fca61414dfa9f2344eba846faac5f238084aa96c836364a91d81d3034ac54924f239a93e247 + languageName: node + linkType: hard + "@polka/url@npm:^1.0.0-next.24": version: 1.0.0-next.29 resolution: "@polka/url@npm:1.0.0-next.29" @@ -4261,6 +4339,66 @@ __metadata: languageName: node linkType: hard +"@testing-library/dom@npm:^10.4.0": + version: 10.4.0 + resolution: "@testing-library/dom@npm:10.4.0" + dependencies: + "@babel/code-frame": "npm:^7.10.4" + "@babel/runtime": "npm:^7.12.5" + "@types/aria-query": "npm:^5.0.1" + aria-query: "npm:5.3.0" + chalk: "npm:^4.1.0" + dom-accessibility-api: "npm:^0.5.9" + lz-string: "npm:^1.5.0" + pretty-format: "npm:^27.0.2" + checksum: 10c0/0352487720ecd433400671e773df0b84b8268fb3fe8e527cdfd7c11b1365b398b4e0eddba6e7e0c85e8d615f48257753283fccec41f6b986fd6c85f15eb5f84f + languageName: node + linkType: hard + +"@testing-library/jest-dom@npm:^6.6.3": + version: 6.6.3 + resolution: "@testing-library/jest-dom@npm:6.6.3" + dependencies: + "@adobe/css-tools": "npm:^4.4.0" + aria-query: "npm:^5.0.0" + chalk: "npm:^3.0.0" + css.escape: "npm:^1.5.1" + dom-accessibility-api: "npm:^0.6.3" + lodash: "npm:^4.17.21" + redent: "npm:^3.0.0" + checksum: 10c0/5566b6c0b7b0709bc244aec3aa3dc9e5f4663e8fb2b99d8cd456fc07279e59db6076cbf798f9d3099a98fca7ef4cd50e4e1f4c4dec5a60a8fad8d24a638a5bf6 + languageName: node + linkType: hard + +"@testing-library/react@npm:^16.3.0": + version: 16.3.0 + resolution: "@testing-library/react@npm:16.3.0" + dependencies: + "@babel/runtime": "npm:^7.12.5" + peerDependencies: + "@testing-library/dom": ^10.0.0 + "@types/react": ^18.0.0 || ^19.0.0 + "@types/react-dom": ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/3a2cb1f87c9a67e1ebbbcfd99b94b01e496fc35147be8bc5d8bf07a699c7d523a09d57ef2f7b1d91afccd1a28e21eda3b00d80187fbb51b1de01e422592d845e + languageName: node + linkType: hard + +"@testing-library/user-event@npm:^14.6.1": + version: 14.6.1 + resolution: "@testing-library/user-event@npm:14.6.1" + peerDependencies: + "@testing-library/dom": ">=7.21.4" + checksum: 10c0/75fea130a52bf320d35d46ed54f3eec77e71a56911b8b69a3fe29497b0b9947b2dc80d30f04054ad4ce7f577856ae3e5397ea7dff0ef14944d3909784c7a93fe + languageName: node + linkType: hard + "@tokenizer/token@npm:^0.3.0": version: 0.3.0 resolution: "@tokenizer/token@npm:0.3.0" @@ -4295,6 +4433,13 @@ __metadata: languageName: node linkType: hard +"@types/aria-query@npm:^5.0.1": + version: 5.0.4 + resolution: "@types/aria-query@npm:5.0.4" + checksum: 10c0/dc667bc6a3acc7bba2bccf8c23d56cb1f2f4defaa704cfef595437107efaa972d3b3db9ec1d66bc2711bfc35086821edd32c302bffab36f2e79b97f312069f08 + languageName: node + linkType: hard + "@types/cacheable-request@npm:^6.0.1": version: 6.0.3 resolution: "@types/cacheable-request@npm:6.0.3" @@ -5546,23 +5691,76 @@ __metadata: languageName: node linkType: hard -"@vitest/expect@npm:3.1.1": - version: 3.1.1 - resolution: "@vitest/expect@npm:3.1.1" +"@vitest/browser@npm:^3.1.4": + version: 3.1.4 + resolution: "@vitest/browser@npm:3.1.4" dependencies: - "@vitest/spy": "npm:3.1.1" - "@vitest/utils": "npm:3.1.1" - chai: "npm:^5.2.0" + "@testing-library/dom": "npm:^10.4.0" + "@testing-library/user-event": "npm:^14.6.1" + "@vitest/mocker": "npm:3.1.4" + "@vitest/utils": "npm:3.1.4" + magic-string: "npm:^0.30.17" + sirv: "npm:^3.0.1" tinyrainbow: "npm:^2.0.0" - checksum: 10c0/ef4528d0ebb89eb3cc044cf597d051c35df8471bb6ba4029e9b3412aa69d0d85a0ce4eb49531fc78fe1ebd97e6428260463068cc96a8d8c1a80150dedfd1ab3a + ws: "npm:^8.18.1" + peerDependencies: + playwright: "*" + vitest: 3.1.4 + webdriverio: ^7.0.0 || ^8.0.0 || ^9.0.0 + peerDependenciesMeta: + playwright: + optional: true + safaridriver: + optional: true + webdriverio: + optional: true + checksum: 10c0/e946141f86aa4eac4bff1c99258e2d0b1710fa37bf769799fb76a9835ffab65f4a0082dcc4fe928bd939d4795e0ef0a9c60818b05e710945995c6fa3373f484a languageName: node linkType: hard -"@vitest/mocker@npm:3.1.1": - version: 3.1.1 - resolution: "@vitest/mocker@npm:3.1.1" +"@vitest/coverage-v8@npm:^3.1.4": + version: 3.1.4 + resolution: "@vitest/coverage-v8@npm:3.1.4" dependencies: - "@vitest/spy": "npm:3.1.1" + "@ampproject/remapping": "npm:^2.3.0" + "@bcoe/v8-coverage": "npm:^1.0.2" + debug: "npm:^4.4.0" + istanbul-lib-coverage: "npm:^3.2.2" + istanbul-lib-report: "npm:^3.0.1" + istanbul-lib-source-maps: "npm:^5.0.6" + istanbul-reports: "npm:^3.1.7" + magic-string: "npm:^0.30.17" + magicast: "npm:^0.3.5" + std-env: "npm:^3.9.0" + test-exclude: "npm:^7.0.1" + tinyrainbow: "npm:^2.0.0" + peerDependencies: + "@vitest/browser": 3.1.4 + vitest: 3.1.4 + peerDependenciesMeta: + "@vitest/browser": + optional: true + checksum: 10c0/e2073c06254772bfcaf00e40b76599aa9a3d66fc84c3d980941c4d216b4cf3db8b6f7f0ebcd4905c4ca08e8d19b505d9c428363e51a80a2d653a4a510b280e41 + languageName: node + linkType: hard + +"@vitest/expect@npm:3.1.4": + version: 3.1.4 + resolution: "@vitest/expect@npm:3.1.4" + dependencies: + "@vitest/spy": "npm:3.1.4" + "@vitest/utils": "npm:3.1.4" + chai: "npm:^5.2.0" + tinyrainbow: "npm:^2.0.0" + checksum: 10c0/9cfd7eb6d965a179b4ec0610a9c08b14dc97dbaf81925c8209a054f7a2a3d1eef59fa5e5cd4dd9bf8cb940d85aee5f5102555511a94be9933faf4cc734462a16 + languageName: node + linkType: hard + +"@vitest/mocker@npm:3.1.4": + version: 3.1.4 + resolution: "@vitest/mocker@npm:3.1.4" + dependencies: + "@vitest/spy": "npm:3.1.4" estree-walker: "npm:^3.0.3" magic-string: "npm:^0.30.17" peerDependencies: @@ -5573,85 +5771,85 @@ __metadata: optional: true vite: optional: true - checksum: 10c0/9264558809e2d7c77ae9ceefad521dc5f886a567aaf0bdd021b73089b8906ffd92c893f3998d16814f38fc653c7413836f508712355c87749a0e86c7d435eec1 + checksum: 10c0/d0b89e3974830d3893e7b8324a77ffeb9436db0969b57c01e2508ebd5b374c9d01f73796c8df8f555a3b1e1b502d40e725f159cd85966eebd3145b2f52e605e2 languageName: node linkType: hard -"@vitest/pretty-format@npm:3.1.1, @vitest/pretty-format@npm:^3.1.1": - version: 3.1.1 - resolution: "@vitest/pretty-format@npm:3.1.1" +"@vitest/pretty-format@npm:3.1.4, @vitest/pretty-format@npm:^3.1.4": + version: 3.1.4 + resolution: "@vitest/pretty-format@npm:3.1.4" dependencies: tinyrainbow: "npm:^2.0.0" - checksum: 10c0/540cd46d317fc80298c93b185f3fb48dfe90eaaa3942fd700fde6e88d658772c01b56ad5b9b36e4ac368a02e0fc8e0dc72bbdd6dd07a5d75e89ef99c8df5ba6e + checksum: 10c0/11e133640435822b8b8528be540b3d66c1de27ebc2dcf1de87608b7f01a44d15302c4d4bf8330fa848a435450d88a09d7e9442747a5739ae5f500ccdd1493159 languageName: node linkType: hard -"@vitest/runner@npm:3.1.1": - version: 3.1.1 - resolution: "@vitest/runner@npm:3.1.1" +"@vitest/runner@npm:3.1.4": + version: 3.1.4 + resolution: "@vitest/runner@npm:3.1.4" dependencies: - "@vitest/utils": "npm:3.1.1" + "@vitest/utils": "npm:3.1.4" pathe: "npm:^2.0.3" - checksum: 10c0/35a541069c3c94a2dd02fca2d70cc8d5e66ba2e891cfb80da354174f510aeb96774ffb34fff39cecde9d5c969be4dd20e240a900beb9b225b7512a615ecc5503 + checksum: 10c0/efb7512eebd3d786baa617eab332ec9ca6ce62eb1c9dd3945019f7510d745b3cd0fc2978868d792050905aacbf158eefc132359c83e61f0398b46be566013ee6 languageName: node linkType: hard -"@vitest/snapshot@npm:3.1.1": - version: 3.1.1 - resolution: "@vitest/snapshot@npm:3.1.1" +"@vitest/snapshot@npm:3.1.4": + version: 3.1.4 + resolution: "@vitest/snapshot@npm:3.1.4" dependencies: - "@vitest/pretty-format": "npm:3.1.1" + "@vitest/pretty-format": "npm:3.1.4" magic-string: "npm:^0.30.17" pathe: "npm:^2.0.3" - checksum: 10c0/43e5fc5db580f20903eb1493d07f08752df8864f7b9b7293a202b2ffe93d8c196a5614d66dda096c6bacc16e12f1836f33ba41898812af6d32676d1eb501536a + checksum: 10c0/ce9d51e1b03e4f91ffad160c570991a8a3c603cb7dc2a9020e58c012e62dccbe2c6ee45e1a1d8489e265b4485c6721eb73b5e91404d1c76da08dcd663f4e18d1 languageName: node linkType: hard -"@vitest/spy@npm:3.1.1": - version: 3.1.1 - resolution: "@vitest/spy@npm:3.1.1" +"@vitest/spy@npm:3.1.4": + version: 3.1.4 + resolution: "@vitest/spy@npm:3.1.4" dependencies: tinyspy: "npm:^3.0.2" - checksum: 10c0/896659d4b42776cfa2057a1da2c33adbd3f2ebd28005ca606d1616d08d2e726dc1460fb37f1ea7f734756b5bccf926c7165f410e63f0a3b8d992eb5489528b08 + checksum: 10c0/747914ac18efa82d75349b0fb0ad8a5e2af6e04f5bbb50a980c9270dd8958f9ddf84cee0849a54e1645af088fc1f709add94a35e99cb14aca2cdb322622ba501 languageName: node linkType: hard -"@vitest/ui@npm:^3.1.1": - version: 3.1.1 - resolution: "@vitest/ui@npm:3.1.1" +"@vitest/ui@npm:^3.1.4": + version: 3.1.4 + resolution: "@vitest/ui@npm:3.1.4" dependencies: - "@vitest/utils": "npm:3.1.1" + "@vitest/utils": "npm:3.1.4" fflate: "npm:^0.8.2" flatted: "npm:^3.3.3" pathe: "npm:^2.0.3" sirv: "npm:^3.0.1" - tinyglobby: "npm:^0.2.12" + tinyglobby: "npm:^0.2.13" tinyrainbow: "npm:^2.0.0" peerDependencies: - vitest: 3.1.1 - checksum: 10c0/03bd014a4afa2c4cd6007d8000d881c653414f30d275fe35067b3d50c8a07b9f53cb2a294a8d36adaece7e4671030f90bd51aedb412d64479b981e051e7996ba + vitest: 3.1.4 + checksum: 10c0/02dd00e92f73aa0b71f69a374a7f991f16da13d3cec044f341b59e29209ad6197e2b9733b15f2a1b32ef77e1a9d5069eeb574035c3cea749ac2800df7ea23698 languageName: node linkType: hard -"@vitest/utils@npm:3.1.1": - version: 3.1.1 - resolution: "@vitest/utils@npm:3.1.1" +"@vitest/utils@npm:3.1.4": + version: 3.1.4 + resolution: "@vitest/utils@npm:3.1.4" dependencies: - "@vitest/pretty-format": "npm:3.1.1" + "@vitest/pretty-format": "npm:3.1.4" loupe: "npm:^3.1.3" tinyrainbow: "npm:^2.0.0" - checksum: 10c0/a9cfe0c0f095b58644ce3ba08309de5be8564c10dad9e62035bd378e60b2834e6a256e6e4ded7dcf027fdc2371301f7965040ad3e6323b747d5b3abbb7ceb0d6 + checksum: 10c0/78f1691a2dd578862b236f4962815e7475e547f006e7303a149dc5f910cc1ce6e0bdcbd7b4fd618122d62ca2dcc28bae464d31543f3898f5d88fa35017e00a95 languageName: node linkType: hard -"@vitest/web-worker@npm:^3.1.3": - version: 3.1.3 - resolution: "@vitest/web-worker@npm:3.1.3" +"@vitest/web-worker@npm:^3.1.4": + version: 3.1.4 + resolution: "@vitest/web-worker@npm:3.1.4" dependencies: debug: "npm:^4.4.0" peerDependencies: - vitest: 3.1.3 - checksum: 10c0/8b8f46f55da0d99716a88d9017d233a4710696405bcd138a5374370b2ce880f71108884a9267905d867d28fcdd1363cf894ef0d9e17268b1d2ff65dc5f706ea6 + vitest: 3.1.4 + checksum: 10c0/dd883a6a52ca9efd63e5055e87c2e61f0b0fe1cfa95472453ee3db5c3880796aa38042ca968000c14020d410840accbb38ca045ca0c9312c3c7b9b57e0e01a36 languageName: node linkType: hard @@ -5750,11 +5948,15 @@ __metadata: "@modelcontextprotocol/sdk": "npm:^1.11.4" "@mozilla/readability": "npm:^0.6.0" "@notionhq/client": "npm:^2.2.15" + "@playwright/test": "npm:^1.52.0" "@reduxjs/toolkit": "npm:^2.2.5" "@shikijs/markdown-it": "npm:^3.4.2" "@strongtz/win32-arm64-msvc": "npm:^0.4.7" "@swc/plugin-styled-components": "npm:^7.1.5" "@tanstack/react-query": "npm:^5.27.0" + "@testing-library/dom": "npm:^10.4.0" + "@testing-library/jest-dom": "npm:^6.6.3" + "@testing-library/react": "npm:^16.3.0" "@tryfabric/martian": "npm:^1.2.4" "@types/diff": "npm:^7" "@types/fs-extra": "npm:^11" @@ -5773,8 +5975,10 @@ __metadata: "@uiw/codemirror-themes-all": "npm:^4.23.12" "@uiw/react-codemirror": "npm:^4.23.12" "@vitejs/plugin-react-swc": "npm:^3.9.0" - "@vitest/ui": "npm:^3.1.1" - "@vitest/web-worker": "npm:^3.1.3" + "@vitest/browser": "npm:^3.1.4" + "@vitest/coverage-v8": "npm:^3.1.4" + "@vitest/ui": "npm:^3.1.4" + "@vitest/web-worker": "npm:^3.1.4" "@xyflow/react": "npm:^12.4.4" antd: "npm:^5.22.5" archiver: "npm:^7.0.1" @@ -5811,6 +6015,7 @@ __metadata: html-to-image: "npm:^1.11.13" husky: "npm:^9.1.7" i18next: "npm:^23.11.5" + jest-styled-components: "npm:^7.2.0" jsdom: "npm:^26.0.0" lint-staged: "npm:^15.5.0" lodash: "npm:^4.17.21" @@ -5826,6 +6031,7 @@ __metadata: openai: "patch:openai@npm%3A4.96.0#~/.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch" os-proxy-config: "npm:^1.1.2" p-queue: "npm:^8.1.0" + playwright: "npm:^1.52.0" prettier: "npm:^3.5.3" proxy-agent: "npm:^6.5.0" rc-virtual-list: "npm:^3.18.6" @@ -5861,7 +6067,7 @@ __metadata: typescript: "npm:^5.6.2" uuid: "npm:^10.0.0" vite: "npm:6.2.6" - vitest: "npm:^3.1.1" + vitest: "npm:^3.1.4" webdav: "npm:^5.8.0" ws: "npm:^8.18.1" zipread: "npm:^1.3.3" @@ -6317,6 +6523,22 @@ __metadata: languageName: node linkType: hard +"aria-query@npm:5.3.0": + version: 5.3.0 + resolution: "aria-query@npm:5.3.0" + dependencies: + dequal: "npm:^2.0.3" + checksum: 10c0/2bff0d4eba5852a9dd578ecf47eaef0e82cc52569b48469b0aac2db5145db0b17b7a58d9e01237706d1e14b7a1b0ac9b78e9c97027ad97679dd8f91b85da1469 + languageName: node + linkType: hard + +"aria-query@npm:^5.0.0": + version: 5.3.2 + resolution: "aria-query@npm:5.3.2" + checksum: 10c0/003c7e3e2cff5540bf7a7893775fc614de82b0c5dde8ae823d47b7a28a9d4da1f7ed85f340bdb93d5649caa927755f0e31ecc7ab63edfdfc00c8ef07e505e03e + languageName: node + linkType: hard + "array-union@npm:^2.1.0": version: 2.1.0 resolution: "array-union@npm:2.1.0" @@ -6949,6 +7171,16 @@ __metadata: languageName: node linkType: hard +"chalk@npm:^3.0.0": + version: 3.0.0 + resolution: "chalk@npm:3.0.0" + dependencies: + ansi-styles: "npm:^4.1.0" + supports-color: "npm:^7.1.0" + checksum: 10c0/ee650b0a065b3d7a6fda258e75d3a86fc8e4effa55871da730a9e42ccb035bf5fd203525e5a1ef45ec2582ecc4f65b47eb11357c526b84dd29a14fb162c414d2 + languageName: node + linkType: hard + "chalk@npm:^4.0.0, chalk@npm:^4.0.2, chalk@npm:^4.1.0, chalk@npm:^4.1.1, chalk@npm:^4.1.2": version: 4.1.2 resolution: "chalk@npm:4.1.2" @@ -7655,6 +7887,13 @@ __metadata: languageName: node linkType: hard +"css.escape@npm:^1.5.1": + version: 1.5.1 + resolution: "css.escape@npm:1.5.1" + checksum: 10c0/5e09035e5bf6c2c422b40c6df2eb1529657a17df37fda5d0433d722609527ab98090baf25b13970ca754079a0f3161dd3dfc0e743563ded8cfa0749d861c1525 + languageName: node + linkType: hard + "cssstyle@npm:^4.2.1": version: 4.3.0 resolution: "cssstyle@npm:4.3.0" @@ -8403,7 +8642,7 @@ __metadata: languageName: node linkType: hard -"dequal@npm:^2.0.0": +"dequal@npm:^2.0.0, dequal@npm:^2.0.3": version: 2.0.3 resolution: "dequal@npm:2.0.3" checksum: 10c0/f98860cdf58b64991ae10205137c0e97d384c3a4edc7f807603887b7c4b850af1224a33d88012009f150861cbee4fa2d322c4cc04b9313bee312e47f6ecaa888 @@ -8550,6 +8789,20 @@ __metadata: languageName: node linkType: hard +"dom-accessibility-api@npm:^0.5.9": + version: 0.5.16 + resolution: "dom-accessibility-api@npm:0.5.16" + checksum: 10c0/b2c2eda4fae568977cdac27a9f0c001edf4f95a6a6191dfa611e3721db2478d1badc01db5bb4fa8a848aeee13e442a6c2a4386d65ec65a1436f24715a2f8d053 + languageName: node + linkType: hard + +"dom-accessibility-api@npm:^0.6.3": + version: 0.6.3 + resolution: "dom-accessibility-api@npm:0.6.3" + checksum: 10c0/10bee5aa514b2a9a37c87cd81268db607a2e933a050074abc2f6fa3da9080ebed206a320cbc123567f2c3087d22292853bdfdceaffdd4334ffe2af9510b29360 + languageName: node + linkType: hard + "dom-serializer@npm:^2.0.0": version: 2.0.0 resolution: "dom-serializer@npm:2.0.0" @@ -9002,10 +9255,10 @@ __metadata: languageName: node linkType: hard -"es-module-lexer@npm:^1.6.0": - version: 1.6.0 - resolution: "es-module-lexer@npm:1.6.0" - checksum: 10c0/667309454411c0b95c476025929881e71400d74a746ffa1ff4cb450bd87f8e33e8eef7854d68e401895039ac0bac64e7809acbebb6253e055dd49ea9e3ea9212 +"es-module-lexer@npm:^1.7.0": + version: 1.7.0 + resolution: "es-module-lexer@npm:1.7.0" + checksum: 10c0/4c935affcbfeba7fb4533e1da10fa8568043df1e3574b869385980de9e2d475ddc36769891936dbb07036edb3c3786a8b78ccf44964cd130dedc1f2c984b6c7b languageName: node linkType: hard @@ -9752,7 +10005,7 @@ __metadata: languageName: node linkType: hard -"expect-type@npm:^1.2.0": +"expect-type@npm:^1.2.1": version: 1.2.1 resolution: "expect-type@npm:1.2.1" checksum: 10c0/b775c9adab3c190dd0d398c722531726cdd6022849b4adba19dceab58dda7e000a7c6c872408cd73d665baa20d381eca36af4f7b393a4ba60dd10232d1fb8898 @@ -9971,7 +10224,7 @@ __metadata: languageName: node linkType: hard -"fdir@npm:^6.4.3": +"fdir@npm:^6.4.3, fdir@npm:^6.4.4": version: 6.4.4 resolution: "fdir@npm:6.4.4" peerDependencies: @@ -10372,6 +10625,16 @@ __metadata: languageName: node linkType: hard +"fsevents@npm:2.3.2": + version: 2.3.2 + resolution: "fsevents@npm:2.3.2" + dependencies: + node-gyp: "npm:latest" + checksum: 10c0/be78a3efa3e181cda3cf7a4637cb527bcebb0bd0ea0440105a3bb45b86f9245b307dc10a2507e8f4498a7d4ec349d1910f4d73e4d4495b16103106e07eee735b + conditions: os=darwin + languageName: node + linkType: hard + "fsevents@npm:~2.3.2, fsevents@npm:~2.3.3": version: 2.3.3 resolution: "fsevents@npm:2.3.3" @@ -10382,6 +10645,15 @@ __metadata: languageName: node linkType: hard +"fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin": + version: 2.3.2 + resolution: "fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin::version=2.3.2&hash=df0bf1" + dependencies: + node-gyp: "npm:latest" + conditions: os=darwin + languageName: node + linkType: hard + "fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.3#optional!builtin": version: 2.3.3 resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin::version=2.3.3&hash=df0bf1" @@ -10598,7 +10870,7 @@ __metadata: languageName: node linkType: hard -"glob@npm:^10.0.0, glob@npm:^10.3.12, glob@npm:^10.3.7": +"glob@npm:^10.0.0, glob@npm:^10.3.12, glob@npm:^10.3.7, glob@npm:^10.4.1": version: 10.4.5 resolution: "glob@npm:10.4.5" dependencies: @@ -11130,6 +11402,13 @@ __metadata: languageName: node linkType: hard +"html-escaper@npm:^2.0.0": + version: 2.0.2 + resolution: "html-escaper@npm:2.0.2" + checksum: 10c0/208e8a12de1a6569edbb14544f4567e6ce8ecc30b9394fcaa4e7bb1e60c12a7c9a1ed27e31290817157e8626f3a4f29e76c8747030822eb84a6abb15c255f0a0 + languageName: node + linkType: hard + "html-parse-stringify@npm:^3.0.1": version: 3.0.1 resolution: "html-parse-stringify@npm:3.0.1" @@ -11848,6 +12127,45 @@ __metadata: languageName: node linkType: hard +"istanbul-lib-coverage@npm:^3.0.0, istanbul-lib-coverage@npm:^3.2.2": + version: 3.2.2 + resolution: "istanbul-lib-coverage@npm:3.2.2" + checksum: 10c0/6c7ff2106769e5f592ded1fb418f9f73b4411fd5a084387a5410538332b6567cd1763ff6b6cadca9b9eb2c443cce2f7ea7d7f1b8d315f9ce58539793b1e0922b + languageName: node + linkType: hard + +"istanbul-lib-report@npm:^3.0.0, istanbul-lib-report@npm:^3.0.1": + version: 3.0.1 + resolution: "istanbul-lib-report@npm:3.0.1" + dependencies: + istanbul-lib-coverage: "npm:^3.0.0" + make-dir: "npm:^4.0.0" + supports-color: "npm:^7.1.0" + checksum: 10c0/84323afb14392de8b6a5714bd7e9af845cfbd56cfe71ed276cda2f5f1201aea673c7111901227ee33e68e4364e288d73861eb2ed48f6679d1e69a43b6d9b3ba7 + languageName: node + linkType: hard + +"istanbul-lib-source-maps@npm:^5.0.6": + version: 5.0.6 + resolution: "istanbul-lib-source-maps@npm:5.0.6" + dependencies: + "@jridgewell/trace-mapping": "npm:^0.3.23" + debug: "npm:^4.1.1" + istanbul-lib-coverage: "npm:^3.0.0" + checksum: 10c0/ffe75d70b303a3621ee4671554f306e0831b16f39ab7f4ab52e54d356a5d33e534d97563e318f1333a6aae1d42f91ec49c76b6cd3f3fb378addcb5c81da0255f + languageName: node + linkType: hard + +"istanbul-reports@npm:^3.1.7": + version: 3.1.7 + resolution: "istanbul-reports@npm:3.1.7" + dependencies: + html-escaper: "npm:^2.0.0" + istanbul-lib-report: "npm:^3.0.0" + checksum: 10c0/a379fadf9cf8dc5dfe25568115721d4a7eb82fbd50b005a6672aff9c6989b20cc9312d7865814e0859cd8df58cbf664482e1d3604be0afde1f7fc3ccc1394a51 + languageName: node + linkType: hard + "jackspeak@npm:^3.1.2": version: 3.4.3 resolution: "jackspeak@npm:3.4.3" @@ -11875,6 +12193,17 @@ __metadata: languageName: node linkType: hard +"jest-styled-components@npm:^7.2.0": + version: 7.2.0 + resolution: "jest-styled-components@npm:7.2.0" + dependencies: + "@adobe/css-tools": "npm:^4.0.1" + peerDependencies: + styled-components: ">= 5" + checksum: 10c0/44eecf73cd1ee50686c9c16517222e2c012422dd7d90a07813f82f3ccce4059563e620d25aed60274e05d31fe6375b1f31a3486033aa39ea5e82fd94afcfa32f + languageName: node + linkType: hard + "jimp@npm:^0.16.1": version: 0.16.13 resolution: "jimp@npm:0.16.13" @@ -12743,6 +13072,15 @@ __metadata: languageName: node linkType: hard +"lz-string@npm:^1.5.0": + version: 1.5.0 + resolution: "lz-string@npm:1.5.0" + bin: + lz-string: bin/bin.js + checksum: 10c0/36128e4de34791838abe979b19927c26e67201ca5acf00880377af7d765b38d1c60847e01c5ec61b1a260c48029084ab3893a3925fd6e48a04011364b089991b + languageName: node + linkType: hard + "mac-system-proxy@npm:^1.0.0": version: 1.0.4 resolution: "mac-system-proxy@npm:1.0.4" @@ -12759,6 +13097,17 @@ __metadata: languageName: node linkType: hard +"magicast@npm:^0.3.5": + version: 0.3.5 + resolution: "magicast@npm:0.3.5" + dependencies: + "@babel/parser": "npm:^7.25.4" + "@babel/types": "npm:^7.25.4" + source-map-js: "npm:^1.2.0" + checksum: 10c0/a6cacc0a848af84f03e3f5bda7b0de75e4d0aa9ddce5517fd23ed0f31b5ddd51b2d0ff0b7e09b51f7de0f4053c7a1107117edda6b0732dca3e9e39e6c5a68c64 + languageName: node + linkType: hard + "make-dir@npm:^1.0.0": version: 1.3.0 resolution: "make-dir@npm:1.3.0" @@ -12768,6 +13117,15 @@ __metadata: languageName: node linkType: hard +"make-dir@npm:^4.0.0": + version: 4.0.0 + resolution: "make-dir@npm:4.0.0" + dependencies: + semver: "npm:^7.5.3" + checksum: 10c0/69b98a6c0b8e5c4fe9acb61608a9fbcfca1756d910f51e5dbe7a9e5cfb74fca9b8a0c8a0ffdf1294a740826c1ab4871d5bf3f62f72a3049e5eac6541ddffed68 + languageName: node + linkType: hard + "make-fetch-happen@npm:^10.0.3, make-fetch-happen@npm:^10.2.1": version: 10.2.1 resolution: "make-fetch-happen@npm:10.2.1" @@ -13947,6 +14305,13 @@ __metadata: languageName: node linkType: hard +"min-indent@npm:^1.0.0": + version: 1.0.1 + resolution: "min-indent@npm:1.0.1" + checksum: 10c0/7e207bd5c20401b292de291f02913230cb1163abca162044f7db1d951fa245b174dc00869d40dd9a9f32a885ad6a5f3e767ee104cf278f399cb4e92d3f582d5c + languageName: node + linkType: hard + "minimalistic-assert@npm:^1.0.1": version: 1.0.1 resolution: "minimalistic-assert@npm:1.0.1" @@ -15373,6 +15738,30 @@ __metadata: languageName: node linkType: hard +"playwright-core@npm:1.52.0": + version: 1.52.0 + resolution: "playwright-core@npm:1.52.0" + bin: + playwright-core: cli.js + checksum: 10c0/640945507e6ca2144e9f596b2a6ecac042c2fd3683ff99e6271e9a7b38f3602d415f282609d569456f66680aab8b3c5bb1b257d8fb63a7fc0ed648261110421f + languageName: node + linkType: hard + +"playwright@npm:1.52.0, playwright@npm:^1.52.0": + version: 1.52.0 + resolution: "playwright@npm:1.52.0" + dependencies: + fsevents: "npm:2.3.2" + playwright-core: "npm:1.52.0" + dependenciesMeta: + fsevents: + optional: true + bin: + playwright: cli.js + checksum: 10c0/2c6edf1e15e59bbaf77f3fa0fe0ac975793c17cff835d9c8b8bc6395a3b6f1c01898b3058ab37891b2e4d424bcc8f1b4844fe70d943e0143d239d7451408c579 + languageName: node + linkType: hard + "plist@npm:3.1.0, plist@npm:^3.0.4, plist@npm:^3.0.5, plist@npm:^3.1.0": version: 3.1.0 resolution: "plist@npm:3.1.0" @@ -15501,6 +15890,17 @@ __metadata: languageName: node linkType: hard +"pretty-format@npm:^27.0.2": + version: 27.5.1 + resolution: "pretty-format@npm:27.5.1" + dependencies: + ansi-regex: "npm:^5.0.1" + ansi-styles: "npm:^5.0.0" + react-is: "npm:^17.0.1" + checksum: 10c0/0cbda1031aa30c659e10921fa94e0dd3f903ecbbbe7184a729ad66f2b6e7f17891e8c7d7654c458fa4ccb1a411ffb695b4f17bbcd3fe075fabe181027c4040ed + languageName: node + linkType: hard + "proc-log@npm:^2.0.1": version: 2.0.1 resolution: "proc-log@npm:2.0.1" @@ -16330,6 +16730,13 @@ __metadata: languageName: node linkType: hard +"react-is@npm:^17.0.1": + version: 17.0.2 + resolution: "react-is@npm:17.0.2" + checksum: 10c0/2bdb6b93fbb1820b024b496042cce405c57e2f85e777c9aabd55f9b26d145408f9f74f5934676ffdc46f3dcff656d78413a6e43968e7b3f92eea35b3052e9053 + languageName: node + linkType: hard + "react-is@npm:^18.0.0, react-is@npm:^18.2.0": version: 18.3.1 resolution: "react-is@npm:18.3.1" @@ -16560,6 +16967,16 @@ __metadata: languageName: node linkType: hard +"redent@npm:^3.0.0": + version: 3.0.0 + resolution: "redent@npm:3.0.0" + dependencies: + indent-string: "npm:^4.0.0" + strip-indent: "npm:^3.0.0" + checksum: 10c0/d64a6b5c0b50eb3ddce3ab770f866658a2b9998c678f797919ceb1b586bab9259b311407280bd80b804e2a7c7539b19238ae6a2a20c843f1a7fcff21d48c2eae + languageName: node + linkType: hard + "redux-persist@npm:^6.0.0": version: 6.0.0 resolution: "redux-persist@npm:6.0.0" @@ -17618,7 +18035,7 @@ __metadata: languageName: node linkType: hard -"source-map-js@npm:>=0.6.2 <2.0.0, source-map-js@npm:^1.2.1": +"source-map-js@npm:>=0.6.2 <2.0.0, source-map-js@npm:^1.2.0, source-map-js@npm:^1.2.1": version: 1.2.1 resolution: "source-map-js@npm:1.2.1" checksum: 10c0/7bda1fc4c197e3c6ff17de1b8b2c20e60af81b63a52cb32ec5a5d67a20a7d42651e2cb34ebe93833c5a2a084377e17455854fee3e21e7925c64a51b6a52b0faf @@ -17768,7 +18185,7 @@ __metadata: languageName: node linkType: hard -"std-env@npm:^3.8.1": +"std-env@npm:^3.9.0": version: 3.9.0 resolution: "std-env@npm:3.9.0" checksum: 10c0/4a6f9218aef3f41046c3c7ecf1f98df00b30a07f4f35c6d47b28329bc2531eef820828951c7d7b39a1c5eb19ad8a46e3ddfc7deb28f0a2f3ceebee11bab7ba50 @@ -17954,6 +18371,15 @@ __metadata: languageName: node linkType: hard +"strip-indent@npm:^3.0.0": + version: 3.0.0 + resolution: "strip-indent@npm:3.0.0" + dependencies: + min-indent: "npm:^1.0.0" + checksum: 10c0/ae0deaf41c8d1001c5d4fbe16cb553865c1863da4fae036683b474fa926af9fc121e155cb3fc57a68262b2ae7d5b8420aa752c97a6428c315d00efe2a3875679 + languageName: node + linkType: hard + "strip-json-comments@npm:^3.1.1": version: 3.1.1 resolution: "strip-json-comments@npm:3.1.1" @@ -18215,6 +18641,17 @@ __metadata: languageName: node linkType: hard +"test-exclude@npm:^7.0.1": + version: 7.0.1 + resolution: "test-exclude@npm:7.0.1" + dependencies: + "@istanbuljs/schema": "npm:^0.1.2" + glob: "npm:^10.4.1" + minimatch: "npm:^9.0.4" + checksum: 10c0/6d67b9af4336a2e12b26a68c83308c7863534c65f27ed4ff7068a56f5a58f7ac703e8fc80f698a19bb154fd8f705cdf7ec347d9512b2c522c737269507e7b263 + languageName: node + linkType: hard + "text-decoder@npm:^1.1.0": version: 1.2.3 resolution: "text-decoder@npm:1.2.3" @@ -18343,6 +18780,16 @@ __metadata: languageName: node linkType: hard +"tinyglobby@npm:^0.2.13": + version: 0.2.13 + resolution: "tinyglobby@npm:0.2.13" + dependencies: + fdir: "npm:^6.4.4" + picomatch: "npm:^4.0.2" + checksum: 10c0/ef07dfaa7b26936601d3f6d999f7928a4d1c6234c5eb36896bb88681947c0d459b7ebe797022400e555fe4b894db06e922b95d0ce60cb05fd827a0a66326b18c + languageName: node + linkType: hard + "tinypool@npm:^1.0.2": version: 1.0.2 resolution: "tinypool@npm:1.0.2" @@ -19147,18 +19594,18 @@ __metadata: languageName: node linkType: hard -"vite-node@npm:3.1.1": - version: 3.1.1 - resolution: "vite-node@npm:3.1.1" +"vite-node@npm:3.1.4": + version: 3.1.4 + resolution: "vite-node@npm:3.1.4" dependencies: cac: "npm:^6.7.14" debug: "npm:^4.4.0" - es-module-lexer: "npm:^1.6.0" + es-module-lexer: "npm:^1.7.0" pathe: "npm:^2.0.3" vite: "npm:^5.0.0 || ^6.0.0" bin: vite-node: vite-node.mjs - checksum: 10c0/15ee73c472ae00f042a7cee09a31355d2c0efbb2dab160377545be9ba4b980a5f4cb2841b98319d87bedf630bbbb075e6b40796b39f65610920cf3fde66fdf8d + checksum: 10c0/2fc71ddadd308b19b0d0dc09f5b9a108ea9bb640ec5fbd6179267994da8fd6c9d6a4c92098af7de73a0fa817055b518b28972452a2f19a1be754e79947e289d2 languageName: node linkType: hard @@ -19269,36 +19716,37 @@ __metadata: languageName: node linkType: hard -"vitest@npm:^3.1.1": - version: 3.1.1 - resolution: "vitest@npm:3.1.1" +"vitest@npm:^3.1.4": + version: 3.1.4 + resolution: "vitest@npm:3.1.4" dependencies: - "@vitest/expect": "npm:3.1.1" - "@vitest/mocker": "npm:3.1.1" - "@vitest/pretty-format": "npm:^3.1.1" - "@vitest/runner": "npm:3.1.1" - "@vitest/snapshot": "npm:3.1.1" - "@vitest/spy": "npm:3.1.1" - "@vitest/utils": "npm:3.1.1" + "@vitest/expect": "npm:3.1.4" + "@vitest/mocker": "npm:3.1.4" + "@vitest/pretty-format": "npm:^3.1.4" + "@vitest/runner": "npm:3.1.4" + "@vitest/snapshot": "npm:3.1.4" + "@vitest/spy": "npm:3.1.4" + "@vitest/utils": "npm:3.1.4" chai: "npm:^5.2.0" debug: "npm:^4.4.0" - expect-type: "npm:^1.2.0" + expect-type: "npm:^1.2.1" magic-string: "npm:^0.30.17" pathe: "npm:^2.0.3" - std-env: "npm:^3.8.1" + std-env: "npm:^3.9.0" tinybench: "npm:^2.9.0" tinyexec: "npm:^0.3.2" + tinyglobby: "npm:^0.2.13" tinypool: "npm:^1.0.2" tinyrainbow: "npm:^2.0.0" vite: "npm:^5.0.0 || ^6.0.0" - vite-node: "npm:3.1.1" + vite-node: "npm:3.1.4" why-is-node-running: "npm:^2.3.0" peerDependencies: "@edge-runtime/vm": "*" "@types/debug": ^4.1.12 "@types/node": ^18.0.0 || ^20.0.0 || >=22.0.0 - "@vitest/browser": 3.1.1 - "@vitest/ui": 3.1.1 + "@vitest/browser": 3.1.4 + "@vitest/ui": 3.1.4 happy-dom: "*" jsdom: "*" peerDependenciesMeta: @@ -19318,7 +19766,7 @@ __metadata: optional: true bin: vitest: vitest.mjs - checksum: 10c0/680f31d2a7ca59509f837acdbacd9dff405e1b00c606d7cd29717127c6b543f186055854562c2604f74c5cd668b70174968d28feb4ed948a7e013c9477a68d50 + checksum: 10c0/aec575e3cc6cf9b3cee224ae63569479e3a41fa980e495a73d384e31e273f34b18317a0da23bbd577c60fe5e717fa41cdc390de4049ce224ffdaa266ea0cdc67 languageName: node linkType: hard From 2ba4e51e93b73fbaf07178ad51ea69ca48135f52 Mon Sep 17 00:00:00 2001 From: fullex <106392080+0xfullex@users.noreply.github.com> Date: Mon, 26 May 2025 16:50:52 +0800 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20Selection=20Assistant=20/=20?= =?UTF-8?q?=E5=88=92=E8=AF=8D=E5=8A=A9=E6=89=8B=20(#5900)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(selection): implement selection assistant with toolbar and action management - Added selection assistant functionality including a toolbar for actions. - Introduced new settings for enabling/disabling the selection assistant and configuring its behavior. - Implemented action items for built-in functionalities like translate, explain, and copy. - Integrated selection service to manage selection events and actions. - Updated localization files to support new selection assistant features in multiple languages. - Added new components for action management and user interaction within the selection assistant. * chore: update selection-hook to version 0.9.10 and exclude prebuilds from packaging * fix: toolbar hiding * feat: enhance error handling and service management in main index * fix: improve logical coordinate handling in SelectionService * fix: update URL loading and coordinate conversion in SelectionService * fix: replace console.error with Logger for error handling in SelectionService * refactor(SelectionService): enhance preloaded action window management * chore(electron-builder): add filter for .node build files in configuration * fix: toolbar position calculating for multi monitor * fix: update selection assistant configuration and improve error handling in SelectionService * fix: SelectionActionUserModal layout * feat: add hints for custom search URL in multiple languages * fix: update calculateToolbarPosition to ensure integer return type and round position values * feat: add action window opacity setting and update related UI components refactor: SelectionActionsList * chore: enhance tooltip for trigger mode settings * fix: console.log * chore: update selection-hook to version 0.9.12 * fix: integrate language settings into selection components * fix: filter out default assistant from user predefined assistants in selection modal * chore: update selection-hook package version to 0.9.13 * chore: update selection-hook package version to 0.9.14 --- electron-builder.yml | 2 + electron.vite.config.ts | 4 +- package.json | 1 + packages/shared/IpcChannel.ts | 17 +- src/main/index.ts | 9 + src/main/ipc.ts | 4 + src/main/services/ConfigManager.ts | 37 +- src/main/services/SelectionService.ts | 1024 +++++++++++++++++ src/preload/index.ts | 16 + src/renderer/selectionAction.html | 41 + src/renderer/selectionToolbar.html | 43 + .../src/assets/styles/selection-toolbar.scss | 26 + src/renderer/src/components/CopyButton.tsx | 83 ++ .../src/hooks/useSelectionAssistant.ts | 48 + src/renderer/src/i18n/locales/en-us.json | 135 +++ src/renderer/src/i18n/locales/ja-jp.json | 135 +++ src/renderer/src/i18n/locales/ru-ru.json | 135 +++ src/renderer/src/i18n/locales/zh-cn.json | 135 +++ src/renderer/src/i18n/locales/zh-tw.json | 135 +++ .../SelectionActionSearchModal.tsx | 232 ++++ .../SelectionActionUserModal.tsx | 337 ++++++ .../SelectionActionsList.tsx | 126 ++ .../SelectionAssistantSettings.tsx | 191 +++ .../components/ActionsList.tsx | 60 + .../components/ActionsListDivider.tsx | 41 + .../components/ActionsListItem.tsx | 163 +++ .../components/SettingsActionsListHeader.tsx | 53 + .../hooks/useSettingsActionsList.ts | 178 +++ .../src/pages/settings/SettingsPage.tsx | 9 + src/renderer/src/services/ApiService.ts | 2 + src/renderer/src/store/index.ts | 4 +- src/renderer/src/store/selectionStore.ts | 73 ++ src/renderer/src/types/selectionTypes.d.ts | 24 + .../selection/action/SelectionActionApp.tsx | 383 ++++++ .../action/components/ActionGeneral.tsx | 334 ++++++ .../action/components/ActionTranslate.tsx | 223 ++++ .../action/components/WindowFooter.tsx | 176 +++ .../windows/selection/action/entryPoint.tsx | 54 + .../selection/toolbar/SelectionToolbar.tsx | 405 +++++++ .../windows/selection/toolbar/entryPoint.tsx | 29 + yarn.lock | 22 + 41 files changed, 5144 insertions(+), 5 deletions(-) create mode 100644 src/main/services/SelectionService.ts create mode 100644 src/renderer/selectionAction.html create mode 100644 src/renderer/selectionToolbar.html create mode 100644 src/renderer/src/assets/styles/selection-toolbar.scss create mode 100644 src/renderer/src/components/CopyButton.tsx create mode 100644 src/renderer/src/hooks/useSelectionAssistant.ts create mode 100644 src/renderer/src/pages/settings/SelectionAssistantSettings/SelectionActionSearchModal.tsx create mode 100644 src/renderer/src/pages/settings/SelectionAssistantSettings/SelectionActionUserModal.tsx create mode 100644 src/renderer/src/pages/settings/SelectionAssistantSettings/SelectionActionsList.tsx create mode 100644 src/renderer/src/pages/settings/SelectionAssistantSettings/SelectionAssistantSettings.tsx create mode 100644 src/renderer/src/pages/settings/SelectionAssistantSettings/components/ActionsList.tsx create mode 100644 src/renderer/src/pages/settings/SelectionAssistantSettings/components/ActionsListDivider.tsx create mode 100644 src/renderer/src/pages/settings/SelectionAssistantSettings/components/ActionsListItem.tsx create mode 100644 src/renderer/src/pages/settings/SelectionAssistantSettings/components/SettingsActionsListHeader.tsx create mode 100644 src/renderer/src/pages/settings/SelectionAssistantSettings/hooks/useSettingsActionsList.ts create mode 100644 src/renderer/src/store/selectionStore.ts create mode 100644 src/renderer/src/types/selectionTypes.d.ts create mode 100644 src/renderer/src/windows/selection/action/SelectionActionApp.tsx create mode 100644 src/renderer/src/windows/selection/action/components/ActionGeneral.tsx create mode 100644 src/renderer/src/windows/selection/action/components/ActionTranslate.tsx create mode 100644 src/renderer/src/windows/selection/action/components/WindowFooter.tsx create mode 100644 src/renderer/src/windows/selection/action/entryPoint.tsx create mode 100644 src/renderer/src/windows/selection/toolbar/SelectionToolbar.tsx create mode 100644 src/renderer/src/windows/selection/toolbar/entryPoint.tsx diff --git a/electron-builder.yml b/electron-builder.yml index 12c9da99cb..5e1f97f001 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -43,6 +43,8 @@ files: - '!node_modules/@tavily/core/node_modules/js-tiktoken' - '!node_modules/pdf-parse/lib/pdf.js/{v1.9.426,v1.10.88,v2.0.550}' - '!node_modules/mammoth/{mammoth.browser.js,mammoth.browser.min.js}' + - '!node_modules/selection-hook/prebuilds/**/*' # we rebuild .node, don't use prebuilds + - '!**/*.{h,iobj,ipdb,tlog,recipe,vcxproj,vcxproj.filters}' # filter .node build files asarUnpack: - resources/** - '**/*.{metal,exp,lib}' diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 7364285e7b..291870d879 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -89,7 +89,9 @@ export default defineConfig({ rollupOptions: { input: { index: resolve(__dirname, 'src/renderer/index.html'), - miniWindow: resolve(__dirname, 'src/renderer/miniWindow.html') + miniWindow: resolve(__dirname, 'src/renderer/miniWindow.html'), + selectionToolbar: resolve(__dirname, 'src/renderer/selectionToolbar.html'), + selectionAction: resolve(__dirname, 'src/renderer/selectionAction.html') } } } diff --git a/package.json b/package.json index ac7809e3ed..ed3ac99837 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,7 @@ "officeparser": "^4.1.1", "os-proxy-config": "^1.1.2", "proxy-agent": "^6.5.0", + "selection-hook": "^0.9.14", "tar": "^7.4.3", "turndown": "^7.2.0", "turndown-plugin-gfm": "^1.0.2", diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index e8bd965065..528b64c4e4 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -176,5 +176,20 @@ export enum IpcChannel { StoreSync_BroadcastSync = 'store-sync:broadcast-sync', // Provider - Provider_AddKey = 'provider:add-key' + Provider_AddKey = 'provider:add-key', + + //Selection Assistant + Selection_TextSelected = 'selection:text-selected', + Selection_ToolbarHide = 'selection:toolbar-hide', + Selection_ToolbarVisibilityChange = 'selection:toolbar-visibility-change', + Selection_ToolbarDetermineSize = 'selection:toolbar-determine-size', + Selection_WriteToClipboard = 'selection:write-to-clipboard', + Selection_SetEnabled = 'selection:set-enabled', + Selection_SetTriggerMode = 'selection:set-trigger-mode', + Selection_SetFollowToolbar = 'selection:set-follow-toolbar', + Selection_ActionWindowClose = 'selection:action-window-close', + Selection_ActionWindowMinimize = 'selection:action-window-minimize', + Selection_ActionWindowPin = 'selection:action-window-pin', + Selection_ProcessAction = 'selection:process-action', + Selection_UpdateActionData = 'selection:update-action-data' } diff --git a/src/main/index.ts b/src/main/index.ts index 12b1c9c16f..d67a8f0189 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -16,6 +16,7 @@ import { registerProtocolClient, setupAppImageDeepLink } from './services/ProtocolClient' +import selectionService, { initSelectionService } from './services/SelectionService' import { registerShortcuts } from './services/ShortcutService' import { TrayService } from './services/TrayService' import { windowService } from './services/WindowService' @@ -84,6 +85,9 @@ if (!app.requestSingleInstanceLock()) { .then((name) => console.log(`Added Extension: ${name}`)) .catch((err) => console.log('An error occurred: ', err)) } + + //start selection assistant service + initSelectionService() }) registerProtocolClient(app) @@ -110,6 +114,11 @@ if (!app.requestSingleInstanceLock()) { app.on('before-quit', () => { app.isQuitting = true + + // quit selection service + if (selectionService) { + selectionService.quit() + } }) app.on('will-quit', async () => { diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 842b952da3..9c75b514c1 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -26,6 +26,7 @@ import * as NutstoreService from './services/NutstoreService' import ObsidianVaultService from './services/ObsidianVaultService' import { ProxyConfig, proxyManager } from './services/ProxyManager' import { searchService } from './services/SearchService' +import { SelectionService } from './services/SelectionService' import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService' import storeSyncService from './services/StoreSyncService' import { TrayService } from './services/TrayService' @@ -379,4 +380,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { // store sync storeSyncService.registerIpcHandler() + + // selection assistant + SelectionService.registerIpcHandler() } diff --git a/src/main/services/ConfigManager.ts b/src/main/services/ConfigManager.ts index 6242709385..996b976f0c 100644 --- a/src/main/services/ConfigManager.ts +++ b/src/main/services/ConfigManager.ts @@ -5,7 +5,7 @@ import Store from 'electron-store' import { locales } from '../utils/locales' -enum ConfigKeys { +export enum ConfigKeys { Language = 'language', Theme = 'theme', LaunchToTray = 'launchToTray', @@ -16,7 +16,10 @@ enum ConfigKeys { ClickTrayToShowQuickAssistant = 'clickTrayToShowQuickAssistant', EnableQuickAssistant = 'enableQuickAssistant', AutoUpdate = 'autoUpdate', - EnableDataCollection = 'enableDataCollection' + EnableDataCollection = 'enableDataCollection', + SelectionAssistantEnabled = 'selectionAssistantEnabled', + SelectionAssistantTriggerMode = 'selectionAssistantTriggerMode', + SelectionAssistantFollowToolbar = 'selectionAssistantFollowToolbar' } export class ConfigManager { @@ -146,6 +149,36 @@ export class ConfigManager { this.set(ConfigKeys.EnableDataCollection, value) } + // Selection Assistant: is enabled the selection assistant + getSelectionAssistantEnabled(): boolean { + return this.get(ConfigKeys.SelectionAssistantEnabled, true) + } + + setSelectionAssistantEnabled(value: boolean) { + this.set(ConfigKeys.SelectionAssistantEnabled, value) + this.notifySubscribers(ConfigKeys.SelectionAssistantEnabled, value) + } + + // Selection Assistant: trigger mode (selected, ctrlkey) + getSelectionAssistantTriggerMode(): string { + return this.get(ConfigKeys.SelectionAssistantTriggerMode, 'selected') + } + + setSelectionAssistantTriggerMode(value: string) { + this.set(ConfigKeys.SelectionAssistantTriggerMode, value) + this.notifySubscribers(ConfigKeys.SelectionAssistantTriggerMode, value) + } + + // Selection Assistant: if action window position follow toolbar + getSelectionAssistantFollowToolbar(): boolean { + return this.get(ConfigKeys.SelectionAssistantFollowToolbar, true) + } + + setSelectionAssistantFollowToolbar(value: boolean) { + this.set(ConfigKeys.SelectionAssistantFollowToolbar, value) + this.notifySubscribers(ConfigKeys.SelectionAssistantFollowToolbar, value) + } + set(key: string, value: unknown) { this.store.set(key, value) } diff --git a/src/main/services/SelectionService.ts b/src/main/services/SelectionService.ts new file mode 100644 index 0000000000..ee000954bf --- /dev/null +++ b/src/main/services/SelectionService.ts @@ -0,0 +1,1024 @@ +import { isDev, isWin } from '@main/constant' +import { IpcChannel } from '@shared/IpcChannel' +import { BrowserWindow, ipcMain, screen } from 'electron' +import Logger from 'electron-log' +import { join } from 'path' +import type { + KeyboardEventData, + MouseEventData, + SelectionHookConstructor, + SelectionHookInstance, + TextSelectionData +} from 'selection-hook' + +import type { ActionItem } from '../../renderer/src/types/selectionTypes' +import { ConfigKeys, configManager } from './ConfigManager' + +let SelectionHook: SelectionHookConstructor | null = null +try { + if (isWin) { + SelectionHook = require('selection-hook') + } +} catch (error) { + Logger.error('Failed to load selection-hook:', error) +} + +// Type definitions +type Point = { x: number; y: number } +type RelativeOrientation = + | 'topLeft' + | 'topRight' + | 'topMiddle' + | 'bottomLeft' + | 'bottomRight' + | 'bottomMiddle' + | 'middleLeft' + | 'middleRight' + | 'center' + +/** SelectionService is a singleton class that manages the selection hook and the toolbar window + * + * Features: + * - Text selection detection and processing + * - Floating toolbar management + * - Action window handling + * - Multiple trigger modes (selection/alt-key) + * - Screen boundary-aware positioning + * + * Usage: + * import selectionService from '/src/main/services/SelectionService' + * selectionService?.start() + */ +export class SelectionService { + private static instance: SelectionService | null = null + private selectionHook: SelectionHookInstance | null = null + + private static isIpcHandlerRegistered = false + + private initStatus: boolean = false + private started: boolean = false + + private triggerMode = 'selected' + private isFollowToolbar = true + + private toolbarWindow: BrowserWindow | null = null + private actionWindows = new Set() + private preloadedActionWindows: BrowserWindow[] = [] + private readonly PRELOAD_ACTION_WINDOW_COUNT = 1 + + private isHideByMouseKeyListenerActive: boolean = false + private isCtrlkeyListenerActive: boolean = false + /** + * Ctrlkey action states: + * 0 - Ready to monitor ctrlkey action + * >0 - Currently monitoring ctrlkey action + * -1 - Ctrlkey action triggered, no need to process again + */ + private lastCtrlkeyDownTime: number = 0 + + private zoomFactor: number = 1 + + private TOOLBAR_WIDTH = 350 + private TOOLBAR_HEIGHT = 43 + + private readonly ACTION_WINDOW_WIDTH = 500 + private readonly ACTION_WINDOW_HEIGHT = 400 + + private constructor() { + try { + if (!SelectionHook) { + throw new Error('module selection-hook not exists') + } + + this.selectionHook = new SelectionHook() + if (this.selectionHook) { + this.initZoomFactor() + + this.initStatus = true + } + } catch (error) { + this.logError('Failed to initialize SelectionService:', error as Error) + } + } + + public static getInstance(): SelectionService | null { + if (!isWin) return null + + if (!SelectionService.instance) { + SelectionService.instance = new SelectionService() + } + + if (SelectionService.instance.initStatus) { + return SelectionService.instance + } + return null + } + + public getSelectionHook(): SelectionHookInstance | null { + return this.selectionHook + } + + /** + * Initialize zoom factor from config and subscribe to changes + * Ensures UI elements scale properly with system DPI settings + */ + private initZoomFactor() { + const zoomFactor = configManager.getZoomFactor() + if (zoomFactor) { + this.setZoomFactor(zoomFactor) + } + + configManager.subscribe('ZoomFactor', this.setZoomFactor) + } + + public setZoomFactor = (zoomFactor: number) => { + this.zoomFactor = zoomFactor + } + + private initConfig() { + this.triggerMode = configManager.getSelectionAssistantTriggerMode() + this.isFollowToolbar = configManager.getSelectionAssistantFollowToolbar() + + configManager.subscribe(ConfigKeys.SelectionAssistantTriggerMode, (triggerMode: string) => { + this.triggerMode = triggerMode + this.processTriggerMode() + }) + + configManager.subscribe(ConfigKeys.SelectionAssistantFollowToolbar, (isFollowToolbar: boolean) => { + this.isFollowToolbar = isFollowToolbar + }) + } + + /** + * Start the selection service and initialize required windows + * @returns {boolean} Success status of service start + */ + public start(): boolean { + if (!this.selectionHook || this.started) { + this.logError(new Error('SelectionService start(): instance is null or already started')) + return false + } + + try { + //init basic configs + this.initConfig() + //make sure the toolbar window is ready + this.createToolbarWindow() + // Initialize preloaded windows + this.initPreloadedActionWindows() + // Handle errors + this.selectionHook.on('error', (error: { message: string }) => { + this.logError('Error in SelectionHook:', error as Error) + }) + // Handle text selection events + this.selectionHook.on('text-selection', this.processTextSelection) + + // Start the hook + if (this.selectionHook.start({ debug: isDev })) { + //init trigger mode configs + this.processTriggerMode() + + this.started = true + this.logInfo('SelectionService Started') + return true + } + + this.logError(new Error('Failed to start text selection hook.')) + return false + } catch (error) { + this.logError('Failed to set up text selection hook:', error as Error) + return false + } + } + + /** + * Stop the selection service and cleanup resources + * Called when user disables selection assistant + * @returns {boolean} Success status of service stop + */ + public stop(): boolean { + if (!this.selectionHook) return false + + this.selectionHook.stop() + this.selectionHook.cleanup() + if (this.toolbarWindow) { + this.toolbarWindow.close() + this.toolbarWindow = null + } + this.started = false + this.logInfo('SelectionService Stopped') + return true + } + + /** + * Completely quit the selection service + * Called when the app is closing + */ + public quit(): void { + if (!this.selectionHook) return + + this.stop() + + this.selectionHook = null + this.initStatus = false + SelectionService.instance = null + this.logInfo('SelectionService Quitted') + } + + /** + * Create and configure the toolbar window + * Sets up window properties, event handlers, and loads the toolbar UI + * @param readyCallback Optional callback when window is ready to show + */ + private createToolbarWindow(readyCallback?: () => void) { + if (this.isToolbarAlive()) return + + const { toolbarWidth, toolbarHeight } = this.getToolbarRealSize() + + this.toolbarWindow = new BrowserWindow({ + width: toolbarWidth, + height: toolbarHeight, + frame: false, + transparent: true, + alwaysOnTop: true, + skipTaskbar: true, + resizable: false, + minimizable: false, + maximizable: false, + movable: true, + focusable: false, + hasShadow: false, + thickFrame: false, + roundedCorners: true, + backgroundMaterial: 'none', + type: 'toolbar', + show: false, + webPreferences: { + preload: join(__dirname, '../preload/index.js'), + contextIsolation: true, + nodeIntegration: false, + sandbox: false, + devTools: isDev ? true : false + } + }) + + // Hide when losing focus + this.toolbarWindow.on('blur', () => { + this.hideToolbar() + }) + + // Clean up when closed + this.toolbarWindow.on('closed', () => { + this.toolbarWindow = null + }) + + // Add show/hide event listeners + this.toolbarWindow.on('show', () => { + this.toolbarWindow?.webContents.send(IpcChannel.Selection_ToolbarVisibilityChange, true) + }) + + this.toolbarWindow.on('hide', () => { + this.toolbarWindow?.webContents.send(IpcChannel.Selection_ToolbarVisibilityChange, false) + }) + + /** uncomment to open dev tools in dev mode */ + // if (isDev) { + // this.toolbarWindow.once('ready-to-show', () => { + // this.toolbarWindow!.webContents.openDevTools({ mode: 'detach' }) + // }) + // } + + if (readyCallback) { + this.toolbarWindow.once('ready-to-show', readyCallback) + } + + /** get ready to load the toolbar window */ + + if (isDev && process.env['ELECTRON_RENDERER_URL']) { + this.toolbarWindow.loadURL(process.env['ELECTRON_RENDERER_URL'] + '/selectionToolbar.html') + } else { + this.toolbarWindow.loadFile(join(__dirname, '../renderer/selectionToolbar.html')) + } + } + + /** + * Show toolbar at specified position with given orientation + * @param point Reference point for positioning, logical coordinates + * @param orientation Preferred position relative to reference point + */ + private showToolbarAtPosition(point: Point, orientation: RelativeOrientation) { + if (!this.isToolbarAlive()) { + this.createToolbarWindow(() => { + this.showToolbarAtPosition(point, orientation) + }) + return + } + + const { x: posX, y: posY } = this.calculateToolbarPosition(point, orientation) + + const { toolbarWidth, toolbarHeight } = this.getToolbarRealSize() + this.toolbarWindow!.setPosition(posX, posY, false) + // Prevent window resize + this.toolbarWindow!.setBounds({ + width: toolbarWidth, + height: toolbarHeight, + x: posX, + y: posY + }) + this.toolbarWindow!.show() + this.toolbarWindow!.setOpacity(1) + this.startHideByMouseKeyListener() + } + + /** + * Hide the toolbar window and cleanup listeners + */ + public hideToolbar(): void { + if (!this.isToolbarAlive()) return + + this.toolbarWindow!.setOpacity(0) + this.toolbarWindow!.hide() + + this.stopHideByMouseKeyListener() + } + + /** + * Check if toolbar window exists and is not destroyed + * @returns {boolean} Toolbar window status + */ + private isToolbarAlive() { + return this.toolbarWindow && !this.toolbarWindow.isDestroyed() + } + + /** + * Update toolbar size based on renderer feedback + * Only updates width if it has changed + * @param width New toolbar width + * @param height New toolbar height + */ + public determineToolbarSize(width: number, height: number) { + const toolbarWidth = Math.ceil(width) + + // only update toolbar width if it's changed + if (toolbarWidth > 0 && toolbarWidth !== this.TOOLBAR_WIDTH && height > 0) { + this.TOOLBAR_WIDTH = toolbarWidth + } + } + + /** + * Get actual toolbar dimensions accounting for zoom factor + * @returns Object containing toolbar width and height + */ + private getToolbarRealSize() { + return { + toolbarWidth: this.TOOLBAR_WIDTH * this.zoomFactor, + toolbarHeight: this.TOOLBAR_HEIGHT * this.zoomFactor + } + } + + /** + * Calculate optimal toolbar position based on selection context + * Ensures toolbar stays within screen boundaries and follows selection direction + * @param point Reference point for positioning, must be INTEGER + * @param orientation Preferred position relative to reference point + * @returns Calculated screen coordinates for toolbar, INTEGER + */ + private calculateToolbarPosition(point: Point, orientation: RelativeOrientation): Point { + // Calculate initial position based on the specified anchor + let posX: number, posY: number + + const { toolbarWidth, toolbarHeight } = this.getToolbarRealSize() + + switch (orientation) { + case 'topLeft': + posX = point.x - toolbarWidth + posY = point.y - toolbarHeight + break + case 'topRight': + posX = point.x + posY = point.y - toolbarHeight + break + case 'topMiddle': + posX = point.x - toolbarWidth / 2 + posY = point.y - toolbarHeight + break + case 'bottomLeft': + posX = point.x - toolbarWidth + posY = point.y + break + case 'bottomRight': + posX = point.x + posY = point.y + break + case 'bottomMiddle': + posX = point.x - toolbarWidth / 2 + posY = point.y + break + case 'middleLeft': + posX = point.x - toolbarWidth + posY = point.y - toolbarHeight / 2 + break + case 'middleRight': + posX = point.x + posY = point.y - toolbarHeight / 2 + break + case 'center': + posX = point.x - toolbarWidth / 2 + posY = point.y - toolbarHeight / 2 + break + default: + // Default to 'topMiddle' if invalid position + posX = point.x - toolbarWidth / 2 + posY = point.y - toolbarHeight / 2 + } + + //use original point to get the display + const display = screen.getDisplayNearestPoint({ x: point.x, y: point.y }) + + // Ensure toolbar stays within screen boundaries + posX = Math.round( + Math.max(display.workArea.x, Math.min(posX, display.workArea.x + display.workArea.width - toolbarWidth)) + ) + posY = Math.round( + Math.max(display.workArea.y, Math.min(posY, display.workArea.y + display.workArea.height - toolbarHeight)) + ) + + return { x: posX, y: posY } + } + + private isSamePoint(point1: Point, point2: Point): boolean { + return point1.x === point2.x && point1.y === point2.y + } + + private isSameLineWithRectPoint(startTop: Point, startBottom: Point, endTop: Point, endBottom: Point): boolean { + return startTop.y === endTop.y && startBottom.y === endBottom.y + } + + /** + * Process text selection data and show toolbar + * Handles different selection scenarios: + * - Single click (cursor position) + * - Mouse selection (single/double line) + * - Keyboard selection (full/detailed) + * @param selectionData Text selection information and coordinates + */ + private processTextSelection = (selectionData: TextSelectionData) => { + // Skip if no text or toolbar already visible + if (!selectionData.text || (this.isToolbarAlive() && this.toolbarWindow!.isVisible())) { + return + } + + // Determine reference point and position for toolbar + let refPoint: { x: number; y: number } = { x: 0, y: 0 } + let isLogical = false + let refOrientation: RelativeOrientation = 'bottomRight' + + switch (selectionData.posLevel) { + case SelectionHook?.PositionLevel.NONE: + { + const cursorPoint = screen.getCursorScreenPoint() + refPoint = { x: cursorPoint.x, y: cursorPoint.y } + refOrientation = 'bottomMiddle' + isLogical = true + } + break + case SelectionHook?.PositionLevel.MOUSE_SINGLE: + { + refOrientation = 'bottomMiddle' + refPoint = { x: selectionData.mousePosEnd.x, y: selectionData.mousePosEnd.y + 16 } + } + break + case SelectionHook?.PositionLevel.MOUSE_DUAL: + { + const yDistance = selectionData.mousePosEnd.y - selectionData.mousePosStart.y + const xDistance = selectionData.mousePosEnd.x - selectionData.mousePosStart.x + + // not in the same line + if (Math.abs(yDistance) > 14) { + if (yDistance > 0) { + refOrientation = 'bottomLeft' + refPoint = { + x: selectionData.mousePosEnd.x, + y: selectionData.mousePosEnd.y + 16 + } + } else { + refOrientation = 'topRight' + refPoint = { + x: selectionData.mousePosEnd.x, + y: selectionData.mousePosEnd.y - 16 + } + } + } else { + // in the same line + if (xDistance > 0) { + refOrientation = 'bottomLeft' + refPoint = { + x: selectionData.mousePosEnd.x, + y: Math.max(selectionData.mousePosEnd.y, selectionData.mousePosStart.y) + 16 + } + } else { + refOrientation = 'bottomRight' + refPoint = { + x: selectionData.mousePosEnd.x, + y: Math.min(selectionData.mousePosEnd.y, selectionData.mousePosStart.y) + 16 + } + } + } + } + break + case SelectionHook?.PositionLevel.SEL_FULL: + case SelectionHook?.PositionLevel.SEL_DETAILED: + { + //some case may not have mouse position, so use the endBottom point as reference + const isNoMouse = + selectionData.mousePosStart.x === 0 && + selectionData.mousePosStart.y === 0 && + selectionData.mousePosEnd.x === 0 && + selectionData.mousePosEnd.y === 0 + + if (isNoMouse) { + refOrientation = 'bottomLeft' + refPoint = { x: selectionData.endBottom.x, y: selectionData.endBottom.y + 4 } + break + } + + const isDoubleClick = this.isSamePoint(selectionData.mousePosStart, selectionData.mousePosEnd) + + const isSameLine = this.isSameLineWithRectPoint( + selectionData.startTop, + selectionData.startBottom, + selectionData.endTop, + selectionData.endBottom + ) + + if (isDoubleClick && isSameLine) { + refOrientation = 'bottomMiddle' + refPoint = { x: selectionData.mousePosEnd.x, y: selectionData.endBottom.y + 4 } + break + } + + if (isSameLine) { + const direction = selectionData.mousePosEnd.x - selectionData.mousePosStart.x + + if (direction > 0) { + refOrientation = 'bottomLeft' + refPoint = { x: selectionData.endBottom.x, y: selectionData.endBottom.y + 4 } + } else { + refOrientation = 'bottomRight' + refPoint = { x: selectionData.startBottom.x, y: selectionData.startBottom.y + 4 } + } + break + } + + const direction = selectionData.mousePosEnd.y - selectionData.mousePosStart.y + + if (direction > 0) { + refOrientation = 'bottomLeft' + refPoint = { x: selectionData.endBottom.x, y: selectionData.endBottom.y + 4 } + } else { + refOrientation = 'topRight' + refPoint = { x: selectionData.startTop.x, y: selectionData.startTop.y - 4 } + } + } + break + } + + if (!isLogical) { + //screenToDipPoint can be float, so we need to round it + refPoint = screen.screenToDipPoint(refPoint) + refPoint = { x: Math.round(refPoint.x), y: Math.round(refPoint.y) } + } + + this.showToolbarAtPosition(refPoint, refOrientation) + this.toolbarWindow?.webContents.send(IpcChannel.Selection_TextSelected, selectionData) + } + + /** + * Global Mouse Event Handling + */ + + // Start monitoring global mouse clicks + private startHideByMouseKeyListener() { + try { + // Register event handlers + this.selectionHook!.on('mouse-down', this.handleMouseDownHide) + this.selectionHook!.on('mouse-wheel', this.handleMouseWheelHide) + this.selectionHook!.on('key-down', this.handleKeyDownHide) + this.isHideByMouseKeyListenerActive = true + } catch (error) { + this.logError('Failed to start global mouse event listener:', error as Error) + } + } + + // Stop monitoring global mouse clicks + private stopHideByMouseKeyListener() { + if (!this.isHideByMouseKeyListenerActive) return + + try { + this.selectionHook!.off('mouse-down', this.handleMouseDownHide) + this.selectionHook!.off('mouse-wheel', this.handleMouseWheelHide) + this.selectionHook!.off('key-down', this.handleKeyDownHide) + this.isHideByMouseKeyListenerActive = false + } catch (error) { + this.logError('Failed to stop global mouse event listener:', error as Error) + } + } + + /** + * Handle mouse wheel events to hide toolbar + * Hides toolbar when user scrolls + * @param data Mouse wheel event data + */ + private handleMouseWheelHide = () => { + this.hideToolbar() + } + + /** + * Handle mouse down events to hide toolbar + * Hides toolbar when clicking outside of it + * @param data Mouse event data + */ + private handleMouseDownHide = (data: MouseEventData) => { + if (!this.isToolbarAlive()) { + return + } + + //data point is physical coordinates, convert to logical coordinates + const mousePoint = screen.screenToDipPoint({ x: data.x, y: data.y }) + + const bounds = this.toolbarWindow!.getBounds() + + // Check if click is outside toolbar + const isInsideToolbar = + mousePoint.x >= bounds.x && + mousePoint.x <= bounds.x + bounds.width && + mousePoint.y >= bounds.y && + mousePoint.y <= bounds.y + bounds.height + + if (!isInsideToolbar) { + this.hideToolbar() + } + } + + /** + * Handle key down events to hide toolbar + * Hides toolbar on any key press except alt key in ctrlkey mode + * @param data Keyboard event data + */ + private handleKeyDownHide = (data: KeyboardEventData) => { + //dont hide toolbar when ctrlkey is pressed + if (this.triggerMode === 'ctrlkey' && this.isCtrlkey(data.vkCode)) { + return + } + + this.hideToolbar() + } + + /** + * Handle key down events in ctrlkey trigger mode + * Processes alt key presses to trigger selection toolbar + * @param data Keyboard event data + */ + private handleKeyDownCtrlkeyMode = (data: KeyboardEventData) => { + if (!this.isCtrlkey(data.vkCode)) { + // reset the lastCtrlkeyDownTime if any other key is pressed + if (this.lastCtrlkeyDownTime > 0) { + this.lastCtrlkeyDownTime = -1 + } + return + } + + if (this.lastCtrlkeyDownTime === -1) { + return + } + + //ctrlkey pressed + if (this.lastCtrlkeyDownTime === 0) { + this.lastCtrlkeyDownTime = Date.now() + return + } + + if (Date.now() - this.lastCtrlkeyDownTime < 350) { + return + } + + this.lastCtrlkeyDownTime = -1 + + const selectionData = this.selectionHook!.getCurrentSelection() + + if (selectionData) { + this.processTextSelection(selectionData) + } + } + + /** + * Handle key up events in ctrlkey trigger mode + * Resets alt key state when key is released + * @param data Keyboard event data + */ + private handleKeyUpCtrlkeyMode = (data: KeyboardEventData) => { + if (!this.isCtrlkey(data.vkCode)) return + this.lastCtrlkeyDownTime = 0 + } + + //check if the key is ctrl key + private isCtrlkey(vkCode: number) { + return vkCode === 162 || vkCode === 163 + } + + /** + * Create a preloaded action window for quick response + * Action windows handle specific operations on selected text + * @returns Configured BrowserWindow instance + */ + private createPreloadedActionWindow(): BrowserWindow { + const preloadedActionWindow = new BrowserWindow({ + width: this.ACTION_WINDOW_WIDTH, + height: this.ACTION_WINDOW_HEIGHT, + minWidth: 300, + minHeight: 200, + frame: false, + transparent: true, + autoHideMenuBar: true, + titleBarStyle: 'hidden', + hasShadow: false, + thickFrame: false, + show: false, + webPreferences: { + preload: join(__dirname, '../preload/index.js'), + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + devTools: true + } + }) + + // Load the base URL without action data + if (isDev && process.env['ELECTRON_RENDERER_URL']) { + preloadedActionWindow.loadURL(process.env['ELECTRON_RENDERER_URL'] + '/selectionAction.html') + } else { + preloadedActionWindow.loadFile(join(__dirname, '../renderer/selectionAction.html')) + } + + return preloadedActionWindow + } + + /** + * Initialize preloaded action windows + * Creates a pool of windows at startup for faster response + */ + private async initPreloadedActionWindows() { + try { + // Create initial pool of preloaded windows + for (let i = 0; i < this.PRELOAD_ACTION_WINDOW_COUNT; i++) { + await this.pushNewActionWindow() + } + } catch (error) { + this.logError('Failed to initialize preloaded windows:', error as Error) + } + } + + /** + * Preload a new action window asynchronously + * This method is called after popping a window to ensure we always have windows ready + */ + private async pushNewActionWindow() { + try { + const actionWindow = this.createPreloadedActionWindow() + this.preloadedActionWindows.push(actionWindow) + } catch (error) { + this.logError('Failed to push new action window:', error as Error) + } + } + + /** + * Pop an action window from the preloadedActionWindows queue + * Immediately returns a window and asynchronously creates a new one + * @returns {BrowserWindow} The action window + */ + private popActionWindow() { + // Get a window from the preloaded queue or create a new one if empty + const actionWindow = this.preloadedActionWindows.pop() || this.createPreloadedActionWindow() + + // Set up event listeners for this instance + actionWindow.on('closed', () => { + this.actionWindows.delete(actionWindow) + if (!actionWindow.isDestroyed()) { + actionWindow.destroy() + } + }) + + this.actionWindows.add(actionWindow) + + // Asynchronously create a new preloaded window + this.pushNewActionWindow() + + return actionWindow + } + + public processAction(actionItem: ActionItem): void { + console.log('processAction', this.preloadedActionWindows.length, this.actionWindows.size) + + const actionWindow = this.popActionWindow() + + actionWindow.webContents.send(IpcChannel.Selection_UpdateActionData, actionItem) + + this.showActionWindow(actionWindow) + } + + /** + * Show action window with proper positioning relative to toolbar + * Ensures window stays within screen boundaries + * @param actionWindow Window to position and show + */ + private showActionWindow(actionWindow: BrowserWindow) { + if (!this.isFollowToolbar || !this.toolbarWindow) { + actionWindow.show() + this.hideToolbar() + return + } + + const toolbarBounds = this.toolbarWindow!.getBounds() + const display = screen.getDisplayNearestPoint({ x: toolbarBounds.x, y: toolbarBounds.y }) + const workArea = display.workArea + const GAP = 6 // 6px gap from screen edges + + // Calculate initial position to center action window horizontally below toolbar + let posX = Math.round(toolbarBounds.x + (toolbarBounds.width - this.ACTION_WINDOW_WIDTH) / 2) + let posY = Math.round(toolbarBounds.y) + + // Ensure action window stays within screen boundaries with a small gap + if (posX + this.ACTION_WINDOW_WIDTH > workArea.x + workArea.width) { + posX = workArea.x + workArea.width - this.ACTION_WINDOW_WIDTH - GAP + } else if (posX < workArea.x) { + posX = workArea.x + GAP + } + if (posY + this.ACTION_WINDOW_HEIGHT > workArea.y + workArea.height) { + // If window would go below screen, try to position it above toolbar + posY = workArea.y + workArea.height - this.ACTION_WINDOW_HEIGHT - GAP + } else if (posY < workArea.y) { + posY = workArea.y + GAP + } + + actionWindow.setPosition(posX, posY, false) + //KEY to make window not resize + actionWindow.setBounds({ + width: this.ACTION_WINDOW_WIDTH, + height: this.ACTION_WINDOW_HEIGHT, + x: posX, + y: posY + }) + + actionWindow.show() + } + + public closeActionWindow(actionWindow: BrowserWindow): void { + actionWindow.close() + } + + public minimizeActionWindow(actionWindow: BrowserWindow): void { + actionWindow.minimize() + } + + public pinActionWindow(actionWindow: BrowserWindow, isPinned: boolean): void { + actionWindow.setAlwaysOnTop(isPinned) + } + + /** + * Update trigger mode behavior + * Switches between selection-based and alt-key based triggering + * Manages appropriate event listeners for each mode + */ + private processTriggerMode() { + if (this.triggerMode === 'selected') { + if (this.isCtrlkeyListenerActive) { + this.selectionHook!.off('key-down', this.handleKeyDownCtrlkeyMode) + this.selectionHook!.off('key-up', this.handleKeyUpCtrlkeyMode) + + this.isCtrlkeyListenerActive = false + } + + this.selectionHook!.enableClipboard() + this.selectionHook!.setSelectionPassiveMode(false) + } else if (this.triggerMode === 'ctrlkey') { + if (!this.isCtrlkeyListenerActive) { + this.selectionHook!.on('key-down', this.handleKeyDownCtrlkeyMode) + this.selectionHook!.on('key-up', this.handleKeyUpCtrlkeyMode) + + this.isCtrlkeyListenerActive = true + } + + this.selectionHook!.disableClipboard() + this.selectionHook!.setSelectionPassiveMode(true) + } + } + + public writeToClipboard(text: string): boolean { + return this.selectionHook?.writeToClipboard(text) ?? false + } + + /** + * Register IPC handlers for communication with renderer process + * Handles toolbar, action window, and selection-related commands + */ + public static registerIpcHandler(): void { + if (this.isIpcHandlerRegistered) return + + ipcMain.handle(IpcChannel.Selection_ToolbarHide, () => { + selectionService?.hideToolbar() + }) + + ipcMain.handle(IpcChannel.Selection_WriteToClipboard, (_, text: string) => { + return selectionService?.writeToClipboard(text) ?? false + }) + + ipcMain.handle(IpcChannel.Selection_ToolbarDetermineSize, (_, width: number, height: number) => { + selectionService?.determineToolbarSize(width, height) + }) + + ipcMain.handle(IpcChannel.Selection_SetEnabled, (_, enabled: boolean) => { + configManager.setSelectionAssistantEnabled(enabled) + }) + + ipcMain.handle(IpcChannel.Selection_SetTriggerMode, (_, triggerMode: string) => { + configManager.setSelectionAssistantTriggerMode(triggerMode) + }) + + ipcMain.handle(IpcChannel.Selection_SetFollowToolbar, (_, isFollowToolbar: boolean) => { + configManager.setSelectionAssistantFollowToolbar(isFollowToolbar) + }) + + ipcMain.handle(IpcChannel.Selection_ProcessAction, (_, actionItem: ActionItem) => { + selectionService?.processAction(actionItem) + }) + + ipcMain.handle(IpcChannel.Selection_ActionWindowClose, (event) => { + const actionWindow = BrowserWindow.fromWebContents(event.sender) + if (actionWindow) { + selectionService?.closeActionWindow(actionWindow) + } + }) + + ipcMain.handle(IpcChannel.Selection_ActionWindowMinimize, (event) => { + const actionWindow = BrowserWindow.fromWebContents(event.sender) + if (actionWindow) { + selectionService?.minimizeActionWindow(actionWindow) + } + }) + + ipcMain.handle(IpcChannel.Selection_ActionWindowPin, (event, isPinned: boolean) => { + const actionWindow = BrowserWindow.fromWebContents(event.sender) + if (actionWindow) { + selectionService?.pinActionWindow(actionWindow, isPinned) + } + }) + + this.isIpcHandlerRegistered = true + } + + private logInfo(message: string) { + isDev && console.log('[SelectionService] Info: ', message) + } + + private logError(...args: [...string[], Error]) { + Logger.error('[SelectionService] Error: ', ...args) + } +} + +/** + * Initialize selection service when app starts + * Sets up config subscription and starts service if enabled + * @returns {boolean} Success status of initialization + */ +export function initSelectionService(): boolean { + if (!isWin) return false + + configManager.subscribe(ConfigKeys.SelectionAssistantEnabled, (enabled: boolean) => { + //avoid closure + const ss = SelectionService.getInstance() + if (!ss) { + Logger.error('SelectionService not initialized: instance is null') + return + } + + if (enabled) { + ss.start() + } else { + ss.stop() + } + }) + + if (!configManager.getSelectionAssistantEnabled()) return false + + const ss = SelectionService.getInstance() + if (!ss) { + Logger.error('SelectionService not initialized: instance is null') + return false + } + + return ss.start() +} + +const selectionService = SelectionService.getInstance() + +export default selectionService diff --git a/src/preload/index.ts b/src/preload/index.ts index 70aae1b18e..2a69ae908e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -6,6 +6,8 @@ import { contextBridge, ipcRenderer, OpenDialogOptions, shell, webUtils } from ' import { Notification } from 'src/renderer/src/types/notification' import { CreateDirectoryOptions } from 'webdav' +import type { ActionItem } from '../renderer/src/types/selectionTypes' + // Custom APIs for renderer const api = { getAppInfo: () => ipcRenderer.invoke(IpcChannel.App_Info), @@ -204,6 +206,20 @@ const api = { subscribe: () => ipcRenderer.invoke(IpcChannel.StoreSync_Subscribe), unsubscribe: () => ipcRenderer.invoke(IpcChannel.StoreSync_Unsubscribe), onUpdate: (action: any) => ipcRenderer.invoke(IpcChannel.StoreSync_OnUpdate, action) + }, + selection: { + hideToolbar: () => ipcRenderer.invoke(IpcChannel.Selection_ToolbarHide), + writeToClipboard: (text: string) => ipcRenderer.invoke(IpcChannel.Selection_WriteToClipboard, text), + determineToolbarSize: (width: number, height: number) => + ipcRenderer.invoke(IpcChannel.Selection_ToolbarDetermineSize, width, height), + setEnabled: (enabled: boolean) => ipcRenderer.invoke(IpcChannel.Selection_SetEnabled, enabled), + setTriggerMode: (triggerMode: string) => ipcRenderer.invoke(IpcChannel.Selection_SetTriggerMode, triggerMode), + setFollowToolbar: (isFollowToolbar: boolean) => + ipcRenderer.invoke(IpcChannel.Selection_SetFollowToolbar, isFollowToolbar), + processAction: (actionItem: ActionItem) => ipcRenderer.invoke(IpcChannel.Selection_ProcessAction, actionItem), + closeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowClose), + minimizeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowMinimize), + pinActionWindow: (isPinned: boolean) => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowPin, isPinned) } } diff --git a/src/renderer/selectionAction.html b/src/renderer/selectionAction.html new file mode 100644 index 0000000000..1dd3fa616c --- /dev/null +++ b/src/renderer/selectionAction.html @@ -0,0 +1,41 @@ + + + + + + + + Cherry Studio Selection Assistant + + + + +
+ + + + + \ No newline at end of file diff --git a/src/renderer/selectionToolbar.html b/src/renderer/selectionToolbar.html new file mode 100644 index 0000000000..1a219f6472 --- /dev/null +++ b/src/renderer/selectionToolbar.html @@ -0,0 +1,43 @@ + + + + + + + + Cherry Studio Selection Toolbar + + + + +
+ + + + + \ No newline at end of file diff --git a/src/renderer/src/assets/styles/selection-toolbar.scss b/src/renderer/src/assets/styles/selection-toolbar.scss new file mode 100644 index 0000000000..dfbb6bbd59 --- /dev/null +++ b/src/renderer/src/assets/styles/selection-toolbar.scss @@ -0,0 +1,26 @@ +@use './font.scss'; + +html { + font-family: var(--font-family); +} + +:root { + --color-selection-toolbar-background: rgba(20, 20, 20, 0.95); + --color-selection-toolbar-border: rgba(55, 55, 55, 0.5); + --color-selection-toolbar-shadow: rgba(50, 50, 50, 0.3); + + --color-selection-toolbar-text: rgba(255, 255, 245, 0.9); + --color-selection-toolbar-hover-bg: #222222; + + --color-primary: #00b96b; + --color-error: #f44336; +} + +[theme-mode='light'] { + --color-selection-toolbar-background: rgba(245, 245, 245, 0.95); + --color-selection-toolbar-border: rgba(200, 200, 200, 0.5); + --color-selection-toolbar-shadow: rgba(50, 50, 50, 0.3); + + --color-selection-toolbar-text: rgba(0, 0, 0, 1); + --color-selection-toolbar-hover-bg: rgba(0, 0, 0, 0.04); +} diff --git a/src/renderer/src/components/CopyButton.tsx b/src/renderer/src/components/CopyButton.tsx new file mode 100644 index 0000000000..bdc34a0675 --- /dev/null +++ b/src/renderer/src/components/CopyButton.tsx @@ -0,0 +1,83 @@ +import { Tooltip } from 'antd' +import { Copy } from 'lucide-react' +import { FC } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +interface CopyButtonProps { + tooltip?: string + textToCopy: string + label?: string + color?: string + hoverColor?: string + size?: number +} + +interface ButtonContainerProps { + $color: string + $hoverColor: string +} + +const CopyButton: FC = ({ + tooltip, + textToCopy, + label, + color = 'var(--color-text-2)', + hoverColor = 'var(--color-primary)', + size = 14 +}) => { + const { t } = useTranslation() + + const handleCopy = () => { + navigator.clipboard + .writeText(textToCopy) + .then(() => { + window.message?.success(t('message.copy.success')) + }) + .catch(() => { + window.message?.error(t('message.copy.failed')) + }) + } + + const button = ( + + + {label && {label}} + + ) + + if (tooltip) { + return {button} + } + + return button +} + +const ButtonContainer = styled.div` + display: flex; + flex-direction: row; + align-items: center; + gap: 4px; + cursor: pointer; + color: ${(props) => props.$color}; + transition: color 0.2s; + + .copy-icon { + color: ${(props) => props.$color}; + transition: color 0.2s; + } + + &:hover { + color: ${(props) => props.$hoverColor}; + + .copy-icon { + color: ${(props) => props.$hoverColor}; + } + } +` + +const RightText = styled.span<{ size: number }>` + font-size: ${(props) => props.size}px; +` + +export default CopyButton diff --git a/src/renderer/src/hooks/useSelectionAssistant.ts b/src/renderer/src/hooks/useSelectionAssistant.ts new file mode 100644 index 0000000000..ef168a799c --- /dev/null +++ b/src/renderer/src/hooks/useSelectionAssistant.ts @@ -0,0 +1,48 @@ +import { useAppDispatch, useAppSelector } from '@renderer/store' +import { + setActionItems, + setActionWindowOpacity, + setIsAutoClose, + setIsAutoPin, + setIsCompact, + setIsFollowToolbar, + setSelectionEnabled, + setTriggerMode +} from '@renderer/store/selectionStore' +import { ActionItem, TriggerMode } from '@renderer/types/selectionTypes' + +export function useSelectionAssistant() { + const dispatch = useAppDispatch() + const selectionStore = useAppSelector((state) => state.selectionStore) + + return { + ...selectionStore, + setSelectionEnabled: (enabled: boolean) => { + dispatch(setSelectionEnabled(enabled)) + window.api.selection.setEnabled(enabled) + }, + setTriggerMode: (mode: TriggerMode) => { + dispatch(setTriggerMode(mode)) + window.api.selection.setTriggerMode(mode) + }, + setIsCompact: (isCompact: boolean) => { + dispatch(setIsCompact(isCompact)) + }, + setIsAutoClose: (isAutoClose: boolean) => { + dispatch(setIsAutoClose(isAutoClose)) + }, + setIsAutoPin: (isAutoPin: boolean) => { + dispatch(setIsAutoPin(isAutoPin)) + }, + setIsFollowToolbar: (isFollowToolbar: boolean) => { + dispatch(setIsFollowToolbar(isFollowToolbar)) + window.api.selection.setFollowToolbar(isFollowToolbar) + }, + setActionWindowOpacity: (opacity: number) => { + dispatch(setActionWindowOpacity(opacity)) + }, + setActionItems: (items: ActionItem[]) => { + dispatch(setActionItems(items)) + } + } +} diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index cf3efe3dca..7ec3bdaa02 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -1779,6 +1779,141 @@ "quit": "Quit", "show_window": "Show Window", "visualization": "Visualization" + }, + "selection": { + "name": "Selection Assistant", + "action": { + "builtin": { + "translate": "Translate", + "explain": "Explain", + "summary": "Summarize", + "search": "Search", + "refine": "Refine", + "copy": "Copy" + }, + "window": { + "pin": "Pin", + "pinned": "Pinned", + "opacity": "Window Opacity", + "original_show": "Show Original", + "original_hide": "Hide Original", + "original_copy": "Copy Original", + "esc_close": "Esc to Close", + "esc_stop": "Esc to Stop", + "c_copy": "C to Copy" + } + }, + "settings": { + "experimental": "Experimental Features", + "enable": { + "title": "Enable", + "description": "Currently only supported on Windows systems" + }, + "toolbar": { + "title": "Toolbar", + "trigger_mode": { + "title": "Trigger Mode", + "description": "Show toolbar immediately when text is selected, or show only when Ctrl key is held after selection.", + "description_note": "The Ctrl key may not work in some apps. If you use AHK or other tools to remap the Ctrl key, it may not work.", + "selected": "Selection", + "ctrlkey": "Ctrl Key" + }, + "compact_mode": { + "title": "Compact Mode", + "description": "In compact mode, only icons are displayed without text" + } + }, + "window": { + "title": "Action Window", + "follow_toolbar": { + "title": "Follow Toolbar", + "description": "Window position will follow the toolbar. When disabled, it will always be centered." + }, + "auto_close": { + "title": "Auto Close", + "description": "Automatically close the window when it's not pinned and loses focus" + }, + "auto_pin": { + "title": "Auto Pin", + "description": "Pin the window by default" + }, + "opacity": { + "title": "Opacity", + "description": "Set the default opacity of the window, 100% is fully opaque" + } + }, + "actions": { + "title": "Actions", + "reset": { + "button": "Reset", + "tooltip": "Reset to default actions. Custom actions will not be deleted.", + "confirm": "Are you sure you want to reset to default actions? Custom actions will not be deleted." + }, + "add_tooltip": { + "enabled": "Add Custom Action", + "disabled": "Maximum number of custom actions reached ({{max}})" + }, + "delete_confirm": "Are you sure you want to delete this custom action?", + "drag_hint": "Drag to reorder. Move above to enable action ({{enabled}}/{{max}})" + }, + "user_modal": { + "title": { + "add": "Add Custom Action", + "edit": "Edit Custom Action" + }, + "name": { + "label": "Name", + "hint": "Please enter action name" + }, + "icon": { + "label": "Icon", + "placeholder": "Enter Lucide icon name", + "error": "Invalid icon name, please check your input", + "tooltip": "Lucide icon names are lowercase, e.g. arrow-right", + "view_all": "View All Icons", + "random": "Random Icon" + }, + "model": { + "label": "Model", + "tooltip": "Using Assistant: Will use both the assistant's system prompt and model parameters", + "default": "Default Model", + "assistant": "Use Assistant" + }, + "assistant": { + "label": "Select Assistant", + "default": "Default" + }, + "prompt": { + "label": "User Prompt", + "tooltip": "User prompt serves as a supplement to user input and won't override the assistant's system prompt", + "placeholder": "Use placeholder {{text}} to represent selected text. When empty, selected text will be appended to this prompt", + "placeholder_text": "Placeholder", + "copy_placeholder": "Copy Placeholder" + } + }, + "search_modal": { + "title": "Set Search Engine", + "engine": { + "label": "Search Engine", + "custom": "Custom" + }, + "custom": { + "name": { + "label": "Custom Name", + "hint": "Please enter search engine name", + "max_length": "Name cannot exceed 16 characters" + }, + "url": { + "label": "Custom Search URL", + "hint": "Use {{queryString}} to represent the search term", + "required": "Please enter search URL", + "invalid_format": "Please enter a valid URL starting with http:// or https://", + "missing_placeholder": "URL must contain {{queryString}} placeholder" + }, + "test": "Test" + } + } + } } } } diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index d2a7c50ca9..a46c13267e 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -1779,6 +1779,141 @@ "quit": "終了", "show_window": "ウィンドウを表示", "visualization": "可視化" + }, + "selection": { + "name": "テキスト選択ツール", + "action": { + "builtin": { + "translate": "翻訳", + "explain": "解説", + "summary": "要約", + "search": "検索", + "refine": "最適化", + "copy": "コピー" + }, + "window": { + "pin": "最前面に固定", + "pinned": "固定中", + "opacity": "ウィンドウの透過度", + "original_show": "原文を表示", + "original_hide": "原文を非表示", + "original_copy": "原文をコピー", + "esc_close": "Escで閉じる", + "esc_stop": "Escで停止", + "c_copy": "Cでコピー" + } + }, + "settings": { + "experimental": "実験的機能", + "enable": { + "title": "有効化", + "description": "現在Windowsのみ対応" + }, + "toolbar": { + "title": "ツールバー", + "trigger_mode": { + "title": "表示方法", + "description": "テキスト選択時に即時表示、またはCtrlキー押下時のみ表示", + "description_note": "一部のアプリはCtrlキーでのテキスト選択に対応していません。AHKなどでCtrlキーをリマップすると、選択できなくなる場合があります。", + "selected": "選択時", + "ctrlkey": "Ctrlキー" + }, + "compact_mode": { + "title": "コンパクトモード", + "description": "アイコンのみ表示(テキスト非表示)" + } + }, + "window": { + "title": "機能ウィンドウ", + "follow_toolbar": { + "title": "ツールバーに追従", + "description": "ウィンドウ位置をツールバーに連動(無効時は中央表示)" + }, + "auto_close": { + "title": "自動閉じる", + "description": "最前面固定されていない場合、フォーカス喪失時に自動閉じる" + }, + "auto_pin": { + "title": "自動で最前面に固定", + "description": "デフォルトで最前面表示" + }, + "opacity": { + "title": "透明度", + "description": "デフォルトの透明度を設定(100%は完全不透明)" + } + }, + "actions": { + "title": "機能設定", + "reset": { + "button": "リセット", + "tooltip": "デフォルト機能にリセット(カスタム機能は保持)", + "confirm": "デフォルト機能にリセットしますか?\nカスタム機能は削除されません" + }, + "add_tooltip": { + "enabled": "カスタム機能を追加", + "disabled": "カスタム機能の上限に達しました (最大{{max}}個)" + }, + "delete_confirm": "このカスタム機能を削除しますか?", + "drag_hint": "ドラッグで並べ替え (有効{{enabled}}/最大{{max}})" + }, + "user_modal": { + "title": { + "add": "カスタム機能追加", + "edit": "カスタム機能編集" + }, + "name": { + "label": "機能名", + "hint": "機能名を入力" + }, + "icon": { + "label": "アイコン", + "placeholder": "Lucideアイコン名を入力", + "error": "無効なアイコン名です", + "tooltip": "例: arrow-right(小文字で入力)", + "view_all": "全アイコンを表示", + "random": "ランダム選択" + }, + "model": { + "label": "モデル", + "tooltip": "アシスタント使用時はシステムプロンプトとモデルパラメータも適用", + "default": "デフォルトモデル", + "assistant": "アシスタントを使用" + }, + "assistant": { + "label": "アシスタント選択", + "default": "デフォルト" + }, + "prompt": { + "label": "ユーザープロンプト", + "tooltip": "アシスタントのシステムプロンプトを上書きせず、入力補助として機能", + "placeholder": "{{text}}で選択テキストを参照(未入力時は末尾に追加)", + "placeholder_text": "プレースホルダー", + "copy_placeholder": "プレースホルダーをコピー" + } + }, + "search_modal": { + "title": "検索エンジン設定", + "engine": { + "label": "検索エンジン", + "custom": "カスタム" + }, + "custom": { + "name": { + "label": "表示名", + "hint": "検索エンジン名(16文字以内)", + "max_length": "16文字以内で入力" + }, + "url": { + "label": "検索URL", + "hint": "{{queryString}}で検索語を表す", + "required": "URLを入力してください", + "invalid_format": "http:// または https:// で始まるURLを入力", + "missing_placeholder": "{{queryString}}を含めてください" + }, + "test": "テスト" + } + } + } } } } diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 3d7d925dce..c8959a4a05 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -1780,6 +1780,141 @@ "quit": "Выйти", "show_window": "Показать окно", "visualization": "Визуализация" + }, + "selection": { + "name": "Помощник выбора", + "action": { + "builtin": { + "translate": "Перевести", + "explain": "Объяснить", + "summary": "Суммаризировать", + "search": "Поиск", + "refine": "Уточнить", + "copy": "Копировать" + }, + "window": { + "pin": "Закрепить", + "pinned": "Закреплено", + "opacity": "Прозрачность окна", + "original_show": "Показать оригинал", + "original_hide": "Скрыть оригинал", + "original_copy": "Копировать оригинал", + "esc_close": "Esc - закрыть", + "esc_stop": "Esc - остановить", + "c_copy": "C - копировать" + } + }, + "settings": { + "experimental": "Экспериментальные функции", + "enable": { + "title": "Включить", + "description": "Поддерживается только в Windows" + }, + "toolbar": { + "title": "Панель инструментов", + "trigger_mode": { + "title": "Режим активации", + "description": "Показывать панель сразу при выделении или только при удержании Ctrl.", + "description_note": "В некоторых приложениях Ctrl может не работать. Если вы используете AHK или другие инструменты для переназначения Ctrl, это может привести к тому, что некоторые приложения не смогут выделить текст.", + "selected": "При выделении", + "ctrlkey": "По Ctrl" + }, + "compact_mode": { + "title": "Компактный режим", + "description": "Отображать только иконки без текста" + } + }, + "window": { + "title": "Окно действий", + "follow_toolbar": { + "title": "Следовать за панелью", + "description": "Окно будет следовать за панелью. Иначе - по центру." + }, + "auto_close": { + "title": "Автозакрытие", + "description": "Закрывать окно при потере фокуса (если не закреплено)" + }, + "auto_pin": { + "title": "Автозакрепление", + "description": "Закреплять окно по умолчанию" + }, + "opacity": { + "title": "Прозрачность", + "description": "Установить прозрачность окна по умолчанию" + } + }, + "actions": { + "title": "Действия", + "reset": { + "button": "Сбросить", + "tooltip": "Сбросить стандартные действия. Пользовательские останутся.", + "confirm": "Сбросить стандартные действия? Пользовательские останутся." + }, + "add_tooltip": { + "enabled": "Добавить действие", + "disabled": "Достигнут лимит ({{max}})" + }, + "delete_confirm": "Удалить это действие?", + "drag_hint": "Перетащите для сортировки. Включено: {{enabled}}/{{max}}" + }, + "user_modal": { + "title": { + "add": "Добавить действие", + "edit": "Редактировать действие" + }, + "name": { + "label": "Название", + "hint": "Введите название" + }, + "icon": { + "label": "Иконка", + "placeholder": "Название иконки Lucide", + "error": "Некорректное название", + "tooltip": "Названия в lowercase, например arrow-right", + "view_all": "Все иконки", + "random": "Случайная" + }, + "model": { + "label": "Модель", + "tooltip": "Использовать ассистента: будут применены его системные настройки", + "default": "По умолчанию", + "assistant": "Ассистент" + }, + "assistant": { + "label": "Ассистент", + "default": "По умолчанию" + }, + "prompt": { + "label": "Промпт", + "tooltip": "Дополняет ввод пользователя, не заменяя системный промпт ассистента", + "placeholder": "Используйте {{text}} для выделенного текста. Если пусто - текст будет добавлен", + "placeholder_text": "Плейсхолдер", + "copy_placeholder": "Копировать плейсхолдер" + } + }, + "search_modal": { + "title": "Поисковая система", + "engine": { + "label": "Поисковик", + "custom": "Свой" + }, + "custom": { + "name": { + "label": "Название", + "hint": "Название поисковика", + "max_length": "Не более 16 символов" + }, + "url": { + "label": "URL поиска", + "hint": "Используйте {{queryString}} для представления поискового запроса", + "required": "Введите URL", + "invalid_format": "URL должен начинаться с http:// или https://", + "missing_placeholder": "Должен содержать {{queryString}}" + }, + "test": "Тест" + } + } + } } } } diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index bd842b58c4..5d08e7a616 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -1779,6 +1779,141 @@ "quit": "退出", "show_window": "显示窗口", "visualization": "可视化" + }, + "selection": { + "name": "划词助手", + "action": { + "builtin": { + "translate": "翻译", + "explain": "解释", + "summary": "总结", + "search": "搜索", + "refine": "优化", + "copy": "复制" + }, + "window": { + "pin": "置顶", + "pinned": "已置顶", + "opacity": "窗口透明度", + "original_show": "显示原文", + "original_hide": "隐藏原文", + "original_copy": "复制原文", + "esc_close": "Esc 关闭", + "esc_stop": "Esc 停止", + "c_copy": "C 复制" + } + }, + "settings": { + "experimental": "实验性功能", + "enable": { + "title": "启用", + "description": "当前仅支持 Windows 系统" + }, + "toolbar": { + "title": "工具栏", + "trigger_mode": { + "title": "触发方式", + "description": "划词立即显示工具栏,或者划词后按住 Ctrl 键才显示工具栏。", + "description_note": "少数应用不支持通过 Ctrl 键划词。若使用了AHK等工具对 Ctrl 键进行了重映射,可能导致部分应用无法划词。", + "selected": "划词", + "ctrlkey": "Ctrl 键" + }, + "compact_mode": { + "title": "紧凑模式", + "description": "紧凑模式下,只显示图标,不显示文字" + } + }, + "window": { + "title": "功能窗口", + "follow_toolbar": { + "title": "跟随工具栏", + "description": "窗口位置将跟随工具栏显示,禁用后则始终居中显示" + }, + "auto_close": { + "title": "自动关闭", + "description": "当窗口未置顶且失去焦点时,将自动关闭该窗口" + }, + "auto_pin": { + "title": "自动置顶", + "description": "默认将窗口置于顶部" + }, + "opacity": { + "title": "透明度", + "description": "设置窗口的默认透明度,100%为完全不透明" + } + }, + "actions": { + "title": "功能", + "reset": { + "button": "重置", + "tooltip": "重置为默认功能,自定义功能不会被删除", + "confirm": "确定要重置为默认功能吗?自定义功能不会被删除。" + }, + "add_tooltip": { + "enabled": "添加自定义功能", + "disabled": "自定义功能已达上限 ({{max}}个)" + }, + "delete_confirm": "确定要删除这个自定义功能吗?", + "drag_hint": "拖拽排序,移动到上方以启用功能 ({{enabled}}/{{max}})" + }, + "user_modal": { + "title": { + "add": "添加自定义功能", + "edit": "编辑自定义功能" + }, + "name": { + "label": "名称", + "hint": "请输入功能名称" + }, + "icon": { + "label": "图标", + "placeholder": "输入 Lucide 图标名称", + "error": "无效的图标名称,请检查输入", + "tooltip": "Lucide图标名称为小写,如 arrow-right", + "view_all": "查看所有图标", + "random": "随机图标" + }, + "model": { + "label": "模型", + "tooltip": "使用助手:会同时使用助手的系统提示词和模型参数", + "default": "默认模型", + "assistant": "使用助手" + }, + "assistant": { + "label": "选择助手", + "default": "默认" + }, + "prompt": { + "label": "用户提示词(Prompt)", + "tooltip": "用户提示词,作为用户输入的补充,不会覆盖助手的系统提示词", + "placeholder": "使用占位符{{text}}代表选中的文本,不填写时,选中的文本将添加到本提示词的末尾", + "placeholder_text": "占位符", + "copy_placeholder": "复制占位符" + } + }, + "search_modal": { + "title": "设置搜索引擎", + "engine": { + "label": "搜索引擎", + "custom": "自定义" + }, + "custom": { + "name": { + "label": "自定义名称", + "hint": "请输入搜索引擎名称", + "max_length": "名称不能超过16个字符" + }, + "url": { + "label": "自定义搜索 URL", + "hint": "用 {{queryString}} 代表搜索词", + "required": "请输入搜索 URL", + "invalid_format": "请输入以 http:// 或 https:// 开头的有效 URL", + "missing_placeholder": "URL 必须包含 {{queryString}} 占位符" + }, + "test": "测试" + } + } + } } } } diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 9003d65fb0..95b710dab2 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -1780,6 +1780,141 @@ "quit": "結束", "show_window": "顯示視窗", "visualization": "視覺化" + }, + "selection": { + "name": "劃詞助手", + "action": { + "builtin": { + "translate": "翻譯", + "explain": "解釋", + "summary": "總結", + "search": "搜尋", + "refine": "優化", + "copy": "複製" + }, + "window": { + "pin": "置頂", + "pinned": "已置頂", + "opacity": "視窗透明度", + "original_show": "顯示原文", + "original_hide": "隱藏原文", + "original_copy": "複製原文", + "esc_close": "Esc 關閉", + "esc_stop": "Esc 停止", + "c_copy": "C 複製" + } + }, + "settings": { + "experimental": "實驗性功能", + "enable": { + "title": "啟用", + "description": "目前僅支援 Windows 系統" + }, + "toolbar": { + "title": "工具列", + "trigger_mode": { + "title": "觸發方式", + "description": "劃詞立即顯示工具列,或者劃詞後按住 Ctrl 鍵才顯示工具列。", + "description_note": "在某些應用中可能無法透過 Ctrl 鍵劃詞。若使用了AHK等工具對Ctrl鍵進行了重新對應,可能導致部分應用程式無法劃詞。", + "selected": "劃詞", + "ctrlkey": "Ctrl 鍵" + }, + "compact_mode": { + "title": "緊湊模式", + "description": "緊湊模式下,只顯示圖示,不顯示文字" + } + }, + "window": { + "title": "功能視窗", + "follow_toolbar": { + "title": "跟隨工具列", + "description": "視窗位置將跟隨工具列顯示,停用後則始終置中顯示" + }, + "auto_close": { + "title": "自動關閉", + "description": "當視窗未置頂且失去焦點時,將自動關閉該視窗" + }, + "auto_pin": { + "title": "自動置頂", + "description": "預設將視窗置於頂部" + }, + "opacity": { + "title": "透明度", + "description": "設置視窗的默認透明度,100%為完全不透明" + } + }, + "actions": { + "title": "功能", + "reset": { + "button": "重設", + "tooltip": "重設為預設功能,自訂功能不會被刪除", + "confirm": "確定要重設為預設功能嗎?自訂功能不會被刪除。" + }, + "add_tooltip": { + "enabled": "新增自訂功能", + "disabled": "自訂功能已達上限 ({{max}}個)" + }, + "delete_confirm": "確定要刪除這個自訂功能嗎?", + "drag_hint": "拖曳排序,移動到上方以啟用功能 ({{enabled}}/{{max}})" + }, + "user_modal": { + "title": { + "add": "新增自訂功能", + "edit": "編輯自訂功能" + }, + "name": { + "label": "名稱", + "hint": "請輸入功能名稱" + }, + "icon": { + "label": "圖示", + "placeholder": "輸入 Lucide 圖示名稱", + "error": "無效的圖示名稱,請檢查輸入", + "tooltip": "Lucide圖示名稱為小寫,如 arrow-right", + "view_all": "檢視所有圖示", + "random": "隨機圖示" + }, + "model": { + "label": "模型", + "tooltip": "使用助手:會同時使用助手的系統提示詞和模型參數", + "default": "預設模型", + "assistant": "使用助手" + }, + "assistant": { + "label": "選擇助手", + "default": "預設" + }, + "prompt": { + "label": "使用者提示詞(Prompt)", + "tooltip": "使用者提示詞,作為使用者輸入的補充,不會覆蓋助手的系統提示詞", + "placeholder": "使用佔位符{{text}}代表選取的文字,不填寫時,選取的文字將加到本提示詞的末尾", + "placeholder_text": "佔位符", + "copy_placeholder": "複製佔位符" + } + }, + "search_modal": { + "title": "設定搜尋引擎", + "engine": { + "label": "搜尋引擎", + "custom": "自訂" + }, + "custom": { + "name": { + "label": "自訂名稱", + "hint": "請輸入搜尋引擎名稱", + "max_length": "名稱不能超過16個字元" + }, + "url": { + "label": "自訂搜尋 URL", + "hint": "使用 {{queryString}} 代表搜尋詞", + "required": "請輸入搜尋 URL", + "invalid_format": "請輸入以 http:// 或 https:// 開頭的有效 URL", + "missing_placeholder": "URL 必須包含 {{queryString}} 佔位符" + }, + "test": "測試" + } + } + } } } } diff --git a/src/renderer/src/pages/settings/SelectionAssistantSettings/SelectionActionSearchModal.tsx b/src/renderer/src/pages/settings/SelectionAssistantSettings/SelectionActionSearchModal.tsx new file mode 100644 index 0000000000..8dfd77d2b8 --- /dev/null +++ b/src/renderer/src/pages/settings/SelectionAssistantSettings/SelectionActionSearchModal.tsx @@ -0,0 +1,232 @@ +import type { ActionItem } from '@renderer/types/selectionTypes' +import { Button, Form, Input, Modal, Select } from 'antd' +import { Globe } from 'lucide-react' +import { FC, useEffect } from 'react' +import { useTranslation } from 'react-i18next' + +interface SearchEngineOption { + label: string + value: string + searchEngine: string + icon: React.ReactNode +} + +export const LogoBing = (props) => { + return ( + + + + ) +} +export const LogoBaidu = (props) => { + return ( + + + + ) +} + +export const LogoGoogle = (props) => { + return ( + + + + ) +} + +export const DEFAULT_SEARCH_ENGINES: SearchEngineOption[] = [ + { + label: 'Google', + value: 'Google', + searchEngine: 'Google|https://www.google.com/search?q={{queryString}}', + icon: + }, + { + label: 'Baidu', + value: 'Baidu', + searchEngine: 'Baidu|https://www.baidu.com/s?wd={{queryString}}', + icon: + }, + { + label: 'Bing', + value: 'Bing', + searchEngine: 'Bing|https://www.bing.com/search?q={{queryString}}', + icon: + }, + { + label: '', + value: 'custom', + searchEngine: '', + icon: + } +] + +const EXAMPLE_URL = 'https://example.com/search?q={{queryString}}' + +interface SelectionActionSearchModalProps { + isModalOpen: boolean + onOk: (searchEngine: string) => void + onCancel: () => void + currentAction?: ActionItem +} + +const SelectionActionSearchModal: FC = ({ + isModalOpen, + onOk, + onCancel, + currentAction +}) => { + const { t } = useTranslation() + const [form] = Form.useForm() + + useEffect(() => { + if (isModalOpen && currentAction?.searchEngine) { + form.resetFields() + + const [engine, url] = currentAction.searchEngine.split('|') + const defaultEngine = DEFAULT_SEARCH_ENGINES.find((e) => e.value === engine) + + if (defaultEngine) { + form.setFieldsValue({ + engine: defaultEngine.value, + customName: '', + customUrl: '' + }) + } else { + // Handle custom search engine + form.setFieldsValue({ + engine: 'custom', + customName: engine, + customUrl: url + }) + } + } + }, [isModalOpen, currentAction, form]) + + const handleOk = async () => { + try { + const values = await form.validateFields() + const selectedEngine = DEFAULT_SEARCH_ENGINES.find((e) => e.value === values.engine) + + const searchEngine = + selectedEngine?.value === 'custom' + ? `${values.customName}|${values.customUrl}` + : selectedEngine?.searchEngine || '' + + onOk(searchEngine) + } catch (error) { + console.error('Validation failed:', error) + } + } + + const handleCancel = () => { + onCancel() + } + + const handleTest = () => { + const values = form.getFieldsValue() + if (values.customUrl) { + const testUrl = values.customUrl.replace('{{queryString}}', 'cherry studio') + window.api.openWebsite(testUrl) + } + } + + return ( + +
+ + + + + { + if (value && !value.includes('{{queryString}}')) { + return Promise.reject(t('selection.settings.search_modal.custom.url.missing_placeholder')) + } + return Promise.resolve() + } + } + ]}> + + {t('selection.settings.search_modal.custom.test')} + + } + /> + + + ) : null + } + +
+
+ ) +} + +export default SelectionActionSearchModal diff --git a/src/renderer/src/pages/settings/SelectionAssistantSettings/SelectionActionUserModal.tsx b/src/renderer/src/pages/settings/SelectionAssistantSettings/SelectionActionUserModal.tsx new file mode 100644 index 0000000000..e1673e7cf4 --- /dev/null +++ b/src/renderer/src/pages/settings/SelectionAssistantSettings/SelectionActionUserModal.tsx @@ -0,0 +1,337 @@ +import ModelAvatar from '@renderer/components/Avatar/ModelAvatar' +import CopyButton from '@renderer/components/CopyButton' +import { useAssistants, useDefaultAssistant } from '@renderer/hooks/useAssistant' +import { getDefaultModel } from '@renderer/services/AssistantService' +import type { ActionItem } from '@renderer/types/selectionTypes' +import { Col, Input, Modal, Radio, Row, Select, Space, Tooltip } from 'antd' +import { CircleHelp, Dices, OctagonX } from 'lucide-react' +import { DynamicIcon, iconNames } from 'lucide-react/dynamic' +import { FC, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +interface SelectionActionUserModalProps { + isModalOpen: boolean + editingAction: ActionItem | null + onOk: (data: ActionItem) => void + onCancel: () => void +} + +const SelectionActionUserModal: FC = ({ + isModalOpen, + editingAction, + onOk, + onCancel +}) => { + const { t } = useTranslation() + const { assistants: userPredefinedAssistants } = useAssistants() + const { defaultAssistant } = useDefaultAssistant() + + const [formData, setFormData] = useState>({}) + const [errors, setErrors] = useState>>({}) + + useEffect(() => { + if (isModalOpen) { + // 如果是编辑模式,使用现有数据;否则使用空数据 + setFormData( + editingAction || { + name: '', + prompt: '', + icon: '', + assistantId: '' + } + ) + setErrors({}) + } + }, [isModalOpen, editingAction]) + + const validateForm = (): boolean => { + const newErrors: Partial> = {} + + if (!formData.name?.trim()) { + newErrors.name = t('selection.settings.user_modal.name.hint') + } + + if (formData.icon && !iconNames.includes(formData.icon as any)) { + newErrors.icon = t('selection.settings.user_modal.icon.error') + } + + setErrors(newErrors) + return Object.keys(newErrors).length === 0 + } + + const handleOk = () => { + if (!validateForm()) { + return + } + + // 构建完整的 ActionItem + const actionItem: ActionItem = { + id: editingAction?.id || `user-${Date.now()}`, + name: formData.name || 'USER', + enabled: editingAction?.enabled || false, + isBuiltIn: editingAction?.isBuiltIn || false, + icon: formData.icon, + prompt: formData.prompt, + assistantId: formData.assistantId + } + + onOk(actionItem) + } + + const handleInputChange = (field: keyof ActionItem, value: string) => { + setFormData((prev) => ({ ...prev, [field]: value })) + // Clear error when user starts typing + if (errors[field]) { + setErrors((prev) => ({ ...prev, [field]: undefined })) + } + } + + return ( + + + +
+ + + {t('selection.settings.user_modal.name.label')} + + handleInputChange('name', e.target.value)} + maxLength={16} + status={errors.name ? 'error' : ''} + /> + {errors.name && {errors.name}} + + + + {t('selection.settings.user_modal.icon.label')} + + + + + + {t('selection.settings.user_modal.icon.view_all')} + + + { + const randomIcon = iconNames[Math.floor(Math.random() * iconNames.length)] + handleInputChange('icon', randomIcon) + }}> + + + + + + handleInputChange('icon', e.target.value)} + style={{ width: '100%' }} + status={errors.icon ? 'error' : ''} + /> + + {formData.icon && + (iconNames.includes(formData.icon as any) ? ( + + ) : ( + + ))} + + + {errors.icon && {errors.icon}} + +
+
+ + + + + {t('selection.settings.user_modal.model.label')} + + + + + + + handleInputChange('assistantId', e.target.value === 'default' ? '' : defaultAssistant.id) + } + buttonStyle="solid"> + {t('selection.settings.user_modal.model.default')} + {t('selection.settings.user_modal.model.assistant')} + + + + + {formData.assistantId && ( + + + {t('selection.settings.user_modal.assistant.label')} + + + + )} + + + {t('selection.settings.user_modal.prompt.label')} + + + + +
+ {t('selection.settings.user_modal.prompt.placeholder_text')} {'{{text}}'} + +
+
+ handleInputChange('prompt', e.target.value)} + rows={4} + style={{ resize: 'none' }} + /> +
+
+
+ ) +} + +const ModalSection = styled.div` + display: flex; + flex-direction: column; + margin-top: 16px; +` + +const ModalSectionTitle = styled.div` + display: flex; + align-items: center; + gap: 4px; + font-weight: 500; + margin-bottom: 8px; +` + +const ModalSectionTitleLabel = styled.div` + font-size: 14px; + font-weight: 500; + color: var(--color-text); +` + +const QuestionIcon = styled(CircleHelp)` + cursor: pointer; + color: var(--color-text-3); +` + +const ErrorText = styled.div` + color: var(--color-error); + font-size: 12px; +` + +const Spacer = styled.div` + flex: 1; +` + +const IconPreview = styled.div` + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + background: var(--color-bg-2); + border-radius: 4px; + border: 1px solid var(--color-border); +` + +const AssistantItem = styled.div` + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + height: 28px; +` + +const AssistantName = styled.span` + max-width: calc(100% - 60px); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +` + +const CurrentTag = styled.span<{ isCurrent: boolean }>` + color: ${(props) => (props.isCurrent ? 'var(--color-primary)' : 'var(--color-text-3)')}; + font-size: 12px; + padding: 2px 4px; + border-radius: 4px; +` + +const DiceButton = styled.div` + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s; + margin-left: 4px; + + .btn-icon { + color: var(--color-text-2); + } + + &:hover { + .btn-icon { + color: var(--color-primary); + } + } + + &:active { + transform: rotate(720deg); + } +` + +export default SelectionActionUserModal diff --git a/src/renderer/src/pages/settings/SelectionAssistantSettings/SelectionActionsList.tsx b/src/renderer/src/pages/settings/SelectionAssistantSettings/SelectionActionsList.tsx new file mode 100644 index 0000000000..91106f762d --- /dev/null +++ b/src/renderer/src/pages/settings/SelectionAssistantSettings/SelectionActionsList.tsx @@ -0,0 +1,126 @@ +import { DragDropContext } from '@hello-pangea/dnd' +import { defaultActionItems } from '@renderer/store/selectionStore' +import type { ActionItem } from '@renderer/types/selectionTypes' +import SelectionToolbar from '@renderer/windows/selection/toolbar/SelectionToolbar' +import { Row } from 'antd' +import { FC } from 'react' +import styled from 'styled-components' + +import { SettingDivider, SettingGroup } from '..' +import ActionsList from './components/ActionsList' +import ActionsListDivider from './components/ActionsListDivider' +import SettingsActionsListHeader from './components/SettingsActionsListHeader' +import { useActionItems } from './hooks/useSettingsActionsList' +import SelectionActionSearchModal from './SelectionActionSearchModal' +import SelectionActionUserModal from './SelectionActionUserModal' + +// Component for managing selection actions in settings +// Handles drag-and-drop reordering, enabling/disabling actions, and custom action management + +// Props for the main component +interface SelectionActionsListProps { + actionItems: ActionItem[] | undefined // List of all available actions + setActionItems: (items: ActionItem[]) => void // Function to update action items +} + +const SelectionActionsList: FC = ({ actionItems, setActionItems }) => { + const { + enabledItems, + disabledItems, + customItemsCount, + isUserModalOpen, + isSearchModalOpen, + userEditingAction, + setIsUserModalOpen, + setIsSearchModalOpen, + handleEditActionItem, + handleAddNewAction, + handleUserModalOk, + handleSearchModalOk, + handleDeleteActionItem, + handleReset, + onDragEnd, + getSearchEngineInfo, + MAX_CUSTOM_ITEMS, + MAX_ENABLED_ITEMS + } = useActionItems(actionItems, setActionItems) + + if (!actionItems || actionItems.length === 0) { + setActionItems(defaultActionItems) + } + + return ( + + + + + + + + + + + + + + + + + + + + + + setIsUserModalOpen(false)} + /> + + setIsSearchModalOpen(false)} + currentAction={actionItems?.find((item) => item.id === 'search')} + /> + + ) +} + +const ActionsListSection = styled.div` + display: flex; + flex-direction: column; + gap: 16px; +` + +const ActionColumn = styled.div` + width: 100%; +` + +const DemoSection = styled(Row)` + align-items: center; + justify-content: center; + margin: 24px 0; +` + +export default SelectionActionsList diff --git a/src/renderer/src/pages/settings/SelectionAssistantSettings/SelectionAssistantSettings.tsx b/src/renderer/src/pages/settings/SelectionAssistantSettings/SelectionAssistantSettings.tsx new file mode 100644 index 0000000000..c1303496eb --- /dev/null +++ b/src/renderer/src/pages/settings/SelectionAssistantSettings/SelectionAssistantSettings.tsx @@ -0,0 +1,191 @@ +import { isWindows } from '@renderer/config/constant' +import { useTheme } from '@renderer/context/ThemeProvider' +import { useSelectionAssistant } from '@renderer/hooks/useSelectionAssistant' +import { TriggerMode } from '@renderer/types/selectionTypes' +import SelectionToolbar from '@renderer/windows/selection/toolbar/SelectionToolbar' +import { Radio, Row, Slider, Switch, Tooltip } from 'antd' +import { CircleHelp } from 'lucide-react' +import { FC, useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +import { + SettingContainer, + SettingDescription, + SettingDivider, + SettingGroup, + SettingRow, + SettingRowTitle, + SettingTitle +} from '..' +import SelectionActionsList from './SelectionActionsList' + +const SelectionAssistantSettings: FC = () => { + const { theme } = useTheme() + const { t } = useTranslation() + const { + selectionEnabled, + triggerMode, + isCompact, + isAutoClose, + isAutoPin, + isFollowToolbar, + actionItems, + actionWindowOpacity, + setSelectionEnabled, + setTriggerMode, + setIsCompact, + setIsAutoClose, + setIsAutoPin, + setIsFollowToolbar, + setActionWindowOpacity, + setActionItems + } = useSelectionAssistant() + + // force disable selection assistant on non-windows systems + useEffect(() => { + if (!isWindows && selectionEnabled) { + setSelectionEnabled(false) + } + }, [selectionEnabled, setSelectionEnabled]) + + return ( + + + + {t('selection.name')} + + {t('selection.settings.experimental')} + + + + + {t('selection.settings.enable.title')} + {!isWindows && {t('selection.settings.enable.description')}} + + setSelectionEnabled(checked)} + disabled={!isWindows} + /> + + + {!selectionEnabled && ( + + + + )} + + {selectionEnabled && ( + <> + + {t('selection.settings.toolbar.title')} + + + + + +
{t('selection.settings.toolbar.trigger_mode.title')}
+ + + +
+ {t('selection.settings.toolbar.trigger_mode.description')} +
+ setTriggerMode(e.target.value as TriggerMode)} + buttonStyle="solid"> + {t('selection.settings.toolbar.trigger_mode.selected')} + {t('selection.settings.toolbar.trigger_mode.ctrlkey')} + +
+ + + + {t('selection.settings.toolbar.compact_mode.title')} + {t('selection.settings.toolbar.compact_mode.description')} + + setIsCompact(checked)} /> + +
+ + + {t('selection.settings.window.title')} + + + + + {t('selection.settings.window.follow_toolbar.title')} + {t('selection.settings.window.follow_toolbar.description')} + + setIsFollowToolbar(checked)} /> + + + + + {t('selection.settings.window.auto_close.title')} + {t('selection.settings.window.auto_close.description')} + + setIsAutoClose(checked)} /> + + + + + {t('selection.settings.window.auto_pin.title')} + {t('selection.settings.window.auto_pin.description')} + + setIsAutoPin(checked)} /> + + + + + {t('selection.settings.window.opacity.title')} + {t('selection.settings.window.opacity.description')} + +
{actionWindowOpacity}%
+ +
+
+ + + + )} +
+ ) +} + +const Spacer = styled.div` + flex: 1; +` +const SettingLabel = styled.div` + flex: 1; +` + +const ExperimentalText = styled.div` + color: var(--color-text-3); + font-size: 12px; +` + +const DemoContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; + margin-top: 15px; + margin-bottom: 5px; +` + +const QuestionIcon = styled(CircleHelp)` + cursor: pointer; + color: var(--color-text-3); +` + +export default SelectionAssistantSettings diff --git a/src/renderer/src/pages/settings/SelectionAssistantSettings/components/ActionsList.tsx b/src/renderer/src/pages/settings/SelectionAssistantSettings/components/ActionsList.tsx new file mode 100644 index 0000000000..7532232b4c --- /dev/null +++ b/src/renderer/src/pages/settings/SelectionAssistantSettings/components/ActionsList.tsx @@ -0,0 +1,60 @@ +import type { DroppableProvided } from '@hello-pangea/dnd' +import { Draggable, Droppable } from '@hello-pangea/dnd' +import type { ActionItem as ActionItemType } from '@renderer/types/selectionTypes' +import { memo } from 'react' +import styled from 'styled-components' + +import ActionsListItemComponent from './ActionsListItem' + +interface ActionListProps { + droppableId: 'enabled' | 'disabled' + items: ActionItemType[] + isLastEnabledItem: boolean + onEdit: (item: ActionItemType) => void + onDelete: (id: string) => void + getSearchEngineInfo: (engine: string) => { icon: any; name: string } | null +} + +const ActionsList = memo( + ({ droppableId, items, isLastEnabledItem, onEdit, onDelete, getSearchEngineInfo }: ActionListProps) => { + return ( + + {(provided: DroppableProvided) => ( + + + {items.map((item, index) => ( + + {(provided) => ( + + )} + + ))} + {provided.placeholder} + + + )} + + ) + } +) + +const List = styled.div` + background: var(--color-bg-1); + border-radius: 4px; + margin-bottom: 16px; + padding-bottom: 1px; +` + +const ActionsListContent = styled.div` + padding: 10px; +` + +export default ActionsList diff --git a/src/renderer/src/pages/settings/SelectionAssistantSettings/components/ActionsListDivider.tsx b/src/renderer/src/pages/settings/SelectionAssistantSettings/components/ActionsListDivider.tsx new file mode 100644 index 0000000000..4940f2c3fb --- /dev/null +++ b/src/renderer/src/pages/settings/SelectionAssistantSettings/components/ActionsListDivider.tsx @@ -0,0 +1,41 @@ +import { memo } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +interface DividerProps { + enabledCount: number + maxEnabled: number +} + +const ActionsListDivider = memo(({ enabledCount, maxEnabled }: DividerProps) => { + const { t } = useTranslation() + + return ( + + + {t('selection.settings.actions.drag_hint', { enabled: enabledCount, max: maxEnabled })} + + + ) +}) + +const DividerContainer = styled.div` + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + color: var(--color-text-3); + margin: 16px 12px; +` + +const DividerLine = styled.div` + flex: 1; + height: 2px; + background: var(--color-border); +` + +const DividerText = styled.span` + margin: 0 16px; +` + +export default ActionsListDivider diff --git a/src/renderer/src/pages/settings/SelectionAssistantSettings/components/ActionsListItem.tsx b/src/renderer/src/pages/settings/SelectionAssistantSettings/components/ActionsListItem.tsx new file mode 100644 index 0000000000..64f11f447e --- /dev/null +++ b/src/renderer/src/pages/settings/SelectionAssistantSettings/components/ActionsListItem.tsx @@ -0,0 +1,163 @@ +import type { DraggableProvided } from '@hello-pangea/dnd' +import type { ActionItem as ActionItemType } from '@renderer/types/selectionTypes' +import { Button } from 'antd' +import { Pencil, Settings2, Trash } from 'lucide-react' +import { DynamicIcon } from 'lucide-react/dynamic' +import { memo } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +interface ActionItemProps { + item: ActionItemType + provided: DraggableProvided + listType: 'enabled' | 'disabled' + isLastEnabledItem: boolean + onEdit: (item: ActionItemType) => void + onDelete: (id: string) => void + getSearchEngineInfo: (engine: string) => { icon: any; name: string } | null +} + +const ActionsListItem = memo( + ({ item, provided, listType, isLastEnabledItem, onEdit, onDelete, getSearchEngineInfo }: ActionItemProps) => { + const { t } = useTranslation() + const isEnabled = listType === 'enabled' + + return ( + + + +
} /> + + {item.isBuiltIn ? t(item.name) : item.name} + {item.id === 'search' && item.searchEngine && ( + + {getSearchEngineInfo(item.searchEngine)?.icon} + {getSearchEngineInfo(item.searchEngine)?.name} + + )} + + + + + ) + } +) + +interface ActionOperationsProps { + item: ActionItemType + onEdit: (item: ActionItemType) => void + onDelete: (id: string) => void +} + +const ActionOperations = memo(({ item, onEdit, onDelete }: ActionOperationsProps) => { + if (!item.isBuiltIn) { + return ( + + + + + ) + } + + if (item.isBuiltIn && item.id === 'search') { + return ( + + + + ) + } + + return null +}) + +const Item = styled.div<{ disabled: boolean }>` + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + margin-bottom: 8px; + background-color: var(--color-bg-1); + border: 1px solid var(--color-border); + border-radius: 6px; + cursor: move; + opacity: ${(props) => (props.disabled ? 0.8 : 1)}; + transition: background-color 0.2s ease; + + &:last-child { + margin-bottom: 0; + } + + &:hover { + background-color: var(--color-bg-2); + } + + &.non-draggable { + cursor: default; + background-color: var(--color-bg-2); + position: relative; + } +` + +const ItemLeft = styled.div` + display: flex; + align-items: center; + flex: 1; +` + +const ItemName = styled.span<{ disabled: boolean }>` + margin-left: 8px; + color: ${(props) => (props.disabled ? 'var(--color-text-3)' : 'var(--color-text-1)')}; +` + +const ItemIcon = styled.div<{ disabled: boolean }>` + margin: 0 8px; + display: flex; + align-items: center; + justify-content: center; + color: ${(props) => (props.disabled ? 'var(--color-text-3)' : 'var(--color-primary)')}; +` + +const ItemDescription = styled.div` + display: flex; + align-items: center; + gap: 4px; + margin-left: 16px; + font-size: 12px; + color: var(--color-text-2); + opacity: 0.8; +` + +const UserActionOpSection = styled.div` + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + + .btn-icon-edit { + color: var(--color-text-3); + + &:hover { + color: var(--color-primary); + } + } + .btn-icon-delete { + color: var(--color-text-3); + + &:hover { + color: var(--color-error); + } + } +` + +export default ActionsListItem diff --git a/src/renderer/src/pages/settings/SelectionAssistantSettings/components/SettingsActionsListHeader.tsx b/src/renderer/src/pages/settings/SelectionAssistantSettings/components/SettingsActionsListHeader.tsx new file mode 100644 index 0000000000..69017b36df --- /dev/null +++ b/src/renderer/src/pages/settings/SelectionAssistantSettings/components/SettingsActionsListHeader.tsx @@ -0,0 +1,53 @@ +import { Button, Row, Tooltip } from 'antd' +import { Plus } from 'lucide-react' +import { memo } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +import { SettingTitle } from '../..' + +interface HeaderSectionProps { + customItemsCount: number + maxCustomItems: number + onReset: () => void + onAdd: () => void +} + +const SettingsActionsListHeader = memo(({ customItemsCount, maxCustomItems, onReset, onAdd }: HeaderSectionProps) => { + const { t } = useTranslation() + const isCustomItemLimitReached = customItemsCount >= maxCustomItems + + return ( + + {t('selection.settings.actions.title')} + + + + {t('selection.settings.actions.reset.button')} + + + +