Compare commits

...

82 Commits

Author SHA1 Message Date
Gregory Schier
219a6b78da Fix cookie jar 2024-01-28 17:49:04 -08:00
Gregory Schier
fb11aff03f Track dialogs 2024-01-28 16:33:36 -08:00
Gregory Schier
15714ae188 Fix dropdown separator 2024-01-28 16:21:41 -08:00
Gregory Schier
ce116d032d Better format 2024-01-28 16:19:46 -08:00
Gregory Schier
6f41df6e52 More response info 2024-01-28 16:02:49 -08:00
Gregory Schier
0853d2ca95 Better BG color 2024-01-28 14:43:04 -08:00
Gregory Schier
6798331ce5 Cookie Support (#19) 2024-01-28 14:39:51 -08:00
Gregory Schier
5ffc75e0ad Add shift to hotkey dialog hotkey 2024-01-19 22:11:20 -08:00
Gregory Schier
bf92371a49 Show alert after force checking updates 2024-01-19 22:11:02 -08:00
Gregory Schier
bd3da86317 Better dialog padding 2024-01-19 22:08:32 -08:00
Gregory Schier
3db3d42246 Change env hotkey to use shift 2024-01-19 21:53:48 -08:00
Gregory Schier
de8bf3ca70 Fix URL 2px grow on focus 2024-01-19 21:49:51 -08:00
Gregory Schier
8bc131de6c Bump version 2024-01-19 13:42:02 -08:00
Gregory Schier
efce69292d Fix analytics again 2024-01-18 22:28:25 -08:00
Gregory Schier
0ccc893440 Fix dialog close button 2024-01-18 20:57:42 -08:00
Gregory Schier
1f9756c917 Fix URLBar expanded state inner buttons 2024-01-18 20:40:56 -08:00
Gregory Schier
be8f0e4521 Some analytics fixes 2024-01-18 20:23:02 -08:00
Gregory Schier
bcdf51d231 Launch analytics events, changelog, better filter styles 2024-01-18 14:42:02 -08:00
Gregory Schier
1a1553eebd Bump version 2024-01-17 14:56:47 -08:00
Gregory Schier
321c3862fe Custom HTTP method names 2024-01-17 14:52:19 -08:00
Gregory Schier
466d412e65 Workspace header tweak Windows 2024-01-17 18:48:43 -08:00
Gregory Schier
86f50b826f Fix header in fullscreen mode on Mac 2024-01-17 09:34:47 -08:00
Gregory Schier
ac1e646e68 Download response, and some fixes 2024-01-16 17:02:55 -08:00
Gregory Schier
33374eefc7 Fix editor toolbar blocking things 2024-01-15 21:44:53 -08:00
Gregory Schier
7047df4f7e Better request creation (Closes #14) 2024-01-15 21:39:27 -08:00
Gregory Schier
c8bd4d0ae0 XPath plugin 2024-01-15 21:27:47 -08:00
Gregory Schier
1e79f76701 Fix send icon 2024-01-15 15:43:55 -08:00
Gregory Schier
18852dca06 Switch to Lucide icons 2024-01-15 15:42:28 -08:00
Gregory Schier
408e7e80b7 Improve response filter UX 2024-01-15 15:19:29 -08:00
Gregory Schier
fc185de023 JSONPath filter plugins working 2024-01-15 15:06:49 -08:00
Gregory Schier
bb9d3a42f3 Move plugin stuff around 2024-01-15 14:33:51 -08:00
Gregory Schier
baf0f4291d Fix request duplication 2024-01-15 13:47:44 -08:00
Gregory Schier
536066142c Fix workspace defaults 2024-01-15 12:25:13 -08:00
Gregory Schier
04cf16497d Better settings dialog 2024-01-15 12:16:44 -08:00
Gregory Schier
feb5972090 Fix resize observer 2024-01-15 12:02:08 -08:00
Gregory Schier
77bf5a58d8 Move request-related settings to workspace 2024-01-15 11:52:36 -08:00
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
Gregory Schier
626aacf982 Bump version to 2024.0.0 2024-01-08 15:57:59 -08:00
Gregory Schier
d5855c45a6 Hotkey labels 2024-01-08 15:57:21 -08:00
Gregory Schier
793bff9f27 Show hotkeys on empty views 2024-01-08 15:13:44 -08:00
Gregory Schier
88ea68e72f Remove base env, fix hotkeys, and QoL improvements 2024-01-07 22:24:19 -08:00
Gregory Schier
35e40d2c55 Fix hotkeys getting stuck on cmd+tab 2024-01-07 21:32:25 -08:00
Gregory Schier
c472b83409 Always show settings dropdown 2023-11-22 09:39:30 -08:00
Gregory Schier
52c26d235c Tweak margin 2023-11-22 09:37:50 -08:00
Gregory Schier
ac54729012 Fix bottom-up dropdown positioning 2023-11-22 09:35:56 -08:00
Gregory Schier
0586034ef4 Bump version 2023-11-22 09:06:47 -08:00
Gregory Schier
91790ba708 Better linux/Windows support for hotkeys 2023-11-22 09:06:22 -08:00
Gregory Schier
d8ab6c0b50 Good hotkey support 2023-11-22 09:01:48 -08:00
Gregory Schier
b600a21a2b Reset URL bar when request changes 2023-11-21 23:26:29 -08:00
Gregory Schier
4f9d1278f7 Env dialog hotkey 2023-11-21 22:35:28 -08:00
Gregory Schier
15aa93f5f9 Remove response body and basic hotkeys 2023-11-21 22:15:01 -08:00
Gregory Schier
c7798092d8 Remove app-specific menu items 2023-11-21 19:18:40 -08:00
Gregory Schier
5560593aaa Fix macOS menu and fallback URL 2023-11-21 09:24:13 -08:00
Gregory Schier
66639e651d Hide menu on windows/linux 2023-11-21 08:17:37 -08:00
Gregory Schier
8e42d5ccdb Disable sandboxing (again) 2023-11-19 21:59:55 -08:00
Gregory Schier
5c62594087 Fix drag-drop reorder 2023-11-19 21:43:01 -08:00
Gregory Schier
26b6c48657 Postman ID generation 2023-11-19 20:54:02 -08:00
Gregory Schier
0290aba982 Bump beta.3 2023-11-19 20:46:55 -08:00
Gregory Schier
0bafc4e4f5 Postman variables + urlencoded forms 2023-11-19 20:29:24 -08:00
Gregory Schier
9a36f94279 Add back Windows/Linux builds 2023-11-19 18:22:13 -08:00
Gregory Schier
1d8e66179e Remove Tauri context menu plugin 2023-11-19 18:21:10 -08:00
Gregory Schier
fda6d16d8e Fix header padding windows/linux 2023-11-19 18:14:49 -08:00
Gregory Schier
c4737916df Some tweaks 2023-11-19 18:13:32 -08:00
Gregory Schier
919465cdbb Beta 2 2023-11-19 17:41:58 -08:00
Gregory Schier
de3730fa4f Network entitlement 2023-11-19 17:41:46 -08:00
178 changed files with 16221 additions and 7989 deletions

View File

@@ -13,13 +13,12 @@ jobs:
include:
- os: macos-12
target: aarch64-apple-darwin
# - os: macos-latest
# target: x86_64-apple-darwin
# - os: windows-2022
# target: x86_64-pc-windows-msvc
# # Re-enable Linux when context menu is supported
# - os: ubuntu-20.04
# target: x86_64-unknown-linux-gnu
- os: macos-latest
target: x86_64-apple-darwin
- os: windows-2022
target: x86_64-pc-windows-msvc
- os: ubuntu-20.04
target: x86_64-unknown-linux-gnu
runs-on: ${{ matrix.os }}

View File

@@ -1,3 +1,4 @@
node_modules/
dist/
out/
.prettierrc.cjs

View File

@@ -6,7 +6,8 @@
<script value="start" />
</scripts>
<node-interpreter value="project" />
<envs />
<envs>
</envs>
<method v="2" />
</configuration>
</component>
</component>

View File

@@ -4,14 +4,17 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Yaak App</title>
<!-- <script src="http://localhost:8097"></script>-->
<!-- <script src="http://localhost:8097"></script>-->
<!-- Certain elements like webview (and maybe <select>?) will use background
color depending on document background color-->
<style>
body {
html, body {
background-color: white;
}
@media (prefers-color-scheme: dark) {
body {
html, body {
background-color: black;
}
}

1687
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,19 +5,21 @@
"type": "module",
"scripts": {
"start": "npm run build:plugins && npm run tauri-dev",
"tauri-dev": "YAAK_ENV=development 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-yaak": "cd src-tauri/plugins/importer-yaak && vite build",
"build:plugins": "run-p build:plugin:*",
"build:plugin:importer-insomnia": "cd plugins/importer-insomnia && vite build --emptyOutDir",
"build:plugin:importer-postman": "cd plugins/importer-postman && vite build --emptyOutDir",
"build:plugin:importer-yaak": "cd plugins/importer-yaak && vite build --emptyOutDir",
"build:plugin:filter-jsonpath": "cd plugins/filter-jsonpath && vite build --emptyOutDir",
"build:plugin:filter-xpath": "cd plugins/filter-xpath && vite build --emptyOutDir",
"test": "vitest",
"coverage": "vitest run --coverage",
"prepare": "husky install"
@@ -32,14 +34,13 @@
"@lezer/generator": "^1.2.2",
"@lezer/highlight": "^1.1.3",
"@lezer/lr": "^1.3.3",
"@radix-ui/react-icons": "^1.2.0",
"@react-hook/resize-observer": "^1.2.6",
"@tailwindcss/container-queries": "^0.1.0",
"@tanstack/query-sync-storage-persister": "^4.27.1",
"@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,6 +48,7 @@
"focus-trap-react": "^10.1.1",
"format-graphql": "^1.4.0",
"framer-motion": "^9.0.4",
"lucide-react": "^0.309.0",
"papaparse": "^5.4.1",
"parse-color": "^1.0.0",
"react": "^18.2.0",
@@ -57,13 +59,12 @@
"react-router-dom": "^6.8.1",
"react-use": "^17.4.0",
"slugify": "^1.6.6",
"tauri-plugin-context-menu": "^0.5.0",
"tauri-plugin-log-api": "github:tauri-apps/tauri-plugin-log#v1",
"uuid": "^9.0.0"
},
"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",
@@ -87,6 +88,7 @@
"postcss": "^8.4.21",
"postcss-nesting": "^11.2.1",
"prettier": "^2.8.4",
"react-devtools": "^4.28.5",
"tailwindcss": "^3.2.7",
"typescript": "^5.0.2",
"vite": "^4.0.0",

173
plugins/filter-jsonpath/package-lock.json generated Normal file
View File

@@ -0,0 +1,173 @@
{
"name": "filter-jsonpath",
"version": "0.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "filter-jsonpath",
"version": "0.0.1",
"dependencies": {
"jsonpath": "^1.1.1"
}
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="
},
"node_modules/escodegen": {
"version": "1.14.3",
"resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.14.3.tgz",
"integrity": "sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw==",
"dependencies": {
"esprima": "^4.0.1",
"estraverse": "^4.2.0",
"esutils": "^2.0.2",
"optionator": "^0.8.1"
},
"bin": {
"escodegen": "bin/escodegen.js",
"esgenerate": "bin/esgenerate.js"
},
"engines": {
"node": ">=4.0"
},
"optionalDependencies": {
"source-map": "~0.6.1"
}
},
"node_modules/escodegen/node_modules/esprima": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
"bin": {
"esparse": "bin/esparse.js",
"esvalidate": "bin/esvalidate.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/esprima": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-1.2.2.tgz",
"integrity": "sha512-+JpPZam9w5DuJ3Q67SqsMGtiHKENSMRVoxvArfJZK01/BfLEObtZ6orJa/MtoGNR/rfMgp5837T41PAmTwAv/A==",
"bin": {
"esparse": "bin/esparse.js",
"esvalidate": "bin/esvalidate.js"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/estraverse": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
"integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
"engines": {
"node": ">=4.0"
}
},
"node_modules/esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/fast-levenshtein": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="
},
"node_modules/jsonpath": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/jsonpath/-/jsonpath-1.1.1.tgz",
"integrity": "sha512-l6Cg7jRpixfbgoWgkrl77dgEj8RPvND0wMH6TwQmi9Qs4TFfS9u5cUFnbeKTwj5ga5Y3BTGGNI28k117LJ009w==",
"dependencies": {
"esprima": "1.2.2",
"static-eval": "2.0.2",
"underscore": "1.12.1"
}
},
"node_modules/levn": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz",
"integrity": "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==",
"dependencies": {
"prelude-ls": "~1.1.2",
"type-check": "~0.3.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/optionator": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz",
"integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==",
"dependencies": {
"deep-is": "~0.1.3",
"fast-levenshtein": "~2.0.6",
"levn": "~0.3.0",
"prelude-ls": "~1.1.2",
"type-check": "~0.3.2",
"word-wrap": "~1.2.3"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/prelude-ls": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz",
"integrity": "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==",
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"optional": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/static-eval": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/static-eval/-/static-eval-2.0.2.tgz",
"integrity": "sha512-N/D219Hcr2bPjLxPiV+TQE++Tsmrady7TqAJugLy7Xk1EumfDWS/f5dtBbkRCGE7wKKXuYockQoj8Rm2/pVKyg==",
"dependencies": {
"escodegen": "^1.8.1"
}
},
"node_modules/type-check": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz",
"integrity": "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==",
"dependencies": {
"prelude-ls": "~1.1.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/underscore": {
"version": "1.12.1",
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.12.1.tgz",
"integrity": "sha512-hEQt0+ZLDVUMhebKxL4x1BTtDY7bavVofhZ9KZ4aI26X9SRaE+Y3m83XUL1UP2jn8ynjndwCCpEHdUG+9pP1Tw=="
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
"integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
"engines": {
"node": ">=0.10.0"
}
}
}
}

View File

@@ -0,0 +1,7 @@
{
"name": "filter-jsonpath",
"version": "0.0.1",
"dependencies": {
"jsonpath": "^1.1.1"
}
}

View File

@@ -0,0 +1,12 @@
import jp from 'jsonpath';
export function pluginHookResponseFilter(filter, text) {
let parsed;
try {
parsed = JSON.parse(text);
} catch (e) {
return;
}
const filtered = jp.query(parsed, filter);
return { filtered: JSON.stringify(filtered, null, 2) };
}

View File

@@ -0,0 +1,13 @@
import { resolve } from 'path';
import { defineConfig } from 'vite';
export default defineConfig({
build: {
lib: {
entry: resolve(__dirname, 'src/index.js'),
fileName: 'index',
formats: ['es'],
},
outDir: resolve(__dirname, '../../src-tauri/plugins/filter-jsonpath'),
},
});

32
plugins/filter-xpath/package-lock.json generated Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "filter-xpath",
"version": "0.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "filter-xpath",
"version": "0.0.1",
"dependencies": {
"@xmldom/xmldom": "^0.8.10",
"xpath": "^0.0.34"
}
},
"node_modules/@xmldom/xmldom": {
"version": "0.8.10",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz",
"integrity": "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/xpath": {
"version": "0.0.34",
"resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.34.tgz",
"integrity": "sha512-FxF6+rkr1rNSQrhUNYrAFJpRXNzlDoMxeXN5qI84939ylEv3qqPFKa85Oxr6tDaJKqwW6KKyo2v26TSv3k6LeA==",
"engines": {
"node": ">=0.6.0"
}
}
}
}

View File

@@ -0,0 +1,8 @@
{
"name": "filter-xpath",
"version": "0.0.1",
"dependencies": {
"@xmldom/xmldom": "^0.8.10",
"xpath": "^0.0.34"
}
}

View File

@@ -0,0 +1,8 @@
import xpath from 'xpath';
import { DOMParser } from '@xmldom/xmldom';
export function pluginHookResponseFilter(filter, text) {
const doc = new DOMParser().parseFromString(text, 'text/xml');
const filtered = `${xpath.select(filter, doc)}`;
return { filtered };
}

View File

@@ -8,6 +8,6 @@ export default defineConfig({
fileName: 'index',
formats: ['es'],
},
outDir: resolve(__dirname, 'out'),
outDir: resolve(__dirname, '../../src-tauri/plugins/filter-xpath'),
},
});

View File

@@ -0,0 +1,4 @@
{
"name": "importer-insomnia",
"version": "0.0.1"
}

View File

@@ -0,0 +1,13 @@
import { resolve } from 'path';
import { defineConfig } from 'vite';
export default defineConfig({
build: {
lib: {
entry: resolve(__dirname, 'src/index.js'),
fileName: 'index',
formats: ['es'],
},
outDir: resolve(__dirname, '../../src-tauri/plugins/importer-insomnia'),
},
});

View File

@@ -0,0 +1,4 @@
{
"name": "importer-postman",
"version": "0.0.1"
}

View File

@@ -32,7 +32,7 @@ export function pluginHookImport(contents: string): { resources: ExportResources
const workspace: ExportResources['workspaces'][0] = {
model: 'workspace',
id: 'wrk_0',
id: generateId('wk'),
name: info.name || 'Postman Import',
description: info.description || '',
};
@@ -43,7 +43,7 @@ export function pluginHookImport(contents: string): { resources: ExportResources
const folder: ExportResources['folders'][0] = {
model: 'folder',
workspaceId: workspace.id,
id: `fld_${exportResources.folders.length}`,
id: generateId('fl'),
name: v.name,
folderId,
};
@@ -57,7 +57,7 @@ export function pluginHookImport(contents: string): { resources: ExportResources
const authPatch = importAuth(r.auth);
const request: ExportResources['requests'][0] = {
model: 'http_request',
id: `req_${exportResources.requests.length}`,
id: generateId('rq'),
workspaceId: workspace.id,
folderId,
name: v.name,
@@ -89,7 +89,7 @@ export function pluginHookImport(contents: string): { resources: ExportResources
importItem(item);
}
return { resources: exportResources };
return { resources: convertTemplateSyntax(exportResources) };
}
function importAuth(
@@ -131,7 +131,7 @@ function importBody(rawBody: any): Pick<HttpRequest, 'body' | 'bodyType' | 'head
),
},
};
} else if ('formdata' in body) {
} else if ('urlencoded' in body) {
return {
headers: [
{
@@ -142,13 +142,39 @@ function importBody(rawBody: any): Pick<HttpRequest, 'body' | 'bodyType' | 'head
],
bodyType: 'application/x-www-form-urlencoded',
body: {
form: toArray(body.formdata).map((f) => ({
form: toArray(body.urlencoded).map((f) => ({
enabled: !f.disabled,
name: f.key ?? '',
value: f.value ?? '',
})),
},
};
} else if ('formdata' in body) {
return {
headers: [
{
name: 'Content-Type',
value: 'multipart/form-data',
enabled: true,
},
],
bodyType: 'multipart/form-data',
body: {
form: toArray(body.formdata).map((f) =>
f.src != null
? {
enabled: !f.disabled,
name: f.key ?? '',
file: f.src ?? '',
}
: {
enabled: !f.disabled,
name: f.key ?? '',
value: f.value ?? '',
},
),
},
};
} else {
// TODO: support other body types
return { headers: [], bodyType: null, body: {} };
@@ -171,3 +197,27 @@ function toArray(value: any): any[] {
if (Object.prototype.toString.call(value) === '[object Array]') return value;
else return [];
}
/** Recursively render all nested object properties */
function convertTemplateSyntax<T>(obj: T): T {
if (typeof obj === 'string') {
return obj.replace(/{{\s*(_\.)?([^}]+)\s*}}/g, '${[$2]}') as T;
} else if (Array.isArray(obj) && obj != null) {
return obj.map(convertTemplateSyntax) as T;
} else if (typeof obj === 'object' && obj != null) {
return Object.fromEntries(
Object.entries(obj).map(([k, v]) => [k, convertTemplateSyntax(v)]),
) as T;
} else {
return obj;
}
}
function generateId(prefix: 'wk' | 'rq' | 'fl'): string {
const alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
let id = `${prefix}_`;
for (let i = 0; i < 10; i++) {
id += alphabet[Math.floor(Math.random() * alphabet.length)];
}
return id;
}

View File

@@ -1,10 +1,10 @@
{
"compilerOptions": {
"target": "ES2020",
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": [
"ES2020"
"ESNext",
],
"skipLibCheck": true,
"moduleResolution": "bundler",
@@ -18,6 +18,6 @@
"noFallthroughCasesInSwitch": true
},
"include": [
"src"
"./src"
]
}

View File

@@ -8,6 +8,6 @@ export default defineConfig({
fileName: 'index',
formats: ['es'],
},
outDir: resolve(__dirname, 'out'),
outDir: resolve(__dirname, '../../src-tauri/plugins/importer-postman'),
},
});

View File

@@ -0,0 +1,4 @@
{
"name": "importer-yaak",
"version": "0.0.1"
}

View File

@@ -8,6 +8,6 @@ export default defineConfig({
fileName: 'index',
formats: ['es'],
},
outDir: resolve(__dirname, 'out'),
outDir: resolve(__dirname, '../../src-tauri/plugins/importer-yaak'),
},
});

View File

