Compare commits

...

18 Commits

Author SHA1 Message Date
Gregory Schier
3539642491 Bump beta version 2024-01-14 20:30:25 -08:00
Gregory Schier
08abea6a6f fix mac decorations 2024-01-14 17:22:31 -08:00
Gregory Schier
0045b85f00 Integrated titlebar windows 2024-01-14 16:44:04 -08:00
Gregory Schier
4b34c3d101 Further titlebar tweaks 2024-01-14 12:02:44 -08:00
Gregory Schier
4af0a15d9f Better titlebar control icons 2024-01-14 11:56:21 -08:00
Gregory Schier
3a4a76c58d Basic Linux/Windows integrated titlebar 2024-01-13 23:40:32 -08:00
Gregory Schier
3086d815c1 Fix hotkey formatting 2024-01-12 22:12:01 -08:00
Gregory Schier
a48a9eab4a beta tag 2024-01-12 22:00:55 -08:00
Gregory Schier
48664c66e5 fix appearance init 2024-01-12 21:59:46 -08:00
Gregory Schier
7aee5176a9 Vendor Openssl 2024-01-12 21:03:28 -08:00
Gregory Schier
0da68ced18 Hotkeys for request switcher 2024-01-12 21:03:20 -08:00
Gregory Schier
39f7d9c113 Appearance setting and gzip/etc support 2024-01-12 13:39:08 -08:00
Gregory Schier
138943bfb6 Initial settings implementation 2024-01-11 21:13:17 -08:00
Gregory Schier
c1c9f882a6 Dropdown manages hotkeys now 2024-01-11 10:18:05 -08:00
Gregory Schier
1bcf26f656 Hotkey for keyboard shortcut help 2024-01-10 22:05:16 -08:00
Gregory Schier
7c2466da5e Bump version number 2024-01-10 16:25:55 -08:00
Gregory Schier
7dc78a1f6f Add hotkey dialog and rust-only analytics 2024-01-10 16:18:08 -08:00
Gregory Schier
88d024023b Fix beta icon 2024-01-08 17:07:42 -08:00
62 changed files with 1253 additions and 558 deletions

View File

@@ -5,17 +5,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Yaak App</title>
<!-- <script src="http://localhost:8097"></script>-->
<style>
body {
background-color: white;
}
@media (prefers-color-scheme: dark) {
body {
background-color: black;
}
}
</style>
</head>
<body>

110
package-lock.json generated
View File

@@ -24,7 +24,7 @@
"@tanstack/react-query": "^4.28.0",
"@tanstack/react-query-devtools": "^4.28.0",
"@tanstack/react-query-persist-client": "^4.28.0",
"@tauri-apps/api": "^1.5.1",
"@tauri-apps/api": "^1.5.3",
"buffer": "^6.0.3",
"classnames": "^2.3.2",
"cm6-graphql": "^0.0.9",
@@ -47,7 +47,7 @@
},
"devDependencies": {
"@tailwindcss/nesting": "^0.0.0-insiders.565cd3e",
"@tauri-apps/cli": "^1.5.4",
"@tauri-apps/cli": "^1.5.6",
"@types/node": "^18.7.10",
"@types/papaparse": "^5.3.7",
"@types/parse-color": "^1.0.1",
@@ -1934,9 +1934,9 @@
}
},
"node_modules/@tauri-apps/api": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-1.5.1.tgz",
"integrity": "sha512-6unsZDOdlXTmauU3NhWhn+Cx0rODV+rvNvTdvolE5Kls5ybA6cqndQENDt1+FS0tF7ozCP66jwWoH6a5h90BrA==",
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-1.5.3.tgz",
"integrity": "sha512-zxnDjHHKjOsrIzZm6nO5Xapb/BxqUq1tc7cGkFXsFkGTsSWgCPH1D8mm0XS9weJY2OaR73I3k3S+b7eSzJDfqA==",
"engines": {
"node": ">= 14.6.0",
"npm": ">= 6.6.0",
@@ -1948,9 +1948,9 @@
}
},
"node_modules/@tauri-apps/cli": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-1.5.6.tgz",
"integrity": "sha512-k4Y19oVCnt7WZb2TnDzLqfs7o98Jq0tUoVMv+JQSzuRDJqaVu2xMBZ8dYplEn+EccdR5SOMyzaLBJWu38TVK1A==",
"version": "1.5.9",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-1.5.9.tgz",
"integrity": "sha512-knSt/9AvCTeyfC6wkyeouF9hBW/0Mzuw+5vBKEvzaGPQsfFJo1ZCp5FkdiZpGBBfnm09BhugasGRTGofzatfqQ==",
"dev": true,
"bin": {
"tauri": "tauri.js"
@@ -1963,22 +1963,22 @@
"url": "https://opencollective.com/tauri"
},
"optionalDependencies": {
"@tauri-apps/cli-darwin-arm64": "1.5.6",
"@tauri-apps/cli-darwin-x64": "1.5.6",
"@tauri-apps/cli-linux-arm-gnueabihf": "1.5.6",
"@tauri-apps/cli-linux-arm64-gnu": "1.5.6",
"@tauri-apps/cli-linux-arm64-musl": "1.5.6",
"@tauri-apps/cli-linux-x64-gnu": "1.5.6",
"@tauri-apps/cli-linux-x64-musl": "1.5.6",
"@tauri-apps/cli-win32-arm64-msvc": "1.5.6",
"@tauri-apps/cli-win32-ia32-msvc": "1.5.6",
"@tauri-apps/cli-win32-x64-msvc": "1.5.6"
"@tauri-apps/cli-darwin-arm64": "1.5.9",
"@tauri-apps/cli-darwin-x64": "1.5.9",
"@tauri-apps/cli-linux-arm-gnueabihf": "1.5.9",
"@tauri-apps/cli-linux-arm64-gnu": "1.5.9",
"@tauri-apps/cli-linux-arm64-musl": "1.5.9",
"@tauri-apps/cli-linux-x64-gnu": "1.5.9",
"@tauri-apps/cli-linux-x64-musl": "1.5.9",
"@tauri-apps/cli-win32-arm64-msvc": "1.5.9",
"@tauri-apps/cli-win32-ia32-msvc": "1.5.9",
"@tauri-apps/cli-win32-x64-msvc": "1.5.9"
}
},
"node_modules/@tauri-apps/cli-darwin-arm64": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-1.5.6.tgz",
"integrity": "sha512-NNvG3XLtciCMsBahbDNUEvq184VZmOveTGOuy0So2R33b/6FDkuWaSgWZsR1mISpOuP034htQYW0VITCLelfqg==",
"version": "1.5.9",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-1.5.9.tgz",
"integrity": "sha512-7C2Jf8f0gzv778mLYb7Eszqqv1bm9Wzews81MRTqKrUIcC+eZEtDXLex+JaEkEzFEUrgIafdOvMBVEavF030IA==",
"cpu": [
"arm64"
],
@@ -1992,9 +1992,9 @@
}
},
"node_modules/@tauri-apps/cli-darwin-x64": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-1.5.6.tgz",
"integrity": "sha512-nkiqmtUQw3N1j4WoVjv81q6zWuZFhBLya/RNGUL94oafORloOZoSY0uTZJAoeieb3Y1YK0rCHSDl02MyV2Fi4A==",
"version": "1.5.9",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-1.5.9.tgz",
"integrity": "sha512-LHKytpkofPYgH8RShWvwDa3hD1ws131x7g7zNasJPfOiCWLqYVQFUuQVmjEUt8+dpHe/P/err5h4z+YZru2d0A==",
"cpu": [
"x64"
],
@@ -2008,9 +2008,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-1.5.6.tgz",
"integrity": "sha512-z6SPx+axZexmWXTIVPNs4Tg7FtvdJl9EKxYN6JPjOmDZcqA13iyqWBQal2DA/GMZ1Xqo3vyJf6EoEaKaliymPQ==",
"version": "1.5.9",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-1.5.9.tgz",
"integrity": "sha512-teGK20IYKx+dVn8wFq/Lg57Q9ce7foq1KHSfyHi464LVt1T0V1rsmULSgZpQPPj/NYPF5BG78PcWYv64yH86jw==",
"cpu": [
"arm"
],
@@ -2024,9 +2024,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-1.5.6.tgz",
"integrity": "sha512-QuQjMQmpsCbzBrmtQiG4uhnfAbdFx3nzm+9LtqjuZlurc12+Mj5MTgqQ3AOwQedH3f7C+KlvbqD2AdXpwTg7VA==",
"version": "1.5.9",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-1.5.9.tgz",
"integrity": "sha512-onJ/DW5Crw38qVx+wquY4uBbfCxVhzhdJmlCYqnYyXsZZmSiPUfSyhV58y+5TYB0q1hG8eYdB5x8VAwzByhGzw==",
"cpu": [
"arm64"
],
@@ -2040,9 +2040,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-1.5.6.tgz",
"integrity": "sha512-8j5dH3odweFeom7bRGlfzDApWVOT4jIq8/214Wl+JeiNVehouIBo9lZGeghZBH3XKFRwEvU23i7sRVjuh2s8mg==",
"version": "1.5.9",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-1.5.9.tgz",
"integrity": "sha512-23AYoLD3acakLp9NtheKQDJl8F66eTOflxoPzdJNRy13hUSxb+W9qpz4rRA+CIzkjICFvO2i3UWjeV9QwDVpsQ==",
"cpu": [
"arm64"
],
@@ -2056,9 +2056,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-1.5.6.tgz",
"integrity": "sha512-gbFHYHfdEGW0ffk8SigDsoXks6USpilF6wR0nqB/JbWzbzFR/sBuLVNQlJl1RKNakyJHu+lsFxGy0fcTdoX8xA==",
"version": "1.5.9",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-1.5.9.tgz",
"integrity": "sha512-9PQA1rE7gh41W2ylyKd5qOGOds55ymaYPml9KOpM0g+cxmCXa+8Wf9K5NKvACnJldJJ6cekWzIyB4eN6o5T+yQ==",
"cpu": [
"x64"
],
@@ -2072,9 +2072,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-x64-musl": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-1.5.6.tgz",
"integrity": "sha512-9v688ogoLkeFYQNgqiSErfhTreLUd8B3prIBSYUt+x4+5Kcw91zWvIh+VSxL1n3KCGGsM7cuXhkGPaxwlEh1ug==",
"version": "1.5.9",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-1.5.9.tgz",
"integrity": "sha512-5hdbNFeDsrJ/pXZ4cSQV4bJwUXPPxXxN3/pAtNUqIph7q+vLcBXOXIMoS64iuyaluJC59lhEwlWZFz+EPv0Hqg==",
"cpu": [
"x64"
],
@@ -2088,9 +2088,9 @@
}
},
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-1.5.6.tgz",
"integrity": "sha512-DRNDXFNZb6y5IZrw+lhTTA9l4wbzO4TNRBAlHAiXUrH+pRFZ/ZJtv5WEuAj9ocVSahVw2NaK5Yaold4NPAxHog==",
"version": "1.5.9",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-1.5.9.tgz",
"integrity": "sha512-O18JufjSB3hSJYu5WWByONouGeX7DraLAtXLErsG1r/VS3zHd/zyuzycrVUaObNXk5bfGlIP0Ypt+RvZJILN2w==",
"cpu": [
"arm64"
],
@@ -2104,9 +2104,9 @@
}
},
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-1.5.6.tgz",
"integrity": "sha512-oUYKNR/IZjF4fsOzRpw0xesl2lOjhsQEyWlgbpT25T83EU113Xgck9UjtI7xemNI/OPCv1tPiaM1e7/ABdg5iA==",
"version": "1.5.9",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-1.5.9.tgz",
"integrity": "sha512-FQxtxTZu0JVBihfd/lmpxo7jyMOesjWQehfyVUqtgMfm5+Pvvw0Y+ZioeDi1TZkFVrT3QDYy8R4LqDLSZVMQRA==",
"cpu": [
"ia32"
],
@@ -2120,9 +2120,9 @@
}
},
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-1.5.6.tgz",
"integrity": "sha512-RmEf1os9C8//uq2hbjXi7Vgz9ne7798ZxqemAZdUwo1pv3oLVZSz1/IvZmUHPdy2e6zSeySqWu1D0Y3QRNN+dg==",
"version": "1.5.9",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-1.5.9.tgz",
"integrity": "sha512-EeI1+L518cIBLKw0qUFwnLIySBeSmPQjPLIlNwSukHSro4tAQPHycEVGgKrdToiCWgaZJBA0e5aRSds0Du2TWg==",
"cpu": [
"x64"
],
@@ -9013,6 +9013,20 @@
"@tauri-apps/api": "1.5.1"
}
},
"node_modules/tauri-plugin-log-api/node_modules/@tauri-apps/api": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-1.5.1.tgz",
"integrity": "sha512-6unsZDOdlXTmauU3NhWhn+Cx0rODV+rvNvTdvolE5Kls5ybA6cqndQENDt1+FS0tF7ozCP66jwWoH6a5h90BrA==",
"engines": {
"node": ">= 14.6.0",
"npm": ">= 6.6.0",
"yarn": ">= 1.19.1"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
}
},
"node_modules/term-size": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/term-size/-/term-size-1.2.0.tgz",

