Compare commits
306 Commits
codex/cli-
...
v2023.0.19
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c456fd4d5 | ||
|
|
38c247e350 | ||
|
|
0c8f72124a | ||
|
|
80ed6b1525 | ||
|
|
4424b3f208 | ||
|
|
2c75abce09 | ||
|
|
4e15eb197f | ||
|
|
a7544b4f8c | ||
|
|
d126aad172 | ||
|
|
acc5c0de50 | ||
|
|
3391da111d | ||
|
|
e37ce96956 | ||
|
|
c51831c975 | ||
|
|
180aa39de4 | ||
|
|
3bd780782e | ||
|
|
f9ba2f79c2 | ||
|
|
d9493de2be | ||
|
|
bc9a623742 | ||
|
|
532edbf274 | ||
|
|
1585692328 | ||
|
|
083f565b12 | ||
|
|
f7f7438c9e | ||
|
|
19934a93bb | ||
|
|
577cfe5bdc | ||
|
|
43ac6afae1 | ||
|
|
8cc11703d3 | ||
|
|
4f7a116378 | ||
|
|
513793d9ce | ||
|
|
67f32b6734 | ||
|
|
66813d67fe | ||
|
|
a38691ed53 | ||
|
|
deeefdcfbf | ||
|
|
db292511b1 | ||
|
|
1a5334c1ce | ||
|
|
11002abe39 | ||
|
|
d922dcb062 | ||
|
|
6fcaa18e86 | ||
|
|
7664c941dd | ||
|
|
6f5cb528c6 | ||
|
|
ebb78922f0 | ||
|
|
2285fe9f1c | ||
|
|
38ba8625d8 | ||
|
|
ab5681c7ad | ||
|
|
f66dcb9267 | ||
|
|
1b6cfbac77 | ||
|
|
4c27e788ea | ||
|
|
769da0b052 | ||
|
|
6b60c86300 | ||
|
|
30c1b5e8c7 | ||
|
|
10af9b6f99 | ||
|
|
aa8c066f2d | ||
|
|
b913b74449 | ||
|
|
b71adce50b | ||
|
|
0fbb44c701 | ||
|
|
de335e8637 | ||
|
|
2999f63a4c | ||
|
|
2abc5e6f0b | ||
|
|
639de4321e | ||
|
|
b3c461afdd | ||
|
|
7d154800a0 | ||
|
|
b48ed0399e | ||
|
|
c5d6e7d74a | ||
|
|
e82f915363 | ||
|
|
3128e9ce76 | ||
|
|
bc0e86757c | ||
|
|
fec99916c2 | ||
|
|
3b5d059b11 | ||
|
|
c3fe2acc8a | ||
|
|
4d002c412b | ||
|
|
46d152b5f1 | ||
|
|
25fa81ebbc | ||
|
|
7c2de3c360 | ||
|
|
3a3b187cd0 | ||
|
|
3226bbe083 | ||
|
|
a1e4e0e6c9 | ||
|
|
b3aa8b893b | ||
|
|
f057139634 | ||
|
|
71a2b11ab4 | ||
|
|
587254a0e7 | ||
|
|
9f4de66f3c | ||
|
|
b0d8908724 | ||
|
|
15c22d98c6 | ||
|
|
3105ae0edc | ||
|
|
11a89f06c1 | ||
|
|
9cbe24e740 | ||
|
|
bfbed13b8f | ||
|
|
2268de6321 | ||
|
|
dd99aa7fcd | ||
|
|
be436bb706 | ||
|
|
bd48726f44 | ||
|
|
10bea83f98 | ||
|
|
8122b4fb84 | ||
|
|
3ae57fb2d8 | ||
|
|
6dc3eecca4 | ||
|
|
9d1d732154 | ||
|
|
8a117415b7 | ||
|
|
d36623ebc9 | ||
|
|
94a3ae3696 | ||
|
|
2836a28988 | ||
|
|
946d7dc89e | ||
|
|
af6300f18b | ||
|
|
905cb4b18e | ||
|
|
305ed09547 | ||
|
|
643356bad3 | ||
|
|
e458675627 | ||
|
|
91e3853692 | ||
|
|
5f0876a136 | ||
|
|
3a38127fb4 | ||
|
|
f3b6070235 | ||
|
|
5e6e78eb9e | ||
|
|
9b66a1d1a8 | ||
|
|
e954d0d7bc | ||
|
|
dab2df7e79 | ||
|
|
bc40e22008 | ||
|
|
eef262c398 | ||
|
|
8eab6e14db | ||
|
|
ded33a110a | ||
|
|
e448a7602a | ||
|
|
4c22215ca5 | ||
|
|
4f501abb72 | ||
|
|
b2dcc38982 | ||
|
|
11b719955b | ||
|
|
d563ac63db | ||
|
|
6d826064c6 | ||
|
|
d30b9d6518 | ||
|
|
8da3364d0f | ||
|
|
07c372b7f5 | ||
|
|
7e01f38253 | ||
|
|
ba637009a7 | ||
|
|
da7388e510 | ||
|
|
3ec88fc896 | ||
|
|
1c9381b2bd | ||
|
|
06349b8d5b | ||
|
|
6dc7dc6ad2 | ||
|
|
f981a15ec3 | ||
|
|
8b648c0301 | ||
|
|
83ce09075b | ||
|
|
168dfb9f6b | ||
|
|
9b8961c23d | ||
|
|
89bca42ee6 | ||
|
|
07d2a43a17 | ||
|
|
c84f2afd09 | ||
|
|
df4dbaecc8 | ||
|
|
d9bf03cefe | ||
|
|
39223e8d89 | ||
|
|
67925e18b2 | ||
|
|
89ad65513d | ||
|
|
90166ddfa3 | ||
|
|
0981b23faf | ||
|
|
664f3b4d87 | ||
|
|
dc97b91a4e | ||
|
|
d310272d19 | ||
|
|
f1be3f01e1 | ||
|
|
c57b6e1d73 | ||
|
|
a938dc45f0 | ||
|
|
bb139744a1 | ||
|
|
3aa3e09552 | ||
|
|
74abfd21b8 | ||
|
|
e703817ba2 | ||
|
|
80dd1e457b | ||
|
|
ea9f8d3ab2 | ||
|
|
fa222bdf12 | ||
|
|
45b360dabd | ||
|
|
5923399359 | ||
|
|
f4600f3e90 | ||
|
|
f883837685 | ||
|
|
b58bc409f0 | ||
|
|
e893e539bb | ||
|
|
90294fbb5d | ||
|
|
ae65f222bc | ||
|
|
1b9813fb4c | ||
|
|
b708b5ae41 | ||
|
|
df136fa915 | ||
|
|
f8329f5b8d | ||
|
|
21141090de | ||
|
|
c0d9740a7d | ||
|
|
afcf630443 | ||
|
|
1fe2c9826a | ||
|
|
7272b80a3f | ||
|
|
92114b7368 | ||
|
|
f39d3e7eed | ||
|
|
cbe0d27a5e | ||
|
|
cd39699467 | ||
|
|
b3ea67aacf | ||
|
|
db4ed9797c | ||
|
|
1ea7d7d685 | ||
|
|
2df725b57a | ||
|
|
74e6648249 | ||
|
|
1026350d9c | ||
|
|
98fb87874d | ||
|
|
41fc3afdc1 | ||
|
|
83dbf46ba4 | ||
|
|
0b2e35bdde | ||
|
|
d90a7331c9 | ||
|
|
264e64a996 | ||
|
|
8915915c47 | ||
|
|
951ed787fa | ||
|
|
64ef6b0c22 | ||
|
|
ef18377b3c | ||
|
|
5904b6fded | ||
|
|
f4401e77bb | ||
|
|
efa5455a7b | ||
|
|
619c8d9e72 | ||
|
|
bdf89ac288 | ||
|
|
debd3c8185 | ||
|
|
f81a3ae8e7 | ||
|
|
7d4e9894c3 | ||
|
|
4bf22d8a60 | ||
|
|
8be4971a23 | ||
|
|
359e916b73 | ||
|
|
68058f3e41 | ||
|
|
0c6fa3e634 | ||
|
|
0fa25c6335 | ||
|
|
5684479f1d | ||
|
|
2d1603601c | ||
|
|
f5394b2210 | ||
|
|
833db5df06 | ||
|
|
525ac7e980 | ||
|
|
44a747c80a | ||
|
|
2056e7f40a | ||
|
|
9b6c1ad364 | ||
|
|
34987bcacb | ||
|
|
b62c11222a | ||
|
|
b3cee3ace3 | ||
|
|
222c054c95 | ||
|
|
46f18a2491 | ||
|
|
f2ca8e2753 | ||
|
|
b0d243c378 | ||
|
|
6161fb86c8 | ||
|
|
b09cc91fe5 | ||
|
|
ef1638cbb3 | ||
|
|
00ef8743f2 | ||
|
|
68222659e3 | ||
|
|
69420a4bba | ||
|
|
0161bbaeb1 | ||
|
|
948dbfe3cc | ||
|
|
338ba8b189 | ||
|
|
ca4655b441 | ||
|
|
bf37499428 | ||
|
|
0b94b57e2a | ||
|
|
fc40aead98 | ||
|
|
7d7f934e6a | ||
|
|
d5fbf4d622 | ||
|
|
e4f6c919dc | ||
|
|
4d806ff2b1 | ||
|
|
bf8f12274f | ||
|
|
f4f438d9fe | ||
|
|
2434f373be | ||
|
|
2bb2061f97 | ||
|
|
2c011a5c2a | ||
|
|
f66b0ccea1 | ||
|
|
665dd8447d | ||
|
|
1b61ce31e6 | ||
|
|
ef4d960698 | ||
|
|
b6d557b632 | ||
|
|
b700bd356c | ||
|
|
620dd7d3ef | ||
|
|
6575121902 | ||
|
|
7c1755a0dc | ||
|
|
8ad301a666 | ||
|
|
ae24cd4939 | ||
|
|
7152e1845e | ||
|
|
96c1dd4081 | ||
|
|
87c7b3a663 | ||
|
|
c1be46a539 | ||
|
|
4655e0018b | ||
|
|
da5ba2e3be | ||
|
|
aaf95f565f | ||
|
|
f32b984e77 | ||
|
|
548aa4c7cd | ||
|
|
0ccceaac77 | ||
|
|
70f534f1d8 | ||
|
|
61fe95b300 | ||
|
|
915e0e8613 | ||
|
|
aace2580da | ||
|
|
3d36905664 | ||
|
|
0d671423da | ||
|
|
aebfcb9437 | ||
|
|
be7ef7beb1 | ||
|
|
d77ed0c5cc | ||
|
|
e57e7bcec5 | ||
|
|
a637842ce4 | ||
|
|
fc54ec49af | ||
|
|
5c43d8510a | ||
|
|
83f84ded8d | ||
|
|
5658da34a2 | ||
|
|
38e8ef6535 | ||
|
|
8c89b06238 | ||
|
|
d85c021305 | ||
|
|
83bb18df03 | ||
|
|
93105a3e89 | ||
|
|
ba3b899115 | ||
|
|
fcfbc1d1da | ||
|
|
72486b448c | ||
|
|
7dea1b7870 | ||
|
|
4de2c496c9 | ||
|
|
9e1393a392 | ||
|
|
0901690ed6 | ||
|
|
95303648cc | ||
|
|
1dbb08c045 | ||
|
|
00a7d9a180 | ||
|
|
6c549dc086 | ||
|
|
dc368e326a | ||
|
|
e42188a627 | ||
|
|
7a6a337eff | ||
|
|
3907344884 |
@@ -1,25 +1,37 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
extends: [
|
extends: [
|
||||||
'eslint:recommended',
|
"eslint:recommended",
|
||||||
'plugin:react/recommended',
|
"plugin:react/recommended",
|
||||||
'plugin:import/recommended',
|
"plugin:react-hooks/recommended",
|
||||||
'plugin:jsx-a11y/recommended',
|
"plugin:import/recommended",
|
||||||
'plugin:@typescript-eslint/recommended',
|
"plugin:jsx-a11y/recommended",
|
||||||
'eslint-config-prettier',
|
"plugin:@typescript-eslint/recommended",
|
||||||
],
|
"eslint-config-prettier"
|
||||||
ignorePatterns: ['src-tauri/**/*'],
|
],
|
||||||
settings: {
|
parser: "@typescript-eslint/parser",
|
||||||
react: {
|
parserOptions: {
|
||||||
version: 'detect',
|
project: ["./tsconfig.json"]
|
||||||
},
|
},
|
||||||
'import/resolver': {
|
ignorePatterns: ["src-tauri/**/*"],
|
||||||
node: {
|
settings: {
|
||||||
paths: ['src-web'],
|
react: {
|
||||||
extensions: ['.js', '.jsx', '.ts', '.tsx'],
|
version: "detect"
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
"react/react-in-jsx-scope": "off",
|
|
||||||
},
|
},
|
||||||
|
"import/resolver": {
|
||||||
|
node: {
|
||||||
|
paths: ["src-web"],
|
||||||
|
extensions: [".ts", ".tsx"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
"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"
|
||||||
|
}]
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
69
.github/workflows/artifacts.yml
vendored
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
name: Generate Artifacts
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: [ v* ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-artifacts:
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- os: macos-12
|
||||||
|
target: aarch64-apple-darwin
|
||||||
|
- os: macos-latest
|
||||||
|
target: x86_64-apple-darwin
|
||||||
|
- os: windows-2022
|
||||||
|
target: x86_64-pc-windows-msvc
|
||||||
|
- 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:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
./src-tauri/target
|
||||||
|
key: ${{ runner.os }}-cargo-${{ hashFiles('src-tauri/Cargo.lock') }}
|
||||||
|
- uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: 18
|
||||||
|
cache: 'npm'
|
||||||
|
- name: install dependencies (ubuntu only)
|
||||||
|
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
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
- name: Run tests
|
||||||
|
run: npm test
|
||||||
|
# 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 }}
|
||||||
|
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||||
|
ENABLE_CODE_SIGNING: ${{ secrets.APPLE_CERTIFICATE }}
|
||||||
|
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||||
|
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||||
|
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||||
|
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||||
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
|
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||||
|
with:
|
||||||
|
tagName: 'v__VERSION__'
|
||||||
|
releaseName: 'Release __VERSION__'
|
||||||
|
releaseBody: '<!-- Release Notes -->'
|
||||||
|
releaseDraft: true
|
||||||
|
prerelease: false
|
||||||
|
args: '--target ${{ matrix.target }}'
|
||||||
2
.gitignore
vendored
@@ -23,4 +23,4 @@ dist-ssr
|
|||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
.rsw
|
*.sqlite
|
||||||
|
|||||||
12
.run/Dev Desktop.run.xml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="Dev Desktop" type="js.build_tools.npm">
|
||||||
|
<package-json value="$PROJECT_DIR$/package.json" />
|
||||||
|
<command value="run" />
|
||||||
|
<scripts>
|
||||||
|
<script value="tauri-dev" />
|
||||||
|
</scripts>
|
||||||
|
<node-interpreter value="project" />
|
||||||
|
<envs />
|
||||||
|
<method v="2" />
|
||||||
|
</configuration>
|
||||||
|
</component>
|
||||||
17
README.md
@@ -1,3 +1,16 @@
|
|||||||
# Tauri REST Client
|
# Yaak Network Toolkit
|
||||||
|
|
||||||
It's a REST client, yo.
|
The most fun you'll ever have working with APIs.
|
||||||
|
|
||||||
|
## Common Commands
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Start dev app
|
||||||
|
npm run tauri-dev
|
||||||
|
|
||||||
|
# Migration commands
|
||||||
|
cd src-tauri
|
||||||
|
cargo sqlx migrate add <name>
|
||||||
|
cargo sqlx migrate run --database-url 'sqlite://db.sqlite?mode=rw'
|
||||||
|
cargo sqlx prepare --database-url 'sqlite://db.sqlite'
|
||||||
|
```
|
||||||
|
|||||||
BIN
design/Icons.afdesign
Normal file
BIN
design/logo.afdesign
Normal file
35
index.html
@@ -1,15 +1,28 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<title>Yaak App</title>
|
||||||
<title>Tauri + React + TS</title>
|
<!-- <script src="http://localhost:8097"></script>-->
|
||||||
</head>
|
<style>
|
||||||
|
body {
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
<body>
|
@media (prefers-color-scheme: dark) {
|
||||||
<div id="root"></div>
|
body {
|
||||||
<div id="radix-portal"></div>
|
background-color: black;
|
||||||
<script type="module" src="/src-web/main.tsx"></script>
|
}
|
||||||
</body>
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<div id="cm-portal" class="cm-portal"></div>
|
||||||
|
<div id="react-portal"></div>
|
||||||
|
<div id="radix-portal" class="cm-portal"></div>
|
||||||
|
<script type="module" src="/src-web/main.tsx"></script>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
6937
package-lock.json
generated
90
package.json
@@ -1,51 +1,85 @@
|
|||||||
{
|
{
|
||||||
"name": "tauri-app",
|
"name": "yaak-app",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "rsw build && tsc && vite build",
|
"tauri-dev": "YAAK_ENV=development tauri dev",
|
||||||
"dev": "vite",
|
"tauri-build": "tauri build",
|
||||||
"lint": "eslint . --ext .ts,.tsx",
|
"tauri": "tauri",
|
||||||
"preview": "vite preview",
|
"build": "npm run build:frontend",
|
||||||
"tauri-dev": "concurrently -n app,rsw \"tauri dev\" \"rsw watch\""
|
"dev": "vite dev",
|
||||||
|
"lint": "tsc && eslint . --ext .ts,.tsx",
|
||||||
|
"build:icon": "tauri icon src-tauri/icons/icon.png",
|
||||||
|
"build:frontend": "vite build",
|
||||||
|
"test": "vitest",
|
||||||
|
"coverage": "vitest run --coverage"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@codemirror/commands": "^6.2.1",
|
||||||
"@codemirror/lang-html": "^6.4.2",
|
"@codemirror/lang-html": "^6.4.2",
|
||||||
"@codemirror/lang-javascript": "^6.1.4",
|
"@codemirror/lang-javascript": "^6.1.4",
|
||||||
"@codemirror/lang-json": "^6.0.1",
|
"@codemirror/lang-json": "^6.0.1",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.0.2",
|
"@codemirror/lang-xml": "^6.0.2",
|
||||||
|
"@codemirror/language": "^6.6.0",
|
||||||
|
"@codemirror/search": "^6.2.3",
|
||||||
|
"@lezer/generator": "^1.2.2",
|
||||||
|
"@lezer/highlight": "^1.1.3",
|
||||||
|
"@lezer/lr": "^1.3.3",
|
||||||
"@radix-ui/react-icons": "^1.2.0",
|
"@radix-ui/react-icons": "^1.2.0",
|
||||||
"@radix-ui/react-popover": "1.0.3",
|
"@react-hook/resize-observer": "^1.2.6",
|
||||||
"@tauri-apps/api": "^1.2.0",
|
"@tailwindcss/container-queries": "^0.1.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.52.0",
|
"@tanstack/query-sync-storage-persister": "^4.27.1",
|
||||||
"@typescript-eslint/parser": "^5.52.0",
|
"@tanstack/react-query": "^4.28.0",
|
||||||
|
"@tanstack/react-query-devtools": "^4.28.0",
|
||||||
|
"@tanstack/react-query-persist-client": "^4.28.0",
|
||||||
|
"@tauri-apps/api": "^1.5.1",
|
||||||
|
"@vitejs/plugin-react": "^3.1.0",
|
||||||
|
"buffer": "^6.0.3",
|
||||||
"classnames": "^2.3.2",
|
"classnames": "^2.3.2",
|
||||||
|
"cm6-graphql": "^0.0.4-canary-b30a2325.0",
|
||||||
"codemirror": "^6.0.1",
|
"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",
|
||||||
|
"react-dnd-html5-backend": "^16.0.1",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-helmet-async": "^1.3.0",
|
||||||
|
"react-router-dom": "^6.8.1",
|
||||||
|
"react-use": "^17.4.0",
|
||||||
|
"uuid": "^9.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/nesting": "^0.0.0-insiders.565cd3e",
|
||||||
|
"@tauri-apps/cli": "^1.5.4",
|
||||||
|
"@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",
|
||||||
|
"@types/react-dom": "^18.0.11",
|
||||||
|
"@types/uuid": "^9.0.1",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^5.57.0",
|
||||||
|
"@typescript-eslint/parser": "^5.57.0",
|
||||||
|
"autoprefixer": "^10.4.13",
|
||||||
"eslint": "^8.34.0",
|
"eslint": "^8.34.0",
|
||||||
"eslint-config-prettier": "^8.6.0",
|
"eslint-config-prettier": "^8.6.0",
|
||||||
"eslint-plugin-import": "^2.27.5",
|
"eslint-plugin-import": "^2.27.5",
|
||||||
"eslint-plugin-jsx-a11y": "^6.7.1",
|
"eslint-plugin-jsx-a11y": "^6.7.1",
|
||||||
"eslint-plugin-react": "^7.32.2",
|
"eslint-plugin-react": "^7.32.2",
|
||||||
"framer-motion": "^9.0.4",
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
"prettier": "^2.8.4",
|
|
||||||
"react": "^18.2.0",
|
|
||||||
"react-dom": "^18.2.0",
|
|
||||||
"react-helmet-async": "^1.3.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@tauri-apps/cli": "^1.2.2",
|
|
||||||
"@types/node": "^18.7.10",
|
|
||||||
"@types/react": "^18.0.15",
|
|
||||||
"@types/react-dom": "^18.0.6",
|
|
||||||
"@vitejs/plugin-react": "^3.0.0",
|
|
||||||
"autoprefixer": "^10.4.13",
|
|
||||||
"concurrently": "^7.6.0",
|
|
||||||
"postcss": "^8.4.21",
|
"postcss": "^8.4.21",
|
||||||
|
"postcss-nesting": "^11.2.1",
|
||||||
|
"prettier": "^2.8.4",
|
||||||
"tailwindcss": "^3.2.7",
|
"tailwindcss": "^3.2.7",
|
||||||
"typescript": "^4.6.4",
|
"typescript": "^5.0.2",
|
||||||
"vite": "^4.0.0",
|
"vite": "^4.0.0",
|
||||||
"vite-plugin-rsw": "^2.0.11",
|
"vite-plugin-svgr": "^2.4.0",
|
||||||
"vite-plugin-top-level-await": "^1.2.4"
|
"vite-plugin-top-level-await": "^1.2.4",
|
||||||
|
"vitest": "^0.29.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
plugins: {
|
plugins: [
|
||||||
tailwindcss: {},
|
require('@tailwindcss/nesting')(require('postcss-nesting')),
|
||||||
autoprefixer: {},
|
require('tailwindcss'),
|
||||||
},
|
require('autoprefixer'),
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
39
rsw.toml
@@ -1,39 +0,0 @@
|
|||||||
name = "rsw"
|
|
||||||
version = "0.1.0"
|
|
||||||
|
|
||||||
#! time interval for file changes to trigger wasm-pack build, default `50` milliseconds
|
|
||||||
interval = 50
|
|
||||||
|
|
||||||
#! link
|
|
||||||
#! npm link @see https://docs.npmjs.com/cli/v8/commands/npm-link
|
|
||||||
#! yarn link @see https://classic.yarnpkg.com/en/docs/cli/link
|
|
||||||
#! pnpm link @see https://pnpm.io/cli/link
|
|
||||||
#! The link command will only be executed if `[[crates]] link = true`
|
|
||||||
#! cli: `npm` | `yarn` | `pnpm`, default is `npm`
|
|
||||||
cli = "npm"
|
|
||||||
|
|
||||||
#! ---------------------------
|
|
||||||
|
|
||||||
#! rsw new <name>
|
|
||||||
[new]
|
|
||||||
#! @see https://rustwasm.github.io/docs/wasm-pack/commands/new.html
|
|
||||||
#! using: `wasm-pack` | `rsw` | `user`, default is `wasm-pack`
|
|
||||||
#! 1. wasm-pack: `rsw new <name> --template <template> --mode <normal|noinstall|force>`
|
|
||||||
#! 2. rsw: `rsw new <name>`, built-in templates
|
|
||||||
#! 3. user: `rsw new <name>`, if `dir` is not configured, use `wasm-pack new <name>` to initialize the project
|
|
||||||
using = "wasm-pack"
|
|
||||||
#! this field needs to be configured when `using = "user"`
|
|
||||||
#! `using = "wasm-pack"` or `using = "rsw"`, this field will be ignored
|
|
||||||
#! copy all files in this directory
|
|
||||||
dir = "my-template"
|
|
||||||
|
|
||||||
#! ################# NPM Package #################
|
|
||||||
|
|
||||||
#! When there is only `name`, other fields will use the default configuration
|
|
||||||
|
|
||||||
#! 📦 -------- package: @rsw --------
|
|
||||||
[[crates]]
|
|
||||||
#! npm package name (path: $ROOT/@rsw)
|
|
||||||
name = "src-wasm/hello"
|
|
||||||
#! run `npm link`: `true` | `false`, default is `false`
|
|
||||||
link = true
|
|
||||||
2307
src-tauri/Cargo.lock
generated
@@ -1,29 +1,37 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "tauri-app"
|
name = "yaak-app"
|
||||||
version = "0.0.0"
|
version = "0.0.0"
|
||||||
description = "A Tauri App"
|
description = "A network protocol testing utility app"
|
||||||
authors = ["you"]
|
authors = ["Gregory Schier"]
|
||||||
license = ""
|
license = "MIT"
|
||||||
repository = ""
|
repository = "https://github.com/gschier/yaak-app"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
[profile.release]
|
||||||
|
strip = true # Automatically strip symbols from the binary.
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "1.2", features = [] }
|
tauri-build = { version = "1.2", features = [] }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
|
objc = "0.2.7"
|
||||||
|
cocoa = "0.25.0"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde_json = { version = "1.0" }
|
serde_json = { version = "1.0", features = ["raw_value"] }
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
tauri = { version = "1.2", features = ["config-toml", "devtools", "shell-open", "system-tray", "window-start-dragging"] }
|
tauri = { version = "1.3", features = ["config-toml", "devtools", "fs-read-file", "os-all", "protocol-asset", "shell-open", "system-tray", "updater", "window-start-dragging"] }
|
||||||
http = { version = "0.2.8" }
|
http = "0.2.8"
|
||||||
reqwest = { version = "0.11.14", features = ["json"] }
|
reqwest = { version = "0.11.14", features = ["json"] }
|
||||||
tokio = { version = "1.25.0", features = ["full"] }
|
tokio = { version = "1.25.0", features = ["sync"] }
|
||||||
futures = { version = "0.3.26" }
|
futures = "0.3.26"
|
||||||
deno_core = { version = "0.171.0" }
|
deno_core = "0.222.0"
|
||||||
deno_ast = { version = "0.24.0", features = ["transpiling"] }
|
deno_ast = { version = "0.29.5", features = ["transpiling"] }
|
||||||
objc = { version = "0.2.7" }
|
sqlx = { version = "0.6.2", features = ["sqlite", "runtime-tokio-rustls", "json", "chrono", "time", "offline"] }
|
||||||
cocoa = { version = "0.24.1" }
|
uuid = "1.3.0"
|
||||||
|
rand = "0.8.5"
|
||||||
|
chrono = { version = "0.4.23", features = ["serde"] }
|
||||||
|
base64 = "0.21.0"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
# by default Tauri runs in production mode
|
# by default Tauri runs in production mode
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
fn main() {
|
fn main() {
|
||||||
tauri_build::build()
|
tauri_build::build()
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 106 KiB |
8
src-tauri/macos/entitlements.plist
Normal 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>
|
||||||
65
src-tauri/migrations/20230225181302_init.sql
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
CREATE TABLE key_values
|
||||||
|
(
|
||||||
|
model TEXT DEFAULT 'key_value' NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
deleted_at DATETIME,
|
||||||
|
namespace TEXT NOT NULL,
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (namespace, key)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE workspaces
|
||||||
|
(
|
||||||
|
id TEXT NOT NULL
|
||||||
|
PRIMARY KEY,
|
||||||
|
model TEXT DEFAULT 'workspace' NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
deleted_at DATETIME,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE http_requests
|
||||||
|
(
|
||||||
|
id TEXT NOT NULL
|
||||||
|
PRIMARY KEY,
|
||||||
|
model TEXT DEFAULT 'http_request' NOT NULL,
|
||||||
|
workspace_id TEXT NOT NULL
|
||||||
|
REFERENCES workspaces
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
deleted_at DATETIME,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
method TEXT NOT NULL,
|
||||||
|
headers TEXT NOT NULL,
|
||||||
|
body TEXT,
|
||||||
|
body_type TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE http_responses
|
||||||
|
(
|
||||||
|
id TEXT NOT NULL
|
||||||
|
PRIMARY KEY,
|
||||||
|
model TEXT DEFAULT 'http_response' NOT NULL,
|
||||||
|
request_id TEXT NOT NULL
|
||||||
|
REFERENCES http_requests
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
workspace_id TEXT NOT NULL
|
||||||
|
REFERENCES workspaces
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
deleted_at DATETIME,
|
||||||
|
elapsed INTEGER NOT NULL,
|
||||||
|
status INTEGER NOT NULL,
|
||||||
|
status_reason TEXT,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
headers TEXT NOT NULL,
|
||||||
|
error TEXT
|
||||||
|
);
|
||||||
1
src-tauri/migrations/20230319042610_sort-priority.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE main.http_requests ADD COLUMN sort_priority REAL NOT NULL DEFAULT 0;
|
||||||
2
src-tauri/migrations/20230330143214_request-auth.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE http_requests ADD COLUMN authentication TEXT NOT NULL DEFAULT '{}';
|
||||||
|
ALTER TABLE http_requests ADD COLUMN authentication_type TEXT;
|
||||||
@@ -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;
|
||||||
@@ -1,5 +1 @@
|
|||||||
console.log('---------------------------');
|
Deno.core.opAsync('op_hello', 'Deno');
|
||||||
console.log('- 👋 Hello from plugin.ts -');
|
|
||||||
console.log('---------------------------');
|
|
||||||
|
|
||||||
Deno.core.ops.op_hello('World');
|
|
||||||
|
|||||||
735
src-tauri/sqlx-data.json
Normal file
@@ -0,0 +1,735 @@
|
|||||||
|
{
|
||||||
|
"db": "SQLite",
|
||||||
|
"06aaf8f4a17566f1d25da2a60f0baf4b5fc28c3cf0c001a84e25edf9eab3c7e3": {
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "model",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "created_at",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Datetime"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "updated_at",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Datetime"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "namespace",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "key",
|
||||||
|
"ordinal": 4,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "value",
|
||||||
|
"ordinal": 5,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"query": "\n SELECT model, created_at, updated_at, namespace, key, value\n FROM key_values\n WHERE namespace = ? AND key = ?\n "
|
||||||
|
},
|
||||||
|
"07d1a1c7b4f3d9625a766e60fd57bb779b71dae30e5bbce34885a911a5a42428": {
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"nullable": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"query": "\n DELETE FROM http_responses\n WHERE id = ?\n "
|
||||||
|
},
|
||||||
|
"26072725d536c3cfdffd9a681d17c0ee2f246ca98e0459630a2430236d3bbdd2": {
|
||||||
|
"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 workspace_id = ?\n ORDER BY created_at DESC\n "
|
||||||
|
},
|
||||||
|
"448a1d1f1866ab42c0f81fcf8eb2930bf21dfdd43ca4831bc1a198cf45ac3732": {
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"nullable": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "model",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "workspace_id",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "created_at",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Datetime"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "updated_at",
|
||||||
|
"ordinal": 4,
|
||||||
|
"type_info": "Datetime"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "name",
|
||||||
|
"ordinal": 5,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "url",
|
||||||
|
"ordinal": 6,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "method",
|
||||||
|
"ordinal": 7,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "body",
|
||||||
|
"ordinal": 8,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "body_type",
|
||||||
|
"ordinal": 9,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "authentication!: Json<HashMap<String, JsonValue>>",
|
||||||
|
"ordinal": 10,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "authentication_type",
|
||||||
|
"ordinal": 11,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sort_priority",
|
||||||
|
"ordinal": 12,
|
||||||
|
"type_info": "Float"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "headers!: sqlx::types::Json<Vec<HttpRequestHeader>>",
|
||||||
|
"ordinal": 13,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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 workspace_id = ?\n "
|
||||||
|
},
|
||||||
|
"84be2b954870ab181738656ecd4d03fca2ff21012947014c79626abfce8e999b": {
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"nullable": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"query": "\n DELETE FROM workspaces\n WHERE id = ?\n "
|
||||||
|
},
|
||||||
|
"8947a2a90478277c42fe9b06bc1fa98197642a4d281a3dbc101be2c9c1fec36c": {
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"nullable": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 11
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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": {
|
||||||
|
"columns": [],
|
||||||
|
"nullable": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 11
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "model",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "created_at",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Datetime"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "updated_at",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Datetime"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "name",
|
||||||
|
"ordinal": 4,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "description",
|
||||||
|
"ordinal": 5,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"query": "\n SELECT id, model, created_at, updated_at, name, description\n FROM workspaces WHERE id = ?\n "
|
||||||
|
},
|
||||||
|
"cea4cae52f16ec78aca9a47b17117422d4f165e5a3b308c70fd1a180382475ea": {
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "model",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "created_at",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Datetime"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "updated_at",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Datetime"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "name",
|
||||||
|
"ordinal": 4,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "description",
|
||||||
|
"ordinal": 5,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"query": "\n SELECT id, model, created_at, updated_at, name, description\n FROM workspaces\n "
|
||||||
|
},
|
||||||
|
"ced098adb79c0ee64e223b6e02371ef253920a2c342275de0fa9c181529a4adc": {
|
||||||
|
"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": "created_at",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Datetime"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "updated_at",
|
||||||
|
"ordinal": 4,
|
||||||
|
"type_info": "Datetime"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "name",
|
||||||
|
"ordinal": 5,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "url",
|
||||||
|
"ordinal": 6,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "method",
|
||||||
|
"ordinal": 7,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "body",
|
||||||
|
"ordinal": 8,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "body_type",
|
||||||
|
"ordinal": 9,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "authentication!: Json<HashMap<String, JsonValue>>",
|
||||||
|
"ordinal": 10,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "authentication_type",
|
||||||
|
"ordinal": 11,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sort_priority",
|
||||||
|
"ordinal": 12,
|
||||||
|
"type_info": "Float"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "headers!: sqlx::types::Json<Vec<HttpRequestHeader>>",
|
||||||
|
"ordinal": 13,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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 "
|
||||||
|
},
|
||||||
|
"d80c09497771e3641022e73ec6c6a87e73a551f88a948a5445d754922b82b50b": {
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"nullable": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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 "
|
||||||
|
},
|
||||||
|
"e0f41023d877d94b7609ce910a71bd89c4827a558654b8ae14d85e6ba86990cf": {
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"nullable": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"query": "\n UPDATE workspaces SET (name, updated_at) =\n (?, CURRENT_TIMESTAMP) WHERE id = ?;\n "
|
||||||
|
},
|
||||||
|
"f116d8cf9aad828135bb8c3a4c8b8e6b857ae13303989e9133a33b2d1cf20e96": {
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"nullable": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"query": "\n INSERT INTO workspaces (id, name, description)\n VALUES (?, ?, ?)\n "
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
use http::header::{HeaderName, USER_AGENT};
|
|
||||||
use http::{HeaderMap, HeaderValue, Method};
|
|
||||||
use reqwest::redirect::Policy;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use tauri::{AppHandle, Wry};
|
|
||||||
|
|
||||||
#[derive(serde::Serialize)]
|
|
||||||
pub struct CustomResponse {
|
|
||||||
status: String,
|
|
||||||
body: String,
|
|
||||||
url: String,
|
|
||||||
method: String,
|
|
||||||
elapsed: u128,
|
|
||||||
elapsed2: u128,
|
|
||||||
headers: HashMap<String, String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn send_request(
|
|
||||||
app_handle: AppHandle<Wry>,
|
|
||||||
url: &str,
|
|
||||||
method: &str,
|
|
||||||
) -> Result<CustomResponse, String> {
|
|
||||||
let start = std::time::Instant::now();
|
|
||||||
|
|
||||||
let mut abs_url = url.to_string();
|
|
||||||
if !abs_url.starts_with("http://") && !abs_url.starts_with("https://") {
|
|
||||||
abs_url = format!("http://{}", url);
|
|
||||||
}
|
|
||||||
|
|
||||||
let client = reqwest::Client::builder()
|
|
||||||
.redirect(Policy::none())
|
|
||||||
.build()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let mut headers = HeaderMap::new();
|
|
||||||
// headers.insert(CONTENT_TYPE, HeaderValue::from_static("image/png"));
|
|
||||||
headers.insert(USER_AGENT, HeaderValue::from_static("reqwest"));
|
|
||||||
headers.insert("x-foo-bar", HeaderValue::from_static("hi mom"));
|
|
||||||
headers.insert(
|
|
||||||
HeaderName::from_static("x-api-key"),
|
|
||||||
HeaderValue::from_static("123-123-123"),
|
|
||||||
);
|
|
||||||
|
|
||||||
let m = Method::from_bytes(method.to_uppercase().as_bytes()).unwrap();
|
|
||||||
let req = client
|
|
||||||
.request(m, abs_url.to_string())
|
|
||||||
.headers(headers)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let req = match req {
|
|
||||||
Ok(v) => v,
|
|
||||||
Err(e) => {
|
|
||||||
println!("Error: {}", e);
|
|
||||||
return Err(e.to_string());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let resp = client.execute(req).await;
|
|
||||||
|
|
||||||
let elapsed = start.elapsed().as_millis();
|
|
||||||
|
|
||||||
let p = app_handle
|
|
||||||
.path_resolver()
|
|
||||||
.resolve_resource("plugins/plugin.ts")
|
|
||||||
.expect("failed to resolve resource");
|
|
||||||
|
|
||||||
crate::runtime::run_plugin_sync(p.to_str().unwrap()).unwrap();
|
|
||||||
|
|
||||||
match resp {
|
|
||||||
Ok(v) => {
|
|
||||||
let url = v.url().to_string();
|
|
||||||
let status = v.status().to_string();
|
|
||||||
let method = method.to_string();
|
|
||||||
let headers = v
|
|
||||||
.headers()
|
|
||||||
.iter()
|
|
||||||
.map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap().to_string()))
|
|
||||||
.collect::<HashMap<String, String>>();
|
|
||||||
let body = v.text().await.unwrap();
|
|
||||||
let elapsed2 = start.elapsed().as_millis();
|
|
||||||
Ok(CustomResponse {
|
|
||||||
status,
|
|
||||||
body,
|
|
||||||
elapsed,
|
|
||||||
elapsed2,
|
|
||||||
method,
|
|
||||||
url,
|
|
||||||
headers,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
println!("Error: {}", e);
|
|
||||||
Err(e.to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn greet(name: &str) -> String {
|
|
||||||
format!("Hello, {}! You've been greeted from Rust!", name)
|
|
||||||
}
|
|
||||||
@@ -7,58 +7,787 @@
|
|||||||
#[macro_use]
|
#[macro_use]
|
||||||
extern crate objc;
|
extern crate objc;
|
||||||
|
|
||||||
mod commands;
|
use std::collections::HashMap;
|
||||||
|
use std::env::current_dir;
|
||||||
|
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;
|
||||||
|
use sqlx::sqlite::SqlitePoolOptions;
|
||||||
|
use sqlx::types::Json;
|
||||||
|
use sqlx::{Pool, Sqlite};
|
||||||
|
use tauri::regex::Regex;
|
||||||
|
#[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 runtime;
|
||||||
mod window_ext;
|
mod window_ext;
|
||||||
|
|
||||||
use tauri::{
|
#[derive(serde::Serialize)]
|
||||||
CustomMenuItem, Manager, SystemTray, SystemTrayEvent, SystemTrayMenu, SystemTrayMenuItem,
|
pub struct CustomResponse {
|
||||||
WindowEvent,
|
status: u16,
|
||||||
};
|
body: String,
|
||||||
use window_ext::WindowExt;
|
url: String,
|
||||||
|
method: String,
|
||||||
|
elapsed: u128,
|
||||||
|
elapsed2: u128,
|
||||||
|
headers: HashMap<String, String>,
|
||||||
|
pub status_reason: Option<&'static str>,
|
||||||
|
}
|
||||||
|
|
||||||
fn main() {
|
async fn migrate_db(
|
||||||
// here `"quit".to_string()` defines the menu item id, and the second parameter is the menu item label.
|
app_handle: AppHandle<Wry>,
|
||||||
let quit = CustomMenuItem::new("quit".to_string(), "Quit");
|
db_instance: &Mutex<Pool<Sqlite>>,
|
||||||
let tray_menu = SystemTrayMenu::new().add_item(quit);
|
) -> Result<(), String> {
|
||||||
let system_tray = SystemTray::new().with_menu(tray_menu);
|
let pool = &*db_instance.lock().await;
|
||||||
|
let p = app_handle
|
||||||
|
.path_resolver()
|
||||||
|
.resolve_resource("migrations")
|
||||||
|
.expect("failed to resolve resource");
|
||||||
|
println!("Running migrations at {}", p.to_string_lossy());
|
||||||
|
let m = Migrator::new(p).await.expect("Failed to load migrations");
|
||||||
|
m.run(pool).await.expect("Failed to run migrations");
|
||||||
|
println!("Migrations complete");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
tauri::Builder::default()
|
#[tauri::command]
|
||||||
.system_tray(system_tray)
|
async fn send_ephemeral_request(
|
||||||
.setup(|app| {
|
request: models::HttpRequest,
|
||||||
let win = app.get_window("main").unwrap();
|
app_handle: AppHandle<Wry>,
|
||||||
win.position_traffic_lights();
|
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||||
Ok(())
|
) -> Result<models::HttpResponse, String> {
|
||||||
})
|
let pool = &*db_instance.lock().await;
|
||||||
.on_system_tray_event(|app, event| match event {
|
let response = models::HttpResponse::default();
|
||||||
SystemTrayEvent::MenuItemClick { id, .. } => match id.as_str() {
|
return actually_send_ephemeral_request(request, &response, &app_handle, pool).await;
|
||||||
"quit" => {
|
}
|
||||||
std::process::exit(0);
|
|
||||||
}
|
|
||||||
"hide" => {
|
|
||||||
let window = app.get_window("main").unwrap();
|
|
||||||
window.hide().unwrap();
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
},
|
|
||||||
_ => {}
|
|
||||||
})
|
|
||||||
.on_window_event(|e| {
|
|
||||||
let apply_offset = || {
|
|
||||||
let win = e.window();
|
|
||||||
win.position_traffic_lights();
|
|
||||||
};
|
|
||||||
|
|
||||||
match e.event() {
|
async fn actually_send_ephemeral_request(
|
||||||
WindowEvent::Resized(..) => apply_offset(),
|
request: models::HttpRequest,
|
||||||
WindowEvent::ThemeChanged(..) => apply_offset(),
|
response: &models::HttpResponse,
|
||||||
_ => {}
|
app_handle: &AppHandle<Wry>,
|
||||||
|
pool: &Pool<Sqlite>,
|
||||||
|
) -> Result<models::HttpResponse, String> {
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
let mut url_string = request.url.to_string();
|
||||||
|
|
||||||
|
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
|
||||||
|
.replace(&url_string, |caps: &tauri::regex::Captures| {
|
||||||
|
let key = caps.get(1).unwrap().as_str();
|
||||||
|
match variables.get(key) {
|
||||||
|
Some(v) => v,
|
||||||
|
None => "",
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.to_string();
|
||||||
commands::send_request,
|
|
||||||
commands::greet
|
if !url_string.starts_with("http://") && !url_string.starts_with("https://") {
|
||||||
])
|
url_string = format!("http://{}", url_string);
|
||||||
.run(tauri::generate_context!())
|
}
|
||||||
.expect("error while running tauri application");
|
|
||||||
|
println!("Sending request to {}", url_string);
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.redirect(Policy::none())
|
||||||
|
// .danger_accept_invalid_certs(true)
|
||||||
|
.build()
|
||||||
|
.expect("Failed to build client");
|
||||||
|
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(USER_AGENT, HeaderValue::from_static("yaak"));
|
||||||
|
headers.insert(ACCEPT, HeaderValue::from_static("*/*"));
|
||||||
|
|
||||||
|
for h in request.headers.0 {
|
||||||
|
if h.name.is_empty() && h.value.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !h.enabled {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let header_name = match HeaderName::from_bytes(h.name.as_bytes()) {
|
||||||
|
Ok(n) => n,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Failed to create header name: {}", e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let header_value = match HeaderValue::from_str(h.value.as_str()) {
|
||||||
|
Ok(n) => n,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Failed to create header value: {}", e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
headers.insert(header_name, header_value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(b) = &request.authentication_type {
|
||||||
|
let empty_value = &serde_json::to_value("").unwrap();
|
||||||
|
if b == "basic" {
|
||||||
|
let a = request.authentication.0;
|
||||||
|
let auth = format!(
|
||||||
|
"{}:{}",
|
||||||
|
a.get("username")
|
||||||
|
.unwrap_or(empty_value)
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or(""),
|
||||||
|
a.get("password")
|
||||||
|
.unwrap_or(empty_value)
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or(""),
|
||||||
|
);
|
||||||
|
let encoded = base64::engine::general_purpose::STANDARD_NO_PAD.encode(auth);
|
||||||
|
headers.insert(
|
||||||
|
"Authorization",
|
||||||
|
HeaderValue::from_str(&format!("Basic {}", encoded)).unwrap(),
|
||||||
|
);
|
||||||
|
} else if b == "bearer" {
|
||||||
|
let token = request
|
||||||
|
.authentication
|
||||||
|
.0
|
||||||
|
.get("token")
|
||||||
|
.unwrap_or(empty_value)
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or("");
|
||||||
|
headers.insert(
|
||||||
|
"Authorization",
|
||||||
|
HeaderValue::from_str(&format!("Bearer {}", token)).unwrap(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let m = Method::from_bytes(request.method.to_uppercase().as_bytes())
|
||||||
|
.expect("Failed to create method");
|
||||||
|
let builder = client.request(m, url_string.to_string()).headers(headers);
|
||||||
|
|
||||||
|
let sendable_req_result = match (request.body, request.body_type) {
|
||||||
|
(Some(b), Some(_)) => builder.body(b).build(),
|
||||||
|
_ => builder.build(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let sendable_req = match sendable_req_result {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
return response_err(response, e.to_string(), &app_handle, pool).await;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let raw_response = client.execute(sendable_req).await;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
let mut response = response.clone();
|
||||||
|
response.status = v.status().as_u16() as i64;
|
||||||
|
response.status_reason = v.status().canonical_reason().map(|s| s.to_string());
|
||||||
|
response.headers = Json(
|
||||||
|
v.headers()
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| models::HttpResponseHeader {
|
||||||
|
name: k.as_str().to_string(),
|
||||||
|
value: v.to_str().unwrap().to_string(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
response.url = v.url().to_string();
|
||||||
|
let body_bytes = v.bytes().await.expect("Failed to get body").to_vec();
|
||||||
|
response.content_length = Some(body_bytes.len() as i64);
|
||||||
|
|
||||||
|
{
|
||||||
|
// Write body to FS
|
||||||
|
let dir = app_handle.path_resolver().app_data_dir().unwrap();
|
||||||
|
let base_dir = dir.join("responses");
|
||||||
|
create_dir_all(base_dir.clone()).expect("Failed to create responses dir");
|
||||||
|
let body_path = match response.id == "" {
|
||||||
|
false => base_dir.join(response.id.clone()),
|
||||||
|
true => base_dir.join(uuid::Uuid::new_v4().to_string()),
|
||||||
|
};
|
||||||
|
let mut f = File::options()
|
||||||
|
.create(true)
|
||||||
|
.truncate(true)
|
||||||
|
.write(true)
|
||||||
|
.open(&body_path)
|
||||||
|
.expect("Failed to open file");
|
||||||
|
f.write_all(body_bytes.as_slice())
|
||||||
|
.expect("Failed to write to file");
|
||||||
|
response.body_path = Some(
|
||||||
|
body_path
|
||||||
|
.to_str()
|
||||||
|
.expect("Failed to get body path")
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also store body directly on the model, if small enough
|
||||||
|
if body_bytes.len() < 100_000 {
|
||||||
|
response.body = Some(body_bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
response.elapsed = start.elapsed().as_millis() as i64;
|
||||||
|
response = models::update_response_if_id(&response, pool)
|
||||||
|
.await
|
||||||
|
.expect("Failed to update response");
|
||||||
|
if request.id != "" {
|
||||||
|
emit_side_effect(app_handle, "updated_model", &response);
|
||||||
|
}
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
Err(e) => response_err(response, e.to_string(), app_handle, pool).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn send_request(
|
||||||
|
window: Window<Wry>,
|
||||||
|
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||||
|
request_id: &str,
|
||||||
|
) -> Result<models::HttpResponse, String> {
|
||||||
|
let pool = &*db_instance.lock().await;
|
||||||
|
|
||||||
|
let req = models::get_request(request_id, pool)
|
||||||
|
.await
|
||||||
|
.expect("Failed to get request");
|
||||||
|
|
||||||
|
let response = models::create_response(&req.id, 0, "", 0, None, None, None, None, vec![], pool)
|
||||||
|
.await
|
||||||
|
.expect("Failed to create response");
|
||||||
|
|
||||||
|
let response2 = response.clone();
|
||||||
|
let app_handle2 = window.app_handle().clone();
|
||||||
|
let pool2 = pool.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
actually_send_ephemeral_request(req, &response2, &app_handle2, &pool2)
|
||||||
|
.await
|
||||||
|
.expect("Failed to send request");
|
||||||
|
});
|
||||||
|
|
||||||
|
emit_and_return(&window, "created_model", response)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn response_err(
|
||||||
|
response: &models::HttpResponse,
|
||||||
|
error: String,
|
||||||
|
app_handle: &AppHandle<Wry>,
|
||||||
|
pool: &Pool<Sqlite>,
|
||||||
|
) -> Result<models::HttpResponse, String> {
|
||||||
|
let mut response = response.clone();
|
||||||
|
response.error = Some(error.clone());
|
||||||
|
response = models::update_response_if_id(&response, pool)
|
||||||
|
.await
|
||||||
|
.expect("Failed to update response");
|
||||||
|
emit_side_effect(app_handle, "updated_model", &response);
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn get_key_value(
|
||||||
|
namespace: &str,
|
||||||
|
key: &str,
|
||||||
|
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||||
|
) -> Result<Option<models::KeyValue>, ()> {
|
||||||
|
let pool = &*db_instance.lock().await;
|
||||||
|
let result = models::get_key_value(namespace, key, pool).await;
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn set_key_value(
|
||||||
|
namespace: &str,
|
||||||
|
key: &str,
|
||||||
|
value: &str,
|
||||||
|
window: Window<Wry>,
|
||||||
|
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||||
|
) -> Result<models::KeyValue, String> {
|
||||||
|
let pool = &*db_instance.lock().await;
|
||||||
|
let (key_value, created) = models::set_key_value(namespace, key, value, pool).await;
|
||||||
|
|
||||||
|
if created {
|
||||||
|
emit_and_return(&window, "created_model", key_value)
|
||||||
|
} else {
|
||||||
|
emit_and_return(&window, "updated_model", key_value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn create_workspace(
|
||||||
|
name: &str,
|
||||||
|
window: Window<Wry>,
|
||||||
|
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||||
|
) -> Result<models::Workspace, String> {
|
||||||
|
let pool = &*db_instance.lock().await;
|
||||||
|
let created_workspace = models::create_workspace(name, "", pool)
|
||||||
|
.await
|
||||||
|
.expect("Failed to create workspace");
|
||||||
|
|
||||||
|
emit_and_return(&window, "created_model", created_workspace)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn create_request(
|
||||||
|
workspace_id: &str,
|
||||||
|
name: &str,
|
||||||
|
sort_priority: f64,
|
||||||
|
window: Window<Wry>,
|
||||||
|
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||||
|
) -> Result<models::HttpRequest, String> {
|
||||||
|
let pool = &*db_instance.lock().await;
|
||||||
|
let headers = Vec::new();
|
||||||
|
let created_request = models::upsert_request(
|
||||||
|
None,
|
||||||
|
workspace_id,
|
||||||
|
name,
|
||||||
|
"GET",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
HashMap::new(),
|
||||||
|
None,
|
||||||
|
"",
|
||||||
|
headers,
|
||||||
|
sort_priority,
|
||||||
|
pool,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("Failed to create request");
|
||||||
|
|
||||||
|
emit_and_return(&window, "created_model", created_request)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn duplicate_request(
|
||||||
|
id: &str,
|
||||||
|
window: Window<Wry>,
|
||||||
|
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||||
|
) -> Result<models::HttpRequest, String> {
|
||||||
|
let pool = &*db_instance.lock().await;
|
||||||
|
let request = models::duplicate_request(id, pool)
|
||||||
|
.await
|
||||||
|
.expect("Failed to 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,
|
||||||
|
window: Window<Wry>,
|
||||||
|
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||||
|
) -> Result<models::HttpRequest, String> {
|
||||||
|
let pool = &*db_instance.lock().await;
|
||||||
|
|
||||||
|
// TODO: Figure out how to make this better
|
||||||
|
let b2;
|
||||||
|
let body = match request.body {
|
||||||
|
Some(b) => {
|
||||||
|
b2 = b;
|
||||||
|
Some(b2.as_str())
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// TODO: Figure out how to make this better
|
||||||
|
let updated_request = models::upsert_request(
|
||||||
|
Some(request.id.as_str()),
|
||||||
|
request.workspace_id.as_str(),
|
||||||
|
request.name.as_str(),
|
||||||
|
request.method.as_str(),
|
||||||
|
body,
|
||||||
|
request.body_type,
|
||||||
|
request.authentication.0,
|
||||||
|
request.authentication_type,
|
||||||
|
request.url.as_str(),
|
||||||
|
request.headers.0,
|
||||||
|
request.sort_priority,
|
||||||
|
pool,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("Failed to update request");
|
||||||
|
|
||||||
|
emit_and_return(&window, "updated_model", updated_request)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn delete_request(
|
||||||
|
window: Window<Wry>,
|
||||||
|
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||||
|
request_id: &str,
|
||||||
|
) -> Result<models::HttpRequest, String> {
|
||||||
|
let pool = &*db_instance.lock().await;
|
||||||
|
let req = models::delete_request(request_id, pool)
|
||||||
|
.await
|
||||||
|
.expect("Failed to delete request");
|
||||||
|
emit_and_return(&window, "deleted_model", req)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn requests(
|
||||||
|
workspace_id: &str,
|
||||||
|
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||||
|
) -> Result<Vec<models::HttpRequest>, String> {
|
||||||
|
let pool = &*db_instance.lock().await;
|
||||||
|
models::find_requests(workspace_id, pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn get_request(
|
||||||
|
id: &str,
|
||||||
|
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||||
|
) -> Result<models::HttpRequest, String> {
|
||||||
|
let pool = &*db_instance.lock().await;
|
||||||
|
models::get_request(id, pool)
|
||||||
|
.await
|
||||||
|
.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,
|
||||||
|
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||||
|
) -> Result<Vec<models::HttpResponse>, String> {
|
||||||
|
let pool = &*db_instance.lock().await;
|
||||||
|
models::find_responses(request_id, pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn delete_response(
|
||||||
|
id: &str,
|
||||||
|
window: Window<Wry>,
|
||||||
|
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||||
|
) -> Result<models::HttpResponse, String> {
|
||||||
|
let pool = &*db_instance.lock().await;
|
||||||
|
let response = models::delete_response(id, pool)
|
||||||
|
.await
|
||||||
|
.expect("Failed to delete response");
|
||||||
|
emit_and_return(&window, "deleted_model", response)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn delete_all_responses(
|
||||||
|
request_id: &str,
|
||||||
|
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let pool = &*db_instance.lock().await;
|
||||||
|
models::delete_all_responses(request_id, pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn workspaces(
|
||||||
|
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||||
|
) -> Result<Vec<models::Workspace>, String> {
|
||||||
|
let pool = &*db_instance.lock().await;
|
||||||
|
let workspaces = models::find_workspaces(pool)
|
||||||
|
.await
|
||||||
|
.expect("Failed to find workspaces");
|
||||||
|
if workspaces.is_empty() {
|
||||||
|
let workspace =
|
||||||
|
models::create_workspace("My Project", "This is the default workspace", pool)
|
||||||
|
.await
|
||||||
|
.expect("Failed to create workspace");
|
||||||
|
Ok(vec![workspace])
|
||||||
|
} else {
|
||||||
|
Ok(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>,
|
||||||
|
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||||
|
id: &str,
|
||||||
|
) -> Result<models::Workspace, String> {
|
||||||
|
let pool = &*db_instance.lock().await;
|
||||||
|
let workspace = models::delete_workspace(id, pool)
|
||||||
|
.await
|
||||||
|
.expect("Failed to delete workspace");
|
||||||
|
emit_and_return(&window, "deleted_model", workspace)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
tauri::Builder::default()
|
||||||
|
.setup(|app| {
|
||||||
|
let dir = match is_dev() {
|
||||||
|
true => current_dir().unwrap(),
|
||||||
|
false => app.path_resolver().app_data_dir().unwrap(),
|
||||||
|
};
|
||||||
|
|
||||||
|
create_dir_all(dir.clone()).expect("Problem creating App directory!");
|
||||||
|
let p = dir.join("db.sqlite");
|
||||||
|
let p_string = p.to_string_lossy().replace(' ', "%20");
|
||||||
|
let url = format!("sqlite://{}?mode=rwc", p_string);
|
||||||
|
println!("Connecting to database at {}", url);
|
||||||
|
tauri::async_runtime::block_on(async move {
|
||||||
|
let pool = SqlitePoolOptions::new()
|
||||||
|
.connect(url.as_str())
|
||||||
|
.await
|
||||||
|
.expect("Failed to connect to database");
|
||||||
|
|
||||||
|
// Setup the DB handle
|
||||||
|
let m = Mutex::new(pool);
|
||||||
|
migrate_db(app.handle(), &m)
|
||||||
|
.await
|
||||||
|
.expect("Failed to migrate database");
|
||||||
|
app.manage(m);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
new_window,
|
||||||
|
workspaces,
|
||||||
|
get_request,
|
||||||
|
requests,
|
||||||
|
send_request,
|
||||||
|
send_ephemeral_request,
|
||||||
|
duplicate_request,
|
||||||
|
create_request,
|
||||||
|
get_workspace,
|
||||||
|
create_workspace,
|
||||||
|
delete_workspace,
|
||||||
|
update_workspace,
|
||||||
|
update_request,
|
||||||
|
delete_request,
|
||||||
|
responses,
|
||||||
|
get_key_value,
|
||||||
|
set_key_value,
|
||||||
|
delete_response,
|
||||||
|
delete_all_responses,
|
||||||
|
])
|
||||||
|
.build(tauri::generate_context!())
|
||||||
|
.expect("error while running tauri application")
|
||||||
|
.run(|app_handle, event| match event {
|
||||||
|
RunEvent::Ready => {
|
||||||
|
create_window(app_handle, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExitRequested { api, .. } => {
|
||||||
|
// }
|
||||||
|
_ => {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_dev() -> bool {
|
||||||
|
let env = option_env!("YAAK_ENV");
|
||||||
|
env.unwrap_or("production") != "production"
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
CustomMenuItem::new("send_request".to_string(), "Send Request")
|
||||||
|
.accelerator("CmdOrCtrl+r"),
|
||||||
|
)
|
||||||
|
.add_item(
|
||||||
|
CustomMenuItem::new("zoom_reset".to_string(), "Zoom to Actual Size")
|
||||||
|
.accelerator("CmdOrCtrl+0"),
|
||||||
|
)
|
||||||
|
.add_item(
|
||||||
|
CustomMenuItem::new("zoom_in".to_string(), "Zoom In").accelerator("CmdOrCtrl+Plus"),
|
||||||
|
)
|
||||||
|
.add_item(
|
||||||
|
CustomMenuItem::new("zoom_out".to_string(), "Zoom Out").accelerator("CmdOrCtrl+-"),
|
||||||
|
)
|
||||||
|
.add_item(
|
||||||
|
CustomMenuItem::new("toggle_sidebar".to_string(), "Toggle Sidebar")
|
||||||
|
.accelerator("CmdOrCtrl+b"),
|
||||||
|
)
|
||||||
|
.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
|
||||||
|
.add_native_item(MenuItem::Separator)
|
||||||
|
.add_item(
|
||||||
|
CustomMenuItem::new("refresh".to_string(), "Refresh")
|
||||||
|
.accelerator("CmdOrCtrl + Shift + r"),
|
||||||
|
)
|
||||||
|
.add_item(
|
||||||
|
CustomMenuItem::new("toggle_devtools".to_string(), "Open Devtools")
|
||||||
|
.accelerator("CmdOrCtrl + Option + i"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let submenu = Submenu::new("Test Menu", test_menu);
|
||||||
|
|
||||||
|
let window_num = handle.windows().len();
|
||||||
|
let window_id = format!("wnd_{}_{}", window_num, generate_id(None));
|
||||||
|
let menu = default_menu.add_submenu(submenu);
|
||||||
|
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();
|
||||||
|
win.on_menu_event(move |event| match event.menu_item_id() {
|
||||||
|
"quit" => std::process::exit(0),
|
||||||
|
"close" => win2.close().unwrap(),
|
||||||
|
"zoom_reset" => win2.emit("zoom", 0).unwrap(),
|
||||||
|
"zoom_in" => win2.emit("zoom", 1).unwrap(),
|
||||||
|
"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, None),
|
||||||
|
"toggle_devtools" => {
|
||||||
|
if win2.is_devtools_open() {
|
||||||
|
win2.close_devtools();
|
||||||
|
} else {
|
||||||
|
win2.open_devtools();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
let win3 = win.clone();
|
||||||
|
win.on_window_event(move |e| {
|
||||||
|
let apply_offset = || {
|
||||||
|
win3.position_traffic_lights();
|
||||||
|
};
|
||||||
|
|
||||||
|
match e {
|
||||||
|
WindowEvent::Resized(..) => apply_offset(),
|
||||||
|
WindowEvent::ThemeChanged(..) => apply_offset(),
|
||||||
|
WindowEvent::CloseRequested { .. } => {
|
||||||
|
println!("CLOSE REQUESTED");
|
||||||
|
// api.prevent_close();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
win.position_traffic_lights();
|
||||||
|
win
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emit an event to all windows, with a source window
|
||||||
|
fn emit_and_return<S: Serialize + Clone, E>(
|
||||||
|
current_window: &Window<Wry>,
|
||||||
|
event: &str,
|
||||||
|
payload: S,
|
||||||
|
) -> Result<S, E> {
|
||||||
|
current_window.emit_all(event, &payload).unwrap();
|
||||||
|
Ok(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emit an event to all windows, used for side-effects where there is no source window to attribute. This
|
||||||
|
fn emit_side_effect<S: Serialize + Clone>(app_handle: &AppHandle<Wry>, event: &str, payload: S) {
|
||||||
|
app_handle.emit_all(event, &payload).unwrap();
|
||||||
}
|
}
|
||||||
|
|||||||
582
src-tauri/src/models.rs
Normal file
@@ -0,0 +1,582 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
use rand::distributions::{Alphanumeric, DistString};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::types::chrono::NaiveDateTime;
|
||||||
|
use sqlx::types::{Json, JsonValue};
|
||||||
|
use sqlx::{Pool, Sqlite};
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Workspace {
|
||||||
|
pub id: String,
|
||||||
|
pub model: String,
|
||||||
|
pub created_at: NaiveDateTime,
|
||||||
|
pub updated_at: NaiveDateTime,
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct HttpRequestHeader {
|
||||||
|
#[serde(default)]
|
||||||
|
pub enabled: bool,
|
||||||
|
pub name: String,
|
||||||
|
pub value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct HttpRequest {
|
||||||
|
pub id: String,
|
||||||
|
pub model: String,
|
||||||
|
pub created_at: NaiveDateTime,
|
||||||
|
pub updated_at: NaiveDateTime,
|
||||||
|
pub sort_priority: f64,
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub url: String,
|
||||||
|
pub method: String,
|
||||||
|
pub body: Option<String>,
|
||||||
|
pub body_type: Option<String>,
|
||||||
|
pub authentication: Json<HashMap<String, JsonValue>>,
|
||||||
|
pub authentication_type: Option<String>,
|
||||||
|
pub headers: Json<Vec<HttpRequestHeader>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct HttpResponseHeader {
|
||||||
|
pub name: String,
|
||||||
|
pub value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct HttpResponse {
|
||||||
|
pub id: String,
|
||||||
|
pub model: String,
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub request_id: String,
|
||||||
|
pub created_at: NaiveDateTime,
|
||||||
|
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: Option<Vec<u8>>,
|
||||||
|
pub body_path: Option<String>,
|
||||||
|
pub headers: Json<Vec<HttpResponseHeader>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct KeyValue {
|
||||||
|
pub model: String,
|
||||||
|
pub created_at: NaiveDateTime,
|
||||||
|
pub updated_at: NaiveDateTime,
|
||||||
|
pub namespace: String,
|
||||||
|
pub key: String,
|
||||||
|
pub value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_key_value(
|
||||||
|
namespace: &str,
|
||||||
|
key: &str,
|
||||||
|
value: &str,
|
||||||
|
pool: &Pool<Sqlite>,
|
||||||
|
) -> (KeyValue, bool) {
|
||||||
|
let existing = get_key_value(namespace, key, pool).await;
|
||||||
|
sqlx::query!(
|
||||||
|
r#"
|
||||||
|
INSERT INTO key_values (namespace, key, value)
|
||||||
|
VALUES (?, ?, ?) ON CONFLICT DO UPDATE SET
|
||||||
|
updated_at = CURRENT_TIMESTAMP,
|
||||||
|
value = excluded.value
|
||||||
|
"#,
|
||||||
|
namespace,
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.expect("Failed to insert key value");
|
||||||
|
|
||||||
|
let kv = get_key_value(namespace, key, pool)
|
||||||
|
.await
|
||||||
|
.expect("Failed to get key value");
|
||||||
|
return (kv, existing.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_key_value(namespace: &str, key: &str, pool: &Pool<Sqlite>) -> Option<KeyValue> {
|
||||||
|
sqlx::query_as!(
|
||||||
|
KeyValue,
|
||||||
|
r#"
|
||||||
|
SELECT model, created_at, updated_at, namespace, key, value
|
||||||
|
FROM key_values
|
||||||
|
WHERE namespace = ? AND key = ?
|
||||||
|
"#,
|
||||||
|
namespace,
|
||||||
|
key,
|
||||||
|
)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn find_workspaces(pool: &Pool<Sqlite>) -> Result<Vec<Workspace>, sqlx::Error> {
|
||||||
|
sqlx::query_as!(
|
||||||
|
Workspace,
|
||||||
|
r#"
|
||||||
|
SELECT id, model, created_at, updated_at, name, description
|
||||||
|
FROM workspaces
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_workspace(id: &str, pool: &Pool<Sqlite>) -> Result<Workspace, sqlx::Error> {
|
||||||
|
sqlx::query_as!(
|
||||||
|
Workspace,
|
||||||
|
r#"
|
||||||
|
SELECT id, model, created_at, updated_at, name, description
|
||||||
|
FROM workspaces WHERE id = ?
|
||||||
|
"#,
|
||||||
|
id,
|
||||||
|
)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_workspace(id: &str, pool: &Pool<Sqlite>) -> Result<Workspace, sqlx::Error> {
|
||||||
|
let workspace = get_workspace(id, pool).await?;
|
||||||
|
let _ = sqlx::query!(
|
||||||
|
r#"
|
||||||
|
DELETE FROM workspaces
|
||||||
|
WHERE id = ?
|
||||||
|
"#,
|
||||||
|
id,
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
for r in find_responses_by_workspace_id(id, pool).await? {
|
||||||
|
delete_response(&r.id, pool).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(workspace)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_workspace(
|
||||||
|
name: &str,
|
||||||
|
description: &str,
|
||||||
|
pool: &Pool<Sqlite>,
|
||||||
|
) -> Result<Workspace, sqlx::Error> {
|
||||||
|
let id = generate_id(Some("wk"));
|
||||||
|
sqlx::query!(
|
||||||
|
r#"
|
||||||
|
INSERT INTO workspaces (id, name, description)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
"#,
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.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?;
|
||||||
|
|
||||||
|
// TODO: Figure out how to make this better
|
||||||
|
let b2;
|
||||||
|
let body = match existing.body {
|
||||||
|
Some(b) => {
|
||||||
|
b2 = b;
|
||||||
|
Some(b2.as_str())
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
upsert_request(
|
||||||
|
None,
|
||||||
|
existing.workspace_id.as_str(),
|
||||||
|
existing.name.as_str(),
|
||||||
|
existing.method.as_str(),
|
||||||
|
body,
|
||||||
|
existing.body_type,
|
||||||
|
existing.authentication.0,
|
||||||
|
existing.authentication_type,
|
||||||
|
existing.url.as_str(),
|
||||||
|
existing.headers.0,
|
||||||
|
existing.sort_priority + 0.001,
|
||||||
|
pool,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn upsert_request(
|
||||||
|
id: Option<&str>,
|
||||||
|
workspace_id: &str,
|
||||||
|
name: &str,
|
||||||
|
method: &str,
|
||||||
|
body: Option<&str>,
|
||||||
|
body_type: Option<String>,
|
||||||
|
authentication: HashMap<String, JsonValue>,
|
||||||
|
authentication_type: Option<String>,
|
||||||
|
url: &str,
|
||||||
|
headers: Vec<HttpRequestHeader>,
|
||||||
|
sort_priority: f64,
|
||||||
|
pool: &Pool<Sqlite>,
|
||||||
|
) -> Result<HttpRequest, sqlx::Error> {
|
||||||
|
let generated_id;
|
||||||
|
let id = match id {
|
||||||
|
Some(v) => v,
|
||||||
|
None => {
|
||||||
|
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 (
|
||||||
|
id,
|
||||||
|
workspace_id,
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
method,
|
||||||
|
body,
|
||||||
|
body_type,
|
||||||
|
authentication,
|
||||||
|
authentication_type,
|
||||||
|
headers,
|
||||||
|
sort_priority
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
|
updated_at = CURRENT_TIMESTAMP,
|
||||||
|
name = excluded.name,
|
||||||
|
method = excluded.method,
|
||||||
|
headers = excluded.headers,
|
||||||
|
body = excluded.body,
|
||||||
|
body_type = excluded.body_type,
|
||||||
|
authentication = excluded.authentication,
|
||||||
|
authentication_type = excluded.authentication_type,
|
||||||
|
url = excluded.url,
|
||||||
|
sort_priority = excluded.sort_priority
|
||||||
|
"#,
|
||||||
|
id,
|
||||||
|
workspace_id,
|
||||||
|
trimmed_name,
|
||||||
|
url,
|
||||||
|
method,
|
||||||
|
body,
|
||||||
|
body_type,
|
||||||
|
auth_json,
|
||||||
|
authentication_type,
|
||||||
|
headers_json,
|
||||||
|
sort_priority,
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
get_request(id, pool).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn find_requests(
|
||||||
|
workspace_id: &str,
|
||||||
|
pool: &Pool<Sqlite>,
|
||||||
|
) -> Result<Vec<HttpRequest>, sqlx::Error> {
|
||||||
|
sqlx::query_as!(
|
||||||
|
HttpRequest,
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
model,
|
||||||
|
workspace_id,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
method,
|
||||||
|
body,
|
||||||
|
body_type,
|
||||||
|
authentication AS "authentication!: Json<HashMap<String, JsonValue>>",
|
||||||
|
authentication_type,
|
||||||
|
sort_priority,
|
||||||
|
headers AS "headers!: sqlx::types::Json<Vec<HttpRequestHeader>>"
|
||||||
|
FROM http_requests
|
||||||
|
WHERE workspace_id = ?
|
||||||
|
"#,
|
||||||
|
workspace_id,
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_request(id: &str, pool: &Pool<Sqlite>) -> Result<HttpRequest, sqlx::Error> {
|
||||||
|
sqlx::query_as!(
|
||||||
|
HttpRequest,
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
model,
|
||||||
|
workspace_id,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
method,
|
||||||
|
body,
|
||||||
|
body_type,
|
||||||
|
authentication AS "authentication!: Json<HashMap<String, JsonValue>>",
|
||||||
|
authentication_type,
|
||||||
|
sort_priority,
|
||||||
|
headers AS "headers!: sqlx::types::Json<Vec<HttpRequestHeader>>"
|
||||||
|
FROM http_requests
|
||||||
|
WHERE id = ?
|
||||||
|
"#,
|
||||||
|
id,
|
||||||
|
)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_request(id: &str, pool: &Pool<Sqlite>) -> Result<HttpRequest, sqlx::Error> {
|
||||||
|
let req = get_request(id, pool).await?;
|
||||||
|
let _ = sqlx::query!(
|
||||||
|
r#"
|
||||||
|
DELETE FROM http_requests
|
||||||
|
WHERE id = ?
|
||||||
|
"#,
|
||||||
|
id,
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
delete_all_responses(id, pool).await?;
|
||||||
|
|
||||||
|
Ok(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_response(
|
||||||
|
request_id: &str,
|
||||||
|
elapsed: i64,
|
||||||
|
url: &str,
|
||||||
|
status: i64,
|
||||||
|
status_reason: Option<&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?;
|
||||||
|
let id = generate_id(Some("rp"));
|
||||||
|
let headers_json = Json(headers);
|
||||||
|
sqlx::query!(
|
||||||
|
r#"
|
||||||
|
INSERT INTO http_responses (
|
||||||
|
id,
|
||||||
|
request_id,
|
||||||
|
workspace_id,
|
||||||
|
elapsed,
|
||||||
|
url,
|
||||||
|
status,
|
||||||
|
status_reason,
|
||||||
|
content_length,
|
||||||
|
body,
|
||||||
|
body_path,
|
||||||
|
headers
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
|
||||||
|
"#,
|
||||||
|
id,
|
||||||
|
request_id,
|
||||||
|
req.workspace_id,
|
||||||
|
elapsed,
|
||||||
|
url,
|
||||||
|
status,
|
||||||
|
status_reason,
|
||||||
|
content_length,
|
||||||
|
body,
|
||||||
|
body_path,
|
||||||
|
headers_json,
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
get_response(&id, pool).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_response_if_id(
|
||||||
|
response: &HttpResponse,
|
||||||
|
pool: &Pool<Sqlite>,
|
||||||
|
) -> Result<HttpResponse, sqlx::Error> {
|
||||||
|
if response.id == "" {
|
||||||
|
return Ok(response.clone());
|
||||||
|
}
|
||||||
|
return update_response(response, pool).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_workspace(
|
||||||
|
workspace: Workspace,
|
||||||
|
pool: &Pool<Sqlite>,
|
||||||
|
) -> Result<Workspace, sqlx::Error> {
|
||||||
|
let trimmed_name = workspace.name.trim();
|
||||||
|
sqlx::query!(
|
||||||
|
r#"
|
||||||
|
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?;
|
||||||
|
get_response(&response.id, pool).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_response(id: &str, pool: &Pool<Sqlite>) -> Result<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 id = ?
|
||||||
|
"#,
|
||||||
|
id,
|
||||||
|
)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn find_responses(
|
||||||
|
request_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 request_id = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
"#,
|
||||||
|
request_id,
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.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?;
|
||||||
|
|
||||||
|
// 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#"
|
||||||
|
DELETE FROM http_responses
|
||||||
|
WHERE id = ?
|
||||||
|
"#,
|
||||||
|
id,
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_all_responses(
|
||||||
|
request_id: &str,
|
||||||
|
pool: &Pool<Sqlite>,
|
||||||
|
) -> Result<(), sqlx::Error> {
|
||||||
|
for r in find_responses(request_id, pool).await? {
|
||||||
|
delete_response(&r.id, pool).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
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}"),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
(function (globalThis) {
|
(function (globalThis) {
|
||||||
Deno.core.initializeAsyncOps();
|
// Deno.core.print(Object.keys(Deno.core).join('\n'));
|
||||||
|
|
||||||
function argsToMessage(...args) {
|
function argsToMessage(...args) {
|
||||||
return args.map((arg) => JSON.stringify(arg)).join(' ');
|
return args.map((arg) => JSON.stringify(arg)).join(' ');
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
use deno_ast::{MediaType, ParseParams, SourceTextInfo};
|
use std::cell::RefCell;
|
||||||
use deno_core::error::AnyError;
|
use std::collections::HashMap;
|
||||||
use deno_core::{op, Extension, JsRuntime, ModuleSource, ModuleType, RuntimeOptions};
|
use std::pin::Pin;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use deno_ast::{MediaType, ParseParams, SourceTextInfo};
|
||||||
|
use deno_core::anyhow::{anyhow, bail, Error};
|
||||||
|
use deno_core::error::AnyError;
|
||||||
use deno_core::futures::FutureExt;
|
use deno_core::futures::FutureExt;
|
||||||
|
use deno_core::{
|
||||||
|
resolve_import, Extension, JsRuntime, ModuleLoader, ModuleSource, ModuleSourceFuture,
|
||||||
|
ModuleSpecifier, ModuleType, ResolutionKind, RuntimeOptions, SourceMapGetter,
|
||||||
|
};
|
||||||
use futures::executor;
|
use futures::executor;
|
||||||
|
|
||||||
pub fn run_plugin_sync(file_path: &str) -> Result<(), AnyError> {
|
pub fn run_plugin_sync(file_path: &str) -> Result<(), AnyError> {
|
||||||
@@ -11,61 +18,85 @@ pub fn run_plugin_sync(file_path: &str) -> Result<(), AnyError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn run_plugin(file_path: &str) -> Result<(), AnyError> {
|
pub async fn run_plugin(file_path: &str) -> Result<(), AnyError> {
|
||||||
let extension = Extension::builder("runtime")
|
let extension = Extension {
|
||||||
.ops(vec![op_hello::decl()])
|
name: "runtime",
|
||||||
.build();
|
// ops: std::borrow::Cow::Borrowed(&[op_hello::DECL]),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let source_map_store = SourceMapStore(Rc::new(RefCell::new(HashMap::new())));
|
||||||
|
|
||||||
// Initialize a runtime instance
|
// Initialize a runtime instance
|
||||||
let mut runtime = JsRuntime::new(RuntimeOptions {
|
let mut runtime = JsRuntime::new(RuntimeOptions {
|
||||||
module_loader: Some(Rc::new(TsModuleLoader)),
|
module_loader: Some(Rc::new(TypescriptModuleLoader {
|
||||||
|
source_maps: source_map_store.clone(),
|
||||||
|
})),
|
||||||
extensions: vec![extension],
|
extensions: vec![extension],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
|
|
||||||
runtime
|
runtime
|
||||||
.execute_script("<runtime>", include_str!("runtime.js"))
|
.execute_script_static("<runtime>", include_str!("runtime.js"))
|
||||||
.unwrap();
|
.expect("Failed to execute runtime.js");
|
||||||
|
|
||||||
let main_module = deno_core::resolve_path(file_path)?;
|
let current_dir = &std::env::current_dir().expect("Unable to get CWD");
|
||||||
let mod_id = runtime.load_main_module(&main_module, None).await?;
|
let main_module =
|
||||||
|
deno_core::resolve_path(file_path, current_dir).expect("Failed to resolve path");
|
||||||
|
let mod_id = runtime
|
||||||
|
.load_main_module(&main_module, None)
|
||||||
|
.await
|
||||||
|
.expect("Failed to load main module");
|
||||||
let result = runtime.mod_evaluate(mod_id);
|
let result = runtime.mod_evaluate(mod_id);
|
||||||
runtime.run_event_loop(false).await?;
|
runtime
|
||||||
|
.run_event_loop(false)
|
||||||
|
.await
|
||||||
|
.expect("Failed to run event loop");
|
||||||
result.await?
|
result.await?
|
||||||
}
|
}
|
||||||
|
|
||||||
#[op]
|
#[derive(Clone)]
|
||||||
async fn op_hello(name: String) -> Result<String, AnyError> {
|
struct SourceMapStore(Rc<RefCell<HashMap<String, Vec<u8>>>>);
|
||||||
let contents = format!("Hello {} from Rust!", name);
|
|
||||||
println!("{}", contents);
|
impl SourceMapGetter for SourceMapStore {
|
||||||
Ok(contents)
|
fn get_source_map(&self, specifier: &str) -> Option<Vec<u8>> {
|
||||||
|
self.0.borrow().get(specifier).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_source_line(&self, _file_name: &str, _line_number: usize) -> Option<String> {
|
||||||
|
None
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct TsModuleLoader;
|
struct TypescriptModuleLoader {
|
||||||
|
source_maps: SourceMapStore,
|
||||||
|
}
|
||||||
|
|
||||||
impl deno_core::ModuleLoader for TsModuleLoader {
|
impl ModuleLoader for TypescriptModuleLoader {
|
||||||
fn resolve(
|
fn resolve(
|
||||||
&self,
|
&self,
|
||||||
specifier: &str,
|
specifier: &str,
|
||||||
referrer: &str,
|
referrer: &str,
|
||||||
_kind: deno_core::ResolutionKind,
|
_kind: ResolutionKind,
|
||||||
) -> Result<deno_core::ModuleSpecifier, AnyError> {
|
) -> Result<ModuleSpecifier, Error> {
|
||||||
deno_core::resolve_import(specifier, referrer).map_err(|e| e.into())
|
Ok(resolve_import(specifier, referrer)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load(
|
fn load(
|
||||||
&self,
|
&self,
|
||||||
module_specifier: &deno_core::ModuleSpecifier,
|
module_specifier: &ModuleSpecifier,
|
||||||
_maybe_referrer: Option<deno_core::ModuleSpecifier>,
|
_maybe_referrer: Option<&ModuleSpecifier>,
|
||||||
_is_dyn_import: bool,
|
_is_dyn_import: bool,
|
||||||
) -> std::pin::Pin<Box<deno_core::ModuleSourceFuture>> {
|
) -> Pin<Box<ModuleSourceFuture>> {
|
||||||
let module_specifier = module_specifier.clone();
|
let source_maps = self.source_maps.clone();
|
||||||
async move {
|
fn load(
|
||||||
let path = module_specifier.to_file_path().unwrap();
|
source_maps: SourceMapStore,
|
||||||
|
module_specifier: &ModuleSpecifier,
|
||||||
|
) -> Result<ModuleSource, AnyError> {
|
||||||
|
let path = module_specifier
|
||||||
|
.to_file_path()
|
||||||
|
.map_err(|_| anyhow!("Only file:// URLs are supported."))?;
|
||||||
|
|
||||||
// Determine what the MediaType is (this is done based on the file
|
let media_type = MediaType::from_path(&path);
|
||||||
// extension) and whether transpiling is required.
|
let (module_type, should_transpile) = match MediaType::from_path(&path) {
|
||||||
let media_type = MediaType::from(&path);
|
|
||||||
let (module_type, should_transpile) = match MediaType::from(&path) {
|
|
||||||
MediaType::JavaScript | MediaType::Mjs | MediaType::Cjs => {
|
MediaType::JavaScript | MediaType::Mjs | MediaType::Cjs => {
|
||||||
(ModuleType::JavaScript, false)
|
(ModuleType::JavaScript, false)
|
||||||
}
|
}
|
||||||
@@ -78,10 +109,9 @@ impl deno_core::ModuleLoader for TsModuleLoader {
|
|||||||
| MediaType::Dcts
|
| MediaType::Dcts
|
||||||
| MediaType::Tsx => (ModuleType::JavaScript, true),
|
| MediaType::Tsx => (ModuleType::JavaScript, true),
|
||||||
MediaType::Json => (ModuleType::Json, false),
|
MediaType::Json => (ModuleType::Json, false),
|
||||||
_ => panic!("Unknown extension {:?}", path.extension()),
|
_ => bail!("Unknown extension {:?}", path.extension()),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Read the file, transpile if necessary.
|
|
||||||
let code = std::fs::read_to_string(&path)?;
|
let code = std::fs::read_to_string(&path)?;
|
||||||
let code = if should_transpile {
|
let code = if should_transpile {
|
||||||
let parsed = deno_ast::parse_module(ParseParams {
|
let parsed = deno_ast::parse_module(ParseParams {
|
||||||
@@ -92,20 +122,28 @@ impl deno_core::ModuleLoader for TsModuleLoader {
|
|||||||
scope_analysis: false,
|
scope_analysis: false,
|
||||||
maybe_syntax: None,
|
maybe_syntax: None,
|
||||||
})?;
|
})?;
|
||||||
parsed.transpile(&Default::default())?.text
|
let res = parsed.transpile(&deno_ast::EmitOptions {
|
||||||
|
inline_source_map: false,
|
||||||
|
source_map: true,
|
||||||
|
inline_sources: true,
|
||||||
|
..Default::default()
|
||||||
|
})?;
|
||||||
|
let source_map = res.source_map.unwrap();
|
||||||
|
source_maps
|
||||||
|
.0
|
||||||
|
.borrow_mut()
|
||||||
|
.insert(module_specifier.to_string(), source_map.into_bytes());
|
||||||
|
res.text
|
||||||
} else {
|
} else {
|
||||||
code
|
code
|
||||||
};
|
};
|
||||||
|
Ok(ModuleSource::new(
|
||||||
// Load and return module.
|
|
||||||
let module = ModuleSource {
|
|
||||||
code: code.into_bytes().into_boxed_slice(),
|
|
||||||
module_type,
|
module_type,
|
||||||
module_url_specified: module_specifier.to_string(),
|
code.into(),
|
||||||
module_url_found: module_specifier.to_string(),
|
module_specifier,
|
||||||
};
|
))
|
||||||
Ok(module)
|
|
||||||
}
|
}
|
||||||
.boxed_local()
|
|
||||||
|
futures::future::ready(load(source_maps, module_specifier)).boxed_local()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
use tauri::{Runtime, Window};
|
use tauri::{Runtime, Window};
|
||||||
|
|
||||||
const TRAFFIC_LIGHT_OFFSET_X: f64 = 15.0;
|
const TRAFFIC_LIGHT_OFFSET_X: f64 = 13.0;
|
||||||
const TRAFFIC_LIGHT_OFFSET_Y: f64 = 20.0;
|
const TRAFFIC_LIGHT_OFFSET_Y: f64 = 18.0;
|
||||||
|
|
||||||
pub trait WindowExt {
|
pub trait WindowExt {
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
fn position_traffic_lights(&self);
|
fn position_traffic_lights(&self);
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<R: Runtime> WindowExt for Window<R> {
|
impl<R: Runtime> WindowExt for Window<R> {
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
fn position_traffic_lights(&self) {
|
||||||
|
// No-op
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
fn position_traffic_lights(&self) {
|
fn position_traffic_lights(&self) {
|
||||||
use cocoa::appkit::{NSView, NSWindow, NSWindowButton};
|
use cocoa::appkit::{NSView, NSWindow, NSWindowButton};
|
||||||
|
|||||||
94
src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
{
|
||||||
|
"build": {
|
||||||
|
"beforeBuildCommand": "npm run build",
|
||||||
|
"beforeDevCommand": "npm run dev",
|
||||||
|
"devPath": "http://localhost:1420",
|
||||||
|
"distDir": "../dist",
|
||||||
|
"withGlobalTauri": false
|
||||||
|
},
|
||||||
|
"package": {
|
||||||
|
"productName": "Yaak",
|
||||||
|
"version": "2023.0.19"
|
||||||
|
},
|
||||||
|
"tauri": {
|
||||||
|
"windows": [],
|
||||||
|
"allowlist": {
|
||||||
|
"all": false,
|
||||||
|
"os": {
|
||||||
|
"all": true
|
||||||
|
},
|
||||||
|
"protocol": {
|
||||||
|
"assetScope": ["$APPDATA/responses/*"],
|
||||||
|
"asset": true
|
||||||
|
},
|
||||||
|
"fs": {
|
||||||
|
"readFile": true,
|
||||||
|
"scope": [
|
||||||
|
"$RESOURCE/*",
|
||||||
|
"$APPDATA/responses/*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"shell": {
|
||||||
|
"all": false,
|
||||||
|
"open": true
|
||||||
|
},
|
||||||
|
"window": {
|
||||||
|
"startDragging": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"category": "DeveloperTool",
|
||||||
|
"copyright": "",
|
||||||
|
"externalBin": [],
|
||||||
|
"icon": [
|
||||||
|
"icons/32x32.png",
|
||||||
|
"icons/128x128.png",
|
||||||
|
"icons/128x128@2x.png",
|
||||||
|
"icons/icon.icns",
|
||||||
|
"icons/icon.ico"
|
||||||
|
],
|
||||||
|
"identifier": "co.schier.yaak",
|
||||||
|
"longDescription": "The best cross-platform visual API client",
|
||||||
|
"resources": [
|
||||||
|
"plugins/*",
|
||||||
|
"migrations/*"
|
||||||
|
],
|
||||||
|
"shortDescription": "The best API client",
|
||||||
|
"targets": [
|
||||||
|
"deb",
|
||||||
|
"appimage",
|
||||||
|
"nsis",
|
||||||
|
"app",
|
||||||
|
"dmg",
|
||||||
|
"updater"
|
||||||
|
],
|
||||||
|
"deb": {
|
||||||
|
"depends": []
|
||||||
|
},
|
||||||
|
"macOS": {
|
||||||
|
"exceptionDomain": "",
|
||||||
|
"entitlements": "macos/entitlements.plist",
|
||||||
|
"frameworks": []
|
||||||
|
},
|
||||||
|
"windows": {
|
||||||
|
"digestAlgorithm": "sha256",
|
||||||
|
"timestampUrl": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
},
|
||||||
|
"systemTray": {
|
||||||
|
"iconAsTemplate": true,
|
||||||
|
"iconPath": "icons/icon.png"
|
||||||
|
},
|
||||||
|
"updater": {
|
||||||
|
"active": true,
|
||||||
|
"dialog": true,
|
||||||
|
"endpoints": [
|
||||||
|
"https://update.yaak.app/check/{{target}}/{{arch}}/{{current_version}}"
|
||||||
|
],
|
||||||
|
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEMxRDJFREQ1MjExQjdGN0IKUldSN2Z4c2gxZTNTd1FHNCtmYnFXMHVVQzhuNkJOM1cwOFBodmdLall3ckhKenpKUytHSTR1MlkK"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
[build]
|
|
||||||
beforeDevCommand = "npm run dev"
|
|
||||||
beforeBuildCommand = "npm run build"
|
|
||||||
devPath = "http://localhost:1420"
|
|
||||||
distDir = "../dist"
|
|
||||||
withGlobalTauri = false
|
|
||||||
|
|
||||||
[package]
|
|
||||||
productName = "Twosomnia"
|
|
||||||
version = "0.0.1"
|
|
||||||
|
|
||||||
[tauri.allowlist]
|
|
||||||
all = false
|
|
||||||
|
|
||||||
[tauri.allowlist.shell]
|
|
||||||
all = false
|
|
||||||
open = true
|
|
||||||
|
|
||||||
[tauri.allowlist.window]
|
|
||||||
startDragging = true
|
|
||||||
|
|
||||||
[tauri.allowlist.fs]
|
|
||||||
scope = [ "$RESOURCE/*" ]
|
|
||||||
|
|
||||||
[tauri.bundle]
|
|
||||||
active = true
|
|
||||||
category = "DeveloperTool"
|
|
||||||
copyright = ""
|
|
||||||
externalBin = [ ]
|
|
||||||
icon = [
|
|
||||||
"icons/32x32.png",
|
|
||||||
"icons/128x128.png",
|
|
||||||
"icons/128x128@2x.png",
|
|
||||||
"icons/icon.icns",
|
|
||||||
"icons/icon.ico"
|
|
||||||
]
|
|
||||||
identifier = "co.schier.twosomnia"
|
|
||||||
longDescription = ""
|
|
||||||
resources = [ "plugins/*" ]
|
|
||||||
shortDescription = ""
|
|
||||||
targets = "all"
|
|
||||||
|
|
||||||
[tauri.bundle.deb]
|
|
||||||
depends = [ ]
|
|
||||||
|
|
||||||
[tauri.bundle.macOS]
|
|
||||||
exceptionDomain = ""
|
|
||||||
frameworks = [ ]
|
|
||||||
|
|
||||||
[tauri.bundle.windows]
|
|
||||||
digestAlgorithm = "sha256"
|
|
||||||
timestampUrl = ""
|
|
||||||
|
|
||||||
[tauri.security]
|
|
||||||
|
|
||||||
[tauri.updater]
|
|
||||||
active = false
|
|
||||||
endpoints = [ ]
|
|
||||||
pubkey = ""
|
|
||||||
dialog = true
|
|
||||||
|
|
||||||
[[tauri.windows]]
|
|
||||||
fullscreen = false
|
|
||||||
height = 800
|
|
||||||
resizable = true
|
|
||||||
title = "Twosomnia"
|
|
||||||
width = 1_400
|
|
||||||
titleBarStyle = "Overlay"
|
|
||||||
hiddenTitle = true
|
|
||||||
|
|
||||||
[tauri.systemTray]
|
|
||||||
iconPath = "icons/icon.png"
|
|
||||||
iconAsTemplate = true
|
|
||||||
6
src-wasm/hello/.gitignore
vendored
@@ -1,6 +0,0 @@
|
|||||||
/target
|
|
||||||
**/*.rs.bk
|
|
||||||
Cargo.lock
|
|
||||||
bin/
|
|
||||||
pkg/
|
|
||||||
wasm-pack.log
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "hello"
|
|
||||||
version = "0.1.0"
|
|
||||||
authors = ["Gregory Schier <gschier1990@gmail.com>"]
|
|
||||||
edition = "2018"
|
|
||||||
|
|
||||||
[lib]
|
|
||||||
crate-type = ["cdylib", "rlib"]
|
|
||||||
|
|
||||||
[features]
|
|
||||||
default = ["console_error_panic_hook"]
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
wasm-bindgen = "0.2.63"
|
|
||||||
|
|
||||||
# The `console_error_panic_hook` crate provides better debugging of panics by
|
|
||||||
# logging them with `console.error`. This is great for development, but requires
|
|
||||||
# all the `std::fmt` and `std::panicking` infrastructure, so isn't great for
|
|
||||||
# code size when deploying.
|
|
||||||
console_error_panic_hook = { version = "0.1.6", optional = true }
|
|
||||||
|
|
||||||
# `wee_alloc` is a tiny allocator for wasm that is only ~1K in code size
|
|
||||||
# compared to the default allocator's ~10K. It is slower than the default
|
|
||||||
# allocator, however.
|
|
||||||
wee_alloc = { version = "0.4.5", optional = true }
|
|
||||||
wasm-bindgen-futures = "0.4.34"
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
wasm-bindgen-test = "0.3.13"
|
|
||||||
|
|
||||||
[profile.release]
|
|
||||||
# Tell `rustc` to optimize for small code size.
|
|
||||||
opt-level = "s"
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
use wasm_bindgen::prelude::*;
|
|
||||||
|
|
||||||
mod utils;
|
|
||||||
|
|
||||||
// When the `wee_alloc` feature is enabled, use `wee_alloc` as the global allocator.
|
|
||||||
#[cfg(feature = "wee_alloc")]
|
|
||||||
#[global_allocator]
|
|
||||||
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
|
|
||||||
|
|
||||||
#[wasm_bindgen]
|
|
||||||
extern "C" {
|
|
||||||
fn alert(s: &str);
|
|
||||||
|
|
||||||
#[wasm_bindgen(js_namespace = console)]
|
|
||||||
fn log(s: &str);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn greet() {
|
|
||||||
log("Hello from Rust WASM!");
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
pub fn set_panic_hook() {
|
|
||||||
// When the `console_error_panic_hook` feature is enabled, we can call the
|
|
||||||
// `set_panic_hook` function at least once during initialization, and then
|
|
||||||
// we will get better error messages if our code ever panics.
|
|
||||||
//
|
|
||||||
// For more details see
|
|
||||||
// https://github.com/rustwasm/console_error_panic_hook#readme
|
|
||||||
#[cfg(feature = "console_error_panic_hook")]
|
|
||||||
console_error_panic_hook::set_once();
|
|
||||||
}
|
|
||||||
137
src-web/App.tsx
@@ -1,137 +0,0 @@
|
|||||||
import { FormEvent, useState } from 'react';
|
|
||||||
import { invoke } from '@tauri-apps/api/tauri';
|
|
||||||
import Editor from './components/Editor/Editor';
|
|
||||||
import { Input } from './components/Input';
|
|
||||||
import { HStack, VStack } from './components/Stacks';
|
|
||||||
import { Button } from './components/Button';
|
|
||||||
import { DropdownMenuRadio } from './components/Dropdown';
|
|
||||||
import { WindowDragRegion } from './components/WindowDragRegion';
|
|
||||||
import { IconButton } from './components/IconButton';
|
|
||||||
|
|
||||||
interface Response {
|
|
||||||
url: string;
|
|
||||||
method: string;
|
|
||||||
body: string;
|
|
||||||
status: string;
|
|
||||||
elapsed: number;
|
|
||||||
elapsed2: number;
|
|
||||||
headers: Record<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [response, setResponse] = useState<Response | null>(null);
|
|
||||||
const [url, setUrl] = useState('https://go-server.schier.dev/debug');
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [method, setMethod] = useState<string>('get');
|
|
||||||
|
|
||||||
async function sendRequest(e: FormEvent<HTMLFormElement>) {
|
|
||||||
e.preventDefault();
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const resp = (await invoke('send_request', { method, url })) as Response;
|
|
||||||
if (resp.body.includes('<head>')) {
|
|
||||||
resp.body = resp.body.replace(/<head>/gi, `<head><base href="${resp.url}"/>`);
|
|
||||||
}
|
|
||||||
setLoading(false);
|
|
||||||
setResponse(resp);
|
|
||||||
} catch (err) {
|
|
||||||
setLoading(false);
|
|
||||||
setError(`${err}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentType = response?.headers['content-type']?.split(';')[0] ?? 'text/plain';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="grid grid-cols-[auto_1fr] h-full">
|
|
||||||
<nav className="w-52 bg-gray-50 h-full border-r border-gray-500/10">
|
|
||||||
<HStack as={WindowDragRegion} className="pl-24 px-1" items="center" justify="end">
|
|
||||||
<IconButton icon="archive" size="sm" />
|
|
||||||
<DropdownMenuRadio
|
|
||||||
onValueChange={null}
|
|
||||||
value={'get'}
|
|
||||||
items={[
|
|
||||||
{ label: 'This is a cool one', value: 'get' },
|
|
||||||
{ label: 'But this one is better', value: 'put' },
|
|
||||||
{ label: 'This one is just alright', value: 'post' },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<IconButton icon="camera" size="sm" />
|
|
||||||
</DropdownMenuRadio>
|
|
||||||
</HStack>
|
|
||||||
</nav>
|
|
||||||
<VStack className="w-full">
|
|
||||||
<HStack as={WindowDragRegion} items="center" className="pl-4 pr-1">
|
|
||||||
<h5>Hello, Friend!</h5>
|
|
||||||
<IconButton icon="gear" className="ml-auto" size="sm" />
|
|
||||||
</HStack>
|
|
||||||
<VStack className="p-4 max-w-[35rem] mx-auto" space={3}>
|
|
||||||
<HStack as="form" className="items-end" onSubmit={sendRequest} space={2}>
|
|
||||||
<DropdownMenuRadio
|
|
||||||
onValueChange={setMethod}
|
|
||||||
value={method}
|
|
||||||
items={[
|
|
||||||
{ label: 'GET', value: 'get' },
|
|
||||||
{ label: 'PUT', value: 'put' },
|
|
||||||
{ label: 'POST', value: 'post' },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Button disabled={loading} color="secondary" forDropdown>
|
|
||||||
{method.toUpperCase()}
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuRadio>
|
|
||||||
<HStack>
|
|
||||||
<Input
|
|
||||||
hideLabel
|
|
||||||
name="url"
|
|
||||||
label="Enter URL"
|
|
||||||
className="rounded-r-none font-mono"
|
|
||||||
onChange={(e) => setUrl(e.currentTarget.value)}
|
|
||||||
value={url}
|
|
||||||
placeholder="Enter a URL..."
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
className="mr-1 rounded-l-none -ml-3"
|
|
||||||
color="primary"
|
|
||||||
type="submit"
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{loading ? 'Sending...' : 'Send'}
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
|
||||||
</HStack>
|
|
||||||
{error && <div className="text-white bg-red-500 px-4 py-1 rounded">{error}</div>}
|
|
||||||
{response !== null && (
|
|
||||||
<>
|
|
||||||
<div className="my-1 italic text-gray-500 text-sm">
|
|
||||||
{response?.method.toUpperCase()}
|
|
||||||
•
|
|
||||||
{response?.status}
|
|
||||||
•
|
|
||||||
{response?.elapsed}ms •
|
|
||||||
{response?.elapsed2}ms
|
|
||||||
</div>
|
|
||||||
{contentType.includes('html') ? (
|
|
||||||
<iframe
|
|
||||||
title="Response preview"
|
|
||||||
srcDoc={response.body}
|
|
||||||
sandbox="allow-scripts allow-same-origin"
|
|
||||||
className="h-[70vh] w-full rounded-lg"
|
|
||||||
/>
|
|
||||||
) : response?.body ? (
|
|
||||||
<Editor value={response?.body} contentType={contentType} />
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</VStack>
|
|
||||||
</VStack>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App;
|
|
||||||
5
src-web/assets/icons/LeftPanelHiddenIcon.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg width="100%" height="100%" viewBox="0 0 15 15" version="1.1" xmlns="http://www.w3.org/2000/svg"
|
||||||
|
style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||||
|
<path fill="currentColor"
|
||||||
|
d="M2.5,1C1.672,1 1,1.672 1,2.5L1,12.5C1,13.328 1.672,14 2.5,14L12.5,14C13.328,14 14,13.328 14,12.5L14,2.5C14,1.672 13.328,1 12.5,1L2.5,1ZM12.5,13C12.776,13 13,12.776 13,12.5L13,2.5C13,2.224 12.776,2 12.5,2L6,2L6,13L12.5,13ZM2.5,2L5,2L5,13L2.5,13C2.224,13 2,12.776 2,12.5L2,2.5C2,2.224 2.224,2 2.5,2Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 553 B |
6
src-web/assets/icons/LeftPanelVisibleIcon.svg
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<svg width="100%" height="100%" viewBox="0 0 15 15" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||||
|
<rect x="0" y="0" width="15" height="15" style="fill:none;"/>
|
||||||
|
<g transform="matrix(1,0,0,1,-16,-8.88178e-16)">
|
||||||
|
<path fill="currentColor" d="M18.5,1C17.672,1 17,1.672 17,2.5L17,12.5C17,13.328 17.672,14 18.5,14L28.5,14C29.328,14 30,13.328 30,12.5L30,2.5C30,1.672 29.328,1 28.5,1L18.5,1ZM28.5,13C28.776,13 29,12.776 29,12.5L29,2.5C29,2.224 28.776,2 28.5,2L22,2L22,13L28.5,13ZM18,11.535L21,12.285L21,13L18.5,13C18.224,13 18,12.776 18,12.5L18,11.535ZM18,10.504L21,11.254L21,9.81L18,9.06L18,10.504ZM18,8.029L21,8.779L21,7.327L18,6.577L18,8.029ZM18,5.546L21,6.296L21,4.833L18,4.083L18,5.546ZM21,3.802L18,3.052L18,2.5C18,2.224 18.224,2 18.5,2L21,2L21,3.802Z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1006 B |
38
src-web/components/App.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { MotionConfig } from 'framer-motion';
|
||||||
|
import { Suspense } from 'react';
|
||||||
|
import { DndProvider } from 'react-dnd';
|
||||||
|
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||||
|
import { HelmetProvider } from 'react-helmet-async';
|
||||||
|
import { AppRouter } from './AppRouter';
|
||||||
|
import { DialogProvider } from './DialogContext';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
logger: undefined,
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: false,
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
|
networkMode: 'offlineFirst',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<MotionConfig transition={{ duration: 0.1 }}>
|
||||||
|
<HelmetProvider>
|
||||||
|
<DndProvider backend={HTML5Backend}>
|
||||||
|
<DialogProvider>
|
||||||
|
<Suspense>
|
||||||
|
<AppRouter />
|
||||||
|
{/*<ReactQueryDevtools initialIsOpen={false} />*/}
|
||||||
|
</Suspense>
|
||||||
|
</DialogProvider>
|
||||||
|
</DndProvider>
|
||||||
|
</HelmetProvider>
|
||||||
|
</MotionConfig>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
src-web/components/AppRouter.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
const router = createBrowserRouter([
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
errorElement: <RouteError />,
|
||||||
|
element: <Layout />,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
element: <Navigate to={routePaths.workspaces()} replace={true} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: routePaths.workspaces(),
|
||||||
|
element: <Workspaces />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: routePaths.workspace({ workspaceId: ':workspaceId' }),
|
||||||
|
element: <WorkspaceOrRedirect />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: routePaths.request({
|
||||||
|
workspaceId: ':workspaceId',
|
||||||
|
requestId: ':requestId',
|
||||||
|
}),
|
||||||
|
element: <Workspace />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function AppRouter() {
|
||||||
|
return <RouterProvider router={router} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
src-web/components/BasicAuth.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { useUpdateRequest } from '../hooks/useUpdateRequest';
|
||||||
|
import type { HttpRequest } from '../lib/models';
|
||||||
|
import { Input } from './core/Input';
|
||||||
|
import { VStack } from './core/Stacks';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
requestId: string;
|
||||||
|
authentication: HttpRequest['authentication'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BasicAuth({ requestId, authentication }: Props) {
|
||||||
|
const updateRequest = useUpdateRequest(requestId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VStack className="my-2" space={2}>
|
||||||
|
<Input
|
||||||
|
label="Username"
|
||||||
|
name="username"
|
||||||
|
size="sm"
|
||||||
|
defaultValue={`${authentication.username}`}
|
||||||
|
onChange={(username: string) => {
|
||||||
|
updateRequest.mutate((r) => ({
|
||||||
|
...r,
|
||||||
|
authentication: { password: r.authentication.password, username },
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Password"
|
||||||
|
name="password"
|
||||||
|
size="sm"
|
||||||
|
type="password"
|
||||||
|
defaultValue={`${authentication.password}`}
|
||||||
|
onChange={(password: string) => {
|
||||||
|
updateRequest.mutate((r) => ({
|
||||||
|
...r,
|
||||||
|
authentication: { username: r.authentication.username, password },
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
src-web/components/BearerAuth.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { useUpdateRequest } from '../hooks/useUpdateRequest';
|
||||||
|
import type { HttpRequest } from '../lib/models';
|
||||||
|
import { Input } from './core/Input';
|
||||||
|
import { VStack } from './core/Stacks';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
requestId: string;
|
||||||
|
authentication: HttpRequest['authentication'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BearerAuth({ requestId, authentication }: Props) {
|
||||||
|
const updateRequest = useUpdateRequest(requestId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VStack className="my-2" space={2}>
|
||||||
|
<Input
|
||||||
|
label="Token"
|
||||||
|
name="token"
|
||||||
|
size="sm"
|
||||||
|
defaultValue={`${authentication.token}`}
|
||||||
|
onChange={(token: string) => {
|
||||||
|
updateRequest.mutate((r) => ({
|
||||||
|
...r,
|
||||||
|
authentication: { token },
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import classnames from 'classnames';
|
|
||||||
import { ButtonHTMLAttributes, forwardRef } from 'react';
|
|
||||||
import { Icon } from './Icon';
|
|
||||||
|
|
||||||
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
|
||||||
color?: 'primary' | 'secondary';
|
|
||||||
size?: 'sm' | 'md';
|
|
||||||
forDropdown?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
|
||||||
{ className, children, size = 'md', forDropdown, color, ...props }: ButtonProps,
|
|
||||||
ref,
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
ref={ref}
|
|
||||||
className={classnames(
|
|
||||||
className,
|
|
||||||
'rounded-md text-white flex items-center',
|
|
||||||
{ 'h-10 px-4': size === 'md' },
|
|
||||||
{ 'h-8 px-3': size === 'sm' },
|
|
||||||
{ 'hover:bg-gray-500/[0.1] active:bg-gray-500/[0.15]': color === undefined },
|
|
||||||
{ 'bg-blue-500 hover:bg-blue-500/90 active:bg-blue-500/80': color === 'primary' },
|
|
||||||
{ 'bg-violet-500 hover:bg-violet-500/90 active:bg-violet-500/80': color === 'secondary' },
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
{forDropdown && <Icon icon="triangle-down" className="ml-1 -mr-1" />}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
64
src-web/components/DialogContext.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import React, { createContext, useContext, useMemo, useState } from 'react';
|
||||||
|
import type { DialogProps } from './core/Dialog';
|
||||||
|
import { Dialog } from './core/Dialog';
|
||||||
|
|
||||||
|
type DialogEntry = {
|
||||||
|
id: string;
|
||||||
|
render: ({ hide }: { hide: () => void }) => React.ReactNode;
|
||||||
|
} & Pick<DialogProps, 'title' | 'description' | 'hideX' | 'className' | 'size'>;
|
||||||
|
|
||||||
|
type DialogEntryOptionalId = Omit<DialogEntry, 'id'> & { id?: string };
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
dialogs: DialogEntry[];
|
||||||
|
actions: Actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Actions {
|
||||||
|
show: (d: DialogEntryOptionalId) => void;
|
||||||
|
hide: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const DialogContext = createContext<State>({} as any);
|
||||||
|
|
||||||
|
export const DialogProvider = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
const [dialogs, setDialogs] = useState<State['dialogs']>([]);
|
||||||
|
const actions = useMemo<Actions>(
|
||||||
|
() => ({
|
||||||
|
show: ({ id: oid, ...props }: DialogEntryOptionalId) => {
|
||||||
|
const id = oid ?? Math.random().toString(36).slice(2);
|
||||||
|
setDialogs((a) => [...a.filter((d) => d.id !== id), { id, ...props }]);
|
||||||
|
},
|
||||||
|
hide: (id: string) => {
|
||||||
|
setDialogs((a) => a.filter((d) => d.id !== id));
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const state: State = {
|
||||||
|
dialogs,
|
||||||
|
actions,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogContext.Provider value={state}>
|
||||||
|
{children}
|
||||||
|
{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;
|
||||||
22
src-web/components/DropMarker.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import classnames from 'classnames';
|
||||||
|
import React, { memo } from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DropMarker = memo(
|
||||||
|
function DropMarker({ className }: Props) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classnames(
|
||||||
|
className,
|
||||||
|
'relative w-full h-0 overflow-visible pointer-events-none',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="absolute z-50 left-2 right-2 -bottom-[0.1rem] h-[0.2rem] bg-blue-500/50 rounded-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
() => true,
|
||||||
|
);
|
||||||
@@ -1,311 +0,0 @@
|
|||||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
|
||||||
import { DropdownMenuRadioGroup } from '@radix-ui/react-dropdown-menu';
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import {
|
|
||||||
CheckIcon,
|
|
||||||
ChevronRightIcon,
|
|
||||||
DotFilledIcon,
|
|
||||||
HamburgerMenuIcon,
|
|
||||||
} from '@radix-ui/react-icons';
|
|
||||||
import { forwardRef, HTMLAttributes, ReactNode, useState } from 'react';
|
|
||||||
import { Button } from './Button';
|
|
||||||
import classnames from 'classnames';
|
|
||||||
import { HotKey } from './HotKey';
|
|
||||||
|
|
||||||
interface DropdownMenuRadioProps {
|
|
||||||
children: ReactNode;
|
|
||||||
onValueChange: ((value: string) => void) | null;
|
|
||||||
value: string;
|
|
||||||
items: {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
}[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DropdownMenuRadio({
|
|
||||||
children,
|
|
||||||
items,
|
|
||||||
onValueChange,
|
|
||||||
value,
|
|
||||||
}: DropdownMenuRadioProps) {
|
|
||||||
return (
|
|
||||||
<DropdownMenu.Root>
|
|
||||||
<DropdownMenuTrigger>{children}</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuPortal>
|
|
||||||
<DropdownMenuContent>
|
|
||||||
<DropdownMenuRadioGroup onValueChange={onValueChange ?? undefined} value={value}>
|
|
||||||
{items.map((item) => (
|
|
||||||
<DropdownMenuRadioItem key={item.value} value={item.value}>
|
|
||||||
{item.label}
|
|
||||||
</DropdownMenuRadioItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuRadioGroup>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenuPortal>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Dropdown() {
|
|
||||||
const [bookmarksChecked, setBookmarksChecked] = useState(true);
|
|
||||||
const [urlsChecked, setUrlsChecked] = useState(false);
|
|
||||||
const [person, setPerson] = useState('pedro');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu.Root>
|
|
||||||
<DropdownMenu.Trigger asChild>
|
|
||||||
<Button aria-label="Customise options">
|
|
||||||
<HamburgerMenuIcon />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
|
|
||||||
<DropdownMenuPortal>
|
|
||||||
<DropdownMenuContent>
|
|
||||||
<DropdownMenuItem rightSlot={<HotKey>⌘T</HotKey>}>New Tab</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem rightSlot={<HotKey>⌘N</HotKey>}>New Window</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem disabled rightSlot={<HotKey>⇧⌘N</HotKey>}>
|
|
||||||
New Private Window
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenu.Sub>
|
|
||||||
<DropdownMenuSubTrigger rightSlot={<ChevronRightIcon />}>
|
|
||||||
More Tools
|
|
||||||
</DropdownMenuSubTrigger>
|
|
||||||
<DropdownMenuPortal>
|
|
||||||
<DropdownMenuSubContent>
|
|
||||||
<DropdownMenuItem rightSlot={<HotKey>⌘S</HotKey>}>Save Page As…</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem>Create Shortcut…</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem>Name Window…</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem>Developer Tools</DropdownMenuItem>
|
|
||||||
</DropdownMenuSubContent>
|
|
||||||
</DropdownMenuPortal>
|
|
||||||
</DropdownMenu.Sub>
|
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
|
|
||||||
<DropdownMenuCheckboxItem
|
|
||||||
checked={bookmarksChecked}
|
|
||||||
onCheckedChange={(v) => setBookmarksChecked(!!v)}
|
|
||||||
rightSlot={<HotKey>⌘B</HotKey>}
|
|
||||||
leftSlot={
|
|
||||||
<DropdownMenu.ItemIndicator className="DropdownMenuItemIndicator">
|
|
||||||
<CheckIcon />
|
|
||||||
</DropdownMenu.ItemIndicator>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Show Bookmarks
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
<DropdownMenuCheckboxItem
|
|
||||||
checked={urlsChecked}
|
|
||||||
onCheckedChange={(v) => setUrlsChecked(!!v)}
|
|
||||||
leftSlot={
|
|
||||||
<DropdownMenu.ItemIndicator className="DropdownMenuItemIndicator">
|
|
||||||
<CheckIcon />
|
|
||||||
</DropdownMenu.ItemIndicator>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Show Full URLs
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
|
|
||||||
<DropdownMenuLabel>People</DropdownMenuLabel>
|
|
||||||
<DropdownMenu.RadioGroup value={person} onValueChange={setPerson}>
|
|
||||||
<DropdownMenuRadioItem value="pedro">Pedro Duarte</DropdownMenuRadioItem>
|
|
||||||
<DropdownMenuRadioItem className="DropdownMenuRadioItem" value="colm">
|
|
||||||
Colm Tuite
|
|
||||||
</DropdownMenuRadioItem>
|
|
||||||
</DropdownMenu.RadioGroup>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenuPortal>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const dropdownMenuClasses = 'bg-background rounded-md shadow-lg p-1.5 border border-gray-100';
|
|
||||||
|
|
||||||
interface DropdownMenuPortalProps {
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuPortal({ children }: DropdownMenuPortalProps) {
|
|
||||||
return (
|
|
||||||
<DropdownMenu.Portal container={document.querySelector<HTMLElement>('#radix-portal')}>
|
|
||||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
|
|
||||||
{children}
|
|
||||||
</motion.div>
|
|
||||||
</DropdownMenu.Portal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const DropdownMenuContent = forwardRef<HTMLDivElement, DropdownMenu.DropdownMenuContentProps>(
|
|
||||||
function DropdownMenuContent(
|
|
||||||
{ className, children, ...props }: DropdownMenu.DropdownMenuContentProps,
|
|
||||||
ref,
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<DropdownMenu.Content
|
|
||||||
ref={ref}
|
|
||||||
align="start"
|
|
||||||
className={classnames(className, dropdownMenuClasses, 'mt-1')}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
type DropdownMenuItemProps = DropdownMenu.DropdownMenuItemProps & ItemInnerProps;
|
|
||||||
|
|
||||||
function DropdownMenuItem({
|
|
||||||
leftSlot,
|
|
||||||
rightSlot,
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: DropdownMenuItemProps) {
|
|
||||||
return (
|
|
||||||
<DropdownMenu.Item
|
|
||||||
asChild
|
|
||||||
className={classnames(className, { 'opacity-30': props.disabled })}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ItemInner leftSlot={leftSlot} rightSlot={rightSlot}>
|
|
||||||
{children}
|
|
||||||
</ItemInner>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type DropdownMenuCheckboxItemProps = DropdownMenu.DropdownMenuCheckboxItemProps & ItemInnerProps;
|
|
||||||
|
|
||||||
function DropdownMenuCheckboxItem({
|
|
||||||
leftSlot,
|
|
||||||
rightSlot,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: DropdownMenuCheckboxItemProps) {
|
|
||||||
return (
|
|
||||||
<DropdownMenu.CheckboxItem asChild {...props}>
|
|
||||||
<ItemInner leftSlot={leftSlot} rightSlot={rightSlot}>
|
|
||||||
{children}
|
|
||||||
</ItemInner>
|
|
||||||
</DropdownMenu.CheckboxItem>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type DropdownMenuSubTriggerProps = DropdownMenu.DropdownMenuSubTriggerProps & ItemInnerProps;
|
|
||||||
|
|
||||||
function DropdownMenuSubTrigger({
|
|
||||||
leftSlot,
|
|
||||||
rightSlot,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: DropdownMenuSubTriggerProps) {
|
|
||||||
return (
|
|
||||||
<DropdownMenu.SubTrigger asChild {...props}>
|
|
||||||
<ItemInner leftSlot={leftSlot} rightSlot={rightSlot}>
|
|
||||||
{children}
|
|
||||||
</ItemInner>
|
|
||||||
</DropdownMenu.SubTrigger>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type DropdownMenuRadioItemProps = Omit<
|
|
||||||
DropdownMenu.DropdownMenuRadioItemProps & ItemInnerProps,
|
|
||||||
'leftSlot'
|
|
||||||
>;
|
|
||||||
|
|
||||||
function DropdownMenuRadioItem({ rightSlot, children, ...props }: DropdownMenuRadioItemProps) {
|
|
||||||
return (
|
|
||||||
<DropdownMenu.RadioItem asChild {...props}>
|
|
||||||
<ItemInner
|
|
||||||
leftSlot={
|
|
||||||
<DropdownMenu.ItemIndicator>
|
|
||||||
<DotFilledIcon />
|
|
||||||
</DropdownMenu.ItemIndicator>
|
|
||||||
}
|
|
||||||
rightSlot={rightSlot}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</ItemInner>
|
|
||||||
</DropdownMenu.RadioItem>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const DropdownMenuSubContent = forwardRef<HTMLDivElement, DropdownMenu.DropdownMenuSubContentProps>(
|
|
||||||
function DropdownMenuSubContent(
|
|
||||||
{ className, ...props }: DropdownMenu.DropdownMenuSubContentProps,
|
|
||||||
ref,
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<DropdownMenu.SubContent
|
|
||||||
ref={ref}
|
|
||||||
alignOffset={0}
|
|
||||||
sideOffset={4}
|
|
||||||
className={classnames(className, dropdownMenuClasses)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
function DropdownMenuLabel({ className, children, ...props }: DropdownMenu.DropdownMenuLabelProps) {
|
|
||||||
return (
|
|
||||||
<DropdownMenu.Label asChild {...props}>
|
|
||||||
<ItemInner noHover className={classnames(className, 'opacity-50 uppercase text-sm')}>
|
|
||||||
{children}
|
|
||||||
</ItemInner>
|
|
||||||
</DropdownMenu.Label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuSeparator({ className, ...props }: DropdownMenu.DropdownMenuSeparatorProps) {
|
|
||||||
return (
|
|
||||||
<DropdownMenu.Separator
|
|
||||||
className={classnames(className, 'h-[1px] bg-gray-400 bg-opacity-30 my-1')}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuTrigger({ className, ...props }: DropdownMenu.DropdownMenuTriggerProps) {
|
|
||||||
return (
|
|
||||||
<DropdownMenu.Trigger
|
|
||||||
asChild
|
|
||||||
className={classnames(className, 'focus:outline-none')}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ItemInnerProps extends HTMLAttributes<HTMLDivElement> {
|
|
||||||
leftSlot?: ReactNode;
|
|
||||||
rightSlot?: ReactNode;
|
|
||||||
children: ReactNode;
|
|
||||||
noHover?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ItemInner = forwardRef<HTMLDivElement, ItemInnerProps>(function ItemInner(
|
|
||||||
{ leftSlot, rightSlot, children, className, noHover, ...props }: ItemInnerProps,
|
|
||||||
ref,
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
className={classnames(
|
|
||||||
className,
|
|
||||||
'outline-none px-2 py-1.5 flex items-center text-sm text-gray-700',
|
|
||||||
{
|
|
||||||
'focus:bg-gray-50 focus:text-gray-900 rounded': !noHover,
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<div className="w-7">{leftSlot}</div>
|
|
||||||
<div>{children}</div>
|
|
||||||
{rightSlot && <div className="ml-auto pl-3">{rightSlot}</div>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
.cm-editor {
|
|
||||||
width: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
border-radius: var(--border-radius-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cm-editor .cm-scroller {
|
|
||||||
border-radius: var(--border-radius-lg);
|
|
||||||
background-color: hsl(var(--color-gray-50) / 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cm-editor .cm-line {
|
|
||||||
padding-left: 1em;
|
|
||||||
padding-right: 1.5em;
|
|
||||||
color: hsl(var(--color-gray-900));
|
|
||||||
}
|
|
||||||
|
|
||||||
.cm-editor .cm-gutters {
|
|
||||||
background-color: transparent;
|
|
||||||
border-right: 0;
|
|
||||||
color: hsl(var(--color-gray-300));
|
|
||||||
}
|
|
||||||
|
|
||||||
.cm-editor .cm-foldPlaceholder {
|
|
||||||
background-color: hsl(var(--color-gray-100));
|
|
||||||
border: 1px solid hsl(var(--color-gray-200));
|
|
||||||
padding: 0 0.3em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cm-editor .cm-activeLineGutter,
|
|
||||||
.cm-editor .cm-activeLine {
|
|
||||||
background-color: hsl(var(--color-gray-50));
|
|
||||||
}
|
|
||||||
|
|
||||||
.cm-editor * {
|
|
||||||
cursor: text;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cm-editor.cm-focused {
|
|
||||||
outline: 0;
|
|
||||||
box-shadow: 0 0 0 2pt rgba(180, 180, 180, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cm-editor .cm-cursor {
|
|
||||||
border-left: 2px solid red;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cm-editor .cm-selectionBackground {
|
|
||||||
background-color: hsl(var(--color-gray-100));
|
|
||||||
}
|
|
||||||
|
|
||||||
.cm-editor.cm-focused .cm-selectionBackground {
|
|
||||||
background-color: hsl(var(--color-gray-100));
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import useCodeMirror from '../../hooks/useCodemirror';
|
|
||||||
import './Editor.css';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
contentType: string;
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Editor(props: Props) {
|
|
||||||
const { ref } = useCodeMirror({ value: props.value, contentType: props.contentType });
|
|
||||||
return <div ref={ref} className="m-0 text-sm overflow-hidden" />;
|
|
||||||
}
|
|
||||||
13
src-web/components/EmptyStateText.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyStateText({ children }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-dashed border-highlight h-full text-gray-400 flex items-center justify-center">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
119
src-web/components/GlobalHooks.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { appWindow } from '@tauri-apps/api/window';
|
||||||
|
import { keyValueQueryKey } from '../hooks/useKeyValue';
|
||||||
|
import { requestsQueryKey } from '../hooks/useRequests';
|
||||||
|
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
|
||||||
|
import { responsesQueryKey } from '../hooks/useResponses';
|
||||||
|
import { useTauriEvent } from '../hooks/useTauriEvent';
|
||||||
|
import { workspacesQueryKey } from '../hooks/useWorkspaces';
|
||||||
|
import { DEFAULT_FONT_SIZE } from '../lib/constants';
|
||||||
|
import { NAMESPACE_NO_SYNC } from '../lib/keyValueStore';
|
||||||
|
import type { HttpRequest, HttpResponse, Model, Workspace } from '../lib/models';
|
||||||
|
import { modelsEq } from '../lib/models';
|
||||||
|
|
||||||
|
export function GlobalHooks() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { wasUpdatedExternally } = useRequestUpdateKey(null);
|
||||||
|
|
||||||
|
useTauriEvent<Model>('created_model', ({ payload, windowLabel }) => {
|
||||||
|
if (shouldIgnoreEvent(payload, windowLabel)) return;
|
||||||
|
|
||||||
|
const queryKey =
|
||||||
|
payload.model === 'http_request'
|
||||||
|
? requestsQueryKey(payload)
|
||||||
|
: payload.model === 'http_response'
|
||||||
|
? responsesQueryKey(payload)
|
||||||
|
: payload.model === 'workspace'
|
||||||
|
? workspacesQueryKey(payload)
|
||||||
|
: payload.model === 'key_value'
|
||||||
|
? keyValueQueryKey(payload)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (queryKey === null) {
|
||||||
|
console.log('Unrecognized created model:', payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shouldIgnoreModel(payload)) {
|
||||||
|
// Order newest first
|
||||||
|
queryClient.setQueryData<Model[]>(queryKey, (values) => [payload, ...(values ?? [])]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useTauriEvent<Model>('updated_model', ({ payload, windowLabel }) => {
|
||||||
|
if (shouldIgnoreEvent(payload, windowLabel)) return;
|
||||||
|
|
||||||
|
const queryKey =
|
||||||
|
payload.model === 'http_request'
|
||||||
|
? requestsQueryKey(payload)
|
||||||
|
: payload.model === 'http_response'
|
||||||
|
? responsesQueryKey(payload)
|
||||||
|
: payload.model === 'workspace'
|
||||||
|
? workspacesQueryKey(payload)
|
||||||
|
: payload.model === 'key_value'
|
||||||
|
? keyValueQueryKey(payload)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (queryKey === null) {
|
||||||
|
console.log('Unrecognized updated model:', payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.model === 'http_request') {
|
||||||
|
wasUpdatedExternally(payload.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shouldIgnoreModel(payload)) {
|
||||||
|
queryClient.setQueryData<Model[]>(queryKey, (values) =>
|
||||||
|
values?.map((v) => (modelsEq(v, payload) ? payload : v)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useTauriEvent<Model>('deleted_model', ({ payload, windowLabel }) => {
|
||||||
|
if (shouldIgnoreEvent(payload, windowLabel)) return;
|
||||||
|
|
||||||
|
if (shouldIgnoreModel(payload)) return;
|
||||||
|
|
||||||
|
if (payload.model === 'workspace') {
|
||||||
|
queryClient.setQueryData<Workspace[]>(workspacesQueryKey(), removeById(payload));
|
||||||
|
} else if (payload.model === 'http_request') {
|
||||||
|
queryClient.setQueryData<HttpRequest[]>(requestsQueryKey(payload), removeById(payload));
|
||||||
|
} else if (payload.model === 'http_response') {
|
||||||
|
queryClient.setQueryData<HttpResponse[]>(responsesQueryKey(payload), removeById(payload));
|
||||||
|
} else if (payload.model === 'key_value') {
|
||||||
|
queryClient.setQueryData(keyValueQueryKey(payload), undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
useTauriEvent<number>('zoom', ({ payload: zoomDelta, windowLabel }) => {
|
||||||
|
if (windowLabel !== appWindow.label) return;
|
||||||
|
const fontSize = parseFloat(window.getComputedStyle(document.documentElement).fontSize);
|
||||||
|
|
||||||
|
let newFontSize;
|
||||||
|
if (zoomDelta === 0) {
|
||||||
|
newFontSize = DEFAULT_FONT_SIZE;
|
||||||
|
} else if (zoomDelta > 0) {
|
||||||
|
newFontSize = Math.min(fontSize * 1.1, DEFAULT_FONT_SIZE * 5);
|
||||||
|
} else if (zoomDelta < 0) {
|
||||||
|
newFontSize = Math.max(fontSize * 0.9, DEFAULT_FONT_SIZE * 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.documentElement.style.fontSize = `${newFontSize}px`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeById<T extends { id: string }>(model: T) {
|
||||||
|
return (entries: T[] | undefined) => entries?.filter((e) => e.id !== model.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldIgnoreEvent = (payload: Model, windowLabel: string) =>
|
||||||
|
windowLabel === appWindow.label && payload.model !== 'http_response';
|
||||||
|
|
||||||
|
const shouldIgnoreModel = (payload: Model) => {
|
||||||
|
if (payload.model === 'key_value') {
|
||||||
|
return payload.namespace === NAMESPACE_NO_SYNC;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
134
src-web/components/GraphQLEditor.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import { updateSchema } from 'cm6-graphql';
|
||||||
|
import type { EditorView } from 'codemirror';
|
||||||
|
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
|
import { useIntrospectGraphQL } from '../hooks/useIntrospectGraphQL';
|
||||||
|
import type { HttpRequest } from '../lib/models';
|
||||||
|
import { Button } from './core/Button';
|
||||||
|
import type { EditorProps } from './core/Editor';
|
||||||
|
import { Editor, formatGraphQL } from './core/Editor';
|
||||||
|
import { FormattedError } from './core/FormattedError';
|
||||||
|
import { Separator } from './core/Separator';
|
||||||
|
import { useDialog } from './DialogContext';
|
||||||
|
|
||||||
|
type Props = Pick<
|
||||||
|
EditorProps,
|
||||||
|
'heightMode' | 'onChange' | 'defaultValue' | 'className' | 'forceUpdateKey'
|
||||||
|
> & {
|
||||||
|
baseRequest: HttpRequest;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface GraphQLBody {
|
||||||
|
query: string;
|
||||||
|
variables?: Record<string, string | number | boolean | null>;
|
||||||
|
operationName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GraphQLEditor({ defaultValue, onChange, baseRequest, ...extraEditorProps }: Props) {
|
||||||
|
const editorViewRef = useRef<EditorView>(null);
|
||||||
|
const { schema, isLoading, error, refetch } = useIntrospectGraphQL(baseRequest);
|
||||||
|
const { query, variables } = useMemo<GraphQLBody>(() => {
|
||||||
|
if (defaultValue === undefined) {
|
||||||
|
return { query: '', variables: {} };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const p = JSON.parse(defaultValue ?? '{}');
|
||||||
|
const query = p.query ?? '';
|
||||||
|
const variables = p.variables;
|
||||||
|
const operationName = p.operationName;
|
||||||
|
return { query, variables, operationName };
|
||||||
|
} catch (err) {
|
||||||
|
return { query: 'failed to parse' };
|
||||||
|
}
|
||||||
|
}, [defaultValue]);
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(b: GraphQLBody) => onChange?.(JSON.stringify(b, null, 2)),
|
||||||
|
[onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleChangeQuery = useCallback(
|
||||||
|
(query: string) => handleChange({ query, variables }),
|
||||||
|
[handleChange, variables],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleChangeVariables = useCallback(
|
||||||
|
(variables: string) => {
|
||||||
|
try {
|
||||||
|
handleChange({ query, variables: JSON.parse(variables) });
|
||||||
|
} catch (e) {
|
||||||
|
// Meh, not much we can do here
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleChange, query],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Refetch the schema when the URL changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (editorViewRef.current === null) return;
|
||||||
|
updateSchema(editorViewRef.current, schema);
|
||||||
|
}, [schema]);
|
||||||
|
|
||||||
|
const dialog = useDialog();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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 ?? ''}
|
||||||
|
format={formatGraphQL}
|
||||||
|
heightMode="auto"
|
||||||
|
onChange={handleChangeQuery}
|
||||||
|
placeholder="..."
|
||||||
|
ref={editorViewRef}
|
||||||
|
actions={
|
||||||
|
(error || isLoading) && (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
color={error ? 'danger' : 'gray'}
|
||||||
|
isLoading={isLoading}
|
||||||
|
onClick={() => {
|
||||||
|
dialog.show({
|
||||||
|
title: 'Introspection Failed',
|
||||||
|
size: 'sm',
|
||||||
|
id: 'introspection-failed',
|
||||||
|
render: () => (
|
||||||
|
<div className="whitespace-pre-wrap">
|
||||||
|
<FormattedError>{error ?? 'unknown'}</FormattedError>
|
||||||
|
<div className="w-full mt-3">
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
dialog.hide('introspection-failed');
|
||||||
|
refetch();
|
||||||
|
}}
|
||||||
|
className="ml-auto"
|
||||||
|
color="secondary"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
Try Again
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error ? 'Introspection Failed' : 'Introspecting'}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{...extraEditorProps}
|
||||||
|
/>
|
||||||
|
<Separator variant="primary" />
|
||||||
|
<p className="pt-1 text-gray-500 text-sm">Variables</p>
|
||||||
|
<Editor
|
||||||
|
contentType="application/json"
|
||||||
|
defaultValue={JSON.stringify(variables, null, 2)}
|
||||||
|
heightMode="auto"
|
||||||
|
onChange={handleChangeVariables}
|
||||||
|
placeholder="{}"
|
||||||
|
useTemplating
|
||||||
|
{...extraEditorProps}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import classnames from 'classnames';
|
|
||||||
import {HTMLAttributes} from 'react';
|
|
||||||
|
|
||||||
const colsClasses = {
|
|
||||||
none: 'grid-cols-none',
|
|
||||||
1: 'grid-cols-1',
|
|
||||||
2: 'grid-cols-2',
|
|
||||||
};
|
|
||||||
|
|
||||||
const rowsClasses = {
|
|
||||||
none: 'grid-rows-none',
|
|
||||||
1: 'grid-rows-1',
|
|
||||||
2: 'grid-rows-2',
|
|
||||||
};
|
|
||||||
|
|
||||||
const gapClasses = {
|
|
||||||
0: 'gap-0',
|
|
||||||
1: 'gap-1',
|
|
||||||
2: 'gap-2',
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = HTMLAttributes<HTMLElement> & {
|
|
||||||
rows?: keyof typeof rowsClasses;
|
|
||||||
cols?: keyof typeof colsClasses;
|
|
||||||
gap?: keyof typeof gapClasses;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function Grid({ className, cols, gap, ...props }: Props) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={classnames(className, 'grid', cols && colsClasses[cols], gap && gapClasses[gap])}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
67
src-web/components/HeaderEditor.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { charsets } from '../lib/data/charsets';
|
||||||
|
import { connections } from '../lib/data/connections';
|
||||||
|
import { encodings } from '../lib/data/encodings';
|
||||||
|
import { headerNames } from '../lib/data/headerNames';
|
||||||
|
import { mimeTypes } from '../lib/data/mimetypes';
|
||||||
|
import type { HttpRequest } from '../lib/models';
|
||||||
|
import type { GenericCompletionConfig } from './core/Editor/genericCompletion';
|
||||||
|
import type { PairEditorProps } from './core/PairEditor';
|
||||||
|
import { PairEditor } from './core/PairEditor';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
forceUpdateKey: string;
|
||||||
|
headers: HttpRequest['headers'];
|
||||||
|
onChange: (headers: HttpRequest['headers']) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function HeaderEditor({ headers, onChange, forceUpdateKey }: Props) {
|
||||||
|
return (
|
||||||
|
<PairEditor
|
||||||
|
pairs={headers}
|
||||||
|
onChange={onChange}
|
||||||
|
forceUpdateKey={forceUpdateKey}
|
||||||
|
nameValidate={validateHttpHeader}
|
||||||
|
nameAutocomplete={nameAutocomplete}
|
||||||
|
valueAutocomplete={valueAutocomplete}
|
||||||
|
namePlaceholder="Header-Name"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIN_MATCH = 3;
|
||||||
|
|
||||||
|
const headerOptionsMap: Record<string, string[]> = {
|
||||||
|
'content-type': mimeTypes,
|
||||||
|
accept: ['*/*', ...mimeTypes],
|
||||||
|
'accept-encoding': encodings,
|
||||||
|
connection: connections,
|
||||||
|
'accept-charset': charsets,
|
||||||
|
};
|
||||||
|
|
||||||
|
const valueAutocomplete = (headerName: string): GenericCompletionConfig | undefined => {
|
||||||
|
const name = headerName.toLowerCase().trim();
|
||||||
|
const options: GenericCompletionConfig['options'] =
|
||||||
|
headerOptionsMap[name]?.map((o) => ({
|
||||||
|
label: o,
|
||||||
|
type: 'constant',
|
||||||
|
boost: 1, // Put above other completions
|
||||||
|
})) ?? [];
|
||||||
|
return { minMatch: MIN_MATCH, options };
|
||||||
|
};
|
||||||
|
|
||||||
|
const nameAutocomplete: PairEditorProps['nameAutocomplete'] = {
|
||||||
|
minMatch: MIN_MATCH,
|
||||||
|
options: headerNames.map((t) => ({
|
||||||
|
label: t,
|
||||||
|
type: 'constant',
|
||||||
|
boost: 1, // Put above other completions
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateHttpHeader = (v: string) => {
|
||||||
|
if (v === '') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return v.match(/^[a-zA-Z0-9-_]+$/) !== null;
|
||||||
|
};
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { HTMLAttributes } from 'react';
|
|
||||||
import classnames from 'classnames';
|
|
||||||
|
|
||||||
export function HotKey({ children }: HTMLAttributes<HTMLSpanElement>) {
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { ComponentType } from 'react';
|
|
||||||
import {
|
|
||||||
ArchiveIcon,
|
|
||||||
CameraIcon,
|
|
||||||
ChevronDownIcon,
|
|
||||||
GearIcon,
|
|
||||||
HomeIcon,
|
|
||||||
TriangleDownIcon,
|
|
||||||
} from '@radix-ui/react-icons';
|
|
||||||
import classnames from 'classnames';
|
|
||||||
|
|
||||||
type IconName = 'archive' | 'home' | 'camera' | 'gear' | 'triangle-down';
|
|
||||||
|
|
||||||
const icons: Record<IconName, ComponentType> = {
|
|
||||||
archive: ArchiveIcon,
|
|
||||||
home: HomeIcon,
|
|
||||||
camera: CameraIcon,
|
|
||||||
gear: GearIcon,
|
|
||||||
'triangle-down': TriangleDownIcon,
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface IconProps {
|
|
||||||
icon: IconName;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Icon({ icon, className }: IconProps) {
|
|
||||||
const Component = icons[icon];
|
|
||||||
return (
|
|
||||||
<div className={classnames(className, 'flex items-center')}>
|
|
||||||
<Component />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { forwardRef } from 'react';
|
|
||||||
import { Icon, IconProps } from './Icon';
|
|
||||||
import { Button, ButtonProps } from './Button';
|
|
||||||
|
|
||||||
type Props = ButtonProps & IconProps;
|
|
||||||
|
|
||||||
export const IconButton = forwardRef<HTMLButtonElement, Props>(function IconButton(
|
|
||||||
{ icon, ...props }: Props,
|
|
||||||
ref,
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<Button ref={ref} className="group" {...props}>
|
|
||||||
<Icon icon={icon} className="text-gray-700 group-hover:text-gray-900" />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { InputHTMLAttributes } from 'react';
|
|
||||||
import classnames from 'classnames';
|
|
||||||
import { VStack } from './Stacks';
|
|
||||||
|
|
||||||
interface Props extends InputHTMLAttributes<HTMLInputElement> {
|
|
||||||
name: string;
|
|
||||||
label: string;
|
|
||||||
hideLabel?: boolean;
|
|
||||||
labelClassName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Input({ label, labelClassName, hideLabel, className, name, ...props }: Props) {
|
|
||||||
const id = `input-${name}`;
|
|
||||||
return (
|
|
||||||
<VStack className="w-full">
|
|
||||||
<label
|
|
||||||
htmlFor={name}
|
|
||||||
className={classnames(labelClassName, 'font-semibold text-sm uppercase text-gray-700', {
|
|
||||||
'sr-only': hideLabel,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id={id}
|
|
||||||
className={classnames(
|
|
||||||
className,
|
|
||||||
'w-0 min-w-[100%] border-2 border-gray-100 bg-gray-50 h-10 pl-3 pr-2 rounded-md text-sm focus:outline-none',
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</VStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
46
src-web/components/Overlay.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import classnames from 'classnames';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { Portal } from './Portal';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
portalName: string;
|
||||||
|
open: boolean;
|
||||||
|
onClose?: () => void;
|
||||||
|
zIndex?: keyof typeof zIndexes;
|
||||||
|
}
|
||||||
|
|
||||||
|
const zIndexes: Record<number, string> = {
|
||||||
|
10: 'z-10',
|
||||||
|
20: 'z-20',
|
||||||
|
30: 'z-30',
|
||||||
|
40: 'z-40',
|
||||||
|
50: 'z-50',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Overlay({ zIndex = 30, open, onClose, portalName, children }: Props) {
|
||||||
|
return (
|
||||||
|
<Portal name={portalName}>
|
||||||
|
{open && (
|
||||||
|
<FocusTrap>
|
||||||
|
<motion.div
|
||||||
|
className={classnames('fixed inset-0', zIndexes[zIndex])}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
onClick={onClose}
|
||||||
|
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" />
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
</FocusTrap>
|
||||||
|
)}
|
||||||
|
</Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
src-web/components/ParameterEditor.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { HttpRequest } from '../lib/models';
|
||||||
|
import { PairEditor } from './core/PairEditor';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
forceUpdateKey: string;
|
||||||
|
parameters: { name: string; value: string }[];
|
||||||
|
onChange: (headers: HttpRequest['headers']) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ParametersEditor({ parameters, forceUpdateKey, onChange }: Props) {
|
||||||
|
return (
|
||||||
|
<PairEditor
|
||||||
|
forceUpdateKey={forceUpdateKey}
|
||||||
|
pairs={parameters}
|
||||||
|
onChange={onChange}
|
||||||
|
namePlaceholder="name"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
src-web/components/Portal.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { usePortal } from '../hooks/usePortal';
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Portal({ children, name }: Props) {
|
||||||
|
const portal = usePortal(name);
|
||||||
|
return createPortal(children, portal);
|
||||||
|
}
|
||||||
83
src-web/components/RecentRequestsDropdown.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
src-web/components/RequestActionsDropdown.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import type { HTMLAttributes, ReactElement } from 'react';
|
||||||
|
import React, { useRef } from 'react';
|
||||||
|
import { useDeleteRequest } from '../hooks/useDeleteRequest';
|
||||||
|
import { useDuplicateRequest } from '../hooks/useDuplicateRequest';
|
||||||
|
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';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
requestId: string;
|
||||||
|
children: ReactElement<HTMLAttributes<HTMLButtonElement>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RequestActionsDropdown({ requestId, children }: Props) {
|
||||||
|
const deleteRequest = useDeleteRequest(requestId);
|
||||||
|
const duplicateRequest = useDuplicateRequest({ id: requestId, navigateAfter: true });
|
||||||
|
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: 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}
|
||||||
|
</Dropdown>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
src-web/components/RequestMethodDropdown.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
import { Button } from './core/Button';
|
||||||
|
import { RadioDropdown } from './core/RadioDropdown';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
method: string;
|
||||||
|
className?: string;
|
||||||
|
onChange: (method: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const methodItems = ['GET', 'PUT', 'POST', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD'].map((m) => ({
|
||||||
|
value: m,
|
||||||
|
label: m,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const RequestMethodDropdown = memo(function RequestMethodDropdown({
|
||||||
|
method,
|
||||||
|
onChange,
|
||||||
|
className,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<RadioDropdown value={method} items={methodItems} onChange={onChange}>
|
||||||
|
<Button size="xs" className={className}>
|
||||||
|
{method.toUpperCase()}
|
||||||
|
</Button>
|
||||||
|
</RadioDropdown>
|
||||||
|
);
|
||||||
|
});
|
||||||
241
src-web/components/RequestPane.tsx
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
import { invoke } from '@tauri-apps/api';
|
||||||
|
import { appWindow } from '@tauri-apps/api/window';
|
||||||
|
import classnames from 'classnames';
|
||||||
|
import type { CSSProperties } from 'react';
|
||||||
|
import { memo, useCallback, useMemo, useState } from 'react';
|
||||||
|
import { createGlobalState } from 'react-use';
|
||||||
|
import { useActiveRequest } from '../hooks/useActiveRequest';
|
||||||
|
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
|
||||||
|
import { useTauriEvent } from '../hooks/useTauriEvent';
|
||||||
|
import { useUpdateRequest } from '../hooks/useUpdateRequest';
|
||||||
|
import { tryFormatJson } from '../lib/formatters';
|
||||||
|
import type { HttpHeader, HttpRequest } from '../lib/models';
|
||||||
|
import {
|
||||||
|
AUTH_TYPE_BASIC,
|
||||||
|
AUTH_TYPE_BEARER,
|
||||||
|
AUTH_TYPE_NONE,
|
||||||
|
BODY_TYPE_GRAPHQL,
|
||||||
|
BODY_TYPE_JSON,
|
||||||
|
BODY_TYPE_NONE,
|
||||||
|
BODY_TYPE_XML,
|
||||||
|
} from '../lib/models';
|
||||||
|
import { BasicAuth } from './BasicAuth';
|
||||||
|
import { BearerAuth } from './BearerAuth';
|
||||||
|
import { CountBadge } from './core/CountBadge';
|
||||||
|
import { Editor } from './core/Editor';
|
||||||
|
import type { TabItem } from './core/Tabs/Tabs';
|
||||||
|
import { TabContent, Tabs } from './core/Tabs/Tabs';
|
||||||
|
import { EmptyStateText } from './EmptyStateText';
|
||||||
|
import { GraphQLEditor } from './GraphQLEditor';
|
||||||
|
import { HeaderEditor } from './HeaderEditor';
|
||||||
|
import { ParametersEditor } from './ParameterEditor';
|
||||||
|
import { UrlBar } from './UrlBar';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
style?: CSSProperties;
|
||||||
|
fullHeight: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useActiveTab = createGlobalState<string>('body');
|
||||||
|
|
||||||
|
export const RequestPane = memo(function RequestPane({ style, fullHeight, className }: Props) {
|
||||||
|
const activeRequest = useActiveRequest();
|
||||||
|
const activeRequestId = activeRequest?.id ?? null;
|
||||||
|
const updateRequest = useUpdateRequest(activeRequestId);
|
||||||
|
const [activeTab, setActiveTab] = useActiveTab();
|
||||||
|
const [forceUpdateHeaderEditorKey, setForceUpdateHeaderEditorKey] = useState<number>(0);
|
||||||
|
const { updateKey: forceUpdateKey } = useRequestUpdateKey(activeRequest?.id ?? null);
|
||||||
|
|
||||||
|
const tabs: TabItem[] = useMemo(
|
||||||
|
() =>
|
||||||
|
activeRequest === null
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
value: 'body',
|
||||||
|
options: {
|
||||||
|
value: activeRequest.bodyType,
|
||||||
|
items: [
|
||||||
|
{ label: 'JSON', value: BODY_TYPE_JSON },
|
||||||
|
{ label: 'XML', value: BODY_TYPE_XML },
|
||||||
|
{ label: 'GraphQL', value: BODY_TYPE_GRAPHQL },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ label: 'No Body', shortLabel: 'Body', value: BODY_TYPE_NONE },
|
||||||
|
],
|
||||||
|
onChange: async (bodyType) => {
|
||||||
|
const patch: Partial<HttpRequest> = { bodyType };
|
||||||
|
if (bodyType === BODY_TYPE_NONE) {
|
||||||
|
patch.headers = activeRequest?.headers.filter(
|
||||||
|
(h) => h.name.toLowerCase() !== 'content-type',
|
||||||
|
);
|
||||||
|
} else if (bodyType == BODY_TYPE_GRAPHQL || bodyType === BODY_TYPE_JSON) {
|
||||||
|
patch.method = 'POST';
|
||||||
|
patch.headers = [
|
||||||
|
...(activeRequest?.headers.filter(
|
||||||
|
(h) => h.name.toLowerCase() !== 'content-type',
|
||||||
|
) ?? []),
|
||||||
|
{
|
||||||
|
name: 'Content-Type',
|
||||||
|
value: 'application/json',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force update header editor so any changed headers are reflected
|
||||||
|
setTimeout(() => setForceUpdateHeaderEditorKey((u) => u + 1), 100);
|
||||||
|
|
||||||
|
await updateRequest.mutate(patch);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// { value: 'params', label: 'URL Params' },
|
||||||
|
{
|
||||||
|
value: 'headers',
|
||||||
|
label: (
|
||||||
|
<div className="flex items-center">
|
||||||
|
Headers
|
||||||
|
<CountBadge count={activeRequest.headers.filter((h) => h.name).length} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'auth',
|
||||||
|
label: 'Auth',
|
||||||
|
options: {
|
||||||
|
value: activeRequest.authenticationType,
|
||||||
|
items: [
|
||||||
|
{ label: 'Basic Auth', shortLabel: 'Basic', value: AUTH_TYPE_BASIC },
|
||||||
|
{ label: 'Bearer Token', shortLabel: 'Bearer', value: AUTH_TYPE_BEARER },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ label: 'No Authentication', shortLabel: 'Auth', value: AUTH_TYPE_NONE },
|
||||||
|
],
|
||||||
|
onChange: async (authenticationType) => {
|
||||||
|
let authentication: HttpRequest['authentication'] = activeRequest?.authentication;
|
||||||
|
if (authenticationType === AUTH_TYPE_BASIC) {
|
||||||
|
authentication = {
|
||||||
|
username: authentication.username ?? '',
|
||||||
|
password: authentication.password ?? '',
|
||||||
|
};
|
||||||
|
} else if (authenticationType === AUTH_TYPE_BEARER) {
|
||||||
|
authentication = {
|
||||||
|
token: authentication.token ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await updateRequest.mutate({ authenticationType, authentication });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[activeRequest, updateRequest],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleBodyChange = useCallback(
|
||||||
|
(body: string) => updateRequest.mutate({ body }),
|
||||||
|
[updateRequest],
|
||||||
|
);
|
||||||
|
const handleHeadersChange = useCallback(
|
||||||
|
(headers: HttpHeader[]) => updateRequest.mutate({ headers }),
|
||||||
|
[updateRequest],
|
||||||
|
);
|
||||||
|
|
||||||
|
useTauriEvent(
|
||||||
|
'send_request',
|
||||||
|
async ({ windowLabel }) => {
|
||||||
|
if (windowLabel !== appWindow.label) return;
|
||||||
|
await invoke('send_request', { requestId: activeRequestId });
|
||||||
|
},
|
||||||
|
[activeRequestId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={style}
|
||||||
|
className={classnames(className, 'h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1')}
|
||||||
|
>
|
||||||
|
{activeRequest && (
|
||||||
|
<>
|
||||||
|
<UrlBar id={activeRequest.id} url={activeRequest.url} method={activeRequest.method} />
|
||||||
|
<Tabs
|
||||||
|
value={activeTab}
|
||||||
|
label="Request"
|
||||||
|
onChangeValue={setActiveTab}
|
||||||
|
tabs={tabs}
|
||||||
|
tabListClassName="mt-2 !mb-1.5"
|
||||||
|
>
|
||||||
|
<TabContent value="auth">
|
||||||
|
{activeRequest.authenticationType === AUTH_TYPE_BASIC ? (
|
||||||
|
<BasicAuth
|
||||||
|
key={forceUpdateKey}
|
||||||
|
requestId={activeRequest.id}
|
||||||
|
authentication={activeRequest.authentication}
|
||||||
|
/>
|
||||||
|
) : activeRequest.authenticationType === AUTH_TYPE_BEARER ? (
|
||||||
|
<BearerAuth
|
||||||
|
key={forceUpdateKey}
|
||||||
|
requestId={activeRequest.id}
|
||||||
|
authentication={activeRequest.authentication}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<EmptyStateText>
|
||||||
|
No Authentication {activeRequest.authenticationType}
|
||||||
|
</EmptyStateText>
|
||||||
|
)}
|
||||||
|
</TabContent>
|
||||||
|
<TabContent value="headers">
|
||||||
|
<HeaderEditor
|
||||||
|
forceUpdateKey={`${forceUpdateHeaderEditorKey}::${forceUpdateKey}`}
|
||||||
|
headers={activeRequest.headers}
|
||||||
|
onChange={handleHeadersChange}
|
||||||
|
/>
|
||||||
|
</TabContent>
|
||||||
|
<TabContent value="params">
|
||||||
|
<ParametersEditor
|
||||||
|
forceUpdateKey={forceUpdateKey}
|
||||||
|
parameters={[]}
|
||||||
|
onChange={() => null}
|
||||||
|
/>
|
||||||
|
</TabContent>
|
||||||
|
<TabContent value="body">
|
||||||
|
{activeRequest.bodyType === BODY_TYPE_JSON ? (
|
||||||
|
<Editor
|
||||||
|
forceUpdateKey={forceUpdateKey}
|
||||||
|
useTemplating
|
||||||
|
placeholder="..."
|
||||||
|
className="!bg-gray-50"
|
||||||
|
heightMode={fullHeight ? 'full' : 'auto'}
|
||||||
|
defaultValue={activeRequest.body ?? ''}
|
||||||
|
contentType="application/json"
|
||||||
|
onChange={handleBodyChange}
|
||||||
|
format={(v) => tryFormatJson(v)}
|
||||||
|
/>
|
||||||
|
) : activeRequest.bodyType === BODY_TYPE_XML ? (
|
||||||
|
<Editor
|
||||||
|
forceUpdateKey={forceUpdateKey}
|
||||||
|
useTemplating
|
||||||
|
placeholder="..."
|
||||||
|
className="!bg-gray-50"
|
||||||
|
heightMode={fullHeight ? 'full' : 'auto'}
|
||||||
|
defaultValue={activeRequest.body ?? ''}
|
||||||
|
contentType="text/xml"
|
||||||
|
onChange={handleBodyChange}
|
||||||
|
/>
|
||||||
|
) : activeRequest.bodyType === BODY_TYPE_GRAPHQL ? (
|
||||||
|
<GraphQLEditor
|
||||||
|
forceUpdateKey={forceUpdateKey}
|
||||||
|
baseRequest={activeRequest}
|
||||||
|
className="!bg-gray-50"
|
||||||
|
defaultValue={activeRequest?.body ?? ''}
|
||||||
|
onChange={handleBodyChange}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<EmptyStateText>No Body</EmptyStateText>
|
||||||
|
)}
|
||||||
|
</TabContent>
|
||||||
|
</Tabs>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
132
src-web/components/RequestResponse.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
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 { useLocalStorage } from 'react-use';
|
||||||
|
import { useActiveWorkspaceId } from '../hooks/useActiveWorkspaceId';
|
||||||
|
import { clamp } from '../lib/clamp';
|
||||||
|
import { RequestPane } from './RequestPane';
|
||||||
|
import { ResizeHandle } from './ResizeHandle';
|
||||||
|
import { ResponsePane } from './ResponsePane';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
style: CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rqst = { gridArea: 'rqst' };
|
||||||
|
const resp = { gridArea: 'resp' };
|
||||||
|
const drag = { gridArea: 'drag' };
|
||||||
|
|
||||||
|
const DEFAULT = 0.5;
|
||||||
|
const MIN_WIDTH_PX = 10;
|
||||||
|
const MIN_HEIGHT_PX = 30;
|
||||||
|
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 [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,
|
||||||
|
);
|
||||||
|
|
||||||
|
useResizeObserver(containerRef, ({ contentRect }) => {
|
||||||
|
setVertical(contentRect.width < STACK_VERTICAL_WIDTH);
|
||||||
|
});
|
||||||
|
|
||||||
|
const styles = useMemo<CSSProperties>(
|
||||||
|
() => ({
|
||||||
|
...style,
|
||||||
|
gridTemplate: vertical
|
||||||
|
? `
|
||||||
|
' ${rqst.gridArea}' minmax(0,${1 - height}fr)
|
||||||
|
' ${drag.gridArea}' 0
|
||||||
|
' ${resp.gridArea}' minmax(0,${height}fr)
|
||||||
|
/ 1fr
|
||||||
|
`
|
||||||
|
: `
|
||||||
|
' ${rqst.gridArea} ${drag.gridArea} ${resp.gridArea}' minmax(0,1fr)
|
||||||
|
/ ${1 - width}fr 0 ${width}fr
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
[vertical, width, height, style],
|
||||||
|
);
|
||||||
|
|
||||||
|
const unsub = () => {
|
||||||
|
if (moveState.current !== null) {
|
||||||
|
document.documentElement.removeEventListener('mousemove', moveState.current.move);
|
||||||
|
document.documentElement.removeEventListener('mouseup', moveState.current.up);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = useCallback(
|
||||||
|
() => (vertical ? setHeight(DEFAULT) : setWidth(DEFAULT)),
|
||||||
|
[setHeight, vertical, setWidth],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleResizeStart = useCallback(
|
||||||
|
(e: ReactMouseEvent<HTMLDivElement>) => {
|
||||||
|
if (containerRef.current === null) return;
|
||||||
|
unsub();
|
||||||
|
|
||||||
|
const containerRect = containerRef.current.getBoundingClientRect();
|
||||||
|
|
||||||
|
const mouseStartX = e.clientX;
|
||||||
|
const mouseStartY = e.clientY;
|
||||||
|
const startWidth = containerRect.width * width;
|
||||||
|
const startHeight = containerRect.height * height;
|
||||||
|
|
||||||
|
moveState.current = {
|
||||||
|
move: (e: MouseEvent) => {
|
||||||
|
e.preventDefault(); // Prevent text selection and things
|
||||||
|
if (vertical) {
|
||||||
|
const maxHeightPx = containerRect.height - MIN_HEIGHT_PX;
|
||||||
|
const newHeightPx = clamp(
|
||||||
|
startHeight - (e.clientY - mouseStartY),
|
||||||
|
MIN_HEIGHT_PX,
|
||||||
|
maxHeightPx,
|
||||||
|
);
|
||||||
|
setHeight(newHeightPx / containerRect.height);
|
||||||
|
} else {
|
||||||
|
const maxWidthPx = containerRect.width - MIN_WIDTH_PX;
|
||||||
|
const newWidthPx = clamp(
|
||||||
|
startWidth - (e.clientX - mouseStartX),
|
||||||
|
MIN_WIDTH_PX,
|
||||||
|
maxWidthPx,
|
||||||
|
);
|
||||||
|
setWidth(newWidthPx / containerRect.width);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
up: (e: MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
unsub();
|
||||||
|
setIsResizing(false);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
document.documentElement.addEventListener('mousemove', moveState.current.move);
|
||||||
|
document.documentElement.addEventListener('mouseup', moveState.current.up);
|
||||||
|
setIsResizing(true);
|
||||||
|
},
|
||||||
|
[width, height, vertical, setHeight, setWidth],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="grid gap-1.5 w-full h-full p-3" style={styles}>
|
||||||
|
<RequestPane style={rqst} fullHeight={!vertical} />
|
||||||
|
<ResizeHandle
|
||||||
|
style={drag}
|
||||||
|
isResizing={isResizing}
|
||||||
|
className={classnames(vertical ? 'translate-y-0.5' : 'translate-x-0.5')}
|
||||||
|
onResizeStart={handleResizeStart}
|
||||||
|
onReset={handleReset}
|
||||||
|
side={vertical ? 'top' : 'left'}
|
||||||
|
justify="center"
|
||||||
|
/>
|
||||||
|
<ResponsePane style={resp} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
57
src-web/components/ResizeHandle.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import classnames from 'classnames';
|
||||||
|
import type { CSSProperties, MouseEvent as ReactMouseEvent } from 'react';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface ResizeBarProps {
|
||||||
|
style?: CSSProperties;
|
||||||
|
className?: string;
|
||||||
|
barClassName?: string;
|
||||||
|
isResizing: boolean;
|
||||||
|
onResizeStart: (e: ReactMouseEvent<HTMLDivElement>) => void;
|
||||||
|
onReset?: () => void;
|
||||||
|
side: 'left' | 'right' | 'top';
|
||||||
|
justify: 'center' | 'end' | 'start';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResizeHandle({
|
||||||
|
style,
|
||||||
|
justify,
|
||||||
|
className,
|
||||||
|
onResizeStart,
|
||||||
|
onReset,
|
||||||
|
isResizing,
|
||||||
|
side,
|
||||||
|
}: ResizeBarProps) {
|
||||||
|
const vertical = side === 'top';
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
draggable
|
||||||
|
style={style}
|
||||||
|
className={classnames(
|
||||||
|
className,
|
||||||
|
'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',
|
||||||
|
side === 'right' && 'right-0',
|
||||||
|
side === 'left' && 'left-0',
|
||||||
|
side === 'top' && 'top-0',
|
||||||
|
)}
|
||||||
|
onDragStart={onResizeStart}
|
||||||
|
onDoubleClick={onReset}
|
||||||
|
>
|
||||||
|
{/* Show global overlay with cursor style to ensure cursor remains the same when moving quickly */}
|
||||||
|
{isResizing && (
|
||||||
|
<div
|
||||||
|
className={classnames(
|
||||||
|
'fixed -left-20 -right-20 -top-20 -bottom-20',
|
||||||
|
vertical && 'cursor-row-resize',
|
||||||
|
!vertical && 'cursor-col-resize',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
src-web/components/ResponseHeaders.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import classnames from 'classnames';
|
||||||
|
import type { HttpResponse } from '../lib/models';
|
||||||
|
import { HStack } from './core/Stacks';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
headers: HttpResponse['headers'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResponseHeaders({ headers }: Props) {
|
||||||
|
return (
|
||||||
|
<dl className="text-xs w-full h-full font-mono overflow-auto">
|
||||||
|
{headers.map((h, i) => {
|
||||||
|
return (
|
||||||
|
<HStack
|
||||||
|
space={3}
|
||||||
|
key={i}
|
||||||
|
className={classnames(i > 0 ? 'border-t border-highlightSecondary py-1' : 'pb-1')}
|
||||||
|
>
|
||||||
|
<dd className="w-1/3 text-violet-600 select-text cursor-text">{h.name}</dd>
|
||||||
|
<dt className="w-2/3 select-text cursor-text break-all">{h.value}</dt>
|
||||||
|
</HStack>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</dl>
|
||||||
|
);
|
||||||
|
}
|
||||||
199
src-web/components/ResponsePane.tsx
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import classnames from 'classnames';
|
||||||
|
import type { CSSProperties } from 'react';
|
||||||
|
import { memo, useEffect, useMemo, useState } from 'react';
|
||||||
|
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 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 { 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 { StatusTag } from './core/StatusTag';
|
||||||
|
import type { TabItem } from './core/Tabs/Tabs';
|
||||||
|
import { TabContent, Tabs } from './core/Tabs/Tabs';
|
||||||
|
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;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
: latestResponse ?? null;
|
||||||
|
const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId);
|
||||||
|
const deleteResponse = useDeleteResponse(activeResponse?.id ?? null);
|
||||||
|
const deleteAllResponses = useDeleteResponses(activeResponse?.requestId);
|
||||||
|
const [activeTab, setActiveTab] = useActiveTab();
|
||||||
|
|
||||||
|
// Unset pinned response when a new one comes in
|
||||||
|
useEffect(() => setPinnedResponseId(null), [responses.length]);
|
||||||
|
|
||||||
|
const contentType = useResponseContentType(activeResponse);
|
||||||
|
|
||||||
|
const tabs: TabItem[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
value: 'body',
|
||||||
|
label: 'Preview',
|
||||||
|
options: {
|
||||||
|
value: viewMode,
|
||||||
|
onChange: setViewMode,
|
||||||
|
items: [
|
||||||
|
{ label: 'Pretty', value: 'pretty' },
|
||||||
|
{ label: 'Raw', value: 'raw' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: (
|
||||||
|
<div className="flex items-center">
|
||||||
|
Headers
|
||||||
|
<CountBadge
|
||||||
|
count={activeResponse?.headers.filter((h) => h.name && h.value).length ?? 0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
value: 'headers',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[activeResponse?.headers, setViewMode, viewMode],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={style}
|
||||||
|
className={classnames(
|
||||||
|
className,
|
||||||
|
'bg-gray-50 max-h-full h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1',
|
||||||
|
'dark:bg-gray-100 rounded-md border border-highlight',
|
||||||
|
'shadow shadow-gray-100 dark:shadow-gray-0 relative',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{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>•</span>
|
||||||
|
<DurationTag millis={activeResponse.elapsed} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!!activeResponse.contentLength && (
|
||||||
|
<>
|
||||||
|
<span>•</span>
|
||||||
|
<SizeTag contentLength={activeResponse.contentLength} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>•</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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
});
|
||||||
35
src-web/components/RouteError.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { useRouteError } from 'react-router-dom';
|
||||||
|
import { useAppRoutes } from '../hooks/useAppRoutes';
|
||||||
|
import { Button } from './core/Button';
|
||||||
|
import { FormattedError } from './core/FormattedError';
|
||||||
|
import { Heading } from './core/Heading';
|
||||||
|
import { VStack } from './core/Stacks';
|
||||||
|
|
||||||
|
export default function RouteError() {
|
||||||
|
const error = useRouteError();
|
||||||
|
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">
|
||||||
|
<Heading>Route Error 🔥</Heading>
|
||||||
|
<FormattedError>{message}</FormattedError>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
404
src-web/components/Sidebar.tsx
Normal file
@@ -0,0 +1,404 @@
|
|||||||
|
import classnames from 'classnames';
|
||||||
|
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 { 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 { isResponseLoading } from '../lib/models';
|
||||||
|
import { Icon } from './core/Icon';
|
||||||
|
import { VStack } from './core/Stacks';
|
||||||
|
import { StatusTag } from './core/StatusTag';
|
||||||
|
import { DropMarker } from './DropMarker';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ItemTypes {
|
||||||
|
REQUEST = 'request',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Sidebar = memo(function Sidebar({ className }: Props) {
|
||||||
|
const { hidden } = useSidebarHidden();
|
||||||
|
const sidebarRef = useRef<HTMLDivElement>(null);
|
||||||
|
const activeRequestId = useActiveRequestId();
|
||||||
|
const unorderedRequests = useRequests();
|
||||||
|
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 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
|
||||||
|
as="ul"
|
||||||
|
className="relative py-3 overflow-y-auto overflow-x-visible"
|
||||||
|
draggable={false}
|
||||||
|
>
|
||||||
|
<SidebarItems
|
||||||
|
selectedIndex={selectedIndex}
|
||||||
|
requests={requests}
|
||||||
|
focused={hasFocus}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
/>
|
||||||
|
</VStack>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
interface SidebarItemsProps {
|
||||||
|
requests: HttpRequest[];
|
||||||
|
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();
|
||||||
|
|
||||||
|
const handleMove = useCallback<DraggableSidebarItemProps['onMove']>(
|
||||||
|
(id, side) => {
|
||||||
|
const dragIndex = requests.findIndex((r) => r.id === id);
|
||||||
|
setHoveredIndex(side === 'above' ? dragIndex : dragIndex + 1);
|
||||||
|
},
|
||||||
|
[requests],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleEnd = useCallback<DraggableSidebarItemProps['onEnd']>(
|
||||||
|
(requestId) => {
|
||||||
|
if (hoveredIndex === null) return;
|
||||||
|
setHoveredIndex(null);
|
||||||
|
|
||||||
|
const index = requests.findIndex((r) => r.id === requestId);
|
||||||
|
const request = requests[index];
|
||||||
|
if (request === undefined) return;
|
||||||
|
|
||||||
|
const newRequests = requests.filter((r) => r.id !== requestId);
|
||||||
|
if (hoveredIndex > index) newRequests.splice(hoveredIndex - 1, 0, request);
|
||||||
|
else newRequests.splice(hoveredIndex, 0, request);
|
||||||
|
|
||||||
|
const beforePriority = newRequests[hoveredIndex - 1]?.sortPriority ?? 0;
|
||||||
|
const afterPriority = newRequests[hoveredIndex + 1]?.sortPriority ?? 0;
|
||||||
|
|
||||||
|
const shouldUpdateAll = afterPriority - beforePriority < 1;
|
||||||
|
if (shouldUpdateAll) {
|
||||||
|
newRequests.forEach(({ id }, i) => {
|
||||||
|
const sortPriority = i * 1000;
|
||||||
|
const update = (r: HttpRequest) => ({ ...r, sortPriority });
|
||||||
|
updateRequest.mutate({ id, update });
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const sortPriority = afterPriority - (afterPriority - beforePriority) / 2;
|
||||||
|
const update = (r: HttpRequest) => ({ ...r, sortPriority });
|
||||||
|
updateRequest.mutate({ id: requestId, update });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[hoveredIndex, requests, updateRequest],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{requests.map((r, i) => (
|
||||||
|
<Fragment key={r.id}>
|
||||||
|
{hoveredIndex === i && <DropMarker />}
|
||||||
|
<DraggableSidebarItem
|
||||||
|
key={r.id}
|
||||||
|
selected={selectedIndex === i}
|
||||||
|
requestId={r.id}
|
||||||
|
requestName={r.name}
|
||||||
|
onMove={handleMove}
|
||||||
|
onEnd={handleEnd}
|
||||||
|
useProminentStyles={focused}
|
||||||
|
onSelect={onSelect}
|
||||||
|
/>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
{hoveredIndex === requests.length && <DropMarker />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type SidebarItemProps = {
|
||||||
|
className?: string;
|
||||||
|
requestId: string;
|
||||||
|
requestName: string;
|
||||||
|
useProminentStyles?: boolean;
|
||||||
|
selected?: boolean;
|
||||||
|
onSelect: (requestId: string) => void;
|
||||||
|
draggable?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const _SidebarItem = forwardRef(function SidebarItem(
|
||||||
|
{ 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) => {
|
||||||
|
await updateRequest.mutate((r) => ({ ...r, name: el.value }));
|
||||||
|
setEditing(false);
|
||||||
|
},
|
||||||
|
[updateRequest],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFocus = useCallback((el: HTMLInputElement | null) => {
|
||||||
|
el?.focus();
|
||||||
|
el?.select();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleInputKeyDown = useCallback(
|
||||||
|
async (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
switch (e.key) {
|
||||||
|
case 'Enter':
|
||||||
|
e.preventDefault();
|
||||||
|
await handleSubmitNameEdit(e.currentTarget);
|
||||||
|
break;
|
||||||
|
case 'Escape':
|
||||||
|
e.preventDefault();
|
||||||
|
setEditing(false);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[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')}>
|
||||||
|
<button
|
||||||
|
// tabIndex={-1} // Will prevent drag-n-drop
|
||||||
|
onClick={handleSelect}
|
||||||
|
disabled={editing}
|
||||||
|
onDoubleClick={handleStartEditing}
|
||||||
|
data-active={isActive}
|
||||||
|
data-selected={selected}
|
||||||
|
className={classnames(
|
||||||
|
'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}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const SidebarItem = memo(_SidebarItem);
|
||||||
|
|
||||||
|
type DraggableSidebarItemProps = SidebarItemProps & {
|
||||||
|
onMove: (id: string, side: 'above' | 'below') => void;
|
||||||
|
onEnd: (id: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DragItem = {
|
||||||
|
id: string;
|
||||||
|
requestName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DraggableSidebarItem = memo(function DraggableSidebarItem({
|
||||||
|
requestName,
|
||||||
|
requestId,
|
||||||
|
onMove,
|
||||||
|
onEnd,
|
||||||
|
...props
|
||||||
|
}: DraggableSidebarItemProps) {
|
||||||
|
const ref = useRef<HTMLLIElement>(null);
|
||||||
|
|
||||||
|
const [, connectDrop] = useDrop<DragItem, void>(
|
||||||
|
{
|
||||||
|
accept: ItemTypes.REQUEST,
|
||||||
|
hover: (item, monitor) => {
|
||||||
|
if (!ref.current) return;
|
||||||
|
const hoverBoundingRect = ref.current?.getBoundingClientRect();
|
||||||
|
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
|
||||||
|
const clientOffset = monitor.getClientOffset();
|
||||||
|
const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top;
|
||||||
|
onMove(requestId, hoverClientY < hoverMiddleY ? 'above' : 'below');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[onMove],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [{ isDragging }, connectDrag] = useDrag<DragItem, unknown, { isDragging: boolean }>(
|
||||||
|
() => ({
|
||||||
|
type: ItemTypes.REQUEST,
|
||||||
|
item: () => ({ id: requestId, requestName }),
|
||||||
|
collect: (m) => ({ isDragging: m.isDragging() }),
|
||||||
|
options: { dropEffect: 'move' },
|
||||||
|
end: () => onEnd(requestId),
|
||||||
|
}),
|
||||||
|
[onEnd],
|
||||||
|
);
|
||||||
|
|
||||||
|
connectDrag(ref);
|
||||||
|
connectDrop(ref);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarItem
|
||||||
|
ref={ref}
|
||||||
|
draggable
|
||||||
|
className={classnames(isDragging && 'opacity-20')}
|
||||||
|
requestName={requestName}
|
||||||
|
requestId={requestId}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
37
src-web/components/SidebarActions.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
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 SidebarActions() {
|
||||||
|
const { hidden, toggle } = useSidebarHidden();
|
||||||
|
const createRequest = useCreateRequest({ navigateAfter: true });
|
||||||
|
|
||||||
|
const handleCreateRequest = useCallback(() => {
|
||||||
|
createRequest.mutate({});
|
||||||
|
}, [createRequest]);
|
||||||
|
|
||||||
|
useTauriEvent('new_request', () => {
|
||||||
|
createRequest.mutate({});
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<IconButton
|
||||||
|
onClick={toggle}
|
||||||
|
className="pointer-events-auto"
|
||||||
|
size="sm"
|
||||||
|
title="Show sidebar"
|
||||||
|
icon={hidden ? 'leftPanelHidden' : 'leftPanelVisible'}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
onClick={handleCreateRequest}
|
||||||
|
className="pointer-events-auto"
|
||||||
|
size="sm"
|
||||||
|
title="Show sidebar"
|
||||||
|
icon="plusCircle"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
import React, { Children, Fragment, HTMLAttributes, ReactNode } from 'react';
|
|
||||||
import classnames from 'classnames';
|
|
||||||
|
|
||||||
const spaceClassesX = {
|
|
||||||
0: 'pr-0',
|
|
||||||
1: 'pr-1',
|
|
||||||
2: 'pr-2',
|
|
||||||
3: 'pr-3',
|
|
||||||
4: 'pr-4',
|
|
||||||
};
|
|
||||||
|
|
||||||
const spaceClassesY = {
|
|
||||||
0: 'pt-0',
|
|
||||||
1: 'pt-1',
|
|
||||||
2: 'pt-2',
|
|
||||||
3: 'pt-3',
|
|
||||||
4: 'pt-4',
|
|
||||||
};
|
|
||||||
|
|
||||||
interface HStackProps extends BaseStackProps {
|
|
||||||
space?: keyof typeof spaceClassesX;
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HStack({ className, space, children, ...props }: HStackProps) {
|
|
||||||
return (
|
|
||||||
<BaseStack className={classnames(className, 'w-full flex-row')} {...props}>
|
|
||||||
{space
|
|
||||||
? Children.toArray(children)
|
|
||||||
.filter(Boolean) // Remove null/false/undefined children
|
|
||||||
.map((c, i) => (
|
|
||||||
<Fragment key={i}>
|
|
||||||
{i > 0 ? (
|
|
||||||
<div
|
|
||||||
className={classnames(spaceClassesX[space], 'pointer-events-none')}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{c}
|
|
||||||
</Fragment>
|
|
||||||
))
|
|
||||||
: children}
|
|
||||||
</BaseStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VStackProps extends BaseStackProps {
|
|
||||||
space?: keyof typeof spaceClassesY;
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function VStack({ className, space, children, ...props }: VStackProps) {
|
|
||||||
return (
|
|
||||||
<BaseStack className={classnames(className, 'w-full h-full flex-col')} {...props}>
|
|
||||||
{space
|
|
||||||
? Children.toArray(children)
|
|
||||||
.filter(Boolean) // Remove null/false/undefined children
|
|
||||||
.map((c, i) => (
|
|
||||||
<Fragment key={i}>
|
|
||||||
{i > 0 ? (
|
|
||||||
<div
|
|
||||||
className={classnames(spaceClassesY[space], 'pointer-events-none')}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{c}
|
|
||||||
</Fragment>
|
|
||||||
))
|
|
||||||
: children}
|
|
||||||
</BaseStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BaseStackProps extends HTMLAttributes<HTMLElement> {
|
|
||||||
items?: 'start' | 'center';
|
|
||||||
justify?: 'start' | 'end';
|
|
||||||
as?: React.ElementType;
|
|
||||||
}
|
|
||||||
|
|
||||||
function BaseStack({ className, items, justify, as = 'div', ...props }: BaseStackProps) {
|
|
||||||
const Component = as;
|
|
||||||
return (
|
|
||||||
<Component
|
|
||||||
className={classnames(className, 'flex flex-grow-0', {
|
|
||||||
'items-center': items === 'center',
|
|
||||||
'items-start': items === 'start',
|
|
||||||
'justify-start': justify === 'start',
|
|
||||||
'justify-end': justify === 'end',
|
|
||||||
})}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
83
src-web/components/UrlBar.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import classnames from 'classnames';
|
||||||
|
import type { EditorView } from 'codemirror';
|
||||||
|
import type { FormEvent } from 'react';
|
||||||
|
import { memo, useCallback, useRef } from 'react';
|
||||||
|
import { useIsResponseLoading } from '../hooks/useIsResponseLoading';
|
||||||
|
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
|
||||||
|
import { useSendRequest } from '../hooks/useSendRequest';
|
||||||
|
import { useTauriEvent } from '../hooks/useTauriEvent';
|
||||||
|
import { useUpdateRequest } from '../hooks/useUpdateRequest';
|
||||||
|
import type { HttpRequest } from '../lib/models';
|
||||||
|
import { IconButton } from './core/IconButton';
|
||||||
|
import { Input } from './core/Input';
|
||||||
|
import { RequestMethodDropdown } from './RequestMethodDropdown';
|
||||||
|
|
||||||
|
type Props = Pick<HttpRequest, 'id' | 'url' | 'method'> & {
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const UrlBar = memo(function UrlBar({ id: requestId, url, method, className }: Props) {
|
||||||
|
const inputRef = useRef<EditorView>(null);
|
||||||
|
const sendRequest = useSendRequest(requestId);
|
||||||
|
const updateRequest = useUpdateRequest(requestId);
|
||||||
|
const handleMethodChange = useCallback(
|
||||||
|
(method: string) => updateRequest.mutate({ method }),
|
||||||
|
[updateRequest],
|
||||||
|
);
|
||||||
|
const handleUrlChange = useCallback(
|
||||||
|
(url: string) => updateRequest.mutate({ url }),
|
||||||
|
[updateRequest],
|
||||||
|
);
|
||||||
|
const loading = useIsResponseLoading(requestId);
|
||||||
|
const { updateKey } = useRequestUpdateKey(requestId);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
sendRequest();
|
||||||
|
},
|
||||||
|
[sendRequest],
|
||||||
|
);
|
||||||
|
|
||||||
|
useTauriEvent('focus_url', () => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className={classnames('url-bar', className)}>
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
size="sm"
|
||||||
|
hideLabel
|
||||||
|
useTemplating
|
||||||
|
contentType="url"
|
||||||
|
className="px-0"
|
||||||
|
name="url"
|
||||||
|
label="Enter URL"
|
||||||
|
forceUpdateKey={updateKey}
|
||||||
|
containerClassName="shadow shadow-gray-100 dark:shadow-gray-50"
|
||||||
|
onChange={handleUrlChange}
|
||||||
|
defaultValue={url}
|
||||||
|
placeholder="https://example.com"
|
||||||
|
leftSlot={
|
||||||
|
<RequestMethodDropdown
|
||||||
|
method={method}
|
||||||
|
onChange={handleMethodChange}
|
||||||
|
className="mx-0.5 h-full my-1"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
rightSlot={
|
||||||
|
<IconButton
|
||||||
|
size="xs"
|
||||||
|
iconSize="sm"
|
||||||
|
title="Send Request"
|
||||||
|
type="submit"
|
||||||
|
className="w-8 mr-0.5"
|
||||||
|
icon={loading ? 'update' : 'paperPlane'}
|
||||||
|
spin={loading}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import classnames from 'classnames';
|
|
||||||
import { HTMLAttributes } from 'react';
|
|
||||||
|
|
||||||
type Props = HTMLAttributes<HTMLDivElement>;
|
|
||||||
|
|
||||||
export function WindowDragRegion({ className, ...props }: Props) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={classnames(className, 'w-full h-11 border-b border-gray-500/10')}
|
|
||||||
data-tauri-drag-region=""
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
184
src-web/components/Workspace.tsx
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import classnames from 'classnames';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import type {
|
||||||
|
CSSProperties,
|
||||||
|
HTMLAttributes,
|
||||||
|
MouseEvent as ReactMouseEvent,
|
||||||
|
ReactNode,
|
||||||
|
} 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';
|
||||||
|
import { WINDOW_FLOATING_SIDEBAR_WIDTH } from '../lib/constants';
|
||||||
|
import { Button } from './core/Button';
|
||||||
|
import { HStack } from './core/Stacks';
|
||||||
|
import { Overlay } from './Overlay';
|
||||||
|
import { RequestResponse } from './RequestResponse';
|
||||||
|
import { ResizeHandle } from './ResizeHandle';
|
||||||
|
import { Sidebar } from './Sidebar';
|
||||||
|
import { SidebarActions } from './SidebarActions';
|
||||||
|
import { WorkspaceHeader } from './WorkspaceHeader';
|
||||||
|
|
||||||
|
const side = { gridArea: 'side' };
|
||||||
|
const head = { gridArea: 'head' };
|
||||||
|
const body = { gridArea: 'body' };
|
||||||
|
const drag = { gridArea: 'drag' };
|
||||||
|
|
||||||
|
export default function Workspace() {
|
||||||
|
const { setWidth, width, resetWidth } = useSidebarWidth();
|
||||||
|
const { show, hide, hidden, toggle } = useSidebarHidden();
|
||||||
|
|
||||||
|
const windowSize = useWindowSize();
|
||||||
|
const [floating, setFloating] = useState<boolean>(false);
|
||||||
|
const [isResizing, setIsResizing] = useState<boolean>(false);
|
||||||
|
const moveState = useRef<{ move: (e: MouseEvent) => void; up: (e: MouseEvent) => void } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
useTauriEvent('toggle_sidebar', toggle);
|
||||||
|
|
||||||
|
// float/un-float sidebar on window resize
|
||||||
|
useEffect(() => {
|
||||||
|
const shouldHide = windowSize.width <= WINDOW_FLOATING_SIDEBAR_WIDTH;
|
||||||
|
if (shouldHide && !hidden) {
|
||||||
|
setFloating(true);
|
||||||
|
hide();
|
||||||
|
} else if (!shouldHide && hidden) {
|
||||||
|
setFloating(false);
|
||||||
|
show();
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [windowSize.width]);
|
||||||
|
|
||||||
|
const unsub = () => {
|
||||||
|
if (moveState.current !== null) {
|
||||||
|
document.documentElement.removeEventListener('mousemove', moveState.current.move);
|
||||||
|
document.documentElement.removeEventListener('mouseup', moveState.current.up);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResizeStart = useCallback(
|
||||||
|
(e: ReactMouseEvent<HTMLDivElement>) => {
|
||||||
|
if (width === undefined) return;
|
||||||
|
|
||||||
|
unsub();
|
||||||
|
const mouseStartX = e.clientX;
|
||||||
|
const startWidth = width;
|
||||||
|
moveState.current = {
|
||||||
|
move: async (e: MouseEvent) => {
|
||||||
|
e.preventDefault(); // Prevent text selection and things
|
||||||
|
setWidth(startWidth + (e.clientX - mouseStartX));
|
||||||
|
},
|
||||||
|
up: (e: MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
unsub();
|
||||||
|
setIsResizing(false);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
document.documentElement.addEventListener('mousemove', moveState.current.move);
|
||||||
|
document.documentElement.addEventListener('mouseup', moveState.current.up);
|
||||||
|
setIsResizing(true);
|
||||||
|
},
|
||||||
|
[setWidth, width],
|
||||||
|
);
|
||||||
|
|
||||||
|
const sideWidth = hidden ? 0 : width;
|
||||||
|
const styles = useMemo<CSSProperties>(
|
||||||
|
() => ({
|
||||||
|
gridTemplate: floating
|
||||||
|
? `
|
||||||
|
' ${head.gridArea}' auto
|
||||||
|
' ${body.gridArea}' minmax(0,1fr)
|
||||||
|
/ 1fr`
|
||||||
|
: `
|
||||||
|
' ${head.gridArea} ${head.gridArea} ${head.gridArea}' auto
|
||||||
|
' ${side.gridArea} ${drag.gridArea} ${body.gridArea}' minmax(0,1fr)
|
||||||
|
/ ${sideWidth}px 0 1fr`,
|
||||||
|
}),
|
||||||
|
[sideWidth, floating],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (windowSize.width <= 100) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Button>Send</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={styles}
|
||||||
|
className={classnames(
|
||||||
|
'grid w-full h-full',
|
||||||
|
// Animate sidebar width changes but only when not resizing
|
||||||
|
// because it's too slow to animate on mouse move
|
||||||
|
!isResizing && 'transition-all',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{floating ? (
|
||||||
|
<Overlay open={!hidden} portalName="sidebar" onClose={hide}>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: -10 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
className={classnames(
|
||||||
|
'absolute top-0 left-0 bottom-0 bg-gray-100 border-r border-highlight w-[14rem]',
|
||||||
|
'grid grid-rows-[auto_1fr]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<HeaderSize className="border-transparent">
|
||||||
|
<HStack space={0.5}>
|
||||||
|
<SidebarActions />
|
||||||
|
</HStack>
|
||||||
|
</HeaderSize>
|
||||||
|
<Sidebar />
|
||||||
|
</motion.div>
|
||||||
|
</Overlay>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div style={side} className={classnames('overflow-hidden bg-gray-100')}>
|
||||||
|
<Sidebar className="border-r border-highlight" />
|
||||||
|
</div>
|
||||||
|
<ResizeHandle
|
||||||
|
className="-translate-x-3"
|
||||||
|
justify="end"
|
||||||
|
side="right"
|
||||||
|
isResizing={isResizing}
|
||||||
|
onResizeStart={handleResizeStart}
|
||||||
|
onReset={resetWidth}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HeaderSizeProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
160
src-web/components/WorkspaceActionsDropdown.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import { invoke } from '@tauri-apps/api';
|
||||||
|
import classnames from 'classnames';
|
||||||
|
import { memo, useMemo } from 'react';
|
||||||
|
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
|
||||||
|
import { useAppRoutes } from '../hooks/useAppRoutes';
|
||||||
|
import { useCreateWorkspace } from '../hooks/useCreateWorkspace';
|
||||||
|
import { useDeleteWorkspace } from '../hooks/useDeleteWorkspace';
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WorkspaceActionsDropdown = memo(function WorkspaceDropdown({ className }: Props) {
|
||||||
|
const workspaces = useWorkspaces();
|
||||||
|
const activeWorkspace = useActiveWorkspace();
|
||||||
|
const activeWorkspaceId = activeWorkspace?.id ?? null;
|
||||||
|
const createWorkspace = useCreateWorkspace({ navigateAfter: true });
|
||||||
|
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,
|
||||||
|
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 [
|
||||||
|
...activeWorkspaceItems,
|
||||||
|
{
|
||||||
|
key: 'rename',
|
||||||
|
label: 'Rename',
|
||||||
|
leftSlot: <Icon icon="pencil" />,
|
||||||
|
onSelect: async () => {
|
||||||
|
const name = await prompt({
|
||||||
|
title: 'Rename Workspace',
|
||||||
|
description: (
|
||||||
|
<>
|
||||||
|
Enter a new name for <InlineCode>{activeWorkspace?.name}</InlineCode>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
name: 'name',
|
||||||
|
label: 'Name',
|
||||||
|
defaultValue: activeWorkspace?.name,
|
||||||
|
});
|
||||||
|
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,
|
||||||
|
activeWorkspace?.name,
|
||||||
|
deleteWorkspace.mutate,
|
||||||
|
dialog,
|
||||||
|
routes,
|
||||||
|
prompt,
|
||||||
|
updateWorkspace,
|
||||||
|
createWorkspace,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown items={items}>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className={classnames(className, 'text-gray-800 !px-2 truncate')}
|
||||||
|
forDropdown
|
||||||
|
>
|
||||||
|
{activeWorkspace?.name}
|
||||||
|
</Button>
|
||||||
|
</Dropdown>
|
||||||
|
);
|
||||||
|
});
|
||||||
45
src-web/components/WorkspaceHeader.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import classnames from 'classnames';
|
||||||
|
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';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Props) {
|
||||||
|
const activeRequest = useActiveRequest();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HStack
|
||||||
|
justifyContent="center"
|
||||||
|
alignItems="center"
|
||||||
|
className={classnames(className, 'w-full h-full')}
|
||||||
|
>
|
||||||
|
<HStack space={0.5} className="flex-1 pointer-events-none" alignItems="center">
|
||||||
|
<SidebarActions />
|
||||||
|
<WorkspaceActionsDropdown className="pointer-events-auto" />
|
||||||
|
</HStack>
|
||||||
|
<div className="pointer-events-none">
|
||||||
|
<RecentRequestsDropdown />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex justify-end -mr-2 pointer-events-none">
|
||||||
|
{activeRequest && (
|
||||||
|
<RequestActionsDropdown requestId={activeRequest?.id}>
|
||||||
|
<IconButton
|
||||||
|
size="sm"
|
||||||
|
title="Request Options"
|
||||||
|
icon="gear"
|
||||||
|
className="pointer-events-auto"
|
||||||
|
/>
|
||||||
|
</RequestActionsDropdown>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</HStack>
|
||||||
|
);
|
||||||
|
});
|
||||||
16
src-web/components/Workspaces.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Navigate } from 'react-router-dom';
|
||||||
|
import { useAppRoutes } from '../hooks/useAppRoutes';
|
||||||
|
import { useWorkspaces } from '../hooks/useWorkspaces';
|
||||||
|
import { Heading } from './core/Heading';
|
||||||
|
|
||||||
|
export default function Workspaces() {
|
||||||
|
const routes = useAppRoutes();
|
||||||
|
const workspaces = useWorkspaces();
|
||||||
|
const workspace = workspaces[0];
|
||||||
|
|
||||||
|
if (workspace === undefined) {
|
||||||
|
return <Heading>There are no workspaces</Heading>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Navigate to={routes.paths.workspace({ workspaceId: workspace.id })} />;
|
||||||
|
}
|
||||||
21
src-web/components/core/Banner.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import classnames from 'classnames';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
export function Banner({ children, className }: Props) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className={classnames(
|
||||||
|
className,
|
||||||
|
'border border-red-500 bg-red-300/10 text-red-800 px-3 py-2 rounded select-auto cursor-text',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
src-web/components/core/Button.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import classnames from 'classnames';
|
||||||
|
import type { HTMLAttributes, ReactNode } from 'react';
|
||||||
|
import { forwardRef, memo, useMemo } from 'react';
|
||||||
|
import { Icon } from './Icon';
|
||||||
|
|
||||||
|
const colorStyles = {
|
||||||
|
custom: 'ring-blue-500/50',
|
||||||
|
default:
|
||||||
|
'text-gray-700 enabled:hocus:bg-gray-700/10 enabled:hocus:text-gray-1000 ring-blue-500/50',
|
||||||
|
gray: 'text-gray-800 bg-highlight enabled:hocus:bg-gray-500/20 enabled:hocus:text-gray-1000 ring-blue-500/50',
|
||||||
|
primary: 'bg-blue-400 text-white enabled:hocus:bg-blue-500 ring-blue-500/50',
|
||||||
|
secondary: 'bg-violet-400 text-white enabled:hocus:bg-violet-500 ring-violet-500/50',
|
||||||
|
warning: 'bg-orange-400 text-white enabled:hocus:bg-orange-500 ring-orange-500/50',
|
||||||
|
danger: 'bg-red-400 text-white enabled:hocus:bg-red-500 ring-red-500/50',
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ButtonProps = HTMLAttributes<HTMLButtonElement> & {
|
||||||
|
color?: keyof typeof colorStyles;
|
||||||
|
isLoading?: boolean;
|
||||||
|
size?: 'sm' | 'md' | 'xs';
|
||||||
|
justify?: 'start' | 'center';
|
||||||
|
type?: 'button' | 'submit';
|
||||||
|
forDropdown?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
title?: string;
|
||||||
|
rightSlot?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const _Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
||||||
|
{
|
||||||
|
isLoading,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
forDropdown,
|
||||||
|
color,
|
||||||
|
type = 'button',
|
||||||
|
justify = 'center',
|
||||||
|
size = 'md',
|
||||||
|
rightSlot,
|
||||||
|
disabled,
|
||||||
|
...props
|
||||||
|
}: ButtonProps,
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const classes = useMemo(
|
||||||
|
() =>
|
||||||
|
classnames(
|
||||||
|
className,
|
||||||
|
'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',
|
||||||
|
size === 'md' && 'h-md px-3',
|
||||||
|
size === 'sm' && 'h-sm px-2.5 text-sm',
|
||||||
|
size === 'xs' && 'h-xs px-2 text-sm',
|
||||||
|
),
|
||||||
|
[className, disabled, color, justify, size],
|
||||||
|
);
|
||||||
|
|
||||||
|
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);
|
||||||
38
src-web/components/core/Checkbox.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import classnames from 'classnames';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { Icon } from './Icon';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (checked: boolean) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Checkbox({ checked, onChange, className, disabled }: Props) {
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
onChange(!checked);
|
||||||
|
}, [onChange, checked]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
role="checkbox"
|
||||||
|
aria-checked={checked ? 'true' : 'false'}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={handleClick}
|
||||||
|
className={classnames(
|
||||||
|
className,
|
||||||
|
'flex-shrink-0 w-4 h-4 border border-gray-200 rounded',
|
||||||
|
'focus:border-focus',
|
||||||
|
'disabled:opacity-disabled',
|
||||||
|
checked && 'bg-gray-200/10',
|
||||||
|
// Remove focus style
|
||||||
|
'outline-none',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<Icon size="sm" icon={checked ? 'check' : 'empty'} />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
src-web/components/core/CountBadge.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import classnames from 'classnames';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
count: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CountBadge({ count, className }: Props) {
|
||||||
|
if (count === 0) return null;
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
src-web/components/core/Dialog.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import classnames from 'classnames';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useKeyPressEvent } from 'react-use';
|
||||||
|
import { Overlay } from '../Overlay';
|
||||||
|
import { Heading } from './Heading';
|
||||||
|
import { IconButton } from './IconButton';
|
||||||
|
|
||||||
|
export interface DialogProps {
|
||||||
|
children: ReactNode;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title: ReactNode;
|
||||||
|
description?: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
size?: 'sm' | 'md' | 'full' | 'dynamic';
|
||||||
|
hideX?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Dialog({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
size = 'full',
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
hideX,
|
||||||
|
}: DialogProps) {
|
||||||
|
const titleId = useMemo(() => Math.random().toString(36).slice(2), []);
|
||||||
|
const descriptionId = useMemo(
|
||||||
|
() => (description ? Math.random().toString(36).slice(2) : undefined),
|
||||||
|
[description],
|
||||||
|
);
|
||||||
|
|
||||||
|
useKeyPressEvent('Escape', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Overlay open={open} onClose={onClose} portalName="dialog">
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
aria-describedby={descriptionId}
|
||||||
|
className="pointer-events-auto"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ top: 5, scale: 0.97 }}
|
||||||
|
animate={{ top: 0, scale: 1 }}
|
||||||
|
className={classnames(
|
||||||
|
className,
|
||||||
|
'relative bg-gray-50 pointer-events-auto',
|
||||||
|
'max-h-[80vh] p-5 rounded-lg overflow-auto',
|
||||||
|
'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}
|
||||||
|
title="Close dialog"
|
||||||
|
aria-label="Close"
|
||||||
|
icon="x"
|
||||||
|
size="sm"
|
||||||
|
className="ml-auto absolute right-1 top-1"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Overlay>
|
||||||
|
);
|
||||||
|
}
|
||||||
379
src-web/components/core/Dropdown.tsx
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
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 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';
|
||||||
|
|
||||||
|
export type DropdownItemSeparator = {
|
||||||
|
type: 'separator';
|
||||||
|
label?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DropdownItem =
|
||||||
|
| {
|
||||||
|
key: string;
|
||||||
|
type?: 'default';
|
||||||
|
label: ReactNode;
|
||||||
|
variant?: 'danger';
|
||||||
|
disabled?: boolean;
|
||||||
|
hidden?: boolean;
|
||||||
|
leftSlot?: ReactNode;
|
||||||
|
rightSlot?: ReactNode;
|
||||||
|
onSelect?: () => void;
|
||||||
|
}
|
||||||
|
| DropdownItemSeparator;
|
||||||
|
|
||||||
|
export interface DropdownProps {
|
||||||
|
children: ReactElement<HTMLAttributes<HTMLButtonElement>>;
|
||||||
|
items: DropdownItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
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 [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: buttonRef,
|
||||||
|
'aria-haspopup': 'true',
|
||||||
|
onClick:
|
||||||
|
existingChild.props?.onClick ??
|
||||||
|
((e: MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setDefaultSelectedIndex(undefined);
|
||||||
|
setOpen((o) => !o);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
return cloneElement(existingChild, props);
|
||||||
|
}, [children]);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
setOpen(false);
|
||||||
|
buttonRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
buttonRef.current?.setAttribute('aria-expanded', open.toString());
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const triggerRect = useMemo(() => {
|
||||||
|
if (!open) return null;
|
||||||
|
return buttonRef.current?.getBoundingClientRect();
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{child}
|
||||||
|
{open && triggerRect && (
|
||||||
|
<Menu
|
||||||
|
ref={menuRef}
|
||||||
|
defaultSelectedIndex={defaultSelectedIndex}
|
||||||
|
items={items}
|
||||||
|
triggerRect={triggerRect}
|
||||||
|
onClose={handleClose}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
interface MenuProps {
|
||||||
|
className?: string;
|
||||||
|
defaultSelectedIndex?: number;
|
||||||
|
items: DropdownProps['items'];
|
||||||
|
triggerRect: DOMRect;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
const initMenu = useCallback((el: HTMLDivElement | null) => {
|
||||||
|
if (el === null) return {};
|
||||||
|
const windowBox = document.documentElement.getBoundingClientRect();
|
||||||
|
const menuBox = el.getBoundingClientRect();
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
const handlePrev = useCallback(() => {
|
||||||
|
setSelectedIndex((currIndex) => {
|
||||||
|
let nextIndex = (currIndex ?? 0) - 1;
|
||||||
|
const maxTries = items.length;
|
||||||
|
for (let i = 0; i < maxTries; i++) {
|
||||||
|
if (items[nextIndex]?.type === 'separator') {
|
||||||
|
nextIndex--;
|
||||||
|
} else if (nextIndex < 0) {
|
||||||
|
nextIndex = items.length - 1;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nextIndex;
|
||||||
|
});
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
|
const handleNext = useCallback(() => {
|
||||||
|
setSelectedIndex((currIndex) => {
|
||||||
|
let nextIndex = (currIndex ?? -1) + 1;
|
||||||
|
const maxTries = items.length;
|
||||||
|
for (let i = 0; i < maxTries; i++) {
|
||||||
|
if (items[nextIndex]?.type === 'separator') {
|
||||||
|
nextIndex++;
|
||||||
|
} else if (nextIndex >= items.length) {
|
||||||
|
nextIndex = 0;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}>(() => {
|
||||||
|
const docWidth = document.documentElement.getBoundingClientRect().width;
|
||||||
|
const spaceRemaining = docWidth - triggerRect.left;
|
||||||
|
const top = triggerRect?.bottom + 5;
|
||||||
|
const onRight = spaceRemaining < 200;
|
||||||
|
const containerStyles = onRight
|
||||||
|
? { top, right: docWidth - triggerRect?.right }
|
||||||
|
: { top, left: triggerRect?.left };
|
||||||
|
const size = { top: '-0.2rem', width: '0.4rem', height: '0.4rem' };
|
||||||
|
const triangleStyles = onRight
|
||||||
|
? { right: triggerRect.width / 2, marginRight: '-0.2rem', ...size }
|
||||||
|
: { left: triggerRect.width / 2, marginLeft: '-0.2rem', ...size };
|
||||||
|
return { containerStyles, triangleStyles };
|
||||||
|
}, [triggerRect]);
|
||||||
|
|
||||||
|
const handleFocus = useCallback(
|
||||||
|
(i: DropdownItem) => {
|
||||||
|
const index = items.findIndex((item) => item === i) ?? null;
|
||||||
|
setSelectedIndex(index);
|
||||||
|
},
|
||||||
|
[items],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Portal name="dropdown">
|
||||||
|
<FocusTrap>
|
||||||
|
<div>
|
||||||
|
<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"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
dir="ltr"
|
||||||
|
ref={containerRef}
|
||||||
|
style={containerStyles}
|
||||||
|
className={classnames(className, 'outline-none mt-1 pointer-events-auto fixed z-50')}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
style={triangleStyles}
|
||||||
|
className="bg-gray-50 absolute rotate-45 border-gray-200 border-t border-l"
|
||||||
|
/>
|
||||||
|
{containerStyles && (
|
||||||
|
<VStack
|
||||||
|
space={0.5}
|
||||||
|
ref={initMenu}
|
||||||
|
style={menuStyles}
|
||||||
|
className={classnames(
|
||||||
|
className,
|
||||||
|
'h-auto bg-gray-50 rounded-md shadow-lg dark:shadow-gray-0 py-1.5 border',
|
||||||
|
'border-gray-200 overflow-auto mb-1 mx-0.5',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{items.map((item, i) => {
|
||||||
|
if (item.type === 'separator') {
|
||||||
|
return <Separator key={i} className="my-1.5" label={item.label} />;
|
||||||
|
}
|
||||||
|
if (item.hidden) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<MenuItem
|
||||||
|
focused={i === selectedIndex}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
key={item.key}
|
||||||
|
item={item}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</VStack>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</FocusTrap>
|
||||||
|
</Portal>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
interface MenuItemProps {
|
||||||
|
className?: string;
|
||||||
|
item: DropdownItem;
|
||||||
|
onSelect: (item: DropdownItem) => void;
|
||||||
|
onFocus: (item: DropdownItem) => void;
|
||||||
|
focused: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuItem({ className, focused, onFocus, item, onSelect, ...props }: MenuItemProps) {
|
||||||
|
const handleClick = useCallback(() => onSelect?.(item), [item, onSelect]);
|
||||||
|
const handleFocus = useCallback(() => onFocus?.(item), [item, onFocus]);
|
||||||
|
|
||||||
|
const initRef = useCallback(
|
||||||
|
(el: HTMLButtonElement | null) => {
|
||||||
|
if (el === null) return;
|
||||||
|
if (focused) {
|
||||||
|
setTimeout(() => el.focus(), 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[focused],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (item.type === 'separator') return <Separator className="my-1.5" />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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 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="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>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
src-web/components/core/DurationTag.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||