@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT id, model, workspace_id, request_id, updated_at, created_at, url,\n status, status_reason, content_length, body, body_path, elapsed, error,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpResponseHeader>>\"\n FROM http_responses\n WHERE workspace_id = ?\n ORDER BY created_at DESC\n ",
"query": "\n SELECT\n id, model, workspace_id, request_id, updated_at, created_at, url, status,\n status_reason, content_length, body_path, elapsed, elapsed_headers, error,\n version, remote_addr,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpResponseHeader>>\"\n FROM http_responses\n WHERE id = ?\n ",
"describe": {
"columns": [
{
@@ -53,18 +53,18 @@
"ordinal": 9,
"type_info": "Int64"
},
{
"name": "body",
"ordinal": 10,
"type_info": "Blob"
},
{
"name": "body_path",
"ordinal": 11,
"ordinal": 10,
"type_info": "Text"
},
{
"name": "elapsed",
"ordinal": 11,
"type_info": "Int64"
},
{
"name": "elapsed_headers",
"ordinal": 12,
"type_info": "Int64"
},
@@ -74,9 +74,19 @@
"type_info": "Text"
},
{
"name": "headers!: sqlx::types::Json<Vec<HttpResponseHeader>>",
"name": "version",
"ordinal": 14,
"type_info": "Text"
},
{
"name": "remote_addr",
"ordinal": 15,
"type_info": "Text"
},
{
"name": "headers!: sqlx::types::Json<Vec<HttpResponseHeader>>",
"ordinal": 16,
"type_info": "Text"
}
],
"parameters": {
@@ -94,11 +104,13 @@
true,
true,
true,
true,
false,
false,
true,
true,
true,
false
]
},
"hash": "26072725d536c3cfdffd9a681d17c0ee2f246ca98e0459630a2430236d3bbdd2"
"hash": "0fa6b56f8c996d14908a56928674b4b35af5fa36f63dc48b9b66ee6dfde78976"
}

View File

@@ -0,0 +1,74 @@
{
"db_name": "SQLite",
"query": "\n SELECT\n id,\n model,\n created_at,\n updated_at,\n name,\n description,\n setting_request_timeout,\n setting_follow_redirects,\n setting_validate_certificates,\n variables AS \"variables!: sqlx::types::Json<Vec<EnvironmentVariable>>\"\n FROM workspaces\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": "name",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "description",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "setting_request_timeout",
"ordinal": 6,
"type_info": "Int64"
},
{
"name": "setting_follow_redirects",
"ordinal": 7,
"type_info": "Bool"
},
{
"name": "setting_validate_certificates",
"ordinal": 8,
"type_info": "Bool"
},
{
"name": "variables!: sqlx::types::Json<Vec<EnvironmentVariable>>",
"ordinal": 9,
"type_info": "Text"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false,
false,
false
]
},
"hash": "20cf0f8b71e600bc40ee204b60a12b2f3728178f3b8788d2e5cc92821b74d470"
}

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

@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT id, model, created_at, updated_at, name, description,\n variables AS \"variables!: sqlx::types::Json<Vec<EnvironmentVariable>>\"\n FROM workspaces\n ",
"query": "\n SELECT\n id,\n model,\n created_at,\n updated_at,\n theme,\n appearance,\n update_channel\n FROM settings\n WHERE id = 'default'\n ",
"describe": {
"columns": [
{
@@ -24,17 +24,17 @@
"type_info": "Datetime"
},
{
"name": "name",
"name": "theme",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "description",
"name": "appearance",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "variables!: sqlx::types::Json<Vec<EnvironmentVariable>>",
"name": "update_channel",
"ordinal": 6,
"type_info": "Text"
}
@@ -52,5 +52,5 @@
false
]
},
"hash": "5588db23df7f30dc75857e05395ebbcf2384e2ac0d7cb87f76d74c6d50781d7b"
"hash": "3b3fb6271340c6ec21a10b4f1b20502c86c425e0b53ac07692f8a4ed0be09335"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n UPDATE http_responses SET (\n elapsed,\n elapsed_headers,\n url,\n status,\n status_reason,\n content_length,\n body_path,\n error,\n headers,\n version,\n remote_addr,\n updated_at\n ) = (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) WHERE id = ?;\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 12
},
"nullable": []
},
"hash": "587aedf827b00bb706c35457a75b811317e66fc84ac0906bf5513d938121a078"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO workspaces (id, name, description, variables)\n VALUES (?, ?, ?, ?)\n ON CONFLICT (id) DO UPDATE SET\n updated_at = CURRENT_TIMESTAMP,\n name = excluded.name,\n description = excluded.description,\n variables = excluded.variables\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "610223ad10b6e25926d486ba775a74b55625fcc4e6637d8a805d44ec3f3b9532"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n UPDATE http_responses SET (\n elapsed,\n url,\n status,\n status_reason,\n content_length,\n body,\n body_path,\n error,\n headers,\n updated_at\n ) = (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) WHERE id = ?;\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 10
},
"nullable": []
},
"hash": "62475fd9483fb5eda01c937949da2ef66ac7005b4be06b87aa6210d462348aca"
}

View File

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

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO http_responses (\n id,\n request_id,\n workspace_id,\n elapsed,\n url,\n status,\n status_reason,\n content_length,\n body,\n body_path,\n headers\n )\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 11
},
"nullable": []
},
"hash": "8947a2a90478277c42fe9b06bc1fa98197642a4d281a3dbc101be2c9c1fec36c"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO http_responses (\n id,\n request_id,\n workspace_id,\n elapsed,\n elapsed_headers,\n url,\n status,\n status_reason,\n content_length,\n body_path,\n headers,\n version,\n remote_addr\n )\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 13
},
"nullable": []
},
"hash": "a1c9a862ca6a07476cb8e7d16d73bd109c070603396a890dc717e50020d006f5"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT id, model, workspace_id, request_id, updated_at, created_at, url,\n status, status_reason, content_length, body, body_path, elapsed, error,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpResponseHeader>>\"\n FROM http_responses\n WHERE request_id = ?\n ORDER BY created_at DESC\n ",
"query": "\n SELECT\n id, model, workspace_id, request_id, updated_at, created_at, url, status,\n status_reason, content_length, body_path, elapsed, elapsed_headers, error,\n version, remote_addr,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpResponseHeader>>\"\n FROM http_responses\n WHERE request_id = ?\n ORDER BY created_at DESC\n LIMIT ?\n ",
"describe": {
"columns": [
{
@@ -53,18 +53,18 @@
"ordinal": 9,
"type_info": "Int64"
},
{
"name": "body",
"ordinal": 10,
"type_info": "Blob"
},
{
"name": "body_path",
"ordinal": 11,
"ordinal": 10,
"type_info": "Text"
},
{
"name": "elapsed",
"ordinal": 11,
"type_info": "Int64"
},
{
"name": "elapsed_headers",
"ordinal": 12,
"type_info": "Int64"
},
@@ -74,13 +74,23 @@
"type_info": "Text"
},
{
"name": "headers!: sqlx::types::Json<Vec<HttpResponseHeader>>",
"name": "version",
"ordinal": 14,
"type_info": "Text"
},
{
"name": "remote_addr",
"ordinal": 15,
"type_info": "Text"
},
{
"name": "headers!: sqlx::types::Json<Vec<HttpResponseHeader>>",
"ordinal": 16,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
"Right": 2
},
"nullable": [
false,
@@ -94,11 +104,13 @@
true,
true,
true,
true,
false,
false,
true,
true,
true,
false
]
},
"hash": "5aa070e61995f8b1724efaa94c5f0cef5a4be6efda5d70354ad449d7d4b5aee4"
"hash": "ac38621cd947c3be9ca0d8ea73325fe35c3866d16f6482fc32c23762f112dc83"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO cookie_jars (\n id,\n workspace_id,\n name,\n cookies\n )\n VALUES (?, ?, ?, ?)\n ON CONFLICT (id) DO UPDATE SET\n updated_at = CURRENT_TIMESTAMP,\n name = excluded.name,\n cookies = excluded.cookies\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "b5ed4dc77f8cf21de1a06f146e47821bdb51fcfe747170bea41e7a366d736bda"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n DELETE FROM cookie_jars\n WHERE id = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "b98609f65dd3a6bbd1ea8dc8bed2840a6d5d13fec1bbc0aa61ca4f60de98a09c"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO workspaces (\n id,\n name,\n description,\n variables,\n setting_request_timeout,\n setting_follow_redirects,\n setting_validate_certificates\n )\n VALUES (?, ?, ?, ?, ?, ?, ?)\n ON CONFLICT (id) DO UPDATE SET\n updated_at = CURRENT_TIMESTAMP,\n name = excluded.name,\n description = excluded.description,\n variables = excluded.variables,\n setting_request_timeout = excluded.setting_request_timeout,\n setting_follow_redirects = excluded.setting_follow_redirects,\n setting_validate_certificates = excluded.setting_validate_certificates\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 7
},
"nullable": []
},
"hash": "cae4e905515ddea1ec2cd685020241f06b49719085a695b897ef8ad409d2cef2"
}

View File

@@ -0,0 +1,56 @@
{
"db_name": "SQLite",
"query": "\n SELECT\n id,\n model,\n created_at,\n updated_at,\n workspace_id,\n name,\n cookies AS \"cookies!: sqlx::types::Json<Vec<JsonValue>>\"\n FROM cookie_jars WHERE workspace_id = ?\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": "workspace_id",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "name",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "cookies!: sqlx::types::Json<Vec<JsonValue>>",
"ordinal": 6,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
false,
false,
false
]
},
"hash": "cb939b45a715d91f7631dea6b2d1bdc59fb3dffbd44ff99bc15adb34ea7093f7"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT id, model, workspace_id, request_id, updated_at, created_at, url,\n status, status_reason, content_length, body, body_path, elapsed, error,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpResponseHeader>>\"\n FROM http_responses\n WHERE id = ?\n ",
"query": "\n SELECT\n id, model, workspace_id, request_id, updated_at, created_at, url, status,\n status_reason, content_length, body_path, elapsed, elapsed_headers, error,\n version, remote_addr,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpResponseHeader>>\"\n FROM http_responses\n WHERE workspace_id = ?\n ORDER BY created_at DESC\n ",
"describe": {
"columns": [
{
@@ -53,18 +53,18 @@
"ordinal": 9,
"type_info": "Int64"
},
{
"name": "body",
"ordinal": 10,
"type_info": "Blob"
},
{
"name": "body_path",
"ordinal": 11,
"ordinal": 10,
"type_info": "Text"
},
{
"name": "elapsed",
"ordinal": 11,
"type_info": "Int64"
},
{
"name": "elapsed_headers",
"ordinal": 12,
"type_info": "Int64"
},
@@ -74,9 +74,19 @@
"type_info": "Text"
},
{
"name": "headers!: sqlx::types::Json<Vec<HttpResponseHeader>>",
"name": "version",
"ordinal": 14,
"type_info": "Text"
},
{
"name": "remote_addr",
"ordinal": 15,
"type_info": "Text"
},
{
"name": "headers!: sqlx::types::Json<Vec<HttpResponseHeader>>",
"ordinal": 16,
"type_info": "Text"
}
],
"parameters": {
@@ -94,11 +104,13 @@
true,
true,
true,
true,
false,
false,
true,
true,
true,
false
]
},
"hash": "c23c61b05a4c9e04ab0c1fc2c579d6f2a82a37aeed8addf9861b4985f2a5422e"
"hash": "d5e087caa163a0c7bfbbadf07eccb80105501cf5baab706aa6792dfe90af8fc9"
}

View File

@@ -0,0 +1,74 @@
{
"db_name": "SQLite",
"query": "\n SELECT\n id,\n model,\n created_at,\n updated_at,\n name,\n description,\n setting_request_timeout,\n setting_follow_redirects,\n setting_validate_certificates,\n variables AS \"variables!: sqlx::types::Json<Vec<EnvironmentVariable>>\"\n FROM workspaces WHERE id = ?\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": "name",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "description",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "setting_request_timeout",
"ordinal": 6,
"type_info": "Int64"
},
{
"name": "setting_follow_redirects",
"ordinal": 7,
"type_info": "Bool"
},
{
"name": "setting_validate_certificates",
"ordinal": 8,
"type_info": "Bool"
},
{
"name": "variables!: sqlx::types::Json<Vec<EnvironmentVariable>>",
"ordinal": 9,
"type_info": "Null"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false,
false,
false
]
},
"hash": "e08fa4f9b2929f20a01d1dc43d6847a309d3e8c5b324df2d039d1c6e07e6eb2f"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT id, model, created_at, updated_at, name, description,\n variables AS \"variables!: sqlx::types::Json<Vec<EnvironmentVariable>>\"\n FROM workspaces WHERE id = ?\n ",
"query": "\n SELECT\n id,\n model,\n created_at,\n updated_at,\n workspace_id,\n name,\n cookies AS \"cookies!: sqlx::types::Json<Vec<JsonValue>>\"\n FROM cookie_jars WHERE id = ?\n ",
"describe": {
"columns": [
{
@@ -24,19 +24,19 @@
"type_info": "Datetime"
},
{
"name": "name",
"name": "workspace_id",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "description",
"name": "name",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "variables!: sqlx::types::Json<Vec<EnvironmentVariable>>",
"name": "cookies!: sqlx::types::Json<Vec<JsonValue>>",
"ordinal": 6,
"type_info": "Null"
"type_info": "Text"
}
],
"parameters": {
@@ -52,5 +52,5 @@
false
]
},
"hash": "dbe457087a7bccbca4c1d673aa8e547df04530a7f860a6ccd4e20126a7cdfa4f"
"hash": "f2ba4708d4a9ff9ce74c407a730040bd7883e9a5c0eb79ef0d8a6782a8eae299"
}

563
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -17,38 +17,48 @@ 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"
boa_runtime = "0.17.3"
chrono = { version = "0.4.23", features = ["serde"] }
boa_engine = { version = "0.17.3", features = ["annex-b"] }
boa_runtime = { version = "0.17.3" }
chrono = { version = "0.4.31", features = ["serde"] }
futures = "0.3.26"
http = "0.2.8"
rand = "0.8.5"
reqwest = { version = "0.11.14", features = ["json", "multipart"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = { version = "1.0", features = ["raw_value"] }
reqwest = { version = "0.11.23", features = ["multipart", "cookies", "gzip", "brotli", "deflate"] }
cookie = { version = "0.18.0" }
serde = { version = "1.0.195", features = ["derive"] }
serde_json = { version = "1.0.111", 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"] }
tokio = { version = "1.25.0", features = ["sync"] }
uuid = "1.3.0"
log = "0.4.20"
tauri-plugin-context-menu = "0.5.0"
datetime = "0.5.2"
window-shadows = "0.2.2"
reqwest_cookie_store = "0.6.0"
[features]
# by default Tauri runs in production mode

View File

@@ -2,10 +2,9 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<!-- Re-enable for sandboxing. Currently disabled because auto-updater doesn't work with sandboxing.-->
<!-- <key>com.apple.security.app-sandbox</key> <true/>-->
<!-- <key>com.apple.security.files.user-selected.read-write</key> <true/>-->
<!-- <key>com.apple.security.network.client</key> <true/>-->
</dict>
</plist>

View File

@@ -0,0 +1 @@
ALTER TABLE http_responses DROP COLUMN body;

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

@@ -0,0 +1,9 @@
-- Add existing request-related settings to workspace
ALTER TABLE workspaces ADD COLUMN setting_request_timeout INTEGER DEFAULT '0' NOT NULL;
ALTER TABLE workspaces ADD COLUMN setting_validate_certificates BOOLEAN DEFAULT TRUE NOT NULL;
ALTER TABLE workspaces ADD COLUMN setting_follow_redirects BOOLEAN DEFAULT TRUE NOT NULL;
-- Remove old settings that used to be global
ALTER TABLE settings DROP COLUMN request_timeout;
ALTER TABLE settings DROP COLUMN follow_redirects;
ALTER TABLE settings DROP COLUMN validate_certificates;

View File

@@ -0,0 +1 @@
ALTER TABLE settings ADD COLUMN update_channel TEXT DEFAULT 'stable' NOT NULL;

View File

@@ -0,0 +1,10 @@
CREATE TABLE cookie_jars
(
id TEXT NOT NULL PRIMARY KEY,
model TEXT DEFAULT 'cookie_jar' NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
name TEXT NOT NULL,
cookies TEXT DEFAULT '[]' NOT NULL,
workspace_id TEXT NOT NULL
);

View File

@@ -0,0 +1,3 @@
ALTER TABLE http_responses ADD COLUMN elapsed_headers INTEGER NOT NULL DEFAULT 0;
ALTER TABLE http_responses ADD COLUMN remote_addr TEXT;
ALTER TABLE http_responses ADD COLUMN version TEXT;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,134 @@
function S(e, t) {
return console.log("IMPORTING Environment", e._id, e.name, JSON.stringify(e, null, 2)), {
id: e._id,
createdAt: new Date(e.created ?? Date.now()).toISOString().replace("Z", ""),
updatedAt: new Date(e.updated ?? Date.now()).toISOString().replace("Z", ""),
workspaceId: t,
model: "environment",
name: e.name,
variables: Object.entries(e.data).map(([n, a]) => ({
enabled: !0,
name: n,
value: `${a}`
}))
};
}
function I(e) {
return m(e) && e._type === "workspace";
}
function y(e) {
return m(e) && e._type === "request_group";
}
function g(e) {
return m(e) && e._type === "request";
}
function f(e) {
return m(e) && e._type === "environment";
}
function m(e) {
return Object.prototype.toString.call(e) === "[object Object]";
}
function w(e) {
return Object.prototype.toString.call(e) === "[object String]";
}
function O(e) {
return Object.entries(e).map(([t, n]) => ({
enabled: !0,
name: t,
value: `${n}`
}));
}
function l(e) {
return w(e) ? e.replaceAll(/{{\s*(_\.)?([^}]+)\s*}}/g, "${[$2]}") : e;
}
function h(e, t, n = 0) {
var c, o;
console.log("IMPORTING REQUEST", e._id, e.name, JSON.stringify(e, null, 2));
let a = null, r = null;
((c = e.body) == null ? void 0 : c.mimeType) === "application/graphql" ? (a = "graphql", r = l(e.body.text)) : ((o = e.body) == null ? void 0 : o.mimeType) === "application/json" && (a = "application/json", r = l(e.body.text));
let i = null, u = {};
return e.authentication.type === "bearer" ? (i = "bearer", u = {
token: l(e.authentication.token)
}) : e.authentication.type === "basic" && (i = "basic", u = {
username: l(e.authentication.username),
password: l(e.authentication.password)
}), {
id: e._id,
createdAt: new Date(e.created ?? Date.now()).toISOString().replace("Z", ""),
updatedAt: new Date(e.updated ?? Date.now()).toISOString().replace("Z", ""),
workspaceId: t,
folderId: e.parentId === t ? null : e.parentId,
model: "http_request",
sortPriority: n,
name: e.name,
url: l(e.url),
body: r,
bodyType: a,
authentication: u,
authenticationType: i,
method: e.method,
headers: (e.headers ?? []).map(({ name: d, value: p, disabled: s }) => ({
enabled: !s,
name: d,
value: p
})).filter(({ name: d, value: p }) => d !== "" || p !== "")
};
}
function _(e, t) {
return console.log("IMPORTING FOLDER", e._id, e.name, JSON.stringify(e, null, 2)), {
id: e._id,
createdAt: new Date(e.created ?? Date.now()).toISOString().replace("Z", ""),
updatedAt: new Date(e.updated ?? Date.now()).toISOString().replace("Z", ""),
folderId: e.parentId === t ? null : e.parentId,
workspaceId: t,
model: "folder",
name: e.name
};
}
function b(e) {
console.log("RUNNING INSOMNIA");
let t;
try {
t = JSON.parse(e);
} catch {
return;
}
if (!m(t) || !Array.isArray(t.resources))
return;
const n = {
workspaces: [],
requests: [],
environments: [],
folders: []
}, a = t.resources.filter(I);
for (const r of a) {
const i = t.resources.find(
(o) => f(o) && o.parentId === r._id
);
n.workspaces.push({
id: r._id,
createdAt: new Date(a.created ?? Date.now()).toISOString().replace("Z", ""),
updatedAt: new Date(a.updated ?? Date.now()).toISOString().replace("Z", ""),
model: "workspace",
name: r.name,
variables: i ? O(i.data) : []
});
const u = t.resources.filter(
(o) => f(o) && o.parentId === (i == null ? void 0 : i._id)
);
n.environments.push(
...u.map((o) => S(o, r._id))
);
const c = (o) => {
const d = t.resources.filter((s) => s.parentId === o);
let p = 0;
for (const s of d)
y(s) ? (n.folders.push(_(s, r._id)), c(s._id)) : g(s) && n.requests.push(h(s, r._id, p++));
};
c(r._id);
}
return n.requests = n.requests.filter(Boolean), n.environments = n.environments.filter(Boolean), n.workspaces = n.workspaces.filter(Boolean), { resources: n };
}
export {
b as pluginHookImport
};

View File

@@ -1,152 +0,0 @@
function S(e, t) {
return (
console.log('IMPORTING Environment', e._id, e.name, JSON.stringify(e, null, 2)),
{
id: e._id,
createdAt: new Date(e.created ?? Date.now()).toISOString().replace('Z', ''),
updatedAt: new Date(e.updated ?? Date.now()).toISOString().replace('Z', ''),
workspaceId: t,
model: 'environment',
name: e.name,
variables: Object.entries(e.data).map(([n, a]) => ({
enabled: !0,
name: n,
value: `${a}`,
})),
}
);
}
function I(e) {
return m(e) && e._type === 'workspace';
}
function y(e) {
return m(e) && e._type === 'request_group';
}
function g(e) {
return m(e) && e._type === 'request';
}
function f(e) {
return m(e) && e._type === 'environment';
}
function m(e) {
return Object.prototype.toString.call(e) === '[object Object]';
}
function w(e) {
return Object.prototype.toString.call(e) === '[object String]';
}
function O(e) {
return Object.entries(e).map(([t, n]) => ({
enabled: !0,
name: t,
value: `${n}`,
}));
}
function l(e) {
return w(e) ? e.replaceAll(/{{\s*(_\.)?([^}]+)\s*}}/g, '${[$2]}') : e;
}
function h(e, t, n = 0) {
var c, o;
console.log('IMPORTING REQUEST', e._id, e.name, JSON.stringify(e, null, 2));
let a = null,
r = null;
((c = e.body) == null ? void 0 : c.mimeType) === 'application/graphql'
? ((a = 'graphql'), (r = l(e.body.text)))
: ((o = e.body) == null ? void 0 : o.mimeType) === 'application/json' &&
((a = 'application/json'), (r = l(e.body.text)));
let i = null,
u = {};
return (
e.authentication.type === 'bearer'
? ((i = 'bearer'),
(u = {
token: l(e.authentication.token),
}))
: e.authentication.type === 'basic' &&
((i = 'basic'),
(u = {
username: l(e.authentication.username),
password: l(e.authentication.password),
})),
{
id: e._id,
createdAt: new Date(e.created ?? Date.now()).toISOString().replace('Z', ''),
updatedAt: new Date(e.updated ?? Date.now()).toISOString().replace('Z', ''),
workspaceId: t,
folderId: e.parentId === t ? null : e.parentId,
model: 'http_request',
sortPriority: n,
name: e.name,
url: l(e.url),
body: r,
bodyType: a,
authentication: u,
authenticationType: i,
method: e.method,
headers: (e.headers ?? [])
.map(({ name: d, value: p, disabled: s }) => ({
enabled: !s,
name: d,
value: p,
}))
.filter(({ name: d, value: p }) => d !== '' || p !== ''),
}
);
}
function _(e, t) {
return (
console.log('IMPORTING FOLDER', e._id, e.name, JSON.stringify(e, null, 2)),
{
id: e._id,
createdAt: new Date(e.created ?? Date.now()).toISOString().replace('Z', ''),
updatedAt: new Date(e.updated ?? Date.now()).toISOString().replace('Z', ''),
folderId: e.parentId === t ? null : e.parentId,
workspaceId: t,
model: 'folder',
name: e.name,
}
);
}
function b(e) {
console.log('RUNNING INSOMNIA');
let t;
try {
t = JSON.parse(e);
} catch {
return;
}
if (!m(t) || !Array.isArray(t.resources)) return;
const n = {
workspaces: [],
requests: [],
environments: [],
folders: [],
},
a = t.resources.filter(I);
for (const r of a) {
const i = t.resources.find((o) => f(o) && o.parentId === r._id);
n.workspaces.push({
id: r._id,
createdAt: new Date(a.created ?? Date.now()).toISOString().replace('Z', ''),
updatedAt: new Date(a.updated ?? Date.now()).toISOString().replace('Z', ''),
model: 'workspace',
name: r.name,
variables: i ? O(i.data) : [],
});
const u = t.resources.filter((o) => f(o) && o.parentId === (i == null ? void 0 : i._id));
n.environments.push(...u.map((o) => S(o, r._id)));
const c = (o) => {
const d = t.resources.filter((s) => s.parentId === o);
let p = 0;
for (const s of d)
y(s) ? (n.folders.push(_(s, r._id)), c(s._id)) : g(s) && n.requests.push(h(s, r._id, p++));
};
c(r._id);
}
return (
(n.requests = n.requests.filter(Boolean)),
(n.environments = n.environments.filter(Boolean)),
(n.workspaces = n.workspaces.filter(Boolean)),
{ resources: n }
);
}
export { b as pluginHookImport };

View File

@@ -0,0 +1,160 @@
const T = "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", w = "https://schema.getpostman.com/json/collection/v2.0.0/collection.json", A = [w, T];
function q(e) {
const t = b(e);
if (t == null)
return;
const n = a(t.info);
if (!A.includes(n.schema) || !Array.isArray(t.item))
return;
const i = {
workspaces: [],
environments: [],
requests: [],
folders: []
}, c = {
model: "workspace",
id: m("wk"),
name: n.name || "Postman Import",
description: n.description || ""
};
i.workspaces.push(c);
const f = (r, u = null) => {
if (typeof r.name == "string" && Array.isArray(r.item)) {
const o = {
model: "folder",
workspaceId: c.id,
id: m("fl"),
name: r.name,
folderId: u
};
i.folders.push(o);
for (const s of r.item)
f(s, o.id);
} else if (typeof r.name == "string" && "request" in r) {
const o = a(r.request), s = k(o.body), d = S(o.auth), g = {
model: "http_request",
id: m("rq"),
workspaceId: c.id,
folderId: u,
name: r.name,
method: o.method || "GET",
url: typeof o.url == "string" ? o.url : a(o.url).raw,
body: s.body,
bodyType: s.bodyType,
authentication: d.authentication,
authenticationType: d.authenticationType,
headers: [
...s.headers,
...d.headers,
...y(o.header).map((p) => ({
name: p.key,
value: p.value,
enabled: !p.disabled
}))
]
};
i.requests.push(g);
} else
console.log("Unknown item", r, u);
};
for (const r of t.item)
f(r);
return { resources: h(i) };
}
function S(e) {
const t = a(e);
return "basic" in t ? {
headers: [],
authenticationType: "basic",
authentication: {
username: t.basic.username || "",
password: t.basic.password || ""
}
} : { headers: [], authenticationType: null, authentication: {} };
}
function k(e) {
const t = a(e);
return "graphql" in t ? {
headers: [
{
name: "Content-Type",
value: "application/json",
enabled: !0
}
],
bodyType: "graphql",
body: {
text: JSON.stringify(
{ query: t.graphql.query, variables: b(t.graphql.variables) },
null,
2
)
}
} : "urlencoded" in t ? {
headers: [
{
name: "Content-Type",
value: "application/x-www-form-urlencoded",
enabled: !0
}
],
bodyType: "application/x-www-form-urlencoded",
body: {
form: y(t.urlencoded).map((n) => ({
enabled: !n.disabled,
name: n.key ?? "",
value: n.value ?? ""
}))
}
} : "formdata" in t ? {
headers: [
{
name: "Content-Type",
value: "multipart/form-data",
enabled: !0
}
],
bodyType: "multipart/form-data",
body: {
form: y(t.formdata).map(
(n) => n.src != null ? {
enabled: !n.disabled,
name: n.key ?? "",
file: n.src ?? ""
} : {
enabled: !n.disabled,
name: n.key ?? "",
value: n.value ?? ""
}
)
}
} : { headers: [], bodyType: null, body: {} };
}
function b(e) {
try {
return a(JSON.parse(e));
} catch {
}
return null;
}
function a(e) {
return Object.prototype.toString.call(e) === "[object Object]" ? e : {};
}
function y(e) {
return Object.prototype.toString.call(e) === "[object Array]" ? e : [];
}
function h(e) {
return typeof e == "string" ? e.replace(/{{\s*(_\.)?([^}]+)\s*}}/g, "${[$2]}") : Array.isArray(e) && e != null ? e.map(h) : typeof e == "object" && e != null ? Object.fromEntries(
Object.entries(e).map(([t, n]) => [t, h(n)])
) : e;
}
function m(e) {
const t = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
let n = `${e}_`;
for (let l = 0; l < 10; l++)
n += t[Math.floor(Math.random() * t.length)];
return n;
}
export {
q as pluginHookImport
};

