Compare commits

...

60 Commits

Author SHA1 Message Date
Gregory Schier
c51831c975 Fix window methods on Linux/Windows 2023-04-27 16:27:02 -07:00
Gregory Schier
180aa39de4 Cross platform window controls 2023-04-27 10:19:49 -07:00
Gregory Schier
3bd780782e Fix ubuntu build 2023-04-26 17:00:30 -07:00
Gregory Schier
f9ba2f79c2 Windows and Linux 2023-04-26 16:54:51 -07:00
Gregory Schier
d9493de2be Bump version 2023-04-26 16:47:50 -07:00
Gregory Schier
bc9a623742 Very basic CSV viewer 2023-04-22 21:53:04 +08:00
Gregory Schier
532edbf274 Truncate response files 2023-04-14 14:15:33 -07:00
Gregory Schier
1585692328 Randomly offset new windows 2023-04-14 14:05:23 -07:00
Gregory Schier
083f565b12 Fix text encoding and delete responses 2023-04-14 13:50:41 -07:00
Gregory Schier
f7f7438c9e Delete response files 2023-04-14 12:17:11 -07:00
Gregory Schier
19934a93bb Readonly editor disable tabindex 2023-04-13 22:36:11 -07:00
Gregory Schier
577cfe5bdc Fix imageview padding 2023-04-13 22:33:47 -07:00
Gregory Schier
43ac6afae1 Duration and size tags 2023-04-13 20:50:17 -07:00
Gregory Schier
8cc11703d3 Comment 2023-04-13 18:55:32 -07:00
Gregory Schier
4f7a116378 Always store response on filesystem 2023-04-13 18:52:56 -07:00
Gregory Schier
513793d9ce Support binary responses! 2023-04-13 18:48:40 -07:00
Gregory Schier
67f32b6734 Blur backdrop 2023-04-11 16:12:26 -07:00
Gregory Schier
66813d67fe Autofocus buttons 2023-04-11 14:04:23 -07:00
Gregory Schier
a38691ed53 Better opening workspaces and redirect workspace to recent request 2023-04-11 11:11:36 -07:00
Gregory Schier
deeefdcfbf Button disabled style opacity 2023-04-10 16:03:45 -07:00
Gregory Schier
db292511b1 Dropdown keys and pointer events 2023-04-10 16:02:29 -07:00
Gregory Schier
1a5334c1ce Upgrade deno core 2023-04-10 11:16:25 -07:00
Gregory Schier
11002abe39 Tweak response history 2023-04-09 23:15:51 -07:00
Gregory Schier
d922dcb062 Fixed multi-window model sync 2023-04-09 22:32:47 -07:00
Gregory Schier
6fcaa18e86 Tweak recent requests 2023-04-09 22:25:00 -07:00
Gregory Schier
7664c941dd Toggle settings 2023-04-09 22:12:16 -07:00
Gregory Schier
6f5cb528c6 Fix sidebar request focus 2023-04-09 22:03:41 -07:00
Gregory Schier
ebb78922f0 More stuff on sidebar 2023-04-09 21:52:04 -07:00
Gregory Schier
2285fe9f1c Small tweaks 2023-04-09 15:32:13 -07:00
Gregory Schier
38ba8625d8 Request history navigator 2023-04-09 15:26:54 -07:00
Gregory Schier
ab5681c7ad Enter name on create workspace 2023-04-09 12:27:02 -07:00
Gregory Schier
f66dcb9267 Rename workspace 2023-04-09 12:23:41 -07:00
Gregory Schier
1b6cfbac77 Sidebar hover transitions 2023-04-06 16:30:46 -07:00
Gregory Schier
4c27e788ea Remove some more key value usage 2023-04-06 16:26:56 -07:00
Gregory Schier
769da0b052 A bunch of tweaks 2023-04-06 16:05:25 -07:00
Gregory Schier
6b60c86300 macOS 12 2023-04-06 08:39:30 -07:00
Gregory Schier
30c1b5e8c7 Remove system tray icon 2023-04-06 08:15:40 -07:00
Gregory Schier
10af9b6f99 Minor tweaks 2023-04-04 17:21:02 -07:00
Gregory Schier
aa8c066f2d Fix some things 2023-04-04 16:56:45 -07:00
Gregory Schier
b913b74449 Editor line wrapping support (not used yet) 2023-04-04 16:40:37 -07:00
Gregory Schier
b71adce50b remove janky last location tracking 2023-04-04 16:23:08 -07:00
Gregory Schier
0fbb44c701 Fix resize cursor 2023-04-04 16:12:45 -07:00
Gregory Schier
de335e8637 Better button styles 2023-04-04 15:40:25 -07:00
Gregory Schier
2999f63a4c Bump version 2023-04-04 13:56:24 -07:00
Gregory Schier
2abc5e6f0b Some small fixes 2023-04-04 13:56:14 -07:00
Gregory Schier
639de4321e A few fixes 2023-04-04 13:31:48 -07:00
Gregory Schier
b3c461afdd Better status tags and delete request on key 2023-04-04 12:36:30 -07:00
Gregory Schier
7d154800a0 Remove expects from request sending 2023-04-04 08:14:32 -07:00
Gregory Schier
b48ed0399e Fix web view height 2023-04-04 07:51:41 -07:00
Gregory Schier
c5d6e7d74a Fix autocomplete spacing 2023-04-04 07:51:19 -07:00
Gregory Schier
e82f915363 Fix input focus border 2023-04-03 12:19:37 -07:00
Gregory Schier
3128e9ce76 Hot keys and cleanup 2023-04-03 07:59:49 -07:00
Gregory Schier
bc0e86757c Add entitlemet for v8 2023-04-02 20:23:21 -07:00
Gregory Schier
fec99916c2 Debug codesigned build 2023-04-02 19:09:14 -07:00
Gregory Schier
3b5d059b11 Disable code signing 2023-04-02 18:27:14 -07:00
Gregory Schier
c3fe2acc8a Fix tauri script command 2023-04-02 17:25:24 -07:00
Gregory Schier
4d002c412b Fix universal binary 2023-04-02 17:12:20 -07:00
Gregory Schier
46d152b5f1 Bump version 2023-04-02 15:44:41 -07:00
Gregory Schier
25fa81ebbc Fix toolchain 2023-04-02 15:44:21 -07:00
Gregory Schier
7c2de3c360 Add proper target 2023-04-02 15:42:19 -07:00
93 changed files with 2269 additions and 1009 deletions

View File

@@ -8,9 +8,9 @@ module.exports = {
"plugin:@typescript-eslint/recommended",
"eslint-config-prettier"
],
parser: '@typescript-eslint/parser',
parser: "@typescript-eslint/parser",
parserOptions: {
project: ['./tsconfig.json'],
project: ["./tsconfig.json"]
},
ignorePatterns: ["src-tauri/**/*"],
settings: {
@@ -25,13 +25,13 @@ module.exports = {
}
},
rules: {
"jsx-a11y/no-autofocus": "warn",
"jsx-a11y/no-autofocus": "off",
"react/react-in-jsx-scope": "off",
"import/no-unresolved": "off",
"@typescript-eslint/consistent-type-imports": ["error", {
prefer: "type-imports",
disallowTypeAnnotations: true,
fixStyle: "separate-type-imports"
}],
}]
}
};

View File

@@ -5,17 +5,26 @@ on:
jobs:
build-artifacts:
runs-on: ${{ matrix.platform }}
strategy:
fail-fast: false
matrix:
# platform: [ ubuntu-latest, macos-latest, windows-latest ]
platform: [ macos-latest ]
include:
- os: macos-12
target: aarch64-apple-darwin
- os: macos-12
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 }}
steps:
- uses: actions/checkout@v3
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Cache Rust
uses: actions/cache@v2
with:
@@ -29,7 +38,7 @@ jobs:
node-version: 18
cache: 'npm'
- name: install dependencies (ubuntu only)
if: matrix.platform == 'ubuntu-latest'
if: matrix.os == 'ubuntu-20.04'
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf
@@ -37,7 +46,9 @@ jobs:
run: npm ci
- name: Run tests
run: npm test
- uses: tauri-apps/tauri-action@v0
# Pin dev version to get non-default targets
# https://github.com/tauri-apps/tauri-action/issues/356
- uses: tauri-apps/tauri-action@dev
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
@@ -49,9 +60,9 @@ jobs:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
with:
tagName: v__VERSION__
releaseName: 'App v__VERSION__'
releaseBody: 'See the assets to download this version and install.'
tagName: 'v__VERSION__'
releaseName: 'Release __VERSION__'
releaseBody: '<!-- Release Notes -->'
releaseDraft: true
prerelease: false
args: '--target universal-apple-darwin'
args: '--target ${{ matrix.target }}'

View File

@@ -4,7 +4,7 @@
<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>-->
<style>
body {
background-color: white;

111
package-lock.json generated
View File

@@ -27,12 +27,14 @@
"@tanstack/react-query-persist-client": "^4.28.0",
"@tauri-apps/api": "^1.2.0",
"@vitejs/plugin-react": "^3.1.0",
"buffer": "^6.0.3",
"classnames": "^2.3.2",
"cm6-graphql": "^0.0.4-canary-b30a2325.0",
"codemirror": "^6.0.1",
"focus-trap-react": "^10.1.1",
"format-graphql": "^1.4.0",
"framer-motion": "^9.0.4",
"papaparse": "^5.4.1",
"parse-color": "^1.0.0",
"react": "^18.2.0",
"react-dnd": "^16.0.1",
@@ -47,6 +49,7 @@
"@tailwindcss/nesting": "^0.0.0-insiders.565cd3e",
"@tauri-apps/cli": "^1.2.2",
"@types/node": "^18.7.10",
"@types/papaparse": "^5.3.7",
"@types/parse-color": "^1.0.1",
"@types/parse-json": "^4.0.0",
"@types/react": "^18.0.31",
@@ -2117,6 +2120,15 @@
"integrity": "sha512-gC3TazRzGoOnoKAhUx+Q0t8S9Tzs74z7m0ipwGpSqQrleP14hKxP4/JUeEQcD3W1/aIpnWl8pHowI7WokuZpXg==",
"devOptional": true
},
"node_modules/@types/papaparse": {
"version": "5.3.7",
"resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.3.7.tgz",
"integrity": "sha512-f2HKmlnPdCvS0WI33WtCs5GD7X1cxzzS/aduaxSu3I7TbhWlENjSPs6z5TaB9K0J+BH1jbmqTaM+ja5puis4wg==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/parse-color": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@types/parse-color/-/parse-color-1.0.1.tgz",
@@ -2820,6 +2832,25 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
]
},
"node_modules/binary-extensions": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
@@ -2876,6 +2907,29 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"node_modules/cac": {
"version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
@@ -4809,6 +4863,25 @@
"resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.0.4.tgz",
"integrity": "sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ=="
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
]
},
"node_modules/ignore": {
"version": "5.2.4",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz",
@@ -5767,6 +5840,11 @@
"node": ">=6"
}
},
"node_modules/papaparse": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.4.1.tgz",
"integrity": "sha512-HipMsgJkZu8br23pW15uvo6sib6wne/4woLZPlFf3rpDyMe9ywEXUsuD7+6K9PRkJlVT51j/sCOYDKGGS3ZJrw=="
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -8824,6 +8902,15 @@
"integrity": "sha512-gC3TazRzGoOnoKAhUx+Q0t8S9Tzs74z7m0ipwGpSqQrleP14hKxP4/JUeEQcD3W1/aIpnWl8pHowI7WokuZpXg==",
"devOptional": true
},
"@types/papaparse": {
"version": "5.3.7",
"resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.3.7.tgz",
"integrity": "sha512-f2HKmlnPdCvS0WI33WtCs5GD7X1cxzzS/aduaxSu3I7TbhWlENjSPs6z5TaB9K0J+BH1jbmqTaM+ja5puis4wg==",
"dev": true,
"requires": {
"@types/node": "*"
}
},
"@types/parse-color": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@types/parse-color/-/parse-color-1.0.1.tgz",
@@ -9325,6 +9412,11 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true
},
"base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="
},
"binary-extensions": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
@@ -9359,6 +9451,15 @@
"update-browserslist-db": "^1.0.10"
}
},
"buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"requires": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"cac": {
"version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
@@ -10812,6 +10913,11 @@
"resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.0.4.tgz",
"integrity": "sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ=="
},
"ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="
},
"ignore": {
"version": "5.2.4",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz",
@@ -11506,6 +11612,11 @@
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="
},
"papaparse": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.4.1.tgz",
"integrity": "sha512-HipMsgJkZu8br23pW15uvo6sib6wne/4woLZPlFf3rpDyMe9ywEXUsuD7+6K9PRkJlVT51j/sCOYDKGGS3ZJrw=="
},
"parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",

View File