View File

@@ -5,18 +5,18 @@
"type": "module",
"scripts": {
"start": "npm run build:plugins && npm run tauri-dev",
"tauri-dev": "tauri dev --no-watch --config src-tauri/tauri-dev.conf.json",
"tauri-dev": "tauri dev --no-watch --config ./src-tauri/tauri-dev.conf.json",
"tauri-build": "tauri build",
"tauri": "tauri",
"build": "npm run build:frontend",
"dev": "vite dev",
"lint": "tsc && eslint . --ext .ts,.tsx",
"build:icon:release": "tauri icon design/icon.png --output src-tauri/icons/release",
"build:icon:dev": "tauri icon design/icon-dev.png --output src-tauri/icons/dev",
"build:icon:release": "tauri icon design/icon.png --output ./src-tauri/icons/release",
"build:icon:dev": "tauri icon design/icon-dev.png --output ./src-tauri/icons/dev",
"build:frontend": "vite build",
"build:plugins": "run-p build:plugin:importer-insomnia build:plugin:importer-postman build:plugin:importer-yaak",
"build:plugin:importer-insomnia": "cd src-tauri/plugins/importer-insomnia && vite build",
"build:plugin:importer-postman": "cd src-tauri/plugins/importer-postman && vite build",
"build:plugin:importer-insomnia": "cd ./src-tauri/plugins/importer-insomnia && vite build",
"build:plugin:importer-postman": "cd ./src-tauri/plugins/importer-postman && vite build",
"build:plugin:importer-yaak": "cd src-tauri/plugins/importer-yaak && vite build",
"test": "vitest",
"coverage": "vitest run --coverage",
@@ -39,7 +39,7 @@
"@tanstack/react-query": "^4.28.0",
"@tanstack/react-query-devtools": "^4.28.0",
"@tanstack/react-query-persist-client": "^4.28.0",
"@tauri-apps/api": "^1.5.1",
"@tauri-apps/api": "^1.5.3",
"buffer": "^6.0.3",
"classnames": "^2.3.2",
"cm6-graphql": "^0.0.9",

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n UPDATE settings SET (\n follow_redirects,\n validate_certificates,\n theme,\n appearance\n ) = (?, ?, ?, ?) WHERE id = 'default';\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "09a8074f7ef8d734607f95391819e38f822488933905dcc7a7788bd184bc7796"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO settings (id)\n VALUES ('default')\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 0
},
"nullable": []
},
"hash": "2c181a4dc13efc52fe6a5a68291c5678a9624020df4ea744e78396f6926d5c88"
}

View File

@@ -0,0 +1,68 @@
{
"db_name": "SQLite",
"query": "\n SELECT\n id,\n model,\n created_at,\n updated_at,\n follow_redirects,\n validate_certificates,\n request_timeout,\n theme,\n appearance\n FROM settings\n WHERE id = 'default'\n ",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "model",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 2,
"type_info": "Datetime"
},
{
"name": "updated_at",
"ordinal": 3,
"type_info": "Datetime"
},
{
"name": "follow_redirects",
"ordinal": 4,
"type_info": "Bool"
},
{
"name": "validate_certificates",
"ordinal": 5,
"type_info": "Bool"
},
{
"name": "request_timeout",
"ordinal": 6,
"type_info": "Int64"
},
{
"name": "theme",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "appearance",
"ordinal": 8,
"type_info": "Text"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false,
false
]
},
"hash": "f27d45f7ea2b04fc203e46a85be96a591a6495794dc042e1e2f3460c9ed65a5c"
}

154
src-tauri/Cargo.lock generated
View File