View File

@@ -1,130 +0,0 @@
const f = 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json',
b = 'https://schema.getpostman.com/json/collection/v2.0.0/collection.json',
w = [b, f];
function A(t) {
const e = m(t);
if (e == null) return;
const r = s(e.info);
if (!w.includes(r.schema) || !Array.isArray(e.item)) return;
const a = {
workspaces: [],
environments: [],
requests: [],
folders: [],
},
c = {
model: 'workspace',
id: 'wrk_0',
name: r.name || 'Postman Import',
description: r.description || '',
};
a.workspaces.push(c);
const p = (o, l = null) => {
if (typeof o.name == 'string' && Array.isArray(o.item)) {
const n = {
model: 'folder',
workspaceId: c.id,
id: `fld_${a.folders.length}`,
name: o.name,
folderId: l,
};
a.folders.push(n);
for (const i of o.item) p(i, n.id);
} else if (typeof o.name == 'string' && 'request' in o) {
const n = s(o.request),
i = T(n.body),
u = g(n.auth),
y = {
model: 'http_request',
id: `req_${a.requests.length}`,
workspaceId: c.id,
folderId: l,
name: o.name,
method: n.method || 'GET',
url: typeof n.url == 'string' ? n.url : s(n.url).raw,
body: i.body,
bodyType: i.bodyType,
authentication: u.authentication,
authenticationType: u.authenticationType,
headers: [
...i.headers,
...u.headers,
...h(n.header).map((d) => ({
name: d.key,
value: d.value,
enabled: !d.disabled,
})),
],
};
a.requests.push(y);
} else console.log('Unknown item', o, l);
};
for (const o of e.item) p(o);
return { resources: a };
}
function g(t) {
const e = s(t);
return 'basic' in e
? {
headers: [],
authenticationType: 'basic',
authentication: {
username: e.basic.username || '',
password: e.basic.password || '',
},
}
: { headers: [], authenticationType: null, authentication: {} };
}
function T(t) {
const e = s(t);
return 'graphql' in e
? {
headers: [
{
name: 'Content-Type',
value: 'application/json',
enabled: !0,
},
],
bodyType: 'graphql',
body: {
text: JSON.stringify(
{ query: e.graphql.query, variables: m(e.graphql.variables) },
null,
2,
),
},
}
: 'formdata' in e
? {
headers: [
{
name: 'Content-Type',
value: 'application/x-www-form-urlencoded',
enabled: !0,
},
],
bodyType: 'application/x-www-form-urlencoded',
body: {
form: h(e.formdata).map((r) => ({
enabled: !r.disabled,
name: r.key ?? '',
value: r.value ?? '',
})),
},
}
: { headers: [], bodyType: null, body: {} };
}
function m(t) {
try {
return s(JSON.parse(t));
} catch {}
return null;
}
function s(t) {
return Object.prototype.toString.call(t) === '[object Object]' ? t : {};
}
function h(t) {
return Object.prototype.toString.call(t) === '[object Array]' ? t : [];
}
export { A as pluginHookImport };

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,17 @@
function u(r) {
let e;
try {
e = JSON.parse(r);
} catch {
return;
}
if (t(e) && e.yaakSchema === 1)
return { resources: e.resources };
}
function t(r) {
return Object.prototype.toString.call(r) === "[object Object]";
}
export {
t as isJSObject,
u as pluginHookImport
};

View File

@@ -1,13 +0,0 @@
function u(r) {
let e;
try {
e = JSON.parse(r);
} catch {
return;
}
if (t(e) && e.yaakSchema === 1) return { resources: e.resources };
}
function t(r) {
return Object.prototype.toString.call(r) === '[object Object]';
}
export { t as isJSObject, u as pluginHookImport };

View File

@@ -1,89 +1,234 @@
use log::{debug, warn};
use serde::{Deserialize, Serialize};
use serde_json::json;
use sqlx::{Pool, Sqlite};
use sqlx::types::JsonValue;
use tauri::{async_runtime, AppHandle, Manager};
use tauri::{AppHandle, Manager, State};
use tokio::sync::Mutex;
use crate::is_dev;
use crate::{is_dev, models};
// serializable
#[derive(Serialize, Deserialize)]
pub enum AnalyticsResource {
App,
// Workspace,
// Environment,
// Folder,
// HttpRequest,
// HttpResponse,
CookieJar,
Dialog,
Environment,
Folder,
HttpRequest,
HttpResponse,
KeyValue,
Sidebar,
Workspace,
}
impl AnalyticsResource {
pub fn from_str(s: &str) -> Option<AnalyticsResource> {
match s {
"App" => Some(AnalyticsResource::App),
"Dialog" => Some(AnalyticsResource::Dialog),
"CookieJar" => Some(AnalyticsResource::CookieJar),
"Environment" => Some(AnalyticsResource::Environment),
"Folder" => Some(AnalyticsResource::Folder),
"HttpRequest" => Some(AnalyticsResource::HttpRequest),
"HttpResponse" => Some(AnalyticsResource::HttpResponse),
"KeyValue" => Some(AnalyticsResource::KeyValue),
"Sidebar" => Some(AnalyticsResource::Sidebar),
"Workspace" => Some(AnalyticsResource::Workspace),
_ => None,
}
}
}
#[derive(Serialize, Deserialize)]
pub enum AnalyticsAction {
Create,
Delete,
DeleteMany,
Duplicate,
Export,
Hide,
Import,
Launch,
// Create,
// Update,
// Upsert,
// Delete,
// Send,
// Duplicate,
LaunchFirst,
LaunchUpdate,
Send,
Show,
Toggle,
Update,
Upsert,
}
impl AnalyticsAction {
pub fn from_str(s: &str) -> Option<AnalyticsAction> {
match s {
"Create" => Some(AnalyticsAction::Create),
"Delete" => Some(AnalyticsAction::Delete),
"DeleteMany" => Some(AnalyticsAction::DeleteMany),
"Duplicate" => Some(AnalyticsAction::Duplicate),
"Export" => Some(AnalyticsAction::Export),
"Hide" => Some(AnalyticsAction::Hide),
"Import" => Some(AnalyticsAction::Import),
"Launch" => Some(AnalyticsAction::Launch),
"LaunchFirst" => Some(AnalyticsAction::LaunchFirst),
"LaunchUpdate" => Some(AnalyticsAction::LaunchUpdate),
"Send" => Some(AnalyticsAction::Send),
"Show" => Some(AnalyticsAction::Show),
"Toggle" => Some(AnalyticsAction::Toggle),
"Update" => Some(AnalyticsAction::Update),
"Upsert" => Some(AnalyticsAction::Upsert),
_ => None,
}
}
}
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::CookieJar => "cookie_jar",
AnalyticsResource::Dialog => "dialog",
AnalyticsResource::Environment => "environment",
AnalyticsResource::Folder => "folder",
AnalyticsResource::HttpRequest => "http_request",
AnalyticsResource::HttpResponse => "http_response",
AnalyticsResource::KeyValue => "key_value",
AnalyticsResource::Sidebar => "sidebar",
AnalyticsResource::Workspace => "workspace",
}
}
fn action_name(action: AnalyticsAction) -> &'static str {
match action {
AnalyticsAction::Create => "create",
AnalyticsAction::Delete => "delete",
AnalyticsAction::DeleteMany => "delete_many",
AnalyticsAction::Duplicate => "duplicate",
AnalyticsAction::Export => "export",
AnalyticsAction::Hide => "hide",
AnalyticsAction::Import => "import",
AnalyticsAction::Launch => "launch",
// AnalyticsAction::Create => "create",
// AnalyticsAction::Update => "update",
// AnalyticsAction::Upsert => "upsert",
// AnalyticsAction::Delete => "delete",
// AnalyticsAction::Send => "send",
// AnalyticsAction::Duplicate => "duplicate",
AnalyticsAction::LaunchFirst => "launch_first",
AnalyticsAction::LaunchUpdate => "launch_update",
AnalyticsAction::Send => "send",
AnalyticsAction::Show => "show",
AnalyticsAction::Toggle => "toggle",
AnalyticsAction::Update => "update",
AnalyticsAction::Upsert => "upsert",
}
}
pub fn track_event(
#[derive(Default, Debug)]
pub struct LaunchEventInfo {
pub current_version: String,
pub previous_version: String,
pub launched_after_update: bool,
pub num_launches: i32,
}
pub async fn track_launch_event(app_handle: &AppHandle) -> LaunchEventInfo {
let namespace = "analytics";
let last_tracked_version_key = "last_tracked_version";
let db_instance: State<'_, Mutex<Pool<Sqlite>>> = app_handle.state();
let pool = &*db_instance.lock().await;
let mut info = LaunchEventInfo::default();
info.num_launches = models::get_key_value_int(namespace, "num_launches", 0, pool).await + 1;
info.previous_version =
models::get_key_value_string(namespace, last_tracked_version_key, "", pool).await;
info.current_version = app_handle.package_info().version.to_string();
if info.previous_version.is_empty() {
track_event(
app_handle,
AnalyticsResource::App,
AnalyticsAction::LaunchFirst,
None,
)
.await;
} else {
info.launched_after_update = info.current_version != info.previous_version;
if info.launched_after_update {
track_event(
app_handle,
AnalyticsResource::App,
AnalyticsAction::LaunchUpdate,
Some(json!({ "num_launches": info.num_launches })),
)
.await;
}
};
// Track a launch event in all cases
track_event(
app_handle,
AnalyticsResource::App,
AnalyticsAction::Launch,
Some(json!({ "num_launches": info.num_launches })),
)
.await;
// Update key values
models::set_key_value_string(
namespace,
last_tracked_version_key,
info.current_version.as_str(),
pool,
)
.await;
models::set_key_value_int(namespace, "num_launches", info.num_launches, pool).await;
info
}
pub async fn track_event(
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);
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 is_dev() {
debug!("Send event (dev): {}", event);
} else if let Err(e) = req.send().await {
warn!(
"Error sending analytics event: {} {} {:?}",
e, event, params
);
} else {
debug!("Send event: {}: {:?}", event, params);
}
});
// Disable analytics actual sending in dev
if is_dev() {
debug!("track: {} {}", event, attributes_json);
return;
}
if let Err(e) = req.send().await {
warn!(
"Error sending analytics event: {} {} {} {:?}",
e, event, attributes_json, params,
);
}
}
fn get_os() -> &'static str {

414
src-tauri/src/http.rs Normal file
View File

