More analytics, and cancel requests

This commit is contained in:
Gregory Schier
2024-02-24 11:30:07 -08:00
parent 0857ef9afd
commit fd044005a6
43 changed files with 565 additions and 541 deletions

241
package-lock.json generated
View File

@@ -58,8 +58,8 @@
"@types/react": "^18.0.31",
"@types/react-dom": "^18.0.11",
"@types/uuid": "^9.0.1",
"@typescript-eslint/eslint-plugin": "^5.57.0",
"@typescript-eslint/parser": "^5.57.0",
"@typescript-eslint/eslint-plugin": "^7.0.2",
"@typescript-eslint/parser": "^7.0.2",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.13",
"eslint": "^8.34.0",
@@ -76,7 +76,7 @@
"prettier": "^2.8.4",
"react-devtools": "^4.27.2",
"tailwindcss": "^3.2.7",
"typescript": "^5.0.2",
"typescript": "^5.3.3",
"vite": "^5.1.1",
"vite-plugin-svgr": "^4.2.0",
"vite-plugin-top-level-await": "^1.4.1",
@@ -2631,9 +2631,9 @@
"devOptional": true
},
"node_modules/@types/semver": {
"version": "7.5.7",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.7.tgz",
"integrity": "sha512-/wdoPq1QqkSj9/QOeKkFquEuPzQbHTWAMPH/PaUMB+JuR31lXhlWXRZ52IpfDYVlDOUBvX09uBrPwxGT1hjNBg==",
"version": "7.5.8",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz",
"integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==",
"dev": true
},
"node_modules/@types/uuid": {
@@ -2653,32 +2653,33 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "5.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz",
"integrity": "sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==",
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.0.2.tgz",
"integrity": "sha512-/XtVZJtbaphtdrWjr+CJclaCVGPtOdBpFEnvtNf/jRV0IiEemRrL0qABex/nEt8isYcnFacm3nPHYQwL+Wb7qg==",
"dev": true,
"dependencies": {
"@eslint-community/regexpp": "^4.4.0",
"@typescript-eslint/scope-manager": "5.62.0",
"@typescript-eslint/type-utils": "5.62.0",
"@typescript-eslint/utils": "5.62.0",
"@eslint-community/regexpp": "^4.5.1",
"@typescript-eslint/scope-manager": "7.0.2",
"@typescript-eslint/type-utils": "7.0.2",
"@typescript-eslint/utils": "7.0.2",
"@typescript-eslint/visitor-keys": "7.0.2",
"debug": "^4.3.4",
"graphemer": "^1.4.0",
"ignore": "^5.2.0",
"natural-compare-lite": "^1.4.0",
"semver": "^7.3.7",
"tsutils": "^3.21.0"
"ignore": "^5.2.4",
"natural-compare": "^1.4.0",
"semver": "^7.5.4",
"ts-api-utils": "^1.0.1"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
"node": "^16.0.0 || >=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^5.0.0",
"eslint": "^6.0.0 || ^7.0.0 || ^8.0.0"
"@typescript-eslint/parser": "^7.0.0",
"eslint": "^8.56.0"
},
"peerDependenciesMeta": {
"typescript": {
@@ -2687,25 +2688,26 @@
}
},
"node_modules/@typescript-eslint/parser": {
"version": "5.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz",
"integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==",
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.0.2.tgz",
"integrity": "sha512-GdwfDglCxSmU+QTS9vhz2Sop46ebNCXpPPvsByK7hu0rFGRHL+AusKQJ7SoN+LbLh6APFpQwHKmDSwN35Z700Q==",
"dev": true,
"dependencies": {
"@typescript-eslint/scope-manager": "5.62.0",
"@typescript-eslint/types": "5.62.0",
"@typescript-eslint/typescript-estree": "5.62.0",
"@typescript-eslint/scope-manager": "7.0.2",
"@typescript-eslint/types": "7.0.2",
"@typescript-eslint/typescript-estree": "7.0.2",
"@typescript-eslint/visitor-keys": "7.0.2",
"debug": "^4.3.4"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
"node": "^16.0.0 || >=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^6.0.0 || ^7.0.0 || ^8.0.0"
"eslint": "^8.56.0"
},
"peerDependenciesMeta": {
"typescript": {
@@ -2714,16 +2716,16 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "5.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz",
"integrity": "sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==",
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.0.2.tgz",
"integrity": "sha512-l6sa2jF3h+qgN2qUMjVR3uCNGjWw4ahGfzIYsCtFrQJCjhbrDPdiihYT8FnnqFwsWX+20hK592yX9I2rxKTP4g==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "5.62.0",
"@typescript-eslint/visitor-keys": "5.62.0"
"@typescript-eslint/types": "7.0.2",
"@typescript-eslint/visitor-keys": "7.0.2"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
"node": "^16.0.0 || >=18.0.0"
},
"funding": {
"type": "opencollective",
@@ -2731,25 +2733,25 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "5.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.62.0.tgz",
"integrity": "sha512-xsSQreu+VnfbqQpW5vnCJdq1Z3Q0U31qiWmRhr98ONQmcp/yhiPJFPq8MXiJVLiksmOKSjIldZzkebzHuCGzew==",
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.0.2.tgz",
"integrity": "sha512-IKKDcFsKAYlk8Rs4wiFfEwJTQlHcdn8CLwLaxwd6zb8HNiMcQIFX9sWax2k4Cjj7l7mGS5N1zl7RCHOVwHq2VQ==",
"dev": true,
"dependencies": {
"@typescript-eslint/typescript-estree": "5.62.0",
"@typescript-eslint/utils": "5.62.0",
"@typescript-eslint/typescript-estree": "7.0.2",
"@typescript-eslint/utils": "7.0.2",
"debug": "^4.3.4",
"tsutils": "^3.21.0"
"ts-api-utils": "^1.0.1"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
"node": "^16.0.0 || >=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "*"
"eslint": "^8.56.0"
},
"peerDependenciesMeta": {
"typescript": {
@@ -2758,12 +2760,12 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "5.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.62.0.tgz",
"integrity": "sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==",
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.0.2.tgz",
"integrity": "sha512-ZzcCQHj4JaXFjdOql6adYV4B/oFOFjPOC9XYwCaZFRvqN8Llfvv4gSxrkQkd2u4Ci62i2c6W6gkDwQJDaRc4nA==",
"dev": true,
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
"node": "^16.0.0 || >=18.0.0"
},
"funding": {
"type": "opencollective",
@@ -2771,21 +2773,22 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "5.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz",
"integrity": "sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==",
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.0.2.tgz",
"integrity": "sha512-3AMc8khTcELFWcKcPc0xiLviEvvfzATpdPj/DXuOGIdQIIFybf4DMT1vKRbuAEOFMwhWt7NFLXRkbjsvKZQyvw==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "5.62.0",
"@typescript-eslint/visitor-keys": "5.62.0",
"@typescript-eslint/types": "7.0.2",
"@typescript-eslint/visitor-keys": "7.0.2",
"debug": "^4.3.4",
"globby": "^11.1.0",
"is-glob": "^4.0.3",
"semver": "^7.3.7",
"tsutils": "^3.21.0"
"minimatch": "9.0.3",
"semver": "^7.5.4",
"ts-api-utils": "^1.0.1"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
"node": "^16.0.0 || >=18.0.0"
},
"funding": {
"type": "opencollective",
@@ -2797,43 +2800,66 @@
}
}
},
"node_modules/@typescript-eslint/utils": {
"version": "5.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.62.0.tgz",
"integrity": "sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ==",
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"dev": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@types/json-schema": "^7.0.9",
"@types/semver": "^7.3.12",
"@typescript-eslint/scope-manager": "5.62.0",
"@typescript-eslint/types": "5.62.0",
"@typescript-eslint/typescript-estree": "5.62.0",
"eslint-scope": "^5.1.1",
"semver": "^7.3.7"
"balanced-match": "^1.0.0"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
"integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
"dev": true,
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@typescript-eslint/utils": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.0.2.tgz",
"integrity": "sha512-PZPIONBIB/X684bhT1XlrkjNZJIEevwkKDsdwfiu1WeqBxYEEdIgVDgm8/bbKHVu+6YOpeRqcfImTdImx/4Bsw==",
"dev": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.4.0",
"@types/json-schema": "^7.0.12",
"@types/semver": "^7.5.0",
"@typescript-eslint/scope-manager": "7.0.2",
"@typescript-eslint/types": "7.0.2",
"@typescript-eslint/typescript-estree": "7.0.2",
"semver": "^7.5.4"
},
"engines": {
"node": "^16.0.0 || >=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^6.0.0 || ^7.0.0 || ^8.0.0"
"eslint": "^8.56.0"
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "5.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz",
"integrity": "sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==",
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.0.2.tgz",
"integrity": "sha512-8Y+YiBmqPighbm5xA2k4wKTxRzx9EkBu7Rlw+WHqMvRJ3RPz/BMBO9b2ru0LUNmXg120PHUXD5+SWFy2R8DqlQ==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "5.62.0",
"eslint-visitor-keys": "^3.3.0"
"@typescript-eslint/types": "7.0.2",
"eslint-visitor-keys": "^3.4.1"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
"node": "^16.0.0 || >=18.0.0"
},
"funding": {
"type": "opencollective",
@@ -5051,28 +5077,6 @@
"semver": "bin/semver.js"
}
},
"node_modules/eslint-scope": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
"integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==",
"dev": true,
"dependencies": {
"esrecurse": "^4.3.0",
"estraverse": "^4.1.1"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/eslint-scope/node_modules/estraverse": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
"integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
"dev": true,
"engines": {
"node": ">=4.0"
}
},
"node_modules/eslint-visitor-keys": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
@@ -6165,9 +6169,9 @@
}
},
"node_modules/ip": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/ip/-/ip-1.1.8.tgz",
"integrity": "sha512-PuExPYUiu6qMBQb4l06ecm6T6ujzhmh+MeJcW9wa89PoAz5pvd4zPgN5WJV104mb6S2T1AwNIAaB70JNrLQWhg==",
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/ip/-/ip-1.1.9.tgz",
"integrity": "sha512-cyRxvOEpNHNtchU3Ln9KC/auJgup87llfQpQ+t5ghoC/UhL16SWzbueiCsdTnWmqAWl7LadfuwhlqmtOaqMHdQ==",
"dev": true
},
"node_modules/is-array-buffer": {
@@ -7329,12 +7333,6 @@
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
"dev": true
},
"node_modules/natural-compare-lite": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz",
"integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==",
"dev": true
},
"node_modules/nearley": {
"version": "2.20.1",
"resolved": "https://registry.npmjs.org/nearley/-/nearley-2.20.1.tgz",
@@ -10153,6 +10151,18 @@
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
},
"node_modules/ts-api-utils": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.2.1.tgz",
"integrity": "sha512-RIYA36cJn2WiH9Hy77hdF9r7oEwxAtB/TS9/S4Qd90Ap4z5FSiin5zEiTL44OII1Y3IIlEvxwxFUVgrHSZ/UpA==",
"dev": true,
"engines": {
"node": ">=16"
},
"peerDependencies": {
"typescript": ">=4.2.0"
}
},
"node_modules/ts-easing": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/ts-easing/-/ts-easing-0.2.0.tgz",
@@ -10192,27 +10202,6 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
},
"node_modules/tsutils": {
"version": "3.21.0",
"resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz",
"integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==",
"dev": true,
"dependencies": {
"tslib": "^1.8.1"
},
"engines": {
"node": ">= 6"
},
"peerDependencies": {
"typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta"
}
},
"node_modules/tsutils/node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"dev": true
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",