@@ -81,6 +81,20 @@ version = "1.0.75"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4668cab20f66d8d020e1fbc0ebe47217433c1b6c8f2040faf858554e394ace6"
[[package]]
name = "async-compression"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc2d0cfb2a7388d34f590e76686704c494ed7aaceed62ee1ba35cbf363abc2a5"
dependencies = [
"brotli",
"flate2",
"futures-core",
"memchr",
"pin-project-lite",
"tokio",
]
[[package]]
name = "atk"
version = "0.15.1"
@@ -769,12 +783,12 @@ dependencies = [
[[package]]
name = "ctor"
version = "0.1.26"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d2301688392eb071b0bf1a37be05c469d3cc4dbbd95df672fe28ab021e6a096"
checksum = "30d2b3721e861707777e3195b0158f950ae6dc4a27e4d02ff9f67e3eb3de199e"
dependencies = [
"quote",
"syn 1.0.109",
"syn 2.0.39",
]
[[package]]
@@ -961,11 +975,12 @@ dependencies = [
[[package]]
name = "embed-resource"
version = "2.4.0"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f54cc3e827ee1c3812239a9a41dede7b4d7d5d5464faa32d71bd7cba28ce2cb2"
checksum = "3bde55e389bea6a966bd467ad1ad7da0ae14546a5bc794d16d1e55e7fca44881"
dependencies = [
"cc",
"memchr",
"rustc_version",
"toml 0.8.8",
"vswhom",
@@ -2013,9 +2028,9 @@ dependencies = [
[[package]]
name = "infer"
version = "0.12.0"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a898e4b7951673fce96614ce5751d13c40fc5674bc2d759288e46c3ab62598b3"
checksum = "f551f8c3a39f68f986517db0d1759de85881894fdc7db798bd2a9df9cb04b7fc"
dependencies = [
"cfb",
]
@@ -2342,9 +2357,9 @@ dependencies = [
[[package]]
name = "memchr"
version = "2.6.4"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f665ee40bc4a3c5590afb1e9677db74a508659dfd71e126420da8274909a0167"
checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149"
[[package]]
name = "memoffset"
@@ -2724,6 +2739,15 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf"
[[package]]
name = "openssl-src"
version = "300.1.6+3.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439fac53e092cd7442a3660c85dde4643ab3b5bd39040912388dcdabf6b88085"
dependencies = [
"cc",
]
[[package]]
name = "openssl-sys"
version = "0.9.95"
@@ -2732,6 +2756,7 @@ checksum = "40a4130519a360279579c2053038317e40eff64d13fd3f004f9e1b72b8a6aaf9"
dependencies = [
"cc",
"libc",
"openssl-src",
"pkg-config",
"vcpkg",
]
@@ -2854,9 +2879,7 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259"
dependencies = [
"phf_macros 0.10.0",
"phf_shared 0.10.0",
"proc-macro-hack",
]
[[package]]
@@ -2933,20 +2956,6 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "phf_macros"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0"
dependencies = [
"phf_generator 0.10.0",
"phf_shared 0.10.0",
"proc-macro-hack",
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "phf_macros"
version = "0.11.2"
@@ -3337,6 +3346,7 @@ version = "0.11.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "046cd98826c46c2ac8ddecae268eb5c2e58628688a5fc7a2643704a73faba95b"
dependencies = [
"async-compression",
"base64 0.21.5",
"bytes",
"encoding_rs",
@@ -4401,9 +4411,9 @@ dependencies = [
[[package]]
name = "tauri-build"
version = "1.5.0"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "defbfc551bd38ab997e5f8e458f87396d2559d05ce32095076ad6c30f7fc5f9c"
checksum = "e9914a4715e0b75d9f387a285c7e26b5bbfeb1249ad9f842675a82481565c532"
dependencies = [
"anyhow",
"cargo_toml",
@@ -4530,9 +4540,9 @@ dependencies = [
[[package]]
name = "tauri-utils"
version = "1.5.0"
version = "1.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34d55e185904a84a419308d523c2c6891d5e2dbcee740c4997eb42e75a7b0f46"
checksum = "ece74810b1d3d44f29f732a7ae09a63183d63949bbdd59c61f8ed2a1b70150db"
dependencies = [
"brotli",
"ctor",
@@ -4545,7 +4555,7 @@ dependencies = [
"kuchikiki",
"log",
"memchr",
"phf 0.10.1",
"phf 0.11.2",
"proc-macro2",
"quote",
"semver",
@@ -4553,10 +4563,10 @@ dependencies = [
"serde_json",
"serde_with",
"thiserror",
"toml 0.5.11",
"toml 0.7.8",
"url",
"walkdir",
"windows 0.39.0",
"windows-version",
]
[[package]]
@@ -5318,6 +5328,18 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "window-shadows"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67ff424735b1ac21293b0492b069394b0a189c8a463fb015a16dea7c2e221c08"
dependencies = [
"cocoa 0.25.0",
"objc",
"raw-window-handle",
"windows-sys 0.48.0",
]
[[package]]
name = "windows"
version = "0.37.0"
@@ -5452,12 +5474,36 @@ dependencies = [
"windows_x86_64_msvc 0.48.5",
]
[[package]]
name = "windows-targets"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a18201040b24831fbb9e4eb208f8892e1f50a37feb53cc7ff887feb8f50e7cd"
dependencies = [
"windows_aarch64_gnullvm 0.52.0",
"windows_aarch64_msvc 0.52.0",
"windows_i686_gnu 0.52.0",
"windows_i686_msvc 0.52.0",
"windows_x86_64_gnu 0.52.0",
"windows_x86_64_gnullvm 0.52.0",
"windows_x86_64_msvc 0.52.0",
]
[[package]]
name = "windows-tokens"
version = "0.39.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f838de2fe15fe6bac988e74b798f26499a8b21a9d97edec321e79b28d1d7f597"
[[package]]
name = "windows-version"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75aa004c988e080ad34aff5739c39d0312f4684699d6d71fc8a198d057b8b9b4"
dependencies = [
"windows-targets 0.52.0",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.42.2"
@@ -5470,6 +5516,12 @@ version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb7764e35d4db8a7921e09562a0304bf2f93e0a51bfccee0bd0bb0b666b015ea"
[[package]]
name = "windows_aarch64_msvc"
version = "0.37.0"
@@ -5494,6 +5546,12 @@ version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbaa0368d4f1d2aaefc55b6fcfee13f41544ddf36801e793edbbfd7d7df075ef"
[[package]]
name = "windows_i686_gnu"
version = "0.37.0"
@@ -5518,6 +5576,12 @@ version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[package]]
name = "windows_i686_gnu"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28637cb1fa3560a16915793afb20081aba2c92ee8af57b4d5f28e4b3e7df313"
[[package]]
name = "windows_i686_msvc"
version = "0.37.0"
@@ -5542,6 +5606,12 @@ version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]]
name = "windows_i686_msvc"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ffe5e8e31046ce6230cc7215707b816e339ff4d4d67c65dffa206fd0f7aa7b9a"
[[package]]
name = "windows_x86_64_gnu"
version = "0.37.0"
@@ -5566,6 +5636,12 @@ version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d6fa32db2bc4a2f5abeacf2b69f7992cd09dca97498da74a151a3132c26befd"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
@@ -5578,6 +5654,12 @@ version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a657e1e9d3f514745a572a6846d3c7aa7dbe1658c056ed9c3344c4109a6949e"
[[package]]
name = "windows_x86_64_msvc"
version = "0.37.0"
@@ -5602,6 +5684,12 @@ version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04"
[[package]]
name = "winnow"
version = "0.5.19"
@@ -5725,6 +5813,7 @@ dependencies = [
"http",
"log",
"objc",
"openssl-sys",
"rand 0.8.5",
"reqwest",
"serde",
@@ -5736,6 +5825,7 @@ dependencies = [
"tauri-plugin-window-state",
"tokio",
"uuid",
"window-shadows",
]
[[package]]

View File

@@ -17,6 +17,9 @@ tauri-build = { version = "1.2", features = [] }
objc = "0.2.7"
cocoa = "0.25.0"
[target.'cfg(target_os = "linux")'.dependencies]
openssl-sys = {version = "0.9", features = ["vendored"] } # For Ubuntu installation to work
[dependencies]
base64 = "0.21.0"
boa_engine = "0.17.3"
@@ -25,22 +28,27 @@ chrono = { version = "0.4.23", features = ["serde"] }
futures = "0.3.26"
http = "0.2.8"
rand = "0.8.5"
reqwest = { version = "0.11.14", features = ["json", "multipart"] }
reqwest = { version = "0.11.14", features = ["json", "multipart", "gzip", "brotli", "deflate"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = { version = "1.0", features = ["raw_value"] }
sqlx = { version = "0.7.2", features = ["sqlite", "runtime-tokio-rustls", "json", "chrono", "time"] }
tauri = { version = "1.3", features = [
tauri = { version = "1.5.2", features = [
"config-toml",
"devtools",
"dialog-open",
"dialog-save",
"fs-read-file",
"os-all",
"protocol-asset",
"shell-open",
"updater",
"window-start-dragging",
"dialog-open",
"dialog-save",
"window-close",
"window-maximize",
"window-minimize",
"window-set-decorations",
"window-set-title",
"window-start-dragging",
"window-unmaximize",
] }
tauri-plugin-window-state = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
tauri-plugin-log = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1", features = ["colored"] }
@@ -48,6 +56,7 @@ tokio = { version = "1.25.0", features = ["sync"] }
uuid = "1.3.0"
log = "0.4.20"
datetime = "0.5.2"
window-shadows = "0.2.2"
[features]
# by default Tauri runs in production mode

View File

@@ -0,0 +1,13 @@
CREATE TABLE settings
(
id TEXT NOT NULL
PRIMARY KEY,
model TEXT DEFAULT 'settings' NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
follow_redirects BOOLEAN DEFAULT TRUE NOT NULL,
validate_certificates BOOLEAN DEFAULT TRUE NOT NULL,
request_timeout INTEGER DEFAULT 0 NOT NULL,
theme TEXT DEFAULT 'default' NOT NULL,
appearance TEXT DEFAULT 'system' NOT NULL
);

View File

@@ -1,89 +1,109 @@
use log::{debug, warn};
use serde::{Deserialize, Serialize};
use sqlx::types::JsonValue;
use tauri::{async_runtime, AppHandle, Manager};
use crate::is_dev;
// serializable
#[derive(Serialize, Deserialize)]
pub enum AnalyticsResource {
App,
// Workspace,
// Environment,
// Folder,
// HttpRequest,
// HttpResponse,
Workspace,
Environment,
Folder,
HttpRequest,
HttpResponse,
}
#[derive(Serialize, Deserialize)]
pub enum AnalyticsAction {
Launch,
// Create,
// Update,
// Upsert,
// Delete,
// Send,
// Duplicate,
Create,
Update,
Upsert,
Delete,
DeleteMany,
Send,
Duplicate,
}
fn resource_name(resource: AnalyticsResource) -> &'static str {
match resource {
AnalyticsResource::App => "app",
// AnalyticsResource::Workspace => "workspace",
// AnalyticsResource::Environment => "environment",
// AnalyticsResource::Folder => "folder",
// AnalyticsResource::HttpRequest => "http_request",
// AnalyticsResource::HttpResponse => "http_response",
AnalyticsResource::Workspace => "workspace",
AnalyticsResource::Environment => "environment",
AnalyticsResource::Folder => "folder",
AnalyticsResource::HttpRequest => "http_request",
AnalyticsResource::HttpResponse => "http_response",
}
}
fn action_name(action: AnalyticsAction) -> &'static str {
match action {
AnalyticsAction::Launch => "launch",
// AnalyticsAction::Create => "create",
// AnalyticsAction::Update => "update",
// AnalyticsAction::Upsert => "upsert",
// AnalyticsAction::Delete => "delete",
// AnalyticsAction::Send => "send",
// AnalyticsAction::Duplicate => "duplicate",
AnalyticsAction::Create => "create",
AnalyticsAction::Update => "update",
AnalyticsAction::Upsert => "upsert",
AnalyticsAction::Delete => "delete",
AnalyticsAction::DeleteMany => "delete_many",
AnalyticsAction::Send => "send",
AnalyticsAction::Duplicate => "duplicate",
}
}
pub fn track_event(
pub fn track_event_blocking(
app_handle: &AppHandle,
resource: AnalyticsResource,
action: AnalyticsAction,
attributes: Option<JsonValue>,
) {
async_runtime::block_on(async move {
let event = format!("{}.{}", resource_name(resource), action_name(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());
let params = vec![
("e", event.clone()),
("a", attributes_json.clone()),
("id", "site_zOK0d7jeBy2TLxFCnZ".to_string()),
("v", info.version.clone().to_string()),
("os", get_os().to_string()),
("tz", tz),
("xy", get_window_size(app_handle)),
];
let url = "https://t.yaak.app/t/e".to_string();
let req = reqwest::Client::builder()
.build()
.unwrap()
.get(&url)
.query(&params);
track_event(app_handle, resource, action, attributes).await;
});
}
if is_dev() {
debug!("Send event (dev): {}", event);
} else if let Err(e) = req.send().await {
warn!(
pub async fn track_event(
app_handle: &AppHandle,
resource: AnalyticsResource,
action: AnalyticsAction,
attributes: Option<JsonValue>,
) {
let event = format!("{}.{}", resource_name(resource), action_name(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());
let site = match is_dev() {
true => "site_TkHWjoXwZPq3HfhERb",
false => "site_zOK0d7jeBy2TLxFCnZ",
};
let base_url = match is_dev() {
true => "http://localhost:7194",
false => "https://t.yaak.app"
};
let params = vec![
("e", event.clone()),
("a", attributes_json.clone()),
("id", site.to_string()),
("v", info.version.clone().to_string()),
("os", get_os().to_string()),
("tz", tz),
("xy", get_window_size(app_handle)),
];
let req = reqwest::Client::builder()
.build()
.unwrap()
.get(format!("{base_url}/t/e"))
.query(&params);
if let Err(e) = req.send().await {
warn!(
"Error sending analytics event: {} {} {:?}",
e, event, params
);
} else {
debug!("Send event: {}: {:?}", event, params);
}
});
} else {
debug!("Send event: {}: {:?}", event, params);
}
}
fn get_os() -> &'static str {

View File

@@ -16,20 +16,22 @@ use fern::colors::ColoredLevelConfig;
use log::{debug, info, warn};
use rand::random;
use serde::Serialize;
use sqlx::{Pool, Sqlite, SqlitePool};
use serde_json::Value;
use sqlx::migrate::Migrator;
use sqlx::types::Json;
use tauri::{AppHandle, RunEvent, State, Window, WindowUrl, Wry};
use tauri::{Manager, WindowEvent};
use sqlx::{Pool, Sqlite, SqlitePool};
#[cfg(target_os = "macos")]
use tauri::TitleBarStyle;
use tauri::{AppHandle, RunEvent, State, Window, WindowUrl, Wry};
use tauri::{Manager, WindowEvent};
use tauri_plugin_log::{fern, LogTarget};
use tauri_plugin_window_state::{StateFlags, WindowExt};
use tokio::sync::Mutex;
use window_shadows::set_shadow;
use window_ext::TrafficLightWindowExt;
use crate::analytics::{AnalyticsAction, AnalyticsResource, track_event};
use crate::analytics::{AnalyticsAction, AnalyticsResource};
use crate::plugin::{ImportResources, ImportResult};
use crate::send::actually_send_request;
use crate::updates::{update_mode_from_str, UpdateMode, YaakUpdater};
@@ -67,7 +69,7 @@ async fn migrate_db(
info!("Running migrations at {}", p.to_string_lossy());
let m = Migrator::new(p).await.expect("Failed to load migrations");
m.run(pool).await.expect("Failed to run migrations");
info!("Migrations complete");
info!("Migrations complete!");
Ok(())
}
@@ -224,6 +226,17 @@ async fn response_err(
Ok(response)
}
#[tauri::command]
async fn track_event(
window: Window<Wry>,
resource: AnalyticsResource,
action: AnalyticsAction,
attributes: Option<Value>,
) -> Result<(), String> {
analytics::track_event(&window.app_handle(), resource, action, attributes).await;
Ok(())
}
#[tauri::command]
async fn set_update_mode(
update_mode: &str,
@@ -504,6 +517,31 @@ async fn list_environments(
Ok(environments)
}
#[tauri::command]
async fn get_settings(
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<models::Settings, String> {
let pool = &*db_instance.lock().await;
models::get_or_create_settings(pool)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn update_settings(
settings: models::Settings,
window: Window<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<models::Settings, String> {
let pool = &*db_instance.lock().await;
let updated_settings = models::update_settings(pool, settings)
.await
.expect("Failed to update settings");
emit_and_return(&window, "updated_model", updated_settings)
}
#[tauri::command]
async fn get_folder(
id: &str,
@@ -659,9 +697,16 @@ fn main() {
)
.plugin(tauri_plugin_window_state::Builder::default().build())
.setup(|app| {
let app_data_dir = app.path_resolver().app_data_dir().unwrap();
let app_config_dir = app.path_resolver().app_config_dir().unwrap();
info!(
"App Config Dir: {}",
app_config_dir.as_path().to_string_lossy(),
);
info!("App Data Dir: {}", app_data_dir.as_path().to_string_lossy(),);
let dir = match is_dev() {
true => current_dir().unwrap(),
false => app.path_resolver().app_data_dir().unwrap(),
false => app_data_dir,
};
create_dir_all(dir.clone()).expect("Problem creating App directory!");
@@ -714,6 +759,7 @@ fn main() {
get_environment,
get_folder,
get_request,
get_settings,
get_workspace,
import_data,
list_environments,
@@ -726,9 +772,11 @@ fn main() {
send_request,
set_key_value,
set_update_mode,
track_event,
update_environment,
update_folder,
update_request,
update_settings,
update_workspace,
])
.build(tauri::generate_context!())
@@ -762,7 +810,7 @@ fn main() {
w.restore_state(StateFlags::all())
.expect("Failed to restore window state");
track_event(
analytics::track_event_blocking(
app_handle,
AnalyticsResource::App,
AnalyticsAction::Launch,
@@ -790,10 +838,12 @@ fn main() {
}
fn is_dev() -> bool {
#[cfg(dev)] {
#[cfg(dev)]
{
return true;
}
#[cfg(not(dev))] {
#[cfg(not(dev))]
{
return false;
}
}
@@ -826,8 +876,19 @@ fn create_window(handle: &AppHandle<Wry>, url: Option<&str>) -> Window<Wry> {
.title_bar_style(TitleBarStyle::Overlay);
}
// Add non-MacOS things
#[cfg(not(target_os = "macos"))]
{
// Doesn't seem to work from Rust, here, so we do it in JS
win_builder = win_builder.decorations(false);
}
let win = win_builder.build().expect("failed to build window");
// Tauri doesn't support shadows when hiding decorations, so we add our own
#[cfg(any(windows, target_os = "macos"))]
set_shadow(&win, true).unwrap();
let win2 = win.clone();
let handle2 = handle.clone();
win.on_menu_event(move |event| match event.menu_item_id() {

View File

@@ -3,11 +3,27 @@ use std::fs;
use rand::distributions::{Alphanumeric, DistString};
use serde::{Deserialize, Serialize};
use sqlx::{Pool, Sqlite};
use sqlx::types::{Json, JsonValue};
use sqlx::types::chrono::NaiveDateTime;
use sqlx::types::{Json, JsonValue};
use sqlx::{Pool, Sqlite};
use tauri::AppHandle;
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct Settings {
pub id: String,
pub model: String,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
// Settings
pub validate_certificates: bool,
pub follow_redirects: bool,
pub theme: String,
pub appearance: String,
pub request_timeout: i64,
}
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct Workspace {
@@ -192,7 +208,11 @@ pub async fn get_key_value(namespace: &str, key: &str, pool: &Pool<Sqlite>) -> O
.ok()
}
pub async fn get_key_value_string(namespace: &str, key: &str, pool: &Pool<Sqlite>) -> Option<String> {
pub async fn get_key_value_string(
namespace: &str,
key: &str,
pool: &Pool<Sqlite>,
) -> Option<String> {
let kv = get_key_value(namespace, key, pool).await?;
let result = serde_json::from_str(&kv.value);
match result {
@@ -283,6 +303,68 @@ pub async fn delete_environment(id: &str, pool: &Pool<Sqlite>) -> Result<Environ
Ok(env)
}
async fn get_settings(pool: &Pool<Sqlite>) -> Result<Settings, sqlx::Error> {
sqlx::query_as!(
Settings,
r#"
SELECT
id,
model,
created_at,
updated_at,
follow_redirects,
validate_certificates,
request_timeout,
theme,
appearance
FROM settings
WHERE id = 'default'
"#,
)
.fetch_one(pool)
.await
}
pub async fn get_or_create_settings(pool: &Pool<Sqlite>) -> Result<Settings, sqlx::Error> {
let existing = get_settings(pool).await;
if let Ok(s) = existing {
Ok(s)
} else {
sqlx::query!(
r#"
INSERT INTO settings (id)
VALUES ('default')
"#,
)
.execute(pool)
.await?;
get_settings(pool).await
}
}
pub async fn update_settings(
pool: &Pool<Sqlite>,
settings: Settings,
) -> Result<Settings, sqlx::Error> {
sqlx::query!(
r#"
UPDATE settings SET (
follow_redirects,
validate_certificates,
theme,
appearance
) = (?, ?, ?, ?) WHERE id = 'default';
"#,
settings.follow_redirects,
settings.validate_certificates,
settings.theme,
settings.appearance,
)
.execute(pool)
.await?;
get_settings(pool).await
}
pub async fn upsert_environment(
pool: &Pool<Sqlite>,
environment: Environment,

View File

@@ -1,15 +1,16 @@
use std::fs;
use std::fs::{create_dir_all, File};
use std::io::Write;
use std::time::Duration;
use base64::Engine;
use http::{HeaderMap, HeaderName, HeaderValue, Method};
use http::header::{ACCEPT, USER_AGENT};
use http::{HeaderMap, HeaderName, HeaderValue, Method};
use log::warn;
use reqwest::multipart;
use reqwest::redirect::Policy;
use sqlx::{Pool, Sqlite};
use sqlx::types::Json;
use sqlx::{Pool, Sqlite};
use tauri::{AppHandle, Wry};
use crate::{emit_side_effect, models, render, response_err};
@@ -34,11 +35,31 @@ pub async fn actually_send_request(
url_string = format!("http://{}", url_string);
}
let client = reqwest::Client::builder()
.redirect(Policy::none())
// .danger_accept_invalid_certs(true)
.build()
.expect("Failed to build client");
let settings = models::get_or_create_settings(pool)
.await
.expect("Failed to get settings");
let mut client_builder = reqwest::Client::builder()
.redirect(match settings.follow_redirects {
true => Policy::limited(10), // TODO: Handle redirects natively
false => Policy::none(),
})
.gzip(true)
.brotli(true)
.deflate(true)
.referer(false)
.danger_accept_invalid_certs(!settings.validate_certificates)
.connection_verbose(true) // TODO: Capture this log somehow
.tls_info(true);
if settings.request_timeout > 0 {
client_builder = client_builder.timeout(Duration::from_millis(
settings.request_timeout.unsigned_abs(),
));
}
// .use_rustls_tls() // TODO: Make this configurable (maybe)
let client = client_builder.build().expect("Failed to build client");
let m = Method::from_bytes(request.method.to_uppercase().as_bytes())
.expect("Failed to create method");
@@ -114,7 +135,9 @@ pub async fn actually_send_request(
let mut query_params = Vec::new();
for p in request.url_parameters.0 {
if !p.enabled || p.name.is_empty() { continue; }
if !p.enabled || p.name.is_empty() {
continue;
}
query_params.push((
render::render(&p.name, &workspace, environment_ref),
render::render(&p.value, &workspace, environment_ref),
@@ -128,18 +151,38 @@ pub async fn actually_send_request(
let request_body = request.body.0;
if request_body.contains_key("text") {
let raw_text = request_body.get("text").unwrap_or(empty_string).as_str().unwrap_or("");
let raw_text = request_body
.get("text")
.unwrap_or(empty_string)
.as_str()
.unwrap_or("");
let body = render::render(raw_text, &workspace, environment_ref);
request_builder = request_builder.body(body);
} else if body_type == "application/x-www-form-urlencoded" && request_body.contains_key("form") {
} else if body_type == "application/x-www-form-urlencoded"
&& request_body.contains_key("form")
{
let mut form_params = Vec::new();
let form = request_body.get("form");
if let Some(f) = form {
for p in f.as_array().unwrap_or(&Vec::new()) {
let enabled = p.get("enabled").unwrap_or(empty_bool).as_bool().unwrap_or(false);
let name = p.get("name").unwrap_or(empty_string).as_str().unwrap_or_default();
if !enabled || name.is_empty() { continue; }
let value = p.get("value").unwrap_or(empty_string).as_str().unwrap_or_default();
let enabled = p
.get("enabled")
.unwrap_or(empty_bool)
.as_bool()
.unwrap_or(false);
let name = p
.get("name")
.unwrap_or(empty_string)
.as_str()
.unwrap_or_default();
if !enabled || name.is_empty() {
continue;
}
let value = p
.get("value")
.unwrap_or(empty_string)
.as_str()
.unwrap_or_default();
form_params.push((
render::render(name, &workspace, environment_ref),
render::render(value, &workspace, environment_ref),
@@ -151,17 +194,41 @@ pub async fn actually_send_request(
let mut multipart_form = multipart::Form::new();
if let Some(form_definition) = request_body.get("form") {
for p in form_definition.as_array().unwrap_or(&Vec::new()) {
let enabled = p.get("enabled").unwrap_or(empty_bool).as_bool().unwrap_or(false);
let name = p.get("name").unwrap_or(empty_string).as_str().unwrap_or_default();
if !enabled || name.is_empty() { continue; }
let enabled = p
.get("enabled")
.unwrap_or(empty_bool)
.as_bool()
.unwrap_or(false);
let name = p
.get("name")
.unwrap_or(empty_string)
.as_str()
.unwrap_or_default();
if !enabled || name.is_empty() {
continue;
}
let file = p.get("file").unwrap_or(empty_string).as_str().unwrap_or_default();
let value = p.get("value").unwrap_or(empty_string).as_str().unwrap_or_default();
let file = p
.get("file")
.unwrap_or(empty_string)
.as_str()
.unwrap_or_default();
let value = p
.get("value")
.unwrap_or(empty_string)
.as_str()
.unwrap_or_default();
multipart_form = multipart_form.part(
render::render(name, &workspace, environment_ref),
match !file.is_empty() {
true => multipart::Part::bytes(fs::read(file).map_err(|e| e.to_string())?),
false => multipart::Part::text(render::render(value, &workspace, environment_ref)),
true => {
multipart::Part::bytes(fs::read(file).map_err(|e| e.to_string())?)
}
false => multipart::Part::text(render::render(
value,
&workspace,
environment_ref,
)),
},
);
}
@@ -202,6 +269,7 @@ pub async fn actually_send_request(
response.url = v.url().to_string();
let body_bytes = v.bytes().await.expect("Failed to get body").to_vec();
response.content_length = Some(body_bytes.len() as i64);
println!("Response: {:?}", body_bytes.len());
{
// Write body to FS
@@ -237,6 +305,9 @@ pub async fn actually_send_request(
}
Ok(response)
}
Err(e) => response_err(response, e.to_string(), app_handle, pool).await,
Err(e) => {
println!("Yo: {}", e);
response_err(response, e.to_string(), app_handle, pool).await
}
}
}

View File

@@ -8,7 +8,7 @@
},
"package": {
"productName": "Yaak",
"version": "2024.0.0"
"version": "2024.0.1-beta.2"
},
"tauri": {
"windows": [],
@@ -35,8 +35,13 @@
"open": true
},
"window": {
"close": true,
"maximize": true,
"minimize": true,
"setDecorations": true,
"setTitle": true,
"startDragging": true,
"setTitle": true
"unmaximize": true
},
"dialog": {
"all": false,

View File

@@ -1,5 +1,4 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { MotionConfig } from 'framer-motion';
import { Suspense } from 'react';
import { DndProvider } from 'react-dnd';
@@ -26,7 +25,7 @@ export function App() {
<DndProvider backend={HTML5Backend}>
<Suspense>
<AppRouter />
<ReactQueryDevtools initialIsOpen={false} />
{/*<ReactQueryDevtools initialIsOpen={false} />*/}
</Suspense>
</DndProvider>
</HelmetProvider>

View File

@@ -4,7 +4,6 @@ import { useActiveEnvironment } from '../hooks/useActiveEnvironment';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { useCreateEnvironment } from '../hooks/useCreateEnvironment';
import { useEnvironments } from '../hooks/useEnvironments';
import { useHotkey } from '../hooks/useHotkey';
import type { ButtonProps } from './core/Button';
import { Button } from './core/Button';
import type { DropdownItem } from './core/Dropdown';
@@ -35,8 +34,6 @@ export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdo
});
}, [dialog, activeEnvironment]);
useHotkey('environmentEditor.toggle', showEnvironmentDialog, { enable: environments.length > 0 });
const items: DropdownItem[] = useMemo(
() => [
...environments.map(

View File

@@ -2,7 +2,6 @@ import { useQueryClient } from '@tanstack/react-query';
import { appWindow } from '@tauri-apps/api/window';
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { useEffectOnce } from 'react-use';
import { keyValueQueryKey } from '../hooks/useKeyValue';
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
import { useRecentEnvironments } from '../hooks/useRecentEnvironments';
@@ -11,9 +10,9 @@ import { useRecentWorkspaces } from '../hooks/useRecentWorkspaces';
import { requestsQueryKey } from '../hooks/useRequests';
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
import { responsesQueryKey } from '../hooks/useResponses';
import { settingsQueryKey } from '../hooks/useSettings';
import { useSyncWindowTitle } from '../hooks/useSyncWindowTitle';
import { workspacesQueryKey } from '../hooks/useWorkspaces';
import { trackPage } from '../lib/analytics';
import { NAMESPACE_NO_SYNC } from '../lib/keyValueStore';
import type { HttpRequest, HttpResponse, Model, Workspace } from '../lib/models';
import { modelsEq } from '../lib/models';
@@ -39,10 +38,6 @@ export function GlobalHooks() {
setPathname(location.pathname).catch(console.error);
}, [location.pathname]);
useEffectOnce(() => {
trackPage('/');
});
useListenToTauriEvent<Model>('created_model', ({ payload, windowLabel }) => {
if (shouldIgnoreEvent(payload, windowLabel)) return;
@@ -55,6 +50,8 @@ export function GlobalHooks() {
? workspacesQueryKey(payload)
: payload.model === 'key_value'
? keyValueQueryKey(payload)
: payload.model === 'settings'
? settingsQueryKey()
: null;
if (queryKey === null) {
@@ -80,6 +77,8 @@ export function GlobalHooks() {
? workspacesQueryKey(payload)
: payload.model === 'key_value'
? keyValueQueryKey(payload)
: payload.model === 'settings'
? settingsQueryKey()
: null;
if (queryKey === null) {
@@ -113,6 +112,8 @@ export function GlobalHooks() {
queryClient.setQueryData<HttpResponse[]>(responsesQueryKey(payload), removeById(payload));
} else if (payload.model === 'key_value') {
queryClient.setQueryData(keyValueQueryKey(payload), undefined);
} else if (payload.model === 'settings') {
queryClient.setQueryData(settingsQueryKey(), undefined);
}
});
useListenToTauriEvent<number>('zoom', ({ payload: zoomDelta, windowLabel }) => {

View File

@@ -0,0 +1,10 @@
import { hotkeyActions } from '../hooks/useHotKey';
import { HotKeyList } from './core/HotKeyList';
export const KeyboardShortcutsDialog = () => {
return (
<div className="h-full w-full">
<HotKeyList hotkeys={hotkeyActions} />
</div>
);
};

View File

@@ -5,6 +5,7 @@ import { useActiveEnvironmentId } from '../hooks/useActiveEnvironmentId';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useActiveWorkspaceId } from '../hooks/useActiveWorkspaceId';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { useHotKey } from '../hooks/useHotKey';
import { useRecentRequests } from '../hooks/useRecentRequests';
import { useRequests } from '../hooks/useRequests';
import { fallbackRequestName } from '../lib/fallbackRequestName';
@@ -33,25 +34,19 @@ export function RecentRequestsDropdown({ className }: Pick<ButtonProps, 'classNa
// Handle key-up
useKeyPressEvent('Control', undefined, () => {
if (!dropdownRef.current?.isOpen) return;
dropdownRef.current?.select?.();
});
useKey(
'Tab',
(e) => {
if (!e.ctrlKey || recentRequestIds.length === 0) return;
useHotKey('requestSwitcher.prev', () => {
if (!dropdownRef.current?.isOpen) dropdownRef.current?.open(1);
dropdownRef.current?.next?.();
});
if (!dropdownRef.current?.isOpen) {
dropdownRef.current?.open(e.shiftKey ? -1 : 1);
return;
}
if (e.shiftKey) dropdownRef.current?.prev?.();
else dropdownRef.current?.next?.();
},
undefined,
[recentRequestIds.length],
);
useHotKey('requestSwitcher.next', () => {
if (!dropdownRef.current?.isOpen) dropdownRef.current?.open(-1);
dropdownRef.current?.prev?.();
});
const items = useMemo<DropdownItem[]>(() => {
if (activeWorkspaceId === null) return [];

View File

@@ -5,8 +5,6 @@ import React, { memo, useCallback, useMemo, useRef, useState } from 'react';
import { useLocalStorage } from 'react-use';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useActiveWorkspaceId } from '../hooks/useActiveWorkspaceId';
import { useCreateRequest } from '../hooks/useCreateRequest';
import { useRequests } from '../hooks/useRequests';
import { clamp } from '../lib/clamp';
import { HotKeyList } from './core/HotKeyList';
import { RequestPane } from './RequestPane';
@@ -29,8 +27,6 @@ const STACK_VERTICAL_WIDTH = 600;
export const RequestResponse = memo(function RequestResponse({ style }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const activeRequest = useActiveRequest();
const createRequest = useCreateRequest();
const requests = useRequests();
const [vertical, setVertical] = useState<boolean>(false);
const [widthRaw, setWidth] = useLocalStorage<number>(`body_width::${useActiveWorkspaceId()}`);
const [heightRaw, setHeight] = useLocalStorage<number>(`body_height::${useActiveWorkspaceId()}`);
@@ -42,7 +38,8 @@ export const RequestResponse = memo(function RequestResponse({ style }: Props) {
);
useResizeObserver(containerRef, ({ contentRect }) => {
setVertical(contentRect.width < STACK_VERTICAL_WIDTH);
const doIt = contentRect.width < STACK_VERTICAL_WIDTH;
setVertical(doIt);
});
const styles = useMemo<CSSProperties>(

View File

@@ -0,0 +1,83 @@
import classNames from 'classnames';
import { useSettings } from '../hooks/useSettings';
import { useTheme } from '../hooks/useTheme';
import { useUpdateSettings } from '../hooks/useUpdateSettings';
import type { Appearance } from '../lib/theme/window';
import { Checkbox } from './core/Checkbox';
import { Input } from './core/Input';
import { VStack } from './core/Stacks';
export const SettingsDialog = () => {
const { appearance, setAppearance } = useTheme();
const settings = useSettings();
const updateSettings = useUpdateSettings();
if (settings == null) {
return null;
}
console.log('SETTINGS', settings);
return (
<VStack space={2}>
<div className="w-full gap-2 grid grid-cols-[auto_1fr] gap-x-6 auto-rows-[2rem] items-center">
<Checkbox
className="col-span-full"
checked={settings.validateCertificates}
title="Validate TLS Certificates"
onChange={(validateCertificates) =>
updateSettings.mutateAsync({ ...settings, validateCertificates })
}
/>
<Checkbox
className="col-span-full"
checked={settings.followRedirects}
title="Follow Redirects"
onChange={(followRedirects) =>
updateSettings.mutateAsync({ ...settings, followRedirects })
}
/>
<div>Request Timeout (ms)</div>
<div>
<Input
size="sm"
name="requestTimeout"
label="Request Timeout (ms)"
containerClassName="col-span-2"
hideLabel
defaultValue={`${settings.requestTimeout}`}
validate={(value) => parseInt(value) >= 0}
onChange={(v) =>
updateSettings.mutateAsync({ ...settings, requestTimeout: parseInt(v) || 0 })
}
/>
</div>
<div>Appearance</div>
<select
value={settings.appearance}
style={selectBackgroundStyles}
onChange={(e) => updateSettings.mutateAsync({ ...settings, appearance: e.target.value })}
className={classNames(
'border w-full px-2 outline-none bg-transparent',
'border-highlight focus:border-focus',
'h-sm',
)}
>
<option value="system">Match System</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</div>
{/*<Checkbox checked={appearance === 'dark'} title="Dark Mode" onChange={toggleAppearance} />*/}
</VStack>
);
};
const selectBackgroundStyles = {
backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e")`,
backgroundPosition: 'right 0.5rem center',
backgroundRepeat: 'no-repeat',
backgroundSize: '1.5em 1.5em',
};

View File

@@ -3,7 +3,6 @@ import { useRef } from 'react';
import { useAppVersion } from '../hooks/useAppVersion';
import { useExportData } from '../hooks/useExportData';
import { useImportData } from '../hooks/useImportData';
import { useTheme } from '../hooks/useTheme';
import { useUpdateMode } from '../hooks/useUpdateMode';
import { Button } from './core/Button';
import type { DropdownRef } from './core/Dropdown';
@@ -12,11 +11,12 @@ 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';
export function SettingsDropdown() {
const importData = useImportData();
const exportData = useExportData();
const { appearance, toggleAppearance } = useTheme();
const appVersion = useAppVersion();
const [updateMode, setUpdateMode] = useUpdateMode();
const dropdownRef = useRef<DropdownRef>(null);
@@ -61,17 +61,39 @@ export function SettingsDropdown() {
onSelect: () => exportData.mutate(),
},
{
key: 'appearance',
label: 'Toggle Theme',
onSelect: toggleAppearance,
leftSlot: <Icon icon={appearance === 'dark' ? 'sun' : 'moon'} />,
key: 'hotkeys',
label: 'Keyboard shortcuts',
hotkeyAction: 'hotkeys.showHelp',
leftSlot: <Icon icon="keyboard" />,
onSelect: () => {
dialog.show({
id: 'hotkey-help',
title: 'Keyboard Shortcuts',
size: 'sm',
render: () => <KeyboardShortcutsDialog />,
});
},
},
{ type: 'separator', label: `v${appVersion.data}` },
{
key: 'settings',
label: 'Settings',
hotkeyAction: 'settings.show',
leftSlot: <Icon icon="gear" />,
onSelect: () => {
dialog.show({
id: 'settings',
size: 'md',
title: 'Settings',
render: () => <SettingsDialog />,
});
},
},
{ type: 'separator', label: `Yaak v${appVersion.data}` },
{
key: 'update-mode',
label: updateMode === 'stable' ? 'Enable Beta' : 'Disable Beta',
onSelect: () => setUpdateMode(updateMode === 'stable' ? 'beta' : 'stable'),
leftSlot: <Icon icon="camera" />,
leftSlot: <Icon icon="rocket" />,
},
{
key: 'update-check',
@@ -87,7 +109,7 @@ export function SettingsDropdown() {
},
]}
>
<IconButton size="sm" title="Request Options" icon="gear" className="pointer-events-auto" />
<IconButton size="sm" title="Main Menu" icon="hamburger" className="pointer-events-auto" />
</Dropdown>
);
}

View File

@@ -16,7 +16,7 @@ import { useDeleteFolder } from '../hooks/useDeleteFolder';
import { useDeleteRequest } from '../hooks/useDeleteRequest';
import { useDuplicateRequest } from '../hooks/useDuplicateRequest';
import { useFolders } from '../hooks/useFolders';
import { useHotkey } from '../hooks/useHotkey';
import { useHotKey } from '../hooks/useHotKey';
import { useKeyValue } from '../hooks/useKeyValue';
import { useLatestResponse } from '../hooks/useLatestResponse';
import { usePrompt } from '../hooks/usePrompt';
@@ -55,7 +55,6 @@ export function Sidebar({ className }: Props) {
const { hidden } = useSidebarHidden();
const sidebarRef = useRef<HTMLLIElement>(null);
const activeRequestId = useActiveRequestId();
const duplicateRequest = useDuplicateRequest({ id: activeRequestId ?? '', navigateAfter: true });
const activeEnvironmentId = useActiveEnvironmentId();
const requests = useRequests();
const folders = useFolders();
@@ -76,8 +75,6 @@ export function Sidebar({ className }: Props) {
namespace: NAMESPACE_NO_SYNC,
});
useHotkey('request.duplicate', () => duplicateRequest.mutate());
const isCollapsed = useCallback(
(id: string) => collapsed.value?.[id] ?? false,
[collapsed.value],
@@ -209,7 +206,7 @@ export function Sidebar({ className }: Props) {
useKeyPressEvent('Backspace', handleDeleteKey);
useKeyPressEvent('Delete', handleDeleteKey);
useHotkey('sidebar.focus', () => {
useHotKey('sidebar.focus', () => {
if (hidden || hasFocus) return;
// Select 0 index on focus if none selected
focusActiveRequest(
@@ -649,10 +646,7 @@ const SidebarItem = forwardRef(function SidebarItem(
label: 'Duplicate',
hotkeyAction: 'request.duplicate',
leftSlot: <Icon icon="copy" />,
onSelect: () => {
console.log('DUPLICATE');
duplicateRequest.mutate();
},
onSelect: () => duplicateRequest.mutate(),
},
{
key: 'deleteRequest',

View File

@@ -1,7 +1,6 @@
import { memo } from 'react';
import { useCreateFolder } from '../hooks/useCreateFolder';
import { useCreateRequest } from '../hooks/useCreateRequest';
import { useHotkey } from '../hooks/useHotkey';
import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { Dropdown } from './core/Dropdown';
import { IconButton } from './core/IconButton';
@@ -12,8 +11,6 @@ export const SidebarActions = memo(function SidebarActions() {
const createFolder = useCreateFolder();
const { hidden, toggle } = useSidebarHidden();
useHotkey('request.create', () => createRequest.mutate({}));
return (
<HStack>
<IconButton

View File

@@ -2,7 +2,7 @@ import classNames from 'classnames';
import type { EditorView } from 'codemirror';
import type { FormEvent } from 'react';
import { memo, useCallback, useRef, useState } from 'react';
import { useHotkey } from '../hooks/useHotkey';
import { useHotKey } from '../hooks/useHotKey';
import { useIsResponseLoading } from '../hooks/useIsResponseLoading';
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
import { useSendRequest } from '../hooks/useSendRequest';
@@ -40,7 +40,7 @@ export const UrlBar = memo(function UrlBar({ id: requestId, url, method, classNa
[sendRequest],
);
useHotkey('urlBar.focus', () => {
useHotKey('urlBar.focus', () => {
const head = inputRef.current?.state.doc.length ?? 0;
inputRef.current?.dispatch({
selection: { anchor: 0, head },

View File

@@ -177,8 +177,8 @@ function HeaderSize({ className, ...props }: HeaderSizeProps) {
<div
className={classNames(
className,
'h-md pt-[1px] flex items-center w-full pr-3 border-b',
platform?.osType === 'Darwin' && 'pl-20',
'h-md pt-[1px] flex items-center w-full border-b',
platform?.osType === 'Darwin' ? 'pl-20 pr-1' : 'pl-1',
)}
{...props}
/>

View File

@@ -1,5 +1,5 @@
import classNames from 'classnames';
import React, { memo } from 'react';
import React, { memo, useState } from 'react';
import { Icon } from './core/Icon';
import { HStack } from './core/Stacks';
import { EnvironmentActionsDropdown } from './EnvironmentActionsDropdown';
@@ -7,12 +7,17 @@ import { RecentRequestsDropdown } from './RecentRequestsDropdown';
import { SettingsDropdown } from './SettingsDropdown';
import { SidebarActions } from './SidebarActions';
import { WorkspaceActionsDropdown } from './WorkspaceActionsDropdown';
import { useOsInfo } from '../hooks/useOsInfo';
import { Button } from './core/Button';
import { appWindow } from '@tauri-apps/api/window';
interface Props {
className?: string;
}
export const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Props) {
const osInfo = useOsInfo();
const [maximized, setMaximized] = useState<boolean>(false);
return (
<HStack
space={2}
@@ -31,8 +36,51 @@ export const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Prop
<div className="pointer-events-none">
<RecentRequestsDropdown />
</div>
<div className="flex-1 flex justify-end -mr-2 pointer-events-none">
<div className="flex-1 flex items-center h-full justify-end pointer-events-none">
<SettingsDropdown />
{osInfo?.osType !== 'Darwin' && (
<HStack className="ml-4" space={1} alignItems="center">
<Button className="!text-gray-600 rounded-none" onClick={() => appWindow.minimize()}>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path fill="currentColor" d="M14 8v1H3V8z" />
</svg>
</Button>
<Button
className="!text-gray-600 rounded-none"
onClick={async () => {
await appWindow.toggleMaximize();
setMaximized(await appWindow.isMaximized());
}}
>
{maximized ? (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path fill="currentColor" d="M3 3v10h10V3zm9 9H4V4h8z" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<g fill="currentColor">
<path d="M3 5v9h9V5zm8 8H4V6h7z" />
<path fillRule="evenodd" d="M5 5h1V4h7v7h-1v1h2V3H5z" clipRule="evenodd" />
</g>
</svg>
)}
</Button>
<Button
color="custom"
className="text-gray-600 rounded-none hocus:bg-red-200 hocus:text-gray-800"
onClick={() => appWindow.close()}
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path
fill="currentColor"
fillRule="evenodd"
d="m7.116 8l-4.558 4.558l.884.884L8 8.884l4.558 4.558l.884-.884L8.884 8l4.558-4.558l-.884-.884L8 7.116L3.442 2.558l-.884.884z"
clipRule="evenodd"
/>
</svg>
</Button>
</HStack>
)}
</div>
</HStack>
);

View File

@@ -1,8 +1,8 @@
import classNames from 'classnames';
import type { HTMLAttributes, ReactNode } from 'react';
import { forwardRef, useImperativeHandle, useMemo, useRef } from 'react';
import type { HotkeyAction } from '../../hooks/useHotkey';
import { useFormattedHotkey, useHotkey } from '../../hooks/useHotkey';
import type { HotkeyAction } from '../../hooks/useHotKey';
import { useFormattedHotkey, useHotKey } from '../../hooks/useHotKey';
import { Icon } from './Icon';
const colorStyles = {
@@ -52,7 +52,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
}: ButtonProps,
ref,
) {
const hotkeyTrigger = useFormattedHotkey(hotkeyAction ?? null);
const hotkeyTrigger = useFormattedHotkey(hotkeyAction ?? null)?.join('');
const fullTitle = hotkeyTrigger ? `${title} ${hotkeyTrigger}` : title;
const classes = useMemo(
@@ -80,7 +80,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
() => buttonRef.current,
);
useHotkey(hotkeyAction ?? null, () => {
useHotKey(hotkeyAction ?? null, () => {
buttonRef.current?.click();
});

View File

@@ -1,6 +1,6 @@
import classNames from 'classnames';
import { useCallback } from 'react';
import { Icon } from './Icon';
import { HStack } from './Stacks';
interface Props {
checked: boolean;
@@ -8,33 +8,47 @@ interface Props {
onChange: (checked: boolean) => void;
disabled?: boolean;
className?: string;
hideLabel?: boolean;
}
export function Checkbox({ checked, onChange, className, disabled, title }: Props) {
const handleClick = useCallback(() => {
onChange(!checked);
}, [onChange, checked]);
export function Checkbox({ checked, onChange, className, disabled, title, hideLabel }: Props) {
return (
<button
role="checkbox"
aria-checked={checked ? 'true' : 'false'}
disabled={disabled}
onClick={handleClick}
title={title}
className={classNames(
className,
'flex-shrink-0 w-4 h-4 border border-gray-200 rounded',
'focus:border-focus',
'disabled:opacity-disabled',
checked && 'bg-gray-200/10',
// Remove focus style
'outline-none',
)}
<HStack
as="label"
space={2}
alignItems="center"
className={classNames(className, disabled && 'opacity-disabled')}
>
<div className="flex items-center justify-center">
<Icon size="sm" icon={checked ? 'check' : 'empty'} />
<div className="relative flex">
<input
aria-hidden
className="appearance-none w-4 h-4 flex-shrink-0 border border-gray-200 rounded focus:border-focus outline-none ring-0"
type="checkbox"
disabled={disabled}
onChange={() => onChange(!checked)}
/>
<div className="absolute inset-0 flex items-center justify-center">
<Icon size="sm" icon={checked ? 'check' : 'empty'} />
</div>
</div>
</button>
{/*<button*/}
{/* role="checkbox"*/}
{/* aria-checked={checked ? 'true' : 'false'}*/}
{/* disabled={disabled}*/}
{/* onClick={handleClick}*/}
{/* title={title}*/}
{/* className={classNames(*/}
{/* className,*/}
{/* 'flex-shrink-0 w-4 h-4 border border-gray-200 rounded',*/}
{/* 'focus:border-focus',*/}
{/* 'disabled:opacity-disabled',*/}
{/* checked && 'bg-gray-200/10',*/}
{/* // Remove focus style*/}
{/* 'outline-none',*/}
{/* )}*/}
{/*>*/}
{/*</button>*/}
{!hideLabel && title}
</HStack>
);
}

View File

@@ -20,7 +20,8 @@ import React, {
useState,
} from 'react';
import { useKey, useKeyPressEvent, useWindowSize } from 'react-use';
import type { HotkeyAction } from '../../hooks/useHotkey';
import type { HotkeyAction } from '../../hooks/useHotKey';
import { useHotKey } from '../../hooks/useHotKey';
import { Overlay } from '../Overlay';
import { Button } from './Button';
import { HotKey } from './HotKey';
@@ -50,6 +51,7 @@ export type DropdownItem = DropdownItemDefault | DropdownItemSeparator;
export interface DropdownProps {
children: ReactElement<HTMLAttributes<HTMLButtonElement>>;
items: DropdownItem[];
openOnHotKeyAction?: HotkeyAction;
}
export interface DropdownRef {
@@ -63,20 +65,24 @@ export interface DropdownRef {
}
export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown(
{ children, items }: DropdownProps,
{ children, items, openOnHotKeyAction }: DropdownProps,
ref,
) {
const [open, setOpen] = useState<boolean>(false);
const [isOpen, setIsOpen] = useState<boolean>(false);
const [defaultSelectedIndex, setDefaultSelectedIndex] = useState<number>();
const buttonRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<Omit<DropdownRef, 'open'>>(null);
useHotKey(openOnHotKeyAction ?? null, () => {
setIsOpen(true);
});
useImperativeHandle(ref, () => ({
...menuRef.current,
isOpen: open,
isOpen: isOpen,
toggle(activeIndex?: number) {
if (!open) this.open(activeIndex);
else setOpen(false);
if (!isOpen) this.open(activeIndex);
else setIsOpen(false);
},
open(activeIndex?: number) {
if (activeIndex === undefined) {
@@ -84,7 +90,7 @@ export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown
} else {
setDefaultSelectedIndex(activeIndex >= 0 ? activeIndex : items.length + activeIndex);
}
setOpen(true);
setIsOpen(true);
},
}));
@@ -101,41 +107,40 @@ export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown
e.preventDefault();
e.stopPropagation();
setDefaultSelectedIndex(undefined);
setOpen((o) => !o);
setIsOpen((o) => !o);
}),
};
return cloneElement(existingChild, props);
}, [children]);
const handleClose = useCallback(() => {
setOpen(false);
setIsOpen(false);
buttonRef.current?.focus();
}, []);
useEffect(() => {
buttonRef.current?.setAttribute('aria-expanded', open.toString());
}, [open]);
buttonRef.current?.setAttribute('aria-expanded', isOpen.toString());
}, [isOpen]);
const windowSize = useWindowSize();
const triggerRect = useMemo(() => {
if (!windowSize) return null; // No-op to TS happy with this dep
if (!open) return null;
if (!isOpen) return null;
return buttonRef.current?.getBoundingClientRect();
}, [open, windowSize]);
}, [isOpen, windowSize]);
return (
<>
{child}
{open && triggerRect && (
<Menu
ref={menuRef}
showTriangle
defaultSelectedIndex={defaultSelectedIndex}
items={items}
triggerShape={triggerRect}
onClose={handleClose}
/>
)}
<Menu
ref={menuRef}
showTriangle
defaultSelectedIndex={defaultSelectedIndex}
items={items}
triggerShape={triggerRect ?? null}
onClose={handleClose}
isOpen={isOpen}
/>
</>
);
});
@@ -161,15 +166,12 @@ export const ContextMenu = forwardRef<DropdownRef, ContextMenuProps>(function Co
[show],
);
if (show === null) {
return null;
}
return (
<Menu
className={className}
ref={ref}
items={items}
isOpen={show != null}
onClose={onClose}
triggerShape={triggerShape}
/>
@@ -180,13 +182,22 @@ interface MenuProps {
className?: string;
defaultSelectedIndex?: number;
items: DropdownProps['items'];
triggerShape: Pick<DOMRect, 'top' | 'bottom' | 'left' | 'right'>;
triggerShape: Pick<DOMRect, 'top' | 'bottom' | 'left' | 'right'> | null;
onClose: () => void;
showTriangle?: boolean;
isOpen: boolean;
}
const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuProps>(function Menu(
{ className, items, onClose, triggerShape, defaultSelectedIndex, showTriangle }: MenuProps,
{
className,
isOpen,
items,
onClose,
triggerShape,
defaultSelectedIndex,
showTriangle,
}: MenuProps,
ref,
) {
const containerRef = useRef<HTMLDivElement | null>(null);
@@ -291,6 +302,8 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
containerStyles: CSSProperties;
triangleStyles: CSSProperties | null;
}>(() => {
if (triggerShape == null) return { containerStyles: {}, triangleStyles: null };
const docRect = document.documentElement.getBoundingClientRect();
const width = triggerShape.right - triggerShape.left;
const hSpaceRemaining = docRect.width - triggerShape.left;
@@ -322,61 +335,76 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
if (items.length === 0) return null;
return (
<Overlay open variant="transparent" portalName="dropdown" zIndex={50}>
<div>
<div tabIndex={-1} aria-hidden className="fixed inset-0 z-30" onClick={onClose} />
<motion.div
tabIndex={0}
onKeyDown={handleMenuKeyDown}
initial={{ opacity: 0, y: -5, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
role="menu"
aria-orientation="vertical"
dir="ltr"
ref={containerRef}
style={containerStyles}
className={classNames(className, 'outline-none my-1 pointer-events-auto fixed z-50')}
>
{triangleStyles && showTriangle && (
<span
aria-hidden
style={triangleStyles}
className="bg-gray-50 absolute rotate-45 border-gray-200 border-t border-l"
<>
{items.map(
(item) =>
item.type !== 'separator' && (
<MenuItemHotKey
key={item.key}
onSelect={handleSelect}
item={item}
action={item.hotkeyAction}
/>
)}
{containerStyles && (
<VStack
space={0.5}
ref={initMenu}
style={menuStyles}
className={classNames(
className,
'h-auto bg-gray-50 rounded-md shadow-lg dark:shadow-gray-0 py-1.5 border',
'border-gray-200 overflow-auto mb-1 mx-0.5',
)}
),
)}
{isOpen && (
<Overlay open variant="transparent" portalName="dropdown" zIndex={50}>
<div>
<div tabIndex={-1} aria-hidden className="fixed inset-0 z-30" onClick={onClose} />
<motion.div
tabIndex={0}
onKeyDown={handleMenuKeyDown}
initial={{ opacity: 0, y: -5, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
role="menu"
aria-orientation="vertical"
dir="ltr"
ref={containerRef}
style={containerStyles}
className={classNames(className, 'outline-none my-1 pointer-events-auto fixed z-50')}
>
{items.map((item, i) => {
if (item.type === 'separator') {
return <Separator key={i} className="my-1.5" label={item.label} />;
}
if (item.hidden) {
return null;
}
return (
<MenuItem
focused={i === selectedIndex}
onFocus={handleFocus}
onSelect={handleSelect}
key={item.key}
item={item}
/>
);
})}
</VStack>
)}
</motion.div>
</div>
</Overlay>
{triangleStyles && showTriangle && (
<span
aria-hidden
style={triangleStyles}
className="bg-gray-50 absolute rotate-45 border-gray-200 border-t border-l"
/>
)}
{containerStyles && (
<VStack
space={0.5}
ref={initMenu}
style={menuStyles}
className={classNames(
className,
'h-auto bg-gray-50 rounded-md shadow-lg dark:shadow-gray-0 py-1.5 border',
'border-gray-200 overflow-auto mb-1 mx-0.5',
)}
>
{items.map((item, i) => {
if (item.type === 'separator') {
return <Separator key={i} className="my-1.5" label={item.label} />;
}
if (item.hidden) {
return null;
}
return (
<MenuItem
focused={i === selectedIndex}
onFocus={handleFocus}
onSelect={handleSelect}
key={item.key}
item={item}
/>
);
})}
</VStack>
)}
</motion.div>
</div>
</Overlay>
)}
</>
);
});
@@ -443,3 +471,14 @@ function MenuItem({ className, focused, onFocus, item, onSelect, ...props }: Men
</Button>
);
}
interface MenuItemHotKeyProps {
action: HotkeyAction | undefined;
onSelect: MenuItemProps['onSelect'];
item: MenuItemProps['item'];
}
function MenuItemHotKey({ action, onSelect, item }: MenuItemHotKeyProps) {
useHotKey(action ?? null, () => onSelect(item));
return null;
}

View File

@@ -290,6 +290,8 @@ 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)) {
onChange.current?.(update.state.doc.toString());
}

View File

@@ -1,7 +1,8 @@
import classNames from 'classnames';
import type { HotkeyAction } from '../../hooks/useHotkey';
import { useFormattedHotkey } from '../../hooks/useHotkey';
import type { HotkeyAction } from '../../hooks/useHotKey';
import { useFormattedHotkey } from '../../hooks/useHotKey';
import { useOsInfo } from '../../hooks/useOsInfo';
import { HStack } from './Stacks';
interface Props {
action: HotkeyAction | null;
@@ -11,20 +12,24 @@ interface Props {
export function HotKey({ action, className, variant }: Props) {
const osInfo = useOsInfo();
const label = useFormattedHotkey(action);
if (label === null || osInfo == null) {
const labelParts = useFormattedHotkey(action);
if (labelParts === null || osInfo == null) {
return null;
}
return (
<span
<HStack
className={classNames(
className,
variant === 'with-bg' && 'rounded border',
'text-sm text-gray-1000 text-opacity-disabled',
'text-gray-1000 text-opacity-disabled',
)}
>
{label}
</span>
{labelParts.map((char, index) => (
<div key={index} className="min-w-[1.1em] text-center">
{char}
</div>
))}
</HStack>
);
}

View File

@@ -1,8 +1,8 @@
import type { HotkeyAction } from '../../hooks/useHotkey';
import { useHotKeyLabel } from '../../hooks/useHotkey';
import type { HotkeyAction } from '../../hooks/useHotKey';
import { useHotKeyLabel } from '../../hooks/useHotKey';
interface Props {
action: HotkeyAction | null;
action: HotkeyAction;
}
export function HotKeyLabel({ action }: Props) {

View File

@@ -1,7 +1,8 @@
import React from 'react';
import type { HotkeyAction } from '../../hooks/useHotkey';
import type { HotkeyAction } from '../../hooks/useHotKey';
import { HotKey } from './HotKey';
import { HotKeyLabel } from './HotKeyLabel';
import { HStack, VStack } from './Stacks';
interface Props {
hotkeys: HotkeyAction[];
@@ -10,14 +11,14 @@ interface Props {
export const HotKeyList = ({ hotkeys }: Props) => {
return (
<div className="mx-auto h-full flex items-center text-gray-700 text-sm">
<div className="flex flex-col gap-1">
<VStack space={2}>
{hotkeys.map((hotkey) => (
<div key={hotkey} className="grid grid-cols-2">
<HStack key={hotkey} className="grid grid-cols-2">
<HotKeyLabel action={hotkey} />
<HotKey className="ml-auto" action={hotkey} />
</div>
</HStack>
))}
</div>
</VStack>
</div>
);
};

View File

@@ -1,4 +1,4 @@
import * as ReactIcons from '@radix-ui/react-icons';
import * as I from '@radix-ui/react-icons';
import classNames from 'classnames';
import type { HTMLAttributes } from 'react';
import { memo } from 'react';
@@ -6,46 +6,49 @@ import { ReactComponent as LeftPanelHiddenIcon } from '../../assets/icons/LeftPa
import { ReactComponent as LeftPanelVisibleIcon } from '../../assets/icons/LeftPanelVisibleIcon.svg';
const icons = {
archive: ReactIcons.ArchiveIcon,
camera: ReactIcons.CameraIcon,
chat: ReactIcons.ChatBubbleIcon,
check: ReactIcons.CheckIcon,
checkbox: ReactIcons.CheckboxIcon,
clock: ReactIcons.ClockIcon,
chevronDown: ReactIcons.ChevronDownIcon,
chevronRight: ReactIcons.ChevronRightIcon,
code: ReactIcons.CodeIcon,
colorWheel: ReactIcons.ColorWheelIcon,
copy: ReactIcons.CopyIcon,
dividerH: ReactIcons.DividerHorizontalIcon,
dotsH: ReactIcons.DotsHorizontalIcon,
dotsV: ReactIcons.DotsVerticalIcon,
download: ReactIcons.DownloadIcon,
drag: ReactIcons.DragHandleDots2Icon,
eye: ReactIcons.EyeOpenIcon,
eyeClosed: ReactIcons.EyeClosedIcon,
gear: ReactIcons.GearIcon,
hamburger: ReactIcons.HamburgerMenuIcon,
home: ReactIcons.HomeIcon,
listBullet: ReactIcons.ListBulletIcon,
magicWand: ReactIcons.MagicWandIcon,
magnifyingGlass: ReactIcons.MagnifyingGlassIcon,
moon: ReactIcons.MoonIcon,
openNewWindow: ReactIcons.OpenInNewWindowIcon,
paperPlane: ReactIcons.PaperPlaneIcon,
pencil: ReactIcons.Pencil2Icon,
plus: ReactIcons.PlusIcon,
plusCircle: ReactIcons.PlusCircledIcon,
question: ReactIcons.QuestionMarkIcon,
rows: ReactIcons.RowsIcon,
sun: ReactIcons.SunIcon,
trash: ReactIcons.TrashIcon,
triangleDown: ReactIcons.TriangleDownIcon,
triangleLeft: ReactIcons.TriangleLeftIcon,
triangleRight: ReactIcons.TriangleRightIcon,
update: ReactIcons.UpdateIcon,
upload: ReactIcons.UploadIcon,
x: ReactIcons.Cross2Icon,
archive: I.ArchiveIcon,
chat: I.ChatBubbleIcon,
check: I.CheckIcon,
checkbox: I.CheckboxIcon,
chevronDown: I.ChevronDownIcon,
chevronRight: I.ChevronRightIcon,
clock: I.ClockIcon,
code: I.CodeIcon,
colorWheel: I.ColorWheelIcon,
copy: I.CopyIcon,
dividerH: I.DividerHorizontalIcon,
dotsH: I.DotsHorizontalIcon,
dotsV: I.DotsVerticalIcon,
download: I.DownloadIcon,
drag: I.DragHandleDots2Icon,
eye: I.EyeOpenIcon,
eyeClosed: I.EyeClosedIcon,
gear: I.GearIcon,
hamburger: I.HamburgerMenuIcon,
home: I.HomeIcon,
keyboard: I.KeyboardIcon,
listBullet: I.ListBulletIcon,
magicWand: I.MagicWandIcon,
magnifyingGlass: I.MagnifyingGlassIcon,
minus: I.MinusIcon,
moon: I.MoonIcon,
openNewWindow: I.OpenInNewWindowIcon,
paperPlane: I.PaperPlaneIcon,
pencil: I.Pencil2Icon,
plus: I.PlusIcon,
plusCircle: I.PlusCircledIcon,
question: I.QuestionMarkIcon,
rocket: I.RocketIcon,
rows: I.RowsIcon,
square: I.SquareIcon,
sun: I.SunIcon,
trash: I.TrashIcon,
triangleDown: I.TriangleDownIcon,
triangleLeft: I.TriangleLeftIcon,
triangleRight: I.TriangleRightIcon,
update: I.UpdateIcon,
upload: I.UploadIcon,
x: I.Cross2Icon,
// Custom
leftPanelHidden: LeftPanelHiddenIcon,

View File

@@ -335,7 +335,8 @@ const FormRow = memo(function FormRow({
<span className="w-3" />
)}
<Checkbox
title={pairContainer.pair.enabled ? 'disable entry' : 'Enable item'}
hideLabel
title={pairContainer.pair.enabled ? 'Disable item' : 'Enable item'}
disabled={isLast}
checked={isLast ? false : !!pairContainer.pair.enabled}
className={classNames('mr-2', isLast && '!opacity-disabled')}

View File

@@ -54,7 +54,7 @@ export const VStack = forwardRef(function VStack(
});
type BaseStackProps = HTMLAttributes<HTMLElement> & {
as?: ComponentType | 'ul' | 'form';
as?: ComponentType | 'ul' | 'label' | 'form';
space?: keyof typeof gapClasses;
alignItems?: 'start' | 'center' | 'stretch';
justifyContent?: 'start' | 'center' | 'end' | 'between';

View File

@@ -13,8 +13,6 @@ export function useCreateEnvironment() {
const prompt = usePrompt();
const workspaceId = useActiveWorkspaceId();
const queryClient = useQueryClient();
const environments = useEnvironments();
const workspaces = useWorkspaces();
return useMutation<Environment, unknown, void>({
mutationFn: async () => {
@@ -26,7 +24,7 @@ export function useCreateEnvironment() {
});
return invoke('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('create_folder', { workspaceId, ...patch });
},
onSettled: () => trackEvent('folder', 'create'),
onSettled: () => trackEvent('Folder', 'Create'),
onSuccess: async (request) => {
await queryClient.invalidateQueries(foldersQueryKey({ workspaceId: request.workspaceId }));
},

View File

@@ -28,7 +28,7 @@ export function useCreateRequest() {
patch.folderId = patch.folderId || activeRequest?.folderId;
return invoke('create_request', { workspaceId, name: '', ...patch });
},
onSettled: () => trackEvent('http_request', 'create'),
onSettled: () => trackEvent('HttpRequest', 'Create'),
onSuccess: async (request) => {
queryClient.setQueryData<HttpRequest[]>(
requestsQueryKey({ workspaceId: request.workspaceId }),

View File

@@ -12,7 +12,7 @@ export function useCreateWorkspace({ navigateAfter }: { navigateAfter: boolean }
mutationFn: (patch) => {
return invoke('create_workspace', patch);
},
onSettled: () => trackEvent('workspace', 'create'),
onSettled: () => trackEvent('Workspace', 'Create'),
onSuccess: async (workspace) => {
queryClient.setQueryData<Workspace[]>(workspacesQueryKey({}), (workspaces) => [
...(workspaces ?? []),

View File

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

View File

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

View File

@@ -27,7 +27,7 @@ export function useDeleteFolder(id: string | null) {
if (!confirmed) return null;
return invoke('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 useDeleteResponse(id: string | null) {
mutationFn: async () => {
return await invoke('delete_response', { id: id });
},
onSettled: () => trackEvent('http_response', 'delete'),
onSettled: () => trackEvent('HttpResponse', 'Delete'),
onSuccess: ({ requestId, id: responseId }) => {
queryClient.setQueryData<HttpResponse[]>(responsesQueryKey({ requestId }), (responses) =>
(responses ?? []).filter((response) => response.id !== responseId),

View File

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

View File

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

View File

@@ -23,7 +23,7 @@ export function useDuplicateRequest({
if (id === null) throw new Error("Can't duplicate a null request");
return invoke('duplicate_request', { id });
},
onSettled: () => trackEvent('http_request', 'duplicate'),
onSettled: () => trackEvent('HttpRequest', 'Duplicate'),
onSuccess: async (request) => {
queryClient.setQueryData<HttpRequest[]>(
requestsQueryKey({ workspaceId: request.workspaceId }),

View File

@@ -1,7 +1,8 @@
import type { OsType } from '@tauri-apps/api/os';
import { useEffect, useRef } from 'react';
import { debounce } from '../lib/debounce';
import { useOsInfo } from './useOsInfo';
import type {OsType} from '@tauri-apps/api/os';
import {useEffect, useRef} from 'react';
import {capitalize} from '../lib/capitalize';
import {debounce} from '../lib/debounce';
import {useOsInfo} from './useOsInfo';
export type HotkeyAction =
| 'request.send'
@@ -10,7 +11,11 @@ export type HotkeyAction =
| 'sidebar.toggle'
| 'sidebar.focus'
| 'urlBar.focus'
| 'environmentEditor.toggle';
| 'environmentEditor.toggle'
| 'hotkeys.showHelp'
| 'requestSwitcher.prev'
| 'requestSwitcher.next'
| 'settings.show';
const hotkeys: Record<HotkeyAction, string[]> = {
'request.send': ['CmdCtrl+Enter', 'CmdCtrl+r'],
@@ -20,13 +25,33 @@ const hotkeys: Record<HotkeyAction, string[]> = {
'sidebar.focus': ['CmdCtrl+1'],
'urlBar.focus': ['CmdCtrl+l'],
'environmentEditor.toggle': ['CmdCtrl+e'],
'hotkeys.showHelp': ['CmdCtrl+/'],
'settings.show': ['CmdCtrl+,'],
'requestSwitcher.prev': ['Control+Tab'],
'requestSwitcher.next': ['Control+Shift+Tab'],
};
const hotkeyLabels: Record<HotkeyAction, string> = {
'request.send': 'Send Request',
'request.create': 'New Request',
'request.duplicate': 'Duplicate Request',
'sidebar.toggle': 'Toggle Sidebar',
'sidebar.focus': 'Focus Sidebar',
'urlBar.focus': 'Focus URL',
'environmentEditor.toggle': 'Edit Environments',
'hotkeys.showHelp': 'Show Keyboard Shortcuts',
'requestSwitcher.prev': 'Go To Next Request',
'requestSwitcher.next': 'Go To Previous Request',
'settings.show': 'Open Settings',
};
export const hotkeyActions: HotkeyAction[] = Object.keys(hotkeys) as (keyof typeof hotkeys)[];
interface Options {
enable?: boolean;
}
export function useHotkey(
export function useHotKey(
action: HotkeyAction | null,
callback: (e: KeyboardEvent) => void,
options: Options = {},
@@ -62,6 +87,8 @@ export function useAnyHotkey(
currentKeys.current.add(normalizeKey(e.key, os));
console.log("HOTKEY", e.key);
for (const [hkAction, hkKeys] of Object.entries(hotkeys) as [HotkeyAction, string[]][]) {
for (const hkKey of hkKeys) {
const keys = hkKey.split('+');
@@ -93,28 +120,11 @@ export function useAnyHotkey(
}, [options.enable, os]);
}
export function useHotKeyLabel(action: HotkeyAction | null): string {
switch (action) {
case 'request.send':
return 'Send Request';
case 'request.create':
return 'New Request';
case 'request.duplicate':
return 'Duplicate Request';
case 'sidebar.toggle':
return 'Toggle Sidebar';
case 'sidebar.focus':
return 'Focus Sidebar';
case 'urlBar.focus':
return 'Focus URL';
case 'environmentEditor.toggle':
return 'Edit Environments';
default:
return 'Unknown';
}
export function useHotKeyLabel(action: HotkeyAction): string {
return hotkeyLabels[action];
}
export function useFormattedHotkey(action: HotkeyAction | null): string | null {
export function useFormattedHotkey(action: HotkeyAction | null): string[] | null {
const osInfo = useOsInfo();
const trigger = action != null ? hotkeys[action]?.[0] ?? null : null;
if (trigger == null || osInfo == null) {
@@ -135,22 +145,24 @@ export function useFormattedHotkey(action: HotkeyAction | null): string | null {
labelParts.push('⌃');
} else if (p === 'Enter') {
labelParts.push('↩');
} else if (p === 'Tab') {
labelParts.push('⇥');
} else {
labelParts.push(p.toUpperCase());
labelParts.push(capitalize(p));
}
} else {
if (p === 'CmdCtrl') {
labelParts.push('Ctrl');
} else {
labelParts.push(p);
labelParts.push(capitalize(p));
}
}
}
if (os === 'Darwin') {
return labelParts.join('');
return labelParts;
} else {
return labelParts.join('+');
return [labelParts.join('+')];
}
}

View File

@@ -10,7 +10,7 @@ export function useSendAnyRequest() {
const alert = useAlert();
return useMutation<HttpResponse, string, string | null>({
mutationFn: (id) => invoke('send_request', { requestId: id, environmentId }),
onSettled: () => trackEvent('http_request', 'send'),
onSettled: () => trackEvent('HttpRequest', 'Send'),
onError: (err) => alert({ title: 'Export Failed', body: err }),
});
}

View File

@@ -10,6 +10,5 @@ export function useSendManyRequests() {
sendAnyRequest.mutate(id);
}
},
onSettled: () => trackEvent('http_request', 'send'),
});
}

View File

@@ -0,0 +1,18 @@
import { useQuery } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import type { Settings } from '../lib/models';
export function settingsQueryKey() {
return ['settings'];
}
export function useSettings() {
return (
useQuery({
queryKey: settingsQueryKey(),
queryFn: async () => {
return (await invoke('get_settings')) as Settings;
},
}).data ?? undefined
);
}

View File

@@ -1,30 +1,35 @@
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import type { Appearance } from '../lib/theme/window';
import {
getAppearance,
setAppearance,
setAppearanceOnDocument,
getPreferredAppearance,
subscribeToPreferredAppearanceChange,
} from '../lib/theme/window';
import { useKeyValue } from './useKeyValue';
import { useSettings } from './useSettings';
export function useTheme() {
const appearanceKv = useKeyValue<Appearance>({
key: 'appearance',
defaultValue: getAppearance(),
});
const [preferredAppearance, setPreferredAppearance] = useState<Appearance>(
getPreferredAppearance(),
);
const handleToggleAppearance = async () => {
appearanceKv.set(appearanceKv.value === 'dark' ? 'light' : 'dark');
};
const settings = useSettings();
// Set appearance when preferred theme changes
useEffect(() => subscribeToPreferredAppearanceChange(appearanceKv.set), [appearanceKv.set]);
useEffect(() => {
return subscribeToPreferredAppearanceChange(setPreferredAppearance);
}, []);
// Sync appearance when k/v changes
useEffect(() => setAppearance(appearanceKv.value), [appearanceKv.value]);
const appearance =
settings == null || settings?.appearance === 'system'
? preferredAppearance
: settings.appearance;
return {
appearance: appearanceKv.value,
toggleAppearance: handleToggleAppearance,
};
useEffect(() => {
if (settings == null) {
return;
}
setAppearanceOnDocument(settings.appearance as Appearance);
}, [appearance, settings]);
return { appearance };
}

View File

@@ -0,0 +1,18 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import type { HttpRequest, Settings } from '../lib/models';
import { requestsQueryKey } from './useRequests';
import { settingsQueryKey } from './useSettings';
export function useUpdateSettings() {
const queryClient = useQueryClient();
return useMutation<void, unknown, Settings>({
mutationFn: async (settings) => {
await invoke('update_settings', { settings });
},
onMutate: async (settings) => {
queryClient.setQueryData<Settings>(settingsQueryKey(), settings);
},
});
}

View File

@@ -1,58 +1,20 @@
import { getVersion } from '@tauri-apps/api/app';
import type { Environment, Folder, HttpRequest, HttpResponse, KeyValue, Workspace } from './models';
const appVersion = await getVersion();
import { invoke } from '@tauri-apps/api';
export function trackEvent(
resource:
| Workspace['model']
| Environment['model']
| Folder['model']
| HttpRequest['model']
| HttpResponse['model']
| KeyValue['model'],
event: 'create' | 'update' | 'delete' | 'delete_many' | 'send' | 'duplicate',
| 'App'
| 'Workspace'
| 'Environment'
| 'Folder'
| 'HttpRequest'
| 'HttpResponse'
| 'KeyValue',
action: 'Launch' | 'Create' | 'Update' | 'Delete' | 'DeleteMany' | 'Send' | 'Duplicate',
attributes: Record<string, string | number> = {},
) {
send('/e', [
{ name: 'e', value: `${resource}.${event}` },
{ name: 'a', value: JSON.stringify({ ...attributes, version: appVersion }) },
]);
}
export function trackPage(pathname: string) {
if (pathname === sessionStorage.lastPathName) {
return;
}
sessionStorage.lastPathName = pathname;
send('/p', [
{
name: 'h',
value: 'desktop.yaak.app',
},
{ name: 'p', value: pathname },
]);
}
function send(path: string, params: { name: string; value: string | number }[]) {
if (localStorage.disableAnalytics === 'true') {
console.log('Analytics disabled', path, params);
}
params.push({ name: 'id', value: 'site_zOK0d7jeBy2TLxFCnZ' });
params.push({
name: 'tz',
value: Intl.DateTimeFormat().resolvedOptions().timeZone,
});
params.push({ name: 'xy', value: screensize() });
const qs = params.map((v) => `${v.name}=${encodeURIComponent(v.value)}`).join('&');
const url = `https://t.yaak.app/t${path}?${qs}`;
fetch(url, { mode: 'no-cors' }).catch((err) => console.log('Error:', err));
}
function screensize() {
const w = window.screen.width;
const h = window.screen.height;
return `${Math.round(w / 100) * 100}x${Math.round(h / 100) * 100}`;
invoke('track_event', {
resource: resource,
action,
attributes,
}).catch(console.error);
}

View File

@@ -0,0 +1,3 @@
export function capitalize(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1);
}

View File

@@ -9,7 +9,7 @@ export const AUTH_TYPE_NONE = null;
export const AUTH_TYPE_BASIC = 'basic';
export const AUTH_TYPE_BEARER = 'bearer';
export type Model = Workspace | HttpRequest | HttpResponse | KeyValue | Environment;
export type Model = Settings | Workspace | HttpRequest | HttpResponse | KeyValue | Environment;
export interface BaseModel {
readonly id: string;
@@ -17,6 +17,15 @@ export interface BaseModel {
readonly updatedAt: string;
}
export interface Settings extends BaseModel {
readonly model: 'settings';
validateCertificates: boolean;
followRedirects: boolean;
requestTimeout: number;
theme: string;
appearance: string;
}
export interface Workspace extends BaseModel {
readonly model: 'workspace';
name: string;

View File

@@ -1,5 +1,9 @@
import { invoke } from '@tauri-apps/api';
import type { Environment, Folder, HttpRequest, Workspace } from './models';
import type { Environment, Folder, HttpRequest, Settings, Workspace } from './models';
export async function getSettings(): Promise<Settings> {
return invoke('get_settings', {});
}
export async function getRequest(id: string | null): Promise<HttpRequest | null> {
if (id === null) return null;

View File

@@ -1,7 +1,9 @@
import type { AppTheme, AppThemeColors } from './theme';
import { generateCSS, toTailwindVariable } from './theme';
export type Appearance = 'dark' | 'light';
export type Appearance = 'dark' | 'light' | 'system';
const DEFAULT_APPEARANCE: Appearance = 'system';
enum Theme {
yaak = 'yaak',
@@ -61,19 +63,11 @@ const lightTheme: AppTheme = {
},
};
export function getAppearance(): Appearance {
const docAppearance = document.documentElement.getAttribute('data-appearance');
if (docAppearance === 'dark' || docAppearance === 'light') {
return docAppearance;
}
return getPreferredAppearance();
}
export function setAppearanceOnDocument(appearance: Appearance = DEFAULT_APPEARANCE) {
const resolvedAppearance = appearance === 'system' ? getPreferredAppearance() : appearance;
const theme = resolvedAppearance === 'dark' ? darkTheme : lightTheme;
export function setAppearance(a?: Appearance) {
const appearance = a ?? getPreferredAppearance();
const theme = appearance === 'dark' ? darkTheme : lightTheme;
document.documentElement.setAttribute('data-appearance', appearance);
document.documentElement.setAttribute('data-resolved-appearance', resolvedAppearance);
document.documentElement.setAttribute('data-theme', theme.name);
let existingStyleEl = document.head.querySelector(`style[data-theme-definition]`);
@@ -85,11 +79,11 @@ export function setAppearance(a?: Appearance) {
existingStyleEl.textContent = [
`/* ${darkTheme.name} */`,
`[data-appearance="dark"] {`,
`[data-resolved-appearance="dark"] {`,
...generateCSS(darkTheme).map(toTailwindVariable),
'}',
`/* ${lightTheme.name} */`,
`[data-appearance="light"] {`,
`[data-resolved-appearance="light"] {`,
...generateCSS(lightTheme).map(toTailwindVariable),
'}',
].join('\n');

View File

@@ -6,7 +6,11 @@
html,
body,
#root {
@apply w-full h-full overflow-hidden bg-gray-50 text-gray-900;
@apply w-full h-full overflow-hidden;
}
#root {
@apply bg-gray-50 text-gray-900;
}
/* Setup default transitions for elements */
@@ -68,4 +72,14 @@
--color-white: 255 100% 100%;
--color-black: 255 0% 0%;
}
select {
@apply appearance-none;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
background-position: right 0.5rem center;
background-repeat: no-repeat;
background-size: 1.5em 1.5em;
padding-right: 2.5rem;
-webkit-print-color-adjust: exact;
}
}

View File

@@ -2,26 +2,31 @@ import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { attachConsole } from 'tauri-plugin-log-api';
import { App } from './components/App';
import { getKeyValue } from './lib/keyValueStore';
import { maybeRestorePathname } from './lib/persistPathname';
import { getPreferredAppearance, setAppearance } from './lib/theme/window';
import './main.css';
import { getSettings } from './lib/store';
import type { Appearance } from './lib/theme/window';
import { setAppearanceOnDocument } from './lib/theme/window';
import { appWindow } from '@tauri-apps/api/window';
import { type } from '@tauri-apps/api/os';
// Hide decorations here because it doesn't work in Rust for some reason (bug?)
const osType = await type();
if (osType !== 'Darwin') {
await appWindow.setDecorations(false);
}
await attachConsole();
await maybeRestorePathname();
const settings = await getSettings();
setAppearanceOnDocument(settings.appearance as Appearance);
document.addEventListener('keydown', (e) => {
// Don't go back in history on backspace
if (e.key === 'Backspace') e.preventDefault();
});
setAppearance(
await getKeyValue({
key: 'appearance',
fallback: getPreferredAppearance(),
}),
);
createRoot(document.getElementById('root') as HTMLElement).render(
<StrictMode>
<App />

View File

@@ -8,7 +8,7 @@ const height = {
/** @type {import("tailwindcss").Config} */
module.exports = {
darkMode: ['class', '[data-appearance="dark"]'],
darkMode: ['class', '[data-resolved-appearance="dark"]'],
content: ['./index.html', './src-web/**/*.{html,js,jsx,ts,tsx}'],
theme: {
extend: {