@@ -0,0 +1,414 @@
use std::fs::{create_dir_all, File};
use std::fs;
use std::io::Write;
use std::path::PathBuf;
use std::str::FromStr;
use std::sync::Arc;
use std::time::Duration;
use base64::Engine;
use http::{HeaderMap, HeaderName, HeaderValue, Method};
use http::header::{ACCEPT, USER_AGENT};
use log::{error, info, warn};
use reqwest::{multipart, Url};
use reqwest::redirect::Policy;
use sqlx::{Pool, Sqlite};
use sqlx::types::{Json, JsonValue};
use tauri::{AppHandle, Wry};
use crate::{emit_side_effect, models, render, response_err};
pub async fn send_http_request(
request: models::HttpRequest,
response: &models::HttpResponse,
environment: Option<models::Environment>,
cookie_jar: Option<models::CookieJar>,
app_handle: &AppHandle<Wry>,
pool: &Pool<Sqlite>,
download_path: Option<PathBuf>,
) -> Result<models::HttpResponse, String> {
let environment_ref = environment.as_ref();
let workspace = models::get_workspace(&request.workspace_id, pool)
.await
.expect("Failed to get Workspace");
let mut url_string = render::render(&request.url, &workspace, environment.as_ref());
if !url_string.starts_with("http://") && !url_string.starts_with("https://") {
url_string = format!("http://{}", url_string);
}
let mut client_builder = reqwest::Client::builder()
.redirect(match workspace.setting_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(!workspace.setting_validate_certificates)
.tls_info(true);
// Add cookie store if specified
let maybe_cookie_manager = match cookie_jar.clone() {
Some(cj) => {
// HACK: Can't construct Cookie without serde, so we have to do this
let cookies = cj
.cookies
.0
.iter()
.map(|json_cookie| {
serde_json::from_value(json_cookie.clone())
.expect("Failed to deserialize cookie")
})
.map(|c| Ok(c))
.collect::<Vec<Result<_, ()>>>();
let store = reqwest_cookie_store::CookieStore::from_cookies(cookies, true)
.expect("Failed to create cookie store");
let cookie_store = reqwest_cookie_store::CookieStoreMutex::new(store);
let cookie_store = Arc::new(cookie_store);
client_builder = client_builder.cookie_provider(Arc::clone(&cookie_store));
Some((cookie_store, cj))
}
None => None,
};
if workspace.setting_request_timeout > 0 {
client_builder = client_builder.timeout(Duration::from_millis(
workspace.setting_request_timeout.unsigned_abs(),
));
}
// .use_rustls_tls() // TODO: Make this configurable (maybe)
let client = client_builder.build().expect("Failed to build client");
let url = match Url::from_str(url_string.as_str()) {
Ok(u) => u,
Err(e) => {
return response_err(response, e.to_string(), app_handle, pool).await;
}
};
let m = Method::from_bytes(request.method.to_uppercase().as_bytes())
.expect("Failed to create method");
let mut request_builder = client.request(m, url.clone());
let mut headers = HeaderMap::new();
headers.insert(USER_AGENT, HeaderValue::from_static("yaak"));
headers.insert(ACCEPT, HeaderValue::from_static("*/*"));
// TODO: Set cookie header ourselves once we also handle redirects. We need to do this
// because reqwest doesn't give us a way to inspect the headers it sent (we have to do
// everything manually to know that).
// if let Some(cookie_store) = maybe_cookie_store.clone() {
// let values1 = cookie_store.get_request_values(&url);
// println!("COOKIE VLUAES: {:?}", values1.collect::<Vec<_>>());
// let raw_value = cookie_store.get_request_values(&url)
// .map(|(name, value)| format!("{}={}", name, value))
// .collect::<Vec<_>>()
// .join("; ");
// headers.insert(
// COOKIE,
// HeaderValue::from_str(&raw_value).expect("Failed to create cookie header"),
// );
// }
for h in request.headers.0 {
if h.name.is_empty() && h.value.is_empty() {
continue;
}
if !h.enabled {
continue;
}
let name = render::render(&h.name, &workspace, environment_ref);
let value = render::render(&h.value, &workspace, environment_ref);
let header_name = match HeaderName::from_bytes(name.as_bytes()) {
Ok(n) => n,
Err(e) => {
error!("Failed to create header name: {}", e);
continue;
}
};
let header_value = match HeaderValue::from_str(value.as_str()) {
Ok(n) => n,
Err(e) => {
error!("Failed to create header value: {}", e);
continue;
}
};
headers.insert(header_name, header_value);
}
if let Some(b) = &request.authentication_type {
let empty_value = &serde_json::to_value("").unwrap();
let a = request.authentication.0;
if b == "basic" {
let raw_username = a
.get("username")
.unwrap_or(empty_value)
.as_str()
.unwrap_or("");
let raw_password = a
.get("password")
.unwrap_or(empty_value)
.as_str()
.unwrap_or("");
let username = render::render(raw_username, &workspace, environment_ref);
let password = render::render(raw_password, &workspace, environment_ref);
let auth = format!("{username}:{password}");
let encoded = base64::engine::general_purpose::STANDARD_NO_PAD.encode(auth);
headers.insert(
"Authorization",
HeaderValue::from_str(&format!("Basic {}", encoded)).unwrap(),
);
} else if b == "bearer" {
let raw_token = a.get("token").unwrap_or(empty_value).as_str().unwrap_or("");
let token = render::render(raw_token, &workspace, environment_ref);
headers.insert(
"Authorization",
HeaderValue::from_str(&format!("Bearer {token}")).unwrap(),
);
}
}
let mut query_params = Vec::new();
for p in request.url_parameters.0 {
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),
));
}
request_builder = request_builder.query(&query_params);
if let Some(body_type) = &request.body_type {
let empty_string = &serde_json::to_value("").unwrap();
let empty_bool = &serde_json::to_value(false).unwrap();
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 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")
{
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();
form_params.push((
render::render(name, &workspace, environment_ref),
render::render(value, &workspace, environment_ref),
));
}
}
request_builder = request_builder.form(&form_params);
} else if body_type == "multipart/form-data" && request_body.contains_key("form") {
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 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,
)),
},
);
}
}
headers.remove("Content-Type"); // reqwest will add this automatically
request_builder = request_builder.multipart(multipart_form);
} else {
warn!("Unsupported body type: {}", body_type);
}
}
// Add headers last, because previous steps may modify them
request_builder = request_builder.headers(headers);
let sendable_req = match request_builder.build() {
Ok(r) => r,
Err(e) => {
return response_err(response, e.to_string(), app_handle, pool).await;
}
};
let start = std::time::Instant::now();
let raw_response = client.execute(sendable_req).await;
match raw_response {
Ok(v) => {
let mut response = response.clone();
response.elapsed_headers = start.elapsed().as_millis() as i64;
let response_headers = v.headers().clone();
response.status = v.status().as_u16() as i64;
response.status_reason = v.status().canonical_reason().map(|s| s.to_string());
response.headers = Json(
response_headers
.iter()
.map(|(k, v)| models::HttpResponseHeader {
name: k.as_str().to_string(),
value: v.to_str().unwrap().to_string(),
})
.collect(),
);
response.url = v.url().to_string();
response.remote_addr = v.remote_addr().map(|a| a.to_string());
response.version = match v.version() {
http::Version::HTTP_09 => Some("HTTP/0.9".to_string()),
http::Version::HTTP_10 => Some("HTTP/1.0".to_string()),
http::Version::HTTP_11 => Some("HTTP/1.1".to_string()),
http::Version::HTTP_2 => Some("HTTP/2".to_string()),
http::Version::HTTP_3 => Some("HTTP/3".to_string()),
_ => None,
};
let content_length = v.content_length();
let body_bytes = v.bytes().await.expect("Failed to get body").to_vec();
response.elapsed = start.elapsed().as_millis() as i64;
// Use content length if available, otherwise use body length
response.content_length = match content_length {
Some(l) => Some(l as i64),
None => Some(body_bytes.len() as i64),
};
{
// Write body to FS
let dir = app_handle.path_resolver().app_data_dir().unwrap();
let base_dir = dir.join("responses");
create_dir_all(base_dir.clone()).expect("Failed to create responses dir");
let body_path = match response.id.is_empty() {
false => base_dir.join(response.id.clone()),
true => base_dir.join(uuid::Uuid::new_v4().to_string()),
};
let mut f = File::options()
.create(true)
.truncate(true)
.write(true)
.open(&body_path)
.expect("Failed to open file");
f.write_all(body_bytes.as_slice())
.expect("Failed to write to file");
response.body_path = Some(
body_path
.to_str()
.expect("Failed to get body path")
.to_string(),
);
}
response = models::update_response_if_id(&response, pool)
.await
.expect("Failed to update response");
if !request.id.is_empty() {
emit_side_effect(app_handle, "updated_model", &response);
}
// Copy response to download path, if specified
match (download_path, response.body_path.clone()) {
(Some(dl_path), Some(body_path)) => {
info!("Downloading response body to {}", dl_path.display());
fs::copy(body_path, dl_path)
.expect("Failed to copy file for response download");
}
_ => {}
};
// Add cookie store if specified
if let Some((cookie_store, mut cookie_jar)) = maybe_cookie_manager {
// let cookies = response_headers.get_all(SET_COOKIE).iter().map(|h| {
// println!("RESPONSE COOKIE: {}", h.to_str().unwrap());
// cookie_store::RawCookie::from_str(h.to_str().unwrap())
// .expect("Failed to parse cookie")
// });
// store.store_response_cookies(cookies, &url);
let json_cookies: Json<Vec<JsonValue>> = Json(
cookie_store
.lock()
.unwrap()
.iter_any()
.map(|c| serde_json::to_value(&c).expect("Failed to serialize cookie"))
.collect::<Vec<_>>(),
);
cookie_jar.cookies = json_cookies;
match models::upsert_cookie_jar(pool, &cookie_jar).await {
Ok(updated_jar) => {
emit_side_effect(app_handle, "updated_model", &updated_jar);
}
Err(e) => {
error!("Failed to update cookie jar: {}", e);
}
};
}
Ok(response)
}
Err(e) => response_err(response, e.to_string(), app_handle, pool).await,
}
}

View File