View File

@@ -75,8 +75,8 @@
"@types/react": "^18.0.31",
"@types/react-dom": "^18.0.11",
"@types/uuid": "^9.0.1",
"@typescript-eslint/eslint-plugin": "^5.57.0",
"@typescript-eslint/parser": "^5.57.0",
"@typescript-eslint/eslint-plugin": "^7.0.2",
"@typescript-eslint/parser": "^7.0.2",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.13",
"eslint": "^8.34.0",
@@ -93,7 +93,7 @@
"prettier": "^2.8.4",
"react-devtools": "^4.27.2",
"tailwindcss": "^3.2.7",
"typescript": "^5.0.2",
"typescript": "^5.3.3",
"vite": "^5.1.1",
"vite-plugin-svgr": "^4.2.0",
"vite-plugin-top-level-await": "^1.4.1",

View File

@@ -1,3 +1,4 @@
use std::fmt::Display;
use log::{debug, warn};
use serde::{Deserialize, Serialize};
use serde_json::json;
@@ -7,7 +8,8 @@ use tauri::{AppHandle, Manager};
use crate::{is_dev, models};
// serializable
#[derive(Serialize, Deserialize)]
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "snake_case")]
pub enum AnalyticsResource {
App,
CookieJar,
@@ -25,28 +27,21 @@ pub enum AnalyticsResource {
}
impl AnalyticsResource {
pub fn from_str(s: &str) -> Option<AnalyticsResource> {
match s {
"App" => Some(AnalyticsResource::App),
"Dialog" => Some(AnalyticsResource::Dialog),
"CookieJar" => Some(AnalyticsResource::CookieJar),
"Environment" => Some(AnalyticsResource::Environment),
"Folder" => Some(AnalyticsResource::Folder),
"GrpcConnection" => Some(AnalyticsResource::GrpcConnection),
"GrpcEvent" => Some(AnalyticsResource::GrpcEvent),
"GrpcRequest" => Some(AnalyticsResource::GrpcRequest),
"HttpRequest" => Some(AnalyticsResource::HttpRequest),
"HttpResponse" => Some(AnalyticsResource::HttpResponse),
"KeyValue" => Some(AnalyticsResource::KeyValue),
"Sidebar" => Some(AnalyticsResource::Sidebar),
"Workspace" => Some(AnalyticsResource::Workspace),
_ => None,
}
pub fn from_str(s: &str) -> serde_json::Result<AnalyticsResource> {
return serde_json::from_str(format!("\"{s}\"").as_str());
}
}
#[derive(Serialize, Deserialize)]
impl Display for AnalyticsResource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", serde_json::to_string(self).unwrap().replace("\"", ""))
}
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "snake_case")]
pub enum AnalyticsAction {
Cancel,
Create,
Delete,
DeleteMany,
@@ -65,63 +60,14 @@ pub enum AnalyticsAction {
}
impl AnalyticsAction {
pub fn from_str(s: &str) -> Option<AnalyticsAction> {
match s {
"Create" => Some(AnalyticsAction::Create),
"Delete" => Some(AnalyticsAction::Delete),
"DeleteMany" => Some(AnalyticsAction::DeleteMany),
"Duplicate" => Some(AnalyticsAction::Duplicate),
"Export" => Some(AnalyticsAction::Export),
"Hide" => Some(AnalyticsAction::Hide),
"Import" => Some(AnalyticsAction::Import),
"Launch" => Some(AnalyticsAction::Launch),
"LaunchFirst" => Some(AnalyticsAction::LaunchFirst),
"LaunchUpdate" => Some(AnalyticsAction::LaunchUpdate),
"Send" => Some(AnalyticsAction::Send),
"Show" => Some(AnalyticsAction::Show),
"Toggle" => Some(AnalyticsAction::Toggle),
"Update" => Some(AnalyticsAction::Update),
"Upsert" => Some(AnalyticsAction::Upsert),
_ => None,
}
pub fn from_str(s: &str) -> serde_json::Result<AnalyticsAction> {
return serde_json::from_str(format!("\"{s}\"").as_str());
}
}
fn resource_name(resource: AnalyticsResource) -> &'static str {
match resource {
AnalyticsResource::App => "app",
AnalyticsResource::CookieJar => "cookie_jar",
AnalyticsResource::Dialog => "dialog",
AnalyticsResource::Environment => "environment",
AnalyticsResource::Folder => "folder",
AnalyticsResource::GrpcRequest => "grpc_request",
AnalyticsResource::GrpcConnection => "grpc_connection",
AnalyticsResource::GrpcEvent => "grpc_event",
AnalyticsResource::HttpRequest => "http_request",
AnalyticsResource::HttpResponse => "http_response",
AnalyticsResource::KeyValue => "key_value",
AnalyticsResource::Sidebar => "sidebar",
AnalyticsResource::Workspace => "workspace",
}
}
fn action_name(action: AnalyticsAction) -> &'static str {
match action {
AnalyticsAction::Create => "create",
AnalyticsAction::Delete => "delete",
AnalyticsAction::DeleteMany => "delete_many",
AnalyticsAction::Duplicate => "duplicate",
AnalyticsAction::Export => "export",
AnalyticsAction::Hide => "hide",
AnalyticsAction::Import => "import",
AnalyticsAction::Launch => "launch",
AnalyticsAction::LaunchFirst => "launch_first",
AnalyticsAction::LaunchUpdate => "launch_update",
AnalyticsAction::Send => "send",
AnalyticsAction::Show => "show",
AnalyticsAction::Toggle => "toggle",
AnalyticsAction::Update => "update",
AnalyticsAction::Upsert => "upsert",
impl Display for AnalyticsAction {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", serde_json::to_string(self).unwrap().replace("\"", ""))
}
}
@@ -195,7 +141,7 @@ pub async fn track_event(
action: AnalyticsAction,
attributes: Option<JsonValue>,
) {
let event = format!("{}.{}", resource_name(resource), action_name(action));
let event = format!("{}.{}", resource, action);
let attributes_json = attributes.unwrap_or("{}".to_string().into()).to_string();
let info = app_handle.package_info();
let tz = datetime::sys_timezone().unwrap_or("unknown".to_string());

View File

@@ -7,13 +7,15 @@ use std::sync::Arc;
use std::time::Duration;
use base64::Engine;
use http::header::{ACCEPT, USER_AGENT};
use http::{HeaderMap, HeaderName, HeaderValue, Method};
use http::header::{ACCEPT, USER_AGENT};
use log::{error, info, warn};
use reqwest::redirect::Policy;
use reqwest::{multipart, Url};
use reqwest::redirect::Policy;
use sqlx::types::{Json, JsonValue};
use tauri::{Manager, Window};
use tokio::sync::oneshot;
use tokio::sync::watch::{Receiver};
use crate::{models, render, response_err};
@@ -24,6 +26,7 @@ pub async fn send_http_request(
environment: Option<models::Environment>,
cookie_jar: Option<models::CookieJar>,
download_path: Option<PathBuf>,
cancel_rx: &mut Receiver<bool>,
) -> Result<models::HttpResponse, String> {
let environment_ref = environment.as_ref();
let workspace = models::get_workspace(window, &request.workspace_id)
@@ -302,7 +305,19 @@ pub async fn send_http_request(
};
let start = std::time::Instant::now();
let raw_response = client.execute(sendable_req).await;
let (resp_tx, resp_rx) = oneshot::channel();
tokio::spawn(async move {
let _ = resp_tx.send(client.execute(sendable_req).await);
});
let raw_response = tokio::select! {
Ok(r) = resp_rx => {r}
_ = cancel_rx.changed() => {
return response_err(response, "Request was cancelled".to_string(), window).await;
}
};
match raw_response {
Ok(v) => {

View File

@@ -10,40 +10,54 @@ extern crate objc;
use std::collections::HashMap;
use std::env::current_dir;
use std::fs::{create_dir_all, read_to_string, File};
use std::fs::{create_dir_all, File, read_to_string};
use std::path::PathBuf;
use std::process::exit;
use std::str::FromStr;
use ::http::uri::InvalidUri;
use ::http::Uri;
use ::http::uri::InvalidUri;
use base64::Engine;
use fern::colors::ColoredLevelConfig;
use log::{debug, error, info, warn};
use rand::random;
use serde_json::{json, Value};
use sqlx::{Pool, Sqlite, SqlitePool};
use sqlx::migrate::Migrator;
use sqlx::types::Json;
use sqlx::{Pool, Sqlite, SqlitePool};
#[cfg(target_os = "macos")]
use tauri::TitleBarStyle;
use tauri::{AppHandle, RunEvent, State, Window, WindowUrl};
use tauri::{Manager, WindowEvent};
#[cfg(target_os = "macos")]
use tauri::TitleBarStyle;
use tauri_plugin_log::{fern, LogTarget};
use tauri_plugin_window_state::{StateFlags, WindowExt};
use tokio::sync::Mutex;
use tokio::sync::{Mutex};
use tokio::time::sleep;
use window_shadows::set_shadow;
use ::grpc::{Code, deserialize_message, serialize_message, ServiceDefinition};
use ::grpc::manager::{DynamicMessage, GrpcHandle};
use ::grpc::{deserialize_message, serialize_message, Code, ServiceDefinition};
use window_ext::TrafficLightWindowExt;
use crate::analytics::{AnalyticsAction, AnalyticsResource};
use crate::grpc::metadata_to_map;
use crate::http::send_http_request;
use crate::models::{cancel_pending_grpc_connections, cancel_pending_responses, create_http_response, delete_all_grpc_connections, delete_all_http_responses, delete_cookie_jar, delete_environment, delete_folder, delete_grpc_connection, delete_grpc_request, delete_http_request, delete_http_response, delete_workspace, duplicate_grpc_request, duplicate_http_request, get_cookie_jar, get_environment, get_folder, get_grpc_connection, get_grpc_request, get_http_request, get_http_response, get_key_value_raw, get_or_create_settings, get_workspace, get_workspace_export_resources, list_cookie_jars, list_environments, list_folders, list_grpc_connections, list_grpc_events, list_grpc_requests, list_requests, list_responses, list_workspaces, set_key_value_raw, update_response_if_id, update_settings, upsert_cookie_jar, upsert_environment, upsert_folder, upsert_grpc_connection, upsert_grpc_event, upsert_grpc_request, upsert_http_request, upsert_workspace, CookieJar, Environment, EnvironmentVariable, Folder, GrpcConnection, GrpcEvent, GrpcEventType, GrpcRequest, HttpRequest, HttpResponse, KeyValue, Settings, Workspace, WorkspaceExportResources};
use crate::plugin::{ImportResult};
use crate::models::{
cancel_pending_grpc_connections, cancel_pending_responses, CookieJar,
create_http_response, delete_all_grpc_connections, delete_all_http_responses, delete_cookie_jar,
delete_environment, delete_folder, delete_grpc_connection, delete_grpc_request,
delete_http_request, delete_http_response, delete_workspace, duplicate_grpc_request,
duplicate_http_request, Environment, EnvironmentVariable, Folder, get_cookie_jar,
get_environment, get_folder, get_grpc_connection, get_grpc_request, get_http_request,
get_http_response, get_key_value_raw, get_or_create_settings, get_workspace,
get_workspace_export_resources, GrpcConnection, GrpcEvent, GrpcEventType, GrpcRequest,
HttpRequest, HttpResponse, KeyValue, list_cookie_jars, list_environments,
list_folders, list_grpc_connections, list_grpc_events, list_grpc_requests,
list_requests, list_responses, list_workspaces, set_key_value_raw, Settings,
update_response_if_id, update_settings, upsert_cookie_jar, upsert_environment, upsert_folder, upsert_grpc_connection,
upsert_grpc_event, upsert_grpc_request, upsert_http_request, upsert_workspace, Workspace, WorkspaceExportResources,
};
use crate::plugin::ImportResult;
use crate::updates::{update_mode_from_str, UpdateMode, YaakUpdater};
mod analytics;
@@ -640,8 +654,21 @@ async fn cmd_send_ephemeral_request(
None => None,
};
// let cookie_jar_id2 = cookie_jar_id.unwrap_or("").to_string();
send_http_request(&window, request, &response, environment, cookie_jar, None).await
let (cancel_tx, mut cancel_rx) = tokio::sync::watch::channel(false);
window.listen_global(format!("cancel_http_response_{}", response.id), move |_event| {
let _ = cancel_tx.send(true);
});
send_http_request(
&window,
request,
&response,
environment,
cookie_jar,
None,
&mut cancel_rx,
)
.await
}
#[tauri::command]
@@ -677,7 +704,10 @@ async fn cmd_filter_response(w: Window, response_id: &str, filter: &str) -> Resu
}
#[tauri::command]
async fn cmd_import_data(w: Window, file_paths: Vec<&str>) -> Result<WorkspaceExportResources, String> {
async fn cmd_import_data(
w: Window,
file_paths: Vec<&str>,
) -> Result<WorkspaceExportResources, String> {
let mut result: Option<ImportResult> = None;
let plugins = vec!["importer-yaak", "importer-insomnia", "importer-postman"];
for plugin_name in plugins {
@@ -778,19 +808,19 @@ async fn cmd_export_data(
#[tauri::command]
async fn cmd_send_http_request(
w: Window,
window: Window,
request_id: &str,
environment_id: Option<&str>,
cookie_jar_id: Option<&str>,
download_dir: Option<&str>,
) -> Result<HttpResponse, String> {
let request = get_http_request(&w, request_id)
let request = get_http_request(&window, request_id)
.await
.expect("Failed to get request");
let environment = match environment_id {
Some(id) => Some(
get_environment(&w, id)
get_environment(&window, id)
.await
.expect("Failed to get environment"),
),
@@ -799,7 +829,7 @@ async fn cmd_send_http_request(
let cookie_jar = match cookie_jar_id {
Some(id) => Some(
get_cookie_jar(&w, id)
get_cookie_jar(&window, id)
.await
.expect("Failed to get cookie jar"),
),
@@ -807,7 +837,7 @@ async fn cmd_send_http_request(
};
let response = create_http_response(
&w,
&window,
&request.id,
0,
0,
@@ -829,13 +859,19 @@ async fn cmd_send_http_request(
None
};
let (cancel_tx, mut cancel_rx) = tokio::sync::watch::channel(false);
window.listen_global(format!("cancel_http_response_{}", response.id), move |_event| {
let _ = cancel_tx.send(true);
});
send_http_request(
&w,
&window,
request.clone(),
&response,
environment,
cookie_jar,
download_path,
&mut cancel_rx,
)
.await
}
@@ -865,11 +901,13 @@ async fn cmd_track_event(
AnalyticsResource::from_str(resource),
AnalyticsAction::from_str(action),
) {
(Some(resource), Some(action)) => {
(Ok(resource), Ok(action)) => {
analytics::track_event(&window.app_handle(), resource, action, attributes).await;
}
_ => {
error!("Invalid action/resource for track_event: {action} {resource}");
(r, a) => {
println!("HttpRequest: {:?}", serde_json::to_string(&AnalyticsResource::HttpRequest));
println!("Send: {:?}", serde_json::to_string(&AnalyticsAction::Send));
error!("Invalid action/resource for track_event: {resource}.{action} = {:?}.{:?}", r, a);
return Err("Invalid event".to_string());
}
};

View File

@@ -12,7 +12,7 @@ use serde::{Deserialize, Serialize};
use serde_json::json;
use tauri::AppHandle;
use crate::models::{Environment, Folder, HttpRequest, Workspace, WorkspaceExportResources};
use crate::models::{WorkspaceExportResources};
#[derive(Default, Debug, Deserialize, Serialize)]
pub struct FilterResult {

View File

@@ -1,4 +1,3 @@
import { ReactNode } from 'react';
import { useCreateFolder } from '../hooks/useCreateFolder';
import { useCreateGrpcRequest } from '../hooks/useCreateGrpcRequest';
import { useCreateHttpRequest } from '../hooks/useCreateHttpRequest';

View File

@@ -27,7 +27,7 @@ export const DialogProvider = ({ children }: { children: React.ReactNode }) => {
const actions = useMemo<Actions>(
() => ({
show({ id, ...props }: DialogEntry) {
trackEvent('Dialog', 'Show', { id });
trackEvent('dialog', 'show', { id });
setDialogs((a) => [...a.filter((d) => d.id !== id), { id, ...props }]);
},
toggle({ id, ...props }: DialogEntry) {

View File

@@ -141,74 +141,56 @@ function EventRow({
}) {
const { eventType, status, createdAt, content, error } = event;
return (
<button
onClick={onClick}
className={classNames(
'w-full grid grid-cols-[auto_minmax(0,3fr)_auto] gap-2 items-center text-left',
'px-1 py-1 font-mono cursor-default group focus:outline-none',
isActive && '!bg-highlight text-gray-900',
'text-gray-800 hover:text-gray-900',
)}
>
<Icon
className={
eventType === 'server_message'
? 'text-blue-600'
: eventType === 'client_message'
? 'text-violet-600'
: eventType === 'error' || (status != null && status > 0)
? 'text-orange-600'
: eventType === 'connection_end'
? 'text-green-600'
: 'text-gray-700'
}
title={
eventType === 'server_message'
? 'Server message'
: eventType === 'client_message'
? 'Client message'
: eventType === 'error' || (status != null && status > 0)
? 'Error'
: eventType === 'connection_end'
? 'Connection response'
: undefined
}
icon={
eventType === 'server_message'
? 'arrowBigDownDash'
: eventType === 'client_message'
? 'arrowBigUpDash'
: eventType === 'error' || (status != null && status > 0)
? 'alert'
: eventType === 'connection_end'
? 'check'
: 'info'
}
/>
<div className={classNames('w-full truncate text-2xs')}>{error ?? content}</div>
<div className={classNames('opacity-50 text-2xs')}>
{format(createdAt + 'Z', 'HH:mm:ss.SSS')}
</div>
</button>
<div className="px-1">
<button
onClick={onClick}
className={classNames(
'w-full grid grid-cols-[auto_minmax(0,3fr)_auto] gap-2 items-center text-left',
'px-1.5 py-1 font-mono cursor-default group focus:outline-none rounded',
isActive && '!bg-highlight text-gray-900',
'text-gray-800 hover:text-gray-900',
)}
>
<Icon
className={
eventType === 'server_message'
? 'text-blue-600'
: eventType === 'client_message'
? 'text-violet-600'
: eventType === 'error' || (status != null && status > 0)
? 'text-orange-600'
: eventType === 'connection_end'
? 'text-green-600'
: 'text-gray-700'
}
title={
eventType === 'server_message'
? 'Server message'
: eventType === 'client_message'
? 'Client message'
: eventType === 'error' || (status != null && status > 0)
? 'Error'
: eventType === 'connection_end'
? 'Connection response'
: undefined
}
icon={
eventType === 'server_message'
? 'arrowBigDownDash'
: eventType === 'client_message'
? 'arrowBigUpDash'
: eventType === 'error' || (status != null && status > 0)
? 'alert'
: eventType === 'connection_end'
? 'check'
: 'info'
}
/>
<div className={classNames('w-full truncate text-2xs')}>{error ?? content}</div>
<div className={classNames('opacity-50 text-2xs')}>
{format(createdAt + 'Z', 'HH:mm:ss.SSS')}
</div>
</button>
</div>
);
}
const GRPC_CODES: Record<number, string> = {
0: 'Ok',
1: 'Cancelled',
2: 'Unknown',
3: 'Invalid argument',
4: 'Deadline exceeded',
5: 'Not found',
6: 'Already exists',
7: 'Permission denied',
8: 'Resource exhausted',
9: 'Failed precondition',
10: 'Aborted',
11: 'Out of range',
12: 'Unimplemented',
13: 'Internal',
14: 'Unavailable',
15: 'Data loss',
16: 'Unauthenticated',
};

View File

@@ -1,6 +1,6 @@
import useResizeObserver from '@react-hook/resize-observer';
import classNames from 'classnames';
import type { CSSProperties, FormEvent } from 'react';
import type { CSSProperties } from 'react';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { createGlobalState } from 'react-use';
import type { ReflectResponseService } from '../hooks/useGrpc';
@@ -104,22 +104,18 @@ export function GrpcConnectionSetupPane({
[updateRequest],
);
const handleConnect = useCallback(
async (e: FormEvent) => {
e.preventDefault();
if (activeRequest == null) return;
const handleConnect = useCallback(async () => {
if (activeRequest == null) return;
if (activeRequest.service == null || activeRequest.method == null) {
alert({
id: 'grpc-invalid-service-method',
title: 'Error',
body: 'Service or method not selected',
});
}
onGo();
},
[activeRequest, onGo],
);
if (activeRequest.service == null || activeRequest.method == null) {
alert({
id: 'grpc-invalid-service-method',
title: 'Error',
body: 'Service or method not selected',
});
}
onGo();
}, [activeRequest, onGo]);
const tabs: TabItem[] = useMemo(
() => [
@@ -176,9 +172,10 @@ export function GrpcConnectionSetupPane({
submitIcon={null}
forceUpdateKey={forceUpdateKey}
placeholder="localhost:50051"
onSubmit={handleConnect}
onSend={handleConnect}
onUrlChange={handleChangeUrl}
isLoading={false}
onCancel={onCancel}
isLoading={isStreaming}
/>
<HStack space={1.5}>
<RadioDropdown

View File

@@ -1,8 +1,10 @@
import classNames from 'classnames';
import type { CSSProperties, FormEvent } from 'react';
import type { CSSProperties } from 'react';
import { memo, useCallback, useMemo, useState } from 'react';
import { createGlobalState } from 'react-use';
import { useCancelHttpResponse } from '../hooks/useCancelHttpResponse';
import { useIsResponseLoading } from '../hooks/useIsResponseLoading';
import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse';
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
import { useSendRequest } from '../hooks/useSendRequest';
import { useUpdateHttpRequest } from '../hooks/useUpdateHttpRequest';
@@ -183,13 +185,15 @@ export const RequestPane = memo(function RequestPane({
);
const sendRequest = useSendRequest(activeRequest.id ?? null);
const handleSend = useCallback(
async (e: FormEvent) => {
e.preventDefault();
await sendRequest.mutateAsync();
},
[sendRequest],
);
const { activeResponse } = usePinnedHttpResponse(activeRequest);
const cancelResponse = useCancelHttpResponse(activeResponse?.id ?? null);
const handleSend = useCallback(async () => {
await sendRequest.mutateAsync();
}, [sendRequest]);
const handleCancel = useCallback(async () => {
await cancelResponse.mutateAsync();
}, [cancelResponse]);
const handleMethodChange = useCallback(
(method: string) => updateRequest.mutate({ method }),
@@ -214,7 +218,8 @@ export const RequestPane = memo(function RequestPane({
url={activeRequest.url}
method={activeRequest.method}
placeholder="https://example.com"
onSubmit={handleSend}
onSend={handleSend}
onCancel={handleCancel}
onMethodChange={handleMethodChange}
onUrlChange={handleUrlChange}
forceUpdateKey={updateKey}

View File

@@ -1,17 +1,17 @@
import classNames from 'classnames';
import type { CSSProperties } from 'react';
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { memo, useMemo } from 'react';
import { createGlobalState } from 'react-use';
import { useHttpResponses } from '../hooks/useHttpResponses';
import { useLatestHttpResponse } from '../hooks/useLatestHttpResponse';
import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse';
import { useResponseContentType } from '../hooks/useResponseContentType';
import { useResponseViewMode } from '../hooks/useResponseViewMode';
import type { HttpRequest, HttpResponse } from '../lib/models';
import type { HttpRequest } from '../lib/models';
import { isResponseLoading } from '../lib/models';
import { Banner } from './core/Banner';
import { CountBadge } from './core/CountBadge';
import { DurationTag } from './core/DurationTag';
import { HotKeyList } from './core/HotKeyList';
import { Icon } from './core/Icon';
import { SizeTag } from './core/SizeTag';
import { HStack } from './core/Stacks';
import { StatusTag } from './core/StatusTag';
@@ -34,27 +34,11 @@ interface Props {
const useActiveTab = createGlobalState<string>('body');
export const ResponsePane = memo(function ResponsePane({ style, className, activeRequest }: Props) {
const [pinnedResponseId, setPinnedResponseId] = useState<string | null>(null);
const latestResponse = useLatestHttpResponse(activeRequest.id);
const responses = useHttpResponses(activeRequest.id);
const activeResponse: HttpResponse | null = pinnedResponseId
? responses.find((r) => r.id === pinnedResponseId) ?? null
: latestResponse ?? null;
const { activeResponse, setPinnedResponse, responses } = usePinnedHttpResponse(activeRequest);
const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId);
const [activeTab, setActiveTab] = useActiveTab();
// Unset pinned response when a new one comes in
useEffect(() => setPinnedResponseId(null), [responses.length]);
const contentType = useResponseContentType(activeResponse);
const handlePinnedResponse = useCallback(
(r: HttpResponse) => {
setPinnedResponseId(r.id);
},
[setPinnedResponseId],
);
const tabs = useMemo<TabItem[]>(
() => [
{
@@ -89,21 +73,21 @@ export const ResponsePane = memo(function ResponsePane({ style, className, activ
style={style}
className={classNames(
className,
'max-h-full h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1',
'max-h-full h-full',
'bg-gray-50 dark:bg-gray-100 rounded-md border border-highlight',
'shadow shadow-gray-100 dark:shadow-gray-0 relative',
)}
>
{!activeResponse && (
<>
<span />
<HotKeyList
hotkeys={['http_request.send', 'http_request.create', 'sidebar.toggle', 'urlBar.focus']}
/>
</>
)}
{activeResponse && !isResponseLoading(activeResponse) && (
<>
{activeResponse == null ? (
<HotKeyList
hotkeys={['http_request.send', 'http_request.create', 'sidebar.toggle', 'urlBar.focus']}
/>
) : isResponseLoading(activeResponse) ? (
<div className="h-full w-full flex items-center justify-center">
<Icon size="lg" className="opacity-disabled" spin icon="refresh" />
</div>
) : (
<div className="grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1">
<HStack
alignItems="center"
className={classNames(
@@ -138,7 +122,7 @@ export const ResponsePane = memo(function ResponsePane({ style, className, activ
<RecentResponsesDropdown
responses={responses}
activeResponse={activeResponse}
onPinnedResponse={handlePinnedResponse}
onPinnedResponse={setPinnedResponse}
/>
</HStack>
)}
@@ -179,7 +163,7 @@ export const ResponsePane = memo(function ResponsePane({ style, className, activ
</TabContent>
</Tabs>
)}
</>
</div>
)}
</div>
);

View File

@@ -1,11 +1,15 @@
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
import { useAppInfo } from '../hooks/useAppInfo';
import { useCheckForUpdates } from '../hooks/useCheckForUpdates';
import { useSettings } from '../hooks/useSettings';
import { useUpdateSettings } from '../hooks/useUpdateSettings';
import { useUpdateWorkspace } from '../hooks/useUpdateWorkspace';
import { trackEvent } from '../lib/analytics';
import { Checkbox } from './core/Checkbox';
import { Heading } from './core/Heading';
import { IconButton } from './core/IconButton';
import { Input } from './core/Input';
import { KeyValueRow, KeyValueRows } from './core/KeyValueRow';
import { Select } from './core/Select';
import { Separator } from './core/Separator';
import { VStack } from './core/Stacks';
@@ -16,6 +20,7 @@ export const SettingsDialog = () => {
const settings = useSettings();
const updateSettings = useUpdateSettings();
const appInfo = useAppInfo();
const checkForUpdates = useCheckForUpdates();
if (settings == null || workspace == null) {
return null;
@@ -29,7 +34,10 @@ export const SettingsDialog = () => {
labelPosition="left"
size="sm"
value={settings.appearance}
onChange={(appearance) => updateSettings.mutateAsync({ ...settings, appearance })}
onChange={async (appearance) => {
await updateSettings.mutateAsync({ ...settings, appearance });
trackEvent('setting', 'update', { appearance });
}}
options={[
{
label: 'System',
@@ -46,24 +54,37 @@ export const SettingsDialog = () => {
]}
/>
<Select
name="updateChannel"
label="Update Channel"
labelPosition="left"
size="sm"
value={settings.updateChannel}
onChange={(updateChannel) => updateSettings.mutateAsync({ ...settings, updateChannel })}
options={[
{
label: 'Release',
value: 'stable',
},
{
label: 'Early Bird (Beta)',
value: 'beta',
},
]}
/>
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-1">
<Select
name="updateChannel"
label="Update Channel"
labelPosition="left"
size="sm"
value={settings.updateChannel}
onChange={async (updateChannel) => {
trackEvent('setting', 'update', { update_channel: updateChannel });
await updateSettings.mutateAsync({ ...settings, updateChannel });
}}
options={[
{
label: 'Release',
value: 'stable',
},
{
label: 'Early Bird (Beta)',
value: 'beta',
},
]}
/>
<IconButton
variant="border"
size="sm"
title="Check for updates"
icon="refresh"
spin={checkForUpdates.isLoading}
onClick={() => checkForUpdates.mutateAsync()}
/>
</div>
<Separator className="my-4" />
@@ -88,41 +109,33 @@ export const SettingsDialog = () => {
<Checkbox
checked={workspace.settingValidateCertificates}
title="Validate TLS Certificates"
onChange={(settingValidateCertificates) =>
updateWorkspace.mutateAsync({ settingValidateCertificates })
}
onChange={async (settingValidateCertificates) => {
trackEvent('workspace', 'update', {
validate_certificates: JSON.stringify(settingValidateCertificates),
});
await updateWorkspace.mutateAsync({ settingValidateCertificates });
}}
/>
<Checkbox
checked={workspace.settingFollowRedirects}
title="Follow Redirects"
onChange={(settingFollowRedirects) =>
updateWorkspace.mutateAsync({ settingFollowRedirects })
}
onChange={async (settingFollowRedirects) => {
trackEvent('workspace', 'update', {
follow_redirects: JSON.stringify(settingFollowRedirects),
});
await updateWorkspace.mutateAsync({ settingFollowRedirects });
}}
/>
</VStack>
<Separator className="my-4" />
<Heading size={2}>App Info</Heading>
<table className="text-sm w-full">
<tbody>
<tr>
<td className="h-xs pr-3">Version</td>
<td className="h-xs text-xs font-mono select-all cursor-text">
{appInfo.data?.version}
</td>
</tr>
{appInfo.data && (
<tr>
<td className="h-xs pr-3 whitespace-nowrap">Data Directory</td>
<td className="h-xs text-xs font-mono select-all cursor-text break-all min-w-0">
{appInfo.data.appDataDir}
</td>
</tr>
)}
</tbody>
</table>
<KeyValueRows>
<KeyValueRow label="Version" value={appInfo.data?.version} />
<KeyValueRow label="Data Directory" value={appInfo.data?.appDataDir} />
</KeyValueRows>
</VStack>
);
};

View File

@@ -5,12 +5,10 @@ import { useAppInfo } from '../hooks/useAppInfo';
import { useExportData } from '../hooks/useExportData';
import { useImportData } from '../hooks/useImportData';
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
import { Button } from './core/Button';
import type { DropdownRef } from './core/Dropdown';
import { Dropdown } from './core/Dropdown';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { VStack } from './core/Stacks';
import { useDialog } from './DialogContext';
import { KeyboardShortcutsDialog } from './KeyboardShortcutsDialog';
import { SettingsDialog } from './SettingsDialog';

View File

@@ -12,7 +12,7 @@ export const SidebarActions = memo(function SidebarActions() {
<HStack className="h-full" alignItems="center">
<IconButton
onClick={async () => {
trackEvent('Sidebar', 'Toggle');
trackEvent('sidebar', 'toggle');
// NOTE: We're not using `toggle` because it may be out of sync
// from changes in other windows

View File

@@ -12,8 +12,9 @@ type Props = Pick<HttpRequest, 'url'> & {
className?: string;
method: HttpRequest['method'] | null;
placeholder: string;
onSubmit: (e: FormEvent) => void;
onSend: () => void;
onUrlChange: (url: string) => void;
onCancel: () => void;
submitIcon?: IconProps['icon'] | null;
onMethodChange?: (method: string) => void;
isLoading: boolean;
@@ -27,7 +28,8 @@ export const UrlBar = memo(function UrlBar({
method,
placeholder,
className,
onSubmit,
onSend,
onCancel,
onMethodChange,
submitIcon = 'sendHorizontal',
isLoading,
@@ -43,8 +45,13 @@ export const UrlBar = memo(function UrlBar({
inputRef.current?.focus();
});
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
isLoading ? onCancel() : onSend();
};
return (
<form onSubmit={onSubmit} className={className}>
<form onSubmit={handleSubmit} className={className}>
<Input
autocompleteVariables
ref={inputRef}
@@ -81,8 +88,7 @@ export const UrlBar = memo(function UrlBar({
title="Send Request"
type="submit"
className="w-8 !h-auto my-0.5 mr-0.5"
icon={isLoading ? 'update' : submitIcon}
spin={isLoading}
icon={isLoading ? 'x' : submitIcon}
hotkeyAction="http_request.send"
/>
)

View File

@@ -1,6 +1,6 @@
import classNames from 'classnames';
import type { HTMLAttributes, ReactNode } from 'react';
import { forwardRef, useImperativeHandle, useMemo, useRef } from 'react';
import { forwardRef, useImperativeHandle, useRef } from 'react';
import type { HotkeyAction } from '../../hooks/useHotKey';
import { useFormattedHotkey, useHotKey } from '../../hooks/useHotKey';
import { Icon } from './Icon';

View File

@@ -1,6 +1,5 @@
import { defaultKeymap } from '@codemirror/commands';
import { Compartment, EditorState, Transaction } from '@codemirror/state';
import type { ViewUpdate } from '@codemirror/view';
import { Compartment, EditorState } from '@codemirror/state';
import { keymap, placeholder as placeholderExt, tooltips } from '@codemirror/view';
import classNames from 'classnames';
import { EditorView } from 'codemirror';
@@ -148,14 +147,6 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
view.dispatch({ effects: languageCompartment.reconfigure(ext) });
}, [contentType, autocomplete, useTemplating, environment, workspace]);
useEffect(() => {
if (cm.current === null) return;
const { view } = cm.current;
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: defaultValue ?? '' } });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [forceUpdateKey]);
const classList = className?.split(/\s+/) ?? [];
const bgClassList = classList
.filter((c) => c.match(/(^|:)?bg-.+/)) // Find bg-* classes
@@ -163,57 +154,59 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
.map((c) => c.replace(/^dark:bg-/, 'dark:!bg-')); // !important
// Initialize the editor when ref mounts
const initEditorRef = useCallback((container: HTMLDivElement | null) => {
if (container === null) {
cm.current?.view.destroy();
cm.current = null;
return;
}
let view: EditorView;
try {
const languageCompartment = new Compartment();
const langExt = getLanguageExtension({
contentType,
useTemplating,
autocomplete,
environment,
workspace,
});
const state = EditorState.create({
doc: `${defaultValue ?? ''}`,
extensions: [
languageCompartment.of(langExt),
placeholderCompartment.current.of([]),
wrapLinesCompartment.current.of([]),
...getExtensions({
container,
readOnly,
singleLine,
onChange: handleChange,
onFocus: handleFocus,
onBlur: handleBlur,
onKeyDown: handleKeyDown,
}),
],
});
view = new EditorView({ state, parent: container });
cm.current = { view, languageCompartment };
syncGutterBg({ parent: container, bgClassList });
if (autoFocus) {
view.focus();
const initEditorRef = useCallback(
(container: HTMLDivElement | null) => {
if (container === null) {
cm.current?.view.destroy();
cm.current = null;
return;
}
if (autoSelect) {
view.dispatch({ selection: { anchor: 0, head: view.state.doc.length } });
}
} catch (e) {
console.log('Failed to initialize Codemirror', e);
}
let view: EditorView;
try {
const languageCompartment = new Compartment();
const langExt = getLanguageExtension({
contentType,
useTemplating,
autocomplete,
environment,
workspace,
});
const state = EditorState.create({
doc: `${defaultValue ?? ''}`,
extensions: [
languageCompartment.of(langExt),
placeholderCompartment.current.of([]),
wrapLinesCompartment.current.of([]),
...getExtensions({
container,
readOnly,
singleLine,
onChange: handleChange,
onFocus: handleFocus,
onBlur: handleBlur,
onKeyDown: handleKeyDown,
}),
],
});
view = new EditorView({ state, parent: container });
cm.current = { view, languageCompartment };
syncGutterBg({ parent: container, bgClassList });
if (autoFocus) {
view.focus();
}
if (autoSelect) {
view.dispatch({ selection: { anchor: 0, head: view.state.doc.length } });
}
} catch (e) {
console.log('Failed to initialize Codemirror', e);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
[forceUpdateKey],
);
// Add bg classes to actions, so they appear over the text
const decoratedActions = useMemo(() => {
@@ -340,29 +333,13 @@ function getExtensions({
// Handle onChange
EditorView.updateListener.of((update) => {
// Only fire onChange if the document changed and the update was from user input. This prevents firing onChange when the document is updated when
// changing pages (one request to another in header editor)
if (onChange && update.docChanged && isViewUpdateFromUserInput(update)) {
if (onChange && update.docChanged) {
onChange.current?.(update.state.doc.toString());
}
}),
];
}
function isViewUpdateFromUserInput(viewUpdate: ViewUpdate) {
// Make sure document has changed, ensuring user events like selections don't count.
if (viewUpdate.docChanged) {
// Check transactions for any that are direct user input, not changes from Y.js or another extension.
for (const transaction of viewUpdate.transactions) {
// Not using Transaction.isUserEvent because that only checks for a specific User event type ( "input", "delete", etc.). Checking the annotation directly allows for any type of user event.
const userEventType = transaction.annotation(Transaction.userEvent);
if (userEventType) return userEventType;
}
}
return false;
}
const syncGutterBg = ({
parent,
bgClassList,

View File

@@ -11,7 +11,7 @@ interface Props {
export const HotKeyList = ({ hotkeys, bottomSlot }: Props) => {
return (
<div className="mx-auto h-full flex items-center text-gray-700 text-sm">
<div className="h-full flex items-center justify-center text-gray-700 text-sm">
<VStack space={2}>
{hotkeys.map((hotkey) => (
<HStack key={hotkey} className="grid grid-cols-2">

View File

@@ -1,6 +1,24 @@
import classNames from 'classnames';
import type { ReactNode } from 'react';
import { HStack } from './Stacks';
import type { HTMLAttributes, ReactElement, ReactNode } from 'react';
export function KeyValueRows({
children,
}: {
children:
| ReactElement<HTMLAttributes<HTMLTableColElement>>
| ReactElement<HTMLAttributes<HTMLTableColElement>>[];
}) {
children = Array.isArray(children) ? children : [children];
return (
<table className="text-xs font-mono min-w-0 w-full mb-auto">
<tbody className="divide-highlightSecondary">
{children.map((child, i) => (
<tr key={i}>{child}</tr>
))}
</tbody>
</table>
);
}
interface Props {
label: ReactNode;
@@ -8,17 +26,13 @@ interface Props {
labelClassName?: string;
}
export function KeyValueRows({ children }: { children: ReactNode }) {
return <dl className="text-xs w-full font-mono divide-highlightSecondary">{children}</dl>;
}
export function KeyValueRow({ label, value, labelClassName }: Props) {
return (
<HStack space={3} className="py-0.5">
<dd className={classNames(labelClassName, 'w-1/3 text-gray-700 select-text cursor-text')}>
<>
<td className={classNames('py-1 pr-2 text-gray-700 select-text cursor-text', labelClassName)}>
{label}
</dd>
<dt className="w-2/3 select-text cursor-text break-all">{value}</dt>
</HStack>
</td>
<td className="py-1 cursor-text select-text break-all min-w-0">{value}</td>
</>
);
}

View File

@@ -0,0 +1,10 @@
import { useMutation } from '@tanstack/react-query';
import { event } from '@tauri-apps/api';
import { trackEvent } from '../lib/analytics';
export function useCancelHttpResponse(id: string | null) {
return useMutation<void>({
mutationFn: () => event.emit(`cancel_http_response_${id}`),
onSettled: () => trackEvent('http_response', 'cancel'),
});
}

View File

@@ -0,0 +1,20 @@
import { useMutation } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import { minPromiseMillis } from '../lib/minPromiseMillis';
import { useAlert } from './useAlert';
export function useCheckForUpdates() {
const alert = useAlert();
return useMutation({
mutationFn: async () => {
const hasUpdate: boolean = await minPromiseMillis(invoke('cmd_check_for_updates'), 500);
if (!hasUpdate) {
alert({
id: 'no-updates',
title: 'No Updates',
body: 'You are currently up to date',
});
}
},
});
}

View File

@@ -26,7 +26,7 @@ export function useCreateCookieJar() {
});
return invoke('cmd_create_cookie_jar', { workspaceId, name });
},
onSettled: () => trackEvent('CookieJar', 'Create'),
onSettled: () => trackEvent('cookie_jar', 'create'),
onSuccess: async (cookieJar) => {
queryClient.setQueryData<CookieJar[]>(
cookieJarsQueryKey({ workspaceId: cookieJar.workspaceId }),

View File

@@ -26,7 +26,7 @@ export function useCreateEnvironment() {
});
return invoke('cmd_create_environment', { name, variables: [], workspaceId });
},
onSettled: () => trackEvent('Environment', 'Create'),
onSettled: () => trackEvent('environment', 'create'),
onSuccess: async (environment) => {
if (workspaceId == null) return;
routes.setEnvironment(environment);

View File

@@ -18,7 +18,7 @@ export function useCreateFolder() {
patch.sortPriority = patch.sortPriority || -Date.now();
return invoke('cmd_create_folder', { workspaceId, ...patch });
},
onSettled: () => trackEvent('Folder', 'Create'),
onSettled: () => trackEvent('folder', 'create'),
onSuccess: async (request) => {
await queryClient.invalidateQueries(foldersQueryKey({ workspaceId: request.workspaceId }));
},

View File

@@ -33,7 +33,7 @@ export function useCreateGrpcRequest() {
// patch.folderId = patch.folderId; // TODO: || activeRequest?.folderId;
return invoke('cmd_create_grpc_request', { workspaceId, name: '', ...patch });
},
onSettled: () => trackEvent('GrpcRequest', 'Create'),
onSettled: () => trackEvent('grpc_request', 'create'),
onSuccess: async (request) => {
routes.navigate('request', {
workspaceId: request.workspaceId,

View File

@@ -34,7 +34,7 @@ export function useCreateHttpRequest() {
patch.folderId = patch.folderId || activeRequest?.folderId;
return invoke('cmd_create_http_request', { workspaceId, name: '', ...patch });
},
onSettled: () => trackEvent('HttpRequest', 'Create'),
onSettled: () => trackEvent('http_request', 'create'),
onSuccess: async (request) => {
routes.navigate('request', {
workspaceId: request.workspaceId,

View File

@@ -10,7 +10,7 @@ export function useCreateWorkspace({ navigateAfter }: { navigateAfter: boolean }
mutationFn: (patch) => {
return invoke('cmd_create_workspace', patch);
},
onSettled: () => trackEvent('Workspace', 'Create'),
onSettled: () => trackEvent('workspace', 'create'),
onSuccess: async (workspace) => {
if (navigateAfter) {
routes.navigate('workspace', { workspaceId: workspace.id });

View File

@@ -30,7 +30,7 @@ export function useDeleteAnyGrpcRequest() {
if (!confirmed) return null;
return invoke('cmd_delete_grpc_request', { requestId: id });
},
onSettled: () => trackEvent('GrpcRequest', 'Delete'),
onSettled: () => trackEvent('grpc_request', 'delete'),
onSuccess: async (request) => {
if (request === null) return;

View File

@@ -31,7 +31,7 @@ export function useDeleteAnyHttpRequest() {
if (!confirmed) return null;
return invoke('cmd_delete_http_request', { requestId: id });
},
onSettled: () => trackEvent('HttpRequest', 'Delete'),
onSettled: () => trackEvent('http_request', 'delete'),
onSuccess: async (request) => {
// Was it cancelled?
if (request === null) return;

View File

@@ -25,7 +25,7 @@ export function useDeleteCookieJar(cookieJar: CookieJar | null) {
if (!confirmed) return null;
return invoke('cmd_delete_cookie_jar', { cookieJarId: cookieJar?.id });
},
onSettled: () => trackEvent('CookieJar', 'Delete'),
onSettled: () => trackEvent('cookie_jar', 'delete'),
onSuccess: async (cookieJar) => {
if (cookieJar === null) return;

View File

@@ -25,7 +25,7 @@ export function useDeleteEnvironment(environment: Environment | null) {
if (!confirmed) return null;
return invoke('cmd_delete_environment', { environmentId: environment?.id });
},
onSettled: () => trackEvent('Environment', 'Delete'),
onSettled: () => trackEvent('environment', 'delete'),
onSuccess: async (environment) => {
if (environment === null) return;

View File

@@ -28,7 +28,7 @@ export function useDeleteFolder(id: string | null) {
if (!confirmed) return null;
return invoke('cmd_delete_folder', { folderId: id });
},
onSettled: () => trackEvent('Folder', 'Delete'),
onSettled: () => trackEvent('folder', 'delete'),
onSuccess: async (folder) => {
// Was it cancelled?
if (folder === null) return;

View File

@@ -10,7 +10,7 @@ export function useDeleteGrpcConnection(id: string | null) {
mutationFn: async () => {
return await invoke('cmd_delete_grpc_connection', { id: id });
},
onSettled: () => trackEvent('GrpcConnection', 'Delete'),
onSettled: () => trackEvent('grpc_connection', 'delete'),
onSuccess: ({ requestId, id: connectionId }) => {
queryClient.setQueryData<GrpcConnection[]>(
grpcConnectionsQueryKey({ requestId }),

View File

@@ -10,7 +10,7 @@ export function useDeleteGrpcConnections(requestId?: string) {
if (requestId === undefined) return;
await invoke('cmd_delete_all_grpc_connections', { requestId });
},
onSettled: () => trackEvent('GrpcConnection', 'DeleteMany'),
onSettled: () => trackEvent('grpc_connection', 'delete_many'),
onSuccess: async () => {
if (requestId === undefined) return;
queryClient.setQueryData(grpcConnectionsQueryKey({ requestId }), []);

View File

@@ -10,7 +10,7 @@ export function useDeleteHttpResponse(id: string | null) {
mutationFn: async () => {
return await invoke('cmd_delete_http_response', { id: id });
},
onSettled: () => trackEvent('HttpResponse', 'Delete'),
onSettled: () => trackEvent('http_response', 'delete'),
onSuccess: ({ requestId, id: responseId }) => {
queryClient.setQueryData<HttpResponse[]>(httpResponsesQueryKey({ requestId }), (responses) =>
(responses ?? []).filter((response) => response.id !== responseId),

View File

@@ -10,7 +10,7 @@ export function useDeleteHttpResponses(requestId?: string) {
if (requestId === undefined) return;
await invoke('cmd_delete_all_http_responses', { requestId });
},
onSettled: () => trackEvent('HttpResponse', 'DeleteMany'),
onSettled: () => trackEvent('http_response', 'delete_many'),
onSuccess: async () => {
if (requestId === undefined) return;
queryClient.setQueryData(httpResponsesQueryKey({ requestId }), []);

View File

@@ -30,7 +30,7 @@ export function useDeleteWorkspace(workspace: Workspace | null) {
if (!confirmed) return null;
return invoke('cmd_delete_workspace', { workspaceId: workspace?.id });
},
onSettled: () => trackEvent('Workspace', 'Delete'),
onSettled: () => trackEvent('workspace', 'delete'),
onSuccess: async (workspace) => {
if (workspace === null) return;

View File

@@ -21,7 +21,7 @@ export function useDuplicateGrpcRequest({
if (id === null) throw new Error("Can't duplicate a null grpc request");
return invoke('cmd_duplicate_grpc_request', { id });
},
onSettled: () => trackEvent('GrpcRequest', 'Duplicate'),
onSettled: () => trackEvent('grpc_request', 'duplicate'),
onSuccess: async (request) => {
if (navigateAfter && activeWorkspaceId !== null) {
routes.navigate('request', {

View File

@@ -21,7 +21,7 @@ export function useDuplicateHttpRequest({
if (id === null) throw new Error("Can't duplicate a null request");
return invoke('cmd_duplicate_http_request', { id });
},
onSettled: () => trackEvent('HttpRequest', 'Duplicate'),
onSettled: () => trackEvent('http_request', 'duplicate'),
onSuccess: async (request) => {
if (navigateAfter && activeWorkspaceId !== null) {
routes.navigate('request', {

View File

@@ -0,0 +1,28 @@
import { useCallback, useEffect } from 'react';
import { createGlobalState } from 'react-use';
import type { HttpRequest, HttpResponse } from '../lib/models';
import { useHttpResponses } from './useHttpResponses';
import { useLatestHttpResponse } from './useLatestHttpResponse';
const usePinnedResponseIdState = createGlobalState<string | null>(null);
export function usePinnedHttpResponse(activeRequest: HttpRequest) {
const [pinnedResponseId, setPinnedResponseId] = usePinnedResponseIdState();
const latestResponse = useLatestHttpResponse(activeRequest.id);
const responses = useHttpResponses(activeRequest.id);
const activeResponse: HttpResponse | null = pinnedResponseId
? responses.find((r) => r.id === pinnedResponseId) ?? null
: latestResponse ?? null;
// Unset pinned response when a new one comes in
useEffect(() => setPinnedResponseId(null), [responses.length, setPinnedResponseId]);
const setPinnedResponse = useCallback(
(r: HttpResponse) => {
setPinnedResponseId(r.id);
},
[setPinnedResponseId],
);
return { activeResponse, setPinnedResponse, pinnedResponseId, responses } as const;
}

View File

@@ -38,7 +38,7 @@ export function useSendAnyRequest(options: { download?: boolean } = {}) {
cookieJarId: activeCookieJar?.id,
});
},
onSettled: () => trackEvent('HttpRequest', 'Send'),
onSettled: () => trackEvent('http_request', 'send'),
onError: (err) => alert({ id: 'send-failed', title: 'Send Failed', body: err }),
});
}

View File

@@ -2,30 +2,33 @@ import { invoke } from '@tauri-apps/api';
export function trackEvent(
resource:
| 'App'
| 'Dialog'
| 'CookieJar'
| 'Sidebar'
| 'Workspace'
| 'Environment'
| 'Folder'
| 'GrpcEvent'
| 'GrpcConnection'
| 'GrpcRequest'
| 'HttpRequest'
| 'HttpResponse'
| 'KeyValue',
| 'app'
| 'cookie_jar'
| 'dialog'
| 'environment'
| 'folder'
| 'grpc_connection'
| 'grpc_event'
| 'grpc_request'
| 'http_request'
| 'http_response'
| 'key_value'
| 'setting'
| 'sidebar'
| 'workspace',
action:
| 'Toggle'
| 'Show'
| 'Hide'
| 'Launch'
| 'Create'
| 'Update'
| 'Delete'
| 'DeleteMany'
| 'Send'
| 'Duplicate',
| 'cancel'
| 'create'
| 'delete'
| 'delete_many'
| 'duplicate'
| 'hide'
| 'launch'
| 'send'
| 'show'
| 'toggle'
| 'update',
attributes: Record<string, string | number> = {},
) {
invoke('cmd_track_event', {