Compare commits
306 Commits
feat/mcp-s
...
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 = {
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:react/recommended',
|
||||
'plugin:import/recommended',
|
||||
'plugin:jsx-a11y/recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'eslint-config-prettier',
|
||||
],
|
||||
ignorePatterns: ['src-tauri/**/*'],
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
'import/resolver': {
|
||||
node: {
|
||||
paths: ['src-web'],
|
||||
extensions: ['.js', '.jsx', '.ts', '.tsx'],
|
||||
},
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"react/react-in-jsx-scope": "off",
|
||||
extends: [
|
||||
"eslint:recommended",
|
||||
"plugin:react/recommended",
|
||||
"plugin:react-hooks/recommended",
|
||||
"plugin:import/recommended",
|
||||
"plugin:jsx-a11y/recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"eslint-config-prettier"
|
||||
],
|
||||
parser: "@typescript-eslint/parser",
|
||||
parserOptions: {
|
||||
project: ["./tsconfig.json"]
|
||||
},
|
||||
ignorePatterns: ["src-tauri/**/*"],
|
||||
settings: {
|
||||
react: {
|
||||
version: "detect"
|
||||
},
|
||||
"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
|
||||
*.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>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<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" />
|
||||
<title>Tauri + React + TS</title>
|
||||
</head>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Yaak App</title>
|
||||
<!-- <script src="http://localhost:8097"></script>-->
|
||||
<style>
|
||||
body {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<div id="radix-portal"></div>
|
||||
<script type="module" src="/src-web/main.tsx"></script>
|
||||
</body>
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background-color: black;
|
||||
}
|
||||
}
|
||||
</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>
|
||||
|
||||
6937
package-lock.json
generated
90
package.json
@@ -1,51 +1,85 @@
|
||||
{
|
||||
"name": "tauri-app",
|
||||
"name": "yaak-app",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "rsw build && tsc && vite build",
|
||||
"dev": "vite",
|
||||
"lint": "eslint . --ext .ts,.tsx",
|
||||
"preview": "vite preview",
|
||||
"tauri-dev": "concurrently -n app,rsw \"tauri dev\" \"rsw watch\""
|
||||
"tauri-dev": "YAAK_ENV=development tauri dev",
|
||||
"tauri-build": "tauri build",
|
||||
"tauri": "tauri",
|
||||
"build": "npm run build:frontend",
|
||||
"dev": "vite dev",
|
||||
"lint": "tsc && eslint . --ext .ts,.tsx",
|
||||
"build:icon": "tauri icon src-tauri/icons/icon.png",
|
||||
"build:frontend": "vite build",
|
||||
"test": "vitest",
|
||||
"coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "^6.2.1",
|
||||
"@codemirror/lang-html": "^6.4.2",
|
||||
"@codemirror/lang-javascript": "^6.1.4",
|
||||
"@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-popover": "1.0.3",
|
||||
"@tauri-apps/api": "^1.2.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.52.0",
|
||||
"@typescript-eslint/parser": "^5.52.0",
|
||||
"@react-hook/resize-observer": "^1.2.6",
|
||||
"@tailwindcss/container-queries": "^0.1.0",
|
||||
"@tanstack/query-sync-storage-persister": "^4.27.1",
|
||||
"@tanstack/react-query": "^4.28.0",
|
||||
"@tanstack/react-query-devtools": "^4.28.0",
|
||||
"@tanstack/react-query-persist-client": "^4.28.0",
|
||||
"@tauri-apps/api": "^1.5.1",
|
||||
"@vitejs/plugin-react": "^3.1.0",
|
||||
"buffer": "^6.0.3",
|
||||
"classnames": "^2.3.2",
|
||||
"cm6-graphql": "^0.0.4-canary-b30a2325.0",
|
||||
"codemirror": "^6.0.1",
|
||||
"focus-trap-react": "^10.1.1",
|
||||
"format-graphql": "^1.4.0",
|
||||
"framer-motion": "^9.0.4",
|
||||
"papaparse": "^5.4.1",
|
||||
"parse-color": "^1.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dnd": "^16.0.1",
|
||||
"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-config-prettier": "^8.6.0",
|
||||
"eslint-plugin-import": "^2.27.5",
|
||||
"eslint-plugin-jsx-a11y": "^6.7.1",
|
||||
"eslint-plugin-react": "^7.32.2",
|
||||
"framer-motion": "^9.0.4",
|
||||
"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",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"postcss": "^8.4.21",
|
||||
"postcss-nesting": "^11.2.1",
|
||||
"prettier": "^2.8.4",
|
||||
"tailwindcss": "^3.2.7",
|
||||
"typescript": "^4.6.4",
|
||||
"typescript": "^5.0.2",
|
||||
"vite": "^4.0.0",
|
||||
"vite-plugin-rsw": "^2.0.11",
|
||||
"vite-plugin-top-level-await": "^1.2.4"
|
||||
"vite-plugin-svgr": "^2.4.0",
|
||||
"vite-plugin-top-level-await": "^1.2.4",
|
||||
"vitest": "^0.29.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
plugins: [
|
||||
require('@tailwindcss/nesting')(require('postcss-nesting')),
|
||||
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]
|
||||
name = "tauri-app"
|
||||
name = "yaak-app"
|
||||
version = "0.0.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
license = ""
|
||||
repository = ""
|
||||
description = "A network protocol testing utility app"
|
||||
authors = ["Gregory Schier"]
|
||||
license = "MIT"
|
||||
repository = "https://github.com/gschier/yaak-app"
|
||||
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]
|
||||
tauri-build = { version = "1.2", features = [] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc = "0.2.7"
|
||||
cocoa = "0.25.0"
|
||||
|
||||
[dependencies]
|
||||
serde_json = { version = "1.0" }
|
||||
serde_json = { version = "1.0", features = ["raw_value"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
tauri = { version = "1.2", features = ["config-toml", "devtools", "shell-open", "system-tray", "window-start-dragging"] }
|
||||
http = { version = "0.2.8" }
|
||||
tauri = { version = "1.3", features = ["config-toml", "devtools", "fs-read-file", "os-all", "protocol-asset", "shell-open", "system-tray", "updater", "window-start-dragging"] }
|
||||
http = "0.2.8"
|
||||
reqwest = { version = "0.11.14", features = ["json"] }
|
||||
tokio = { version = "1.25.0", features = ["full"] }
|
||||
futures = { version = "0.3.26" }
|
||||
deno_core = { version = "0.171.0" }
|
||||
deno_ast = { version = "0.24.0", features = ["transpiling"] }
|
||||
objc = { version = "0.2.7" }
|
||||
cocoa = { version = "0.24.1" }
|
||||
tokio = { version = "1.25.0", features = ["sync"] }
|
||||
futures = "0.3.26"
|
||||
deno_core = "0.222.0"
|
||||
deno_ast = { version = "0.29.5", features = ["transpiling"] }
|
||||
sqlx = { version = "0.6.2", features = ["sqlite", "runtime-tokio-rustls", "json", "chrono", "time", "offline"] }
|
||||
uuid = "1.3.0"
|
||||
rand = "0.8.5"
|
||||
chrono = { version = "0.4.23", features = ["serde"] }
|
||||
base64 = "0.21.0"
|
||||
|
||||
[features]
|
||||
# by default Tauri runs in production mode
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
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('---------------------------');
|
||||
console.log('- 👋 Hello from plugin.ts -');
|
||||
console.log('---------------------------');
|
||||
|
||||
Deno.core.ops.op_hello('World');
|
||||
Deno.core.opAsync('op_hello', 'Deno');
|
||||
|
||||
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]
|
||||
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 window_ext;
|
||||
|
||||
use tauri::{
|
||||
CustomMenuItem, Manager, SystemTray, SystemTrayEvent, SystemTrayMenu, SystemTrayMenuItem,
|
||||
WindowEvent,
|
||||
};
|
||||
use window_ext::WindowExt;
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct CustomResponse {
|
||||
status: u16,
|
||||
body: String,
|
||||
url: String,
|
||||
method: String,
|
||||
elapsed: u128,
|
||||
elapsed2: u128,
|
||||
headers: HashMap<String, String>,
|
||||
pub status_reason: Option<&'static str>,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// here `"quit".to_string()` defines the menu item id, and the second parameter is the menu item label.
|
||||
let quit = CustomMenuItem::new("quit".to_string(), "Quit");
|
||||
let tray_menu = SystemTrayMenu::new().add_item(quit);
|
||||
let system_tray = SystemTray::new().with_menu(tray_menu);
|
||||
async fn migrate_db(
|
||||
app_handle: AppHandle<Wry>,
|
||||
db_instance: &Mutex<Pool<Sqlite>>,
|
||||
) -> Result<(), String> {
|
||||
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()
|
||||
.system_tray(system_tray)
|
||||
.setup(|app| {
|
||||
let win = app.get_window("main").unwrap();
|
||||
win.position_traffic_lights();
|
||||
Ok(())
|
||||
})
|
||||
.on_system_tray_event(|app, event| match event {
|
||||
SystemTrayEvent::MenuItemClick { id, .. } => match id.as_str() {
|
||||
"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();
|
||||
};
|
||||
#[tauri::command]
|
||||
async fn send_ephemeral_request(
|
||||
request: models::HttpRequest,
|
||||
app_handle: AppHandle<Wry>,
|
||||
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||
) -> Result<models::HttpResponse, String> {
|
||||
let pool = &*db_instance.lock().await;
|
||||
let response = models::HttpResponse::default();
|
||||
return actually_send_ephemeral_request(request, &response, &app_handle, pool).await;
|
||||
}
|
||||
|
||||
match e.event() {
|
||||
WindowEvent::Resized(..) => apply_offset(),
|
||||
WindowEvent::ThemeChanged(..) => apply_offset(),
|
||||
_ => {}
|
||||
async fn actually_send_ephemeral_request(
|
||||
request: models::HttpRequest,
|
||||
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![
|
||||
commands::send_request,
|
||||
commands::greet
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
.to_string();
|
||||
|
||||
if !url_string.starts_with("http://") && !url_string.starts_with("https://") {
|
||||
url_string = format!("http://{}", url_string);
|
||||
}
|
||||
|
||||
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) {
|
||||
Deno.core.initializeAsyncOps();
|
||||
// Deno.core.print(Object.keys(Deno.core).join('\n'));
|
||||
|
||||
function argsToMessage(...args) {
|
||||
return args.map((arg) => JSON.stringify(arg)).join(' ');
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
use deno_ast::{MediaType, ParseParams, SourceTextInfo};
|
||||
use deno_core::error::AnyError;
|
||||
use deno_core::{op, Extension, JsRuntime, ModuleSource, ModuleType, RuntimeOptions};
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::pin::Pin;
|
||||
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::{
|
||||
resolve_import, Extension, JsRuntime, ModuleLoader, ModuleSource, ModuleSourceFuture,
|
||||
ModuleSpecifier, ModuleType, ResolutionKind, RuntimeOptions, SourceMapGetter,
|
||||
};
|
||||
use futures::executor;
|
||||
|
||||
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> {
|
||||
let extension = Extension::builder("runtime")
|
||||
.ops(vec![op_hello::decl()])
|
||||
.build();
|
||||
let extension = Extension {
|
||||
name: "runtime",
|
||||
// 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
|
||||
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],
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
runtime
|
||||
.execute_script("<runtime>", include_str!("runtime.js"))
|
||||
.unwrap();
|
||||
.execute_script_static("<runtime>", include_str!("runtime.js"))
|
||||
.expect("Failed to execute runtime.js");
|
||||
|
||||
let main_module = deno_core::resolve_path(file_path)?;
|
||||
let mod_id = runtime.load_main_module(&main_module, None).await?;
|
||||
let current_dir = &std::env::current_dir().expect("Unable to get CWD");
|
||||
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);
|
||||
runtime.run_event_loop(false).await?;
|
||||
runtime
|
||||
.run_event_loop(false)
|
||||
.await
|
||||
.expect("Failed to run event loop");
|
||||
result.await?
|
||||
}
|
||||
|
||||
#[op]
|
||||
async fn op_hello(name: String) -> Result<String, AnyError> {
|
||||
let contents = format!("Hello {} from Rust!", name);
|
||||
println!("{}", contents);
|
||||
Ok(contents)
|
||||
#[derive(Clone)]
|
||||
struct SourceMapStore(Rc<RefCell<HashMap<String, Vec<u8>>>>);
|
||||
|
||||
impl SourceMapGetter for SourceMapStore {
|
||||
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(
|
||||
&self,
|
||||
specifier: &str,
|
||||
referrer: &str,
|
||||
_kind: deno_core::ResolutionKind,
|
||||
) -> Result<deno_core::ModuleSpecifier, AnyError> {
|
||||
deno_core::resolve_import(specifier, referrer).map_err(|e| e.into())
|
||||
_kind: ResolutionKind,
|
||||
) -> Result<ModuleSpecifier, Error> {
|
||||
Ok(resolve_import(specifier, referrer)?)
|
||||
}
|
||||
|
||||
fn load(
|
||||
&self,
|
||||
module_specifier: &deno_core::ModuleSpecifier,
|
||||
_maybe_referrer: Option<deno_core::ModuleSpecifier>,
|
||||
module_specifier: &ModuleSpecifier,
|
||||
_maybe_referrer: Option<&ModuleSpecifier>,
|
||||
_is_dyn_import: bool,
|
||||
) -> std::pin::Pin<Box<deno_core::ModuleSourceFuture>> {
|
||||
let module_specifier = module_specifier.clone();
|
||||
async move {
|
||||
let path = module_specifier.to_file_path().unwrap();
|
||||
) -> Pin<Box<ModuleSourceFuture>> {
|
||||
let source_maps = self.source_maps.clone();
|
||||
fn load(
|
||||
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
|
||||
// extension) and whether transpiling is required.
|
||||
let media_type = MediaType::from(&path);
|
||||
let (module_type, should_transpile) = match MediaType::from(&path) {
|
||||
let media_type = MediaType::from_path(&path);
|
||||
let (module_type, should_transpile) = match MediaType::from_path(&path) {
|
||||
MediaType::JavaScript | MediaType::Mjs | MediaType::Cjs => {
|
||||
(ModuleType::JavaScript, false)
|
||||
}
|
||||
@@ -78,10 +109,9 @@ impl deno_core::ModuleLoader for TsModuleLoader {
|
||||
| MediaType::Dcts
|
||||
| MediaType::Tsx => (ModuleType::JavaScript, true),
|
||||
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 = if should_transpile {
|
||||
let parsed = deno_ast::parse_module(ParseParams {
|
||||
@@ -92,20 +122,28 @@ impl deno_core::ModuleLoader for TsModuleLoader {
|
||||
scope_analysis: false,
|
||||
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 {
|
||||
code
|
||||
};
|
||||
|
||||
// Load and return module.
|
||||
let module = ModuleSource {
|
||||
code: code.into_bytes().into_boxed_slice(),
|
||||
Ok(ModuleSource::new(
|
||||
module_type,
|
||||
module_url_specified: module_specifier.to_string(),
|
||||
module_url_found: module_specifier.to_string(),
|
||||
};
|
||||
Ok(module)
|
||||
code.into(),
|
||||
module_specifier,
|
||||
))
|
||||
}
|
||||
.boxed_local()
|
||||
|
||||
futures::future::ready(load(source_maps, module_specifier)).boxed_local()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
use tauri::{Runtime, Window};
|
||||
|
||||
const TRAFFIC_LIGHT_OFFSET_X: f64 = 15.0;
|
||||
const TRAFFIC_LIGHT_OFFSET_Y: f64 = 20.0;
|
||||
const TRAFFIC_LIGHT_OFFSET_X: f64 = 13.0;
|
||||
const TRAFFIC_LIGHT_OFFSET_Y: f64 = 18.0;
|
||||
|
||||
pub trait WindowExt {
|
||||
#[cfg(target_os = "macos")]
|
||||
fn position_traffic_lights(&self);
|
||||
}
|
||||
|
||||
impl<R: Runtime> WindowExt for Window<R> {
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn position_traffic_lights(&self) {
|
||||
// No-op
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn position_traffic_lights(&self) {
|
||||
use cocoa::appkit::{NSView, NSWindow, NSWindowButton};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||