@@ -3,42 +3,46 @@
windows_subsystem = "windows"
)]
extern crate core;
#[cfg(target_os = "macos")]
#[macro_use]
extern crate objc;
use std::collections::HashMap;
use std::env::current_dir;
use std::fs::{create_dir_all, File};
use std::fs::{create_dir_all, read_to_string, File};
use std::process::exit;
use fern::colors::ColoredLevelConfig;
use log::{debug, error, info};
use log::{debug, error, info, warn};
use rand::random;
use serde::Serialize;
use sqlx::{Pool, Sqlite, SqlitePool};
use serde_json::{json, Value};
use sqlx::migrate::Migrator;
use sqlx::types::Json;
use tauri::{AppHandle, Menu, RunEvent, State, Submenu, Window, WindowUrl, Wry};
use tauri::{CustomMenuItem, 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 tokio::time::sleep;
use window_shadows::set_shadow;
use window_ext::TrafficLightWindowExt;
use crate::analytics::{AnalyticsAction, AnalyticsResource, track_event};
use crate::analytics::{AnalyticsAction, AnalyticsResource};
use crate::http::send_http_request;
use crate::plugin::{ImportResources, ImportResult};
use crate::send::actually_send_request;
use crate::updates::{update_mode_from_str, UpdateMode, YaakUpdater};
mod analytics;
mod http;
mod models;
mod plugin;
mod render;
mod send;
mod updates;
mod window_ext;
mod window_menu;
@@ -67,7 +71,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(())
}
@@ -75,14 +79,79 @@ async fn migrate_db(
async fn send_ephemeral_request(
mut request: models::HttpRequest,
environment_id: Option<&str>,
cookie_jar_id: Option<&str>,
app_handle: AppHandle<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<models::HttpResponse, String> {
let pool = &*db_instance.lock().await;
let response = models::HttpResponse::new();
let environment_id2 = environment_id.unwrap_or("n/a").to_string();
request.id = "".to_string();
actually_send_request(request, &response, &environment_id2, &app_handle, pool).await
let environment = match environment_id {
Some(id) => Some(
models::get_environment(id, pool)
.await
.expect("Failed to get environment"),
),
None => None,
};
let cookie_jar = match cookie_jar_id {
Some(id) => Some(
models::get_cookie_jar(id, pool)
.await
.expect("Failed to get cookie jar"),
),
None => None,
};
// let cookie_jar_id2 = cookie_jar_id.unwrap_or("").to_string();
send_http_request(
request,
&response,
environment,
cookie_jar,
&app_handle,
pool,
None,
)
.await
}
#[tauri::command]
async fn filter_response(
window: Window<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
response_id: &str,
filter: &str,
) -> Result<String, String> {
let pool = &*db_instance.lock().await;
let response = models::get_response(response_id, pool)
.await
.expect("Failed to get response");
if let None = response.body_path {
return Err("Response body not found".to_string());
}
let mut content_type = "".to_string();
for header in response.headers.iter() {
if header.name.to_lowercase() == "content-type" {
content_type = header.value.to_string().to_lowercase();
break;
}
}
// TODO: Have plugins register their own content type (regex?)
let plugin_name = if content_type.contains("json") {
"filter-jsonpath"
} else {
"filter-xpath"
};
let body = read_to_string(response.body_path.unwrap()).unwrap();
let filter_result = plugin::run_plugin_filter(&window.app_handle(), plugin_name, filter, &body)
.await
.expect("Failed to run filter");
Ok(filter_result.filtered)
}
#[tauri::command]
@@ -102,6 +171,13 @@ async fn import_data(
)
.await
{
analytics::track_event(
&window.app_handle(),
AnalyticsResource::App,
AnalyticsAction::Import,
Some(json!({ "plugin": plugin_name })),
)
.await;
result = Some(r);
break;
}
@@ -168,8 +244,17 @@ async fn export_data(
serde_json::to_writer_pretty(&f, &export_data)
.map_err(|e| e.to_string())
.expect("Failed to write");
f.sync_all().expect("Failed to sync");
info!("Exported Yaak workspace to {:?}", export_path);
analytics::track_event(
&app_handle,
AnalyticsResource::App,
AnalyticsAction::Export,
None,
)
.await;
Ok(())
}
@@ -179,33 +264,69 @@ async fn send_request(
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
request_id: &str,
environment_id: Option<&str>,
cookie_jar_id: Option<&str>,
download_dir: Option<&str>,
) -> Result<models::HttpResponse, String> {
let pool = &*db_instance.lock().await;
let app_handle = window.app_handle();
let req = models::get_request(request_id, pool)
let request = models::get_request(request_id, pool)
.await
.expect("Failed to get request");
let response = models::create_response(&req.id, 0, "", 0, None, None, None, None, vec![], pool)
.await
.expect("Failed to create response");
let response2 = response.clone();
let environment_id2 = environment_id.unwrap_or("n/a").to_string();
let app_handle2 = window.app_handle().clone();
let pool2 = pool.clone();
tokio::spawn(async move {
if let Err(e) =
actually_send_request(req, &response2, &environment_id2, &app_handle2, &pool2).await
{
response_err(&response2, e, &app_handle2, &pool2)
let environment = match environment_id {
Some(id) => Some(
models::get_environment(id, pool)
.await
.expect("Failed to update response");
}
});
.expect("Failed to get environment"),
),
None => None,
};
emit_and_return(&window, "created_model", response)
let cookie_jar = match cookie_jar_id {
Some(id) => Some(
models::get_cookie_jar(id, pool)
.await
.expect("Failed to get cookie jar"),
),
None => None,
};
let response = models::create_response(
&request.id,
0,
0,
"",
0,
None,
None,
None,
vec![],
None,
None,
pool,
)
.await
.expect("Failed to create response");
let download_path = if let Some(p) = download_dir {
Some(std::path::Path::new(p).to_path_buf())
} else {
None
};
emit_side_effect(&app_handle, "created_model", response.clone());
send_http_request(
request.clone(),
&response,
environment,
cookie_jar,
&app_handle,
&pool,
download_path,
)
.await
}
async fn response_err(
@@ -224,6 +345,28 @@ async fn response_err(
Ok(response)
}
#[tauri::command]
async fn track_event(
window: Window<Wry>,
resource: &str,
action: &str,
attributes: Option<Value>,
) -> Result<(), String> {
match (
AnalyticsResource::from_str(resource),
AnalyticsAction::from_str(action),
) {
(Some(resource), Some(action)) => {
analytics::track_event(&window.app_handle(), resource, action, attributes).await;
}
_ => {
error!("Invalid action/resource for track_event: {action} {resource}");
return Err("Invalid event".to_string());
}
};
Ok(())
}
#[tauri::command]
async fn set_update_mode(
update_mode: &str,
@@ -240,7 +383,7 @@ async fn get_key_value(
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<Option<models::KeyValue>, ()> {
let pool = &*db_instance.lock().await;
let result = models::get_key_value(namespace, key, pool).await;
let result = models::get_key_value_raw(namespace, key, pool).await;
Ok(result)
}
@@ -253,7 +396,7 @@ async fn set_key_value(
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<models::KeyValue, String> {
let pool = &*db_instance.lock().await;
let (key_value, created) = models::set_key_value(namespace, key, value, pool).await;
let (key_value, created) = models::set_key_value_raw(namespace, key, value, pool).await;
if created {
emit_and_return(&window, "created_model", key_value)
@@ -269,17 +412,63 @@ async fn create_workspace(
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<models::Workspace, String> {
let pool = &*db_instance.lock().await;
let created_workspace = models::upsert_workspace(
let created_workspace =
models::upsert_workspace(pool, models::Workspace::new(name.to_string()))
.await
.expect("Failed to create Workspace");
emit_and_return(&window, "created_model", created_workspace)
}
#[tauri::command]
async fn update_cookie_jar(
cookie_jar: models::CookieJar,
window: Window<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<models::CookieJar, String> {
let pool = &*db_instance.lock().await;
println!("Updating cookie jar {}", cookie_jar.cookies.len());
let updated = models::upsert_cookie_jar(pool, &cookie_jar)
.await
.expect("Failed to update cookie jar");
emit_and_return(&window, "updated_model", updated)
}
#[tauri::command]
async fn delete_cookie_jar(
window: Window<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
cookie_jar_id: &str,
) -> Result<models::CookieJar, String> {
let pool = &*db_instance.lock().await;
let req = models::delete_cookie_jar(cookie_jar_id, pool)
.await
.expect("Failed to delete cookie jar");
emit_and_return(&window, "deleted_model", req)
}
#[tauri::command]
async fn create_cookie_jar(
workspace_id: &str,
name: &str,
window: Window<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<models::CookieJar, String> {
let pool = &*db_instance.lock().await;
let created_cookie_jar = models::upsert_cookie_jar(
pool,
models::Workspace {
&models::CookieJar {
name: name.to_string(),
workspace_id: workspace_id.to_string(),
..Default::default()
},
)
.await
.expect("Failed to create Workspace");
.expect("Failed to create cookie jar");
emit_and_return(&window, "created_model", created_workspace)
emit_and_return(&window, "created_model", created_cookie_jar)
}
#[tauri::command]
@@ -504,6 +693,27 @@ async fn list_environments(
Ok(environments)
}
#[tauri::command]
async fn get_settings(db_instance: State<'_, Mutex<Pool<Sqlite>>>) -> Result<models::Settings, ()> {
let pool = &*db_instance.lock().await;
Ok(models::get_or_create_settings(pool).await)
}
#[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,
@@ -526,6 +736,44 @@ async fn get_request(
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn get_cookie_jar(
id: &str,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<models::CookieJar, String> {
let pool = &*db_instance.lock().await;
models::get_cookie_jar(id, pool)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn list_cookie_jars(
workspace_id: &str,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<Vec<models::CookieJar>, String> {
let pool = &*db_instance.lock().await;
let cookie_jars = models::find_cookie_jars(workspace_id, pool)
.await
.expect("Failed to find cookie jars");
if cookie_jars.is_empty() {
let cookie_jar = models::upsert_cookie_jar(
pool,
&models::CookieJar {
name: "Default".to_string(),
workspace_id: workspace_id.to_string(),
..Default::default()
},
)
.await
.expect("Failed to create CookieJar");
Ok(vec![cookie_jar])
} else {
Ok(cookie_jars)
}
}
#[tauri::command]
async fn get_environment(
id: &str,
@@ -551,10 +799,11 @@ async fn get_workspace(
#[tauri::command]
async fn list_responses(
request_id: &str,
limit: Option<i64>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<Vec<models::HttpResponse>, String> {
let pool = &*db_instance.lock().await;
models::find_responses(request_id, pool)
models::find_responses(request_id, limit, pool)
.await
.map_err(|e| e.to_string())
}
@@ -631,7 +880,7 @@ async fn check_for_updates(
app_handle: AppHandle<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
yaak_updater: State<'_, Mutex<YaakUpdater>>,
) -> Result<(), String> {
) -> Result<bool, String> {
let pool = &*db_instance.lock().await;
let update_mode = get_update_mode(pool).await;
yaak_updater
@@ -651,26 +900,38 @@ fn main() {
.level_for("sqlx", log::LevelFilter::Warn)
.level_for("hyper", log::LevelFilter::Info)
.level_for("tracing", log::LevelFilter::Info)
.level_for("reqwest", log::LevelFilter::Debug)
.level_for("reqwest", log::LevelFilter::Info)
.level_for("tokio_util", log::LevelFilter::Info)
.level_for("cookie_store", log::LevelFilter::Info)
.with_colors(ColoredLevelConfig::default())
.level(log::LevelFilter::Trace)
.build(),
)
.plugin(tauri_plugin_window_state::Builder::default().build())
.plugin(tauri_plugin_context_menu::init())
.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!");
let p = dir.join("db.sqlite");
File::options().write(true).create(true).open(&p).expect("Problem creating database file!");
File::options()
.write(true)
.create(true)
.open(&p)
.expect("Problem creating database file!");
let p_string = p.to_string_lossy().replace(' ', "%20");
let url = format!("sqlite://{}?mode=rwc", p_string);
println!("Connecting to database at {}", url);
info!("Connecting to database at {}", url);
tauri::async_runtime::block_on(async move {
let pool = SqlitePool::connect(p.to_str().unwrap())
@@ -694,11 +955,13 @@ fn main() {
})
.invoke_handler(tauri::generate_handler![
check_for_updates,
create_cookie_jar,
create_environment,
create_folder,
create_request,
create_workspace,
delete_all_responses,
delete_cookie_jar,
delete_environment,
delete_folder,
delete_request,
@@ -706,12 +969,16 @@ fn main() {
delete_workspace,
duplicate_request,
export_data,
get_key_value,
filter_response,
get_cookie_jar,
get_environment,
get_folder,
get_key_value,
get_request,
get_settings,
get_workspace,
import_data,
list_cookie_jars,
list_environments,
list_folders,
list_requests,
@@ -722,9 +989,12 @@ fn main() {
send_request,
set_key_value,
set_update_mode,
track_event,
update_cookie_jar,
update_environment,
update_folder,
update_request,
update_settings,
update_workspace,
])
.build(tauri::generate_context!())
@@ -749,21 +1019,27 @@ fn main() {
debug!("Updater downloaded");
}
tauri::UpdaterEvent::Error(e) => {
error!("Updater error: {:?}", e);
warn!("Updater received error: {:?}", e);
}
_ => {}
},
RunEvent::Ready => {
let w = create_window(app_handle, None);
w.restore_state(StateFlags::all())
.expect("Failed to restore window state");
if let Err(e) = w.restore_state(StateFlags::all()) {
error!("Failed to restore window state {}", e);
}
track_event(
app_handle,
AnalyticsResource::App,
AnalyticsAction::Launch,
None,
);
let h = app_handle.clone();
tauri::async_runtime::spawn(async move {
let info = analytics::track_launch_event(&h).await;
info!("Launched Yaak {:?}", info);
// Wait for window render and give a chance for the user to notice
if info.launched_after_update && info.num_launches > 1 {
sleep(std::time::Duration::from_secs(5)).await;
let _ = w.emit("show_changelog", true);
}
});
}
RunEvent::WindowEvent {
label: _label,
@@ -786,28 +1062,18 @@ fn main() {
}
fn is_dev() -> bool {
let env = option_env!("YAAK_ENV");
env.unwrap_or("production") != "production"
#[cfg(dev)]
{
return true;
}
#[cfg(not(dev))]
{
return false;
}
}
fn create_window(handle: &AppHandle<Wry>, url: Option<&str>) -> Window<Wry> {
let mut app_menu = window_menu::os_default("Yaak".to_string().as_str());
if is_dev() {
let submenu = Submenu::new(
"Developer",
Menu::new()
.add_item(
CustomMenuItem::new("refresh".to_string(), "Refresh")
.accelerator("CmdOrCtrl + Shift + r"),
)
.add_item(
CustomMenuItem::new("toggle_devtools".to_string(), "Open Devtools")
.accelerator("CmdOrCtrl + Option + i"),
),
);
app_menu = app_menu.add_submenu(submenu);
}
let app_menu = window_menu::os_default("Yaak".to_string().as_str());
let window_num = handle.windows().len();
let window_id = format!("wnd_{}", window_num);
let mut win_builder = tauri::WindowBuilder::new(
@@ -815,7 +1081,6 @@ fn create_window(handle: &AppHandle<Wry>, url: Option<&str>) -> Window<Wry> {
window_id,
WindowUrl::App(url.unwrap_or_default().into()),
)
.menu(app_menu)
.fullscreen(false)
.resizable(true)
.inner_size(1100.0, 600.0)
@@ -830,12 +1095,24 @@ fn create_window(handle: &AppHandle<Wry>, url: Option<&str>) -> Window<Wry> {
#[cfg(target_os = "macos")]
{
win_builder = win_builder
.menu(app_menu)
.hidden_title(true)
.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() {
@@ -875,7 +1152,6 @@ fn create_window(handle: &AppHandle<Wry>, url: Option<&str>) -> Window<Wry> {
WindowEvent::Focused(..) => apply_offset(),
WindowEvent::ScaleFactorChanged { .. } => apply_offset(),
WindowEvent::CloseRequested { .. } => {
println!("CLOSE REQUESTED");
// api.prevent_close();
}
_ => {}
@@ -902,10 +1178,6 @@ fn emit_side_effect<S: Serialize + Clone>(app_handle: &AppHandle<Wry>, event: &s
}
async fn get_update_mode(pool: &Pool<Sqlite>) -> UpdateMode {
let mode = models::get_key_value_string("app", "update_mode", pool)
.await;
match mode {
Some(mode) => update_mode_from_str(&mode),
None => UpdateMode::Stable,
}
let settings = models::get_or_create_settings(pool).await;
update_mode_from_str(settings.update_channel.as_str())
}

View File

@@ -1,6 +1,7 @@
use std::collections::HashMap;
use std::fs;
use log::error;
use rand::distributions::{Alphanumeric, DistString};
use serde::{Deserialize, Serialize};
use sqlx::{Pool, Sqlite};
@@ -8,6 +9,22 @@ use sqlx::types::{Json, JsonValue};
use sqlx::types::chrono::NaiveDateTime;
use tauri::AppHandle;
fn default_true() -> bool {
true
}
#[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,
pub theme: String,
pub appearance: String,
pub update_channel: String,
}
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct Workspace {
@@ -18,6 +35,43 @@ pub struct Workspace {
pub name: String,
pub description: String,
pub variables: Json<Vec<EnvironmentVariable>>,
// Settings
#[serde(default = "default_true")]
pub setting_validate_certificates: bool,
#[serde(default = "default_true")]
pub setting_follow_redirects: bool,
pub setting_request_timeout: i64,
}
// Implement default for Workspace
impl Workspace {
pub(crate) fn new(name: String) -> Self {
Self {
name,
model: "workspace".to_string(),
setting_validate_certificates: true,
setting_follow_redirects: true,
..Default::default()
}
}
}
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize, Default)]
pub struct CookieX {
}
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct CookieJar {
pub id: String,
pub model: String,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
pub workspace_id: String,
pub name: String,
pub cookies: Json<Vec<JsonValue>>,
}
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize, Default)]
@@ -32,14 +86,10 @@ pub struct Environment {
pub variables: Json<Vec<EnvironmentVariable>>,
}
fn default_enabled() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct EnvironmentVariable {
#[serde(default = "default_enabled")]
#[serde(default = "default_true")]
pub enabled: bool,
pub name: String,
pub value: String,
@@ -48,7 +98,7 @@ pub struct EnvironmentVariable {
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct HttpRequestHeader {
#[serde(default = "default_enabled")]
#[serde(default = "default_true")]
pub enabled: bool,
pub name: String,
pub value: String,
@@ -57,7 +107,7 @@ pub struct HttpRequestHeader {
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct HttpUrlParameter {
#[serde(default = "default_enabled")]
#[serde(default = "default_true")]
pub enabled: bool,
pub name: String,
pub value: String,
@@ -121,10 +171,12 @@ pub struct HttpResponse {
pub error: Option<String>,
pub url: String,
pub content_length: Option<i64>,
pub version: Option<String>,
pub elapsed: i64,
pub elapsed_headers: i64,
pub remote_addr: Option<String>,
pub status: i64,
pub status_reason: Option<String>,
pub body: Option<Vec<u8>>,
pub body_path: Option<String>,
pub headers: Json<Vec<HttpResponseHeader>>,
}
@@ -149,13 +201,75 @@ pub struct KeyValue {
pub value: String,
}
pub async fn set_key_value(
pub async fn set_key_value_string(
namespace: &str,
key: &str,
value: &str,
pool: &Pool<Sqlite>,
) -> (KeyValue, bool) {
let existing = get_key_value(namespace, key, pool).await;
let encoded = serde_json::to_string(value);
set_key_value_raw(namespace, key, &encoded.unwrap(), pool).await
}
pub async fn set_key_value_int(
namespace: &str,
key: &str,
value: i32,
pool: &Pool<Sqlite>,
) -> (KeyValue, bool) {
let encoded = serde_json::to_string(&value);
set_key_value_raw(namespace, key, &encoded.unwrap(), pool).await
}
pub async fn get_key_value_string(
namespace: &str,
key: &str,
default: &str,
pool: &Pool<Sqlite>,
) -> String {
match get_key_value_raw(namespace, key, pool).await {
None => default.to_string(),
Some(v) => {
let result = serde_json::from_str(&v.value);
match result {
Ok(v) => v,
Err(e) => {
error!("Failed to parse string key value: {}", e);
default.to_string()
}
}
},
}
}
pub async fn get_key_value_int(
namespace: &str,
key: &str,
default: i32,
pool: &Pool<Sqlite>,
) -> i32 {
match get_key_value_raw(namespace, key, pool).await {
None => default.clone(),
Some(v) => {
let result = serde_json::from_str(&v.value);
match result {
Ok(v) => v,
Err(e) => {
error!("Failed to parse int key value: {}", e);
default.clone()
}
}
},
}
}
pub async fn set_key_value_raw(
namespace: &str,
key: &str,
value: &str,
pool: &Pool<Sqlite>,
) -> (KeyValue, bool) {
let existing = get_key_value_raw(namespace, key, pool).await;
sqlx::query!(
r#"
INSERT INTO key_values (namespace, key, value)
@@ -171,13 +285,13 @@ pub async fn set_key_value(
.await
.expect("Failed to insert key value");
let kv = get_key_value(namespace, key, pool)
let kv = get_key_value_raw(namespace, key, pool)
.await
.expect("Failed to get key value");
(kv, existing.is_none())
}
pub async fn get_key_value(namespace: &str, key: &str, pool: &Pool<Sqlite>) -> Option<KeyValue> {
pub async fn get_key_value_raw(namespace: &str, key: &str, pool: &Pool<Sqlite>) -> Option<KeyValue> {
sqlx::query_as!(
KeyValue,
r#"
@@ -193,23 +307,20 @@ 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> {
let kv = get_key_value(namespace, key, pool).await?;
let result = serde_json::from_str(&kv.value);
match result {
Ok(v) => Some(v),
Err(e) => {
println!("Failed to parse key value: {}", e);
None
}
}
}
pub async fn find_workspaces(pool: &Pool<Sqlite>) -> Result<Vec<Workspace>, sqlx::Error> {
sqlx::query_as!(
Workspace,
r#"
SELECT id, model, created_at, updated_at, name, description,
SELECT
id,
model,
created_at,
updated_at,
name,
description,
setting_request_timeout,
setting_follow_redirects,
setting_validate_certificates,
variables AS "variables!: sqlx::types::Json<Vec<EnvironmentVariable>>"
FROM workspaces
"#,
@@ -222,7 +333,16 @@ pub async fn get_workspace(id: &str, pool: &Pool<Sqlite>) -> Result<Workspace, s
sqlx::query_as!(
Workspace,
r#"
SELECT id, model, created_at, updated_at, name, description,
SELECT
id,
model,
created_at,
updated_at,
name,
description,
setting_request_timeout,
setting_follow_redirects,
setting_validate_certificates,
variables AS "variables!: sqlx::types::Json<Vec<EnvironmentVariable>>"
FROM workspaces WHERE id = ?
"#,
@@ -251,6 +371,96 @@ pub async fn delete_workspace(id: &str, pool: &Pool<Sqlite>) -> Result<Workspace
Ok(workspace)
}
pub async fn get_cookie_jar(id: &str, pool: &Pool<Sqlite>) -> Result<CookieJar, sqlx::Error> {
sqlx::query_as!(
CookieJar,
r#"
SELECT
id,
model,
created_at,
updated_at,
workspace_id,
name,
cookies AS "cookies!: sqlx::types::Json<Vec<JsonValue>>"
FROM cookie_jars WHERE id = ?
"#,
id,
)
.fetch_one(pool)
.await
}
pub async fn find_cookie_jars(workspace_id: &str, pool: &Pool<Sqlite>) -> Result<Vec<CookieJar>, sqlx::Error> {
sqlx::query_as!(
CookieJar,
r#"
SELECT
id,
model,
created_at,
updated_at,
workspace_id,
name,
cookies AS "cookies!: sqlx::types::Json<Vec<JsonValue>>"
FROM cookie_jars WHERE workspace_id = ?
"#,
workspace_id,
)
.fetch_all(pool)
.await
}
pub async fn delete_cookie_jar(id: &str, pool: &Pool<Sqlite>) -> Result<CookieJar, sqlx::Error> {
let cookie_jar = get_cookie_jar(id, pool).await?;
let _ = sqlx::query!(
r#"
DELETE FROM cookie_jars
WHERE id = ?
"#,
id,
)
.execute(pool)
.await;
Ok(cookie_jar)
}
pub async fn upsert_cookie_jar(
pool: &Pool<Sqlite>,
cookie_jar: &CookieJar,
) -> Result<CookieJar, sqlx::Error> {
let id = match cookie_jar.id.as_str() {
"" => generate_id(Some("cj")),
_ => cookie_jar.id.to_string(),
};
let trimmed_name = cookie_jar.name.trim();
sqlx::query!(
r#"
INSERT INTO cookie_jars (
id,
workspace_id,
name,
cookies
)
VALUES (?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET
updated_at = CURRENT_TIMESTAMP,
name = excluded.name,
cookies = excluded.cookies
"#,
id,
cookie_jar.workspace_id,
trimmed_name,
cookie_jar.cookies,
)
.execute(pool)
.await?;
get_cookie_jar(&id, pool).await
}
pub async fn find_environments(
workspace_id: &str,
pool: &Pool<Sqlite>,
@@ -284,6 +494,63 @@ 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,
theme,
appearance,
update_channel
FROM settings
WHERE id = 'default'
"#,
)
.fetch_one(pool)
.await
}
pub async fn get_or_create_settings(pool: &Pool<Sqlite>) -> Settings {
if let Ok(settings) = get_settings(pool).await {
settings
} else {
sqlx::query!(
r#"
INSERT INTO settings (id)
VALUES ('default')
"#,
)
.execute(pool)
.await.expect("Failed to insert settings");
get_settings(pool).await.expect("Failed to get settings")
}
}
pub async fn update_settings(
pool: &Pool<Sqlite>,
settings: Settings,
) -> Result<Settings, sqlx::Error> {
sqlx::query!(
r#"
UPDATE settings SET (
theme,
appearance,
update_channel
) = (?, ?, ?) WHERE id = 'default';
"#,
settings.theme,
settings.appearance,
settings.update_channel
)
.execute(pool)
.await?;
get_settings(pool).await
}
pub async fn upsert_environment(
pool: &Pool<Sqlite>,
environment: Environment,
@@ -587,16 +854,19 @@ pub async fn delete_request(id: &str, pool: &Pool<Sqlite>) -> Result<HttpRequest
Ok(req)
}
#[allow(clippy::too_many_arguments)]
pub async fn create_response(
request_id: &str,
elapsed: i64,
elapsed_headers: i64,
url: &str,
status: i64,
status_reason: Option<&str>,
content_length: Option<i64>,
body: Option<Vec<u8>>,
body_path: Option<&str>,
headers: Vec<HttpResponseHeader>,
version: Option<&str>,
remote_addr: Option<&str>,
pool: &Pool<Sqlite>,
) -> Result<HttpResponse, sqlx::Error> {
let req = get_request(request_id, pool).await?;
@@ -609,27 +879,31 @@ pub async fn create_response(
request_id,
workspace_id,
elapsed,
elapsed_headers,
url,
status,
status_reason,
content_length,
body,
body_path,
headers
headers,
version,
remote_addr
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
"#,
id,
request_id,
req.workspace_id,
elapsed,
elapsed_headers,
url,
status,
status_reason,
content_length,
body,
body_path,
headers_json,
version,
remote_addr,
)
.execute(pool)
.await?;
@@ -672,18 +946,32 @@ pub async fn upsert_workspace(
let trimmed_name = workspace.name.trim();
sqlx::query!(
r#"
INSERT INTO workspaces (id, name, description, variables)
VALUES (?, ?, ?, ?)
INSERT INTO workspaces (
id,
name,
description,
variables,
setting_request_timeout,
setting_follow_redirects,
setting_validate_certificates
)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET
updated_at = CURRENT_TIMESTAMP,
name = excluded.name,
description = excluded.description,
variables = excluded.variables
variables = excluded.variables,
setting_request_timeout = excluded.setting_request_timeout,
setting_follow_redirects = excluded.setting_follow_redirects,
setting_validate_certificates = excluded.setting_validate_certificates
"#,
id,
trimmed_name,
workspace.description,
workspace.variables,
workspace.setting_request_timeout,
workspace.setting_follow_redirects,
workspace.setting_validate_certificates,
)
.execute(pool)
.await?;
@@ -700,26 +988,30 @@ pub async fn update_response(
r#"
UPDATE http_responses SET (
elapsed,
elapsed_headers,
url,
status,
status_reason,
content_length,
body,
body_path,
error,
headers,
version,
remote_addr,
updated_at
) = (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) WHERE id = ?;
) = (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) WHERE id = ?;
"#,
response.elapsed,
response.elapsed_headers,
response.url,
response.status,
response.status_reason,
response.content_length,
response.body,
response.body_path,
response.error,
headers_json,
response.version,
response.remote_addr,
response.id,
)
.execute(pool)
@@ -731,8 +1023,10 @@ pub async fn get_response(id: &str, pool: &Pool<Sqlite>) -> Result<HttpResponse,
sqlx::query_as!(
HttpResponse,
r#"
SELECT id, model, workspace_id, request_id, updated_at, created_at, url,
status, status_reason, content_length, body, body_path, elapsed, error,
SELECT
id, model, workspace_id, request_id, updated_at, created_at, url, status,
status_reason, content_length, body_path, elapsed, elapsed_headers, error,
version, remote_addr,
headers AS "headers!: sqlx::types::Json<Vec<HttpResponseHeader>>"
FROM http_responses
WHERE id = ?
@@ -745,19 +1039,25 @@ pub async fn get_response(id: &str, pool: &Pool<Sqlite>) -> Result<HttpResponse,
pub async fn find_responses(
request_id: &str,
limit: Option<i64>,
pool: &Pool<Sqlite>,
) -> Result<Vec<HttpResponse>, sqlx::Error> {
let limit_unwrapped = limit.unwrap_or_else(|| i64::MAX);
sqlx::query_as!(
HttpResponse,
r#"
SELECT id, model, workspace_id, request_id, updated_at, created_at, url,
status, status_reason, content_length, body, body_path, elapsed, error,
SELECT
id, model, workspace_id, request_id, updated_at, created_at, url, status,
status_reason, content_length, body_path, elapsed, elapsed_headers, error,
version, remote_addr,
headers AS "headers!: sqlx::types::Json<Vec<HttpResponseHeader>>"
FROM http_responses
WHERE request_id = ?
ORDER BY created_at DESC
LIMIT ?
"#,
request_id,
limit_unwrapped,
)
.fetch_all(pool)
.await
@@ -770,8 +1070,10 @@ pub async fn find_responses_by_workspace_id(
sqlx::query_as!(
HttpResponse,
r#"
SELECT id, model, workspace_id, request_id, updated_at, created_at, url,
status, status_reason, content_length, body, body_path, elapsed, error,
SELECT
id, model, workspace_id, request_id, updated_at, created_at, url, status,
status_reason, content_length, body_path, elapsed, elapsed_headers, error,
version, remote_addr,
headers AS "headers!: sqlx::types::Json<Vec<HttpResponseHeader>>"
FROM http_responses
WHERE workspace_id = ?
@@ -789,7 +1091,7 @@ pub async fn delete_response(id: &str, pool: &Pool<Sqlite>) -> Result<HttpRespon
// Delete the body file if it exists
if let Some(p) = resp.body_path.clone() {
if let Err(e) = fs::remove_file(p) {
println!("Failed to delete body file: {}", e);
error!("Failed to delete body file: {}", e);
};
}
@@ -810,7 +1112,7 @@ pub async fn delete_all_responses(
request_id: &str,
pool: &Pool<Sqlite>,
) -> Result<(), sqlx::Error> {
for r in find_responses(request_id, pool).await? {
for r in find_responses(request_id, None, pool).await? {
delete_response(&r.id, pool).await?;
}
Ok(())

View File

@@ -1,20 +1,24 @@
use std::fs;
use boa_engine::builtins::promise::PromiseState;
use boa_engine::{
js_string,
module::{ModuleLoader, SimpleModuleLoader},
property::Attribute,
Context, JsArgs, JsNativeError, JsValue, Module, NativeFunction, Source,
Context, js_string, JsNativeError, JsValue, Module, module::SimpleModuleLoader,
property::Attribute, Source,
};
use boa_engine::builtins::promise::PromiseState;
use boa_engine::module::ModuleLoader;
use boa_runtime::Console;
use log::debug;
use log::{debug, error};
use serde::{Deserialize, Serialize};
use serde_json::json;
use tauri::AppHandle;
use crate::models::{Environment, Folder, HttpRequest, Workspace};
#[derive(Default, Debug, Deserialize, Serialize)]
pub struct FilterResult {
pub filtered: String,
}
#[derive(Default, Debug, Deserialize, Serialize)]
pub struct ImportResult {
pub resources: ImportResources,
@@ -28,6 +32,29 @@ pub struct ImportResources {
pub requests: Vec<HttpRequest>,
}
pub async fn run_plugin_filter(
app_handle: &AppHandle,
plugin_name: &str,
response_body: &str,
filter: &str,
) -> Option<FilterResult> {
let result_json = run_plugin(
app_handle,
plugin_name,
"pluginHookResponseFilter",
&[js_string!(response_body).into(), js_string!(filter).into()],
);
if result_json.is_null() {
error!("Plugin {} failed to run", plugin_name);
return None;
}
let resources: FilterResult =
serde_json::from_value(result_json).expect("failed to parse filter plugin result json");
Some(resources)
}
pub async fn run_plugin_import(
app_handle: &AppHandle,
plugin_name: &str,
@@ -63,7 +90,7 @@ fn run_plugin(
.resolve_resource("plugins")
.expect("failed to resolve plugin directory resource")
.join(plugin_name);
let plugin_index_file = plugin_dir.join("out/index.js");
let plugin_index_file = plugin_dir.join("index.mjs");
debug!(
"Running plugin dir={:?} file={:?}",
@@ -80,7 +107,6 @@ fn run_plugin(
.expect("failed to create context");
add_runtime(context);
add_globals(context);
let source = Source::from_filepath(&plugin_index_file).expect("Error opening file");
@@ -88,7 +114,6 @@ fn run_plugin(
let module = Module::parse(source, None, context).expect("failed to parse module");
// Insert parsed entrypoint into the module loader
// TODO: Is this needed if loaded from file already?
loader.insert(plugin_index_file, module.clone());
let promise_result = module
@@ -131,26 +156,9 @@ fn run_plugin(
}
}
fn add_runtime(context: &mut Context<'_>) {
fn add_runtime(context: &mut Context) {
let console = Console::init(context);
context
.register_global_property(js_string!(Console::NAME), console, Attribute::all())
.expect("the console builtin shouldn't exist");
}
fn add_globals(context: &mut Context<'_>) {
context
.register_global_builtin_callable(
"sayHello",
1,
NativeFunction::from_fn_ptr(|_, args, context| {
let value: String = args
.get_or_undefined(0)
.try_js_into(context)
.expect("failed to convert arg");
println!("Hello {}!", value);
Ok(value.into())
}),
)
.expect("failed to register global");
}

View File

@@ -1,247 +0,0 @@
use std::fs;
use std::fs::{create_dir_all, File};
use std::io::Write;
use base64::Engine;
use http::{HeaderMap, HeaderName, HeaderValue, Method};
use http::header::{ACCEPT, USER_AGENT};
use log::warn;
use reqwest::multipart;
use reqwest::redirect::Policy;
use sqlx::{Pool, Sqlite};
use sqlx::types::Json;
use tauri::{AppHandle, Wry};
use crate::{emit_side_effect, models, render, response_err};
pub async fn actually_send_request(
request: models::HttpRequest,
response: &models::HttpResponse,
environment_id: &str,
app_handle: &AppHandle<Wry>,
pool: &Pool<Sqlite>,
) -> Result<models::HttpResponse, String> {
let start = std::time::Instant::now();
let environment = models::get_environment(environment_id, pool).await.ok();
let environment_ref = environment.as_ref();
let workspace = models::get_workspace(&request.workspace_id, pool)
.await
.expect("Failed to get Workspace");
let mut url_string = render::render(&request.url, &workspace, environment.as_ref());
if !url_string.starts_with("http://") && !url_string.starts_with("https://") {
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 m = Method::from_bytes(request.method.to_uppercase().as_bytes())
.expect("Failed to create method");
let mut request_builder = client.request(m, url_string.to_string());
let mut headers = HeaderMap::new();
headers.insert(USER_AGENT, HeaderValue::from_static("yaak"));
headers.insert(ACCEPT, HeaderValue::from_static("*/*"));
for h in request.headers.0 {
if h.name.is_empty() && h.value.is_empty() {
continue;
}
if !h.enabled {
continue;
}
let name = render::render(&h.name, &workspace, environment_ref);
let value = render::render(&h.value, &workspace, environment_ref);
let header_name = match HeaderName::from_bytes(name.as_bytes()) {
Ok(n) => n,
Err(e) => {
eprintln!("Failed to create header name: {}", e);
continue;
}
};
let header_value = match HeaderValue::from_str(value.as_str()) {
Ok(n) => n,
Err(e) => {
eprintln!("Failed to create header value: {}", e);
continue;
}
};
headers.insert(header_name, header_value);
}
if let Some(b) = &request.authentication_type {
let empty_value = &serde_json::to_value("").unwrap();
let a = request.authentication.0;
if b == "basic" {
let raw_username = a
.get("username")
.unwrap_or(empty_value)
.as_str()
.unwrap_or("");
let raw_password = a
.get("password")
.unwrap_or(empty_value)
.as_str()
.unwrap_or("");
let username = render::render(raw_username, &workspace, environment_ref);
let password = render::render(raw_password, &workspace, environment_ref);
let auth = format!("{username}:{password}");
let encoded = base64::engine::general_purpose::STANDARD_NO_PAD.encode(auth);
headers.insert(
"Authorization",
HeaderValue::from_str(&format!("Basic {}", encoded)).unwrap(),
);
} else if b == "bearer" {
let raw_token = a.get("token").unwrap_or(empty_value).as_str().unwrap_or("");
let token = render::render(raw_token, &workspace, environment_ref);
headers.insert(
"Authorization",
HeaderValue::from_str(&format!("Bearer {token}")).unwrap(),
);
}
}
let mut query_params = Vec::new();
for p in request.url_parameters.0 {
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),
));
}
request_builder = request_builder.query(&query_params);
if let Some(body_type) = &request.body_type {
let empty_string = &serde_json::to_value("").unwrap();
let empty_bool = &serde_json::to_value(false).unwrap();
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 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") {
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();
form_params.push((
render::render(name, &workspace, environment_ref),
render::render(value, &workspace, environment_ref),
));
}
}
request_builder = request_builder.form(&form_params);
} else if body_type == "multipart/form-data" && request_body.contains_key("form") {
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 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)),
},
);
}
}
headers.remove("Content-Type"); // reqwest will add this automatically
request_builder = request_builder.multipart(multipart_form);
} else {
warn!("Unsupported body type: {}", body_type);
}
}
// Add headers last, because previous steps may modify them
request_builder = request_builder.headers(headers);
let sendable_req = match request_builder.build() {
Ok(r) => r,
Err(e) => {
return response_err(response, e.to_string(), app_handle, pool).await;
}
};
let raw_response = client.execute(sendable_req).await;
match raw_response {
Ok(v) => {
let mut response = response.clone();
response.status = v.status().as_u16() as i64;
response.status_reason = v.status().canonical_reason().map(|s| s.to_string());
response.headers = Json(
v.headers()
.iter()
.map(|(k, v)| models::HttpResponseHeader {
name: k.as_str().to_string(),
value: v.to_str().unwrap().to_string(),
})
.collect(),
);
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);
{
// Write body to FS
let dir = app_handle.path_resolver().app_data_dir().unwrap();
let base_dir = dir.join("responses");
create_dir_all(base_dir.clone()).expect("Failed to create responses dir");
let body_path = match response.id.is_empty() {
false => base_dir.join(response.id.clone()),
true => base_dir.join(uuid::Uuid::new_v4().to_string()),
};
let mut f = File::options()
.create(true)
.truncate(true)
.write(true)
.open(&body_path)
.expect("Failed to open file");
f.write_all(body_bytes.as_slice())
.expect("Failed to write to file");
response.body_path = Some(
body_path
.to_str()
.expect("Failed to get body path")
.to_string(),
);
}
// Also store body directly on the model, if small enough
if body_bytes.len() < 100_000 {
response.body = Some(body_bytes);
}
response.elapsed = start.elapsed().as_millis() as i64;
response = models::update_response_if_id(&response, pool)
.await
.expect("Failed to update response");
if !request.id.is_empty() {
emit_side_effect(app_handle, "updated_model", &response);
}
Ok(response)
}
Err(e) => response_err(response, e.to_string(), app_handle, pool).await,
}
}

View File

@@ -29,14 +29,17 @@ impl YaakUpdater {
&mut self,
app_handle: &AppHandle<Wry>,
mode: UpdateMode,
) -> Result<(), updater::Error> {
if is_dev() {
info!("Skipping update check because we are in dev mode");
return Ok(());
}
) -> Result<bool, updater::Error> {
self.last_update_check = SystemTime::now();
let update_mode = get_update_mode_str(mode);
info!("Checking for updates mode={}", update_mode);
let enabled = !is_dev();
info!("Checking for updates mode={} enabled={}", update_mode, enabled);
if !enabled {
return Ok(false);
}
match app_handle
.updater()
.header("X-Update-Mode", update_mode)?
@@ -62,7 +65,7 @@ impl YaakUpdater {
if dialog::blocking::ask(
None::<&Window>,
"Update Installed",
format!("Would you like to restart the app?",),
"Would you like to restart the app?",
) {
h.restart();
}
@@ -78,9 +81,9 @@ impl YaakUpdater {
});
},
);
Ok(())
Ok(true)
}
Err(updater::Error::UpToDate) => Ok(()),
Err(updater::Error::UpToDate) => Ok(false),
Err(e) => Err(e),
}
}
@@ -88,9 +91,10 @@ impl YaakUpdater {
&mut self,
app_handle: &AppHandle<Wry>,
mode: UpdateMode,
) -> Result<(), updater::Error> {
if self.last_update_check.elapsed().unwrap().as_secs() < MAX_UPDATE_CHECK_SECONDS {
return Ok(());
) -> Result<bool, updater::Error> {
let ignore_check = self.last_update_check.elapsed().unwrap().as_secs() < MAX_UPDATE_CHECK_SECONDS;
if ignore_check {
return Ok(false);
}
self.force_check(app_handle, mode).await

View File

@@ -1,4 +1,5 @@
use tauri::{AboutMetadata, CustomMenuItem, Menu, MenuItem, Submenu};
use crate::is_dev;
pub fn os_default(#[allow(unused)] app_name: &str) -> Menu {
let mut menu = Menu::new();
@@ -12,6 +13,11 @@ pub fn os_default(#[allow(unused)] app_name: &str) -> Menu {
AboutMetadata::default(),
))
.add_native_item(MenuItem::Separator)
.add_item(
CustomMenuItem::new("toggle_settings".to_string(), "Settings")
.accelerator("CmdOrCtrl+,"),
)
.add_native_item(MenuItem::Separator)
.add_native_item(MenuItem::Services)
.add_native_item(MenuItem::Separator)
.add_native_item(MenuItem::Hide)
@@ -69,23 +75,23 @@ pub fn os_default(#[allow(unused)] app_name: &str) -> Menu {
)
.add_item(
CustomMenuItem::new("zoom_out".to_string(), "Zoom Out").accelerator("CmdOrCtrl+-"),
)
.add_native_item(MenuItem::Separator)
.add_item(
CustomMenuItem::new("toggle_sidebar".to_string(), "Toggle Sidebar")
.accelerator("CmdOrCtrl+b"),
)
.add_item(
CustomMenuItem::new("focus_sidebar".to_string(), "Focus Sidebar")
.accelerator("CmdOrCtrl+1"),
)
.add_item(
CustomMenuItem::new("toggle_settings".to_string(), "Toggle Settings")
.accelerator("CmdOrCtrl+,"),
)
.add_item(
CustomMenuItem::new("focus_url".to_string(), "Focus URL").accelerator("CmdOrCtrl+l"),
);
// .add_native_item(MenuItem::Separator)
// .add_item(
// CustomMenuItem::new("toggle_sidebar".to_string(), "Toggle Sidebar")
// .accelerator("CmdOrCtrl+b"),
// )
// .add_item(
// CustomMenuItem::new("focus_sidebar".to_string(), "Focus Sidebar")
// .accelerator("CmdOrCtrl+1"),
// )
// .add_item(
// CustomMenuItem::new("toggle_settings".to_string(), "Toggle Settings")
// .accelerator("CmdOrCtrl+,"),
// )
// .add_item(
// CustomMenuItem::new("focus_url".to_string(), "Focus URL").accelerator("CmdOrCtrl+l"),
// );
menu = menu.add_submenu(Submenu::new("View", view_menu));
let mut window_menu = Menu::new();
@@ -98,22 +104,37 @@ pub fn os_default(#[allow(unused)] app_name: &str) -> Menu {
window_menu = window_menu.add_native_item(MenuItem::CloseWindow);
menu = menu.add_submenu(Submenu::new("Window", window_menu));
menu = menu.add_submenu(Submenu::new(
"Workspace",
Menu::new()
.add_item(
CustomMenuItem::new("send_request".to_string(), "Send Request")
.accelerator("CmdOrCtrl+r"),
)
.add_item(
CustomMenuItem::new("new_request".to_string(), "New Request")
.accelerator("CmdOrCtrl+n"),
)
.add_item(
CustomMenuItem::new("duplicate_request".to_string(), "Duplicate Request")
.accelerator("CmdOrCtrl+d"),
),
));
// menu = menu.add_submenu(Submenu::new(
// "Workspace",
// Menu::new()
// .add_item(
// CustomMenuItem::new("send_request".to_string(), "Send Request")
// .accelerator("CmdOrCtrl+r"),
// )
// .add_item(
// CustomMenuItem::new("new_request".to_string(), "New Request")
// .accelerator("CmdOrCtrl+n"),
// )
// .add_item(
// CustomMenuItem::new("duplicate_request".to_string(), "Duplicate Request")
// .accelerator("CmdOrCtrl+d"),
// ),
// ));
if is_dev() {
menu = menu.add_submenu(Submenu::new(
"Developer",
Menu::new()
.add_item(
CustomMenuItem::new("refresh".to_string(), "Refresh")
.accelerator("CmdOrCtrl + Shift + r"),
)
.add_item(
CustomMenuItem::new("toggle_devtools".to_string(), "Open Devtools")
.accelerator("CmdOrCtrl + Option + i"),
),
));
}
menu
}

View File

@@ -8,7 +8,7 @@
},
"package": {
"productName": "Yaak",
"version": "2023.4.0-beta.1"
"version": "2024.2.0"
},
"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 +0,0 @@
<svg width="100%" height="100%" viewBox="0 0 15 15" version="1.1" xmlns="http://www.w3.org/2000/svg"
style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path fill="currentColor"
d="M2.5,1C1.672,1 1,1.672 1,2.5L1,12.5C1,13.328 1.672,14 2.5,14L12.5,14C13.328,14 14,13.328 14,12.5L14,2.5C14,1.672 13.328,1 12.5,1L2.5,1ZM12.5,13C12.776,13 13,12.776 13,12.5L13,2.5C13,2.224 12.776,2 12.5,2L6,2L6,13L12.5,13ZM2.5,2L5,2L5,13L2.5,13C2.224,13 2,12.776 2,12.5L2,2.5C2,2.224 2.224,2 2.5,2Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 553 B

View File

@@ -1,6 +0,0 @@
<svg width="100%" height="100%" viewBox="0 0 15 15" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<rect x="0" y="0" width="15" height="15" style="fill:none;"/>
<g transform="matrix(1,0,0,1,-16,-8.88178e-16)">
<path fill="currentColor" d="M18.5,1C17.672,1 17,1.672 17,2.5L17,12.5C17,13.328 17.672,14 18.5,14L28.5,14C29.328,14 30,13.328 30,12.5L30,2.5C30,1.672 29.328,1 28.5,1L18.5,1ZM28.5,13C28.776,13 29,12.776 29,12.5L29,2.5C29,2.224 28.776,2 28.5,2L22,2L22,13L28.5,13ZM18,11.535L21,12.285L21,13L18.5,13C18.224,13 18,12.776 18,12.5L18,11.535ZM18,10.504L21,11.254L21,9.81L18,9.06L18,10.504ZM18,8.029L21,8.779L21,7.327L18,6.577L18,8.029ZM18,5.546L21,6.296L21,4.833L18,4.083L18,5.546ZM21,3.802L18,3.052L18,2.5C18,2.224 18.224,2 18.5,2L21,2L21,3.802Z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1006 B

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

@@ -0,0 +1,74 @@
import { useCookieJars } from '../hooks/useCookieJars';
import { useUpdateCookieJar } from '../hooks/useUpdateCookieJar';
import { cookieDomain } from '../lib/models';
import { Banner } from './core/Banner';
import { IconButton } from './core/IconButton';
import { InlineCode } from './core/InlineCode';
interface Props {
cookieJarId: string | null;
}
export const CookieDialog = function ({ cookieJarId }: Props) {
const updateCookieJar = useUpdateCookieJar(cookieJarId ?? null);
const cookieJars = useCookieJars();
const cookieJar = cookieJars.find((c) => c.id === cookieJarId);
if (cookieJar == null) {
return <div>No cookie jar selected</div>;
}
if (cookieJar.cookies.length === 0) {
return (
<Banner>
Cookies will appear when a response contains the <InlineCode>Set-Cookie</InlineCode> header
</Banner>
);
}
return (
<div className="pb-2">
<table className="w-full text-xs mb-auto min-w-full max-w-full divide-y">
<thead>
<tr>
<th className="py-2 text-left">Domain</th>
<th className="py-2 text-left pl-4">Cookie</th>
<th className="py-2 pl-4"></th>
</tr>
</thead>
<tbody className="divide-y">
{cookieJar?.cookies.map((c) => (
<tr key={c.domain + c.raw_cookie}>
<td className="py-2 select-text cursor-text font-mono font-semibold max-w-0">
{cookieDomain(c)}
</td>
<td className="py-2 pl-4 select-text cursor-text font-mono text-gray-700 whitespace-nowrap overflow-x-auto max-w-[200px] hide-scrollbars">
{c.raw_cookie}
</td>
<td className="max-w-0 w-10">
<IconButton
icon="trash"
size="xs"
iconSize="sm"
title="Delete"
className="ml-auto"
onClick={async () => {
console.log(
'DELETE COOKIE',
c,
cookieJar.cookies.filter((c2) => c2 !== c).length,
);
await updateCookieJar.mutateAsync({
...cookieJar,
cookies: cookieJar.cookies.filter((c2) => c2 !== c),
});
}}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
};

View File

@@ -0,0 +1,94 @@
import { useActiveCookieJar } from '../hooks/useActiveCookieJar';
import { useCookieJars } from '../hooks/useCookieJars';
import { useCreateCookieJar } from '../hooks/useCreateCookieJar';
import { useDeleteCookieJar } from '../hooks/useDeleteCookieJar';
import { usePrompt } from '../hooks/usePrompt';
import { useUpdateCookieJar } from '../hooks/useUpdateCookieJar';
import { CookieDialog } from './CookieDialog';
import { Dropdown, type DropdownItem } from './core/Dropdown';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { InlineCode } from './core/InlineCode';
import { useDialog } from './DialogContext';
export function CookieDropdown() {
const cookieJars = useCookieJars();
const { activeCookieJar, setActiveCookieJarId } = useActiveCookieJar();
const updateCookieJar = useUpdateCookieJar(activeCookieJar?.id ?? null);
const deleteCookieJar = useDeleteCookieJar(activeCookieJar ?? null);
const createCookieJar = useCreateCookieJar();
const dialog = useDialog();
const prompt = usePrompt();
return (
<Dropdown
items={[
...cookieJars.map((j) => ({
key: j.id,
label: j.name,
leftSlot: <Icon icon={j.id === activeCookieJar?.id ? 'check' : 'empty'} />,
onSelect: () => setActiveCookieJarId(j.id),
})),
...((cookieJars.length > 0 && activeCookieJar != null
? [
{ type: 'separator', label: activeCookieJar.name },
{
key: 'manage',
label: 'Manage Cookies',
leftSlot: <Icon icon="cookie" />,
onSelect: () => {
if (activeCookieJar == null) return;
dialog.show({
id: 'cookies',
title: 'Manage Cookies',
size: 'full',
render: () => <CookieDialog cookieJarId={activeCookieJar.id} />,
});
},
},
{
key: 'rename',
label: 'Rename',
leftSlot: <Icon icon="pencil" />,
onSelect: async () => {
const name = await prompt({
id: 'rename-cookie-jar',
title: 'Rename Cookie Jar',
description: (
<>
Enter a new name for <InlineCode>{activeCookieJar?.name}</InlineCode>
</>
),
name: 'name',
label: 'Name',
defaultValue: activeCookieJar?.name,
});
updateCookieJar.mutate({ name });
},
},
...((cookieJars.length > 1 // Never delete the last one
? [
{
key: 'delete',
label: 'Delete',
leftSlot: <Icon icon="trash" />,
variant: 'danger',
onSelect: () => deleteCookieJar.mutateAsync(),
},
]
: []) as DropdownItem[]),
]
: []) as DropdownItem[]),
{ type: 'separator' },
{
key: 'create-cookie-jar',
label: 'New Cookie Jar',
leftSlot: <Icon icon="plus" />,
onSelect: () => createCookieJar.mutate(),
},
]}
>
<IconButton size="sm" icon="cookie" title="Cookie Jar" />
</Dropdown>
);
}

View File

@@ -1,4 +1,5 @@
import React, { createContext, useContext, useMemo, useState } from 'react';
import { trackEvent } from '../lib/analytics';
import type { DialogProps } from './core/Dialog';
import { Dialog } from './core/Dialog';
@@ -7,15 +8,14 @@ type DialogEntry = {
render: ({ hide }: { hide: () => void }) => React.ReactNode;
} & Pick<DialogProps, 'title' | 'description' | 'hideX' | 'className' | 'size'>;
type DialogEntryOptionalId = Omit<DialogEntry, 'id'> & { id?: string };
interface State {
dialogs: DialogEntry[];
actions: Actions;
}
interface Actions {
show: (d: DialogEntryOptionalId) => void;
show: (d: DialogEntry) => void;
toggle: (d: DialogEntry) => void;
hide: (id: string) => void;
}
@@ -26,15 +26,19 @@ export const DialogProvider = ({ children }: { children: React.ReactNode }) => {
const [dialogs, setDialogs] = useState<State['dialogs']>([]);
const actions = useMemo<Actions>(
() => ({
show: ({ id: oid, ...props }: DialogEntryOptionalId) => {
const id = oid ?? Math.random().toString(36).slice(2);
show({ id, ...props }: DialogEntry) {
trackEvent('Dialog', 'Show', { id });
setDialogs((a) => [...a.filter((d) => d.id !== id), { id, ...props }]);
},
toggle({ id, ...props }: DialogEntry) {
if (dialogs.some((d) => d.id === id)) this.hide(id);
else this.show({ id, ...props });
},
hide: (id: string) => {
setDialogs((a) => a.filter((d) => d.id !== id));
},
}),
[],
[dialogs],
);
const state: State = {

View File

@@ -2,6 +2,7 @@ import classNames from 'classnames';
import { memo, useCallback, useMemo } from 'react';
import { useActiveEnvironment } from '../hooks/useActiveEnvironment';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { useCreateEnvironment } from '../hooks/useCreateEnvironment';
import { useEnvironments } from '../hooks/useEnvironments';
import type { ButtonProps } from './core/Button';
import { Button } from './core/Button';
@@ -21,11 +22,13 @@ export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdo
}: Props) {
const environments = useEnvironments();
const activeEnvironment = useActiveEnvironment();
const createEnvironment = useCreateEnvironment();
const dialog = useDialog();
const routes = useAppRoutes();
const showEnvironmentDialog = useCallback(() => {
dialog.show({
dialog.toggle({
id: 'environment-editor',
title: 'Manage Environments',
render: () => <EnvironmentEditDialog initialEnvironment={activeEnvironment} />,
});
@@ -51,14 +54,28 @@ export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdo
...((environments.length > 0
? [{ type: 'separator', label: 'Environments' }]
: []) as DropdownItem[]),
...((environments.length > 0
? [
{
key: 'edit',
label: 'Manage Environments',
hotKeyAction: 'environmentEditor.toggle',
leftSlot: <Icon icon="box" />,
onSelect: showEnvironmentDialog,
},
]
: []) as DropdownItem[]),
{
key: 'edit',
label: 'Manage Environments',
leftSlot: <Icon icon="gear" />,
onSelect: showEnvironmentDialog,
key: 'new',
label: 'New Environment',
leftSlot: <Icon icon="plus" />,
onSelect: async () => {
await createEnvironment.mutateAsync();
showEnvironmentDialog();
},
},
],
[activeEnvironment, environments, routes, showEnvironmentDialog],
[activeEnvironment?.id, createEnvironment, environments, routes, showEnvironmentDialog],
);
return (

View File

@@ -43,40 +43,37 @@ export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
[environments, selectedEnvironmentId],
);
const handleCreateEnvironment = async () => {
const e = await createEnvironment.mutateAsync();
setSelectedEnvironmentId(e.id);
};
return (
<div
className={classNames(
'h-full grid gap-x-8 grid-rows-[minmax(0,1fr)]',
'h-full pt-1 grid gap-x-8 grid-rows-[minmax(0,1fr)]',
showSidebar ? 'grid-cols-[auto_minmax(0,1fr)]' : 'grid-cols-[minmax(0,1fr)]',
)}
>
{showSidebar && (
<aside className="grid grid-rows-[minmax(0,1fr)_auto] gap-y-0.5 h-full max-w-[250px] pr-3 border-r border-gray-100 -ml-2">
<aside className="grid grid-rows-[minmax(0,1fr)_auto] gap-y-0.5 h-full max-w-[250px] pr-3 border-r border-gray-100 -ml-2 pb-4">
<div className="min-w-0 h-full w-full overflow-y-scroll">
<SidebarButton
active={selectedEnvironment == null}
onClick={() => setSelectedEnvironmentId(null)}
>
Base Environment
</SidebarButton>
<div className="ml-3 pl-2 border-l border-highlight">
{environments.map((e) => (
<SidebarButton
key={e.id}
active={selectedEnvironment?.id === e.id}
onClick={() => setSelectedEnvironmentId(e.id)}
>
{e.name}
</SidebarButton>
))}
</div>
{environments.map((e) => (
<SidebarButton
key={e.id}
active={selectedEnvironment?.id === e.id}
onClick={() => setSelectedEnvironmentId(e.id)}
>
{e.name}
</SidebarButton>
))}
</div>
<Button
size="sm"
className="w-full text-center"
color="gray"
justify="center"
onClick={() => createEnvironment.mutate()}
onClick={handleCreateEnvironment}
>
New Environment
</Button>
@@ -155,6 +152,7 @@ const EnvironmentEditor = function ({
leftSlot: <Icon icon="pencil" size="sm" />,
onSelect: async () => {
const name = await prompt({
id: 'rename-environment',
title: 'Rename Environment',
description: (
<>
@@ -191,7 +189,12 @@ const EnvironmentEditor = function ({
<h1 className="text-xl">{environment?.name ?? 'Base Environment'}</h1>
{items != null && (
<Dropdown items={items}>
<IconButton icon="gear" title="Environment Actions" size="sm" className="!h-auto w-8" />
<IconButton
icon="moreVertical"
title="Environment Actions"
size="sm"
className="!h-auto w-8"
/>
</Dropdown>
)}
</HStack>
@@ -199,7 +202,6 @@ const EnvironmentEditor = function ({
nameAutocomplete={nameAutocomplete}
nameAutocompleteVariables={false}
namePlaceholder="VAR_NAME"
valuePlaceholder="variable value"
nameValidate={validateName}
valueAutocompleteVariables={false}
forceUpdateKey={environment?.id ?? workspace?.id ?? 'n/a'}

View File

@@ -2,7 +2,7 @@ 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 { cookieJarsQueryKey } from '../hooks/useCookieJars';
import { keyValueQueryKey } from '../hooks/useKeyValue';
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
import { useRecentEnvironments } from '../hooks/useRecentEnvironments';
@@ -11,9 +11,10 @@ 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 { useSyncAppearance } from '../hooks/useSyncAppearance';
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';
@@ -28,6 +29,8 @@ export function GlobalHooks() {
useRecentEnvironments();
useRecentRequests();
useSyncAppearance();
useSyncWindowTitle();
const queryClient = useQueryClient();
@@ -39,10 +42,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 +54,10 @@ export function GlobalHooks() {
? workspacesQueryKey(payload)
: payload.model === 'key_value'
? keyValueQueryKey(payload)
: payload.model === 'settings'
? settingsQueryKey()
: payload.model === 'cookie_jar'
? cookieJarsQueryKey(payload)
: null;
if (queryKey === null) {
@@ -80,6 +83,10 @@ export function GlobalHooks() {
? workspacesQueryKey(payload)
: payload.model === 'key_value'
? keyValueQueryKey(payload)
: payload.model === 'cookie_jar'
? cookieJarsQueryKey(payload)
: payload.model === 'settings'
? settingsQueryKey()
: null;
if (queryKey === null) {
@@ -92,9 +99,11 @@ export function GlobalHooks() {
}
if (!shouldIgnoreModel(payload)) {
console.time('set query date');
queryClient.setQueryData<Model[]>(queryKey, (values) =>
values?.map((v) => (modelsEq(v, payload) ? payload : v)),
);
console.timeEnd('set query date');
}
});
@@ -111,6 +120,10 @@ 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 === 'cookie_jar') {
queryClient.setQueryData(cookieJarsQueryKey(payload), undefined);
} else if (payload.model === 'settings') {
queryClient.setQueryData(settingsQueryKey(), undefined);
}
});
useListenToTauriEvent<number>('zoom', ({ payload: zoomDelta, windowLabel }) => {

View File

@@ -82,40 +82,45 @@ export function GraphQLEditor({ defaultValue, onChange, baseRequest, ...extraEdi
placeholder="..."
ref={editorViewRef}
actions={
(error || isLoading) && (
<Button
size="xs"
color={error ? 'danger' : 'gray'}
isLoading={isLoading}
onClick={() => {
dialog.show({
title: 'Introspection Failed',
size: 'dynamic',
id: 'introspection-failed',
render: () => (
<>
<FormattedError>{error ?? 'unknown'}</FormattedError>
<div className="w-full mt-3">
<Button
onClick={() => {
dialog.hide('introspection-failed');
refetch();
}}
className="ml-auto"
color="secondary"
size="sm"
>
Try Again
</Button>
</div>
</>
),
});
}}
>
{error ? 'Introspection Failed' : 'Introspecting'}
</Button>
)
error || isLoading
? [
<div key="introspection" className="!opacity-100">
<Button
key="introspection"
size="xs"
color={error ? 'danger' : 'gray'}
isLoading={isLoading}
onClick={() => {
dialog.show({
title: 'Introspection Failed',
size: 'dynamic',
id: 'introspection-failed',
render: () => (
<>
<FormattedError>{error ?? 'unknown'}</FormattedError>
<div className="w-full my-4">
<Button
onClick={() => {
dialog.hide('introspection-failed');
refetch();
}}
className="ml-auto"
color="secondary"
size="sm"
>
Try Again
</Button>
</div>
</>
),
});
}}
>
{error ? 'Introspection Failed' : 'Introspecting'}
</Button>
</div>,
]
: []
}
{...extraEditorProps}
/>

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 pb-2">
<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

@@ -54,7 +54,7 @@ export const RecentResponsesDropdown = function ResponsePane({
>
<IconButton
title="Show response history"
icon="triangleDown"
icon="chevronDown"
className="ml-auto"
size="sm"
iconSize="md"

View File

@@ -1,56 +0,0 @@
import { useRef } from 'react';
import { useDeleteRequest } from '../hooks/useDeleteRequest';
import { useDuplicateRequest } from '../hooks/useDuplicateRequest';
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
import type { DropdownProps, DropdownRef } from './core/Dropdown';
import { Dropdown } from './core/Dropdown';
import { HotKey } from './core/HotKey';
import { Icon } from './core/Icon';
interface Props {
requestId: string | null;
children: DropdownProps['children'];
}
export function RequestActionsDropdown({ requestId, children }: Props) {
const deleteRequest = useDeleteRequest(requestId);
const duplicateRequest = useDuplicateRequest({ id: requestId, navigateAfter: true });
const dropdownRef = useRef<DropdownRef>(null);
useListenToTauriEvent('toggle_settings', () => {
dropdownRef.current?.toggle();
});
// TODO: Put this somewhere better
useListenToTauriEvent('duplicate_request', () => {
duplicateRequest.mutate();
});
if (requestId == null) {
return null;
}
return (
<Dropdown
ref={dropdownRef}
items={[
{
key: 'duplicate',
label: 'Duplicate',
onSelect: duplicateRequest.mutate,
leftSlot: <Icon icon="copy" />,
rightSlot: <HotKey modifier="Meta" keyName="D" />,
},
{
key: 'delete',
label: 'Delete',
onSelect: deleteRequest.mutate,
variant: 'danger',
leftSlot: <Icon icon="trash" />,
},
]}
>
{children}
</Dropdown>
);
}

View File

@@ -1,5 +1,9 @@
import { memo } from 'react';
import { memo, useMemo } from 'react';
import { usePrompt } from '../hooks/usePrompt';
import { Button } from './core/Button';
import type { DropdownItem } from './core/Dropdown';
import { Icon } from './core/Icon';
import type { RadioDropdownItem } from './core/RadioDropdown';
import { RadioDropdown } from './core/RadioDropdown';
type Props = {
@@ -8,7 +12,15 @@ type Props = {
onChange: (method: string) => void;
};
const methodItems = ['GET', 'PUT', 'POST', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD'].map((m) => ({
const radioItems: RadioDropdownItem<string>[] = [
'GET',
'PUT',
'POST',
'PATCH',
'DELETE',
'OPTIONS',
'HEAD',
].map((m) => ({
value: m,
label: m,
}));
@@ -18,8 +30,33 @@ export const RequestMethodDropdown = memo(function RequestMethodDropdown({
onChange,
className,
}: Props) {
const prompt = usePrompt();
const extraItems = useMemo<DropdownItem[]>(
() => [
{ type: 'separator' },
{
key: 'custom',
label: 'CUSTOM',
leftSlot: <Icon icon="sparkles" />,
onSelect: async () => {
const newMethod = await prompt({
id: 'custom-method',
label: 'Http Method',
name: 'httpMethod',
defaultValue: '',
title: 'Custom Method',
description: 'Enter a custom method name',
placeholder: 'CUSTOM',
});
onChange(newMethod);
},
},
],
[onChange, prompt],
);
return (
<RadioDropdown value={method} items={methodItems} onChange={onChange}>
<RadioDropdown value={method} items={radioItems} extraItems={extraItems} onChange={onChange}>
<Button size="xs" className={className}>
{method.toUpperCase()}
</Button>

View File

@@ -1,12 +1,8 @@
import { invoke } from '@tauri-apps/api';
import { appWindow } from '@tauri-apps/api/window';
import classNames from 'classnames';
import type { CSSProperties } from 'react';
import { memo, useCallback, useMemo, useState } from 'react';
import { createGlobalState } from 'react-use';
import { useActiveEnvironmentId } from '../hooks/useActiveEnvironmentId';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
import { useUpdateRequest } from '../hooks/useUpdateRequest';
import { tryFormatJson } from '../lib/formatters';
@@ -47,7 +43,6 @@ const useActiveTab = createGlobalState<string>('body');
export const RequestPane = memo(function RequestPane({ style, fullHeight, className }: Props) {
const activeRequest = useActiveRequest();
const activeRequestId = activeRequest?.id ?? null;
const activeEnvironmentId = useActiveEnvironmentId();
const updateRequest = useUpdateRequest(activeRequestId);
const [activeTab, setActiveTab] = useActiveTab();
const [forceUpdateHeaderEditorKey, setForceUpdateHeaderEditorKey] = useState<number>(0);
@@ -183,18 +178,6 @@ export const RequestPane = memo(function RequestPane({ style, fullHeight, classN
[updateRequest],
);
useListenToTauriEvent(
'send_request',
async ({ windowLabel }) => {
if (windowLabel !== appWindow.label) return;
await invoke('send_request', {
requestId: activeRequestId,
environmentId: activeEnvironmentId,
});
},
[activeRequestId, activeEnvironmentId],
);
return (
<div
style={style}
@@ -202,7 +185,12 @@ export const RequestPane = memo(function RequestPane({ style, fullHeight, classN
>
{activeRequest && (
<>
<UrlBar id={activeRequest.id} url={activeRequest.url} method={activeRequest.method} />
<UrlBar
key={activeRequest.id} // Force-reset the url bar when the active request changes
id={activeRequest.id}
url={activeRequest.url}
method={activeRequest.method}
/>
<Tabs
value={activeTab}
label="Request"

View File

@@ -3,8 +3,10 @@ import classNames from 'classnames';
import type { CSSProperties, MouseEvent as ReactMouseEvent } from 'react';
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 { clamp } from '../lib/clamp';
import { HotKeyList } from './core/HotKeyList';
import { RequestPane } from './RequestPane';
import { ResizeHandle } from './ResizeHandle';
import { ResponsePane } from './ResponsePane';
@@ -20,10 +22,11 @@ const drag = { gridArea: 'drag' };
const DEFAULT = 0.5;
const MIN_WIDTH_PX = 10;
const MIN_HEIGHT_PX = 30;
const STACK_VERTICAL_WIDTH = 600;
const STACK_VERTICAL_WIDTH = 700;
export const RequestResponse = memo(function RequestResponse({ style }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const activeRequest = useActiveRequest();
const [vertical, setVertical] = useState<boolean>(false);
const [widthRaw, setWidth] = useLocalStorage<number>(`body_width::${useActiveWorkspaceId()}`);
const [heightRaw, setHeight] = useLocalStorage<number>(`body_height::${useActiveWorkspaceId()}`);
@@ -34,7 +37,7 @@ export const RequestResponse = memo(function RequestResponse({ style }: Props) {
null,
);
useResizeObserver(containerRef, ({ contentRect }) => {
useResizeObserver(containerRef.current, ({ contentRect }) => {
setVertical(contentRect.width < STACK_VERTICAL_WIDTH);
});
@@ -114,6 +117,10 @@ export const RequestResponse = memo(function RequestResponse({ style }: Props) {
[width, height, vertical, setHeight, setWidth],
);
if (activeRequest === null) {
return <HotKeyList hotkeys={['request.create', 'sidebar.toggle']} />;
}
return (
<div ref={containerRef} className="grid gap-1.5 w-full h-full p-3" style={styles}>
<RequestPane style={rqst} fullHeight={!vertical} />

View File

@@ -1,26 +1,66 @@
import { shell } from '@tauri-apps/api';
import classNames from 'classnames';
import type { ReactNode } from 'react';
import type { HttpResponse } from '../lib/models';
import { IconButton } from './core/IconButton';
import { Separator } from './core/Separator';
import { HStack } from './core/Stacks';
interface Props {
headers: HttpResponse['headers'];
response: HttpResponse;
}
export function ResponseHeaders({ headers }: Props) {
export function ResponseHeaders({ response }: Props) {
return (
<dl className="text-xs w-full h-full font-mono overflow-auto">
{headers.map((h, i) => {
return (
<HStack
space={3}
key={i}
className={classNames(i > 0 ? 'border-t border-highlightSecondary py-1' : 'pb-1')}
>
<dd className="w-1/3 text-violet-600 select-text cursor-text">{h.name}</dd>
<dt className="w-2/3 select-text cursor-text break-all">{h.value}</dt>
</HStack>
);
})}
</dl>
<div className="overflow-auto h-full pb-4">
<dl className="text-xs w-full font-mono flex flex-col">
{response.headers.map((h, i) => (
<Row key={i} label={h.name} value={h.value} labelClassName="!text-violet-600" />
))}
</dl>
<Separator className="my-4">Other Info</Separator>
<dl className="text-xs w-full font-mono divide-highlightSecondary">
<Row label="Version" value={response.version} />
<Row label="Remote Address" value={response.remoteAddr} />
<Row
label={
<div className="flex items-center">
URL
<IconButton
iconSize="sm"
className="inline-block w-auto ml-1 !h-auto opacity-50 hover:opacity-100"
icon="externalLink"
onClick={() => shell.open(response.url)}
title="Open in browser"
/>
</div>
}
value={
<div className="flex">
<span className="select-text cursor-text">{response.url}</span>
</div>
}
/>
</dl>
</div>
);
}
function Row({
label,
value,
labelClassName,
}: {
label: ReactNode;
value: ReactNode;
labelClassName?: string;
}) {
return (
<HStack space={3} className="py-0.5">
<dd className={classNames(labelClassName, 'w-1/3 text-gray-700 select-text cursor-text')}>
{label}
</dd>
<dt className="w-2/3 select-text cursor-text break-all">{value}</dt>
</HStack>
);
}

View File

@@ -1,8 +1,8 @@
import classNames from 'classnames';
import type { CSSProperties } from 'react';
import { useCallback, memo, useEffect, useMemo, useState } from 'react';
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { createGlobalState } from 'react-use';
import { useActiveRequestId } from '../hooks/useActiveRequestId';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useLatestResponse } from '../hooks/useLatestResponse';
import { useResponseContentType } from '../hooks/useResponseContentType';
import { useResponses } from '../hooks/useResponses';
@@ -12,18 +12,19 @@ import { isResponseLoading } from '../lib/models';
import { Banner } from './core/Banner';
import { CountBadge } from './core/CountBadge';
import { DurationTag } from './core/DurationTag';
import { HotKeyList } from './core/HotKeyList';
import { SizeTag } from './core/SizeTag';
import { HStack } from './core/Stacks';
import { StatusTag } from './core/StatusTag';
import type { TabItem } from './core/Tabs/Tabs';
import { TabContent, Tabs } from './core/Tabs/Tabs';
import { EmptyStateText } from './EmptyStateText';
import { RecentResponsesDropdown } from './RecentResponsesDropdown';
import { ResponseHeaders } from './ResponseHeaders';
import { CsvViewer } from './responseViewers/CsvViewer';
import { ImageViewer } from './responseViewers/ImageViewer';
import { TextViewer } from './responseViewers/TextViewer';
import { WebPageViewer } from './responseViewers/WebPageViewer';
import { RecentResponsesDropdown } from './RecentResponsesDropdown';
interface Props {
style?: CSSProperties;
@@ -34,9 +35,9 @@ const useActiveTab = createGlobalState<string>('body');
export const ResponsePane = memo(function ResponsePane({ style, className }: Props) {
const [pinnedResponseId, setPinnedResponseId] = useState<string | null>(null);
const activeRequestId = useActiveRequestId();
const latestResponse = useLatestResponse(activeRequestId);
const responses = useResponses(activeRequestId);
const activeRequest = useActiveRequest();
const latestResponse = useLatestResponse(activeRequest?.id ?? null);
const responses = useResponses(activeRequest?.id ?? null);
const activeResponse: HttpResponse | null = pinnedResponseId
? responses.find((r) => r.id === pinnedResponseId) ?? null
: latestResponse ?? null;
@@ -48,11 +49,14 @@ export const ResponsePane = memo(function ResponsePane({ style, className }: Pro
const contentType = useResponseContentType(activeResponse);
const handlePinnedResponse = useCallback((r: HttpResponse) => {
setPinnedResponseId(r.id);
}, [setPinnedResponseId])
const handlePinnedResponse = useCallback(
(r: HttpResponse) => {
setPinnedResponseId(r.id);
},
[setPinnedResponseId],
);
const tabs: TabItem[] = useMemo(
const tabs = useMemo<TabItem[]>(
() => [
{
value: 'body',
@@ -62,7 +66,7 @@ export const ResponsePane = memo(function ResponsePane({ style, className }: Pro
onChange: setViewMode,
items: [
{ label: 'Pretty', value: 'pretty' },
{ label: 'Raw', value: 'raw' },
...(contentType?.startsWith('image') ? [] : [{ label: 'Raw', value: 'raw' }]),
],
},
},
@@ -78,20 +82,36 @@ export const ResponsePane = memo(function ResponsePane({ style, className }: Pro
value: 'headers',
},
],
[activeResponse?.headers, setViewMode, viewMode],
[activeResponse?.headers, contentType, setViewMode, viewMode],
);
if (activeRequest === null) {
return null;
}
return (
<div
style={style}
className={classNames(
className,
'bg-gray-50 max-h-full h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1',
'dark:bg-gray-100 rounded-md border border-highlight',
'max-h-full h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1',
'bg-gray-50 dark:bg-gray-100 rounded-md border border-highlight',
'shadow shadow-gray-100 dark:shadow-gray-0 relative',
)}
>
{activeResponse?.error && <Banner className="m-2">{activeResponse.error}</Banner>}
{activeResponse?.error && (
<Banner color="danger" className="m-2">
{activeResponse.error}
</Banner>
)}
{!activeResponse && (
<>
<span />
<HotKeyList
hotkeys={['request.send', 'request.create', 'sidebar.toggle', 'urlBar.focus']}
/>
</>
)}
{activeResponse && !activeResponse.error && !isResponseLoading(activeResponse) && (
<>
<HStack
@@ -110,7 +130,10 @@ export const ResponsePane = memo(function ResponsePane({ style, className }: Pro
{activeResponse.elapsed > 0 && (
<>
<span>&bull;</span>
<DurationTag millis={activeResponse.elapsed} />
<DurationTag
headers={activeResponse.elapsedHeaders}
total={activeResponse.elapsed}
/>
</>
)}
{!!activeResponse.contentLength && (
@@ -140,15 +163,19 @@ export const ResponsePane = memo(function ResponsePane({ style, className }: Pro
tabListClassName="mt-1.5"
>
<TabContent value="headers">
<ResponseHeaders headers={activeResponse?.headers ?? []} />
<ResponseHeaders response={activeResponse} />
</TabContent>
<TabContent value="body">
{!activeResponse.contentLength ? (
<EmptyStateText>Empty Body</EmptyStateText>
) : viewMode === 'pretty' && contentType?.includes('html') ? (
<WebPageViewer response={activeResponse} />
) : contentType?.startsWith('image') ? (
<ImageViewer className="pb-2" response={activeResponse} />
) : activeResponse.contentLength > 2 * 1000 * 1000 ? (
<div className="text-sm italic text-gray-500">
Cannot preview text responses larger than 2MB
</div>
) : viewMode === 'pretty' && contentType?.includes('html') ? (
<WebPageViewer response={activeResponse} />
) : contentType?.match(/csv|tab-separated/) ? (
<CsvViewer className="pb-2" response={activeResponse} />
) : (

View File

@@ -7,7 +7,7 @@ import { VStack } from './core/Stacks';
export default function RouteError() {
const error = useRouteError();
console.log("Error", error);
console.log('Error', error);
const stringified = JSON.stringify(error);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const message = (error as any).message ?? stringified;

View File

@@ -0,0 +1,92 @@
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
import { useSettings } from '../hooks/useSettings';
import { useUpdateSettings } from '../hooks/useUpdateSettings';
import { useUpdateWorkspace } from '../hooks/useUpdateWorkspace';
import { Checkbox } from './core/Checkbox';
import { Heading } from './core/Heading';
import { Input } from './core/Input';
import { Select } from './core/Select';
import { Separator } from './core/Separator';
import { VStack } from './core/Stacks';
export const SettingsDialog = () => {
const workspace = useActiveWorkspace();
const updateWorkspace = useUpdateWorkspace(workspace?.id ?? null);
const settings = useSettings();
const updateSettings = useUpdateSettings();
if (settings == null || workspace == null) {
return null;
}
return (
<VStack space={2} className="mb-4">
<Select
name="appearance"
label="Appearance"
labelPosition="left"
labelClassName="w-1/3"
size="sm"
value={settings.appearance}
onChange={(appearance) => updateSettings.mutateAsync({ ...settings, appearance })}
options={{
system: 'System',
light: 'Light',
dark: 'Dark',
}}
/>
<Select
name="updateChannel"
label="Update Channel"
labelPosition="left"
labelClassName="w-1/3"
size="sm"
value={settings.updateChannel}
onChange={(updateChannel) => updateSettings.mutateAsync({ ...settings, updateChannel })}
options={{
stable: 'Release',
beta: 'Early Bird (Beta)',
}}
/>
<Separator className="my-4" />
<Heading size={2}>
Workspace{' '}
<div className="inline-block ml-1 bg-gray-500 dark:bg-gray-300 px-2 py-0.5 text-sm rounded text-white dark:text-gray-900">
{workspace.name}
</div>
</Heading>
<VStack className="mt-1 w-full" space={3}>
<Input
size="sm"
name="requestTimeout"
label="Request Timeout (ms)"
labelPosition="left"
labelClassName="w-1/3"
containerClassName="col-span-2"
defaultValue={`${workspace.settingRequestTimeout}`}
validate={(value) => parseInt(value) >= 0}
onChange={(v) => updateWorkspace.mutateAsync({ settingRequestTimeout: parseInt(v) || 0 })}
/>
<Checkbox
checked={workspace.settingValidateCertificates}
title="Validate TLS Certificates"
onChange={(settingValidateCertificates) =>
updateWorkspace.mutateAsync({ settingValidateCertificates })
}
/>
<Checkbox
checked={workspace.settingFollowRedirects}
title="Follow Redirects"
onChange={(settingFollowRedirects) =>
updateWorkspace.mutateAsync({ settingFollowRedirects })
}
/>
</VStack>
</VStack>
);
};

View File

@@ -0,0 +1,142 @@
import { invoke, shell } from '@tauri-apps/api';
import { useRef, useState } from 'react';
import { useAlert } from '../hooks/useAlert';
import { useAppVersion } from '../hooks/useAppVersion';
import { useExportData } from '../hooks/useExportData';
import { useImportData } from '../hooks/useImportData';
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
import { Button } from './core/Button';
import type { DropdownRef } from './core/Dropdown';
import { Dropdown } from './core/Dropdown';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { VStack } from './core/Stacks';
import { useDialog } from './DialogContext';
import { KeyboardShortcutsDialog } from './KeyboardShortcutsDialog';
import { SettingsDialog } from './SettingsDialog';
export function SettingsDropdown() {
const importData = useImportData();
const exportData = useExportData();
const appVersion = useAppVersion();
const dropdownRef = useRef<DropdownRef>(null);
const dialog = useDialog();
const alert = useAlert();
const [showChangelog, setShowChangelog] = useState<boolean>(false);
useListenToTauriEvent('show_changelog', () => {
setShowChangelog(true);
});
return (
<Dropdown
ref={dropdownRef}
onClose={() => setShowChangelog(false)}
items={[
{
key: 'settings',
label: 'Settings',
hotKeyAction: 'settings.show',
leftSlot: <Icon icon="settings" />,
onSelect: () => {
dialog.show({
id: 'settings',
size: 'md',
title: 'Settings',
render: () => <SettingsDialog />,
});
},
},
{
key: 'hotkeys',
label: 'Keyboard shortcuts',
hotKeyAction: 'hotkeys.showHelp',
leftSlot: <Icon icon="keyboard" />,
onSelect: () => {
dialog.show({
id: 'hotkey',
title: 'Keyboard Shortcuts',
size: 'sm',
render: () => <KeyboardShortcutsDialog />,
});
},
},
{
key: 'import-data',
label: 'Import Data',
leftSlot: <Icon icon="folderInput" />,
onSelect: () => {
dialog.show({
id: 'import',
title: 'Import Data',
size: 'sm',
render: ({ hide }) => {
return (
<VStack space={3} className="pb-4">
<p>Insomnia or Postman Collection v2/v2.1 formats are supported</p>
<Button
size="sm"
color="primary"
onClick={async () => {
await importData.mutateAsync();
hide();
}}
>
Select File
</Button>
</VStack>
);
},
});
},
},
{
key: 'export-data',
label: 'Export Data',
leftSlot: <Icon icon="folderOutput" />,
onSelect: () => exportData.mutate(),
},
{ type: 'separator', label: `Yaak v${appVersion.data}` },
{
key: 'update-check',
label: 'Check for Updates',
leftSlot: <Icon icon="update" />,
onSelect: async () => {
const hasUpdate: boolean = await invoke('check_for_updates');
if (!hasUpdate) {
alert({
id: 'no-updates',
title: 'No Updates',
body: 'You are currently up to date',
});
}
console.log('HAS UPDATE', hasUpdate);
},
},
{
key: 'feedback',
label: 'Feedback',
leftSlot: <Icon icon="chat" />,
rightSlot: <Icon icon="externalLink" />,
onSelect: () => shell.open('https://yaak.canny.io'),
},
{
key: 'changelog',
label: 'Changelog',
variant: showChangelog ? 'notify' : 'default',
leftSlot: <Icon icon="cake" />,
rightSlot: <Icon icon="externalLink" />,
onSelect: () => shell.open(`https://yaak.app/changelog/${appVersion.data}`),
},
]}
>
<IconButton
size="sm"
title="Main Menu"
icon="settings"
className="pointer-events-auto"
showBadge={showChangelog}
/>
</Dropdown>
);
}

View File

@@ -5,8 +5,8 @@ import type { XYCoord } from 'react-dnd';
import { useDrag, useDrop } from 'react-dnd';
import { useKey, useKeyPressEvent } from 'react-use';
import { showMenu } from 'tauri-plugin-context-menu';
import { useActiveEnvironmentId } from '../hooks/useActiveEnvironmentId';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useActiveRequestId } from '../hooks/useActiveRequestId';
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
import { useAppRoutes } from '../hooks/useAppRoutes';
@@ -15,10 +15,11 @@ import { useCreateRequest } from '../hooks/useCreateRequest';
import { useDeleteAnyRequest } from '../hooks/useDeleteAnyRequest';
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 { useKeyValue } from '../hooks/useKeyValue';
import { useLatestResponse } from '../hooks/useLatestResponse';
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
import { usePrompt } from '../hooks/usePrompt';
import { useRequests } from '../hooks/useRequests';
import { useSendManyRequests } from '../hooks/useSendFolder';
@@ -31,9 +32,8 @@ import { fallbackRequestName } from '../lib/fallbackRequestName';
import { NAMESPACE_NO_SYNC } from '../lib/keyValueStore';
import type { Folder, HttpRequest, Workspace } from '../lib/models';
import { isResponseLoading } from '../lib/models';
import { Dropdown } from './core/Dropdown';
import { ContextMenu } from './core/Dropdown';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { InlineCode } from './core/InlineCode';
import { VStack } from './core/Stacks';
import { StatusTag } from './core/StatusTag';
@@ -55,7 +55,6 @@ interface TreeNode {
export function Sidebar({ className }: Props) {
const { hidden } = useSidebarHidden();
const createRequest = useCreateRequest();
const sidebarRef = useRef<HTMLLIElement>(null);
const activeRequestId = useActiveRequestId();
const activeEnvironmentId = useActiveEnvironmentId();
@@ -63,6 +62,7 @@ export function Sidebar({ className }: Props) {
const folders = useFolders();
const deleteAnyRequest = useDeleteAnyRequest();
const activeWorkspace = useActiveWorkspace();
const duplicateRequest = useDuplicateRequest({ id: activeRequestId, navigateAfter: true });
const routes = useAppRoutes();
const [hasFocus, setHasFocus] = useState<boolean>(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
@@ -78,6 +78,10 @@ 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],
@@ -86,10 +90,18 @@ export function Sidebar({ className }: Props) {
const { tree, treeParentMap, selectableRequests } = useMemo<{
tree: TreeNode | null;
treeParentMap: Record<string, TreeNode>;
selectableRequests: { id: string; index: number; tree: TreeNode }[];
selectableRequests: {
id: string;
index: number;
tree: TreeNode;
}[];
}>(() => {
const treeParentMap: Record<string, TreeNode> = {};
const selectableRequests: { id: string; index: number; tree: TreeNode }[] = [];
const selectableRequests: {
id: string;
index: number;
tree: TreeNode;
}[] = [];
if (activeWorkspace == null) {
return { tree: null, treeParentMap, selectableRequests };
}
@@ -119,11 +131,16 @@ export function Sidebar({ className }: Props) {
return { tree, treeParentMap, selectableRequests };
}, [activeWorkspace, requests, folders]);
// TODO: Move these listeners to a central place
useListenToTauriEvent('new_request', async () => createRequest.mutate({}));
const focusActiveRequest = useCallback(
(args: { forced?: { id: string; tree: TreeNode }; noFocusSidebar?: boolean } = {}) => {
(
args: {
forced?: {
id: string;
tree: TreeNode;
};
noFocusSidebar?: boolean;
} = {},
) => {
const { forced, noFocusSidebar } = args;
const tree = forced?.tree ?? treeParentMap[activeRequestId ?? 'n/a'] ?? null;
const children = tree?.children ?? [];
@@ -196,19 +213,15 @@ export function Sidebar({ className }: Props) {
useKeyPressEvent('Backspace', handleDeleteKey);
useKeyPressEvent('Delete', handleDeleteKey);
useListenToTauriEvent(
'focus_sidebar',
() => {
if (hidden || hasFocus) return;
// Select 0 index on focus if none selected
focusActiveRequest(
selectedTree != null && selectedId != null
? { forced: { id: selectedId, tree: selectedTree } }
: undefined,
);
},
[focusActiveRequest, hidden, activeRequestId],
);
useHotKey('sidebar.focus', () => {
if (hidden || hasFocus) return;
// Select 0 index on focus if none selected
focusActiveRequest(
selectedTree != null && selectedId != null
? { forced: { id: selectedId, tree: selectedTree } }
: undefined,
);
});
useKeyPressEvent('Enter', (e) => {
if (!hasFocus) return;
@@ -310,8 +323,9 @@ export function Sidebar({ className }: Props) {
newChildren.splice(hoveredIndex - 1, 0, child);
}
const prev = newChildren[hoveredIndex - 1]?.item;
const next = newChildren[hoveredIndex + 1]?.item;
const insertedIndex = newChildren.findIndex((c) => c.item === child.item);
const prev = newChildren[insertedIndex - 1]?.item;
const next = newChildren[insertedIndex + 1]?.item;
const beforePriority = prev == null || prev.model === 'workspace' ? 0 : prev.sortPriority;
const afterPriority = next == null || next.model === 'workspace' ? 0 : next.sortPriority;
@@ -509,19 +523,21 @@ const SidebarItem = forwardRef(function SidebarItem(
}: SidebarItemProps,
ref: ForwardedRef<HTMLLIElement>,
) {
const activeRequest = useActiveRequest();
const createRequest = useCreateRequest();
const createFolder = useCreateFolder();
const deleteFolder = useDeleteFolder(itemId);
const sendRequest = useSendRequest(itemId);
const sendManyRequests = useSendManyRequests();
const deleteRequest = useDeleteRequest(itemId);
const duplicateRequest = useDuplicateRequest({ id: itemId, navigateAfter: true });
const sendRequest = useSendRequest(itemId);
const sendAndDownloadRequest = useSendRequest(itemId, { download: true });
const sendManyRequests = useSendManyRequests();
const latestResponse = useLatestResponse(itemId);
const updateRequest = useUpdateRequest(itemId);
const updateAnyFolder = useUpdateAnyFolder();
const prompt = usePrompt();
const [editing, setEditing] = useState<boolean>(false);
const activeRequestId = useActiveRequestId();
const isActive = activeRequestId === itemId;
const isActive = activeRequest?.id === itemId;
const handleSubmitNameEdit = useCallback(
(el: HTMLInputElement) => {
@@ -565,105 +581,104 @@ const SidebarItem = forwardRef(function SidebarItem(
[handleSubmitNameEdit],
);
const handleContextMenu = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
showMenu({
pos: { x: e.clientX, y: e.clientY },
items:
itemModel === 'http_request'
? [
{
label: 'Send Request',
event: () => sendRequest.mutate(),
},
{
label: 'Delete Request',
event: () => deleteRequest.mutate(),
},
]
: [
{
label: 'Send All',
event: () => sendManyRequests.mutate(child.children.map((c) => c.item.id)),
},
{
label: 'Delete Folder',
event: () => deleteFolder.mutate(),
},
],
})
.then((r) => console.log(r))
.catch((e) => console.log(e));
},
[itemModel, sendRequest, deleteRequest, sendManyRequests, child.children, deleteFolder],
);
const handleSelect = useCallback(() => onSelect(itemId), [onSelect, itemId]);
const [showContextMenu, setShowContextMenu] = useState<{
x: number;
y: number;
} | null>(null);
const handleContextMenu = useCallback((e: React.MouseEvent<HTMLElement>) => {
e.preventDefault();
e.stopPropagation();
setShowContextMenu({ x: e.clientX, y: e.clientY });
}, []);
return (
<li ref={ref}>
<div className={classNames(className, 'block relative group/item px-2 pb-0.5')}>
{itemModel === 'folder' && (
<Dropdown
items={[
{
key: 'sendAll',
label: 'Send All',
leftSlot: <Icon icon="paperPlane" />,
onSelect: () => sendManyRequests.mutate(child.children.map((c) => c.item.id)),
},
{ type: 'separator', label: itemName },
{
key: 'rename',
label: 'Rename',
leftSlot: <Icon icon="pencil" />,
onSelect: async () => {
const name = await prompt({
title: 'Rename Folder',
description: (
<>
Enter a new name for <InlineCode>{itemName}</InlineCode>
</>
),
name: 'name',
label: 'Name',
defaultValue: itemName,
});
updateAnyFolder.mutate({ id: itemId, update: (f) => ({ ...f, name }) });
},
},
{
key: 'deleteFolder',
label: 'Delete',
variant: 'danger',
leftSlot: <Icon icon="trash" />,
onSelect: () => deleteFolder.mutate(),
},
{ type: 'separator' },
{
key: 'createRequest',
label: 'New Request',
leftSlot: <Icon icon="plus" />,
onSelect: () => createRequest.mutate({ folderId: itemId, sortPriority: -1 }),
},
{
key: 'createFolder',
label: 'New Folder',
leftSlot: <Icon icon="plus" />,
onSelect: () => createFolder.mutate({ folderId: itemId, sortPriority: -1 }),
},
]}
>
<IconButton
title="Folder options"
size="xs"
icon="dotsV"
className="ml-auto !bg-transparent absolute right-2 opacity-0 group-hover/item:opacity-70 transition-opacity"
/>
</Dropdown>
)}
<ContextMenu
show={showContextMenu}
items={
itemModel === 'folder'
? [
{
key: 'sendAll',
label: 'Send All',
leftSlot: <Icon icon="sendHorizontal" />,
onSelect: () => sendManyRequests.mutate(child.children.map((c) => c.item.id)),
},
{
key: 'rename',
label: 'Rename',
leftSlot: <Icon icon="pencil" />,
onSelect: async () => {
const name = await prompt({
id: 'rename-folder',
title: 'Rename Folder',
description: (
<>
Enter a new name for <InlineCode>{itemName}</InlineCode>
</>
),
name: 'name',
label: 'Name',
defaultValue: itemName,
});
updateAnyFolder.mutate({ id: itemId, update: (f) => ({ ...f, name }) });
},
},
{
key: 'deleteFolder',
label: 'Delete',
variant: 'danger',
leftSlot: <Icon icon="trash" />,
onSelect: () => deleteFolder.mutate(),
},
{ type: 'separator' },
{
key: 'createRequest',
label: 'New Request',
leftSlot: <Icon icon="plus" />,
onSelect: () => createRequest.mutate({ folderId: itemId }),
},
{
key: 'createFolder',
label: 'New Folder',
leftSlot: <Icon icon="plus" />,
onSelect: () => createFolder.mutate({ folderId: itemId, sortPriority: -1 }),
},
]
: [
{
key: 'sendRequest',
label: 'Send',
hotKeyAction: 'request.send',
hotKeyLabelOnly: true, // Already bound in URL bar
leftSlot: <Icon icon="sendHorizontal" />,
onSelect: () => sendRequest.mutate(),
},
{ type: 'separator' },
{
key: 'duplicateRequest',
label: 'Duplicate',
hotKeyAction: 'request.duplicate',
hotKeyLabelOnly: true, // Would trigger for every request (bad)
leftSlot: <Icon icon="copy" />,
onSelect: () => {
duplicateRequest.mutate();
},
},
{
key: 'deleteRequest',
variant: 'danger',
label: 'Delete',
leftSlot: <Icon icon="trash" />,
onSelect: () => deleteRequest.mutate(),
},
]
}
onClose={() => setShowContextMenu(null)}
/>
<button
// tabIndex={-1} // Will prevent drag-n-drop
disabled={editing}
@@ -758,7 +773,13 @@ function DraggableSidebarItem({
[onMove],
);
const [{ isDragging }, connectDrag] = useDrag<DragItem, unknown, { isDragging: boolean }>(
const [{ isDragging }, connectDrag] = useDrag<
DragItem,
unknown,
{
isDragging: boolean;
}
>(
() => ({
type: ItemTypes.REQUEST,
item: () => {

View File

@@ -2,8 +2,8 @@ import { memo } from 'react';
import { useCreateFolder } from '../hooks/useCreateFolder';
import { useCreateRequest } from '../hooks/useCreateRequest';
import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { trackEvent } from '../lib/analytics';
import { Dropdown } from './core/Dropdown';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { HStack } from './core/Stacks';
@@ -15,10 +15,14 @@ export const SidebarActions = memo(function SidebarActions() {
return (
<HStack>
<IconButton
onClick={toggle}
onClick={() => {
trackEvent('Sidebar', 'Toggle');
toggle();
}}
className="pointer-events-auto"
size="sm"
title="Show sidebar"
hotkeyAction="sidebar.toggle"
icon={hidden ? 'leftPanelHidden' : 'leftPanelVisible'}
/>
<Dropdown
@@ -26,13 +30,12 @@ export const SidebarActions = memo(function SidebarActions() {
{
key: 'create-request',
label: 'New Request',
leftSlot: <Icon icon="plus" />,
hotKeyAction: 'request.create',
onSelect: () => createRequest.mutate({}),
},
{
key: 'create-folder',
label: 'New Folder',
leftSlot: <Icon icon="plus" />,
onSelect: () => createFolder.mutate({}),
},
]}

View File

@@ -1,9 +1,8 @@
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 { useIsResponseLoading } from '../hooks/useIsResponseLoading';
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
import { useSendRequest } from '../hooks/useSendRequest';
import { useUpdateRequest } from '../hooks/useUpdateRequest';
@@ -40,16 +39,21 @@ export const UrlBar = memo(function UrlBar({ id: requestId, url, method, classNa
[sendRequest],
);
useListenToTauriEvent('focus_url', () => {
useHotKey('urlBar.focus', () => {
const head = inputRef.current?.state.doc.length ?? 0;
inputRef.current?.dispatch({
selection: { anchor: 0, head },
});
inputRef.current?.focus();
});
return (
<form onSubmit={handleSubmit} className={classNames('url-bar', className)}>
<form onSubmit={handleSubmit} className={className}>
<Input
autocompleteVariables
ref={inputRef}
size={isFocused ? 'auto' : 'sm'}
size="sm"
wrapLines={isFocused}
hideLabel
useTemplating
contentType="url"
@@ -67,18 +71,19 @@ export const UrlBar = memo(function UrlBar({ id: requestId, url, method, classNa
<RequestMethodDropdown
method={method}
onChange={handleMethodChange}
className="!h-auto mx-0.5 my-0.5"
className="mx-0.5 my-0.5"
/>
}
rightSlot={
<IconButton
size="xs"
iconSize="sm"
iconSize="md"
title="Send Request"
type="submit"
className="!h-auto w-8 mr-0.5 my-0.5"
icon={loading ? 'update' : 'paperPlane'}
className="w-8 mr-0.5 my-0.5"
icon={loading ? 'update' : 'sendHorizontal'}
spin={loading}
hotkeyAction="request.send"
/>
}
/>

View File

@@ -8,7 +8,7 @@ import type {
} from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useWindowSize } from 'react-use';
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
import { useIsFullscreen } from '../hooks/useIsFullscreen';
import { useOsInfo } from '../hooks/useOsInfo';
import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { useSidebarWidth } from '../hooks/useSidebarWidth';
@@ -30,7 +30,7 @@ const WINDOW_FLOATING_SIDEBAR_WIDTH = 600;
export default function Workspace() {
const { setWidth, width, resetWidth } = useSidebarWidth();
const { hide, show, hidden, toggle } = useSidebarHidden();
const { hide, show, hidden } = useSidebarHidden();
const windowSize = useWindowSize();
const [floating, setFloating] = useState<boolean>(false);
@@ -39,14 +39,16 @@ export default function Workspace() {
null,
);
useListenToTauriEvent('toggle_sidebar', toggle);
// float/un-float sidebar on window resize
useEffect(() => {
const shouldHide = windowSize.width <= WINDOW_FLOATING_SIDEBAR_WIDTH;
if (shouldHide) setFloating(true);
else if (!shouldHide) setFloating(false);
}, [windowSize.width]);
if (shouldHide && !floating) {
setFloating(true);
hide();
} else if (!shouldHide && floating) {
setFloating(false);
}
}, [floating, hide, windowSize.width]);
const unsub = () => {
if (moveState.current !== null) {
@@ -172,12 +174,14 @@ interface HeaderSizeProps extends HTMLAttributes<HTMLDivElement> {
function HeaderSize({ className, ...props }: HeaderSizeProps) {
const platform = useOsInfo();
const fullscreen = useIsFullscreen();
const stoplightsVisible = platform?.osType === 'Darwin' && !fullscreen;
return (
<div
className={classNames(
className,
'h-md pt-[1px] flex items-center w-full pr-3 pl-20 border-b',
platform?.osType === 'Darwin' && 'pl-20',
'h-md pt-[1px] flex items-center w-full border-b',
stoplightsVisible ? 'pl-20 pr-1' : 'pl-1',
)}
{...props}
/>

Some files were not shown because too many files have changed in this diff Show More