@@ -6,6 +6,7 @@
"scripts": {
"tauri-dev": "YAAK_ENV=development tauri dev",
"tauri-build": "tauri build",
"tauri": "tauri",
"build": "npm run build:frontend",
"dev": "vite dev",
"lint": "tsc && eslint . --ext .ts,.tsx",
@@ -34,12 +35,14 @@
"@tanstack/react-query-persist-client": "^4.28.0",
"@tauri-apps/api": "^1.2.0",
"@vitejs/plugin-react": "^3.1.0",
"buffer": "^6.0.3",
"classnames": "^2.3.2",
"cm6-graphql": "^0.0.4-canary-b30a2325.0",
"codemirror": "^6.0.1",
"focus-trap-react": "^10.1.1",
"format-graphql": "^1.4.0",
"framer-motion": "^9.0.4",
"papaparse": "^5.4.1",
"parse-color": "^1.0.0",
"react": "^18.2.0",
"react-dnd": "^16.0.1",
@@ -54,6 +57,7 @@
"@tailwindcss/nesting": "^0.0.0-insiders.565cd3e",
"@tauri-apps/cli": "^1.2.2",
"@types/node": "^18.7.10",
"@types/papaparse": "^5.3.7",
"@types/parse-color": "^1.0.1",
"@types/parse-json": "^4.0.0",
"@types/react": "^18.0.31",

24
src-tauri/Cargo.lock generated
View File

@@ -679,9 +679,9 @@ dependencies = [
[[package]]
name = "deno_core"
version = "0.178.0"
version = "0.179.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d58bd9e43979857fd26f4696f8e9f39fb645539ef3e604264521b408daf1d92b"
checksum = "8c9307ca2299cb7b0bdaa345cbdc82a252a8e4e5a4463e28f44c715d55e460fb"
dependencies = [
"anyhow",
"bytes",
@@ -704,9 +704,9 @@ dependencies = [
[[package]]
name = "deno_ops"
version = "0.56.0"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f494a90671467e3de74b557b3c1fe805aad87c7239580d2be8f2dddde971824"
checksum = "04610f07342fbb33a2b7ea7aa16a95ab71adb13a0ce858a8d1a1414660a83e3e"
dependencies = [
"once_cell",
"pmutil",
@@ -2320,6 +2320,17 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "os_info"
version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "006e42d5b888366f1880eda20371fedde764ed2213dc8496f49622fa0c99cd5e"
dependencies = [
"log",
"serde",
"winapi",
]
[[package]]
name = "overload"
version = "0.1.1"
@@ -3161,9 +3172,9 @@ dependencies = [
[[package]]
name = "serde_v8"
version = "0.89.0"
version = "0.90.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9541cff99b1b9da15461aada44f09577af1f614add71f2fedc250c7e650a0383"
checksum = "916ca7852a4c5f0ba59ce4a46301bf7c7ad573c2c89a0fe67e90fe30dcbd6f7d"
dependencies = [
"bytes",
"derive_more",
@@ -4025,6 +4036,7 @@ dependencies = [
"objc",
"once_cell",
"open",
"os_info",
"percent-encoding",
"rand 0.8.5",
"raw-window-handle",

View File

@@ -20,12 +20,12 @@ cocoa = "0.24.1"
[dependencies]
serde_json = { version = "1.0", features = ["raw_value"] }
serde = { version = "1.0", features = ["derive"] }
tauri = { version = "1.2", features = ["config-toml", "devtools", "shell-open", "system-tray", "updater", "window-start-dragging"] }
tauri = { version = "1.2", features = ["config-toml", "devtools", "fs-read-file", "os-all", "protocol-asset", "shell-open", "system-tray", "updater", "window-start-dragging"] }
http = "0.2.8"
reqwest = { version = "0.11.14", features = ["json"] }
tokio = { version = "1.25.0", features = ["sync"] }
futures = "0.3.26"
deno_core = "0.178.0"
deno_core = "0.179.0"
deno_ast = { version = "0.25.0", features = ["transpiling"] }
sqlx = { version = "0.6.2", features = ["sqlite", "runtime-tokio-rustls", "json", "chrono", "time", "offline"] }
uuid = "1.3.0"

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!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.cs.allow-unsigned-executable-memory</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,5 @@
DELETE FROM main.http_responses;
ALTER TABLE http_responses DROP COLUMN body;
ALTER TABLE http_responses ADD COLUMN body BLOB;
ALTER TABLE http_responses ADD COLUMN body_path TEXT;
ALTER TABLE http_responses ADD COLUMN content_length INTEGER;

View File

@@ -58,25 +58,107 @@
},
"query": "\n DELETE FROM http_responses\n WHERE id = ?\n "
},
"0fa36011553f7ca91113459a5cefd47f990f9b548a95e475ffd6e4b017059488": {
"26072725d536c3cfdffd9a681d17c0ee2f246ca98e0459630a2430236d3bbdd2": {
"describe": {
"columns": [],
"nullable": [],
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "model",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "workspace_id",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "request_id",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "updated_at",
"ordinal": 4,
"type_info": "Datetime"
},
{
"name": "created_at",
"ordinal": 5,
"type_info": "Datetime"
},
{
"name": "url",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "status",
"ordinal": 7,
"type_info": "Int64"
},
{
"name": "status_reason",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "content_length",
"ordinal": 9,
"type_info": "Int64"
},
{
"name": "body",
"ordinal": 10,
"type_info": "Blob"
},
{
"name": "body_path",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "elapsed",
"ordinal": 12,
"type_info": "Int64"
},
{
"name": "error",
"ordinal": 13,
"type_info": "Text"
},
{
"name": "headers!: sqlx::types::Json<Vec<HttpResponseHeader>>",
"ordinal": 14,
"type_info": "Text"
}
],
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false,
true,
true,
true,
true,
false,
true,
false
],
"parameters": {
"Right": 1
}
},
"query": "\n DELETE FROM http_responses\n WHERE request_id = ?\n "
},
"318ed5a1126fe00719393cf4e6c788ee5a265af88b7253f61a475f78c6774ef6": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 9
}
},
"query": "\n INSERT INTO http_responses (\n id,\n request_id,\n workspace_id,\n elapsed,\n url,\n status,\n status_reason,\n body,\n headers\n )\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);\n "
"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 "
},
"448a1d1f1866ab42c0f81fcf8eb2930bf21dfdd43ca4831bc1a198cf45ac3732": {
"describe": {
@@ -88,6 +170,118 @@
},
"query": "\n DELETE FROM http_requests\n WHERE id = ?\n "
},
"5aa070e61995f8b1724efaa94c5f0cef5a4be6efda5d70354ad449d7d4b5aee4": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "model",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "workspace_id",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "request_id",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "updated_at",
"ordinal": 4,
"type_info": "Datetime"
},
{
"name": "created_at",
"ordinal": 5,
"type_info": "Datetime"
},
{
"name": "url",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "status",
"ordinal": 7,
"type_info": "Int64"
},
{
"name": "status_reason",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "content_length",
"ordinal": 9,
"type_info": "Int64"
},
{
"name": "body",
"ordinal": 10,
"type_info": "Blob"
},
{
"name": "body_path",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "elapsed",
"ordinal": 12,
"type_info": "Int64"
},
{
"name": "error",
"ordinal": 13,
"type_info": "Text"
},
{
"name": "headers!: sqlx::types::Json<Vec<HttpResponseHeader>>",
"ordinal": 14,
"type_info": "Text"
}
],
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false,
true,
true,
true,
true,
false,
true,
false
],
"parameters": {
"Right": 1
}
},
"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 "
},
"62475fd9483fb5eda01c937949da2ef66ac7005b4be06b87aa6210d462348aca": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 10
}
},
"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 "
},
"6f0cb5a6d1e8dbc8cdfcc3c7e7944b2c83c22cb795b9d6b98fe067dabec9680b": {
"describe": {
"columns": [
@@ -194,15 +388,15 @@
},
"query": "\n DELETE FROM workspaces\n WHERE id = ?\n "
},
"a83698dcf9a815b881097133edb31a34ba25e7c6c114d463c495342a85371639": {
"8947a2a90478277c42fe9b06bc1fa98197642a4d281a3dbc101be2c9c1fec36c": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 8
"Right": 11
}
},
"query": "\n UPDATE http_responses SET (elapsed, url, status, status_reason, body, error, headers, updated_at) =\n (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) WHERE id = ?;\n "
"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 "
},
"b19c275180909a39342b13c3cdcf993781636913ae590967f5508c46a56dc961": {
"describe": {
@@ -214,6 +408,108 @@
},
"query": "\n INSERT INTO http_requests (\n id,\n workspace_id,\n name,\n url,\n method,\n body,\n body_type,\n authentication,\n authentication_type,\n headers,\n sort_priority\n )\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n ON CONFLICT (id) DO UPDATE SET\n updated_at = CURRENT_TIMESTAMP,\n name = excluded.name,\n method = excluded.method,\n headers = excluded.headers,\n body = excluded.body,\n body_type = excluded.body_type,\n authentication = excluded.authentication,\n authentication_type = excluded.authentication_type,\n url = excluded.url,\n sort_priority = excluded.sort_priority\n "
},
"c23c61b05a4c9e04ab0c1fc2c579d6f2a82a37aeed8addf9861b4985f2a5422e": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "model",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "workspace_id",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "request_id",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "updated_at",
"ordinal": 4,
"type_info": "Datetime"
},
{
"name": "created_at",
"ordinal": 5,
"type_info": "Datetime"
},
{
"name": "url",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "status",
"ordinal": 7,
"type_info": "Int64"
},
{
"name": "status_reason",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "content_length",
"ordinal": 9,
"type_info": "Int64"
},
{
"name": "body",
"ordinal": 10,
"type_info": "Blob"
},
{
"name": "body_path",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "elapsed",
"ordinal": 12,
"type_info": "Int64"
},
{
"name": "error",
"ordinal": 13,
"type_info": "Text"
},
{
"name": "headers!: sqlx::types::Json<Vec<HttpResponseHeader>>",
"ordinal": 14,
"type_info": "Text"
}
],
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false,
true,
true,
true,
true,
false,
true,
false
],
"parameters": {
"Right": 1
}
},
"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 "
},
"caf3f21bf291dfbd36446592066e96c1f83abe96f6ea9211a3e049eb9c58a8c8": {
"describe": {
"columns": [
@@ -406,96 +702,6 @@
},
"query": "\n SELECT\n id,\n model,\n workspace_id,\n created_at,\n updated_at,\n name,\n url,\n method,\n body,\n body_type,\n authentication AS \"authentication!: Json<HashMap<String, JsonValue>>\",\n authentication_type,\n sort_priority,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpRequestHeader>>\"\n FROM http_requests\n WHERE id = ?\n "
},
"d5ad6d5f82fe837fa9215bd4619ec18a7c95b3088d4fbf9825f2d1d28069d1ce": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "model",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "workspace_id",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "request_id",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "updated_at",
"ordinal": 4,
"type_info": "Datetime"
},
{
"name": "created_at",
"ordinal": 5,
"type_info": "Datetime"
},
{
"name": "status",
"ordinal": 6,
"type_info": "Int64"
},
{
"name": "status_reason",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "body",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "elapsed",
"ordinal": 9,
"type_info": "Int64"
},
{
"name": "url",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "error",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "headers!: sqlx::types::Json<Vec<HttpResponseHeader>>",
"ordinal": 12,
"type_info": "Text"
}
],
"nullable": [
false,
false,
false,
false,
false,
false,
false,
true,
false,
false,
false,
true,
false
],
"parameters": {
"Right": 1
}
},
"query": "\n SELECT id, model, workspace_id, request_id, updated_at,\n created_at, status, status_reason, body, elapsed, url, error,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpResponseHeader>>\"\n FROM http_responses\n WHERE request_id = ?\n ORDER BY created_at ASC\n "
},
"d80c09497771e3641022e73ec6c6a87e73a551f88a948a5445d754922b82b50b": {
"describe": {
"columns": [],
@@ -506,95 +712,15 @@
},
"query": "\n INSERT INTO key_values (namespace, key, value)\n VALUES (?, ?, ?) ON CONFLICT DO UPDATE SET\n updated_at = CURRENT_TIMESTAMP,\n value = excluded.value\n "
},
"e3ade0a69348d512e47e964bded9d7d890b92fdc1e01c6c22fa5e91f943639f2": {
"e0f41023d877d94b7609ce910a71bd89c4827a558654b8ae14d85e6ba86990cf": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "model",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "workspace_id",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "request_id",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "updated_at",
"ordinal": 4,
"type_info": "Datetime"
},
{
"name": "created_at",
"ordinal": 5,
"type_info": "Datetime"
},
{
"name": "status",
"ordinal": 6,
"type_info": "Int64"
},
{
"name": "status_reason",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "body",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "elapsed",
"ordinal": 9,
"type_info": "Int64"
},
{
"name": "url",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "error",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "headers!: sqlx::types::Json<Vec<HttpResponseHeader>>",
"ordinal": 12,
"type_info": "Text"
}
],
"nullable": [
false,
false,
false,
false,
false,
false,
false,
true,
false,
false,
false,
true,
false
],
"columns": [],
"nullable": [],
"parameters": {
"Right": 1
"Right": 2
}
},
"query": "\n SELECT id, model, workspace_id, request_id, updated_at, created_at,\n status, status_reason, body, elapsed, url, error,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpResponseHeader>>\"\n FROM http_responses\n WHERE id = ?\n "
"query": "\n UPDATE workspaces SET (name, updated_at) =\n (?, CURRENT_TIMESTAMP) WHERE id = ?;\n "
},
"f116d8cf9aad828135bb8c3a4c8b8e6b857ae13303989e9133a33b2d1cf20e96": {
"describe": {

View File

@@ -9,11 +9,13 @@ extern crate objc;
use std::collections::HashMap;
use std::env::current_dir;
use std::fs::create_dir_all;
use std::fs::{create_dir_all, File};
use std::io::Write;
use base64::Engine;
use http::header::{HeaderName, ACCEPT, USER_AGENT};
use http::{HeaderMap, HeaderValue, Method};
use rand::random;
use reqwest::redirect::Policy;
use serde::Serialize;
use sqlx::migrate::Migrator;
@@ -21,12 +23,16 @@ use sqlx::sqlite::SqlitePoolOptions;
use sqlx::types::Json;
use sqlx::{Pool, Sqlite};
use tauri::regex::Regex;
use tauri::{AppHandle, Menu, MenuItem, RunEvent, State, Submenu, TitleBarStyle, Window, Wry};
use tauri::{CustomMenuItem, Manager, SystemTray, SystemTrayEvent, SystemTrayMenu, WindowEvent};
#[cfg(target_os = "macos")]
use tauri::TitleBarStyle;
use tauri::{AppHandle, Menu, MenuItem, RunEvent, State, Submenu, Window, WindowUrl, Wry};
use tauri::{CustomMenuItem, Manager, WindowEvent};
use tokio::sync::Mutex;
use window_ext::WindowExt;
use crate::models::generate_id;
mod models;
mod runtime;
mod window_ext;
@@ -79,11 +85,8 @@ async fn actually_send_ephemeral_request(
let start = std::time::Instant::now();
let mut url_string = request.url.to_string();
let mut variables = HashMap::new();
variables.insert("PROJECT_ID", "project_123");
variables.insert("TOKEN", "s3cret");
variables.insert("DOMAIN", "schier.co");
variables.insert("BASE_URL", "https://schier.co");
let variables: HashMap<&str, &str> = HashMap::new();
// variables.insert("", "");
let re = Regex::new(r"\$\{\[\s*([^]\s]+)\s*]}").expect("Failed to create regex");
url_string = re
@@ -104,6 +107,7 @@ async fn actually_send_ephemeral_request(
let client = reqwest::Client::builder()
.redirect(Policy::none())
// .danger_accept_invalid_certs(true)
.build()
.expect("Failed to build client");
@@ -188,12 +192,23 @@ async fn actually_send_ephemeral_request(
let raw_response = client.execute(sendable_req).await;
let p = app_handle
.path_resolver()
.resolve_resource("plugins/plugin.ts")
.expect("failed to resolve resource");
let plugin_rel_path = "plugins/plugin.ts";
let plugin_path = match app_handle.path_resolver().resolve_resource(plugin_rel_path) {
Some(p) => p,
None => {
return response_err(
response,
format!("Plugin not found at {}", plugin_rel_path),
&app_handle,
pool,
)
.await;
}
};
runtime::run_plugin_sync(p.to_str().unwrap()).unwrap();
if let Err(e) = runtime::run_plugin_sync(plugin_path.to_str().unwrap()) {
return response_err(response, e.to_string(), &app_handle, pool).await;
}
match raw_response {
Ok(v) => {
@@ -210,9 +225,38 @@ async fn actually_send_ephemeral_request(
.collect(),
);
response.url = v.url().to_string();
response.body = v.text().await.expect("Failed to get body");
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 = base_dir.join(response.id.clone());
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)
response = models::update_response_if_id(&response, pool)
.await
.expect("Failed to update response");
if request.id != "" {
@@ -236,7 +280,7 @@ async fn send_request(
.await
.expect("Failed to get request");
let response = models::create_response(&req.id, 0, "", 0, None, "", vec![], pool)
let response = models::create_response(&req.id, 0, "", 0, None, None, None, None, vec![], pool)
.await
.expect("Failed to create response");
@@ -260,7 +304,7 @@ async fn response_err(
) -> Result<models::HttpResponse, String> {
let mut response = response.clone();
response.error = Some(error.clone());
response = models::update_response_if_id(response, pool)
response = models::update_response_if_id(&response, pool)
.await
.expect("Failed to update response");
emit_side_effect(app_handle, "updated_model", &response);
@@ -353,6 +397,21 @@ async fn duplicate_request(
emit_and_return(&window, "updated_model", request)
}
#[tauri::command]
async fn update_workspace(
workspace: models::Workspace,
window: Window<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<models::Workspace, String> {
let pool = &*db_instance.lock().await;
let updated_workspace = models::update_workspace(workspace, pool)
.await
.expect("Failed to update request");
emit_and_return(&window, "updated_model", updated_workspace)
}
#[tauri::command]
async fn update_request(
request: models::HttpRequest,
@@ -427,6 +486,17 @@ async fn get_request(
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn get_workspace(
id: &str,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<models::Workspace, String> {
let pool = &*db_instance.lock().await;
models::get_workspace(id, pool)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn responses(
request_id: &str,
@@ -481,6 +551,12 @@ async fn workspaces(
}
}
#[tauri::command]
async fn new_window(window: Window<Wry>, url: &str) -> Result<(), String> {
create_window(&window.app_handle(), Some(url));
Ok(())
}
#[tauri::command]
async fn delete_workspace(
window: Window<Wry>,
@@ -494,18 +570,8 @@ async fn delete_workspace(
emit_and_return(&window, "deleted_model", workspace)
}
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
}
fn main() {
let quit = CustomMenuItem::new("quit".to_string(), "Quit");
let tray_menu = SystemTrayMenu::new().add_item(quit);
let system_tray = SystemTray::new().with_menu(tray_menu);
tauri::Builder::default()
.system_tray(system_tray)
.setup(|app| {
let dir = match is_dev() {
true => current_dir().unwrap(),
@@ -533,22 +599,8 @@ fn main() {
Ok(())
})
})
.on_system_tray_event(|app, event| {
if let SystemTrayEvent::MenuItemClick { id, .. } = event {
match id.as_str() {
"quit" => {
std::process::exit(0);
}
"hide" => {
let window = app.get_window("main").unwrap();
window.hide().unwrap();
}
_ => {}
};
}
})
.invoke_handler(tauri::generate_handler![
greet,
new_window,
workspaces,
get_request,
requests,
@@ -556,8 +608,10 @@ fn main() {
send_ephemeral_request,
duplicate_request,
create_request,
get_workspace,
create_workspace,
delete_workspace,
update_workspace,
update_request,
delete_request,
responses,
@@ -570,7 +624,7 @@ fn main() {
.expect("error while running tauri application")
.run(|app_handle, event| match event {
RunEvent::Ready => {
create_window(app_handle);
create_window(app_handle, None);
}
// ExitRequested { api, .. } => {
@@ -584,7 +638,7 @@ fn is_dev() -> bool {
env.unwrap_or("production") != "production"
}
fn create_window(handle: &AppHandle<Wry>) -> Window<Wry> {
fn create_window(handle: &AppHandle<Wry>, url: Option<&str>) -> Window<Wry> {
let default_menu = Menu::os_default("Yaak".to_string().as_str());
let mut test_menu = Menu::new()
.add_item(
@@ -608,6 +662,22 @@ fn create_window(handle: &AppHandle<Wry>) -> Window<Wry> {
.add_item(
CustomMenuItem::new("focus_url".to_string(), "Focus URL").accelerator("CmdOrCtrl+l"),
)
.add_item(
CustomMenuItem::new("new_request".to_string(), "New Request")
.accelerator("CmdOrCtrl+n"),
)
.add_item(
CustomMenuItem::new("toggle_settings".to_string(), "Toggle Settings")
.accelerator("CmdOrCtrl+,"),
)
.add_item(
CustomMenuItem::new("duplicate_request".to_string(), "Duplicate Request")
.accelerator("CmdOrCtrl+d"),
)
.add_item(
CustomMenuItem::new("focus_sidebar".to_string(), "Focus Sidebar")
.accelerator("CmdOrCtrl+1"),
)
.add_item(CustomMenuItem::new("new_window".to_string(), "New Window"));
if is_dev() {
test_menu = test_menu
@@ -625,21 +695,36 @@ fn create_window(handle: &AppHandle<Wry>) -> Window<Wry> {
let submenu = Submenu::new("Test Menu", test_menu);
let window_num = handle.windows().len();
let window_id = format!("wnd_{}", window_num);
let window_id = format!("wnd_{}_{}", window_num, generate_id(None));
let menu = default_menu.add_submenu(submenu);
let win = tauri::WindowBuilder::new(handle, window_id, tauri::WindowUrl::App("".into()))
.menu(menu)
.fullscreen(false)
.resizable(true)
.inner_size(1100.0, 600.0)
.hidden_title(true)
.title(match is_dev() {
true => "Yaak Dev",
false => "Yaak",
})
.title_bar_style(TitleBarStyle::Overlay)
.build()
.expect("failed to build window");
let mut win_builder = tauri::WindowBuilder::new(
handle,
window_id,
WindowUrl::App(url.unwrap_or_default().into()),
)
.menu(menu)
.fullscreen(false)
.resizable(true)
.inner_size(1100.0, 600.0)
.position(
// Randomly offset so windows don't stack exactly
100.0 + random::<f64>() * 30.0,
100.0 + random::<f64>() * 30.0,
)
.title(match is_dev() {
true => "Yaak Dev",
false => "Yaak",
});
// Add macOS-only things
#[cfg(target_os = "macos")]
{
win_builder = win_builder
.hidden_title(true)
.title_bar_style(TitleBarStyle::Overlay);
}
let win = win_builder.build().expect("failed to build window");
let win2 = win.clone();
let handle2 = handle.clone();
@@ -651,9 +736,13 @@ fn create_window(handle: &AppHandle<Wry>) -> Window<Wry> {
"zoom_out" => win2.emit("zoom", -1).unwrap(),
"toggle_sidebar" => win2.emit("toggle_sidebar", true).unwrap(),
"focus_url" => win2.emit("focus_url", true).unwrap(),
"focus_sidebar" => win2.emit("focus_sidebar", true).unwrap(),
"send_request" => win2.emit("send_request", true).unwrap(),
"new_request" => _ = win2.emit("new_request", true).unwrap(),
"toggle_settings" => _ = win2.emit("toggle_settings", true).unwrap(),
"duplicate_request" => _ = win2.emit("duplicate_request", true).unwrap(),
"refresh" => win2.eval("location.reload()").unwrap(),
"new_window" => _ = create_window(&handle2),
"new_window" => _ = create_window(&handle2, None),
"toggle_devtools" => {
if win2.is_devtools_open() {
win2.close_devtools();
@@ -667,7 +756,6 @@ fn create_window(handle: &AppHandle<Wry>) -> Window<Wry> {
let win3 = win.clone();
win.on_window_event(move |e| {
let apply_offset = || {
#[cfg(target_os = "macos")]
win3.position_traffic_lights();
};
@@ -682,9 +770,7 @@ fn create_window(handle: &AppHandle<Wry>) -> Window<Wry> {
}
});
#[cfg(target_os = "macos")]
win.position_traffic_lights();
win
}

View File

@@ -1,4 +1,5 @@
use std::collections::HashMap;
use std::fs;
use rand::distributions::{Alphanumeric, DistString};
use serde::{Deserialize, Serialize};
@@ -63,10 +64,12 @@ pub struct HttpResponse {
pub updated_at: NaiveDateTime,
pub error: Option<String>,
pub url: String,
pub content_length: Option<i64>,
pub elapsed: i64,
pub status: i64,
pub status_reason: Option<String>,
pub body: String,
pub body: Option<Vec<u8>>,
pub body_path: Option<String>,
pub headers: Json<Vec<HttpResponseHeader>>,
}
@@ -151,9 +154,7 @@ pub async fn get_workspace(id: &str, pool: &Pool<Sqlite>) -> Result<Workspace, s
}
pub async fn delete_workspace(id: &str, pool: &Pool<Sqlite>) -> Result<Workspace, sqlx::Error> {
let workspace = get_workspace(id, pool)
.await
.expect("Failed to get request to delete");
let workspace = get_workspace(id, pool).await?;
let _ = sqlx::query!(
r#"
DELETE FROM workspaces
@@ -163,6 +164,11 @@ pub async fn delete_workspace(id: &str, pool: &Pool<Sqlite>) -> Result<Workspace
)
.execute(pool)
.await;
for r in find_responses_by_workspace_id(id, pool).await? {
delete_response(&r.id, pool).await?;
}
Ok(workspace)
}
@@ -171,7 +177,7 @@ pub async fn create_workspace(
description: &str,
pool: &Pool<Sqlite>,
) -> Result<Workspace, sqlx::Error> {
let id = generate_id("wk");
let id = generate_id(Some("wk"));
sqlx::query!(
r#"
INSERT INTO workspaces (id, name, description)
@@ -182,16 +188,13 @@ pub async fn create_workspace(
description,
)
.execute(pool)
.await
.expect("Failed to insert new workspace");
.await?;
get_workspace(&id, pool).await
}
pub async fn duplicate_request(id: &str, pool: &Pool<Sqlite>) -> Result<HttpRequest, sqlx::Error> {
let existing = get_request(id, pool)
.await
.expect("Failed to get request to duplicate");
let existing = get_request(id, pool).await?;
// TODO: Figure out how to make this better
let b2;
@@ -238,12 +241,13 @@ pub async fn upsert_request(
let id = match id {
Some(v) => v,
None => {
generated_id = generate_id("rq");
generated_id = generate_id(Some("rq"));
generated_id.as_str()
}
};
let headers_json = Json(headers);
let auth_json = Json(authentication);
let trimmed_name = name.trim();
sqlx::query!(
r#"
INSERT INTO http_requests (
@@ -274,7 +278,7 @@ pub async fn upsert_request(
"#,
id,
workspace_id,
name,
trimmed_name,
url,
method,
body,
@@ -285,8 +289,7 @@ pub async fn upsert_request(
sort_priority,
)
.execute(pool)
.await
.expect("Failed to insert new request");
.await?;
get_request(id, pool).await
}
@@ -350,9 +353,7 @@ pub async fn get_request(id: &str, pool: &Pool<Sqlite>) -> Result<HttpRequest, s
}
pub async fn delete_request(id: &str, pool: &Pool<Sqlite>) -> Result<HttpRequest, sqlx::Error> {
let req = get_request(id, pool)
.await
.expect("Failed to get request to delete");
let req = get_request(id, pool).await?;
let _ = sqlx::query!(
r#"
DELETE FROM http_requests
@@ -363,6 +364,8 @@ pub async fn delete_request(id: &str, pool: &Pool<Sqlite>) -> Result<HttpRequest
.execute(pool)
.await;
delete_all_responses(id, pool).await?;
Ok(req)
}
@@ -372,14 +375,14 @@ pub async fn create_response(
url: &str,
status: i64,
status_reason: Option<&str>,
body: &str,
content_length: Option<i64>,
body: Option<Vec<u8>>,
body_path: Option<&str>,
headers: Vec<HttpResponseHeader>,
pool: &Pool<Sqlite>,
) -> Result<HttpResponse, sqlx::Error> {
let req = get_request(request_id, pool)
.await
.expect("Failed to get request");
let id = generate_id("rp");
let req = get_request(request_id, pool).await?;
let id = generate_id(Some("rp"));
let headers_json = Json(headers);
sqlx::query!(
r#"
@@ -391,10 +394,12 @@ pub async fn create_response(
url,
status,
status_reason,
content_length,
body,
body_path,
headers
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
"#,
id,
request_id,
@@ -403,57 +408,88 @@ pub async fn create_response(
url,
status,
status_reason,
content_length,
body,
body_path,
headers_json,
)
.execute(pool)
.await
.expect("Failed to insert new response");
.await?;
get_response(&id, pool).await
}
pub async fn update_response_if_id(
response: HttpResponse,
response: &HttpResponse,
pool: &Pool<Sqlite>,
) -> Result<HttpResponse, sqlx::Error> {
if response.id == "" {
return Ok(response);
return Ok(response.clone());
}
return update_response(response, pool).await;
}
pub async fn update_response(
response: HttpResponse,
pub async fn update_workspace(
workspace: Workspace,
pool: &Pool<Sqlite>,
) -> Result<HttpResponse, sqlx::Error> {
let headers_json = Json(response.headers);
) -> Result<Workspace, sqlx::Error> {
let trimmed_name = workspace.name.trim();
sqlx::query!(
r#"
UPDATE http_responses SET (elapsed, url, status, status_reason, body, error, headers, updated_at) =
(?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) WHERE id = ?;
UPDATE workspaces SET (name, updated_at) =
(?, CURRENT_TIMESTAMP) WHERE id = ?;
"#,
trimmed_name,
workspace.id,
)
.execute(pool)
.await?;
get_workspace(&workspace.id, pool).await
}
pub async fn update_response(
response: &HttpResponse,
pool: &Pool<Sqlite>,
) -> Result<HttpResponse, sqlx::Error> {
let headers_json = Json(&response.headers);
sqlx::query!(
r#"
UPDATE http_responses SET (
elapsed,
url,
status,
status_reason,
content_length,
body,
body_path,
error,
headers,
updated_at
) = (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) WHERE id = ?;
"#,
response.elapsed,
response.url,
response.status,
response.status_reason,
response.content_length,
response.body,
response.body_path,
response.error,
headers_json,
response.id,
)
.execute(pool)
.await
.expect("Failed to update response");
.await?;
get_response(&response.id, pool).await
}
pub async fn get_response(id: &str, pool: &Pool<Sqlite>) -> Result<HttpResponse, sqlx::Error> {
sqlx::query_as_unchecked!(
sqlx::query_as!(
HttpResponse,
r#"
SELECT id, model, workspace_id, request_id, updated_at, created_at,
status, status_reason, body, elapsed, url, error,
SELECT id, model, workspace_id, request_id, updated_at, created_at, url,
status, status_reason, content_length, body, body_path, elapsed, error,
headers AS "headers!: sqlx::types::Json<Vec<HttpResponseHeader>>"
FROM http_responses
WHERE id = ?
@@ -471,12 +507,12 @@ pub async fn find_responses(
sqlx::query_as!(
HttpResponse,
r#"
SELECT id, model, workspace_id, request_id, updated_at,
created_at, status, status_reason, body, elapsed, url, error,
SELECT id, model, workspace_id, request_id, updated_at, created_at, url,
status, status_reason, content_length, body, body_path, elapsed, error,
headers AS "headers!: sqlx::types::Json<Vec<HttpResponseHeader>>"
FROM http_responses
WHERE request_id = ?
ORDER BY created_at ASC
ORDER BY created_at DESC
"#,
request_id,
)
@@ -484,10 +520,35 @@ pub async fn find_responses(
.await
}
pub async fn find_responses_by_workspace_id(
workspace_id: &str,
pool: &Pool<Sqlite>,
) -> Result<Vec<HttpResponse>, sqlx::Error> {
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,
headers AS "headers!: sqlx::types::Json<Vec<HttpResponseHeader>>"
FROM http_responses
WHERE workspace_id = ?
ORDER BY created_at DESC
"#,
workspace_id,
)
.fetch_all(pool)
.await
}
pub async fn delete_response(id: &str, pool: &Pool<Sqlite>) -> Result<HttpResponse, sqlx::Error> {
let resp = get_response(id, pool)
.await
.expect("Failed to get response to delete");
let resp = get_response(id, pool).await?;
// 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);
};
}
let _ = sqlx::query!(
r#"
@@ -506,22 +567,16 @@ pub async fn delete_all_responses(
request_id: &str,
pool: &Pool<Sqlite>,
) -> Result<(), sqlx::Error> {
let _ = sqlx::query!(
r#"
DELETE FROM http_responses
WHERE request_id = ?
"#,
request_id,
)
.execute(pool)
.await;
for r in find_responses(request_id, pool).await? {
delete_response(&r.id, pool).await?;
}
Ok(())
}
pub fn generate_id(prefix: &str) -> String {
format!(
"{prefix}_{}",
Alphanumeric.sample_string(&mut rand::thread_rng(), 10)
)
pub fn generate_id(prefix: Option<&str>) -> String {
let id = Alphanumeric.sample_string(&mut rand::thread_rng(), 10);
return match prefix {
None => id,
Some(p) => format!("{p}_{id}"),
};
}

View File

@@ -1,6 +1,6 @@
use tauri::{Runtime, Window};
const TRAFFIC_LIGHT_OFFSET_X: f64 = 10.0;
const TRAFFIC_LIGHT_OFFSET_X: f64 = 13.0;
const TRAFFIC_LIGHT_OFFSET_Y: f64 = 18.0;
pub trait WindowExt {
@@ -8,6 +8,11 @@ pub trait WindowExt {
}
impl<R: Runtime> WindowExt for Window<R> {
#[cfg(not(target_os = "macos"))]
fn position_traffic_lights(&self) {
// No-op
}
#[cfg(target_os = "macos")]
fn position_traffic_lights(&self) {
use cocoa::appkit::{NSView, NSWindow, NSWindowButton};

View File

@@ -8,15 +8,24 @@
},
"package": {
"productName": "Yaak",
"version": "2023.0.3"
"version": "2023.0.16"
},
"tauri": {
"windows": [],
"allowlist": {
"all": false,
"os": {
"all": true
},
"protocol": {
"assetScope": ["$APPDATA/responses/*"],
"asset": true
},
"fs": {
"readFile": true,
"scope": [
"$RESOURCE/*"
"$RESOURCE/*",
"$APPDATA/responses/*"
]
},
"shell": {
@@ -52,6 +61,7 @@
},
"macOS": {
"exceptionDomain": "",
"entitlements": "macos/entitlements.plist",
"frameworks": []
},
"windows": {
@@ -59,7 +69,8 @@
"timestampUrl": ""
}
},
"security": {},
"security": {
},
"systemTray": {
"iconAsTemplate": true,
"iconPath": "icons/icon.png"

View File

@@ -1,7 +1,4 @@
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { persistQueryClient } from '@tanstack/react-query-persist-client';
import { MotionConfig } from 'framer-motion';
import { Suspense } from 'react';
import { DndProvider } from 'react-dnd';
@@ -9,33 +6,18 @@ import { HTML5Backend } from 'react-dnd-html5-backend';
import { HelmetProvider } from 'react-helmet-async';
import { AppRouter } from './AppRouter';
import { DialogProvider } from './DialogContext';
import { TauriListeners } from './TauriListeners';
const queryClient = new QueryClient({
logger: undefined,
defaultOptions: {
queries: {
retry: false,
cacheTime: 1000 * 60 * 60 * 24, // 24 hours
refetchOnWindowFocus: true,
networkMode: 'offlineFirst',
// It's a desktop app, so this isn't necessary
refetchOnWindowFocus: false,
},
},
});
const localStoragePersister = createSyncStoragePersister({
storage: window.localStorage,
throttleTime: 1000, // 1 second
});
persistQueryClient({
queryClient,
persister: localStoragePersister,
maxAge: 1000 * 60 * 60 * 24, // 24 hours
});
export function App() {
return (
<QueryClientProvider client={queryClient}>
@@ -45,8 +27,7 @@ export function App() {
<DialogProvider>
<Suspense>
<AppRouter />
<TauriListeners />
<ReactQueryDevtools initialIsOpen={false} />
{/*<ReactQueryDevtools initialIsOpen={false} />*/}
</Suspense>
</DialogProvider>
</DndProvider>

View File

@@ -1,13 +1,8 @@
import { useEffect } from 'react';
import {
createBrowserRouter,
Navigate,
Outlet,
RouterProvider,
useLocation,
} from 'react-router-dom';
import { routePaths } from '../hooks/useRoutes';
import { setLastLocation } from '../lib/lastLocation';
import { createBrowserRouter, Navigate, Outlet, RouterProvider } from 'react-router-dom';
import { routePaths, useAppRoutes } from '../hooks/useAppRoutes';
import { useRecentRequests } from '../hooks/useRecentRequests';
import { useRequests } from '../hooks/useRequests';
import { GlobalHooks } from './GlobalHooks';
import RouteError from './RouteError';
import Workspace from './Workspace';
import Workspaces from './Workspaces';
@@ -16,7 +11,7 @@ const router = createBrowserRouter([
{
path: '/',
errorElement: <RouteError />,
element: <RouterRoot />,
element: <Layout />,
children: [
{
path: '/',
@@ -28,7 +23,7 @@ const router = createBrowserRouter([
},
{
path: routePaths.workspace({ workspaceId: ':workspaceId' }),
element: <Workspace />,
element: <WorkspaceOrRedirect />,
},
{
path: routePaths.request({
@@ -45,10 +40,28 @@ export function AppRouter() {
return <RouterProvider router={router} />;
}
function RouterRoot() {
const { pathname } = useLocation();
useEffect(() => {
setLastLocation(pathname).catch(console.error);
}, [pathname]);
return <Outlet />;
function WorkspaceOrRedirect() {
const recentRequests = useRecentRequests();
const requests = useRequests();
const request = requests.find((r) => r.id === recentRequests[0]);
const routes = useAppRoutes();
if (request === undefined) {
return <Workspace />;
}
return (
<Navigate
to={routes.paths.request({ workspaceId: request.workspaceId, requestId: request.id })}
/>
);
}
function Layout() {
return (
<>
<Outlet />
<GlobalHooks />
</>
);
}

View File

@@ -45,13 +45,20 @@ export const DialogProvider = ({ children }: { children: React.ReactNode }) => {
return (
<DialogContext.Provider value={state}>
{children}
{dialogs.map(({ id, render, ...props }) => (
<Dialog open key={id} onClose={() => actions.hide(id)} {...props}>
{render({ hide: () => actions.hide(id) })}
</Dialog>
{dialogs.map((props: DialogEntry) => (
<DialogInstance key={props.id} {...props} />
))}
</DialogContext.Provider>
);
};
function DialogInstance({ id, render, ...props }: DialogEntry) {
const { actions } = useContext(DialogContext);
return (
<Dialog open onClose={() => actions.hide(id)} {...props}>
{render({ hide: () => actions.hide(id) })}
</Dialog>
);
}
export const useDialog = () => useContext(DialogContext).actions;

View File

@@ -11,7 +11,7 @@ import { NAMESPACE_NO_SYNC } from '../lib/keyValueStore';
import type { HttpRequest, HttpResponse, Model, Workspace } from '../lib/models';
import { modelsEq } from '../lib/models';
export function TauriListeners() {
export function GlobalHooks() {
const queryClient = useQueryClient();
const { wasUpdatedExternally } = useRequestUpdateKey(null);
@@ -30,14 +30,13 @@ export function TauriListeners() {
: null;
if (queryKey === null) {
if (payload.model) {
console.log('Unrecognized created model:', payload);
}
console.log('Unrecognized created model:', payload);
return;
}
if (!shouldIgnoreModel(payload)) {
queryClient.setQueryData<Model[]>(queryKey, (values) => [...(values ?? []), payload]);
// Order newest first
queryClient.setQueryData<Model[]>(queryKey, (values) => [payload, ...(values ?? [])]);
}
});
@@ -56,9 +55,7 @@ export function TauriListeners() {
: null;
if (queryKey === null) {
if (payload.model) {
console.log('Unrecognized updated model:', payload);
}
console.log('Unrecognized updated model:', payload);
return;
}
@@ -115,7 +112,8 @@ const shouldIgnoreEvent = (payload: Model, windowLabel: string) =>
windowLabel === appWindow.label && payload.model !== 'http_response';
const shouldIgnoreModel = (payload: Model) => {
if (payload.model === 'http_response') return false;
if (payload.model === 'key_value' && payload.namespace === NAMESPACE_NO_SYNC) return false;
return true;
if (payload.model === 'key_value') {
return payload.namespace === NAMESPACE_NO_SYNC;
}
return false;
};

View File

@@ -71,7 +71,7 @@ export function GraphQLEditor({ defaultValue, onChange, baseRequest, ...extraEdi
const dialog = useDialog();
return (
<div className="pb-2 h-full grid grid-rows-[minmax(0,100%)_auto_auto_minmax(0,auto)]">
<div className="pb-2 h-full w-full grid grid-cols-1 grid-rows-[minmax(0,100%)_auto_auto_minmax(0,auto)]">
<Editor
contentType="application/graphql"
defaultValue={query ?? ''}

View File

@@ -1,7 +1,7 @@
import classnames from 'classnames';
import FocusTrap from 'focus-trap-react';
import type { ReactNode } from 'react';
import { motion } from 'framer-motion';
import type { ReactNode } from 'react';
import { Portal } from './Portal';
interface Props {
@@ -33,7 +33,7 @@ export function Overlay({ zIndex = 30, open, onClose, portalName, children }: Pr
<div
aria-hidden
onClick={onClose}
className="absolute inset-0 bg-gray-600/60 dark:bg-black/50"
className="absolute inset-0 bg-gray-600/30 dark:bg-black/30 backdrop-blur-sm"
/>
{/* Add region to still be able to drag the window */}
<div data-tauri-drag-region className="absolute top-0 left-0 right-0 h-md" />

View File

@@ -0,0 +1,83 @@
import { useMemo, useRef } from 'react';
import { useKey, useKeyPressEvent } from 'react-use';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useActiveWorkspaceId } from '../hooks/useActiveWorkspaceId';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { useRecentRequests } from '../hooks/useRecentRequests';
import { useRequests } from '../hooks/useRequests';
import { Button } from './core/Button';
import { CountBadge } from './core/CountBadge';
import type { DropdownItem, DropdownRef } from './core/Dropdown';
import { Dropdown } from './core/Dropdown';
export function RecentRequestsDropdown() {
const dropdownRef = useRef<DropdownRef>(null);
const activeRequest = useActiveRequest();
const activeWorkspaceId = useActiveWorkspaceId();
const recentRequestIds = useRecentRequests();
const requests = useRequests();
const routes = useAppRoutes();
useKeyPressEvent('Control', undefined, () => {
// Key up
dropdownRef.current?.select?.();
});
useKey(
'Tab',
(e) => {
if (!e.ctrlKey || recentRequestIds.length === 0) return;
if (!dropdownRef.current?.isOpen) {
// Set to 1 because the first item is the active request
dropdownRef.current?.open(e.shiftKey ? -1 : 0);
}
if (e.shiftKey) dropdownRef.current?.prev?.();
else dropdownRef.current?.next?.();
},
undefined,
[recentRequestIds.length],
);
const items = useMemo<DropdownItem[]>(() => {
if (activeWorkspaceId === null) return [];
const recentRequestItems: DropdownItem[] = [];
for (const id of recentRequestIds) {
const request = requests.find((r) => r.id === id);
if (request === undefined) continue;
recentRequestItems.push({
key: request.id,
label: request.name,
leftSlot: <CountBadge className="!ml-0 px-0 w-5" count={recentRequestItems.length + 1} />,
onSelect: () => {
routes.navigate('request', {
requestId: request.id,
workspaceId: activeWorkspaceId,
});
},
});
}
// No recent requests to show
if (recentRequestItems.length === 0) {
return [];
}
return recentRequestItems.slice(0, 20);
}, [activeWorkspaceId, recentRequestIds, requests, routes]);
return (
<Dropdown ref={dropdownRef} items={items}>
<Button
disabled={activeRequest === null}
size="sm"
className="flex-[2] text-center text-gray-800 text-sm truncate pointer-events-none"
>
{activeRequest?.name ?? 'No Request'}
</Button>
</Dropdown>
);
}

View File

@@ -1,11 +1,13 @@
import type { HTMLAttributes, ReactElement } from 'react';
import { useConfirm } from '../hooks/useConfirm';
import React, { useRef } from 'react';
import { useDeleteRequest } from '../hooks/useDeleteRequest';
import { useDuplicateRequest } from '../hooks/useDuplicateRequest';
import { useRequest } from '../hooks/useRequest';
import { useTauriEvent } from '../hooks/useTauriEvent';
import { useTheme } from '../hooks/useTheme';
import type { DropdownRef } from './core/Dropdown';
import { Dropdown } from './core/Dropdown';
import { HotKey } from './core/HotKey';
import { Icon } from './core/Icon';
import { InlineCode } from './core/InlineCode';
interface Props {
requestId: string;
@@ -13,37 +15,45 @@ interface Props {
}
export function RequestActionsDropdown({ requestId, children }: Props) {
const request = useRequest(requestId ?? null);
const deleteRequest = useDeleteRequest(requestId ?? null);
const deleteRequest = useDeleteRequest(requestId);
const duplicateRequest = useDuplicateRequest({ id: requestId, navigateAfter: true });
const confirm = useConfirm();
const dropdownRef = useRef<DropdownRef>(null);
const { appearance, toggleAppearance } = useTheme();
useTauriEvent('toggle_settings', () => {
dropdownRef.current?.toggle();
});
// TODO: Put this somewhere better
useTauriEvent('duplicate_request', () => {
duplicateRequest.mutate();
});
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: async () => {
const confirmed = await confirm({
title: 'Delete Request',
variant: 'delete',
description: (
<>
Are you sure you want to delete <InlineCode>{request?.name}</InlineCode>?
</>
),
});
if (confirmed) {
deleteRequest.mutate();
}
},
onSelect: deleteRequest.mutate,
variant: 'danger',
leftSlot: <Icon icon="trash" />,
},
{ type: 'separator', label: 'Yaak Settings' },
{
key: 'appearance',
label: appearance === 'dark' ? 'Light Theme' : 'Dark Theme',
onSelect: toggleAppearance,
leftSlot: <Icon icon={appearance === 'dark' ? 'sun' : 'moon'} />,
},
]}
>
{children}

View File

@@ -162,7 +162,7 @@ export const RequestPane = memo(function RequestPane({ style, fullHeight, classN
label="Request"
onChangeValue={setActiveTab}
tabs={tabs}
tabListClassName="mt-2"
tabListClassName="mt-2 !mb-1.5"
>
<TabContent value="auth">
{activeRequest.authenticationType === AUTH_TYPE_BASIC ? (

View File

@@ -2,7 +2,8 @@ import useResizeObserver from '@react-hook/resize-observer';
import classnames from 'classnames';
import type { CSSProperties, MouseEvent as ReactMouseEvent } from 'react';
import React, { memo, useCallback, useMemo, useRef, useState } from 'react';
import { useKeyValue } from '../hooks/useKeyValue';
import { useLocalStorage } from 'react-use';
import { useActiveWorkspaceId } from '../hooks/useActiveWorkspaceId';
import { clamp } from '../lib/clamp';
import { RequestPane } from './RequestPane';
import { ResizeHandle } from './ResizeHandle';
@@ -24,10 +25,10 @@ const STACK_VERTICAL_WIDTH = 650;
export const RequestResponse = memo(function RequestResponse({ style }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const [vertical, setVertical] = useState<boolean>(false);
const widthKv = useKeyValue<number>({ key: 'body_width', defaultValue: DEFAULT });
const heightKv = useKeyValue<number>({ key: 'body_height', defaultValue: DEFAULT });
const width = widthKv.value ?? DEFAULT;
const height = heightKv.value ?? DEFAULT;
const [widthRaw, setWidth] = useLocalStorage<number>(`body_width::${useActiveWorkspaceId()}`);
const [heightRaw, setHeight] = useLocalStorage<number>(`body_height::${useActiveWorkspaceId()}`);
const width = widthRaw ?? DEFAULT;
const height = heightRaw ?? DEFAULT;
const [isResizing, setIsResizing] = useState<boolean>(false);
const moveState = useRef<{ move: (e: MouseEvent) => void; up: (e: MouseEvent) => void } | null>(
null,
@@ -63,8 +64,8 @@ export const RequestResponse = memo(function RequestResponse({ style }: Props) {
};
const handleReset = useCallback(
() => (vertical ? heightKv.set(DEFAULT) : widthKv.set(DEFAULT)),
[heightKv, vertical, widthKv],
() => (vertical ? setHeight(DEFAULT) : setWidth(DEFAULT)),
[setHeight, vertical, setWidth],
);
const handleResizeStart = useCallback(
@@ -89,7 +90,7 @@ export const RequestResponse = memo(function RequestResponse({ style }: Props) {
MIN_HEIGHT_PX,
maxHeightPx,
);
heightKv.set(newHeightPx / containerRect.height);
setHeight(newHeightPx / containerRect.height);
} else {
const maxWidthPx = containerRect.width - MIN_WIDTH_PX;
const newWidthPx = clamp(
@@ -97,7 +98,7 @@ export const RequestResponse = memo(function RequestResponse({ style }: Props) {
MIN_WIDTH_PX,
maxWidthPx,
);
widthKv.set(newWidthPx / containerRect.width);
setWidth(newWidthPx / containerRect.width);
}
},
up: (e: MouseEvent) => {
@@ -110,7 +111,7 @@ export const RequestResponse = memo(function RequestResponse({ style }: Props) {
document.documentElement.addEventListener('mouseup', moveState.current.up);
setIsResizing(true);
},
[width, height, vertical, heightKv, widthKv],
[width, height, vertical, setHeight, setWidth],
);
return (

View File

@@ -30,8 +30,8 @@ export function ResizeHandle({
style={style}
className={classnames(
className,
'group z-10 flex cursor-ew-resize',
vertical ? 'w-full h-3 cursor-ns-resize' : 'h-full w-3 cursor-ew-resize',
'group z-10 flex',
vertical ? 'w-full h-3 cursor-row-resize' : 'h-full w-3 cursor-col-resize',
justify === 'center' && 'justify-center',
justify === 'end' && 'justify-end',
justify === 'start' && 'justify-start',
@@ -46,9 +46,9 @@ export function ResizeHandle({
{isResizing && (
<div
className={classnames(
'fixed -left-20 -right-20 -top-20 -bottom-20 cursor-ew-resize',
vertical && 'cursor-ns-resize',
!vertical && 'cursor-ew-resize',
'fixed -left-20 -right-20 -top-20 -bottom-20',
vertical && 'cursor-row-resize',
!vertical && 'cursor-col-resize',
)}
/>
)}

View File

@@ -8,13 +8,13 @@ interface Props {
export function ResponseHeaders({ headers }: Props) {
return (
<dl className="text-xs w-full font-mono">
<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')}
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>

View File

@@ -5,23 +5,30 @@ import { createGlobalState } from 'react-use';
import { useActiveRequestId } from '../hooks/useActiveRequestId';
import { useDeleteResponse } from '../hooks/useDeleteResponse';
import { useDeleteResponses } from '../hooks/useDeleteResponses';
import { useLatestResponse } from '../hooks/useLatestResponse';
import { useResponseContentType } from '../hooks/useResponseContentType';
import { useResponses } from '../hooks/useResponses';
import { useResponseViewMode } from '../hooks/useResponseViewMode';
import { tryFormatJson } from '../lib/formatters';
import type { HttpResponse } from '../lib/models';
import { isResponseLoading } from '../lib/models';
import { pluralize } from '../lib/pluralize';
import { Banner } from './core/Banner';
import { CountBadge } from './core/CountBadge';
import { Dropdown } from './core/Dropdown';
import { Editor } from './core/Editor';
import { DurationTag } from './core/DurationTag';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { SizeTag } from './core/SizeTag';
import { HStack } from './core/Stacks';
import { StatusColor } from './core/StatusColor';
import { StatusTag } from './core/StatusTag';
import type { TabItem } from './core/Tabs/Tabs';
import { TabContent, Tabs } from './core/Tabs/Tabs';
import { Webview } from './core/Webview';
import { EmptyStateText } from './EmptyStateText';
import { ResponseHeaders } from './ResponseHeaders';
import { CsvViewer } from './responseViewers/CsvViewer';
import { ImageViewer } from './responseViewers/ImageViewer';
import { TextViewer } from './responseViewers/TextViewer';
import { WebPageViewer } from './responseViewers/WebPageViewer';
interface Props {
style?: CSSProperties;
@@ -33,11 +40,12 @@ 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 activeResponse: HttpResponse | null = pinnedResponseId
? responses.find((r) => r.id === pinnedResponseId) ?? null
: responses[responses.length - 1] ?? null;
const [viewMode, toggleViewMode] = useResponseViewMode(activeResponse?.requestId);
: latestResponse ?? null;
const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId);
const deleteResponse = useDeleteResponse(activeResponse?.id ?? null);
const deleteAllResponses = useDeleteResponses(activeResponse?.requestId);
const [activeTab, setActiveTab] = useActiveTab();
@@ -45,16 +53,22 @@ export const ResponsePane = memo(function ResponsePane({ style, className }: Pro
// Unset pinned response when a new one comes in
useEffect(() => setPinnedResponseId(null), [responses.length]);
const contentType = useMemo(
() =>
activeResponse?.headers.find((h) => h.name.toLowerCase() === 'content-type')?.value ??
'text/plain',
[activeResponse],
);
const contentType = useResponseContentType(activeResponse);
const tabs = useMemo(
const tabs: TabItem[] = useMemo(
() => [
{ label: 'Body', value: 'body' },
{
value: 'body',
label: 'Preview',
options: {
value: viewMode,
onChange: setViewMode,
items: [
{ label: 'Pretty', value: 'pretty' },
{ label: 'Raw', value: 'raw' },
],
},
},
{
label: (
<div className="flex items-center">
@@ -67,7 +81,7 @@ export const ResponsePane = memo(function ResponsePane({ style, className }: Pro
value: 'headers',
},
],
[activeResponse?.headers],
[activeResponse?.headers, setViewMode, viewMode],
);
return (
@@ -80,105 +94,105 @@ export const ResponsePane = memo(function ResponsePane({ style, className }: Pro
'shadow shadow-gray-100 dark:shadow-gray-0 relative',
)}
>
<HStack
alignItems="center"
className={classnames(
'italic text-gray-700 text-sm w-full flex-shrink-0',
// Remove a bit of space because the tabs have lots too
'-mb-1.5',
)}
>
{activeResponse && (
<>
<div className="whitespace-nowrap p-3 py-2">
<StatusColor statusCode={activeResponse.status}>
{activeResponse.status}
{activeResponse.statusReason && ` ${activeResponse.statusReason}`}
</StatusColor>
&nbsp;&bull;&nbsp;
{activeResponse.elapsed}ms &nbsp;&bull;&nbsp;
{Math.round(activeResponse.body.length / 1000)} KB
</div>
{activeResponse?.error && <Banner className="m-2">{activeResponse.error}</Banner>}
{activeResponse && !activeResponse.error && !isResponseLoading(activeResponse) && (
<>
<HStack
alignItems="center"
className={classnames(
'text-gray-700 text-sm w-full flex-shrink-0',
// Remove a bit of space because the tabs have lots too
'-mb-1.5',
)}
>
{activeResponse && (
<HStack alignItems="center" className="w-full">
<div className="whitespace-nowrap px-3">
<HStack space={2}>
<StatusTag showReason response={activeResponse} />
{activeResponse.elapsed > 0 && (
<>
<span>&bull;</span>
<DurationTag millis={activeResponse.elapsed} />
</>
)}
{!!activeResponse.contentLength && (
<>
<span>&bull;</span>
<SizeTag contentLength={activeResponse.contentLength} />
</>
)}
</HStack>
</div>
<Dropdown
items={[
{
label: viewMode === 'pretty' ? 'View Raw' : 'View Prettified',
onSelect: toggleViewMode,
},
{ type: 'separator', label: 'Actions' },
{
label: 'Clear Response',
onSelect: deleteResponse.mutate,
disabled: responses.length === 0,
},
{
label: `Clear ${responses.length} ${pluralize('Response', responses.length)}`,
onSelect: deleteAllResponses.mutate,
hidden: responses.length <= 1,
disabled: responses.length === 0,
},
{ type: 'separator', label: 'History' },
...responses.slice(0, 10).map((r) => ({
label: r.status + ' - ' + r.elapsed + ' ms',
leftSlot: activeResponse?.id === r.id ? <Icon icon="check" /> : <></>,
onSelect: () => setPinnedResponseId(r.id),
})),
]}
>
<IconButton
title="Show response history"
icon="triangleDown"
className="ml-auto"
size="sm"
iconSize="md"
/>
</Dropdown>
</>
)}
</HStack>
<Dropdown
items={[
{
key: 'clear-single',
label: 'Clear Response',
onSelect: deleteResponse.mutate,
disabled: responses.length === 0,
},
{
key: 'clear-all',
label: `Clear ${responses.length} ${pluralize('Response', responses.length)}`,
onSelect: deleteAllResponses.mutate,
hidden: responses.length <= 1,
disabled: responses.length === 0,
},
{ type: 'separator', label: 'History' },
...responses.slice(0, 20).map((r) => ({
key: r.id,
label: (
<HStack space={2}>
<StatusTag className="text-xs" response={r} />
<span>&bull;</span> <span>{r.elapsed}ms</span>
</HStack>
),
leftSlot:
activeResponse?.id === r.id ? <Icon icon="check" /> : <Icon icon="empty" />,
onSelect: () => setPinnedResponseId(r.id),
})),
]}
>
<IconButton
title="Show response history"
icon="triangleDown"
className="ml-auto"
size="sm"
iconSize="md"
/>
</Dropdown>
</HStack>
)}
</HStack>
{activeResponse?.error ? (
<Banner className="m-2">{activeResponse.error}</Banner>
) : (
<Tabs
value={activeTab}
onChangeValue={setActiveTab}
label="Response"
className="px-3"
tabs={tabs}
>
<TabContent value="body">
{activeResponse === null ? (
<EmptyStateText>No Response</EmptyStateText>
) : viewMode === 'pretty' && contentType.includes('html') ? (
<Webview
body={activeResponse.body}
contentType={contentType}
url={activeResponse.url}
/>
) : viewMode === 'pretty' && contentType.includes('json') ? (
<Editor
readOnly
forceUpdateKey={`pretty::${activeResponse.updatedAt}`}
className="bg-gray-50 dark:!bg-gray-100"
defaultValue={tryFormatJson(activeResponse?.body)}
contentType={contentType}
/>
) : activeResponse?.body ? (
<Editor
readOnly
forceUpdateKey={activeResponse.updatedAt}
className="bg-gray-50 dark:!bg-gray-100"
defaultValue={activeResponse?.body}
contentType={contentType}
/>
) : null}
</TabContent>
<TabContent value="headers">
<ResponseHeaders headers={activeResponse?.headers ?? []} />
</TabContent>
</Tabs>
<Tabs
value={activeTab}
onChangeValue={setActiveTab}
label="Response"
tabs={tabs}
className="ml-3 mr-1"
tabListClassName="mt-1.5"
>
<TabContent value="headers">
<ResponseHeaders headers={activeResponse?.headers ?? []} />
</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} />
) : contentType?.match(/csv|tab-separated/) ? (
<CsvViewer className="pb-2" response={activeResponse} />
) : (
<TextViewer response={activeResponse} pretty={viewMode === 'pretty'} />
)}
</TabContent>
</Tabs>
</>
)}
</div>
);

View File

@@ -1,4 +1,5 @@
import { useRouteError } from 'react-router-dom';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { Button } from './core/Button';
import { Heading } from './core/Heading';
import { VStack } from './core/Stacks';
@@ -8,6 +9,7 @@ export default function RouteError() {
const stringified = JSON.stringify(error);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const message = (error as any).message ?? stringified;
const routes = useAppRoutes();
return (
<div className="flex items-center justify-center h-full">
<VStack space={5} className="max-w-[30rem] !h-auto">
@@ -15,9 +17,19 @@ export default function RouteError() {
<pre className="text-sm select-auto cursor-text bg-gray-100 p-3 rounded whitespace-normal">
{message}
</pre>
<Button to="/" color="primary">
Go Home
</Button>
<VStack space={2}>
<Button
color="primary"
onClick={() => {
routes.navigate('workspaces');
}}
>
Go Home
</Button>
<Button color="secondary" onClick={() => window.location.reload()}>
Refresh
</Button>
</VStack>
</VStack>
</div>
);

View File

@@ -1,19 +1,24 @@
import classnames from 'classnames';
import type { ForwardedRef, KeyboardEvent } from 'react';
import type { ForwardedRef } from 'react';
import React, { forwardRef, Fragment, memo, useCallback, useMemo, useRef, useState } from 'react';
import type { XYCoord } from 'react-dnd';
import { useDrag, useDrop } from 'react-dnd';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useKey, useKeyPressEvent } from 'react-use';
import { useActiveRequestId } from '../hooks/useActiveRequestId';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { useDeleteAnyRequest } from '../hooks/useDeleteAnyRequest';
import { useLatestResponse } from '../hooks/useLatestResponse';
import { useRequests } from '../hooks/useRequests';
import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { useTauriEvent } from '../hooks/useTauriEvent';
import { useUpdateAnyRequest } from '../hooks/useUpdateAnyRequest';
import { useUpdateRequest } from '../hooks/useUpdateRequest';
import type { HttpRequest } from '../lib/models';
import { Button } from './core/Button';
import { IconButton } from './core/IconButton';
import { HStack, VStack } from './core/Stacks';
import { isResponseLoading } from '../lib/models';
import { Icon } from './core/Icon';
import { VStack } from './core/Stacks';
import { StatusTag } from './core/StatusTag';
import { DropMarker } from './DropMarker';
import { RequestActionsDropdown } from './RequestActionsDropdown';
import { ToggleThemeButton } from './ToggleThemeButton';
interface Props {
className?: string;
@@ -24,18 +29,120 @@ enum ItemTypes {
}
export const Sidebar = memo(function Sidebar({ className }: Props) {
const { hidden } = useSidebarHidden();
const sidebarRef = useRef<HTMLDivElement>(null);
const activeRequestId = useActiveRequestId();
const unorderedRequests = useRequests();
const activeRequest = useActiveRequest();
const deleteAnyRequest = useDeleteAnyRequest();
const routes = useAppRoutes();
const requests = useMemo(
() => [...unorderedRequests].sort((a, b) => a.sortPriority - b.sortPriority),
[unorderedRequests],
);
const [hasFocus, setHasFocus] = useState<boolean>(false);
const [selectedIndex, setSelectedIndex] = useState<number>();
const focusActiveRequest = useCallback(
(forcedIndex?: number) => {
const index = forcedIndex ?? requests.findIndex((r) => r.id === activeRequestId);
if (index < 0) return;
setSelectedIndex(index >= 0 ? index : undefined);
setHasFocus(true);
sidebarRef.current?.focus();
},
[activeRequestId, requests],
);
const handleSelect = useCallback(
(requestId: string) => {
const index = requests.findIndex((r) => r.id === requestId);
const request = requests[index];
if (!request) return;
routes.navigate('request', { requestId, workspaceId: request.workspaceId });
setSelectedIndex(index);
focusActiveRequest(index);
},
[focusActiveRequest, requests, routes],
);
const handleFocus = useCallback(() => {
if (hasFocus) return;
focusActiveRequest();
}, [focusActiveRequest, hasFocus]);
const handleBlur = useCallback(() => setHasFocus(false), []);
const handleDeleteKey = useCallback(
(e: KeyboardEvent) => {
if (!hasFocus) return;
e.preventDefault();
const selectedRequest = requests[selectedIndex ?? -1];
if (selectedRequest === undefined) return;
deleteAnyRequest.mutate(selectedRequest.id);
},
[deleteAnyRequest, hasFocus, requests, selectedIndex],
);
useKeyPressEvent('Backspace', handleDeleteKey);
useKeyPressEvent('Delete', handleDeleteKey);
useTauriEvent(
'focus_sidebar',
() => {
if (hidden || hasFocus) return;
// Select 0 index on focus if none selected
focusActiveRequest(selectedIndex ?? 0);
},
[focusActiveRequest, hidden, activeRequestId],
);
useKeyPressEvent('Enter', (e) => {
if (!hasFocus) return;
const request = requests[selectedIndex ?? -1];
if (!request || request.id === activeRequestId) return;
e.preventDefault();
routes.navigate('request', { requestId: request.id, workspaceId: request.workspaceId });
});
useKey(
'ArrowUp',
() => {
if (!hasFocus) return;
let newIndex = (selectedIndex ?? requests.length) - 1;
if (newIndex < 0) {
newIndex = requests.length - 1;
}
setSelectedIndex(newIndex);
},
undefined,
[hasFocus, requests, selectedIndex],
);
useKey(
'ArrowDown',
() => {
if (!hasFocus) return;
let newIndex = (selectedIndex ?? -1) + 1;
if (newIndex > requests.length - 1) {
newIndex = 0;
}
setSelectedIndex(newIndex);
},
undefined,
[hasFocus, requests, selectedIndex],
);
return (
<div className="relative h-full">
<div aria-hidden={hidden} className="relative h-full">
<div
role="menu"
aria-orientation="vertical"
dir="ltr"
ref={sidebarRef}
onFocus={handleFocus}
onBlur={handleBlur}
tabIndex={hidden ? -1 : 0}
className={classnames(className, 'h-full relative grid grid-rows-[minmax(0,1fr)_auto]')}
>
<VStack
@@ -43,23 +150,26 @@ export const Sidebar = memo(function Sidebar({ className }: Props) {
className="relative py-3 overflow-y-auto overflow-x-visible"
draggable={false}
>
<SidebarItems activeRequestId={activeRequest?.id} requests={requests} />
<SidebarItems
selectedIndex={selectedIndex}
requests={requests}
focused={hasFocus}
onSelect={handleSelect}
/>
</VStack>
<HStack className="mx-1 pb-1" alignItems="center" justifyContent="end">
<ToggleThemeButton />
</HStack>
</div>
</div>
);
});
function SidebarItems({
requests,
activeRequestId,
}: {
interface SidebarItemsProps {
requests: HttpRequest[];
activeRequestId?: string;
}) {
focused: boolean;
selectedIndex?: number;
onSelect: (requestId: string) => void;
}
function SidebarItems({ requests, focused, selectedIndex, onSelect }: SidebarItemsProps) {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const updateRequest = useUpdateAnyRequest();
@@ -110,12 +220,13 @@ function SidebarItems({
{hoveredIndex === i && <DropMarker />}
<DraggableSidebarItem
key={r.id}
selected={selectedIndex === i}
requestId={r.id}
requestName={r.name}
workspaceId={r.workspaceId}
active={r.id === activeRequestId}
onMove={handleMove}
onEnd={handleEnd}
useProminentStyles={focused}
onSelect={onSelect}
/>
</Fragment>
))}
@@ -128,16 +239,20 @@ type SidebarItemProps = {
className?: string;
requestId: string;
requestName: string;
workspaceId: string;
active?: boolean;
useProminentStyles?: boolean;
selected?: boolean;
onSelect: (requestId: string) => void;
};
const _SidebarItem = forwardRef(function SidebarItem(
{ className, requestName, requestId, workspaceId, active }: SidebarItemProps,
{ className, requestName, requestId, useProminentStyles, selected, onSelect }: SidebarItemProps,
ref: ForwardedRef<HTMLLIElement>,
) {
const latestResponse = useLatestResponse(requestId);
const updateRequest = useUpdateRequest(requestId);
const [editing, setEditing] = useState<boolean>(false);
const activeRequestId = useActiveRequestId();
const isActive = activeRequestId === requestId;
const handleSubmitNameEdit = useCallback(
async (el: HTMLInputElement) => {
@@ -152,19 +267,9 @@ const _SidebarItem = forwardRef(function SidebarItem(
el?.select();
}, []);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLElement>) => {
// Hitting enter on active request during keyboard nav will start edit
if (active && e.key === 'Enter') {
e.preventDefault();
setEditing(true);
}
},
[active],
);
const handleInputKeyDown = useCallback(
async (e: KeyboardEvent<HTMLInputElement>) => {
async (e: React.KeyboardEvent<HTMLInputElement>) => {
e.stopPropagation();
switch (e.key) {
case 'Enter':
e.preventDefault();
@@ -179,54 +284,62 @@ const _SidebarItem = forwardRef(function SidebarItem(
[handleSubmitNameEdit],
);
const handleStartEditing = useCallback(() => setEditing(true), [setEditing]);
const handleBlur = useCallback(
(e: React.FocusEvent<HTMLInputElement>) => {
handleSubmitNameEdit(e.currentTarget).catch(console.error);
},
[handleSubmitNameEdit],
);
const handleSelect = useCallback(() => {
onSelect(requestId);
}, [onSelect, requestId]);
return (
<li ref={ref} className={classnames(className, 'block group/item px-2 pb-0.5')}>
<div className="relative">
<Button
color="custom"
size="sm"
to={`/workspaces/${workspaceId}/requests/${requestId}`}
draggable={false} // Item should drag, not the link
onDoubleClick={() => setEditing(true)}
onClick={active ? () => setEditing(true) : undefined}
justify="start"
onKeyDown={handleKeyDown}
className={classnames(
editing && 'focus-within:border-focus',
active
? 'bg-highlight text-gray-900'
: 'text-gray-600 group-hover/item:text-gray-800 active:bg-highlightSecondary',
// Move out of the way when trash is shown
'group-hover/item:pr-7',
)}
>
{editing ? (
<input
ref={handleFocus}
defaultValue={requestName}
className="bg-transparent outline-none w-full"
onBlur={(e) => handleSubmitNameEdit(e.currentTarget)}
onKeyDown={handleInputKeyDown}
/>
) : (
<span className={classnames('truncate', !requestName && 'text-gray-400 italic')}>
{requestName || 'New Request'}
</span>
)}
</Button>
<RequestActionsDropdown requestId={requestId}>
<IconButton
color="custom"
size="sm"
title="Request Options"
icon="dotsH"
className={classnames(
'absolute right-0 top-0 transition-opacity !opacity-0',
'group-hover/item:!opacity-100 focus-visible:!opacity-100',
)}
<button
tabIndex={-1}
color="custom"
onClick={handleSelect}
disabled={editing}
draggable={false} // Item should drag, not the link
onDoubleClick={handleStartEditing}
data-active={isActive}
data-selected={selected}
className={classnames(
// 'outline-none',
'w-full flex items-center text-sm h-xs px-2 rounded-md transition-colors',
editing && 'ring-1 focus-within:ring-focus',
isActive && 'bg-highlight text-gray-800',
!isActive && 'text-gray-600 group-hover/item:text-gray-800 active:bg-highlightSecondary',
selected && useProminentStyles && '!bg-violet-500/20 text-gray-900',
)}
>
{editing ? (
<input
ref={handleFocus}
defaultValue={requestName}
className="bg-transparent outline-none w-full"
onBlur={handleBlur}
onKeyDown={handleInputKeyDown}
/>
</RequestActionsDropdown>
</div>
) : (
<span className={classnames('truncate', !requestName && 'text-gray-400 italic')}>
{requestName || 'New Request'}
</span>
)}
{latestResponse && (
<div className="ml-auto">
{isResponseLoading(latestResponse) ? (
<Icon spin size="sm" icon="update" />
) : (
<StatusTag className="text-2xs dark:opacity-80" response={latestResponse} />
)}
</div>
)}
</button>
</li>
);
});
@@ -239,17 +352,15 @@ type DraggableSidebarItemProps = SidebarItemProps & {
type DragItem = {
id: string;
workspaceId: string;
requestName: string;
};
const DraggableSidebarItem = memo(function DraggableSidebarItem({
requestName,
requestId,
workspaceId,
active,
onMove,
onEnd,
...props
}: DraggableSidebarItemProps) {
const ref = useRef<HTMLLIElement>(null);
@@ -271,7 +382,7 @@ const DraggableSidebarItem = memo(function DraggableSidebarItem({
const [{ isDragging }, connectDrag] = useDrag<DragItem, unknown, { isDragging: boolean }>(
() => ({
type: ItemTypes.REQUEST,
item: () => ({ id: requestId, requestName, workspaceId }),
item: () => ({ id: requestId, requestName }),
collect: (m) => ({ isDragging: m.isDragging() }),
options: { dropEffect: 'move' },
end: () => onEnd(requestId),
@@ -288,8 +399,7 @@ const DraggableSidebarItem = memo(function DraggableSidebarItem({
className={classnames(isDragging && 'opacity-20')}
requestName={requestName}
requestId={requestId}
workspaceId={workspaceId}
active={active}
{...props}
/>
);
});

View File

@@ -1,15 +1,21 @@
import { memo, useCallback } from 'react';
import { useCreateRequest } from '../hooks/useCreateRequest';
import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { useTauriEvent } from '../hooks/useTauriEvent';
import { IconButton } from './core/IconButton';
export const SidebarActions = memo(function SidebarDisplayToggle() {
export const SidebarActions = memo(function SidebarActions() {
const { hidden, toggle } = useSidebarHidden();
const createRequest = useCreateRequest({ navigateAfter: true });
const handleCreateRequest = useCallback(() => {
createRequest.mutate({ name: 'New Request' });
createRequest.mutate({});
}, [createRequest]);
useTauriEvent('new_request', () => {
createRequest.mutate({});
});
return (
<>
<IconButton

View File

@@ -1,14 +0,0 @@
import React from 'react';
import { useTheme } from '../hooks/useTheme';
import { IconButton } from './core/IconButton';
export function ToggleThemeButton() {
const { appearance, toggleAppearance } = useTheme();
return (
<IconButton
title={appearance === 'dark' ? 'Enable light mode' : 'Enable dark mode'}
icon={appearance === 'dark' ? 'moon' : 'sun'}
onClick={toggleAppearance}
/>
);
}

View File

@@ -75,7 +75,6 @@ export const UrlBar = memo(function UrlBar({ id: requestId, url, method, classNa
className="w-8 mr-0.5"
icon={loading ? 'update' : 'paperPlane'}
spin={loading}
disabled={loading}
/>
}
/>

View File

@@ -8,6 +8,7 @@ import type {
} from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useWindowSize } from 'react-use';
import { useOsInfo } from '../hooks/useOsInfo';
import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { useSidebarWidth } from '../hooks/useSidebarWidth';
import { useTauriEvent } from '../hooks/useTauriEvent';
@@ -27,7 +28,7 @@ const body = { gridArea: 'body' };
const drag = { gridArea: 'drag' };
export default function Workspace() {
const { set: setWidth, value: width, reset: resetWidth } = useSidebarWidth();
const { setWidth, width, resetWidth } = useSidebarWidth();
const { show, hide, hidden, toggle } = useSidebarHidden();
const windowSize = useWindowSize();
@@ -119,13 +120,6 @@ export default function Workspace() {
!isResizing && 'transition-all',
)}
>
<HeaderSize
data-tauri-drag-region
className="w-full bg-gray-50 border-b border-b-highlight text-gray-900"
style={head}
>
<WorkspaceHeader className="pointer-events-none" />
</HeaderSize>
{floating ? (
<Overlay open={!hidden} portalName="sidebar" onClose={hide}>
<motion.div
@@ -146,11 +140,8 @@ export default function Workspace() {
</Overlay>
) : (
<>
<div
style={side}
className={classnames('overflow-hidden bg-gray-100 border-r border-highlight')}
>
<Sidebar />
<div style={side} className={classnames('overflow-hidden bg-gray-100')}>
<Sidebar className="border-r border-highlight" />
</div>
<ResizeHandle
className="-translate-x-3"
@@ -162,6 +153,13 @@ export default function Workspace() {
/>
</>
)}
<HeaderSize
data-tauri-drag-region
className="w-full bg-gray-50 border-b border-b-highlight text-gray-900"
style={head}
>
<WorkspaceHeader className="pointer-events-none" />
</HeaderSize>
<RequestResponse style={body} />
</div>
);
@@ -172,11 +170,13 @@ interface HeaderSizeProps extends HTMLAttributes<HTMLDivElement> {
}
function HeaderSize({ className, ...props }: HeaderSizeProps) {
const platform = useOsInfo();
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',
)}
{...props}
/>

View File

@@ -1,16 +1,20 @@
import { invoke } from '@tauri-apps/api';
import classnames from 'classnames';
import { memo, useMemo } from 'react';
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
import { useConfirm } from '../hooks/useConfirm';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { useCreateWorkspace } from '../hooks/useCreateWorkspace';
import { useDeleteWorkspace } from '../hooks/useDeleteWorkspace';
import { useRoutes } from '../hooks/useRoutes';
import { usePrompt } from '../hooks/usePrompt';
import { useUpdateWorkspace } from '../hooks/useUpdateWorkspace';
import { useWorkspaces } from '../hooks/useWorkspaces';
import { Button } from './core/Button';
import type { DropdownItem } from './core/Dropdown';
import { Dropdown } from './core/Dropdown';
import { Icon } from './core/Icon';
import { InlineCode } from './core/InlineCode';
import { HStack } from './core/Stacks';
import { useDialog } from './DialogContext';
type Props = {
className?: string;
@@ -21,58 +25,125 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceDropdown({ classN
const activeWorkspace = useActiveWorkspace();
const activeWorkspaceId = activeWorkspace?.id ?? null;
const createWorkspace = useCreateWorkspace({ navigateAfter: true });
const deleteWorkspace = useDeleteWorkspace(activeWorkspaceId);
const routes = useRoutes();
const confirm = useConfirm();
const updateWorkspace = useUpdateWorkspace(activeWorkspaceId);
const deleteWorkspace = useDeleteWorkspace(activeWorkspace);
const dialog = useDialog();
const prompt = usePrompt();
const routes = useAppRoutes();
const items: DropdownItem[] = useMemo(() => {
const workspaceItems = workspaces.map((w) => ({
key: w.id,
label: w.name,
leftSlot: activeWorkspaceId === w.id ? <Icon icon="check" /> : <Icon icon="empty" />,
onSelect: () => {
if (w.id === activeWorkspaceId) return;
routes.navigate('workspace', { workspaceId: w.id });
onSelect: async () => {
dialog.show({
id: 'open-workspace',
size: 'sm',
title: 'Open Workspace',
description: (
<>
Where would you like to open <InlineCode>{w.name}</InlineCode>?
</>
),
render: ({ hide }) => {
return (
<HStack space={2} justifyContent="end" className="mt-6">
<Button
className="focus"
color="gray"
onClick={() => {
hide();
routes.navigate('workspace', { workspaceId: w.id });
}}
>
This Window
</Button>
<Button
autoFocus
className="focus"
color="gray"
rightSlot={<Icon icon="openNewWindow" />}
onClick={async () => {
hide();
await invoke('new_window', {
url: routes.paths.workspace({ workspaceId: w.id }),
});
}}
>
New Window
</Button>
</HStack>
);
},
});
},
}));
const activeWorkspaceItems: DropdownItem[] =
workspaces.length <= 1
? []
: [
...workspaceItems,
{
type: 'separator',
label: activeWorkspace?.name,
},
];
return [
...workspaceItems,
...activeWorkspaceItems,
{
type: 'separator',
label: 'Actions',
},
{
label: 'New Workspace',
leftSlot: <Icon icon="plus" />,
onSelect: () => createWorkspace.mutate({ name: 'New Workspace' }),
},
{
label: 'Delete Workspace',
leftSlot: <Icon icon="trash" />,
key: 'rename',
label: 'Rename',
leftSlot: <Icon icon="pencil" />,
onSelect: async () => {
const confirmed = await confirm({
title: 'Delete Workspace',
variant: 'delete',
const name = await prompt({
title: 'Rename Workspace',
description: (
<>
Are you sure you want to delete <InlineCode>{activeWorkspace?.name}</InlineCode>?
Enter a new name for <InlineCode>{activeWorkspace?.name}</InlineCode>
</>
),
name: 'name',
label: 'Name',
defaultValue: activeWorkspace?.name,
});
if (confirmed) {
deleteWorkspace.mutate();
}
updateWorkspace.mutate({ name });
},
},
{
key: 'delete',
label: 'Delete',
leftSlot: <Icon icon="trash" />,
onSelect: deleteWorkspace.mutate,
variant: 'danger',
},
{ type: 'separator' },
{
key: 'create-workspace',
label: 'Create Workspace',
leftSlot: <Icon icon="plus" />,
onSelect: async () => {
const name = await prompt({
name: 'name',
label: 'Name',
defaultValue: '',
description: 'Enter a name for the new workspace',
title: 'Create Workspace',
});
createWorkspace.mutate({ name });
},
},
];
}, [
workspaces,
activeWorkspaceId,
routes,
createWorkspace,
confirm,
activeWorkspace?.name,
deleteWorkspace,
deleteWorkspace.mutate,
dialog,
routes,
prompt,
updateWorkspace,
createWorkspace,
]);
return (

View File

@@ -3,6 +3,7 @@ import { memo } from 'react';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { IconButton } from './core/IconButton';
import { HStack } from './core/Stacks';
import { RecentRequestsDropdown } from './RecentRequestsDropdown';
import { RequestActionsDropdown } from './RequestActionsDropdown';
import { SidebarActions } from './SidebarActions';
import { WorkspaceActionsDropdown } from './WorkspaceActionsDropdown';
@@ -13,6 +14,7 @@ interface Props {
export const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Props) {
const activeRequest = useActiveRequest();
return (
<HStack
justifyContent="center"
@@ -23,8 +25,8 @@ export const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Prop
<SidebarActions />
<WorkspaceActionsDropdown className="pointer-events-auto" />
</HStack>
<div className="flex-[2] text-center text-gray-800 text-sm truncate pointer-events-none">
{activeRequest?.name}
<div className="pointer-events-none">
<RecentRequestsDropdown />
</div>
<div className="flex-1 flex justify-end -mr-2 pointer-events-none">
{activeRequest && (

View File

@@ -1,10 +1,10 @@
import { Navigate } from 'react-router-dom';
import { useRoutes } from '../hooks/useRoutes';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { useWorkspaces } from '../hooks/useWorkspaces';
import { Heading } from './core/Heading';
export default function Workspaces() {
const routes = useRoutes();
const routes = useAppRoutes();
const workspaces = useWorkspaces();
const workspace = workspaces[0];

View File

@@ -1,7 +1,6 @@
import classnames from 'classnames';
import type { HTMLAttributes } from 'react';
import type { HTMLAttributes, ReactNode } from 'react';
import { forwardRef, memo, useMemo } from 'react';
import { Link } from 'react-router-dom';
import { Icon } from './Icon';
const colorStyles = {
@@ -15,8 +14,7 @@ const colorStyles = {
danger: 'bg-red-400 text-white enabled:hocus:bg-red-500 ring-red-500/50',
};
export type ButtonProps = HTMLAttributes<HTMLElement> & {
to?: string;
export type ButtonProps = HTMLAttributes<HTMLButtonElement> & {
color?: keyof typeof colorStyles;
isLoading?: boolean;
size?: 'sm' | 'md' | 'xs';
@@ -25,19 +23,22 @@ export type ButtonProps = HTMLAttributes<HTMLElement> & {
forDropdown?: boolean;
disabled?: boolean;
title?: string;
rightSlot?: ReactNode;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const _Button = forwardRef<any, ButtonProps>(function Button(
const _Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{
to,
isLoading,
className,
children,
forDropdown,
color,
type = 'button',
justify = 'center',
size = 'md',
rightSlot,
disabled,
...props
}: ButtonProps,
ref,
@@ -46,10 +47,10 @@ const _Button = forwardRef<any, ButtonProps>(function Button(
() =>
classnames(
className,
'opacity-90 hover:opacity-100',
'outline-none whitespace-nowrap',
'flex-shrink-0 outline-none whitespace-nowrap',
'focus-visible-or-class:ring',
'rounded-md flex items-center',
disabled ? 'pointer-events-none opacity-disabled' : 'pointer-events-auto',
colorStyles[color || 'default'],
justify === 'start' && 'justify-start',
justify === 'center' && 'justify-center',
@@ -57,25 +58,17 @@ const _Button = forwardRef<any, ButtonProps>(function Button(
size === 'sm' && 'h-sm px-2.5 text-sm',
size === 'xs' && 'h-xs px-2 text-sm',
),
[color, size, justify, className],
[className, disabled, color, justify, size],
);
if (typeof to === 'string') {
return (
<Link ref={ref} to={to} className={classes} {...props}>
{children}
{forDropdown && <Icon icon="chevronDown" className="ml-1 -mr-1" />}
</Link>
);
} else {
return (
<button ref={ref} className={classes} {...props}>
{isLoading && <Icon icon="update" size={size} className="animate-spin mr-1" />}
{children}
{forDropdown && <Icon icon="chevronDown" size={size} className="ml-1 -mr-1" />}
</button>
);
}
return (
<button ref={ref} type={type} className={classes} disabled={disabled} {...props}>
{isLoading && <Icon icon="update" size={size} className="animate-spin mr-1" />}
{children}
{rightSlot && <div className="ml-1">{rightSlot}</div>}
{forDropdown && <Icon icon="chevronDown" size={size} className="ml-1 -mr-1" />}
</button>
);
});
export const Button = memo(_Button);

View File

@@ -1,17 +1,21 @@
import classnames from 'classnames';
interface Props {
count: number;
className?: string;
}
export function CountBadge({ count }: Props) {
export function CountBadge({ count, className }: Props) {
if (count === 0) return null;
return (
<>
<div
aria-hidden
className="opacity-70 border border-highlight text-3xs rounded mb-0.5 px-1 ml-1 h-4 font-mono"
>
{count}
</div>
</>
<div
aria-hidden
className={classnames(
className,
'opacity-70 border border-highlight text-3xs rounded mb-0.5 px-1 ml-1 h-4 font-mono',
)}
>
{count}
</div>
);
}

View File

@@ -55,13 +55,20 @@ export function Dialog({
className,
'relative bg-gray-50 pointer-events-auto',
'max-h-[80vh] p-5 rounded-lg overflow-auto',
'dark:border border-gray-200 shadow-md shadow-black/10',
'dark:border border-highlight shadow shadow-black/10',
size === 'sm' && 'w-[25rem]',
size === 'md' && 'w-[45rem]',
size === 'full' && 'w-[80vw]',
size === 'dynamic' && 'min-w-[30vw] max-w-[80vw]',
)}
>
<Heading className="text-xl font-semibold w-full" id={titleId}>
{title}
</Heading>
{description && <p id={descriptionId}>{description}</p>}
<div className="mt-4">{children}</div>
{/*Put close at the end so that it's the last thing to be tabbed to*/}
{!hideX && (
<IconButton
onClick={onClose}
@@ -72,11 +79,6 @@ export function Dialog({
className="ml-auto absolute right-1 top-1"
/>
)}
<Heading className="text-xl font-semibold w-full" id={titleId}>
{title}
</Heading>
{description && <p id={descriptionId}>{description}</p>}
<div className="mt-6">{children}</div>
</motion.div>
</div>
</div>

View File

@@ -2,9 +2,20 @@ import classnames from 'classnames';
import FocusTrap from 'focus-trap-react';
import { motion } from 'framer-motion';
import type { CSSProperties, HTMLAttributes, MouseEvent, ReactElement, ReactNode } from 'react';
import { Children, cloneElement, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useKeyPressEvent } from 'react-use';
import React, {
Children,
cloneElement,
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import { useKey, useKeyPressEvent } from 'react-use';
import { Portal } from '../Portal';
import { Button } from './Button';
import { Separator } from './Separator';
import { VStack } from './Stacks';
@@ -15,8 +26,10 @@ export type DropdownItemSeparator = {
export type DropdownItem =
| {
key: string;
type?: 'default';
label: string;
label: ReactNode;
variant?: 'danger';
disabled?: boolean;
hidden?: boolean;
leftSlot?: ReactNode;
@@ -30,21 +43,52 @@ export interface DropdownProps {
items: DropdownItem[];
}
export function Dropdown({ children, items }: DropdownProps) {
export interface DropdownRef {
isOpen: boolean;
open: (activeIndex?: number) => void;
toggle: () => void;
close?: () => void;
next?: () => void;
prev?: () => void;
select?: () => void;
}
export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown(
{ children, items }: DropdownProps,
ref,
) {
const [open, setOpen] = useState<boolean>(false);
const ref = useRef<HTMLButtonElement>(null);
const [defaultSelectedIndex, setDefaultSelectedIndex] = useState<number>();
const buttonRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<Omit<DropdownRef, 'open'>>(null);
useImperativeHandle(ref, () => ({
...menuRef.current,
isOpen: open,
toggle: () => setOpen(!open),
open: (activeIndex?: number) => {
if (activeIndex === undefined) {
setDefaultSelectedIndex(undefined);
} else {
setDefaultSelectedIndex(activeIndex >= 0 ? activeIndex : items.length + activeIndex);
}
setOpen(true);
},
}));
const child = useMemo(() => {
const existingChild = Children.only(children);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const props: any = {
...existingChild.props,
ref,
ref: buttonRef,
'aria-haspopup': 'true',
onClick:
existingChild.props?.onClick ??
((e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setDefaultSelectedIndex(undefined);
setOpen((o) => !o);
}),
};
@@ -53,37 +97,48 @@ export function Dropdown({ children, items }: DropdownProps) {
const handleClose = useCallback(() => {
setOpen(false);
ref.current?.focus();
buttonRef.current?.focus();
}, []);
useEffect(() => {
ref.current?.setAttribute('aria-expanded', open.toString());
buttonRef.current?.setAttribute('aria-expanded', open.toString());
}, [open]);
const triggerRect = useMemo(() => {
if (!open) return null;
return ref.current?.getBoundingClientRect();
return buttonRef.current?.getBoundingClientRect();
}, [open]);
return (
<>
{child}
{open && triggerRect && (
<Menu items={items} triggerRect={triggerRect} onClose={handleClose} />
<Menu
ref={menuRef}
defaultSelectedIndex={defaultSelectedIndex}
items={items}
triggerRect={triggerRect}
onClose={handleClose}
/>
)}
</>
);
}
});
interface MenuProps {
className?: string;
defaultSelectedIndex?: number;
items: DropdownProps['items'];
triggerRect: DOMRect;
onClose: () => void;
}
function Menu({ className, items, onClose, triggerRect }: MenuProps) {
const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuProps>(function Menu(
{ className, items, onClose, triggerRect, defaultSelectedIndex }: MenuProps,
ref,
) {
const containerRef = useRef<HTMLDivElement | null>(null);
const [selectedIndex, setSelectedIndex] = useState<number | null>(defaultSelectedIndex ?? null);
const [menuStyles, setMenuStyles] = useState<CSSProperties>({});
// Calculate the max height so we can scroll
@@ -94,13 +149,23 @@ function Menu({ className, items, onClose, triggerRect }: MenuProps) {
setMenuStyles({ maxHeight: windowBox.height - menuBox.top - 5 });
}, []);
// Close menu on space bar
const handleMenuKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === ' ') {
e.preventDefault();
onClose();
}
},
[onClose],
);
useKeyPressEvent('Escape', (e) => {
e.preventDefault();
onClose();
});
useKeyPressEvent('ArrowUp', (e) => {
e.preventDefault();
const handlePrev = useCallback(() => {
setSelectedIndex((currIndex) => {
let nextIndex = (currIndex ?? 0) - 1;
const maxTries = items.length;
@@ -115,10 +180,9 @@ function Menu({ className, items, onClose, triggerRect }: MenuProps) {
}
return nextIndex;
});
});
}, [items]);
useKeyPressEvent('ArrowDown', (e) => {
e.preventDefault();
const handleNext = useCallback(() => {
setSelectedIndex((currIndex) => {
let nextIndex = (currIndex ?? -1) + 1;
const maxTries = items.length;
@@ -133,8 +197,44 @@ function Menu({ className, items, onClose, triggerRect }: MenuProps) {
}
return nextIndex;
});
}, [items]);
useKey('ArrowUp', (e) => {
e.preventDefault();
handlePrev();
});
useKey('ArrowDown', (e) => {
e.preventDefault();
handleNext();
});
const handleSelect = useCallback(
(i: DropdownItem) => {
onClose();
setSelectedIndex(null);
if (i.type !== 'separator') {
i.onSelect?.();
}
},
[onClose],
);
useImperativeHandle(
ref,
() => ({
close: onClose,
prev: handlePrev,
next: handleNext,
select: () => {
const item = items[selectedIndex ?? -1] ?? null;
if (!item) return;
handleSelect(item);
},
}),
[handleNext, handlePrev, handleSelect, items, onClose, selectedIndex],
);
const { containerStyles, triangleStyles } = useMemo<{
containerStyles: CSSProperties;
triangleStyles: CSSProperties;
@@ -153,17 +253,6 @@ function Menu({ className, items, onClose, triggerRect }: MenuProps) {
return { containerStyles, triangleStyles };
}, [triggerRect]);
const handleSelect = useCallback(
(i: DropdownItem) => {
onClose();
setSelectedIndex(null);
if (i.type !== 'separator') {
i.onSelect?.();
}
},
[onClose],
);
const handleFocus = useCallback(
(i: DropdownItem) => {
const index = items.findIndex((item) => item === i) ?? null;
@@ -172,7 +261,7 @@ function Menu({ className, items, onClose, triggerRect }: MenuProps) {
[items],
);
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
if (items.length === 0) return null;
return (
<Portal name="dropdown">
@@ -181,6 +270,7 @@ function Menu({ className, items, onClose, triggerRect }: MenuProps) {
<div tabIndex={-1} aria-hidden className="fixed inset-0" onClick={onClose} />
<motion.div
tabIndex={0}
onKeyDown={handleMenuKeyDown}
initial={{ opacity: 0, y: -5, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
role="menu"
@@ -218,7 +308,7 @@ function Menu({ className, items, onClose, triggerRect }: MenuProps) {
focused={i === selectedIndex}
onFocus={handleFocus}
onSelect={handleSelect}
key={i + item.label}
key={item.key}
item={item}
/>
);
@@ -230,7 +320,7 @@ function Menu({ className, items, onClose, triggerRect }: MenuProps) {
</FocusTrap>
</Portal>
);
}
});
interface MenuItemProps {
className?: string;
@@ -257,23 +347,33 @@ function MenuItem({ className, focused, onFocus, item, onSelect, ...props }: Men
if (item.type === 'separator') return <Separator className="my-1.5" />;
return (
<button
<Button
ref={initRef}
size="xs"
tabIndex={-1}
onMouseEnter={(e) => e.currentTarget.focus()}
onMouseLeave={(e) => e.currentTarget.blur()}
onFocus={handleFocus}
onClick={handleClick}
justify="start"
className={classnames(
className,
'min-w-[8rem] outline-none px-2 mx-1.5 h-7 flex items-center text-sm text-gray-700 whitespace-nowrap pr-4',
'min-w-[8rem] outline-none px-2 mx-1.5 flex text-sm text-gray-700 whitespace-nowrap',
'focus:bg-highlight focus:text-gray-900 rounded',
item.variant === 'danger' && 'text-red-600',
)}
{...props}
>
{item.leftSlot && <div className="w-6">{item.leftSlot}</div>}
<div>{item.label}</div>
{item.leftSlot && <div className="pr-2 flex justify-start">{item.leftSlot}</div>}
<div
className={classnames(
// Add padding on right when no right slot, for some visual balance
!item.rightSlot && 'pr-4',
)}
>
{item.label}
</div>
{item.rightSlot && <div className="ml-auto pl-3">{item.rightSlot}</div>}
</button>
</Button>
);
}

View File

@@ -0,0 +1,25 @@
interface Props {
millis: number;
}
export function DurationTag({ millis }: Props) {
let num;
let unit;
if (millis > 1000 * 60) {
num = millis / 1000 / 60;
unit = 'min';
} else if (millis > 1000) {
num = millis / 1000;
unit = 's';
} else {
num = millis;
unit = 'ms';
}
return (
<span title={`${millis} milliseconds`}>
{Math.round(num * 10) / 10} {unit}
</span>
);
}

View File

@@ -39,7 +39,7 @@
/* Style gutters */
.cm-gutters {
@apply border-0 text-gray-500/50 opacity-95;
@apply border-0 text-gray-500/50;
.cm-gutterElement {
@apply cursor-default;
@@ -183,20 +183,16 @@
@apply bg-highlight text-gray-900;
}
& > ul > li:hover {
@apply text-gray-800;
}
.cm-completionIcon {
@apply text-sm flex items-center pb-0.5;
@apply text-sm flex items-center pb-0.5 flex-shrink-0;
}
.cm-completionLabel {
@apply text-gray-700;
}
.cm-completionDetail {
@apply ml-auto;
@apply ml-auto pl-6;
}
}
}

View File

@@ -24,10 +24,10 @@ export interface EditorProps {
type?: 'text' | 'password';
className?: string;
heightMode?: 'auto' | 'full';
contentType?: string;
contentType?: string | null;
forceUpdateKey?: string;
autoFocus?: boolean;
defaultValue?: string;
defaultValue?: string | null;
placeholder?: string;
tooltipContainer?: HTMLElement;
useTemplating?: boolean;
@@ -35,6 +35,7 @@ export interface EditorProps {
onFocus?: () => void;
onBlur?: () => void;
singleLine?: boolean;
wrapLines?: boolean;
format?: (v: string) => string;
autocomplete?: GenericCompletionConfig;
actions?: ReactNode;
@@ -59,6 +60,7 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
format,
autocomplete,
actions,
wrapLines,
}: EditorProps,
ref,
) {
@@ -93,6 +95,15 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
cm.current?.view.dispatch({ effects: effect });
}, [placeholder]);
// Update wrap lines
const wrapLinesCompartment = useRef(new Compartment());
useEffect(() => {
if (cm.current === null) return;
const ext = wrapLines ? [EditorView.lineWrapping] : [];
const effect = wrapLinesCompartment.current.reconfigure(ext);
cm.current?.view.dispatch({ effects: effect });
}, [wrapLines]);
// Update language extension when contentType changes
useEffect(() => {
if (cm.current === null) return;
@@ -126,16 +137,15 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
doc: `${defaultValue ?? ''}`,
extensions: [
languageCompartment.of(langExt),
placeholderCompartment.current.of(
placeholderExt(placeholderElFromText(placeholder ?? '')),
),
placeholderCompartment.current.of([]),
wrapLinesCompartment.current.of([]),
...getExtensions({
container,
readOnly,
singleLine,
onChange: handleChange,
onFocus: handleFocus,
onBlur: handleBlur,
readOnly,
singleLine,
}),
],
});
@@ -223,7 +233,9 @@ function getExtensions({
keymap.of(singleLine ? defaultKeymap.filter((k) => k.key !== 'Enter') : defaultKeymap),
...(singleLine ? [singleLineExt()] : []),
...(!singleLine ? [multiLineExtensions] : []),
...(readOnly ? [EditorState.readOnly.of(true)] : []),
...(readOnly
? [EditorState.readOnly.of(true), EditorView.contentAttributes.of({ tabindex: '-1' })]
: []),
...(singleLine
? [
EditorView.domEventHandlers({

View File

@@ -3,18 +3,8 @@ import type { CompletionContext } from '@codemirror/autocomplete';
const openTag = '${[ ';
const closeTag = ' ]}';
const variables = [
{ name: 'DOMAIN' },
{ name: 'BASE_URL' },
{ name: 'CONTENT_THINGY' },
{ name: 'TOKEN' },
{ name: 'PROJECT_ID' },
{ name: 'DUMMY' },
{ name: 'DUMMY_2' },
{ name: 'STRIPE_PUB_KEY' },
{ name: 'RAILWAY_TOKEN' },
{ name: 'SECRET' },
{ name: 'PORT' },
const variables: { name: string }[] = [
// TODO: Put variables here
];
const MIN_MATCH_VAR = 2;

View File

@@ -1,15 +1,21 @@
import classnames from 'classnames';
import type { HTMLAttributes } from 'react';
export function HotKey({ children }: HTMLAttributes<HTMLSpanElement>) {
interface Props {
modifier: 'Meta' | 'Control' | 'Shift';
keyName: string;
}
const keys: Record<Props['modifier'], string> = {
Control: '⌃',
Meta: '⌘',
Shift: '⇧',
};
export function HotKey({ modifier, keyName }: Props) {
return (
<span
className={classnames(
'bg-gray-400 bg-opacity-20 px-1.5 py-0.5 rounded text-sm',
'font-mono text-gray-500 tracking-widest',
)}
>
{children}
<span className={classnames('text-sm text-gray-600')}>
{keys[modifier]}
{keyName}
</span>
);
}

View File

@@ -22,7 +22,9 @@ import {
MagicWandIcon,
MagnifyingGlassIcon,
MoonIcon,
OpenInNewWindowIcon,
PaperPlaneIcon,
Pencil2Icon,
PlusCircledIcon,
PlusIcon,
QuestionMarkIcon,
@@ -35,6 +37,7 @@ import {
UpdateIcon,
} from '@radix-ui/react-icons';
import classnames from 'classnames';
import type { HTMLAttributes } from 'react';
import { memo } from 'react';
import { ReactComponent as LeftPanelHiddenIcon } from '../../assets/icons/LeftPanelHiddenIcon.svg';
import { ReactComponent as LeftPanelVisibleIcon } from '../../assets/icons/LeftPanelVisibleIcon.svg';
@@ -64,7 +67,9 @@ const icons = {
magicWand: MagicWandIcon,
magnifyingGlass: MagnifyingGlassIcon,
moon: MoonIcon,
openNewWindow: OpenInNewWindowIcon,
paperPlane: PaperPlaneIcon,
pencil: Pencil2Icon,
plus: PlusIcon,
plusCircle: PlusCircledIcon,
question: QuestionMarkIcon,
@@ -76,7 +81,7 @@ const icons = {
triangleRight: TriangleRightIcon,
update: UpdateIcon,
x: Cross2Icon,
empty: () => <span />,
empty: (props: HTMLAttributes<HTMLSpanElement>) => <span {...props} />,
};
export interface IconProps {

View File

@@ -1,6 +1,6 @@
import classnames from 'classnames';
import type { MouseEvent } from 'react';
import { forwardRef, memo, useCallback } from 'react';
import { forwardRef, useCallback } from 'react';
import { useTimedBoolean } from '../../hooks/useTimedBoolean';
import type { ButtonProps } from './Button';
import { Button } from './Button';
@@ -15,7 +15,7 @@ type Props = IconProps &
title: string;
};
const _IconButton = forwardRef<HTMLButtonElement, Props>(function IconButton(
export const IconButton = forwardRef<HTMLButtonElement, Props>(function IconButton(
{
showConfirm,
icon,
@@ -32,7 +32,7 @@ const _IconButton = forwardRef<HTMLButtonElement, Props>(function IconButton(
) {
const [confirmed, setConfirmed] = useTimedBoolean();
const handleClick = useCallback(
(e: MouseEvent<HTMLElement>) => {
(e: MouseEvent<HTMLButtonElement>) => {
if (showConfirm) setConfirmed();
onClick?.(e);
},
@@ -69,5 +69,3 @@ const _IconButton = forwardRef<HTMLButtonElement, Props>(function IconButton(
</Button>
);
});
export const IconButton = memo(_IconButton);

View File

@@ -57,12 +57,12 @@ export const Input = forwardRef<EditorView | undefined, InputProps>(function Inp
const [currentValue, setCurrentValue] = useState(defaultValue ?? '');
const [focused, setFocused] = useState(false);
const handleOnFocus = useCallback(() => {
const handleFocus = useCallback(() => {
setFocused(true);
onFocus?.();
}, [onFocus]);
const handleOnBlur = useCallback(() => {
const handleBlur = useCallback(() => {
setFocused(false);
onBlur?.();
}, [onBlur]);
@@ -107,8 +107,8 @@ export const Input = forwardRef<EditorView | undefined, InputProps>(function Inp
className={classnames(
containerClassName,
'relative w-full rounded-md text-gray-900',
'border border-highlight',
focused && 'border-focus',
'border',
focused ? 'border-focus' : 'border-highlight',
!isValid && '!border-invalid',
size === 'md' && 'h-md leading-md',
size === 'sm' && 'h-sm leading-sm',
@@ -125,8 +125,8 @@ export const Input = forwardRef<EditorView | undefined, InputProps>(function Inp
placeholder={placeholder}
onChange={handleChange}
className={inputClassName}
onFocus={handleOnFocus}
onBlur={handleOnBlur}
onFocus={handleFocus}
onBlur={handleBlur}
{...props}
/>
{type === 'password' && (

View File

@@ -137,7 +137,7 @@ export const PairEditor = memo(function PairEditor({
className={classnames(
className,
'@container',
'pb-2 grid',
'pb-2 grid overflow-auto max-h-full',
// Move over the width of the drag handle
'-ml-3',
)}

View File

@@ -32,6 +32,7 @@ export function RadioDropdown<T = string | null>({
return item;
} else {
return {
key: item.label,
label: item.label,
shortLabel: item.shortLabel,
onSelect: () => onChange(item.value),

View File

@@ -0,0 +1,28 @@
interface Props {
contentLength: number;
}
export function SizeTag({ contentLength }: Props) {
let num;
let unit;
if (contentLength > 1000 * 1000 * 1000) {
num = contentLength / 1000 / 1000 / 1000;
unit = 'GB';
} else if (contentLength > 1000 * 1000) {
num = contentLength / 1000 / 1000;
unit = 'MB';
} else if (contentLength > 1000) {
num = contentLength / 1000;
unit = 'KB';
} else {
num = contentLength;
unit = 'B';
}
return (
<span title={`${contentLength} bytes`}>
{Math.round(num * 10) / 10} {unit}
</span>
);
}

View File

@@ -1,23 +0,0 @@
import classnames from 'classnames';
import type { ReactNode } from 'react';
interface Props {
statusCode: number;
children: ReactNode;
}
export function StatusColor({ statusCode, children }: Props) {
return (
<span
className={classnames(
statusCode >= 100 && statusCode < 200 && 'text-green-600',
statusCode >= 200 && statusCode < 300 && 'text-green-600',
statusCode >= 300 && statusCode < 400 && 'text-pink-600',
statusCode >= 400 && statusCode < 500 && 'text-orange-600',
statusCode >= 500 && statusCode < 600 && 'text-red-600',
)}
>
{children}
</span>
);
}

View File

@@ -0,0 +1,29 @@
import classnames from 'classnames';
import type { HttpResponse } from '../../lib/models';
interface Props {
response: Pick<HttpResponse, 'status' | 'statusReason' | 'error'>;
className?: string;
showReason?: boolean;
}
export function StatusTag({ response, className, showReason }: Props) {
const { status, error } = response;
const label = error ? 'ERR' : status;
return (
<span
className={classnames(
className,
'font-mono',
status >= 0 && status < 100 && 'text-red-600',
status >= 100 && status < 200 && 'text-green-600',
status >= 200 && status < 300 && 'text-green-600',
status >= 300 && status < 400 && 'text-pink-600',
status >= 400 && status < 500 && 'text-orange-600',
status >= 500 && 'text-red-600',
)}
>
{label} {showReason && response.statusReason && response.statusReason}
</span>
);
}

View File

@@ -73,16 +73,18 @@ export function Tabs({
aria-label={label}
className={classnames(
tabListClassName,
'flex items-center overflow-x-auto hide-scrollbars mt-1 mb-2',
'flex items-center overflow-x-auto overflow-y-visible hide-scrollbars mt-1 mb-2',
// Give space for button focus states within overflow boundary.
'px-2 -mx-2',
'-mx-5 pl-3 py-1',
)}
>
<HStack space={1} className="flex-shrink-0">
<HStack space={2} className="flex-shrink-0">
{tabs.map((t) => {
const isActive = t.value === value;
// const btnClassName = classnames(isActive ? 'bg-highlightSecondary' : 'text-gray-600');
const btnClassName = classnames(isActive ? '' : 'text-gray-600', '!px-0 mr-4 ml-[1px]');
const btnClassName = classnames(
isActive ? '' : 'text-gray-600 hover:text-gray-800',
'!px-2 ml-[1px]',
);
if ('options' in t) {
const option = t.options.items.find(
@@ -147,7 +149,7 @@ export const TabContent = memo(function TabContent({
<div
tabIndex={-1}
data-tab={value}
className={classnames(className, 'tab-content', 'overflow-auto hidden w-full h-full')}
className={classnames(className, 'tab-content', 'hidden w-full h-full')}
>
{children}
</div>

View File

@@ -0,0 +1,39 @@
import classnames from 'classnames';
import Papa from 'papaparse';
import { useMemo } from 'react';
import { useResponseBodyText } from '../../hooks/useResponseBodyText';
import type { HttpResponse } from '../../lib/models';
interface Props {
response: HttpResponse;
className?: string;
}
export function CsvViewer({ response, className }: Props) {
const body = useResponseBodyText(response);
const parsed = useMemo(() => {
if (body === null) return null;
return Papa.parse<string[]>(body);
}, [body]);
if (parsed === null) return null;
return (
<div className="overflow-auto h-full">
<table className={classnames(className, 'text-sm')}>
<tbody>
{parsed.data.map((row, i) => (
<tr key={i} className={classnames('border-l border-t', i > 0 && 'border-b')}>
{row.map((col, j) => (
<td key={j} className="border-r px-1.5">
{col}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,23 @@
import { convertFileSrc } from '@tauri-apps/api/tauri';
import classnames from 'classnames';
import type { HttpResponse } from '../../lib/models';
interface Props {
response: HttpResponse;
className?: string;
}
export function ImageViewer({ response, className }: Props) {
if (response.bodyPath === null) {
return <div>Empty response body</div>;
}
const src = convertFileSrc(response.bodyPath);
return (
<img
src={src}
alt="Response preview"
className={classnames(className, 'max-w-full max-h-full')}
/>
);
}

View File

@@ -0,0 +1,26 @@
import { useResponseBodyText } from '../../hooks/useResponseBodyText';
import { useResponseContentType } from '../../hooks/useResponseContentType';
import { tryFormatJson } from '../../lib/formatters';
import type { HttpResponse } from '../../lib/models';
import { Editor } from '../core/Editor';
interface Props {
response: HttpResponse;
pretty: boolean;
}
export function TextViewer({ response, pretty }: Props) {
const contentType = useResponseContentType(response);
const rawBody = useResponseBodyText(response) ?? '';
const body = pretty && contentType?.includes('json') ? tryFormatJson(rawBody) : rawBody;
return (
<Editor
readOnly
forceUpdateKey={body}
className="bg-gray-50 dark:!bg-gray-100"
defaultValue={body}
contentType={contentType}
/>
);
}

View File

@@ -1,27 +1,29 @@
import { useMemo } from 'react';
import { useResponseBodyText } from '../../hooks/useResponseBodyText';
import type { HttpResponse } from '../../lib/models';
interface Props {
body: string;
contentType: string;
url: string;
response: HttpResponse;
}
export function Webview({ body, url, contentType }: Props) {
export function WebPageViewer({ response }: Props) {
const { url } = response;
const body = useResponseBodyText(response) ?? '';
const contentForIframe: string | undefined = useMemo(() => {
if (!contentType.includes('html')) return;
if (body.includes('<head>')) {
return body.replace(/<head>/gi, `<head><base href="${url}"/>`);
}
return body;
}, [url, body, contentType]);
}, [url, body]);
return (
<div className="px-2 pb-2">
<div className="h-full pb-3">
<iframe
title="Response preview"
srcDoc={contentForIframe}
sandbox="allow-scripts allow-same-origin"
className="h-full w-full rounded-md border border-gray-100/20"
className="h-full w-full rounded border border-highlightSecondary"
/>
</div>
);

View File

@@ -3,7 +3,7 @@ import { Button } from '../components/core/Button';
import { HStack } from '../components/core/Stacks';
export interface ConfirmProps {
hide: () => void;
onHide: () => void;
onResult: (result: boolean) => void;
variant?: 'delete' | 'confirm';
}
@@ -18,29 +18,23 @@ const confirmButtonTexts: Record<NonNullable<ConfirmProps['variant']>, string> =
confirm: 'Confirm',
};
export function Confirm({ hide, onResult, variant = 'confirm' }: ConfirmProps) {
const focusRef = (el: HTMLButtonElement | null) => {
setTimeout(() => {
el?.focus();
});
};
export function Confirm({ onHide, onResult, variant = 'confirm' }: ConfirmProps) {
const handleHide = () => {
onResult(false);
hide();
onHide();
};
const handleSuccess = () => {
onResult(true);
hide();
onHide();
};
return (
<HStack space={2} justifyContent="end">
<HStack space={2} justifyContent="end" className="mt-6">
<Button className="focus" color="gray" onClick={handleHide}>
Cancel
</Button>
<Button className="focus" ref={focusRef} color={colors[variant]} onClick={handleSuccess}>
<Button autoFocus className="focus" color={colors[variant]} onClick={handleSuccess}>
{confirmButtonTexts[variant]}
</Button>
</HStack>

48
src-web/hooks/Prompt.tsx Normal file
View File

@@ -0,0 +1,48 @@
import type { FormEvent } from 'react';
import { useCallback, useState } from 'react';
import { Button } from '../components/core/Button';
import type { InputProps } from '../components/core/Input';
import { Input } from '../components/core/Input';
import { HStack, VStack } from '../components/core/Stacks';
export interface PromptProps {
onHide: () => void;
onResult: (value: string) => void;
label: InputProps['label'];
name: InputProps['name'];
defaultValue: InputProps['defaultValue'];
}
export function Prompt({ onHide, label, name, defaultValue, onResult }: PromptProps) {
const [value, setValue] = useState<string>(defaultValue ?? '');
const handleSubmit = useCallback(
(e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
onHide();
onResult(value);
},
[onHide, onResult, value],
);
return (
<form onSubmit={handleSubmit}>
<VStack space={6}>
<Input
hideLabel
label={label}
name={name}
defaultValue={defaultValue}
onChange={setValue}
/>
<HStack space={2} justifyContent="end">
<Button className="focus" color="gray" onClick={onHide}>
Cancel
</Button>
<Button type="submit" className="focus" color="primary">
Save
</Button>
</HStack>
</VStack>
</form>
);
}

View File

@@ -1,5 +1,5 @@
import { useParams } from 'react-router-dom';
import type { RouteParamsRequest } from './useRoutes';
import type { RouteParamsRequest } from './useAppRoutes';
export function useActiveRequestId(): string | null {
const { requestId } = useParams<RouteParamsRequest>();

View File

@@ -1,5 +1,5 @@
import { useParams } from 'react-router-dom';
import type { RouteParamsWorkspace } from './useRoutes';
import type { RouteParamsWorkspace } from './useAppRoutes';
export function useActiveWorkspaceId(): string | null {
const { workspaceId } = useParams<RouteParamsWorkspace>();

View File

@@ -1,3 +1,4 @@
import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
export type RouteParamsWorkspace = {
@@ -25,19 +26,22 @@ export const routePaths = {
},
};
export function useRoutes() {
export function useAppRoutes() {
const navigate = useNavigate();
return {
navigate<T extends keyof typeof routePaths>(
path: T,
...params: Parameters<(typeof routePaths)[T]>
) {
// Not sure how to make TS work here, but it's good from the
// outside caller perspective.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const resolvedPath = routePaths[path](...(params as any));
navigate(resolvedPath);
},
paths: routePaths,
};
return useMemo(
() => ({
navigate<T extends keyof typeof routePaths>(
path: T,
...params: Parameters<(typeof routePaths)[T]>
) {
// Not sure how to make TS work here, but it's good from the
// outside caller perspective.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const resolvedPath = routePaths[path](...(params as any));
navigate(resolvedPath);
},
paths: routePaths,
}),
[navigate],
);
}

View File

@@ -20,7 +20,7 @@ export function useConfirm() {
description,
hideX: true,
size: 'sm',
render: ({ hide }) => Confirm({ hide, variant, onResult }),
render: ({ hide }) => Confirm({ onHide: hide, variant, onResult }),
});
});
}

View File

@@ -2,12 +2,12 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import type { HttpRequest } from '../lib/models';
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
import { useAppRoutes } from './useAppRoutes';
import { requestsQueryKey, useRequests } from './useRequests';
import { useRoutes } from './useRoutes';
export function useCreateRequest({ navigateAfter }: { navigateAfter: boolean }) {
const workspaceId = useActiveWorkspaceId();
const routes = useRoutes();
const routes = useAppRoutes();
const requests = useRequests();
const queryClient = useQueryClient();
@@ -16,8 +16,9 @@ export function useCreateRequest({ navigateAfter }: { navigateAfter: boolean })
if (workspaceId === null) {
throw new Error("Cannot create request when there's no active workspace");
}
const sortPriority = maxSortPriority(requests) + 1000;
return invoke('create_request', { sortPriority, workspaceId, ...patch });
patch.name = patch.name || 'New Request';
patch.sortPriority = patch.sortPriority || maxSortPriority(requests) + 1000;
return invoke('create_request', { workspaceId, ...patch });
},
onSuccess: async (request) => {
queryClient.setQueryData<HttpRequest[]>(

View File

@@ -1,11 +1,11 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import type { Workspace } from '../lib/models';
import { useRoutes } from './useRoutes';
import { useAppRoutes } from './useAppRoutes';
import { workspacesQueryKey } from './useWorkspaces';
export function useCreateWorkspace({ navigateAfter }: { navigateAfter: boolean }) {
const routes = useRoutes();
const routes = useAppRoutes();
const queryClient = useQueryClient();
return useMutation<Workspace, unknown, Pick<Workspace, 'name'>>({
mutationFn: (patch) => {

View File

@@ -0,0 +1,40 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import { InlineCode } from '../components/core/InlineCode';
import type { HttpRequest } from '../lib/models';
import { getRequest } from '../lib/store';
import { useConfirm } from './useConfirm';
import { requestsQueryKey } from './useRequests';
import { responsesQueryKey } from './useResponses';
export function useDeleteAnyRequest() {
const queryClient = useQueryClient();
const confirm = useConfirm();
return useMutation<HttpRequest | null, string, string>({
mutationFn: async (id) => {
const request = await getRequest(id);
const confirmed = await confirm({
title: 'Delete Request',
variant: 'delete',
description: (
<>
Permanently delete <InlineCode>{request?.name}</InlineCode>?
</>
),
});
if (!confirmed) return null;
return invoke('delete_request', { requestId: id });
},
onSuccess: async (request) => {
// Was it cancelled?
if (request === null) return;
const { workspaceId, id: requestId } = request;
queryClient.setQueryData(responsesQueryKey({ requestId }), []); // Responses were deleted
queryClient.setQueryData<HttpRequest[]>(requestsQueryKey({ workspaceId }), (requests) =>
(requests ?? []).filter((r) => r.id !== requestId),
);
},
});
}

View File

@@ -1,25 +0,0 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import type { HttpRequest } from '../lib/models';
import { useActiveRequestId } from './useActiveRequestId';
import { requestsQueryKey } from './useRequests';
import { responsesQueryKey } from './useResponses';
import { useRoutes } from './useRoutes';
export function useDeleteRequest(id: string | null) {
const queryClient = useQueryClient();
const activeRequestId = useActiveRequestId();
const routes = useRoutes();
return useMutation<HttpRequest, string>({
mutationFn: async () => invoke('delete_request', { requestId: id }),
onSuccess: async ({ workspaceId, id: requestId }) => {
queryClient.setQueryData(responsesQueryKey({ requestId }), []); // Responses were deleted
queryClient.setQueryData<HttpRequest[]>(requestsQueryKey({ workspaceId }), (requests) =>
(requests ?? []).filter((r) => r.id !== requestId),
);
if (activeRequestId === requestId) {
routes.navigate('workspace', { workspaceId });
}
},
});
}

View File

@@ -0,0 +1,40 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import { InlineCode } from '../components/core/InlineCode';
import type { HttpRequest } from '../lib/models';
import { getRequest } from '../lib/store';
import { useConfirm } from './useConfirm';
import { requestsQueryKey } from './useRequests';
import { responsesQueryKey } from './useResponses';
export function useDeleteRequest(id: string | null) {
const queryClient = useQueryClient();
const confirm = useConfirm();
return useMutation<HttpRequest | null, string>({
mutationFn: async () => {
const request = await getRequest(id);
const confirmed = await confirm({
title: 'Delete Request',
variant: 'delete',
description: (
<>
Permanently delete <InlineCode>{request?.name}</InlineCode>?
</>
),
});
if (!confirmed) return null;
return invoke('delete_request', { requestId: id });
},
onSuccess: async (request) => {
// Was it cancelled?
if (request === null) return;
const { workspaceId, id: requestId } = request;
queryClient.setQueryData(responsesQueryKey({ requestId }), []); // Responses were deleted
queryClient.setQueryData<HttpRequest[]>(requestsQueryKey({ workspaceId }), (requests) =>
(requests ?? []).filter((r) => r.id !== requestId),
);
},
});
}

View File

@@ -1,20 +1,37 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import { InlineCode } from '../components/core/InlineCode';
import type { Workspace } from '../lib/models';
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
import { useAppRoutes } from './useAppRoutes';
import { useConfirm } from './useConfirm';
import { requestsQueryKey } from './useRequests';
import { useRoutes } from './useRoutes';
import { workspacesQueryKey } from './useWorkspaces';
export function useDeleteWorkspace(id: string | null) {
export function useDeleteWorkspace(workspace: Workspace | null) {
const queryClient = useQueryClient();
const activeWorkspaceId = useActiveWorkspaceId();
const routes = useRoutes();
return useMutation<Workspace, string>({
mutationFn: () => {
return invoke('delete_workspace', { id });
const routes = useAppRoutes();
const confirm = useConfirm();
return useMutation<Workspace | null, string>({
mutationFn: async () => {
const confirmed = await confirm({
title: 'Delete Workspace',
variant: 'delete',
description: (
<>
Permanently delete <InlineCode>{workspace?.name}</InlineCode>?
</>
),
});
if (!confirmed) return null;
return invoke('delete_workspace', { id: workspace?.id });
},
onSuccess: async ({ id: workspaceId }) => {
onSuccess: async (workspace) => {
if (workspace === null) return;
const { id: workspaceId } = workspace;
queryClient.setQueryData<Workspace[]>(workspacesQueryKey({}), (workspaces) =>
workspaces?.filter((workspace) => workspace.id !== workspaceId),
);

View File

@@ -2,8 +2,8 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import type { HttpRequest } from '../lib/models';
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
import { useAppRoutes } from './useAppRoutes';
import { requestsQueryKey } from './useRequests';
import { useRoutes } from './useRoutes';
export function useDuplicateRequest({
id,
@@ -13,7 +13,7 @@ export function useDuplicateRequest({
navigateAfter: boolean;
}) {
const workspaceId = useActiveWorkspaceId();
const routes = useRoutes();
const routes = useAppRoutes();
const queryClient = useQueryClient();
return useMutation<HttpRequest, string>({
mutationFn: async () => {

View File

@@ -35,6 +35,10 @@ export function useIntrospectGraphQL(baseRequest: HttpRequest) {
);
}
if (response.body === null) {
return Promise.reject(new Error('Empty body returned in response'));
}
const { data } = JSON.parse(response.body);
return buildClientSchema(data);
},

View File

@@ -1,8 +1,8 @@
import { useResponses } from './useResponses';
import { isResponseLoading } from '../lib/models';
import { useLatestResponse } from './useLatestResponse';
export function useIsResponseLoading(requestId: string | null): boolean {
const responses = useResponses(requestId);
const response = responses[responses.length - 1];
if (!response) return false;
return !(response.body || response.status || response.error);
const response = useLatestResponse(requestId);
if (response === null) return false;
return isResponseLoading(response);
}

View File

@@ -0,0 +1,7 @@
import type { HttpResponse } from '../lib/models';
import { useResponses } from './useResponses';
export function useLatestResponse(requestId: string | null): HttpResponse | null {
const responses = useResponses(requestId);
return responses[0] ?? null;
}

View File

@@ -0,0 +1,12 @@
import { useQuery } from '@tanstack/react-query';
import type { OsType } from '@tauri-apps/api/os';
import { type } from '@tauri-apps/api/os';
export function useOsInfo() {
return useQuery<{ osType: OsType }>({
queryKey: ['platform'],
queryFn: async () => {
return { osType: await type() };
},
}).data;
}

View File

@@ -0,0 +1,30 @@
import type { DialogProps } from '../components/core/Dialog';
import { useDialog } from '../components/DialogContext';
import type { PromptProps } from './Prompt';
import { Prompt } from './Prompt';
export function usePrompt() {
const dialog = useDialog();
return ({
title,
description,
name,
label,
defaultValue,
}: {
title: DialogProps['title'];
description?: DialogProps['description'];
name: PromptProps['name'];
label: PromptProps['label'];
defaultValue: PromptProps['defaultValue'];
}) =>
new Promise((onResult: PromptProps['onResult']) => {
dialog.show({
title,
description,
hideX: true,
size: 'sm',
render: ({ hide }) => Prompt({ onHide: hide, onResult, name, label, defaultValue }),
});
});
}

View File

@@ -0,0 +1,36 @@
import { useEffect } from 'react';
import { createGlobalState, useEffectOnce, useLocalStorage } from 'react-use';
import { useActiveRequestId } from './useActiveRequestId';
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
const useHistoryState = createGlobalState<string[]>([]);
export function useRecentRequests() {
const activeWorkspaceId = useActiveWorkspaceId();
const activeRequestId = useActiveRequestId();
const [history, setHistory] = useHistoryState();
const [lsState, setLSState] = useLocalStorage<string[]>(
'recent_requests::' + activeWorkspaceId,
[],
);
useEffect(() => {
setLSState(history);
}, [history, setLSState]);
useEffectOnce(() => {
if (lsState) {
setHistory(lsState);
}
});
useEffect(() => {
setHistory((h: string[]) => {
if (activeRequestId === null) return h;
const withoutCurrentRequest = h.filter((id) => id !== activeRequestId);
return [activeRequestId, ...withoutCurrentRequest];
});
}, [activeRequestId, setHistory]);
return history.slice(1);
}

View File

@@ -0,0 +1,20 @@
import { useQuery } from '@tanstack/react-query';
import { readBinaryFile } from '@tauri-apps/api/fs';
import type { HttpResponse } from '../lib/models';
export function useResponseBodyBlob(response: HttpResponse) {
return useQuery<Uint8Array | null>({
enabled: response != null,
queryKey: ['response-body-binary', response?.updatedAt],
initialData: null,
queryFn: async () => {
if (response.body) {
return Uint8Array.of(...response.body);
}
if (response.bodyPath) {
return readBinaryFile(response.bodyPath);
}
return null;
},
}).data;
}

View File

@@ -0,0 +1,22 @@
import { useQuery } from '@tanstack/react-query';
import { readTextFile } from '@tauri-apps/api/fs';
import type { HttpResponse } from '../lib/models';
export function useResponseBodyText(response: HttpResponse) {
return useQuery<string | null>({
queryKey: ['response-body-text', response?.updatedAt],
initialData: null,
queryFn: async () => {
if (response.body) {
const uint8Array = Uint8Array.of(...response.body);
return new TextDecoder().decode(uint8Array);
}
if (response.bodyPath) {
return await readTextFile(response.bodyPath);
}
return null;
},
}).data;
}

View File

@@ -0,0 +1,9 @@
import { useMemo } from 'react';
import type { HttpResponse } from '../lib/models';
export function useResponseContentType(response: HttpResponse | null): string | null {
return useMemo(
() => response?.headers.find((h) => h.name.toLowerCase() === 'content-type')?.value ?? null,
[response],
);
}

View File

@@ -1,15 +1,8 @@
import { useKeyValue } from './useKeyValue';
import { useLocalStorage } from 'react-use';
export function useResponseViewMode(requestId?: string): [string | undefined, () => void] {
const v = useKeyValue<string>({
namespace: 'app',
key: ['response_view_mode', requestId ?? 'n/a'],
defaultValue: 'pretty',
});
const DEFAULT_VIEW_MODE = 'pretty';
const toggle = () => {
v.set(v.value === 'pretty' ? 'raw' : 'pretty');
};
return [v.value, toggle];
export function useResponseViewMode(requestId?: string): [string, (m: 'pretty' | 'raw') => void] {
const [value, setValue] = useLocalStorage<'pretty' | 'raw'>(`response_view_mode::${requestId}`);
return [value ?? DEFAULT_VIEW_MODE, setValue];
}

View File

@@ -1,11 +1,13 @@
import { useMemo } from 'react';
import { NAMESPACE_NO_SYNC } from '../lib/keyValueStore';
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
import { useKeyValue } from './useKeyValue';
export function useSidebarHidden() {
const activeWorkspaceId = useActiveWorkspaceId();
const { set, value } = useKeyValue<boolean>({
namespace: NAMESPACE_NO_SYNC,
key: 'sidebar_hidden',
key: ['sidebar_hidden', activeWorkspaceId ?? 'n/a'],
defaultValue: false,
});

View File

@@ -1,10 +1,10 @@
import { NAMESPACE_NO_SYNC } from '../lib/keyValueStore';
import { useKeyValue } from './useKeyValue';
import { useCallback, useMemo } from 'react';
import { useLocalStorage } from 'react-use';
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
export function useSidebarWidth() {
return useKeyValue<number>({
namespace: NAMESPACE_NO_SYNC,
key: 'sidebar_width',
defaultValue: 200,
});
const activeWorkspaceId = useActiveWorkspaceId();
const [width, setWidth] = useLocalStorage<number>(`sidebar_width::${activeWorkspaceId}`, 220);
const resetWidth = useCallback(() => setWidth(220), [setWidth]);
return useMemo(() => ({ width, setWidth, resetWidth }), [width, setWidth, resetWidth]);
}

View File

@@ -0,0 +1,29 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import type { Workspace } from '../lib/models';
import { getWorkspace } from '../lib/store';
import { workspacesQueryKey } from './useWorkspaces';
export function useUpdateWorkspace(id: string | null) {
const queryClient = useQueryClient();
return useMutation<void, unknown, Partial<Workspace> | ((w: Workspace) => Workspace)>({
mutationFn: async (v) => {
const workspace = await getWorkspace(id);
if (workspace == null) {
throw new Error("Can't update a null workspace");
}
const newWorkspace = typeof v === 'function' ? v(workspace) : { ...workspace, ...v };
await invoke('update_workspace', { workspace: newWorkspace });
},
onMutate: async (v) => {
const workspace = await getWorkspace(id);
if (workspace === null) return;
const newWorkspace = typeof v === 'function' ? v(workspace) : { ...workspace, ...v };
queryClient.setQueryData<Workspace[]>(workspacesQueryKey(workspace), (workspaces) =>
(workspaces ?? []).map((w) => (w.id === newWorkspace.id ? newWorkspace : w)),
);
},
});
}

View File

@@ -1,17 +0,0 @@
import { getKeyValue, NAMESPACE_NO_SYNC, setKeyValue } from './keyValueStore';
export async function getLastLocation(): Promise<string> {
return getKeyValue({ namespace: NAMESPACE_NO_SYNC, key: 'last_location', fallback: '/' });
}
export async function setLastLocation(pathname: string): Promise<void> {
return setKeyValue({ namespace: NAMESPACE_NO_SYNC, key: 'last_location', value: pathname });
}
export async function syncLastLocation(): Promise<void> {
const lastPathname = await getLastLocation();
if (lastPathname !== window.location.pathname) {
console.log(`Redirecting to last location: ${lastPathname}`);
window.location.assign(lastPathname);
}
}

View File

@@ -64,7 +64,9 @@ export interface HttpResponse extends BaseModel {
readonly workspaceId: string;
readonly model: 'http_response';
readonly requestId: string;
readonly body: string;
readonly body: number[] | null;
readonly bodyPath: string | null;
readonly contentLength: number | null;
readonly error: string;
readonly status: number;
readonly elapsed: number;
@@ -72,3 +74,7 @@ export interface HttpResponse extends BaseModel {
readonly url: string;
readonly headers: HttpHeader[];
}
export function isResponseLoading(response: HttpResponse): boolean {
return !(response.body || response.status || response.error);
}

View File

@@ -1,5 +1,5 @@
import { invoke } from '@tauri-apps/api';
import type { HttpRequest } from './models';
import type { HttpRequest, Workspace } from './models';
export async function getRequest(id: string | null): Promise<HttpRequest | null> {
if (id === null) return null;
@@ -9,3 +9,12 @@ export async function getRequest(id: string | null): Promise<HttpRequest | null>
}
return request;
}
export async function getWorkspace(id: string | null): Promise<Workspace | null> {
if (id === null) return null;
const workspace: Workspace = (await invoke('get_workspace', { id })) ?? null;
if (workspace == null) {
return null;
}
return workspace;
}

View File

@@ -12,11 +12,11 @@ const darkTheme: AppTheme = {
colors: {
gray: '#6b5b98',
red: '#ff417b',
orange: '#ff9411',
orange: '#fd9014',
yellow: '#e8d13f',
green: '#43e76f',
green: '#3fd265',
blue: '#219dff',
pink: '#f670f6',
pink: '#ff6dff',
violet: '#b176ff',
},
},
@@ -31,11 +31,11 @@ const lightTheme: AppTheme = {
colors: {
gray: '#7f8fb0',
red: '#ec3f87',
orange: '#ff8b00',
orange: '#ff8000',
yellow: '#e7cf24',
green: '#00d365',
blue: '#0090ff',
pink: '#f670f6',
pink: '#ea6cea',
violet: '#ac6cff',
},
},
@@ -50,14 +50,6 @@ export function getAppearance(): Appearance {
return getPreferredAppearance();
}
export function toggleAppearance(): Appearance {
const currentTheme =
document.documentElement.getAttribute('data-appearance') ?? getPreferredAppearance();
const newAppearance = currentTheme === 'dark' ? 'light' : 'dark';
setAppearance(newAppearance);
return newAppearance;
}
export function setAppearance(a?: Appearance) {
const appearance = a ?? getPreferredAppearance();
const theme = appearance === 'dark' ? darkTheme : lightTheme;

View File

@@ -33,20 +33,24 @@
}
/* Style the scrollbars */
::-webkit-scrollbar-corner,
::-webkit-scrollbar {
@apply w-1.5 h-1.5;
}
* {
::-webkit-scrollbar-corner,
::-webkit-scrollbar {
@apply w-1.5 h-1.5;
}
.scrollbar-track,
::-webkit-scrollbar-corner,
::-webkit-scrollbar {
@apply bg-transparent;
}
.scrollbar-track,
::-webkit-scrollbar-corner,
::-webkit-scrollbar {
@apply bg-transparent;
}
.scrollbar-thumb,
::-webkit-scrollbar-thumb {
@apply bg-gray-500/30 hover:bg-gray-500/50 rounded-full;
&:hover {
&.scrollbar-thumb,
&::-webkit-scrollbar-thumb {
@apply bg-gray-500/30 hover:bg-gray-500/50 rounded-full;
}
}
}
iframe {

View File

@@ -2,12 +2,10 @@ import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './components/App';
import { getKeyValue } from './lib/keyValueStore';
import { syncLastLocation } from './lib/lastLocation';
import { getPreferredAppearance, setAppearance } from './lib/theme/window';
import './main.css';
setAppearance(await getKeyValue({ key: 'appearance', fallback: getPreferredAppearance() }));
await syncLastLocation();
// root holds our app's root DOM Element:
createRoot(document.getElementById('root') as HTMLElement).render(

View File

@@ -16,13 +16,13 @@ module.exports = {
"xs": "0.8rem"
},
height: {
"xs": "1.5rem",
"xs": "1.75rem",
"sm": "2.0rem",
"md": "2.5rem"
},
lineHeight: {
// HACK: Minus 2 to account for borders inside inputs
"xs": "calc(1.5rem - 2px)",
"xs": "calc(1.75rem - 2px)",
"sm": "calc(2.0rem - 2px)",
"md": "calc(2.5rem - 2px)"
},