More analytics, and cancel requests

This commit is contained in:
Gregory Schier
2024-02-24 11:30:07 -08:00
parent e75e6865ea
commit 5664d41073
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": "^18.0.31",
"@types/react-dom": "^18.0.11", "@types/react-dom": "^18.0.11",
"@types/uuid": "^9.0.1", "@types/uuid": "^9.0.1",
"@typescript-eslint/eslint-plugin": "^5.57.0", "@typescript-eslint/eslint-plugin": "^7.0.2",
"@typescript-eslint/parser": "^5.57.0", "@typescript-eslint/parser": "^7.0.2",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.13", "autoprefixer": "^10.4.13",
"eslint": "^8.34.0", "eslint": "^8.34.0",
@@ -76,7 +76,7 @@
"prettier": "^2.8.4", "prettier": "^2.8.4",
"react-devtools": "^4.27.2", "react-devtools": "^4.27.2",
"tailwindcss": "^3.2.7", "tailwindcss": "^3.2.7",
"typescript": "^5.0.2", "typescript": "^5.3.3",
"vite": "^5.1.1", "vite": "^5.1.1",
"vite-plugin-svgr": "^4.2.0", "vite-plugin-svgr": "^4.2.0",
"vite-plugin-top-level-await": "^1.4.1", "vite-plugin-top-level-await": "^1.4.1",
@@ -2631,9 +2631,9 @@
"devOptional": true "devOptional": true
}, },
"node_modules/@types/semver": { "node_modules/@types/semver": {
"version": "7.5.7", "version": "7.5.8",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.7.tgz", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz",
"integrity": "sha512-/wdoPq1QqkSj9/QOeKkFquEuPzQbHTWAMPH/PaUMB+JuR31lXhlWXRZ52IpfDYVlDOUBvX09uBrPwxGT1hjNBg==", "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==",
"dev": true "dev": true
}, },
"node_modules/@types/uuid": { "node_modules/@types/uuid": {
@@ -2653,32 +2653,33 @@
} }
}, },
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "5.62.0", "version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.0.2.tgz",
"integrity": "sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==", "integrity": "sha512-/XtVZJtbaphtdrWjr+CJclaCVGPtOdBpFEnvtNf/jRV0IiEemRrL0qABex/nEt8isYcnFacm3nPHYQwL+Wb7qg==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"@eslint-community/regexpp": "^4.4.0", "@eslint-community/regexpp": "^4.5.1",
"@typescript-eslint/scope-manager": "5.62.0", "@typescript-eslint/scope-manager": "7.0.2",
"@typescript-eslint/type-utils": "5.62.0", "@typescript-eslint/type-utils": "7.0.2",
"@typescript-eslint/utils": "5.62.0", "@typescript-eslint/utils": "7.0.2",
"@typescript-eslint/visitor-keys": "7.0.2",
"debug": "^4.3.4", "debug": "^4.3.4",
"graphemer": "^1.4.0", "graphemer": "^1.4.0",
"ignore": "^5.2.0", "ignore": "^5.2.4",
"natural-compare-lite": "^1.4.0", "natural-compare": "^1.4.0",
"semver": "^7.3.7", "semver": "^7.5.4",
"tsutils": "^3.21.0" "ts-api-utils": "^1.0.1"
}, },
"engines": { "engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0" "node": "^16.0.0 || >=18.0.0"
}, },
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
"url": "https://opencollective.com/typescript-eslint" "url": "https://opencollective.com/typescript-eslint"
}, },
"peerDependencies": { "peerDependencies": {
"@typescript-eslint/parser": "^5.0.0", "@typescript-eslint/parser": "^7.0.0",
"eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" "eslint": "^8.56.0"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"typescript": { "typescript": {
@@ -2687,25 +2688,26 @@
} }
}, },
"node_modules/@typescript-eslint/parser": { "node_modules/@typescript-eslint/parser": {
"version": "5.62.0", "version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.0.2.tgz",
"integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==", "integrity": "sha512-GdwfDglCxSmU+QTS9vhz2Sop46ebNCXpPPvsByK7hu0rFGRHL+AusKQJ7SoN+LbLh6APFpQwHKmDSwN35Z700Q==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "5.62.0", "@typescript-eslint/scope-manager": "7.0.2",
"@typescript-eslint/types": "5.62.0", "@typescript-eslint/types": "7.0.2",
"@typescript-eslint/typescript-estree": "5.62.0", "@typescript-eslint/typescript-estree": "7.0.2",
"@typescript-eslint/visitor-keys": "7.0.2",
"debug": "^4.3.4" "debug": "^4.3.4"
}, },
"engines": { "engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0" "node": "^16.0.0 || >=18.0.0"
}, },
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
"url": "https://opencollective.com/typescript-eslint" "url": "https://opencollective.com/typescript-eslint"
}, },
"peerDependencies": { "peerDependencies": {
"eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" "eslint": "^8.56.0"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"typescript": { "typescript": {
@@ -2714,16 +2716,16 @@
} }
}, },
"node_modules/@typescript-eslint/scope-manager": { "node_modules/@typescript-eslint/scope-manager": {
"version": "5.62.0", "version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.0.2.tgz",
"integrity": "sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==", "integrity": "sha512-l6sa2jF3h+qgN2qUMjVR3uCNGjWw4ahGfzIYsCtFrQJCjhbrDPdiihYT8FnnqFwsWX+20hK592yX9I2rxKTP4g==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"@typescript-eslint/types": "5.62.0", "@typescript-eslint/types": "7.0.2",
"@typescript-eslint/visitor-keys": "5.62.0" "@typescript-eslint/visitor-keys": "7.0.2"
}, },
"engines": { "engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0" "node": "^16.0.0 || >=18.0.0"
}, },
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
@@ -2731,25 +2733,25 @@
} }
}, },
"node_modules/@typescript-eslint/type-utils": { "node_modules/@typescript-eslint/type-utils": {
"version": "5.62.0", "version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.62.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.0.2.tgz",
"integrity": "sha512-xsSQreu+VnfbqQpW5vnCJdq1Z3Q0U31qiWmRhr98ONQmcp/yhiPJFPq8MXiJVLiksmOKSjIldZzkebzHuCGzew==", "integrity": "sha512-IKKDcFsKAYlk8Rs4wiFfEwJTQlHcdn8CLwLaxwd6zb8HNiMcQIFX9sWax2k4Cjj7l7mGS5N1zl7RCHOVwHq2VQ==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"@typescript-eslint/typescript-estree": "5.62.0", "@typescript-eslint/typescript-estree": "7.0.2",
"@typescript-eslint/utils": "5.62.0", "@typescript-eslint/utils": "7.0.2",
"debug": "^4.3.4", "debug": "^4.3.4",
"tsutils": "^3.21.0" "ts-api-utils": "^1.0.1"
}, },
"engines": { "engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0" "node": "^16.0.0 || >=18.0.0"
}, },
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
"url": "https://opencollective.com/typescript-eslint" "url": "https://opencollective.com/typescript-eslint"
}, },
"peerDependencies": { "peerDependencies": {
"eslint": "*" "eslint": "^8.56.0"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"typescript": { "typescript": {
@@ -2758,12 +2760,12 @@
} }
}, },
"node_modules/@typescript-eslint/types": { "node_modules/@typescript-eslint/types": {
"version": "5.62.0", "version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.62.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.0.2.tgz",
"integrity": "sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==", "integrity": "sha512-ZzcCQHj4JaXFjdOql6adYV4B/oFOFjPOC9XYwCaZFRvqN8Llfvv4gSxrkQkd2u4Ci62i2c6W6gkDwQJDaRc4nA==",
"dev": true, "dev": true,
"engines": { "engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0" "node": "^16.0.0 || >=18.0.0"
}, },
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
@@ -2771,21 +2773,22 @@
} }
}, },
"node_modules/@typescript-eslint/typescript-estree": { "node_modules/@typescript-eslint/typescript-estree": {
"version": "5.62.0", "version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.0.2.tgz",
"integrity": "sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==", "integrity": "sha512-3AMc8khTcELFWcKcPc0xiLviEvvfzATpdPj/DXuOGIdQIIFybf4DMT1vKRbuAEOFMwhWt7NFLXRkbjsvKZQyvw==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"@typescript-eslint/types": "5.62.0", "@typescript-eslint/types": "7.0.2",
"@typescript-eslint/visitor-keys": "5.62.0", "@typescript-eslint/visitor-keys": "7.0.2",
"debug": "^4.3.4", "debug": "^4.3.4",
"globby": "^11.1.0", "globby": "^11.1.0",
"is-glob": "^4.0.3", "is-glob": "^4.0.3",
"semver": "^7.3.7", "minimatch": "9.0.3",
"tsutils": "^3.21.0" "semver": "^7.5.4",
"ts-api-utils": "^1.0.1"
}, },
"engines": { "engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0" "node": "^16.0.0 || >=18.0.0"
}, },
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
@@ -2797,43 +2800,66 @@
} }
} }
}, },
"node_modules/@typescript-eslint/utils": { "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "5.62.0", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.62.0.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ==", "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.2.0", "balanced-match": "^1.0.0"
"@types/json-schema": "^7.0.9", }
"@types/semver": "^7.3.12", },
"@typescript-eslint/scope-manager": "5.62.0", "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
"@typescript-eslint/types": "5.62.0", "version": "9.0.3",
"@typescript-eslint/typescript-estree": "5.62.0", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
"eslint-scope": "^5.1.1", "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
"semver": "^7.3.7" "dev": true,
"dependencies": {
"brace-expansion": "^2.0.1"
}, },
"engines": { "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": { "funding": {
"type": "opencollective", "type": "opencollective",
"url": "https://opencollective.com/typescript-eslint" "url": "https://opencollective.com/typescript-eslint"
}, },
"peerDependencies": { "peerDependencies": {
"eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" "eslint": "^8.56.0"
} }
}, },
"node_modules/@typescript-eslint/visitor-keys": { "node_modules/@typescript-eslint/visitor-keys": {
"version": "5.62.0", "version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.0.2.tgz",
"integrity": "sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==", "integrity": "sha512-8Y+YiBmqPighbm5xA2k4wKTxRzx9EkBu7Rlw+WHqMvRJ3RPz/BMBO9b2ru0LUNmXg120PHUXD5+SWFy2R8DqlQ==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"@typescript-eslint/types": "5.62.0", "@typescript-eslint/types": "7.0.2",
"eslint-visitor-keys": "^3.3.0" "eslint-visitor-keys": "^3.4.1"
}, },
"engines": { "engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0" "node": "^16.0.0 || >=18.0.0"
}, },
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
@@ -5051,28 +5077,6 @@
"semver": "bin/semver.js" "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": { "node_modules/eslint-visitor-keys": {
"version": "3.4.3", "version": "3.4.3",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
@@ -6165,9 +6169,9 @@
} }
}, },
"node_modules/ip": { "node_modules/ip": {
"version": "1.1.8", "version": "1.1.9",
"resolved": "https://registry.npmjs.org/ip/-/ip-1.1.8.tgz", "resolved": "https://registry.npmjs.org/ip/-/ip-1.1.9.tgz",
"integrity": "sha512-PuExPYUiu6qMBQb4l06ecm6T6ujzhmh+MeJcW9wa89PoAz5pvd4zPgN5WJV104mb6S2T1AwNIAaB70JNrLQWhg==", "integrity": "sha512-cyRxvOEpNHNtchU3Ln9KC/auJgup87llfQpQ+t5ghoC/UhL16SWzbueiCsdTnWmqAWl7LadfuwhlqmtOaqMHdQ==",
"dev": true "dev": true
}, },
"node_modules/is-array-buffer": { "node_modules/is-array-buffer": {
@@ -7329,12 +7333,6 @@
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
"dev": true "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": { "node_modules/nearley": {
"version": "2.20.1", "version": "2.20.1",
"resolved": "https://registry.npmjs.org/nearley/-/nearley-2.20.1.tgz", "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", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" "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": { "node_modules/ts-easing": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/ts-easing/-/ts-easing-0.2.0.tgz", "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", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" "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": { "node_modules/type-check": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "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": "^18.0.31",
"@types/react-dom": "^18.0.11", "@types/react-dom": "^18.0.11",
"@types/uuid": "^9.0.1", "@types/uuid": "^9.0.1",
"@typescript-eslint/eslint-plugin": "^5.57.0", "@typescript-eslint/eslint-plugin": "^7.0.2",
"@typescript-eslint/parser": "^5.57.0", "@typescript-eslint/parser": "^7.0.2",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.13", "autoprefixer": "^10.4.13",
"eslint": "^8.34.0", "eslint": "^8.34.0",
@@ -93,7 +93,7 @@
"prettier": "^2.8.4", "prettier": "^2.8.4",
"react-devtools": "^4.27.2", "react-devtools": "^4.27.2",
"tailwindcss": "^3.2.7", "tailwindcss": "^3.2.7",
"typescript": "^5.0.2", "typescript": "^5.3.3",
"vite": "^5.1.1", "vite": "^5.1.1",
"vite-plugin-svgr": "^4.2.0", "vite-plugin-svgr": "^4.2.0",
"vite-plugin-top-level-await": "^1.4.1", "vite-plugin-top-level-await": "^1.4.1",

View File

@@ -1,3 +1,4 @@
use std::fmt::Display;
use log::{debug, warn}; use log::{debug, warn};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
@@ -7,7 +8,8 @@ use tauri::{AppHandle, Manager};
use crate::{is_dev, models}; use crate::{is_dev, models};
// serializable // serializable
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "snake_case")]
pub enum AnalyticsResource { pub enum AnalyticsResource {
App, App,
CookieJar, CookieJar,
@@ -25,28 +27,21 @@ pub enum AnalyticsResource {
} }
impl AnalyticsResource { impl AnalyticsResource {
pub fn from_str(s: &str) -> Option<AnalyticsResource> { pub fn from_str(s: &str) -> serde_json::Result<AnalyticsResource> {
match s { return serde_json::from_str(format!("\"{s}\"").as_str());
"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,
}
} }
} }
#[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 { pub enum AnalyticsAction {
Cancel,
Create, Create,
Delete, Delete,
DeleteMany, DeleteMany,
@@ -65,63 +60,14 @@ pub enum AnalyticsAction {
} }
impl AnalyticsAction { impl AnalyticsAction {
pub fn from_str(s: &str) -> Option<AnalyticsAction> { pub fn from_str(s: &str) -> serde_json::Result<AnalyticsAction> {
match s { return serde_json::from_str(format!("\"{s}\"").as_str());
"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,
}
} }
} }
fn resource_name(resource: AnalyticsResource) -> &'static str { impl Display for AnalyticsAction {
match resource { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
AnalyticsResource::App => "app", write!(f, "{}", serde_json::to_string(self).unwrap().replace("\"", ""))
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",
} }
} }
@@ -195,7 +141,7 @@ pub async fn track_event(
action: AnalyticsAction, action: AnalyticsAction,
attributes: Option<JsonValue>, 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 attributes_json = attributes.unwrap_or("{}".to_string().into()).to_string();
let info = app_handle.package_info(); let info = app_handle.package_info();
let tz = datetime::sys_timezone().unwrap_or("unknown".to_string()); 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 std::time::Duration;
use base64::Engine; use base64::Engine;
use http::header::{ACCEPT, USER_AGENT};
use http::{HeaderMap, HeaderName, HeaderValue, Method}; use http::{HeaderMap, HeaderName, HeaderValue, Method};
use http::header::{ACCEPT, USER_AGENT};
use log::{error, info, warn}; use log::{error, info, warn};
use reqwest::redirect::Policy;
use reqwest::{multipart, Url}; use reqwest::{multipart, Url};
use reqwest::redirect::Policy;
use sqlx::types::{Json, JsonValue}; use sqlx::types::{Json, JsonValue};
use tauri::{Manager, Window}; use tauri::{Manager, Window};
use tokio::sync::oneshot;
use tokio::sync::watch::{Receiver};
use crate::{models, render, response_err}; use crate::{models, render, response_err};
@@ -24,6 +26,7 @@ pub async fn send_http_request(
environment: Option<models::Environment>, environment: Option<models::Environment>,
cookie_jar: Option<models::CookieJar>, cookie_jar: Option<models::CookieJar>,
download_path: Option<PathBuf>, download_path: Option<PathBuf>,
cancel_rx: &mut Receiver<bool>,
) -> Result<models::HttpResponse, String> { ) -> Result<models::HttpResponse, String> {
let environment_ref = environment.as_ref(); let environment_ref = environment.as_ref();
let workspace = models::get_workspace(window, &request.workspace_id) 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 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 { match raw_response {
Ok(v) => { Ok(v) => {

View File

@@ -10,40 +10,54 @@ extern crate objc;
use std::collections::HashMap; use std::collections::HashMap;
use std::env::current_dir; 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::path::PathBuf;
use std::process::exit; use std::process::exit;
use std::str::FromStr; use std::str::FromStr;
use ::http::uri::InvalidUri;
use ::http::Uri; use ::http::Uri;
use ::http::uri::InvalidUri;
use base64::Engine; use base64::Engine;
use fern::colors::ColoredLevelConfig; use fern::colors::ColoredLevelConfig;
use log::{debug, error, info, warn}; use log::{debug, error, info, warn};
use rand::random; use rand::random;
use serde_json::{json, Value}; use serde_json::{json, Value};
use sqlx::{Pool, Sqlite, SqlitePool};
use sqlx::migrate::Migrator; use sqlx::migrate::Migrator;
use sqlx::types::Json; 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::{AppHandle, RunEvent, State, Window, WindowUrl};
use tauri::{Manager, WindowEvent}; use tauri::{Manager, WindowEvent};
#[cfg(target_os = "macos")]
use tauri::TitleBarStyle;
use tauri_plugin_log::{fern, LogTarget}; use tauri_plugin_log::{fern, LogTarget};
use tauri_plugin_window_state::{StateFlags, WindowExt}; use tauri_plugin_window_state::{StateFlags, WindowExt};
use tokio::sync::Mutex; use tokio::sync::{Mutex};
use tokio::time::sleep; use tokio::time::sleep;
use window_shadows::set_shadow; use window_shadows::set_shadow;
use ::grpc::{Code, deserialize_message, serialize_message, ServiceDefinition};
use ::grpc::manager::{DynamicMessage, GrpcHandle}; use ::grpc::manager::{DynamicMessage, GrpcHandle};
use ::grpc::{deserialize_message, serialize_message, Code, ServiceDefinition};
use window_ext::TrafficLightWindowExt; use window_ext::TrafficLightWindowExt;
use crate::analytics::{AnalyticsAction, AnalyticsResource}; use crate::analytics::{AnalyticsAction, AnalyticsResource};
use crate::grpc::metadata_to_map; use crate::grpc::metadata_to_map;
use crate::http::send_http_request; 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::models::{
use crate::plugin::{ImportResult}; 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}; use crate::updates::{update_mode_from_str, UpdateMode, YaakUpdater};
mod analytics; mod analytics;
@@ -640,8 +654,21 @@ async fn cmd_send_ephemeral_request(
None => None, None => None,
}; };
// let cookie_jar_id2 = cookie_jar_id.unwrap_or("").to_string(); let (cancel_tx, mut cancel_rx) = tokio::sync::watch::channel(false);
send_http_request(&window, request, &response, environment, cookie_jar, None).await 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] #[tauri::command]
@@ -677,7 +704,10 @@ async fn cmd_filter_response(w: Window, response_id: &str, filter: &str) -> Resu
} }
#[tauri::command] #[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 mut result: Option<ImportResult> = None;
let plugins = vec!["importer-yaak", "importer-insomnia", "importer-postman"]; let plugins = vec!["importer-yaak", "importer-insomnia", "importer-postman"];
for plugin_name in plugins { for plugin_name in plugins {
@@ -778,19 +808,19 @@ async fn cmd_export_data(
#[tauri::command] #[tauri::command]
async fn cmd_send_http_request( async fn cmd_send_http_request(
w: Window, window: Window,
request_id: &str, request_id: &str,
environment_id: Option<&str>, environment_id: Option<&str>,
cookie_jar_id: Option<&str>, cookie_jar_id: Option<&str>,
download_dir: Option<&str>, download_dir: Option<&str>,
) -> Result<HttpResponse, String> { ) -> Result<HttpResponse, String> {
let request = get_http_request(&w, request_id) let request = get_http_request(&window, request_id)
.await .await
.expect("Failed to get request"); .expect("Failed to get request");
let environment = match environment_id { let environment = match environment_id {
Some(id) => Some( Some(id) => Some(
get_environment(&w, id) get_environment(&window, id)
.await .await
.expect("Failed to get environment"), .expect("Failed to get environment"),
), ),
@@ -799,7 +829,7 @@ async fn cmd_send_http_request(
let cookie_jar = match cookie_jar_id { let cookie_jar = match cookie_jar_id {
Some(id) => Some( Some(id) => Some(
get_cookie_jar(&w, id) get_cookie_jar(&window, id)
.await .await
.expect("Failed to get cookie jar"), .expect("Failed to get cookie jar"),
), ),
@@ -807,7 +837,7 @@ async fn cmd_send_http_request(
}; };
let response = create_http_response( let response = create_http_response(
&w, &window,
&request.id, &request.id,
0, 0,
0, 0,
@@ -829,13 +859,19 @@ async fn cmd_send_http_request(
None 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( send_http_request(
&w, &window,
request.clone(), request.clone(),
&response, &response,
environment, environment,
cookie_jar, cookie_jar,
download_path, download_path,
&mut cancel_rx,
) )
.await .await
} }
@@ -865,11 +901,13 @@ async fn cmd_track_event(
AnalyticsResource::from_str(resource), AnalyticsResource::from_str(resource),
AnalyticsAction::from_str(action), AnalyticsAction::from_str(action),
) { ) {
(Some(resource), Some(action)) => { (Ok(resource), Ok(action)) => {
analytics::track_event(&window.app_handle(), resource, action, attributes).await; analytics::track_event(&window.app_handle(), resource, action, attributes).await;
} }
_ => { (r, a) => {
error!("Invalid action/resource for track_event: {action} {resource}"); 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()); return Err("Invalid event".to_string());
} }
}; };

View File

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

View File

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

View File

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

View File

@@ -141,74 +141,56 @@ function EventRow({
}) { }) {
const { eventType, status, createdAt, content, error } = event; const { eventType, status, createdAt, content, error } = event;
return ( return (
<button <div className="px-1">
onClick={onClick} <button
className={classNames( onClick={onClick}
'w-full grid grid-cols-[auto_minmax(0,3fr)_auto] gap-2 items-center text-left', className={classNames(
'px-1 py-1 font-mono cursor-default group focus:outline-none', 'w-full grid grid-cols-[auto_minmax(0,3fr)_auto] gap-2 items-center text-left',
isActive && '!bg-highlight text-gray-900', 'px-1.5 py-1 font-mono cursor-default group focus:outline-none rounded',
'text-gray-800 hover:text-gray-900', isActive && '!bg-highlight text-gray-900',
)} 'text-gray-800 hover:text-gray-900',
> )}
<Icon >
className={ <Icon
eventType === 'server_message' className={
? 'text-blue-600' eventType === 'server_message'
: eventType === 'client_message' ? 'text-blue-600'
? 'text-violet-600' : eventType === 'client_message'
: eventType === 'error' || (status != null && status > 0) ? 'text-violet-600'
? 'text-orange-600' : eventType === 'error' || (status != null && status > 0)
: eventType === 'connection_end' ? 'text-orange-600'
? 'text-green-600' : eventType === 'connection_end'
: 'text-gray-700' ? 'text-green-600'
} : 'text-gray-700'
title={ }
eventType === 'server_message' title={
? 'Server message' eventType === 'server_message'
: eventType === 'client_message' ? 'Server message'
? 'Client message' : eventType === 'client_message'
: eventType === 'error' || (status != null && status > 0) ? 'Client message'
? 'Error' : eventType === 'error' || (status != null && status > 0)
: eventType === 'connection_end' ? 'Error'
? 'Connection response' : eventType === 'connection_end'
: undefined ? 'Connection response'
} : undefined
icon={ }
eventType === 'server_message' icon={
? 'arrowBigDownDash' eventType === 'server_message'
: eventType === 'client_message' ? 'arrowBigDownDash'
? 'arrowBigUpDash' : eventType === 'client_message'
: eventType === 'error' || (status != null && status > 0) ? 'arrowBigUpDash'
? 'alert' : eventType === 'error' || (status != null && status > 0)
: eventType === 'connection_end' ? 'alert'
? 'check' : eventType === 'connection_end'
: 'info' ? 'check'
} : 'info'
/> }
<div className={classNames('w-full truncate text-2xs')}>{error ?? content}</div> />
<div className={classNames('opacity-50 text-2xs')}> <div className={classNames('w-full truncate text-2xs')}>{error ?? content}</div>
{format(createdAt + 'Z', 'HH:mm:ss.SSS')} <div className={classNames('opacity-50 text-2xs')}>
</div> {format(createdAt + 'Z', 'HH:mm:ss.SSS')}
</button> </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 useResizeObserver from '@react-hook/resize-observer';
import classNames from 'classnames'; import classNames from 'classnames';
import type { CSSProperties, FormEvent } from 'react'; import type { CSSProperties } from 'react';
import React, { useCallback, useMemo, useRef, useState } from 'react'; import React, { useCallback, useMemo, useRef, useState } from 'react';
import { createGlobalState } from 'react-use'; import { createGlobalState } from 'react-use';
import type { ReflectResponseService } from '../hooks/useGrpc'; import type { ReflectResponseService } from '../hooks/useGrpc';
@@ -104,22 +104,18 @@ export function GrpcConnectionSetupPane({
[updateRequest], [updateRequest],
); );
const handleConnect = useCallback( const handleConnect = useCallback(async () => {
async (e: FormEvent) => { if (activeRequest == null) return;
e.preventDefault();
if (activeRequest == null) return;
if (activeRequest.service == null || activeRequest.method == null) { if (activeRequest.service == null || activeRequest.method == null) {
alert({ alert({
id: 'grpc-invalid-service-method', id: 'grpc-invalid-service-method',
title: 'Error', title: 'Error',
body: 'Service or method not selected', body: 'Service or method not selected',
}); });
} }
onGo(); onGo();
}, }, [activeRequest, onGo]);
[activeRequest, onGo],
);
const tabs: TabItem[] = useMemo( const tabs: TabItem[] = useMemo(
() => [ () => [
@@ -176,9 +172,10 @@ export function GrpcConnectionSetupPane({
submitIcon={null} submitIcon={null}
forceUpdateKey={forceUpdateKey} forceUpdateKey={forceUpdateKey}
placeholder="localhost:50051" placeholder="localhost:50051"
onSubmit={handleConnect} onSend={handleConnect}
onUrlChange={handleChangeUrl} onUrlChange={handleChangeUrl}
isLoading={false} onCancel={onCancel}
isLoading={isStreaming}
/> />
<HStack space={1.5}> <HStack space={1.5}>
<RadioDropdown <RadioDropdown

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import classNames from 'classnames'; import classNames from 'classnames';
import type { HTMLAttributes, ReactNode } from 'react'; 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 type { HotkeyAction } from '../../hooks/useHotKey';
import { useFormattedHotkey, useHotKey } from '../../hooks/useHotKey'; import { useFormattedHotkey, useHotKey } from '../../hooks/useHotKey';
import { Icon } from './Icon'; import { Icon } from './Icon';

View File

@@ -1,6 +1,5 @@
import { defaultKeymap } from '@codemirror/commands'; import { defaultKeymap } from '@codemirror/commands';
import { Compartment, EditorState, Transaction } from '@codemirror/state'; import { Compartment, EditorState } from '@codemirror/state';
import type { ViewUpdate } from '@codemirror/view';
import { keymap, placeholder as placeholderExt, tooltips } from '@codemirror/view'; import { keymap, placeholder as placeholderExt, tooltips } from '@codemirror/view';
import classNames from 'classnames'; import classNames from 'classnames';
import { EditorView } from 'codemirror'; import { EditorView } from 'codemirror';
@@ -148,14 +147,6 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
view.dispatch({ effects: languageCompartment.reconfigure(ext) }); view.dispatch({ effects: languageCompartment.reconfigure(ext) });
}, [contentType, autocomplete, useTemplating, environment, workspace]); }, [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 classList = className?.split(/\s+/) ?? [];
const bgClassList = classList const bgClassList = classList
.filter((c) => c.match(/(^|:)?bg-.+/)) // Find bg-* classes .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 .map((c) => c.replace(/^dark:bg-/, 'dark:!bg-')); // !important
// Initialize the editor when ref mounts // Initialize the editor when ref mounts
const initEditorRef = useCallback((container: HTMLDivElement | null) => { const initEditorRef = useCallback(
if (container === null) { (container: HTMLDivElement | null) => {
cm.current?.view.destroy(); if (container === null) {
cm.current = null; cm.current?.view.destroy();
return; 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();
} }
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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); [forceUpdateKey],
);
// Add bg classes to actions, so they appear over the text // Add bg classes to actions, so they appear over the text
const decoratedActions = useMemo(() => { const decoratedActions = useMemo(() => {
@@ -340,29 +333,13 @@ function getExtensions({
// Handle onChange // Handle onChange
EditorView.updateListener.of((update) => { 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 if (onChange && update.docChanged) {
// changing pages (one request to another in header editor)
if (onChange && update.docChanged && isViewUpdateFromUserInput(update)) {
onChange.current?.(update.state.doc.toString()); 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 = ({ const syncGutterBg = ({
parent, parent,
bgClassList, bgClassList,

View File

@@ -11,7 +11,7 @@ interface Props {
export const HotKeyList = ({ hotkeys, bottomSlot }: Props) => { export const HotKeyList = ({ hotkeys, bottomSlot }: Props) => {
return ( 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}> <VStack space={2}>
{hotkeys.map((hotkey) => ( {hotkeys.map((hotkey) => (
<HStack key={hotkey} className="grid grid-cols-2"> <HStack key={hotkey} className="grid grid-cols-2">

View File

@@ -1,6 +1,24 @@
import classNames from 'classnames'; import classNames from 'classnames';
import type { ReactNode } from 'react'; import type { HTMLAttributes, ReactElement, ReactNode } from 'react';
import { HStack } from './Stacks';
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 { interface Props {
label: ReactNode; label: ReactNode;
@@ -8,17 +26,13 @@ interface Props {
labelClassName?: string; 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) { export function KeyValueRow({ label, value, labelClassName }: Props) {
return ( 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} {label}
</dd> </td>
<dt className="w-2/3 select-text cursor-text break-all">{value}</dt> <td className="py-1 cursor-text select-text break-all min-w-0">{value}</td>
</HStack> </>
); );
} }

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 }); return invoke('cmd_create_cookie_jar', { workspaceId, name });
}, },
onSettled: () => trackEvent('CookieJar', 'Create'), onSettled: () => trackEvent('cookie_jar', 'create'),
onSuccess: async (cookieJar) => { onSuccess: async (cookieJar) => {
queryClient.setQueryData<CookieJar[]>( queryClient.setQueryData<CookieJar[]>(
cookieJarsQueryKey({ workspaceId: cookieJar.workspaceId }), cookieJarsQueryKey({ workspaceId: cookieJar.workspaceId }),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -30,7 +30,7 @@ export function useDeleteWorkspace(workspace: Workspace | null) {
if (!confirmed) return null; if (!confirmed) return null;
return invoke('cmd_delete_workspace', { workspaceId: workspace?.id }); return invoke('cmd_delete_workspace', { workspaceId: workspace?.id });
}, },
onSettled: () => trackEvent('Workspace', 'Delete'), onSettled: () => trackEvent('workspace', 'delete'),
onSuccess: async (workspace) => { onSuccess: async (workspace) => {
if (workspace === null) return; 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"); if (id === null) throw new Error("Can't duplicate a null grpc request");
return invoke('cmd_duplicate_grpc_request', { id }); return invoke('cmd_duplicate_grpc_request', { id });
}, },
onSettled: () => trackEvent('GrpcRequest', 'Duplicate'), onSettled: () => trackEvent('grpc_request', 'duplicate'),
onSuccess: async (request) => { onSuccess: async (request) => {
if (navigateAfter && activeWorkspaceId !== null) { if (navigateAfter && activeWorkspaceId !== null) {
routes.navigate('request', { routes.navigate('request', {

View File

@@ -21,7 +21,7 @@ export function useDuplicateHttpRequest({
if (id === null) throw new Error("Can't duplicate a null request"); if (id === null) throw new Error("Can't duplicate a null request");
return invoke('cmd_duplicate_http_request', { id }); return invoke('cmd_duplicate_http_request', { id });
}, },
onSettled: () => trackEvent('HttpRequest', 'Duplicate'), onSettled: () => trackEvent('http_request', 'duplicate'),
onSuccess: async (request) => { onSuccess: async (request) => {
if (navigateAfter && activeWorkspaceId !== null) { if (navigateAfter && activeWorkspaceId !== null) {
routes.navigate('request', { 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, cookieJarId: activeCookieJar?.id,
}); });
}, },
onSettled: () => trackEvent('HttpRequest', 'Send'), onSettled: () => trackEvent('http_request', 'send'),
onError: (err) => alert({ id: 'send-failed', title: 'Send Failed', body: err }), 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( export function trackEvent(
resource: resource:
| 'App' | 'app'
| 'Dialog' | 'cookie_jar'
| 'CookieJar' | 'dialog'
| 'Sidebar' | 'environment'
| 'Workspace' | 'folder'
| 'Environment' | 'grpc_connection'
| 'Folder' | 'grpc_event'
| 'GrpcEvent' | 'grpc_request'
| 'GrpcConnection' | 'http_request'
| 'GrpcRequest' | 'http_response'
| 'HttpRequest' | 'key_value'
| 'HttpResponse' | 'setting'
| 'KeyValue', | 'sidebar'
| 'workspace',
action: action:
| 'Toggle' | 'cancel'
| 'Show' | 'create'
| 'Hide' | 'delete'
| 'Launch' | 'delete_many'
| 'Create' | 'duplicate'
| 'Update' | 'hide'
| 'Delete' | 'launch'
| 'DeleteMany' | 'send'
| 'Send' | 'show'
| 'Duplicate', | 'toggle'
| 'update',
attributes: Record<string, string | number> = {}, attributes: Record<string, string | number> = {},
) { ) {
invoke('cmd_track_event', { invoke('cmd_track_event', {