mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-01-03 11:59:08 -05:00
Compare commits
132 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -1,31 +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: {
|
||||
"jsx-a11y/no-autofocus": "warn",
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"@typescript-eslint/consistent-type-imports": ["error", {
|
||||
prefer: "type-imports",
|
||||
disallowTypeAnnotations: true,
|
||||
fixStyle: "separate-type-imports",
|
||||
}]
|
||||
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": "warn",
|
||||
"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"
|
||||
}],
|
||||
}
|
||||
};
|
||||
|
||||
20
.github/workflows/artifacts.yml
vendored
20
.github/workflows/artifacts.yml
vendored
@@ -10,7 +10,8 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform: [ ubuntu-latest, macos-latest, windows-latest ]
|
||||
# platform: [ ubuntu-latest, macos-latest, windows-latest ]
|
||||
platform: [ macos-latest ]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
@@ -27,6 +28,10 @@ jobs:
|
||||
with:
|
||||
node-version: 18
|
||||
cache: 'npm'
|
||||
- name: install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: 'aarch64-apple-darwin,x86_64-apple-darwin'
|
||||
- name: install dependencies (ubuntu only)
|
||||
if: matrix.platform == 'ubuntu-latest'
|
||||
run: |
|
||||
@@ -41,9 +46,16 @@ jobs:
|
||||
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_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
with:
|
||||
tagName: v__VERSION__
|
||||
releaseName: 'App v__VERSION__'
|
||||
tagName: 'v__VERSION__'
|
||||
releaseName: 'Release __VERSION__'
|
||||
releaseBody: 'See the assets to download this version and install.'
|
||||
releaseDraft: true
|
||||
releaseDraft: false
|
||||
prerelease: false
|
||||
args: '--target universal-apple-darwin'
|
||||
|
||||
@@ -6,9 +6,7 @@
|
||||
<script value="tauri-dev" />
|
||||
</scripts>
|
||||
<node-interpreter value="project" />
|
||||
<envs>
|
||||
<env name="DATABASE_URL" value="sqlite://$USER_HOME$/Library/Application%20Support/co.schier.yaak/db.sqlite" />
|
||||
</envs>
|
||||
<envs />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
</component>
|
||||
17
README.md
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'
|
||||
```
|
||||
|
||||
37
index.html
37
index.html
@@ -1,17 +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>Yaak App</title>
|
||||
<!-- <script src="http://localhost:8097"></script>-->
|
||||
</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="cm-portal" class="cm-portal" style="pointer-events: auto"></div>
|
||||
<div id="radix-portal" class="cm-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>
|
||||
|
||||
3197
package-lock.json
generated
3197
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
44
package.json
44
package.json
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "tauri-app",
|
||||
"name": "yaak-app",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"tauri-dev": "tauri dev",
|
||||
"tauri-build": "npm run build:icon && tauri build",
|
||||
"tauri-dev": "YAAK_ENV=development tauri dev",
|
||||
"tauri-build": "tauri build",
|
||||
"build": "npm run build:frontend",
|
||||
"dev": "vite dev",
|
||||
"lint": "tsc && eslint . --ext .ts,.tsx",
|
||||
@@ -25,27 +25,30 @@
|
||||
"@lezer/generator": "^1.2.2",
|
||||
"@lezer/highlight": "^1.1.3",
|
||||
"@lezer/lr": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.0.2",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.2",
|
||||
"@radix-ui/react-icons": "^1.2.0",
|
||||
"@radix-ui/react-popover": "1.0.3",
|
||||
"@radix-ui/react-scroll-area": "^1.0.2",
|
||||
"@radix-ui/react-separator": "^1.0.1",
|
||||
"@radix-ui/react-tabs": "^1.0.3",
|
||||
"@tanstack/react-query": "^4.24.10",
|
||||
"@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.2.0",
|
||||
"@vitejs/plugin-react": "^3.1.0",
|
||||
"classnames": "^2.3.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.8.1",
|
||||
"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",
|
||||
"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-use": "^17.4.0"
|
||||
"react-router-dom": "^6.8.1",
|
||||
"react-use": "^17.4.0",
|
||||
"uuid": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/nesting": "^0.0.0-insiders.565cd3e",
|
||||
@@ -53,22 +56,25 @@
|
||||
"@types/node": "^18.7.10",
|
||||
"@types/parse-color": "^1.0.1",
|
||||
"@types/parse-json": "^4.0.0",
|
||||
"@types/react-dom": "^18.0.6",
|
||||
"@typescript-eslint/eslint-plugin": "^5.52.0",
|
||||
"@typescript-eslint/parser": "^5.52.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",
|
||||
"concurrently": "^7.6.0",
|
||||
"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",
|
||||
"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-svgr": "^2.4.0",
|
||||
"vite-plugin-top-level-await": "^1.2.4",
|
||||
"vitest": "^0.29.2"
|
||||
}
|
||||
|
||||
1024
src-tauri/Cargo.lock
generated
1024
src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -7,29 +7,31 @@ 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 = { version = "0.2.7" }
|
||||
cocoa = { version = "0.24.1" }
|
||||
objc = "0.2.7"
|
||||
cocoa = "0.24.1"
|
||||
|
||||
[dependencies]
|
||||
serde_json = { version = "1.0", features = ["raw_value"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
tauri = { version = "1.2", features = ["config-toml", "devtools", "shell-open", "system-tray", "updater", "window-start-dragging"] }
|
||||
http = { version = "0.2.8" }
|
||||
http = "0.2.8"
|
||||
reqwest = { version = "0.11.14", features = ["json"] }
|
||||
tokio = { version = "1.25.0", features = ["sync"] }
|
||||
futures = { version = "0.3.26" }
|
||||
deno_core = { version = "0.174.0" }
|
||||
deno_ast = { version = "0.24.0", features = ["transpiling"] }
|
||||
futures = "0.3.26"
|
||||
deno_core = "0.178.0"
|
||||
deno_ast = { version = "0.25.0", features = ["transpiling"] }
|
||||
sqlx = { version = "0.6.2", features = ["sqlite", "runtime-tokio-rustls", "json", "chrono", "time", "offline"] }
|
||||
uuid = { version = "1.3.0" }
|
||||
rand = { version = "0.8.5" }
|
||||
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
|
||||
|
||||
Binary file not shown.
@@ -1,39 +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,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
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
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE http_requests
|
||||
(
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
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
|
||||
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,
|
||||
request_id TEXT NOT NULL REFERENCES http_requests (id) ON DELETE CASCADE,
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
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,
|
||||
elapsed INTEGER NOT NULL,
|
||||
status INTEGER NOT NULL,
|
||||
status_reason TEXT,
|
||||
url TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
headers TEXT NOT NULL
|
||||
url TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
headers TEXT NOT NULL,
|
||||
error TEXT
|
||||
);
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE http_responses ADD COLUMN error TEXT NULL;
|
||||
1
src-tauri/migrations/20230319042610_sort-priority.sql
Normal file
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
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;
|
||||
@@ -1,5 +1,53 @@
|
||||
{
|
||||
"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": [],
|
||||
@@ -20,77 +68,15 @@
|
||||
},
|
||||
"query": "\n DELETE FROM http_responses\n WHERE request_id = ?\n "
|
||||
},
|
||||
"3d2a542964d946ff9854d053b1adf04985d97a6de27b713188505c1f99c77707": {
|
||||
"318ed5a1126fe00719393cf4e6c788ee5a265af88b7253f61a475f78c6774ef6": {
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "workspace_id",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "created_at",
|
||||
"ordinal": 2,
|
||||
"type_info": "Datetime"
|
||||
},
|
||||
{
|
||||
"name": "updated_at",
|
||||
"ordinal": 3,
|
||||
"type_info": "Datetime"
|
||||
},
|
||||
{
|
||||
"name": "deleted_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": "headers!: sqlx::types::Json<Vec<HttpRequestHeader>>",
|
||||
"ordinal": 9,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false
|
||||
],
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
"Right": 9
|
||||
}
|
||||
},
|
||||
"query": "\n SELECT id, workspace_id, created_at, updated_at, deleted_at, name, url, method, body,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpRequestHeader>>\"\n FROM http_requests\n WHERE workspace_id = ?\n "
|
||||
"query": "\n INSERT INTO http_responses (\n id,\n request_id,\n workspace_id,\n elapsed,\n url,\n status,\n status_reason,\n body,\n headers\n )\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);\n "
|
||||
},
|
||||
"448a1d1f1866ab42c0f81fcf8eb2930bf21dfdd43ca4831bc1a198cf45ac3732": {
|
||||
"describe": {
|
||||
@@ -102,7 +88,7 @@
|
||||
},
|
||||
"query": "\n DELETE FROM http_requests\n WHERE id = ?\n "
|
||||
},
|
||||
"7ec60cbc3c9f26e8af86a21ef6b66e564f4fa518925c92308b04f882237a244e": {
|
||||
"6f0cb5a6d1e8dbc8cdfcc3c7e7944b2c83c22cb795b9d6b98fe067dabec9680b": {
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -111,22 +97,22 @@
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "workspace_id",
|
||||
"name": "model",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "created_at",
|
||||
"name": "workspace_id",
|
||||
"ordinal": 2,
|
||||
"type_info": "Datetime"
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "updated_at",
|
||||
"name": "created_at",
|
||||
"ordinal": 3,
|
||||
"type_info": "Datetime"
|
||||
},
|
||||
{
|
||||
"name": "deleted_at",
|
||||
"name": "updated_at",
|
||||
"ordinal": 4,
|
||||
"type_info": "Datetime"
|
||||
},
|
||||
@@ -151,95 +137,28 @@
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "headers!: sqlx::types::Json<Vec<HttpRequestHeader>>",
|
||||
"name": "body_type",
|
||||
"ordinal": 9,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
}
|
||||
},
|
||||
"query": "\n SELECT id, workspace_id, created_at, updated_at, deleted_at, name, url, method, body,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpRequestHeader>>\"\n FROM http_requests\n WHERE id = ?\n ORDER BY created_at DESC\n "
|
||||
},
|
||||
"7f623d0e8f1ddad33d356e2d159b776a2bef1a238cb9200d74eb0c5e3983df85": {
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "workspace_id",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "request_id",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "updated_at",
|
||||
"ordinal": 3,
|
||||
"type_info": "Datetime"
|
||||
},
|
||||
{
|
||||
"name": "deleted_at",
|
||||
"ordinal": 4,
|
||||
"type_info": "Datetime"
|
||||
},
|
||||
{
|
||||
"name": "created_at",
|
||||
"ordinal": 5,
|
||||
"type_info": "Datetime"
|
||||
},
|
||||
{
|
||||
"name": "status",
|
||||
"ordinal": 6,
|
||||
"type_info": "Int64"
|
||||
},
|
||||
{
|
||||
"name": "status_reason",
|
||||
"ordinal": 7,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "body",
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "elapsed",
|
||||
"ordinal": 9,
|
||||
"type_info": "Int64"
|
||||
},
|
||||
{
|
||||
"name": "url",
|
||||
"name": "authentication!: Json<HashMap<String, JsonValue>>",
|
||||
"ordinal": 10,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "error",
|
||||
"name": "authentication_type",
|
||||
"ordinal": 11,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "headers!: sqlx::types::Json<Vec<HttpResponseHeader>>",
|
||||
"name": "sort_priority",
|
||||
"ordinal": 12,
|
||||
"type_info": "Float"
|
||||
},
|
||||
{
|
||||
"name": "headers!: sqlx::types::Json<Vec<HttpRequestHeader>>",
|
||||
"ordinal": 13,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
@@ -248,79 +167,32 @@
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
}
|
||||
},
|
||||
"query": "\n SELECT id, workspace_id, request_id, updated_at, deleted_at,\n created_at, status, status_reason, body, elapsed, url, error,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpResponseHeader>>\"\n FROM http_responses\n WHERE request_id = ?\n ORDER BY created_at ASC\n "
|
||||
"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 "
|
||||
},
|
||||
"8069c0bd326f659faca7b45b03e5317d7339a168f4cd7776d9f84304bb7ae7ac": {
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "created_at",
|
||||
"ordinal": 1,
|
||||
"type_info": "Datetime"
|
||||
},
|
||||
{
|
||||
"name": "updated_at",
|
||||
"ordinal": 2,
|
||||
"type_info": "Datetime"
|
||||
},
|
||||
{
|
||||
"name": "deleted_at",
|
||||
"ordinal": 3,
|
||||
"type_info": "Datetime"
|
||||
},
|
||||
{
|
||||
"name": "name",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "description",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
}
|
||||
},
|
||||
"query": "\n SELECT id, created_at, updated_at, deleted_at, name, description\n FROM workspaces\n "
|
||||
},
|
||||
"a097740ea4ab772ec6f9d8a5144d6871e0b172130d5abe4da61e663155d2bf25": {
|
||||
"84be2b954870ab181738656ecd4d03fca2ff21012947014c79626abfce8e999b": {
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 7
|
||||
"Right": 1
|
||||
}
|
||||
},
|
||||
"query": "\n INSERT INTO http_requests (id, workspace_id, name, url, method, body, headers, updated_at)\n VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)\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 url = excluded.url\n "
|
||||
"query": "\n DELETE FROM workspaces\n WHERE id = ?\n "
|
||||
},
|
||||
"a83698dcf9a815b881097133edb31a34ba25e7c6c114d463c495342a85371639": {
|
||||
"describe": {
|
||||
@@ -332,27 +204,17 @@
|
||||
},
|
||||
"query": "\n UPDATE http_responses SET (elapsed, url, status, status_reason, body, error, headers, updated_at) =\n (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) WHERE id = ?;\n "
|
||||
},
|
||||
"e767522f92c8c49cd2e563e58737a05092daf9b1dc763bacc82a5c14d696d78e": {
|
||||
"b19c275180909a39342b13c3cdcf993781636913ae590967f5508c46a56dc961": {
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 9
|
||||
"Right": 11
|
||||
}
|
||||
},
|
||||
"query": "\n INSERT INTO http_responses (id, request_id, workspace_id, elapsed, url, status, status_reason, body, headers)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);\n "
|
||||
"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 "
|
||||
},
|
||||
"f116d8cf9aad828135bb8c3a4c8b8e6b857ae13303989e9133a33b2d1cf20e96": {
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
}
|
||||
},
|
||||
"query": "\n INSERT INTO workspaces (id, name, description)\n VALUES (?, ?, ?)\n "
|
||||
},
|
||||
"fa5011146663ba5675764f33bf55a86b8274aa18737aff427fd1e3fb74ef3535": {
|
||||
"caf3f21bf291dfbd36446592066e96c1f83abe96f6ea9211a3e049eb9c58a8c8": {
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -361,17 +223,17 @@
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "created_at",
|
||||
"name": "model",
|
||||
"ordinal": 1,
|
||||
"type_info": "Datetime"
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "updated_at",
|
||||
"name": "created_at",
|
||||
"ordinal": 2,
|
||||
"type_info": "Datetime"
|
||||
},
|
||||
{
|
||||
"name": "deleted_at",
|
||||
"name": "updated_at",
|
||||
"ordinal": 3,
|
||||
"type_info": "Datetime"
|
||||
},
|
||||
@@ -390,7 +252,7 @@
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
],
|
||||
@@ -398,9 +260,9 @@
|
||||
"Right": 1
|
||||
}
|
||||
},
|
||||
"query": "\n SELECT id, created_at, updated_at, deleted_at, name, description\n FROM workspaces\n WHERE id = ?\n "
|
||||
"query": "\n SELECT id, model, created_at, updated_at, name, description\n FROM workspaces WHERE id = ?\n "
|
||||
},
|
||||
"fb2c2328678bbdcb64b79ced26f3d7a1b08d315ef6dedfe4d5ae4231c861b079": {
|
||||
"cea4cae52f16ec78aca9a47b17117422d4f165e5a3b308c70fd1a180382475ea": {
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -409,14 +271,14 @@
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "workspace_id",
|
||||
"name": "model",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "request_id",
|
||||
"name": "created_at",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
"type_info": "Datetime"
|
||||
},
|
||||
{
|
||||
"name": "updated_at",
|
||||
@@ -424,7 +286,151 @@
|
||||
"type_info": "Datetime"
|
||||
},
|
||||
{
|
||||
"name": "deleted_at",
|
||||
"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 "
|
||||
},
|
||||
"d5ad6d5f82fe837fa9215bd4619ec18a7c95b3088d4fbf9825f2d1d28069d1ce": {
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "model",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "workspace_id",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "request_id",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "updated_at",
|
||||
"ordinal": 4,
|
||||
"type_info": "Datetime"
|
||||
},
|
||||
@@ -474,7 +480,7 @@
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
@@ -488,6 +494,116 @@
|
||||
"Right": 1
|
||||
}
|
||||
},
|
||||
"query": "\n SELECT id, workspace_id, request_id, updated_at, deleted_at, created_at,\n status, status_reason, body, elapsed, url, error,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpResponseHeader>>\"\n FROM http_responses\n WHERE id = ?\n "
|
||||
"query": "\n SELECT id, model, workspace_id, request_id, updated_at,\n created_at, status, status_reason, body, elapsed, url, error,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpResponseHeader>>\"\n FROM http_responses\n WHERE request_id = ?\n ORDER BY created_at ASC\n "
|
||||
},
|
||||
"d80c09497771e3641022e73ec6c6a87e73a551f88a948a5445d754922b82b50b": {
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"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 "
|
||||
},
|
||||
"e3ade0a69348d512e47e964bded9d7d890b92fdc1e01c6c22fa5e91f943639f2": {
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "model",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "workspace_id",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "request_id",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "updated_at",
|
||||
"ordinal": 4,
|
||||
"type_info": "Datetime"
|
||||
},
|
||||
{
|
||||
"name": "created_at",
|
||||
"ordinal": 5,
|
||||
"type_info": "Datetime"
|
||||
},
|
||||
{
|
||||
"name": "status",
|
||||
"ordinal": 6,
|
||||
"type_info": "Int64"
|
||||
},
|
||||
{
|
||||
"name": "status_reason",
|
||||
"ordinal": 7,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "body",
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "elapsed",
|
||||
"ordinal": 9,
|
||||
"type_info": "Int64"
|
||||
},
|
||||
{
|
||||
"name": "url",
|
||||
"ordinal": 10,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "error",
|
||||
"ordinal": 11,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "headers!: sqlx::types::Json<Vec<HttpResponseHeader>>",
|
||||
"ordinal": 12,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
}
|
||||
},
|
||||
"query": "\n SELECT id, model, workspace_id, request_id, updated_at, created_at,\n status, status_reason, body, elapsed, url, error,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpResponseHeader>>\"\n FROM http_responses\n WHERE id = ?\n "
|
||||
},
|
||||
"f116d8cf9aad828135bb8c3a4c8b8e6b857ae13303989e9133a33b2d1cf20e96": {
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
}
|
||||
},
|
||||
"query": "\n INSERT INTO workspaces (id, name, description)\n VALUES (?, ?, ?)\n "
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
#![cfg_attr(
|
||||
all(not(debug_assertions), target_os = "windows"),
|
||||
windows_subsystem = "windows"
|
||||
all(not(debug_assertions), target_os = "windows"),
|
||||
windows_subsystem = "windows"
|
||||
)]
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -8,17 +8,20 @@ windows_subsystem = "windows"
|
||||
extern crate objc;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::env::current_dir;
|
||||
use std::fs::create_dir_all;
|
||||
|
||||
use http::header::{ACCEPT, HeaderName, USER_AGENT};
|
||||
use base64::Engine;
|
||||
use http::header::{HeaderName, ACCEPT, USER_AGENT};
|
||||
use http::{HeaderMap, HeaderValue, Method};
|
||||
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;
|
||||
use tauri::{AppHandle, Menu, State, Submenu, Wry};
|
||||
use tauri::{AppHandle, Menu, MenuItem, RunEvent, State, Submenu, TitleBarStyle, Window, Wry};
|
||||
use tauri::{CustomMenuItem, Manager, SystemTray, SystemTrayEvent, SystemTrayMenu, WindowEvent};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
@@ -49,32 +52,32 @@ async fn migrate_db(
|
||||
.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 ran");
|
||||
println!("Migrations complete");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn send_request(
|
||||
async fn send_ephemeral_request(
|
||||
request: models::HttpRequest,
|
||||
app_handle: AppHandle<Wry>,
|
||||
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||
request_id: &str,
|
||||
) -> Result<String, String> {
|
||||
) -> 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;
|
||||
}
|
||||
|
||||
let req = models::get_request(request_id, pool)
|
||||
.await
|
||||
.expect("Failed to get request");
|
||||
|
||||
let mut response = models::create_response(&req.id, 0, "", 0, None, "", vec![], pool)
|
||||
.await
|
||||
.expect("Failed to create response");
|
||||
app_handle.emit_all("updated_response", &response).unwrap();
|
||||
|
||||
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 = req.url.to_string();
|
||||
let mut url_string = request.url.to_string();
|
||||
|
||||
let mut variables = HashMap::new();
|
||||
variables.insert("PROJECT_ID", "project_123");
|
||||
@@ -97,6 +100,8 @@ async fn send_request(
|
||||
url_string = format!("http://{}", url_string);
|
||||
}
|
||||
|
||||
println!("Sending request to {}", url_string);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.redirect(Policy::none())
|
||||
.build()
|
||||
@@ -106,10 +111,13 @@ async fn send_request(
|
||||
headers.insert(USER_AGENT, HeaderValue::from_static("yaak"));
|
||||
headers.insert(ACCEPT, HeaderValue::from_static("*/*"));
|
||||
|
||||
for h in req.headers.0 {
|
||||
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) => {
|
||||
@@ -127,23 +135,58 @@ async fn send_request(
|
||||
headers.insert(header_name, header_value);
|
||||
}
|
||||
|
||||
let m =
|
||||
Method::from_bytes(req.method.to_uppercase().as_bytes()).expect("Failed to create method");
|
||||
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 req.body {
|
||||
Some(b) => builder.body(b).build(),
|
||||
None => builder.build(),
|
||||
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;
|
||||
return response_err(response, e.to_string(), &app_handle, pool).await;
|
||||
}
|
||||
};
|
||||
|
||||
let resp = client.execute(sendable_req).await;
|
||||
let raw_response = client.execute(sendable_req).await;
|
||||
|
||||
let p = app_handle
|
||||
.path_resolver()
|
||||
@@ -152,8 +195,9 @@ async fn send_request(
|
||||
|
||||
runtime::run_plugin_sync(p.to_str().unwrap()).unwrap();
|
||||
|
||||
match resp {
|
||||
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(
|
||||
@@ -168,57 +212,153 @@ async fn send_request(
|
||||
response.url = v.url().to_string();
|
||||
response.body = v.text().await.expect("Failed to get body");
|
||||
response.elapsed = start.elapsed().as_millis() as i64;
|
||||
response = models::update_response(response, pool)
|
||||
response = models::update_response_if_id(response, pool)
|
||||
.await
|
||||
.expect("Failed to update response");
|
||||
app_handle.emit_all("updated_response", &response).unwrap();
|
||||
Ok(response.id)
|
||||
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, "", 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(
|
||||
mut response: models::HttpResponse,
|
||||
response: &models::HttpResponse,
|
||||
error: String,
|
||||
app_handle: AppHandle<Wry>,
|
||||
app_handle: &AppHandle<Wry>,
|
||||
pool: &Pool<Sqlite>,
|
||||
) -> Result<String, String> {
|
||||
) -> Result<models::HttpResponse, String> {
|
||||
let mut response = response.clone();
|
||||
response.error = Some(error.clone());
|
||||
response = models::update_response(response, pool)
|
||||
response = models::update_response_if_id(response, pool)
|
||||
.await
|
||||
.expect("Failed to update response");
|
||||
app_handle.emit_all("updated_response", &response).unwrap();
|
||||
Ok(response.id)
|
||||
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,
|
||||
app_handle: AppHandle<Wry>,
|
||||
sort_priority: f64,
|
||||
window: Window<Wry>,
|
||||
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||
) -> Result<String, String> {
|
||||
) -> 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, "", headers, pool)
|
||||
.await
|
||||
.expect("Failed to create request");
|
||||
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");
|
||||
|
||||
app_handle
|
||||
.emit_all("updated_request", &created_request)
|
||||
.unwrap();
|
||||
emit_and_return(&window, "created_model", created_request)
|
||||
}
|
||||
|
||||
Ok(created_request.id)
|
||||
#[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_request(
|
||||
request: models::HttpRequest,
|
||||
app_handle: AppHandle<Wry>,
|
||||
window: Window<Wry>,
|
||||
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||
) -> Result<(), String> {
|
||||
) -> Result<models::HttpRequest, String> {
|
||||
let pool = &*db_instance.lock().await;
|
||||
|
||||
// TODO: Figure out how to make this better
|
||||
@@ -231,29 +371,30 @@ async fn update_request(
|
||||
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");
|
||||
.await
|
||||
.expect("Failed to update request");
|
||||
|
||||
app_handle
|
||||
.emit_all("updated_request", updated_request)
|
||||
.unwrap();
|
||||
|
||||
Ok(())
|
||||
emit_and_return(&window, "updated_model", updated_request)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn delete_request(
|
||||
app_handle: AppHandle<Wry>,
|
||||
window: Window<Wry>,
|
||||
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||
request_id: &str,
|
||||
) -> Result<models::HttpRequest, String> {
|
||||
@@ -261,9 +402,7 @@ async fn delete_request(
|
||||
let req = models::delete_request(request_id, pool)
|
||||
.await
|
||||
.expect("Failed to delete request");
|
||||
app_handle.emit_all("deleted_request", request_id).unwrap();
|
||||
|
||||
Ok(req)
|
||||
emit_and_return(&window, "deleted_model", req)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -277,6 +416,17 @@ async fn requests(
|
||||
.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 responses(
|
||||
request_id: &str,
|
||||
@@ -291,12 +441,14 @@ async fn responses(
|
||||
#[tauri::command]
|
||||
async fn delete_response(
|
||||
id: &str,
|
||||
window: Window<Wry>,
|
||||
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||
) -> Result<(), String> {
|
||||
) -> Result<models::HttpResponse, String> {
|
||||
let pool = &*db_instance.lock().await;
|
||||
models::delete_response(id, pool)
|
||||
let response = models::delete_response(id, pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
.expect("Failed to delete response");
|
||||
emit_and_return(&window, "deleted_model", response)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -319,15 +471,29 @@ async fn workspaces(
|
||||
.await
|
||||
.expect("Failed to find workspaces");
|
||||
if workspaces.is_empty() {
|
||||
let workspace = models::create_workspace("Default", "This is the default workspace", pool)
|
||||
.await
|
||||
.expect("Failed to create workspace");
|
||||
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 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)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn greet(name: &str) -> String {
|
||||
format!("Hello, {}! You've been greeted from Rust!", name)
|
||||
@@ -338,43 +504,32 @@ fn main() {
|
||||
let tray_menu = SystemTrayMenu::new().add_item(quit);
|
||||
let system_tray = SystemTray::new().with_menu(tray_menu);
|
||||
|
||||
let default_menu = Menu::os_default("Yaak".to_string().as_str());
|
||||
let submenu = Submenu::new("Test Menu", Menu::new()
|
||||
.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+-")),
|
||||
);
|
||||
|
||||
let menu = default_menu.add_submenu(submenu);
|
||||
|
||||
tauri::Builder::default()
|
||||
.menu(menu)
|
||||
.system_tray(system_tray)
|
||||
.setup(|app| {
|
||||
let win = app.get_window("main").unwrap();
|
||||
let dir = match is_dev() {
|
||||
true => current_dir().unwrap(),
|
||||
false => app.path_resolver().app_data_dir().unwrap(),
|
||||
};
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
win.position_traffic_lights();
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.setup(|app| {
|
||||
let dir = 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!("DB PATH: {}", 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(())
|
||||
})
|
||||
})
|
||||
@@ -392,42 +547,158 @@ fn main() {
|
||||
};
|
||||
}
|
||||
})
|
||||
.on_menu_event(|event| {
|
||||
match event.menu_item_id() {
|
||||
"quit" => std::process::exit(0),
|
||||
"close" => event.window().close().unwrap(),
|
||||
"zoom_reset" => event.window().emit("zoom", 0).unwrap(),
|
||||
"zoom_in" => event.window().emit("zoom", 1).unwrap(),
|
||||
"zoom_out" => event.window().emit("zoom", -1).unwrap(),
|
||||
_ => {}
|
||||
};
|
||||
})
|
||||
.on_window_event(|e| {
|
||||
let apply_offset = || {
|
||||
let win = e.window();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
win.position_traffic_lights();
|
||||
};
|
||||
|
||||
match e.event() {
|
||||
WindowEvent::Resized(..) => apply_offset(),
|
||||
WindowEvent::ThemeChanged(..) => apply_offset(),
|
||||
_ => {}
|
||||
}
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
greet,
|
||||
workspaces,
|
||||
get_request,
|
||||
requests,
|
||||
send_request,
|
||||
send_ephemeral_request,
|
||||
duplicate_request,
|
||||
create_request,
|
||||
create_workspace,
|
||||
delete_workspace,
|
||||
update_request,
|
||||
delete_request,
|
||||
responses,
|
||||
get_key_value,
|
||||
set_key_value,
|
||||
delete_response,
|
||||
delete_all_responses,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while running tauri application")
|
||||
.run(|app_handle, event| match event {
|
||||
RunEvent::Ready => {
|
||||
create_window(app_handle);
|
||||
}
|
||||
|
||||
// ExitRequested { api, .. } => {
|
||||
// }
|
||||
_ => {}
|
||||
});
|
||||
}
|
||||
|
||||
fn is_dev() -> bool {
|
||||
let env = option_env!("YAAK_ENV");
|
||||
env.unwrap_or("production") != "production"
|
||||
}
|
||||
|
||||
fn create_window(handle: &AppHandle<Wry>) -> 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_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);
|
||||
let menu = default_menu.add_submenu(submenu);
|
||||
let win = tauri::WindowBuilder::new(handle, window_id, tauri::WindowUrl::App("".into()))
|
||||
.menu(menu)
|
||||
.fullscreen(false)
|
||||
.resizable(true)
|
||||
.inner_size(1100.0, 600.0)
|
||||
.hidden_title(true)
|
||||
.title(match is_dev() {
|
||||
true => "Yaak Dev",
|
||||
false => "Yaak",
|
||||
})
|
||||
.title_bar_style(TitleBarStyle::Overlay)
|
||||
.build()
|
||||
.expect("failed to build window");
|
||||
|
||||
let 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(),
|
||||
"send_request" => win2.emit("send_request", true).unwrap(),
|
||||
"refresh" => win2.eval("location.reload()").unwrap(),
|
||||
"new_window" => _ = create_window(&handle2),
|
||||
"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 = || {
|
||||
#[cfg(target_os = "macos")]
|
||||
win3.position_traffic_lights();
|
||||
};
|
||||
|
||||
match e {
|
||||
WindowEvent::Resized(..) => apply_offset(),
|
||||
WindowEvent::ThemeChanged(..) => apply_offset(),
|
||||
WindowEvent::CloseRequested { .. } => {
|
||||
println!("CLOSE REQUESTED");
|
||||
// api.prevent_close();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use rand::distributions::{Alphanumeric, DistString};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::types::chrono::NaiveDateTime;
|
||||
use sqlx::types::Json;
|
||||
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 deleted_at: Option<NaiveDateTime>,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
}
|
||||
@@ -18,6 +20,8 @@ pub struct Workspace {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HttpRequestHeader {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
}
|
||||
@@ -26,14 +30,18 @@ pub struct HttpRequestHeader {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HttpRequest {
|
||||
pub id: String,
|
||||
pub model: String,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
pub deleted_at: Option<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>>,
|
||||
}
|
||||
|
||||
@@ -44,15 +52,15 @@ pub struct HttpResponseHeader {
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize)]
|
||||
#[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 deleted_at: Option<NaiveDateTime>,
|
||||
pub error: Option<String>,
|
||||
pub url: String,
|
||||
pub elapsed: i64,
|
||||
@@ -62,11 +70,66 @@ pub struct HttpResponse {
|
||||
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, created_at, updated_at, deleted_at, name, description
|
||||
SELECT id, model, created_at, updated_at, name, description
|
||||
FROM workspaces
|
||||
"#,
|
||||
)
|
||||
@@ -78,9 +141,8 @@ pub async fn get_workspace(id: &str, pool: &Pool<Sqlite>) -> Result<Workspace, s
|
||||
sqlx::query_as!(
|
||||
Workspace,
|
||||
r#"
|
||||
SELECT id, created_at, updated_at, deleted_at, name, description
|
||||
FROM workspaces
|
||||
WHERE id = ?
|
||||
SELECT id, model, created_at, updated_at, name, description
|
||||
FROM workspaces WHERE id = ?
|
||||
"#,
|
||||
id,
|
||||
)
|
||||
@@ -88,6 +150,22 @@ pub async fn get_workspace(id: &str, pool: &Pool<Sqlite>) -> Result<Workspace, s
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_workspace(id: &str, pool: &Pool<Sqlite>) -> Result<Workspace, sqlx::Error> {
|
||||
let workspace = get_workspace(id, pool)
|
||||
.await
|
||||
.expect("Failed to get request to delete");
|
||||
let _ = sqlx::query!(
|
||||
r#"
|
||||
DELETE FROM workspaces
|
||||
WHERE id = ?
|
||||
"#,
|
||||
id,
|
||||
)
|
||||
.execute(pool)
|
||||
.await;
|
||||
Ok(workspace)
|
||||
}
|
||||
|
||||
pub async fn create_workspace(
|
||||
name: &str,
|
||||
description: &str,
|
||||
@@ -110,14 +188,50 @@ pub async fn create_workspace(
|
||||
get_workspace(&id, pool).await
|
||||
}
|
||||
|
||||
pub async fn duplicate_request(id: &str, pool: &Pool<Sqlite>) -> Result<HttpRequest, sqlx::Error> {
|
||||
let existing = get_request(id, pool)
|
||||
.await
|
||||
.expect("Failed to get request to duplicate");
|
||||
|
||||
// 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;
|
||||
@@ -129,17 +243,34 @@ pub async fn upsert_request(
|
||||
}
|
||||
};
|
||||
let headers_json = Json(headers);
|
||||
let auth_json = Json(authentication);
|
||||
sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO http_requests (id, workspace_id, name, url, method, body, headers, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
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,
|
||||
url = excluded.url
|
||||
body_type = excluded.body_type,
|
||||
authentication = excluded.authentication,
|
||||
authentication_type = excluded.authentication_type,
|
||||
url = excluded.url,
|
||||
sort_priority = excluded.sort_priority
|
||||
"#,
|
||||
id,
|
||||
workspace_id,
|
||||
@@ -147,7 +278,11 @@ pub async fn upsert_request(
|
||||
url,
|
||||
method,
|
||||
body,
|
||||
body_type,
|
||||
auth_json,
|
||||
authentication_type,
|
||||
headers_json,
|
||||
sort_priority,
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
@@ -162,7 +297,20 @@ pub async fn find_requests(
|
||||
sqlx::query_as!(
|
||||
HttpRequest,
|
||||
r#"
|
||||
SELECT id, workspace_id, created_at, updated_at, deleted_at, name, url, method, body,
|
||||
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 = ?
|
||||
@@ -177,11 +325,23 @@ pub async fn get_request(id: &str, pool: &Pool<Sqlite>) -> Result<HttpRequest, s
|
||||
sqlx::query_as!(
|
||||
HttpRequest,
|
||||
r#"
|
||||
SELECT id, workspace_id, created_at, updated_at, deleted_at, name, url, method, body,
|
||||
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 = ?
|
||||
ORDER BY created_at DESC
|
||||
"#,
|
||||
id,
|
||||
)
|
||||
@@ -223,7 +383,17 @@ pub async fn create_response(
|
||||
let headers_json = Json(headers);
|
||||
sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO http_responses (id, request_id, workspace_id, elapsed, url, status, status_reason, body, headers)
|
||||
INSERT INTO http_responses (
|
||||
id,
|
||||
request_id,
|
||||
workspace_id,
|
||||
elapsed,
|
||||
url,
|
||||
status,
|
||||
status_reason,
|
||||
body,
|
||||
headers
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);
|
||||
"#,
|
||||
id,
|
||||
@@ -243,6 +413,16 @@ pub async fn create_response(
|
||||
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);
|
||||
}
|
||||
return update_response(response, pool).await;
|
||||
}
|
||||
|
||||
pub async fn update_response(
|
||||
response: HttpResponse,
|
||||
pool: &Pool<Sqlite>,
|
||||
@@ -272,7 +452,7 @@ pub async fn get_response(id: &str, pool: &Pool<Sqlite>) -> Result<HttpResponse,
|
||||
sqlx::query_as_unchecked!(
|
||||
HttpResponse,
|
||||
r#"
|
||||
SELECT id, workspace_id, request_id, updated_at, deleted_at, created_at,
|
||||
SELECT id, model, workspace_id, request_id, updated_at, created_at,
|
||||
status, status_reason, body, elapsed, url, error,
|
||||
headers AS "headers!: sqlx::types::Json<Vec<HttpResponseHeader>>"
|
||||
FROM http_responses
|
||||
@@ -291,7 +471,7 @@ pub async fn find_responses(
|
||||
sqlx::query_as!(
|
||||
HttpResponse,
|
||||
r#"
|
||||
SELECT id, workspace_id, request_id, updated_at, deleted_at,
|
||||
SELECT id, model, workspace_id, request_id, updated_at,
|
||||
created_at, status, status_reason, body, elapsed, url, error,
|
||||
headers AS "headers!: sqlx::types::Json<Vec<HttpResponseHeader>>"
|
||||
FROM http_responses
|
||||
@@ -304,7 +484,11 @@ pub async fn find_responses(
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_response(id: &str, pool: &Pool<Sqlite>) -> Result<(), sqlx::Error> {
|
||||
pub async fn delete_response(id: &str, pool: &Pool<Sqlite>) -> Result<HttpResponse, sqlx::Error> {
|
||||
let resp = get_response(id, pool)
|
||||
.await
|
||||
.expect("Failed to get response to delete");
|
||||
|
||||
let _ = sqlx::query!(
|
||||
r#"
|
||||
DELETE FROM http_responses
|
||||
@@ -315,7 +499,7 @@ pub async fn delete_response(id: &str, pool: &Pool<Sqlite>) -> Result<(), sqlx::
|
||||
.execute(pool)
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
pub async fn delete_all_responses(
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use deno_ast::{MediaType, ParseParams, SourceTextInfo};
|
||||
use deno_core::error::AnyError;
|
||||
use deno_core::{op, Extension, JsRuntime, ModuleSource, ModuleType, RuntimeOptions};
|
||||
use std::rc::Rc;
|
||||
|
||||
use deno_ast::{MediaType, ParseParams, SourceTextInfo};
|
||||
use deno_core::error::AnyError;
|
||||
use deno_core::futures::FutureExt;
|
||||
use deno_core::{op, Extension, JsRuntime, ModuleCode, ModuleSource, ModuleType, RuntimeOptions};
|
||||
use futures::executor;
|
||||
|
||||
pub fn run_plugin_sync(file_path: &str) -> Result<(), AnyError> {
|
||||
@@ -26,7 +26,9 @@ pub async fn run_plugin(file_path: &str) -> Result<(), AnyError> {
|
||||
.execute_script("<runtime>", include_str!("runtime.js"))
|
||||
.expect("Failed to execute runtime.js");
|
||||
|
||||
let main_module = deno_core::resolve_path(file_path).expect("Failed to resolve path");
|
||||
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
|
||||
@@ -66,12 +68,14 @@ impl deno_core::ModuleLoader for TsModuleLoader {
|
||||
) -> std::pin::Pin<Box<deno_core::ModuleSourceFuture>> {
|
||||
let module_specifier = module_specifier.clone();
|
||||
async move {
|
||||
let path = module_specifier.to_file_path().unwrap();
|
||||
let path = module_specifier
|
||||
.to_file_path()
|
||||
.expect("Failed to convert to file path");
|
||||
|
||||
// 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 media_type {
|
||||
MediaType::JavaScript | MediaType::Mjs | MediaType::Cjs => {
|
||||
(ModuleType::JavaScript, false)
|
||||
}
|
||||
@@ -105,7 +109,7 @@ impl deno_core::ModuleLoader for TsModuleLoader {
|
||||
|
||||
// Load and return module.
|
||||
let module = ModuleSource {
|
||||
code: code.into_bytes().into_boxed_slice(),
|
||||
code: ModuleCode::from(code),
|
||||
module_type,
|
||||
module_url_specified: module_specifier.to_string(),
|
||||
module_url_found: module_specifier.to_string(),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use tauri::{Runtime, Window};
|
||||
|
||||
const TRAFFIC_LIGHT_OFFSET_X: f64 = 15.0;
|
||||
const TRAFFIC_LIGHT_OFFSET_Y: f64 = 22.0;
|
||||
const TRAFFIC_LIGHT_OFFSET_X: f64 = 10.0;
|
||||
const TRAFFIC_LIGHT_OFFSET_Y: f64 = 18.0;
|
||||
|
||||
pub trait WindowExt {
|
||||
fn position_traffic_lights(&self);
|
||||
|
||||
@@ -8,20 +8,10 @@
|
||||
},
|
||||
"package": {
|
||||
"productName": "Yaak",
|
||||
"version": "0.0.2"
|
||||
"version": "2023.0.4"
|
||||
},
|
||||
"tauri": {
|
||||
"windows": [
|
||||
{
|
||||
"fullscreen": false,
|
||||
"height": 800,
|
||||
"hiddenTitle": true,
|
||||
"resizable": true,
|
||||
"title": "Yaak",
|
||||
"titleBarStyle": "Overlay",
|
||||
"width": 1400
|
||||
}
|
||||
],
|
||||
"windows": [],
|
||||
"allowlist": {
|
||||
"all": false,
|
||||
"fs": {
|
||||
@@ -78,7 +68,7 @@
|
||||
"active": true,
|
||||
"dialog": true,
|
||||
"endpoints": [
|
||||
"https://update.yaak.app/check/{{target}}/{{current_version}}"
|
||||
"https://update.yaak.app/check/{{target}}/{{arch}}/{{current_version}}"
|
||||
],
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEMxRDJFREQ1MjExQjdGN0IKUldSN2Z4c2gxZTNTd1FHNCtmYnFXMHVVQzhuNkJOM1cwOFBodmdLall3ckhKenpKUytHSTR1MlkK"
|
||||
}
|
||||
|
||||
BIN
src-web/assets/icons/Icons.afdesign
Normal file
BIN
src-web/assets/icons/Icons.afdesign
Normal file
Binary file not shown.
5
src-web/assets/icons/LeftPanelHiddenIcon.svg
Normal file
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
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 |
@@ -1,79 +1,39 @@
|
||||
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
import { persistQueryClient } from '@tanstack/react-query-persist-client';
|
||||
import { MotionConfig } from 'framer-motion';
|
||||
import { Suspense } from 'react';
|
||||
import { DndProvider } from 'react-dnd';
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||
import { HelmetProvider } from 'react-helmet-async';
|
||||
import { AppRouter } from './AppRouter';
|
||||
import { requestsQueryKey } from '../hooks/useRequests';
|
||||
import { responsesQueryKey } from '../hooks/useResponses';
|
||||
import { DEFAULT_FONT_SIZE } from '../lib/constants';
|
||||
import type { HttpRequest, HttpResponse } from '../lib/models';
|
||||
import { convertDates } from '../lib/models';
|
||||
import { DialogProvider } from './DialogContext';
|
||||
import { TauriListeners } from './TauriListeners';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
const queryClient = new QueryClient({
|
||||
logger: undefined,
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
cacheTime: 1000 * 60 * 60 * 24, // 24 hours
|
||||
networkMode: 'offlineFirst',
|
||||
|
||||
await listen('updated_request', ({ payload: request }: { payload: HttpRequest }) => {
|
||||
queryClient.setQueryData(
|
||||
requestsQueryKey(request.workspaceId),
|
||||
(requests: HttpRequest[] = []) => {
|
||||
const newRequests = [];
|
||||
let found = false;
|
||||
for (const r of requests) {
|
||||
if (r.id === request.id) {
|
||||
found = true;
|
||||
newRequests.push(convertDates(request));
|
||||
} else {
|
||||
newRequests.push(r);
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
newRequests.push(convertDates(request));
|
||||
}
|
||||
return newRequests;
|
||||
// It's a desktop app, so this isn't necessary
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
await listen('deleted_request', ({ payload: request }: { payload: HttpRequest }) => {
|
||||
queryClient.setQueryData(requestsQueryKey(request.workspaceId), (requests: HttpRequest[] = []) =>
|
||||
requests.filter((r) => r.id !== request.id),
|
||||
);
|
||||
const localStoragePersister = createSyncStoragePersister({
|
||||
storage: window.localStorage,
|
||||
throttleTime: 1000, // 1 second
|
||||
});
|
||||
|
||||
await listen('updated_response', ({ payload: response }: { payload: HttpResponse }) => {
|
||||
queryClient.setQueryData(
|
||||
responsesQueryKey(response.requestId),
|
||||
(responses: HttpResponse[] = []) => {
|
||||
const newResponses = [];
|
||||
let found = false;
|
||||
for (const r of responses) {
|
||||
if (r.id === response.id) {
|
||||
found = true;
|
||||
newResponses.push(convertDates(response));
|
||||
} else {
|
||||
newResponses.push(r);
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
newResponses.push(convertDates(response));
|
||||
}
|
||||
return newResponses;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
await listen('zoom', ({ payload: zoomDelta }: { payload: number }) => {
|
||||
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`;
|
||||
persistQueryClient({
|
||||
queryClient,
|
||||
persister: localStoragePersister,
|
||||
maxAge: 1000 * 60 * 60 * 24, // 24 hours
|
||||
});
|
||||
|
||||
export function App() {
|
||||
@@ -81,7 +41,15 @@ export function App() {
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MotionConfig transition={{ duration: 0.1 }}>
|
||||
<HelmetProvider>
|
||||
<AppRouter />
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<DialogProvider>
|
||||
<Suspense>
|
||||
<AppRouter />
|
||||
<TauriListeners />
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</Suspense>
|
||||
</DialogProvider>
|
||||
</DndProvider>
|
||||
</HelmetProvider>
|
||||
</MotionConfig>
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -1,25 +1,40 @@
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
|
||||
|
||||
const Workspaces = lazy(() => import('./Workspaces'));
|
||||
const Workspace = lazy(() => import('./Workspace'));
|
||||
const RouteError = lazy(() => import('./RouteError'));
|
||||
import { useEffect } from 'react';
|
||||
import {
|
||||
createBrowserRouter,
|
||||
Navigate,
|
||||
Outlet,
|
||||
RouterProvider,
|
||||
useLocation,
|
||||
} from 'react-router-dom';
|
||||
import { routePaths } from '../hooks/useRoutes';
|
||||
import { setLastLocation } from '../lib/lastLocation';
|
||||
import RouteError from './RouteError';
|
||||
import Workspace from './Workspace';
|
||||
import Workspaces from './Workspaces';
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: '/',
|
||||
errorElement: <RouteError />,
|
||||
element: <RouterRoot />,
|
||||
children: [
|
||||
{
|
||||
path: '/',
|
||||
element: <Navigate to={routePaths.workspaces()} replace={true} />,
|
||||
},
|
||||
{
|
||||
path: routePaths.workspaces(),
|
||||
element: <Workspaces />,
|
||||
},
|
||||
{
|
||||
path: '/workspaces/:workspaceId',
|
||||
path: routePaths.workspace({ workspaceId: ':workspaceId' }),
|
||||
element: <Workspace />,
|
||||
},
|
||||
{
|
||||
path: '/workspaces/:workspaceId/requests/:requestId',
|
||||
path: routePaths.request({
|
||||
workspaceId: ':workspaceId',
|
||||
requestId: ':requestId',
|
||||
}),
|
||||
element: <Workspace />,
|
||||
},
|
||||
],
|
||||
@@ -27,9 +42,13 @@ const router = createBrowserRouter([
|
||||
]);
|
||||
|
||||
export function AppRouter() {
|
||||
return (
|
||||
<Suspense>
|
||||
<RouterProvider router={router} />
|
||||
</Suspense>
|
||||
);
|
||||
return <RouterProvider router={router} />;
|
||||
}
|
||||
|
||||
function RouterRoot() {
|
||||
const { pathname } = useLocation();
|
||||
useEffect(() => {
|
||||
setLastLocation(pathname).catch(console.error);
|
||||
}, [pathname]);
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
43
src-web/components/BasicAuth.tsx
Normal file
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
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>
|
||||
);
|
||||
}
|
||||
57
src-web/components/DialogContext.tsx
Normal file
57
src-web/components/DialogContext.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
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(({ id, render, ...props }) => (
|
||||
<Dialog open key={id} onClose={() => actions.hide(id)} {...props}>
|
||||
{render({ hide: () => actions.hide(id) })}
|
||||
</Dialog>
|
||||
))}
|
||||
</DialogContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useDialog = () => useContext(DialogContext).actions;
|
||||
22
src-web/components/DropMarker.tsx
Normal file
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,
|
||||
);
|
||||
13
src-web/components/EmptyStateText.tsx
Normal file
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>
|
||||
);
|
||||
}
|
||||
118
src-web/components/GraphQLEditor.tsx
Normal file
118
src-web/components/GraphQLEditor.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
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 { 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 introspection = 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, introspection.data);
|
||||
}, [introspection.data]);
|
||||
|
||||
const dialog = useDialog();
|
||||
|
||||
return (
|
||||
<div className="pb-2 h-full grid 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={
|
||||
(introspection.error || introspection.isLoading) && (
|
||||
<Button
|
||||
size="xs"
|
||||
color={introspection.error ? 'danger' : 'gray'}
|
||||
isLoading={introspection.isLoading}
|
||||
onClick={() => {
|
||||
dialog.show({
|
||||
title: 'Introspection Failed',
|
||||
size: 'sm',
|
||||
render: () => (
|
||||
<div className="whitespace-pre-wrap">{introspection.error?.message}</div>
|
||||
),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{introspection.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,117 +1,67 @@
|
||||
import classnames from 'classnames';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useUpdateRequest } from '../hooks/useUpdateRequest';
|
||||
import type { HttpHeader, HttpRequest } from '../lib/models';
|
||||
import { IconButton } from './core/IconButton';
|
||||
import { Input } from './core/Input';
|
||||
import { VStack } from './core/Stacks';
|
||||
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';
|
||||
|
||||
interface Props {
|
||||
request: HttpRequest;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
type PairWithId = { header: Partial<HttpHeader>; id: string };
|
||||
|
||||
export function HeaderEditor({ request, className }: Props) {
|
||||
const updateRequest = useUpdateRequest(request);
|
||||
const saveHeaders = (pairs: PairWithId[]) => {
|
||||
const headers = pairs.map((p) => ({ name: '', value: '', ...p.header }));
|
||||
updateRequest.mutate({ headers });
|
||||
};
|
||||
|
||||
const newPair = () => {
|
||||
return { header: { name: '', value: '' }, id: Math.random().toString() };
|
||||
};
|
||||
|
||||
const [pairs, setPairs] = useState<PairWithId[]>(
|
||||
request.headers.map((h) => ({ header: h, id: Math.random().toString() })),
|
||||
);
|
||||
|
||||
const setPairsAndSave = (fn: (pairs: PairWithId[]) => PairWithId[]) => {
|
||||
setPairs((oldPairs) => {
|
||||
const newPairs = fn(oldPairs);
|
||||
saveHeaders(newPairs);
|
||||
return newPairs;
|
||||
});
|
||||
};
|
||||
|
||||
const handleChangeHeader = (pair: PairWithId) => {
|
||||
setPairsAndSave((pairs) =>
|
||||
pairs.map((p) =>
|
||||
pair.id !== p.id ? p : { id: p.id, header: { ...p.header, ...pair.header } },
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const lastPair = pairs[pairs.length - 1];
|
||||
if (lastPair === undefined) {
|
||||
setPairs([newPair()]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastPair.header.name !== '' || lastPair.header.value !== '') {
|
||||
setPairsAndSave((pairs) => [...pairs, newPair()]);
|
||||
}
|
||||
}, [pairs]);
|
||||
|
||||
const handleDelete = (pair: PairWithId) => {
|
||||
setPairsAndSave((oldPairs) => oldPairs.filter((p) => p.id !== pair.id));
|
||||
};
|
||||
type Props = {
|
||||
forceUpdateKey: string;
|
||||
headers: HttpRequest['headers'];
|
||||
onChange: (headers: HttpRequest['headers']) => void;
|
||||
};
|
||||
|
||||
export function HeaderEditor({ headers, onChange, forceUpdateKey }: Props) {
|
||||
return (
|
||||
<div className={classnames(className, 'pb-6 grid')}>
|
||||
<VStack space={2}>
|
||||
{pairs.map((p, i) => (
|
||||
<FormRow
|
||||
key={p.id}
|
||||
pair={p}
|
||||
onChange={handleChangeHeader}
|
||||
onDelete={i < pairs.length - 1 ? handleDelete : undefined}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
</div>
|
||||
<PairEditor
|
||||
pairs={headers}
|
||||
onChange={onChange}
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
nameValidate={validateHttpHeader}
|
||||
nameAutocomplete={nameAutocomplete}
|
||||
valueAutocomplete={valueAutocomplete}
|
||||
namePlaceholder="Header-Name"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FormRow({
|
||||
pair,
|
||||
onChange,
|
||||
onDelete,
|
||||
}: {
|
||||
pair: PairWithId;
|
||||
onChange: (pair: PairWithId) => void;
|
||||
onDelete?: (pair: PairWithId) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="group grid grid-cols-[1fr_1fr_2.5rem] grid-rows-1 gap-2 items-center">
|
||||
<Input
|
||||
hideLabel
|
||||
useEditor={{ useTemplating: true }}
|
||||
name="name"
|
||||
label="Name"
|
||||
placeholder="name"
|
||||
defaultValue={pair.header.name}
|
||||
onChange={(name) => onChange({ id: pair.id, header: { name } })}
|
||||
/>
|
||||
<Input
|
||||
hideLabel
|
||||
name="value"
|
||||
label="Value"
|
||||
useEditor={{ useTemplating: true }}
|
||||
placeholder="value"
|
||||
defaultValue={pair.header.value}
|
||||
onChange={(value) => onChange({ id: pair.id, header: { value } })}
|
||||
/>
|
||||
{onDelete && (
|
||||
<IconButton
|
||||
icon="trash"
|
||||
onClick={() => onDelete(pair)}
|
||||
className="invisible group-hover:visible"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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;
|
||||
};
|
||||
|
||||
46
src-web/components/Overlay.tsx
Normal file
46
src-web/components/Overlay.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import classnames from 'classnames';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
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/60 dark:bg-black/50"
|
||||
/>
|
||||
{/* 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
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
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);
|
||||
}
|
||||
52
src-web/components/RequestActionsDropdown.tsx
Normal file
52
src-web/components/RequestActionsDropdown.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { HTMLAttributes, ReactElement } from 'react';
|
||||
import { useConfirm } from '../hooks/useConfirm';
|
||||
import { useDeleteRequest } from '../hooks/useDeleteRequest';
|
||||
import { useDuplicateRequest } from '../hooks/useDuplicateRequest';
|
||||
import { useRequest } from '../hooks/useRequest';
|
||||
import { Dropdown } from './core/Dropdown';
|
||||
import { Icon } from './core/Icon';
|
||||
import { InlineCode } from './core/InlineCode';
|
||||
|
||||
interface Props {
|
||||
requestId: string;
|
||||
children: ReactElement<HTMLAttributes<HTMLButtonElement>>;
|
||||
}
|
||||
|
||||
export function RequestActionsDropdown({ requestId, children }: Props) {
|
||||
const request = useRequest(requestId ?? null);
|
||||
const deleteRequest = useDeleteRequest(requestId ?? null);
|
||||
const duplicateRequest = useDuplicateRequest({ id: requestId, navigateAfter: true });
|
||||
const confirm = useConfirm();
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
label: 'Duplicate',
|
||||
onSelect: duplicateRequest.mutate,
|
||||
leftSlot: <Icon icon="copy" />,
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
onSelect: async () => {
|
||||
const confirmed = await confirm({
|
||||
title: 'Delete Request',
|
||||
variant: 'delete',
|
||||
description: (
|
||||
<>
|
||||
Are you sure you want to delete <InlineCode>{request?.name}</InlineCode>?
|
||||
</>
|
||||
),
|
||||
});
|
||||
if (confirmed) {
|
||||
deleteRequest.mutate();
|
||||
}
|
||||
},
|
||||
leftSlot: <Icon icon="trash" />,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
28
src-web/components/RequestMethodDropdown.tsx
Normal file
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>
|
||||
);
|
||||
});
|
||||
@@ -1,64 +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 { useSendRequest } from '../hooks/useSendRequest';
|
||||
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 { HeaderEditor } from './HeaderEditor';
|
||||
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;
|
||||
}
|
||||
|
||||
export function RequestPane({ fullHeight, className }: Props) {
|
||||
const activeRequest = useActiveRequest();
|
||||
const updateRequest = useUpdateRequest(activeRequest);
|
||||
const sendRequest = useSendRequest(activeRequest);
|
||||
const useActiveTab = createGlobalState<string>('body');
|
||||
|
||||
if (activeRequest === null) return null;
|
||||
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 className={classnames(className, 'py-2 grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1')}>
|
||||
<div className="pl-2">
|
||||
<UrlBar
|
||||
key={activeRequest.id}
|
||||
method={activeRequest.method}
|
||||
url={activeRequest.url}
|
||||
loading={sendRequest.isLoading}
|
||||
onMethodChange={(method) => updateRequest.mutate({ method })}
|
||||
onUrlChange={(url) => updateRequest.mutate({ url })}
|
||||
sendRequest={sendRequest.mutate}
|
||||
/>
|
||||
</div>
|
||||
<Tabs
|
||||
tabs={[
|
||||
{ value: 'body', label: 'JSON' },
|
||||
{ value: 'params', label: 'Params' },
|
||||
{ value: 'headers', label: 'Headers' },
|
||||
{ value: 'auth', label: 'Auth' },
|
||||
]}
|
||||
className="mt-2"
|
||||
tabListClassName="px-2"
|
||||
defaultValue="body"
|
||||
label="Request body"
|
||||
>
|
||||
<TabContent value="headers" className="pl-2">
|
||||
<HeaderEditor key={activeRequest.id} request={activeRequest} />
|
||||
</TabContent>
|
||||
<TabContent value="body">
|
||||
<Editor
|
||||
key={activeRequest.id}
|
||||
className="!bg-gray-50"
|
||||
heightMode={fullHeight ? 'full' : 'auto'}
|
||||
useTemplating
|
||||
defaultValue={activeRequest.body ?? ''}
|
||||
contentType="application/graphql+json"
|
||||
onChange={(body) => updateRequest.mutate({ body })}
|
||||
/>
|
||||
</TabContent>
|
||||
</Tabs>
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
131
src-web/components/RequestResponse.tsx
Normal file
131
src-web/components/RequestResponse.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import useResizeObserver from '@react-hook/resize-observer';
|
||||
import classnames from 'classnames';
|
||||
import type { CSSProperties, MouseEvent as ReactMouseEvent } from 'react';
|
||||
import React, { memo, useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useKeyValue } from '../hooks/useKeyValue';
|
||||
import { 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 widthKv = useKeyValue<number>({ key: 'body_width', defaultValue: DEFAULT });
|
||||
const heightKv = useKeyValue<number>({ key: 'body_height', defaultValue: DEFAULT });
|
||||
const width = widthKv.value ?? DEFAULT;
|
||||
const height = heightKv.value ?? DEFAULT;
|
||||
const [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 ? heightKv.set(DEFAULT) : widthKv.set(DEFAULT)),
|
||||
[heightKv, vertical, widthKv],
|
||||
);
|
||||
|
||||
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,
|
||||
);
|
||||
heightKv.set(newHeightPx / containerRect.height);
|
||||
} else {
|
||||
const maxWidthPx = containerRect.width - MIN_WIDTH_PX;
|
||||
const newWidthPx = clamp(
|
||||
startWidth - (e.clientX - mouseStartX),
|
||||
MIN_WIDTH_PX,
|
||||
maxWidthPx,
|
||||
);
|
||||
widthKv.set(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, heightKv, widthKv],
|
||||
);
|
||||
|
||||
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
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 cursor-ew-resize',
|
||||
vertical ? 'w-full h-3 cursor-ns-resize' : 'h-full w-3 cursor-ew-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 cursor-ew-resize',
|
||||
vertical && 'cursor-ns-resize',
|
||||
!vertical && 'cursor-ew-resize',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
src-web/components/ResponseHeaders.tsx
Normal file
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 font-mono">
|
||||
{headers.map((h, i) => {
|
||||
return (
|
||||
<HStack
|
||||
space={3}
|
||||
key={i}
|
||||
className={classnames(i > 0 && 'border-t border-highlightSecondary', 'py-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>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +1,49 @@
|
||||
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 { useDeleteResponse } from '../hooks/useResponseDelete';
|
||||
import { useResponses } from '../hooks/useResponses';
|
||||
import { useResponseViewMode } from '../hooks/useResponseViewMode';
|
||||
import { tryFormatJson } from '../lib/formatters';
|
||||
import type { HttpResponse } from '../lib/models';
|
||||
import { pluralize } from '../lib/pluralize';
|
||||
import { Banner } from './core/Banner';
|
||||
import { CountBadge } from './core/CountBadge';
|
||||
import { Dropdown } from './core/Dropdown';
|
||||
import { Editor } from './core/Editor';
|
||||
import { Icon } from './core/Icon';
|
||||
import { IconButton } from './core/IconButton';
|
||||
import { HStack } from './core/Stacks';
|
||||
import { StatusColor } from './core/StatusColor';
|
||||
import { TabContent, Tabs } from './core/Tabs/Tabs';
|
||||
import { Webview } from './core/Webview';
|
||||
import { EmptyStateText } from './EmptyStateText';
|
||||
import { ResponseHeaders } from './ResponseHeaders';
|
||||
|
||||
interface Props {
|
||||
style?: CSSProperties;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const ResponsePane = memo(function ResponsePane({ className }: Props) {
|
||||
const [activeResponseId, setActiveResponseId] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useState<'pretty' | 'raw'>('pretty');
|
||||
const responses = useResponses();
|
||||
const activeResponse: HttpResponse | null = activeResponseId
|
||||
? responses.find((r) => r.id === activeResponseId) ?? null
|
||||
: responses[responses.length - 1] ?? null;
|
||||
const deleteResponse = useDeleteResponse(activeResponse);
|
||||
const deleteAllResponses = useDeleteResponses(activeResponse?.requestId);
|
||||
const useActiveTab = createGlobalState<string>('body');
|
||||
|
||||
useEffect(() => {
|
||||
setActiveResponseId(null);
|
||||
}, [responses.length]);
|
||||
export const ResponsePane = memo(function ResponsePane({ style, className }: Props) {
|
||||
const [pinnedResponseId, setPinnedResponseId] = useState<string | null>(null);
|
||||
const activeRequestId = useActiveRequestId();
|
||||
const responses = useResponses(activeRequestId);
|
||||
const activeResponse: HttpResponse | null = pinnedResponseId
|
||||
? responses.find((r) => r.id === pinnedResponseId) ?? null
|
||||
: responses[responses.length - 1] ?? null;
|
||||
const [viewMode, toggleViewMode] = useResponseViewMode(activeResponse?.requestId);
|
||||
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 = useMemo(
|
||||
() =>
|
||||
@@ -38,28 +52,45 @@ export const ResponsePane = memo(function ResponsePane({ className }: Props) {
|
||||
[activeResponse],
|
||||
);
|
||||
|
||||
if (activeResponse === null) {
|
||||
return null;
|
||||
}
|
||||
const tabs = useMemo(
|
||||
() => [
|
||||
{ label: 'Body', value: 'body' },
|
||||
{
|
||||
label: (
|
||||
<div className="flex items-center">
|
||||
Headers
|
||||
<CountBadge
|
||||
count={activeResponse?.headers.filter((h) => h.name && h.value).length ?? 0}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
value: 'headers',
|
||||
},
|
||||
],
|
||||
[activeResponse?.headers],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-2">
|
||||
<div
|
||||
<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',
|
||||
)}
|
||||
>
|
||||
<HStack
|
||||
alignItems="center"
|
||||
className={classnames(
|
||||
className,
|
||||
'max-h-full h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1 ',
|
||||
'dark:bg-gray-100 rounded-md overflow-hidden border border-gray-200',
|
||||
'shadow shadow-gray-100 dark:shadow-gray-0',
|
||||
'italic text-gray-700 text-sm w-full flex-shrink-0',
|
||||
// Remove a bit of space because the tabs have lots too
|
||||
'-mb-1.5',
|
||||
)}
|
||||
>
|
||||
{/*<HStack as={WindowDragRegion} items="center" className="pl-1.5 pr-1">*/}
|
||||
{/*</HStack>*/}
|
||||
<HStack
|
||||
alignItems="center"
|
||||
className="italic text-gray-700 text-sm w-full mb-1 flex-shrink-0 pl-2"
|
||||
>
|
||||
{activeResponse && activeResponse.status > 0 && (
|
||||
<div className="whitespace-nowrap">
|
||||
{activeResponse && (
|
||||
<>
|
||||
<div className="whitespace-nowrap p-3 py-2">
|
||||
<StatusColor statusCode={activeResponse.status}>
|
||||
{activeResponse.status}
|
||||
{activeResponse.statusReason && ` ${activeResponse.statusReason}`}
|
||||
@@ -68,64 +99,87 @@ export const ResponsePane = memo(function ResponsePane({ className }: Props) {
|
||||
{activeResponse.elapsed}ms •
|
||||
{Math.round(activeResponse.body.length / 1000)} KB
|
||||
</div>
|
||||
)}
|
||||
|
||||
<HStack alignItems="center" className="ml-auto h-8">
|
||||
<IconButton
|
||||
icon={viewMode === 'pretty' ? 'eye' : 'code'}
|
||||
size="sm"
|
||||
className="ml-1"
|
||||
onClick={() => setViewMode((m) => (m === 'pretty' ? 'raw' : 'pretty'))}
|
||||
/>
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
label: viewMode === 'pretty' ? 'View Raw' : 'View Prettified',
|
||||
onSelect: toggleViewMode,
|
||||
},
|
||||
{ type: 'separator', label: 'Actions' },
|
||||
{
|
||||
label: 'Clear Response',
|
||||
onSelect: deleteResponse.mutate,
|
||||
disabled: responses.length === 0,
|
||||
},
|
||||
{
|
||||
label: 'Clear All Responses',
|
||||
label: `Clear ${responses.length} ${pluralize('Response', responses.length)}`,
|
||||
onSelect: deleteAllResponses.mutate,
|
||||
hidden: responses.length <= 1,
|
||||
disabled: responses.length === 0,
|
||||
},
|
||||
'-----',
|
||||
{ type: 'separator', label: 'History' },
|
||||
...responses.slice(0, 10).map((r) => ({
|
||||
label: r.status + ' - ' + r.elapsed + ' ms',
|
||||
leftSlot: activeResponse?.id === r.id ? <Icon icon="check" /> : <></>,
|
||||
onSelect: () => setActiveResponseId(r.id),
|
||||
onSelect: () => setPinnedResponseId(r.id),
|
||||
})),
|
||||
]}
|
||||
>
|
||||
<IconButton icon="clock" className="ml-auto" size="sm" />
|
||||
<IconButton
|
||||
title="Show response history"
|
||||
icon="triangleDown"
|
||||
className="ml-auto"
|
||||
size="sm"
|
||||
iconSize="md"
|
||||
/>
|
||||
</Dropdown>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{activeResponse?.error ? (
|
||||
<div className="p-1">
|
||||
<div className="text-white bg-red-500 px-3 py-2 rounded">{activeResponse.error}</div>
|
||||
</div>
|
||||
) : viewMode === 'pretty' && contentType.includes('html') ? (
|
||||
<Webview body={activeResponse.body} contentType={contentType} url={activeResponse.url} />
|
||||
) : viewMode === 'pretty' && contentType.includes('json') ? (
|
||||
<Editor
|
||||
readOnly
|
||||
key={`${contentType}:${activeResponse.updatedAt}:pretty`}
|
||||
className="bg-gray-50 dark:!bg-gray-100"
|
||||
defaultValue={tryFormatJson(activeResponse?.body)}
|
||||
contentType={contentType}
|
||||
/>
|
||||
) : activeResponse?.body ? (
|
||||
<Editor
|
||||
readOnly
|
||||
key={`${contentType}:${activeResponse.updatedAt}`}
|
||||
className="bg-gray-50 dark:!bg-gray-100"
|
||||
defaultValue={activeResponse?.body}
|
||||
contentType={contentType}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{activeResponse?.error ? (
|
||||
<Banner className="m-2">{activeResponse.error}</Banner>
|
||||
) : (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChangeValue={setActiveTab}
|
||||
label="Response"
|
||||
className="px-3"
|
||||
tabs={tabs}
|
||||
>
|
||||
<TabContent value="body">
|
||||
{activeResponse === null ? (
|
||||
<EmptyStateText>No Response</EmptyStateText>
|
||||
) : viewMode === 'pretty' && contentType.includes('html') ? (
|
||||
<Webview
|
||||
body={activeResponse.body}
|
||||
contentType={contentType}
|
||||
url={activeResponse.url}
|
||||
/>
|
||||
) : viewMode === 'pretty' && contentType.includes('json') ? (
|
||||
<Editor
|
||||
readOnly
|
||||
forceUpdateKey={`pretty::${activeResponse.updatedAt}`}
|
||||
className="bg-gray-50 dark:!bg-gray-100"
|
||||
defaultValue={tryFormatJson(activeResponse?.body)}
|
||||
contentType={contentType}
|
||||
/>
|
||||
) : activeResponse?.body ? (
|
||||
<Editor
|
||||
readOnly
|
||||
forceUpdateKey={activeResponse.updatedAt}
|
||||
className="bg-gray-50 dark:!bg-gray-100"
|
||||
defaultValue={activeResponse?.body}
|
||||
contentType={contentType}
|
||||
/>
|
||||
) : null}
|
||||
</TabContent>
|
||||
<TabContent value="headers">
|
||||
<ResponseHeaders headers={activeResponse?.headers ?? []} />
|
||||
</TabContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ 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;
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
|
||||
@@ -1,118 +1,295 @@
|
||||
import classnames from 'classnames';
|
||||
import { useState } from 'react';
|
||||
import type { ForwardedRef, KeyboardEvent } from 'react';
|
||||
import React, { forwardRef, Fragment, memo, useCallback, useMemo, useRef, useState } from 'react';
|
||||
import type { XYCoord } from 'react-dnd';
|
||||
import { useDrag, useDrop } from 'react-dnd';
|
||||
import { useActiveRequest } from '../hooks/useActiveRequest';
|
||||
import { useCreateRequest } from '../hooks/useCreateRequest';
|
||||
import { useDeleteRequest } from '../hooks/useDeleteRequest';
|
||||
import { useRequests } from '../hooks/useRequests';
|
||||
import { useTheme } from '../hooks/useTheme';
|
||||
import { useUpdateAnyRequest } from '../hooks/useUpdateAnyRequest';
|
||||
import { useUpdateRequest } from '../hooks/useUpdateRequest';
|
||||
import type { HttpRequest } from '../lib/models';
|
||||
import { Button } from './core/Button';
|
||||
import { IconButton } from './core/IconButton';
|
||||
import { HStack, VStack } from './core/Stacks';
|
||||
import { WindowDragRegion } from './core/WindowDragRegion';
|
||||
import { DropMarker } from './DropMarker';
|
||||
import { RequestActionsDropdown } from './RequestActionsDropdown';
|
||||
import { ToggleThemeButton } from './ToggleThemeButton';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Sidebar({ className }: Props) {
|
||||
const requests = useRequests();
|
||||
const activeRequest = useActiveRequest();
|
||||
const deleteRequest = useDeleteRequest(activeRequest);
|
||||
const createRequest = useCreateRequest({ navigateAfter: true });
|
||||
const { appearance, toggleAppearance } = useTheme();
|
||||
return (
|
||||
<div
|
||||
className={classnames(
|
||||
className,
|
||||
'w-[15rem] bg-gray-100 h-full border-r border-gray-200 relative grid grid-rows-[auto,1fr]',
|
||||
)}
|
||||
>
|
||||
<HStack as={WindowDragRegion} alignItems="center" justifyContent="end">
|
||||
<IconButton
|
||||
className="mx-1"
|
||||
icon="plusCircle"
|
||||
onClick={async () => {
|
||||
await createRequest.mutate({ name: 'Test Request' });
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
<VStack as="ul" className="py-3 px-2 overflow-auto h-full" space={1}>
|
||||
{requests.map((r) => (
|
||||
<SidebarItem key={r.id} request={r} active={r.id === activeRequest?.id} />
|
||||
))}
|
||||
{/*<Colors />*/}
|
||||
enum ItemTypes {
|
||||
REQUEST = 'request',
|
||||
}
|
||||
|
||||
<HStack
|
||||
className="absolute bottom-1 left-1 right-0 mx-1"
|
||||
alignItems="center"
|
||||
justifyContent="end"
|
||||
export const Sidebar = memo(function Sidebar({ className }: Props) {
|
||||
const sidebarRef = useRef<HTMLDivElement>(null);
|
||||
const unorderedRequests = useRequests();
|
||||
const activeRequest = useActiveRequest();
|
||||
const requests = useMemo(
|
||||
() => [...unorderedRequests].sort((a, b) => a.sortPriority - b.sortPriority),
|
||||
[unorderedRequests],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative h-full">
|
||||
<div
|
||||
ref={sidebarRef}
|
||||
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}
|
||||
>
|
||||
<IconButton icon="trash" onClick={() => deleteRequest.mutate()} />
|
||||
<IconButton icon={appearance === 'dark' ? 'moon' : 'sun'} onClick={toggleAppearance} />
|
||||
<SidebarItems activeRequestId={activeRequest?.id} requests={requests} />
|
||||
</VStack>
|
||||
<HStack className="mx-1 pb-1" alignItems="center" justifyContent="end">
|
||||
<ToggleThemeButton />
|
||||
</HStack>
|
||||
</VStack>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function SidebarItem({ request, active }: { request: HttpRequest; active: boolean }) {
|
||||
const updateRequest = useUpdateRequest(request);
|
||||
const [editing, setEditing] = useState<boolean>(false);
|
||||
const handleSubmitNameEdit = async (el: HTMLInputElement) => {
|
||||
await updateRequest.mutate({ name: el.value });
|
||||
setEditing(false);
|
||||
};
|
||||
function SidebarItems({
|
||||
requests,
|
||||
activeRequestId,
|
||||
}: {
|
||||
requests: HttpRequest[];
|
||||
activeRequestId?: string;
|
||||
}) {
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
const updateRequest = useUpdateAnyRequest();
|
||||
|
||||
const handleFocus = (el: HTMLInputElement | null) => {
|
||||
el?.focus();
|
||||
el?.select();
|
||||
};
|
||||
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 (
|
||||
<li>
|
||||
<Button
|
||||
color="custom"
|
||||
size="sm"
|
||||
className={classnames(
|
||||
editing && 'focus-within:border-blue-400/40',
|
||||
active
|
||||
? 'bg-gray-200/70 text-gray-900'
|
||||
: 'text-gray-600 hover:text-gray-800 active:bg-gray-200/30',
|
||||
)}
|
||||
onKeyDown={(e) => {
|
||||
// Hitting enter on active request during keyboard nav will start edit
|
||||
if (active && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
setEditing(true);
|
||||
}
|
||||
}}
|
||||
to={`/workspaces/${request.workspaceId}/requests/${request.id}`}
|
||||
onDoubleClick={() => setEditing(true)}
|
||||
justify="start"
|
||||
>
|
||||
{editing ? (
|
||||
<input
|
||||
ref={handleFocus}
|
||||
defaultValue={request.name}
|
||||
className="bg-transparent outline-none w-full"
|
||||
onBlur={(e) => handleSubmitNameEdit(e.currentTarget)}
|
||||
onKeyDown={async (e) => {
|
||||
switch (e.key) {
|
||||
case 'Enter':
|
||||
await handleSubmitNameEdit(e.currentTarget);
|
||||
break;
|
||||
case 'Escape':
|
||||
setEditing(false);
|
||||
break;
|
||||
}
|
||||
}}
|
||||
<>
|
||||
{requests.map((r, i) => (
|
||||
<Fragment key={r.id}>
|
||||
{hoveredIndex === i && <DropMarker />}
|
||||
<DraggableSidebarItem
|
||||
key={r.id}
|
||||
requestId={r.id}
|
||||
requestName={r.name}
|
||||
workspaceId={r.workspaceId}
|
||||
active={r.id === activeRequestId}
|
||||
onMove={handleMove}
|
||||
onEnd={handleEnd}
|
||||
/>
|
||||
) : (
|
||||
<span className="truncate">{request.name || request.url}</span>
|
||||
)}
|
||||
</Button>
|
||||
</li>
|
||||
</Fragment>
|
||||
))}
|
||||
{hoveredIndex === requests.length && <DropMarker />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type SidebarItemProps = {
|
||||
className?: string;
|
||||
requestId: string;
|
||||
requestName: string;
|
||||
workspaceId: string;
|
||||
active?: boolean;
|
||||
};
|
||||
|
||||
const _SidebarItem = forwardRef(function SidebarItem(
|
||||
{ className, requestName, requestId, workspaceId, active }: SidebarItemProps,
|
||||
ref: ForwardedRef<HTMLLIElement>,
|
||||
) {
|
||||
const updateRequest = useUpdateRequest(requestId);
|
||||
const [editing, setEditing] = useState<boolean>(false);
|
||||
|
||||
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 handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLElement>) => {
|
||||
// Hitting enter on active request during keyboard nav will start edit
|
||||
if (active && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
setEditing(true);
|
||||
}
|
||||
},
|
||||
[active],
|
||||
);
|
||||
|
||||
const handleInputKeyDown = useCallback(
|
||||
async (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
switch (e.key) {
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
await handleSubmitNameEdit(e.currentTarget);
|
||||
break;
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
setEditing(false);
|
||||
break;
|
||||
}
|
||||
},
|
||||
[handleSubmitNameEdit],
|
||||
);
|
||||
|
||||
return (
|
||||
<li ref={ref} className={classnames(className, 'block group/item px-2 pb-0.5')}>
|
||||
<div className="relative">
|
||||
<Button
|
||||
color="custom"
|
||||
size="sm"
|
||||
to={`/workspaces/${workspaceId}/requests/${requestId}`}
|
||||
draggable={false} // Item should drag, not the link
|
||||
onDoubleClick={() => setEditing(true)}
|
||||
onClick={active ? () => setEditing(true) : undefined}
|
||||
justify="start"
|
||||
onKeyDown={handleKeyDown}
|
||||
className={classnames(
|
||||
editing && 'focus-within:border-focus',
|
||||
active
|
||||
? 'bg-highlight text-gray-900'
|
||||
: 'text-gray-600 group-hover/item:text-gray-800 active:bg-highlightSecondary',
|
||||
// Move out of the way when trash is shown
|
||||
'group-hover/item:pr-7',
|
||||
)}
|
||||
>
|
||||
{editing ? (
|
||||
<input
|
||||
ref={handleFocus}
|
||||
defaultValue={requestName}
|
||||
className="bg-transparent outline-none w-full"
|
||||
onBlur={(e) => handleSubmitNameEdit(e.currentTarget)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
/>
|
||||
) : (
|
||||
<span className={classnames('truncate', !requestName && 'text-gray-400 italic')}>
|
||||
{requestName || 'New Request'}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<RequestActionsDropdown requestId={requestId}>
|
||||
<IconButton
|
||||
color="custom"
|
||||
size="sm"
|
||||
title="Request Options"
|
||||
icon="dotsH"
|
||||
className={classnames(
|
||||
'absolute right-0 top-0 transition-opacity !opacity-0',
|
||||
'group-hover/item:!opacity-100 focus-visible:!opacity-100',
|
||||
)}
|
||||
/>
|
||||
</RequestActionsDropdown>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
});
|
||||
const SidebarItem = memo(_SidebarItem);
|
||||
|
||||
type DraggableSidebarItemProps = SidebarItemProps & {
|
||||
onMove: (id: string, side: 'above' | 'below') => void;
|
||||
onEnd: (id: string) => void;
|
||||
};
|
||||
|
||||
type DragItem = {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
requestName: string;
|
||||
};
|
||||
|
||||
const DraggableSidebarItem = memo(function DraggableSidebarItem({
|
||||
requestName,
|
||||
requestId,
|
||||
workspaceId,
|
||||
active,
|
||||
onMove,
|
||||
onEnd,
|
||||
}: 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, workspaceId }),
|
||||
collect: (m) => ({ isDragging: m.isDragging() }),
|
||||
options: { dropEffect: 'move' },
|
||||
end: () => onEnd(requestId),
|
||||
}),
|
||||
[onEnd],
|
||||
);
|
||||
|
||||
connectDrag(ref);
|
||||
connectDrop(ref);
|
||||
|
||||
return (
|
||||
<SidebarItem
|
||||
ref={ref}
|
||||
className={classnames(isDragging && 'opacity-20')}
|
||||
requestName={requestName}
|
||||
requestId={requestId}
|
||||
workspaceId={workspaceId}
|
||||
active={active}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
31
src-web/components/SidebarActions.tsx
Normal file
31
src-web/components/SidebarActions.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useCreateRequest } from '../hooks/useCreateRequest';
|
||||
import { useSidebarHidden } from '../hooks/useSidebarHidden';
|
||||
import { IconButton } from './core/IconButton';
|
||||
|
||||
export const SidebarActions = memo(function SidebarDisplayToggle() {
|
||||
const { hidden, toggle } = useSidebarHidden();
|
||||
const createRequest = useCreateRequest({ navigateAfter: true });
|
||||
const handleCreateRequest = useCallback(() => {
|
||||
createRequest.mutate({ name: 'New Request' });
|
||||
}, [createRequest]);
|
||||
|
||||
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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
121
src-web/components/TauriListeners.tsx
Normal file
121
src-web/components/TauriListeners.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
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 TauriListeners() {
|
||||
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) {
|
||||
if (payload.model) {
|
||||
console.log('Unrecognized created model:', payload);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shouldIgnoreModel(payload)) {
|
||||
queryClient.setQueryData<Model[]>(queryKey, (values) => [...(values ?? []), payload]);
|
||||
}
|
||||
});
|
||||
|
||||
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) {
|
||||
if (payload.model) {
|
||||
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 === 'http_response') return false;
|
||||
if (payload.model === 'key_value' && payload.namespace === NAMESPACE_NO_SYNC) return false;
|
||||
return true;
|
||||
};
|
||||
14
src-web/components/ToggleThemeButton.tsx
Normal file
14
src-web/components/ToggleThemeButton.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import { useTheme } from '../hooks/useTheme';
|
||||
import { IconButton } from './core/IconButton';
|
||||
|
||||
export function ToggleThemeButton() {
|
||||
const { appearance, toggleAppearance } = useTheme();
|
||||
return (
|
||||
<IconButton
|
||||
title={appearance === 'dark' ? 'Enable light mode' : 'Enable dark mode'}
|
||||
icon={appearance === 'dark' ? 'moon' : 'sun'}
|
||||
onClick={toggleAppearance}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,67 +1,84 @@
|
||||
import { Button } from './core/Button';
|
||||
import { DropdownMenuRadio } from './core/Dropdown';
|
||||
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';
|
||||
|
||||
interface Props {
|
||||
sendRequest: () => void;
|
||||
loading: boolean;
|
||||
method: string;
|
||||
url: string;
|
||||
onMethodChange: (method: string) => void;
|
||||
onUrlChange: (url: string) => void;
|
||||
}
|
||||
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();
|
||||
});
|
||||
|
||||
export function UrlBar({ sendRequest, loading, onMethodChange, method, onUrlChange, url }: Props) {
|
||||
return (
|
||||
<form
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
sendRequest();
|
||||
}}
|
||||
className="w-full flex items-center"
|
||||
>
|
||||
<form onSubmit={handleSubmit} className={classnames('url-bar', className)}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
size="sm"
|
||||
hideLabel
|
||||
useEditor={{ useTemplating: true, contentType: 'url' }}
|
||||
useTemplating
|
||||
contentType="url"
|
||||
className="px-0"
|
||||
name="url"
|
||||
label="Enter URL"
|
||||
containerClassName="shadow shadow-gray-100 dark:shadow-gray-0"
|
||||
onChange={onUrlChange}
|
||||
forceUpdateKey={updateKey}
|
||||
containerClassName="shadow shadow-gray-100 dark:shadow-gray-50"
|
||||
onChange={handleUrlChange}
|
||||
defaultValue={url}
|
||||
placeholder="Enter a URL..."
|
||||
placeholder="https://example.com"
|
||||
leftSlot={
|
||||
<DropdownMenuRadio
|
||||
onValueChange={(v) => onMethodChange(v.value)}
|
||||
value={method.toUpperCase()}
|
||||
items={[
|
||||
{ label: 'GET', value: 'GET' },
|
||||
{ label: 'PUT', value: 'PUT' },
|
||||
{ label: 'POST', value: 'POST' },
|
||||
{ label: 'PATCH', value: 'PATCH' },
|
||||
{ label: 'DELETE', value: 'DELETE' },
|
||||
{ label: 'OPTIONS', value: 'OPTIONS' },
|
||||
{ label: 'HEAD', value: 'HEAD' },
|
||||
]}
|
||||
>
|
||||
<Button type="button" disabled={loading} size="sm" className="mx-0.5" justify="start">
|
||||
{method.toUpperCase()}
|
||||
</Button>
|
||||
</DropdownMenuRadio>
|
||||
<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="mr-0.5"
|
||||
size="sm"
|
||||
className="w-8 mr-0.5"
|
||||
icon={loading ? 'update' : 'paperPlane'}
|
||||
spin={loading}
|
||||
disabled={loading}
|
||||
title="Send Request"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,40 +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 { RequestPane } from './RequestPane';
|
||||
import { ResponsePane } from './ResponsePane';
|
||||
import { Sidebar } from './Sidebar';
|
||||
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 { WindowDragRegion } from './core/WindowDragRegion';
|
||||
import { useActiveRequest } from '../hooks/useActiveRequest';
|
||||
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 activeRequest = useActiveRequest();
|
||||
const { width } = useWindowSize();
|
||||
const isH = width > 900;
|
||||
const { set: setWidth, value: width, reset: 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 className="grid grid-cols-[auto_1fr] grid-rows-1 h-full text-gray-900">
|
||||
<Sidebar />
|
||||
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full">
|
||||
<HStack
|
||||
as={WindowDragRegion}
|
||||
className="px-3 bg-gray-50 text-gray-900 border-b border-b-gray-200 pt-[1px]"
|
||||
alignItems="center"
|
||||
>
|
||||
{activeRequest?.name}
|
||||
</HStack>
|
||||
<div
|
||||
className={classnames(
|
||||
'grid',
|
||||
isH
|
||||
? 'grid-cols-[1fr_1fr] grid-rows-1'
|
||||
: 'grid-cols-1 grid-rows-[minmax(0,auto)_minmax(0,100%)]',
|
||||
)}
|
||||
>
|
||||
<RequestPane fullHeight={isH} className={classnames(!isH && 'pr-2 pb-0')} />
|
||||
<ResponsePane />
|
||||
</div>
|
||||
</div>
|
||||
<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',
|
||||
)}
|
||||
>
|
||||
<HeaderSize
|
||||
data-tauri-drag-region
|
||||
className="w-full bg-gray-50 border-b border-b-highlight text-gray-900"
|
||||
style={head}
|
||||
>
|
||||
<WorkspaceHeader className="pointer-events-none" />
|
||||
</HeaderSize>
|
||||
{floating ? (
|
||||
<Overlay open={!hidden} portalName="sidebar" onClose={hide}>
|
||||
<motion.div
|
||||
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 border-r border-highlight')}
|
||||
>
|
||||
<Sidebar />
|
||||
</div>
|
||||
<ResizeHandle
|
||||
className="-translate-x-3"
|
||||
justify="end"
|
||||
side="right"
|
||||
isResizing={isResizing}
|
||||
onResizeStart={handleResizeStart}
|
||||
onReset={resetWidth}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<RequestResponse style={body} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface HeaderSizeProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function HeaderSize({ className, ...props }: HeaderSizeProps) {
|
||||
return (
|
||||
<div
|
||||
className={classnames(
|
||||
className,
|
||||
'h-md pt-[1px] flex items-center w-full pr-3 pl-20 border-b',
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
89
src-web/components/WorkspaceActionsDropdown.tsx
Normal file
89
src-web/components/WorkspaceActionsDropdown.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import classnames from 'classnames';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
|
||||
import { useConfirm } from '../hooks/useConfirm';
|
||||
import { useCreateWorkspace } from '../hooks/useCreateWorkspace';
|
||||
import { useDeleteWorkspace } from '../hooks/useDeleteWorkspace';
|
||||
import { useRoutes } from '../hooks/useRoutes';
|
||||
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';
|
||||
|
||||
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 deleteWorkspace = useDeleteWorkspace(activeWorkspaceId);
|
||||
const routes = useRoutes();
|
||||
const confirm = useConfirm();
|
||||
|
||||
const items: DropdownItem[] = useMemo(() => {
|
||||
const workspaceItems = workspaces.map((w) => ({
|
||||
label: w.name,
|
||||
leftSlot: activeWorkspaceId === w.id ? <Icon icon="check" /> : <Icon icon="empty" />,
|
||||
onSelect: () => {
|
||||
if (w.id === activeWorkspaceId) return;
|
||||
routes.navigate('workspace', { workspaceId: w.id });
|
||||
},
|
||||
}));
|
||||
|
||||
return [
|
||||
...workspaceItems,
|
||||
{
|
||||
type: 'separator',
|
||||
label: 'Actions',
|
||||
},
|
||||
{
|
||||
label: 'New Workspace',
|
||||
leftSlot: <Icon icon="plus" />,
|
||||
onSelect: () => createWorkspace.mutate({ name: 'New Workspace' }),
|
||||
},
|
||||
{
|
||||
label: 'Delete Workspace',
|
||||
leftSlot: <Icon icon="trash" />,
|
||||
onSelect: async () => {
|
||||
const confirmed = await confirm({
|
||||
title: 'Delete Workspace',
|
||||
variant: 'delete',
|
||||
description: (
|
||||
<>
|
||||
Are you sure you want to delete <InlineCode>{activeWorkspace?.name}</InlineCode>?
|
||||
</>
|
||||
),
|
||||
});
|
||||
if (confirmed) {
|
||||
deleteWorkspace.mutate();
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}, [
|
||||
workspaces,
|
||||
activeWorkspaceId,
|
||||
routes,
|
||||
createWorkspace,
|
||||
confirm,
|
||||
activeWorkspace?.name,
|
||||
deleteWorkspace,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Dropdown items={items}>
|
||||
<Button
|
||||
size="sm"
|
||||
className={classnames(className, 'text-gray-800 !px-2 truncate')}
|
||||
forDropdown
|
||||
>
|
||||
{activeWorkspace?.name}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
});
|
||||
43
src-web/components/WorkspaceHeader.tsx
Normal file
43
src-web/components/WorkspaceHeader.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import classnames from 'classnames';
|
||||
import { memo } from 'react';
|
||||
import { useActiveRequest } from '../hooks/useActiveRequest';
|
||||
import { IconButton } from './core/IconButton';
|
||||
import { HStack } from './core/Stacks';
|
||||
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="flex-[2] text-center text-gray-800 text-sm truncate pointer-events-none">
|
||||
{activeRequest?.name}
|
||||
</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>
|
||||
);
|
||||
});
|
||||
@@ -1,18 +1,16 @@
|
||||
import { Button } from './core/Button';
|
||||
import { Heading } from './core/Heading';
|
||||
import { VStack } from './core/Stacks';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useRoutes } from '../hooks/useRoutes';
|
||||
import { useWorkspaces } from '../hooks/useWorkspaces';
|
||||
import { Heading } from './core/Heading';
|
||||
|
||||
export default function Workspaces() {
|
||||
const routes = useRoutes();
|
||||
const workspaces = useWorkspaces();
|
||||
return (
|
||||
<VStack as="ul" className="p-12">
|
||||
<Heading>Workspaces</Heading>
|
||||
{workspaces.map((w) => (
|
||||
<Button key={w.id} color="gray" to={`/workspaces/${w.id}`}>
|
||||
{w.name}
|
||||
</Button>
|
||||
))}
|
||||
</VStack>
|
||||
);
|
||||
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
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>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +1,37 @@
|
||||
import classnames from 'classnames';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
import { forwardRef, memo, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
const colorStyles = {
|
||||
custom: '',
|
||||
default: 'text-gray-700 enabled:hover:bg-gray-700/10 enabled:hover:text-gray-1000',
|
||||
gray: 'text-gray-800 bg-gray-100 enabled:hover:bg-gray-500/20 enabled:hover:text-gray-1000',
|
||||
primary: 'bg-blue-400 text-white hover:bg-blue-500',
|
||||
secondary: 'bg-violet-400 text-white hover:bg-violet-500',
|
||||
warning: 'bg-orange-400 text-white hover:bg-orange-500',
|
||||
danger: 'bg-red-400 text-white hover:bg-red-500',
|
||||
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<HTMLElement> & {
|
||||
to?: string;
|
||||
color?: keyof typeof colorStyles;
|
||||
size?: 'sm' | 'md';
|
||||
isLoading?: boolean;
|
||||
size?: 'sm' | 'md' | 'xs';
|
||||
justify?: 'start' | 'center';
|
||||
type?: 'button' | 'submit';
|
||||
forDropdown?: boolean;
|
||||
disabled?: boolean;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const Button = forwardRef<any, ButtonProps>(function Button(
|
||||
const _Button = forwardRef<any, ButtonProps>(function Button(
|
||||
{
|
||||
to,
|
||||
isLoading,
|
||||
className,
|
||||
children,
|
||||
forDropdown,
|
||||
@@ -37,48 +42,40 @@ export const Button = forwardRef<any, ButtonProps>(function Button(
|
||||
}: ButtonProps,
|
||||
ref,
|
||||
) {
|
||||
const classes = useMemo(
|
||||
() =>
|
||||
classnames(
|
||||
className,
|
||||
'opacity-90 hover:opacity-100',
|
||||
'outline-none whitespace-nowrap',
|
||||
'focus-visible-or-class:ring',
|
||||
'rounded-md flex items-center',
|
||||
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',
|
||||
),
|
||||
[color, size, justify, className],
|
||||
);
|
||||
|
||||
if (typeof to === 'string') {
|
||||
return (
|
||||
<Link
|
||||
ref={ref}
|
||||
to={to}
|
||||
className={classnames(
|
||||
className,
|
||||
'outline-none',
|
||||
'border border-transparent focus-visible:border-blue-300',
|
||||
'rounded-md flex items-center',
|
||||
colorStyles[color || 'default'],
|
||||
justify === 'start' && 'justify-start',
|
||||
justify === 'center' && 'justify-center',
|
||||
size === 'md' && 'h-9 px-3',
|
||||
size === 'sm' && 'h-7 px-2.5 text-sm',
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Link ref={ref} to={to} className={classes} {...props}>
|
||||
{children}
|
||||
{forDropdown && <Icon icon="triangleDown" className="ml-1 -mr-1" />}
|
||||
{forDropdown && <Icon icon="chevronDown" className="ml-1 -mr-1" />}
|
||||
</Link>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={classnames(
|
||||
className,
|
||||
'outline-none',
|
||||
'border border-transparent focus-visible:border-blue-300',
|
||||
'rounded-md flex items-center',
|
||||
colorStyles[color || 'default'],
|
||||
justify === 'start' && 'justify-start',
|
||||
justify === 'center' && 'justify-center',
|
||||
size === 'md' && 'h-9 px-3',
|
||||
size === 'sm' && 'h-7 px-2.5 text-sm',
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<button ref={ref} className={classes} {...props}>
|
||||
{isLoading && <Icon icon="update" size={size} className="animate-spin mr-1" />}
|
||||
{children}
|
||||
{forDropdown && <Icon icon="triangleDown" className="ml-1 -mr-1" />}
|
||||
{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
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>
|
||||
);
|
||||
}
|
||||
17
src-web/components/core/CountBadge.tsx
Normal file
17
src-web/components/core/CountBadge.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
interface Props {
|
||||
count: number;
|
||||
}
|
||||
|
||||
export function CountBadge({ count }: Props) {
|
||||
if (count === 0) return null;
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
aria-hidden
|
||||
className="opacity-70 border border-highlight text-3xs rounded mb-0.5 px-1 ml-1 h-4 font-mono"
|
||||
>
|
||||
{count}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +1,85 @@
|
||||
import * as D from '@radix-ui/react-dialog';
|
||||
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';
|
||||
import { HStack, VStack } from './Stacks';
|
||||
|
||||
interface Props {
|
||||
export interface DialogProps {
|
||||
children: ReactNode;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
description?: string;
|
||||
onClose: () => void;
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
className?: string;
|
||||
wide?: boolean;
|
||||
size?: 'sm' | 'md' | 'full' | 'dynamic';
|
||||
hideX?: boolean;
|
||||
}
|
||||
|
||||
export function Dialog({
|
||||
children,
|
||||
className,
|
||||
wide,
|
||||
size = 'full',
|
||||
open,
|
||||
onOpenChange,
|
||||
onClose,
|
||||
title,
|
||||
description,
|
||||
}: Props) {
|
||||
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 (
|
||||
<D.Root open={open} onOpenChange={onOpenChange}>
|
||||
<D.Portal container={document.querySelector<HTMLElement>('#radix-portal')}>
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
|
||||
<D.Overlay className="fixed inset-0 bg-gray-600/60 dark:bg-black/50" />
|
||||
<D.Content>
|
||||
<div
|
||||
className={classnames(
|
||||
className,
|
||||
'absolute top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%] bg-gray-100',
|
||||
'w-[20rem] max-h-[80vh] p-5 rounded-lg overflow-auto',
|
||||
'dark:border border-gray-200 shadow-md shadow-black/10',
|
||||
wide && 'w-[80vw] max-w-[50rem]',
|
||||
)}
|
||||
>
|
||||
<D.Close asChild className="ml-auto absolute right-1 top-1">
|
||||
<IconButton aria-label="Close" icon="x" size="sm" />
|
||||
</D.Close>
|
||||
<VStack space={3}>
|
||||
<HStack alignItems="center" className="pb-3">
|
||||
<D.Title className="text-xl font-semibold">{title}</D.Title>
|
||||
</HStack>
|
||||
{description && <D.Description>{description}</D.Description>}
|
||||
<div>{children}</div>
|
||||
</VStack>
|
||||
</div>
|
||||
</D.Content>
|
||||
</motion.div>
|
||||
</D.Portal>
|
||||
</D.Root>
|
||||
<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-gray-200 shadow-md shadow-black/10',
|
||||
size === 'sm' && 'w-[25rem]',
|
||||
size === 'md' && 'w-[45rem]',
|
||||
size === 'full' && 'w-[80vw]',
|
||||
size === 'dynamic' && 'min-w-[30vw] max-w-[80vw]',
|
||||
)}
|
||||
>
|
||||
{!hideX && (
|
||||
<IconButton
|
||||
onClick={onClose}
|
||||
title="Close dialog"
|
||||
aria-label="Close"
|
||||
icon="x"
|
||||
size="sm"
|
||||
className="ml-auto absolute right-1 top-1"
|
||||
/>
|
||||
)}
|
||||
<Heading className="text-xl font-semibold w-full" id={titleId}>
|
||||
{title}
|
||||
</Heading>
|
||||
{description && <p id={descriptionId}>{description}</p>}
|
||||
<div className="mt-6">{children}</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</Overlay>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import * as Separator from '@radix-ui/react-separator';
|
||||
import classnames from 'classnames';
|
||||
|
||||
interface Props {
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
decorative?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Divider({ className, orientation = 'horizontal', decorative }: Props) {
|
||||
return (
|
||||
<Separator.Root
|
||||
className={classnames(
|
||||
className,
|
||||
'bg-gray-50',
|
||||
orientation === 'horizontal' && 'w-full h-[1px]',
|
||||
orientation === 'vertical' && 'h-full w-[1px]',
|
||||
)}
|
||||
orientation={orientation}
|
||||
decorative={decorative}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,306 +1,279 @@
|
||||
import * as D from '@radix-ui/react-dropdown-menu';
|
||||
import { CheckIcon } from '@radix-ui/react-icons';
|
||||
import classnames from 'classnames';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import type { ReactNode, ForwardedRef } from 'react';
|
||||
import { forwardRef, useImperativeHandle, useLayoutEffect, useState } from 'react';
|
||||
import type { CSSProperties, HTMLAttributes, MouseEvent, ReactElement, ReactNode } from 'react';
|
||||
import { Children, cloneElement, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useKeyPressEvent } from 'react-use';
|
||||
import { Portal } from '../Portal';
|
||||
import { Separator } from './Separator';
|
||||
import { VStack } from './Stacks';
|
||||
|
||||
interface DropdownMenuRadioProps {
|
||||
children: ReactNode;
|
||||
onValueChange: ((v: { label: string; value: string }) => void) | null;
|
||||
value: string;
|
||||
export type DropdownItemSeparator = {
|
||||
type: 'separator';
|
||||
label?: string;
|
||||
items: {
|
||||
label: string;
|
||||
value: string;
|
||||
}[];
|
||||
}
|
||||
};
|
||||
|
||||
export function DropdownMenuRadio({
|
||||
children,
|
||||
items,
|
||||
onValueChange,
|
||||
label,
|
||||
value,
|
||||
}: DropdownMenuRadioProps) {
|
||||
const handleChange = (value: string) => {
|
||||
const item = items.find((item) => item.value === value);
|
||||
if (item && onValueChange) {
|
||||
onValueChange(item);
|
||||
export type DropdownItem =
|
||||
| {
|
||||
type?: 'default';
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
hidden?: boolean;
|
||||
leftSlot?: ReactNode;
|
||||
rightSlot?: ReactNode;
|
||||
onSelect?: () => void;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<D.Root>
|
||||
<DropdownMenuTrigger>{children}</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent>
|
||||
{label && <DropdownMenuLabel>{label}</DropdownMenuLabel>}
|
||||
<D.DropdownMenuRadioGroup onValueChange={handleChange} value={value}>
|
||||
{items.map((item) => (
|
||||
<DropdownMenuRadioItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</D.DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</D.Root>
|
||||
);
|
||||
}
|
||||
| DropdownItemSeparator;
|
||||
|
||||
export interface DropdownProps {
|
||||
children: ReactNode;
|
||||
items: (
|
||||
| {
|
||||
label: string;
|
||||
onSelect?: () => void;
|
||||
disabled?: boolean;
|
||||
leftSlot?: ReactNode;
|
||||
}
|
||||
| '-----'
|
||||
)[];
|
||||
children: ReactElement<HTMLAttributes<HTMLButtonElement>>;
|
||||
items: DropdownItem[];
|
||||
}
|
||||
|
||||
export function Dropdown({ children, items }: DropdownProps) {
|
||||
return (
|
||||
<D.Root>
|
||||
<DropdownMenuTrigger>{children}</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent>
|
||||
{items.map((item, i) => {
|
||||
if (item === '-----') {
|
||||
return <DropdownMenuSeparator key={i} />;
|
||||
} else {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={i}
|
||||
onSelect={() => item.onSelect?.()}
|
||||
disabled={item.disabled}
|
||||
leftSlot={item.leftSlot}
|
||||
>
|
||||
{item.label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</D.Root>
|
||||
);
|
||||
}
|
||||
|
||||
interface DropdownMenuPortalProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({ children }: DropdownMenuPortalProps) {
|
||||
const container = document.querySelector<Element>('#radix-portal');
|
||||
if (container === null) return null;
|
||||
return (
|
||||
<D.Portal>
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
|
||||
{children}
|
||||
</motion.div>
|
||||
</D.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
const DropdownMenuContent = forwardRef<HTMLDivElement, D.DropdownMenuContentProps>(
|
||||
function DropdownMenuContent(
|
||||
{ className, children, ...props }: D.DropdownMenuContentProps,
|
||||
ref: ForwardedRef<HTMLDivElement>,
|
||||
) {
|
||||
const [styles, setStyles] = useState<{ maxHeight: number }>();
|
||||
const [divRef, setDivRef] = useState<HTMLDivElement | null>(null);
|
||||
useImperativeHandle<HTMLDivElement | null, HTMLDivElement | null>(ref, () => divRef);
|
||||
|
||||
const initDivRef = (ref: HTMLDivElement | null) => {
|
||||
setDivRef(ref);
|
||||
const [open, setOpen] = useState<boolean>(false);
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
const child = useMemo(() => {
|
||||
const existingChild = Children.only(children);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const props: any = {
|
||||
...existingChild.props,
|
||||
ref,
|
||||
'aria-haspopup': 'true',
|
||||
onClick:
|
||||
existingChild.props?.onClick ??
|
||||
((e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOpen((o) => !o);
|
||||
}),
|
||||
};
|
||||
return cloneElement(existingChild, props);
|
||||
}, [children]);
|
||||
|
||||
// Calculate the max height so we can scroll
|
||||
useLayoutEffect(() => {
|
||||
if (divRef === null) return;
|
||||
// Needs to be in a setTimeout because the ref is not positioned yet
|
||||
// TODO: Make this better?
|
||||
setTimeout(() => {
|
||||
const windowBox = document.documentElement.getBoundingClientRect();
|
||||
const menuBox = divRef.getBoundingClientRect();
|
||||
const styles = { maxHeight: windowBox.height - menuBox.top - 5 - 45 };
|
||||
setStyles(styles);
|
||||
});
|
||||
}, [divRef]);
|
||||
const handleClose = useCallback(() => {
|
||||
setOpen(false);
|
||||
ref.current?.focus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<D.Content
|
||||
ref={initDivRef}
|
||||
align="start"
|
||||
className={classnames(
|
||||
className,
|
||||
'bg-gray-50 rounded-md shadow-lg p-1.5 border border-gray-200',
|
||||
'overflow-auto m-1',
|
||||
)}
|
||||
style={styles}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</D.Content>
|
||||
);
|
||||
},
|
||||
);
|
||||
useEffect(() => {
|
||||
ref.current?.setAttribute('aria-expanded', open.toString());
|
||||
}, [open]);
|
||||
|
||||
type DropdownMenuItemProps = D.DropdownMenuItemProps & ItemInnerProps;
|
||||
const triggerRect = useMemo(() => {
|
||||
if (!open) return null;
|
||||
return ref.current?.getBoundingClientRect();
|
||||
}, [open]);
|
||||
|
||||
function DropdownMenuItem({
|
||||
leftSlot,
|
||||
rightSlot,
|
||||
className,
|
||||
children,
|
||||
disabled,
|
||||
...props
|
||||
}: DropdownMenuItemProps) {
|
||||
return (
|
||||
<D.Item
|
||||
asChild
|
||||
disabled={disabled}
|
||||
className={classnames(className, disabled && 'opacity-30')}
|
||||
{...props}
|
||||
>
|
||||
<ItemInner leftSlot={leftSlot} rightSlot={rightSlot}>
|
||||
{children}
|
||||
</ItemInner>
|
||||
</D.Item>
|
||||
<>
|
||||
{child}
|
||||
{open && triggerRect && (
|
||||
<Menu items={items} triggerRect={triggerRect} onClose={handleClose} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// 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>
|
||||
// );
|
||||
// }
|
||||
interface MenuProps {
|
||||
className?: string;
|
||||
items: DropdownProps['items'];
|
||||
triggerRect: DOMRect;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// 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>
|
||||
// );
|
||||
// }
|
||||
function Menu({ className, items, onClose, triggerRect }: MenuProps) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [menuStyles, setMenuStyles] = useState<CSSProperties>({});
|
||||
|
||||
type DropdownMenuRadioItemProps = Omit<D.DropdownMenuRadioItemProps & ItemInnerProps, 'leftSlot'>;
|
||||
// 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 });
|
||||
}, []);
|
||||
|
||||
function DropdownMenuRadioItem({ rightSlot, children, ...props }: DropdownMenuRadioItemProps) {
|
||||
return (
|
||||
<D.RadioItem asChild {...props}>
|
||||
<ItemInner
|
||||
leftSlot={
|
||||
<D.ItemIndicator>
|
||||
<CheckIcon />
|
||||
</D.ItemIndicator>
|
||||
useKeyPressEvent('Escape', (e) => {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
});
|
||||
|
||||
useKeyPressEvent('ArrowUp', (e) => {
|
||||
e.preventDefault();
|
||||
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;
|
||||
}
|
||||
rightSlot={rightSlot}
|
||||
>
|
||||
{children}
|
||||
</ItemInner>
|
||||
</D.RadioItem>
|
||||
}
|
||||
return nextIndex;
|
||||
});
|
||||
});
|
||||
|
||||
useKeyPressEvent('ArrowDown', (e) => {
|
||||
e.preventDefault();
|
||||
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;
|
||||
});
|
||||
});
|
||||
|
||||
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 handleSelect = useCallback(
|
||||
(i: DropdownItem) => {
|
||||
onClose();
|
||||
setSelectedIndex(null);
|
||||
if (i.type !== 'separator') {
|
||||
i.onSelect?.();
|
||||
}
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
}
|
||||
|
||||
// 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}
|
||||
// />
|
||||
// );
|
||||
// },
|
||||
// );
|
||||
const handleFocus = useCallback(
|
||||
(i: DropdownItem) => {
|
||||
const index = items.findIndex((item) => item === i) ?? null;
|
||||
setSelectedIndex(index);
|
||||
},
|
||||
[items],
|
||||
);
|
||||
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||
|
||||
function DropdownMenuLabel({ className, children, ...props }: D.DropdownMenuLabelProps) {
|
||||
return (
|
||||
<D.Label asChild {...props}>
|
||||
<ItemInner noHover className={classnames(className, 'opacity-50 uppercase text-sm')}>
|
||||
{children}
|
||||
</ItemInner>
|
||||
</D.Label>
|
||||
<Portal name="dropdown">
|
||||
<FocusTrap>
|
||||
<div>
|
||||
<div tabIndex={-1} aria-hidden className="fixed inset-0" onClick={onClose} />
|
||||
<motion.div
|
||||
tabIndex={0}
|
||||
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={i + item.label}
|
||||
item={item}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</FocusTrap>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({ className, ...props }: D.DropdownMenuSeparatorProps) {
|
||||
return (
|
||||
<D.Separator
|
||||
className={classnames(className, 'h-[1px] bg-gray-400 bg-opacity-30 my-1')}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuTriggerProps = D.DropdownMenuTriggerProps & {
|
||||
children: ReactNode;
|
||||
interface MenuItemProps {
|
||||
className?: string;
|
||||
};
|
||||
item: DropdownItem;
|
||||
onSelect: (item: DropdownItem) => void;
|
||||
onFocus: (item: DropdownItem) => void;
|
||||
focused: boolean;
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({ children, className, ...props }: DropdownMenuTriggerProps) {
|
||||
return (
|
||||
<D.Trigger asChild className={classnames(className)} {...props}>
|
||||
{children}
|
||||
</D.Trigger>
|
||||
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],
|
||||
);
|
||||
}
|
||||
|
||||
interface ItemInnerProps {
|
||||
leftSlot?: ReactNode;
|
||||
rightSlot?: ReactNode;
|
||||
children: ReactNode;
|
||||
noHover?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
if (item.type === 'separator') return <Separator className="my-1.5" />;
|
||||
|
||||
const ItemInner = forwardRef<HTMLDivElement, ItemInnerProps>(function ItemInner(
|
||||
{ leftSlot, rightSlot, children, className, noHover, ...props }: ItemInnerProps,
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
<button
|
||||
ref={initRef}
|
||||
tabIndex={-1}
|
||||
onMouseEnter={(e) => e.currentTarget.focus()}
|
||||
onMouseLeave={(e) => e.currentTarget.blur()}
|
||||
onFocus={handleFocus}
|
||||
onClick={handleClick}
|
||||
className={classnames(
|
||||
className,
|
||||
'outline-none px-2 py-1.5 flex items-center text-sm text-gray-700 whitespace-nowrap pr-4',
|
||||
!noHover && 'focus:bg-gray-50 focus:text-gray-900 rounded',
|
||||
'min-w-[8rem] outline-none px-2 mx-1.5 h-7 flex items-center text-sm text-gray-700 whitespace-nowrap pr-4',
|
||||
'focus:bg-highlight focus:text-gray-900 rounded',
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{leftSlot && <div className="w-6">{leftSlot}</div>}
|
||||
<div>{children}</div>
|
||||
{rightSlot && <div className="ml-auto pl-3">{rightSlot}</div>}
|
||||
</div>
|
||||
{item.leftSlot && <div className="w-6">{item.leftSlot}</div>}
|
||||
<div>{item.label}</div>
|
||||
{item.rightSlot && <div className="ml-auto pl-3">{item.rightSlot}</div>}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,25 +12,34 @@
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
.cm-content {
|
||||
@apply py-0;
|
||||
}
|
||||
|
||||
.cm-line {
|
||||
@apply text-gray-900 pl-1 pr-1.5;
|
||||
@apply text-gray-800 pl-1 pr-1.5;
|
||||
}
|
||||
|
||||
.cm-placeholder {
|
||||
@apply text-placeholder;
|
||||
}
|
||||
|
||||
.cm-scroller {
|
||||
/* Inherit line-height from outside */
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
/* Don't show selection on blurred input */
|
||||
.cm-selectionBackground {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
&.cm-focused .cm-selectionBackground {
|
||||
@apply bg-gray-400;
|
||||
@apply bg-selection;
|
||||
}
|
||||
|
||||
/* Style gutters */
|
||||
.cm-gutters {
|
||||
@apply border-0 text-gray-500/60;
|
||||
@apply border-0 text-gray-500/50 opacity-95;
|
||||
|
||||
.cm-gutterElement {
|
||||
@apply cursor-default;
|
||||
@@ -38,7 +47,7 @@
|
||||
}
|
||||
|
||||
.placeholder-widget {
|
||||
@apply text-[0.9em] text-gray-800 dark:text-gray-900 px-1 rounded cursor-default dark:shadow;
|
||||
@apply text-xs text-gray-800 dark:text-gray-900 px-1 rounded cursor-default dark:shadow;
|
||||
|
||||
/* NOTE: Background and border are translucent so we can see text selection through it */
|
||||
@apply bg-gray-300/40 border border-gray-300 border-opacity-40 hover:border-opacity-80;
|
||||
@@ -50,17 +59,15 @@
|
||||
|
||||
&.cm-singleline {
|
||||
.cm-editor {
|
||||
@apply h-full w-full;
|
||||
@apply w-full h-auto;
|
||||
}
|
||||
|
||||
.cm-scroller {
|
||||
@apply font-mono flex text-[0.8rem];
|
||||
align-items: center !important;
|
||||
overflow: hidden !important;
|
||||
@apply font-mono text-[0.8rem] overflow-hidden;
|
||||
}
|
||||
|
||||
.cm-line {
|
||||
@apply px-0;
|
||||
@apply px-2 overflow-hidden;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,16 +87,29 @@
|
||||
|
||||
.cm-scroller {
|
||||
@apply font-mono text-[0.75rem];
|
||||
|
||||
/*
|
||||
* Round corners or they'll stick out of the editor bounds of editor is rounded.
|
||||
* Could potentially be pushed up from the editor like we do with bg color but this
|
||||
* is probably fine.
|
||||
*/
|
||||
@apply rounded-lg;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Obscure text for password fields */
|
||||
.cm-wrapper.cm-obscure-text .cm-line {
|
||||
-webkit-text-security: disc;
|
||||
}
|
||||
|
||||
.cm-editor .cm-gutterElement {
|
||||
@apply flex items-center;
|
||||
transition: color var(--transition-duration);
|
||||
}
|
||||
|
||||
.cm-editor .fold-gutter-icon {
|
||||
@apply pt-[0.3em] pl-[0.4em] px-[0.4em] h-4 cursor-pointer rounded;
|
||||
@apply pt-[0.25em] pl-[0.4em] px-[0.4em] h-4 cursor-pointer rounded;
|
||||
}
|
||||
|
||||
.cm-editor .fold-gutter-icon::after {
|
||||
@@ -98,7 +118,7 @@
|
||||
}
|
||||
|
||||
.cm-editor .fold-gutter-icon[data-open] {
|
||||
@apply pt-[0.4em] pl-[0.3em];
|
||||
@apply pt-[0.38em] pl-[0.3em];
|
||||
}
|
||||
|
||||
.cm-editor .fold-gutter-icon[data-open]::after {
|
||||
@@ -120,7 +140,7 @@
|
||||
|
||||
.cm-wrapper:not(.cm-readonly) .cm-editor {
|
||||
&.cm-focused .cm-activeLineGutter {
|
||||
@apply text-gray-800;
|
||||
@apply text-gray-600;
|
||||
}
|
||||
|
||||
.cm-cursor {
|
||||
@@ -130,13 +150,21 @@
|
||||
|
||||
.cm-singleline .cm-editor {
|
||||
.cm-content {
|
||||
@apply h-full flex items-center;
|
||||
@apply h-full flex items-center;
|
||||
}
|
||||
}
|
||||
|
||||
/* NOTE: Extra selector required to override default styles */
|
||||
.cm-tooltip.cm-tooltip {
|
||||
@apply shadow-lg bg-gray-50 rounded overflow-hidden text-gray-900 border border-gray-200 z-50 pointer-events-auto;
|
||||
@apply shadow-lg bg-gray-50 rounded text-gray-700 border border-gray-200 z-50 pointer-events-auto text-sm;
|
||||
|
||||
&.cm-completionInfo-right {
|
||||
@apply ml-1;
|
||||
}
|
||||
|
||||
&.cm-completionInfo-right-narrow {
|
||||
@apply ml-1;
|
||||
}
|
||||
|
||||
* {
|
||||
@apply transition-none;
|
||||
@@ -152,7 +180,7 @@
|
||||
}
|
||||
|
||||
& > ul > li[aria-selected] {
|
||||
@apply bg-gray-100 text-gray-900;
|
||||
@apply bg-highlight text-gray-900;
|
||||
}
|
||||
|
||||
& > ul > li:hover {
|
||||
@@ -162,5 +190,18 @@
|
||||
.cm-completionIcon {
|
||||
@apply text-sm flex items-center pb-0.5;
|
||||
}
|
||||
|
||||
|
||||
.cm-completionLabel {
|
||||
}
|
||||
|
||||
.cm-completionDetail {
|
||||
@apply ml-auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Add default icon. Needs low priority so it can be overwritten */
|
||||
.cm-completionIcon::after {
|
||||
content: '𝑥';
|
||||
}
|
||||
|
||||
@@ -1,131 +1,216 @@
|
||||
import { defaultKeymap } from '@codemirror/commands';
|
||||
import { Compartment, EditorState } from '@codemirror/state';
|
||||
import { Compartment, EditorState, Transaction } from '@codemirror/state';
|
||||
import type { ViewUpdate } from '@codemirror/view';
|
||||
import { keymap, placeholder as placeholderExt, tooltips } from '@codemirror/view';
|
||||
import classnames from 'classnames';
|
||||
import { EditorView } from 'codemirror';
|
||||
import { formatSdl } from 'format-graphql';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useDebounce, useUnmount } from 'react-use';
|
||||
import { debounce } from '../../../lib/debounce';
|
||||
import type { MutableRefObject, ReactNode } from 'react';
|
||||
import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef } from 'react';
|
||||
import { IconButton } from '../IconButton';
|
||||
import { HStack } from '../Stacks';
|
||||
import './Editor.css';
|
||||
import { baseExtensions, getLanguageExtension, multiLineExtensions } from './extensions';
|
||||
import type { GenericCompletionConfig } from './genericCompletion';
|
||||
import { singleLineExt } from './singleLine';
|
||||
|
||||
export interface _EditorProps {
|
||||
// Export some things so all the code-split parts are in this file
|
||||
export { buildClientSchema, getIntrospectionQuery } from 'graphql/utilities';
|
||||
export { graphql } from 'cm6-graphql';
|
||||
export { formatSdl } from 'format-graphql';
|
||||
|
||||
export interface EditorProps {
|
||||
id?: string;
|
||||
readOnly?: boolean;
|
||||
type?: 'text' | 'password';
|
||||
className?: string;
|
||||
heightMode?: 'auto' | 'full';
|
||||
contentType?: string;
|
||||
forceUpdateKey?: string;
|
||||
autoFocus?: boolean;
|
||||
defaultValue?: string;
|
||||
placeholder?: string;
|
||||
tooltipContainer?: HTMLElement;
|
||||
useTemplating?: boolean;
|
||||
onChange?: (value: string) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
singleLine?: boolean;
|
||||
format?: (v: string) => string;
|
||||
autocomplete?: GenericCompletionConfig;
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
export function _Editor({
|
||||
readOnly,
|
||||
heightMode,
|
||||
contentType,
|
||||
autoFocus,
|
||||
placeholder,
|
||||
useTemplating,
|
||||
defaultValue,
|
||||
onChange,
|
||||
className,
|
||||
singleLine,
|
||||
}: _EditorProps) {
|
||||
const cm = useRef<{ view: EditorView; langHolder: Compartment } | null>(null);
|
||||
const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
|
||||
{
|
||||
readOnly,
|
||||
type = 'text',
|
||||
heightMode,
|
||||
contentType,
|
||||
autoFocus,
|
||||
placeholder,
|
||||
useTemplating,
|
||||
defaultValue,
|
||||
forceUpdateKey,
|
||||
onChange,
|
||||
onFocus,
|
||||
onBlur,
|
||||
className,
|
||||
singleLine,
|
||||
format,
|
||||
autocomplete,
|
||||
actions,
|
||||
}: EditorProps,
|
||||
ref,
|
||||
) {
|
||||
const cm = useRef<{ view: EditorView; languageCompartment: Compartment } | null>(null);
|
||||
useImperativeHandle(ref, () => cm.current?.view);
|
||||
|
||||
// Unmount the editor
|
||||
useUnmount(() => {
|
||||
cm.current?.view.destroy();
|
||||
cm.current = null;
|
||||
});
|
||||
// Use ref so we can update the onChange handler without re-initializing the editor
|
||||
const handleChange = useRef<EditorProps['onChange']>(onChange);
|
||||
useEffect(() => {
|
||||
handleChange.current = onChange;
|
||||
}, [onChange]);
|
||||
|
||||
// Use ref so we can update the onChange handler without re-initializing the editor
|
||||
const handleFocus = useRef<EditorProps['onFocus']>(onFocus);
|
||||
useEffect(() => {
|
||||
handleFocus.current = onFocus;
|
||||
}, [onFocus]);
|
||||
|
||||
// Use ref so we can update the onChange handler without re-initializing the editor
|
||||
const handleBlur = useRef<EditorProps['onBlur']>(onBlur);
|
||||
useEffect(() => {
|
||||
handleBlur.current = onBlur;
|
||||
}, [onBlur]);
|
||||
|
||||
// Update placeholder
|
||||
const placeholderCompartment = useRef(new Compartment());
|
||||
useEffect(() => {
|
||||
if (cm.current === null) return;
|
||||
const effect = placeholderCompartment.current.reconfigure(
|
||||
placeholderExt(placeholderElFromText(placeholder ?? '')),
|
||||
);
|
||||
cm.current?.view.dispatch({ effects: effect });
|
||||
}, [placeholder]);
|
||||
|
||||
// Update language extension when contentType changes
|
||||
useEffect(() => {
|
||||
if (cm.current === null) return;
|
||||
const { view, langHolder } = cm.current;
|
||||
const ext = getLanguageExtension({ contentType, useTemplating });
|
||||
view.dispatch({ effects: langHolder.reconfigure(ext) });
|
||||
}, [contentType]);
|
||||
const { view, languageCompartment } = cm.current;
|
||||
const ext = getLanguageExtension({ contentType, useTemplating, autocomplete });
|
||||
view.dispatch({ effects: languageCompartment.reconfigure(ext) });
|
||||
}, [contentType, autocomplete, useTemplating]);
|
||||
|
||||
// Initialize the editor
|
||||
const initDivRef = (el: HTMLDivElement | null) => {
|
||||
if (el === null || cm.current !== null) return;
|
||||
useEffect(() => {
|
||||
if (cm.current === null) return;
|
||||
const { view } = cm.current;
|
||||
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: defaultValue ?? '' } });
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [forceUpdateKey]);
|
||||
|
||||
// Initialize the editor when ref mounts
|
||||
const initEditorRef = useCallback((container: HTMLDivElement | null) => {
|
||||
if (container === null) {
|
||||
cm.current?.view.destroy();
|
||||
cm.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
let view: EditorView;
|
||||
try {
|
||||
const langHolder = new Compartment();
|
||||
const langExt = getLanguageExtension({ contentType, useTemplating });
|
||||
const languageCompartment = new Compartment();
|
||||
const langExt = getLanguageExtension({ contentType, useTemplating, autocomplete });
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: `${defaultValue ?? ''}`,
|
||||
extensions: [
|
||||
langHolder.of(langExt),
|
||||
languageCompartment.of(langExt),
|
||||
placeholderCompartment.current.of(
|
||||
placeholderExt(placeholderElFromText(placeholder ?? '')),
|
||||
),
|
||||
...getExtensions({
|
||||
container: el,
|
||||
container,
|
||||
onChange: handleChange,
|
||||
onFocus: handleFocus,
|
||||
onBlur: handleBlur,
|
||||
readOnly,
|
||||
placeholder,
|
||||
singleLine,
|
||||
onChange,
|
||||
contentType,
|
||||
useTemplating,
|
||||
}),
|
||||
],
|
||||
});
|
||||
const view = new EditorView({ state, parent: el });
|
||||
cm.current = { view, langHolder };
|
||||
syncGutterBg({ parent: el, className });
|
||||
|
||||
view = new EditorView({ state, parent: container });
|
||||
cm.current = { view, languageCompartment };
|
||||
syncGutterBg({ parent: container, className });
|
||||
if (autoFocus) view.focus();
|
||||
} catch (e) {
|
||||
console.log('Failed to initialize Codemirror', e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const cmContainer = (
|
||||
<div
|
||||
ref={initDivRef}
|
||||
ref={initEditorRef}
|
||||
className={classnames(
|
||||
className,
|
||||
'cm-wrapper text-base bg-gray-50',
|
||||
type === 'password' && 'cm-obscure-text',
|
||||
heightMode === 'auto' ? 'cm-auto-height' : 'cm-full-height',
|
||||
singleLine ? 'cm-singleline' : 'cm-multiline',
|
||||
readOnly && 'cm-readonly',
|
||||
)}
|
||||
>
|
||||
{contentType?.includes('graphql') && (
|
||||
<IconButton
|
||||
icon="eye"
|
||||
className="absolute right-3 bottom-3 z-10"
|
||||
onClick={() => {
|
||||
const doc = cm.current?.view.state.doc ?? '';
|
||||
const insert = formatSdl(doc.toString());
|
||||
cm.current?.view.dispatch({ changes: { from: 0, to: doc.length, insert } });
|
||||
}}
|
||||
/>
|
||||
/>
|
||||
);
|
||||
|
||||
if (singleLine) {
|
||||
return cmContainer;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group relative h-full w-full">
|
||||
{cmContainer}
|
||||
{format && (
|
||||
<HStack space={0.5} alignItems="center" className="absolute bottom-2 right-0 ">
|
||||
{actions}
|
||||
<IconButton
|
||||
showConfirm
|
||||
size="sm"
|
||||
title="Reformat contents"
|
||||
icon="magicWand"
|
||||
className="transition-opacity opacity-0 group-hover:opacity-70"
|
||||
onClick={() => {
|
||||
if (cm.current === null) return;
|
||||
const { doc } = cm.current.view.state;
|
||||
const insert = format(doc.toString());
|
||||
// Update editor and blur because the cursor will reset anyway
|
||||
cm.current.view.dispatch({ changes: { from: 0, to: doc.length, insert } });
|
||||
cm.current.view.contentDOM.blur();
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export const Editor = memo(_Editor);
|
||||
|
||||
function getExtensions({
|
||||
container,
|
||||
readOnly,
|
||||
singleLine,
|
||||
placeholder,
|
||||
onChange,
|
||||
contentType,
|
||||
useTemplating,
|
||||
}: Pick<
|
||||
_EditorProps,
|
||||
'singleLine' | 'onChange' | 'contentType' | 'useTemplating' | 'placeholder' | 'readOnly'
|
||||
> & { container: HTMLDivElement | null }) {
|
||||
const ext = getLanguageExtension({ contentType, useTemplating });
|
||||
|
||||
onFocus,
|
||||
onBlur,
|
||||
}: Pick<EditorProps, 'singleLine' | 'readOnly'> & {
|
||||
container: HTMLDivElement | null;
|
||||
onChange: MutableRefObject<EditorProps['onChange']>;
|
||||
onFocus: MutableRefObject<EditorProps['onFocus']>;
|
||||
onBlur: MutableRefObject<EditorProps['onBlur']>;
|
||||
}) {
|
||||
// TODO: Ensure tooltips render inside the dialog if we are in one.
|
||||
const parent =
|
||||
container?.closest<HTMLDivElement>('[role="dialog"]') ??
|
||||
@@ -138,9 +223,7 @@ function getExtensions({
|
||||
keymap.of(singleLine ? defaultKeymap.filter((k) => k.key !== 'Enter') : defaultKeymap),
|
||||
...(singleLine ? [singleLineExt()] : []),
|
||||
...(!singleLine ? [multiLineExtensions] : []),
|
||||
...(ext ? [ext] : []),
|
||||
...(readOnly ? [EditorState.readOnly.of(true)] : []),
|
||||
...(placeholder ? [placeholderExt(placeholder)] : []),
|
||||
...(singleLine
|
||||
? [
|
||||
EditorView.domEventHandlers({
|
||||
@@ -160,15 +243,35 @@ function getExtensions({
|
||||
]
|
||||
: []),
|
||||
|
||||
// Handle onFocus
|
||||
EditorView.domEventHandlers({
|
||||
focus: onFocus.current,
|
||||
blur: onBlur.current,
|
||||
}),
|
||||
|
||||
// Handle onChange
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (typeof onChange === 'function' && update.docChanged) {
|
||||
onChange(update.state.doc.toString());
|
||||
if (onChange && update.docChanged && isViewUpdateFromUserInput(update)) {
|
||||
onChange.current?.(update.state.doc.toString());
|
||||
}
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function isViewUpdateFromUserInput(viewUpdate: ViewUpdate) {
|
||||
// Make sure document has changed, ensuring user events like selections don't count.
|
||||
if (viewUpdate.docChanged) {
|
||||
// Check transactions for any that are direct user input, not changes from Y.js or another extension.
|
||||
for (const transaction of viewUpdate.transactions) {
|
||||
// Not using Transaction.isUserEvent because that only checks for a specific User event type ( "input", "delete", etc.). Checking the annotation directly allows for any type of user event.
|
||||
const userEventType = transaction.annotation(Transaction.userEvent);
|
||||
if (userEventType) return userEventType;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const syncGutterBg = ({
|
||||
parent,
|
||||
className = '',
|
||||
@@ -186,3 +289,9 @@ const syncGutterBg = ({
|
||||
gutterEl?.classList.add(...bgClasses);
|
||||
}
|
||||
};
|
||||
|
||||
const placeholderElFromText = (text: string) => {
|
||||
const el = document.createElement('div');
|
||||
el.innerHTML = text.replace('\n', '<br/>');
|
||||
return el;
|
||||
};
|
||||
|
||||
@@ -32,24 +32,29 @@ import {
|
||||
rectangularSelection,
|
||||
} from '@codemirror/view';
|
||||
import { tags as t } from '@lezer/highlight';
|
||||
import { graphqlLanguageSupport } from 'cm6-graphql';
|
||||
import { debouncedAutocompletionDisplay } from './autocomplete';
|
||||
import { graphql, graphqlLanguageSupport } from 'cm6-graphql';
|
||||
import type { EditorProps } from './index';
|
||||
import { text } from './text/extension';
|
||||
import { twig } from './twig/extension';
|
||||
import { url } from './url/extension';
|
||||
|
||||
export const myHighlightStyle = HighlightStyle.define([
|
||||
{
|
||||
tag: [t.documentMeta, t.blockComment, t.lineComment, t.docComment, t.comment],
|
||||
color: '#757b93',
|
||||
color: 'hsl(var(--color-gray-600))',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
{
|
||||
tag: [t.paren],
|
||||
color: 'hsl(var(--color-gray-900))',
|
||||
},
|
||||
{
|
||||
tag: [t.name, t.tagName, t.angleBracket, t.docString, t.number],
|
||||
color: 'hsl(var(--color-blue-600))',
|
||||
},
|
||||
{ tag: [t.variableName], color: 'hsl(var(--color-green-600))' },
|
||||
{ tag: [t.bool], color: 'hsl(var(--color-pink-600))' },
|
||||
{ tag: [t.attributeName], color: 'hsl(var(--color-violet-600))' },
|
||||
{ tag: [t.attributeName, t.propertyName], color: 'hsl(var(--color-violet-600))' },
|
||||
{ tag: [t.attributeValue], color: 'hsl(var(--color-orange-600))' },
|
||||
{ tag: [t.string], color: 'hsl(var(--color-yellow-600))' },
|
||||
{ tag: [t.keyword, t.meta, t.operator], color: 'hsl(var(--color-red-600))' },
|
||||
@@ -78,7 +83,7 @@ export const myHighlightStyle = HighlightStyle.define([
|
||||
// ]);
|
||||
|
||||
const syntaxExtensions: Record<string, LanguageSupport> = {
|
||||
'application/graphql+json': graphqlLanguageSupport(),
|
||||
'application/graphql': graphqlLanguageSupport(),
|
||||
'application/json': json(),
|
||||
'application/javascript': javascript(),
|
||||
'text/html': html(),
|
||||
@@ -89,18 +94,19 @@ const syntaxExtensions: Record<string, LanguageSupport> = {
|
||||
|
||||
export function getLanguageExtension({
|
||||
contentType,
|
||||
useTemplating,
|
||||
}: {
|
||||
contentType?: string;
|
||||
useTemplating?: boolean;
|
||||
}) {
|
||||
useTemplating = false,
|
||||
autocomplete,
|
||||
}: Pick<EditorProps, 'contentType' | 'useTemplating' | 'autocomplete'>) {
|
||||
if (contentType === 'application/graphql') {
|
||||
return graphql();
|
||||
}
|
||||
const justContentType = contentType?.split(';')[0] ?? contentType ?? '';
|
||||
const base = syntaxExtensions[justContentType] ?? json();
|
||||
const base = syntaxExtensions[justContentType] ?? text();
|
||||
if (!useTemplating) {
|
||||
return [base];
|
||||
return base ? base : [];
|
||||
}
|
||||
|
||||
return twig(base);
|
||||
return twig(base, autocomplete);
|
||||
}
|
||||
|
||||
export const baseExtensions = [
|
||||
@@ -112,7 +118,14 @@ export const baseExtensions = [
|
||||
// TODO: Figure out how to debounce showing of autocomplete in a good way
|
||||
// debouncedAutocompletionDisplay({ millis: 1000 }),
|
||||
// autocompletion({ closeOnBlur: true, interactionDelay: 200, activateOnTyping: false }),
|
||||
autocompletion({ closeOnBlur: true, interactionDelay: 300 }),
|
||||
autocompletion({
|
||||
// closeOnBlur: false,
|
||||
interactionDelay: 200,
|
||||
compareCompletions: (a, b) => {
|
||||
// Don't sort completions at all, only on boost
|
||||
return (a.boost ?? 0) - (b.boost ?? 0);
|
||||
},
|
||||
}),
|
||||
syntaxHighlighting(myHighlightStyle),
|
||||
EditorState.allowMultipleSelections.of(true),
|
||||
];
|
||||
|
||||
29
src-web/components/core/Editor/genericCompletion.ts
Normal file
29
src-web/components/core/Editor/genericCompletion.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { CompletionContext } from '@codemirror/autocomplete';
|
||||
|
||||
export interface GenericCompletionOption {
|
||||
label: string;
|
||||
type: 'constant' | 'variable';
|
||||
/** When given, should be a number from -99 to 99 that adjusts
|
||||
* how this completion is ranked compared to other completions
|
||||
* that match the input as well as this one. A negative number
|
||||
* moves it down the list, a positive number moves it up. */
|
||||
boost?: number;
|
||||
}
|
||||
|
||||
export interface GenericCompletionConfig {
|
||||
minMatch?: number;
|
||||
options: GenericCompletionOption[];
|
||||
}
|
||||
|
||||
export function genericCompletion({ options, minMatch = 1 }: GenericCompletionConfig) {
|
||||
return function completions(context: CompletionContext) {
|
||||
const toMatch = context.matchBefore(/^.*/);
|
||||
if (toMatch === null) return null;
|
||||
|
||||
const matchedMinimumLength = toMatch.to - toMatch.from >= minMatch;
|
||||
if (!matchedMinimumLength && !context.explicit) return null;
|
||||
|
||||
const optionsWithoutExactMatches = options.filter((o) => o.label !== toMatch.text);
|
||||
return { from: toMatch.from, options: optionsWithoutExactMatches, info: 'hello' };
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
import { memo } from 'react';
|
||||
import { _Editor } from './Editor';
|
||||
import type { _EditorProps } from './Editor';
|
||||
import * as editor from './Editor';
|
||||
|
||||
export type EditorProps = _EditorProps;
|
||||
export const Editor = memo(_Editor);
|
||||
export type { EditorProps } from './Editor';
|
||||
// TODO: Figure out why code-splitting breaks production build from
|
||||
// showing any content
|
||||
// const editor = await import('./Editor');
|
||||
|
||||
export const Editor = editor.Editor;
|
||||
export const graphql = editor.graphql;
|
||||
export const getIntrospectionQuery = editor.getIntrospectionQuery;
|
||||
export const buildClientSchema = editor.buildClientSchema;
|
||||
export const formatGraphQL = editor.formatSdl;
|
||||
|
||||
14
src-web/components/core/Editor/text/extension.ts
Normal file
14
src-web/components/core/Editor/text/extension.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { LanguageSupport, LRLanguage } from '@codemirror/language';
|
||||
import { parser } from './text';
|
||||
|
||||
export const textLanguageName = 'text';
|
||||
|
||||
const textLanguage = LRLanguage.define({
|
||||
name: textLanguageName,
|
||||
parser,
|
||||
languageData: {},
|
||||
});
|
||||
|
||||
export function text() {
|
||||
return new LanguageSupport(textLanguage);
|
||||
}
|
||||
5
src-web/components/core/Editor/text/text.grammar
Normal file
5
src-web/components/core/Editor/text/text.grammar
Normal file
@@ -0,0 +1,5 @@
|
||||
@top Template { Text }
|
||||
|
||||
@tokens {
|
||||
Text { ![]+ }
|
||||
}
|
||||
4
src-web/components/core/Editor/text/text.terms.ts
Normal file
4
src-web/components/core/Editor/text/text.terms.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
export const
|
||||
Template = 1,
|
||||
Text = 2
|
||||
16
src-web/components/core/Editor/text/text.ts
Normal file
16
src-web/components/core/Editor/text/text.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
import {LRParser} from "@lezer/lr"
|
||||
export const parser = LRParser.deserialize({
|
||||
version: 14,
|
||||
states: "[OQOPOOQOOOOO",
|
||||
stateData: "V~OQPO~O",
|
||||
goto: "QPP",
|
||||
nodeNames: "⚠ Template Text",
|
||||
maxTerm: 3,
|
||||
skippedNodes: [0],
|
||||
repeatNodeCount: 0,
|
||||
tokenData: "p~RRO;'S[;'S;=`j<%lO[~aRQ~O;'S[;'S;=`j<%lO[~mP;=`<%l[",
|
||||
tokenizers: [0],
|
||||
topRules: {"Template":[0,1]},
|
||||
tokenPrec: 0
|
||||
})
|
||||
@@ -6,6 +6,7 @@ const closeTag = ' ]}';
|
||||
const variables = [
|
||||
{ name: 'DOMAIN' },
|
||||
{ name: 'BASE_URL' },
|
||||
{ name: 'CONTENT_THINGY' },
|
||||
{ name: 'TOKEN' },
|
||||
{ name: 'PROJECT_ID' },
|
||||
{ name: 'DUMMY' },
|
||||
@@ -17,7 +18,7 @@ const variables = [
|
||||
];
|
||||
|
||||
const MIN_MATCH_VAR = 2;
|
||||
const MIN_MATCH_NAME = 4;
|
||||
const MIN_MATCH_NAME = 3;
|
||||
|
||||
export function completions(context: CompletionContext) {
|
||||
const toStartOfName = context.matchBefore(/\w*/);
|
||||
|
||||
@@ -1,35 +1,41 @@
|
||||
import { LanguageSupport, LRLanguage } from '@codemirror/language';
|
||||
import type { LanguageSupport } from '@codemirror/language';
|
||||
import { LRLanguage } from '@codemirror/language';
|
||||
import { parseMixed } from '@lezer/common';
|
||||
import type { GenericCompletionConfig } from '../genericCompletion';
|
||||
import { genericCompletion } from '../genericCompletion';
|
||||
import { placeholders } from '../placeholder';
|
||||
import { textLanguageName } from '../text/extension';
|
||||
import { completions } from './completion';
|
||||
import { placeholders } from '../widgets';
|
||||
import { parser as twigParser } from './twig';
|
||||
|
||||
export function twig(base?: LanguageSupport) {
|
||||
const language = mixedOrPlainLanguage(base);
|
||||
const completion = language.data.of({
|
||||
autocomplete: completions,
|
||||
});
|
||||
const languageSupport = new LanguageSupport(language, [completion]);
|
||||
export function twig(base: LanguageSupport, autocomplete?: GenericCompletionConfig) {
|
||||
const language = mixLanguage(base);
|
||||
const completion = language.data.of({ autocomplete: completions });
|
||||
const completionBase = base.language.data.of({ autocomplete: completions });
|
||||
const additionalCompletion = autocomplete
|
||||
? [language.data.of({ autocomplete: genericCompletion(autocomplete) })]
|
||||
: [];
|
||||
|
||||
if (base) {
|
||||
const completion2 = base.language.data.of({ autocomplete: completions });
|
||||
const languageSupport2 = new LanguageSupport(base.language, [completion2]);
|
||||
return [languageSupport, languageSupport2, placeholders, base.support];
|
||||
} else {
|
||||
return [languageSupport, placeholders];
|
||||
}
|
||||
return [
|
||||
language,
|
||||
completion,
|
||||
completionBase,
|
||||
base.support,
|
||||
placeholders,
|
||||
...additionalCompletion,
|
||||
];
|
||||
}
|
||||
|
||||
function mixedOrPlainLanguage(base?: LanguageSupport): LRLanguage {
|
||||
function mixLanguage(base: LanguageSupport): LRLanguage {
|
||||
const name = 'twig';
|
||||
|
||||
if (base == null) {
|
||||
return LRLanguage.define({ name, parser: twigParser });
|
||||
}
|
||||
|
||||
const parser = twigParser.configure({
|
||||
wrap: parseMixed((node) => {
|
||||
if (!node.type.isTop) return null;
|
||||
// If the base language is text, we can overwrite at the top
|
||||
if (base.language.name !== textLanguageName && !node.type.isTop) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
parser: base.language.parser,
|
||||
overlay: (node) => node.type.name === 'Text',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { styleTags, tags as t } from '@lezer/highlight';
|
||||
|
||||
export const highlight = styleTags({
|
||||
'if endif': t.controlKeyword,
|
||||
'${[ ]}': t.meta,
|
||||
DirectiveContent: t.variableName,
|
||||
Open: t.tagName,
|
||||
Close: t.tagName,
|
||||
Content: t.keyword,
|
||||
});
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
@top Template { (directive | Text)* }
|
||||
@top Template { (Tag | Text)* }
|
||||
|
||||
directive {
|
||||
Insert
|
||||
@local tokens {
|
||||
Close { "]}" }
|
||||
@else Content
|
||||
}
|
||||
|
||||
@skip {space} {
|
||||
Insert { "${[" DirectiveContent "]}" }
|
||||
@skip { } {
|
||||
Open { "${[" }
|
||||
Tag { Open (Content)+ Close }
|
||||
}
|
||||
|
||||
@tokens {
|
||||
Text { ![$] Text? }
|
||||
space { @whitespace+ }
|
||||
DirectiveContent { ![\]}] DirectiveContent? }
|
||||
@precedence { space DirectiveContent }
|
||||
"${[" "]}"
|
||||
}
|
||||
|
||||
@external propSource highlight from "./highlight"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
export const
|
||||
Template = 1,
|
||||
Insert = 2,
|
||||
DirectiveContent = 4,
|
||||
Tag = 2,
|
||||
Open = 3,
|
||||
Content = 4,
|
||||
Close = 5,
|
||||
Text = 6
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
import {LRParser} from "@lezer/lr"
|
||||
import {LRParser, LocalTokenGroup} from "@lezer/lr"
|
||||
import {highlight} from "./highlight"
|
||||
export const parser = LRParser.deserialize({
|
||||
version: 14,
|
||||
states: "zQVOPOOO_QQO'#C^OOOO'#Cc'#CcQVOPOOOdQQO,58xOOOO-E6a-E6aOOOO1G.d1G.d",
|
||||
stateData: "l~OYOS~ORPOUQO~OSSO~OTUO~OYS~",
|
||||
goto: "cWPPXPPPP]TQORQRORTR",
|
||||
nodeNames: "⚠ Template Insert ${[ DirectiveContent ]} Text",
|
||||
states: "!^QQOPOOOOOO'#C_'#C_OYOQO'#C^OOOO'#Cc'#CcQQOPOOOOOO'#Cd'#CdO_OQO,58xOOOO-E6a-E6aOOOO-E6b-E6bOOOO1G.d1G.d",
|
||||
stateData: "g~OUROYPO~OSTO~OSTOTXO~O",
|
||||
goto: "nXPPY^PPPbhTROSTQOSQSORVSQUQRWU",
|
||||
nodeNames: "⚠ Template Tag Open Content Close Text",
|
||||
maxTerm: 10,
|
||||
propSources: [highlight],
|
||||
skippedNodes: [0],
|
||||
repeatNodeCount: 1,
|
||||
tokenData: ")gRRmOX!|X^$y^p!|pq$yqt!|tu&}u#P!|#P#Q(k#Q#q!|#q#r$[#r#y!|#y#z$y#z$f!|$f$g$y$g#BY!|#BY#BZ$y#BZ$IS!|$IS$I_$y$I_$I|!|$I|$JO$y$JO$JT!|$JT$JU$y$JU$KV!|$KV$KW$y$KW&FU!|&FU&FV$y&FV;'S!|;'S;=`$s<%lO!|R#TXUPSQOt!|tu#pu#P!|#P#Q$[#Q#q!|#q#r$[#r;'S!|;'S;=`$s<%lO!|Q#uTSQO#P#p#Q#q#p#r;'S#p;'S;=`$U<%lO#pQ$XP;=`<%l#pP$aSUPOt$[u;'S$[;'S;=`$m<%lO$[P$pP;=`<%l$[R$vP;=`<%l!|R%SmUPYQSQOX!|X^$y^p!|pq$yqt!|tu#pu#P!|#P#Q$[#Q#q!|#q#r$[#r#y!|#y#z$y#z$f!|$f$g$y$g#BY!|#BY#BZ$y#BZ$IS!|$IS$I_$y$I_$I|!|$I|$JO$y$JO$JT!|$JT$JU$y$JU$KV!|$KV$KW$y$KW&FU!|&FU&FV$y&FV;'S!|;'S;=`$s<%lO!|R'SVSQO#P#p#Q#o#p#o#p'i#p#q#p#r;'S#p;'S;=`$U<%lO#pR'nVSQO!}#p!}#O(T#O#P#p#Q#q#p#r;'S#p;'S;=`$U<%lO#pR([TRPSQO#P#p#Q#q#p#r;'S#p;'S;=`$U<%lO#pR(pUUPOt$[u#q$[#q#r)S#r;'S$[;'S;=`$m<%lO$[R)ZSTQUPOt$[u;'S$[;'S;=`$m<%lO$[",
|
||||
tokenizers: [0, 1],
|
||||
repeatNodeCount: 2,
|
||||
tokenData: "![~RTOtbtuyu;'Sb;'S;=`s<%lOb~gSU~Otbu;'Sb;'S;=`s<%lOb~vP;=`<%lb~|P#o#p!P~!SP!}#O!V~![OY~",
|
||||
tokenizers: [1, new LocalTokenGroup("b~RP#P#QU~XP#q#r[~aOT~~", 17, 4)],
|
||||
topRules: {"Template":[0,1]},
|
||||
tokenPrec: 25
|
||||
tokenPrec: 0
|
||||
})
|
||||
|
||||
@@ -1,19 +1,9 @@
|
||||
import type { CompletionContext } from '@codemirror/autocomplete';
|
||||
import { genericCompletion } from '../genericCompletion';
|
||||
|
||||
const options = [
|
||||
{ label: 'http://', type: 'constant' },
|
||||
{ label: 'https://', type: 'constant' },
|
||||
];
|
||||
|
||||
const MIN_MATCH = 1;
|
||||
|
||||
export function completions(context: CompletionContext) {
|
||||
const toMatch = context.matchBefore(/^[\w:/]*/);
|
||||
if (toMatch === null) return null;
|
||||
|
||||
const matchedMinimumLength = toMatch.to - toMatch.from >= MIN_MATCH;
|
||||
if (!matchedMinimumLength && !context.explicit) return null;
|
||||
|
||||
const optionsWithoutExactMatches = options.filter((o) => o.label !== toMatch.text);
|
||||
return { from: toMatch.from, options: optionsWithoutExactMatches };
|
||||
}
|
||||
export const completions = genericCompletion({
|
||||
options: [
|
||||
{ label: 'http://', type: 'constant' },
|
||||
{ label: 'https://', type: 'constant' },
|
||||
],
|
||||
minMatch: 1,
|
||||
});
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import classnames from 'classnames';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export function Heading({ className, children, ...props }: Props) {
|
||||
export function Heading({ className, children, ...props }: HTMLAttributes<HTMLHeadingElement>) {
|
||||
return (
|
||||
<h1 className={classnames(className, 'text-2xl font-semibold text-gray-900 mb-3')} {...props}>
|
||||
{children}
|
||||
|
||||
@@ -1,41 +1,68 @@
|
||||
import {
|
||||
ArchiveIcon,
|
||||
CameraIcon,
|
||||
CheckboxIcon,
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
ClockIcon,
|
||||
CodeIcon,
|
||||
ColorWheelIcon,
|
||||
CopyIcon,
|
||||
Cross2Icon,
|
||||
DividerHorizontalIcon,
|
||||
DotsHorizontalIcon,
|
||||
DotsVerticalIcon,
|
||||
DragHandleDots2Icon,
|
||||
EyeClosedIcon,
|
||||
EyeOpenIcon,
|
||||
GearIcon,
|
||||
HamburgerMenuIcon,
|
||||
HomeIcon,
|
||||
MoonIcon,
|
||||
ListBulletIcon,
|
||||
MagicWandIcon,
|
||||
MagnifyingGlassIcon,
|
||||
MoonIcon,
|
||||
PaperPlaneIcon,
|
||||
PlusCircledIcon,
|
||||
PlusIcon,
|
||||
QuestionMarkIcon,
|
||||
RowsIcon,
|
||||
SunIcon,
|
||||
TrashIcon,
|
||||
TriangleDownIcon,
|
||||
TriangleLeftIcon,
|
||||
TriangleRightIcon,
|
||||
UpdateIcon,
|
||||
RowsIcon,
|
||||
} from '@radix-ui/react-icons';
|
||||
import classnames from 'classnames';
|
||||
import { memo } from 'react';
|
||||
import { ReactComponent as LeftPanelHiddenIcon } from '../../assets/icons/LeftPanelHiddenIcon.svg';
|
||||
import { ReactComponent as LeftPanelVisibleIcon } from '../../assets/icons/LeftPanelVisibleIcon.svg';
|
||||
|
||||
const icons = {
|
||||
archive: ArchiveIcon,
|
||||
camera: CameraIcon,
|
||||
check: CheckIcon,
|
||||
checkbox: CheckboxIcon,
|
||||
clock: ClockIcon,
|
||||
chevronDown: ChevronDownIcon,
|
||||
code: CodeIcon,
|
||||
colorWheel: ColorWheelIcon,
|
||||
copy: CopyIcon,
|
||||
dividerH: DividerHorizontalIcon,
|
||||
dotsH: DotsHorizontalIcon,
|
||||
dotsV: DotsVerticalIcon,
|
||||
drag: DragHandleDots2Icon,
|
||||
eye: EyeOpenIcon,
|
||||
eyeClosed: EyeClosedIcon,
|
||||
gear: GearIcon,
|
||||
hamburger: HamburgerMenuIcon,
|
||||
home: HomeIcon,
|
||||
leftPanelHidden: LeftPanelHiddenIcon,
|
||||
leftPanelVisible: LeftPanelVisibleIcon,
|
||||
listBullet: ListBulletIcon,
|
||||
magicWand: MagicWandIcon,
|
||||
magnifyingGlass: MagnifyingGlassIcon,
|
||||
moon: MoonIcon,
|
||||
paperPlane: PaperPlaneIcon,
|
||||
plus: PlusIcon,
|
||||
@@ -49,6 +76,7 @@ const icons = {
|
||||
triangleRight: TriangleRightIcon,
|
||||
update: UpdateIcon,
|
||||
x: Cross2Icon,
|
||||
empty: () => <span />,
|
||||
};
|
||||
|
||||
export interface IconProps {
|
||||
@@ -58,7 +86,7 @@ export interface IconProps {
|
||||
spin?: boolean;
|
||||
}
|
||||
|
||||
export function Icon({ icon, spin, size = 'md', className }: IconProps) {
|
||||
export const Icon = memo(function Icon({ icon, spin, size = 'md', className }: IconProps) {
|
||||
const Component = icons[icon] ?? icons.question;
|
||||
return (
|
||||
<Component
|
||||
@@ -66,10 +94,10 @@ export function Icon({ icon, spin, size = 'md', className }: IconProps) {
|
||||
className,
|
||||
'text-inherit',
|
||||
size === 'md' && 'h-4 w-4',
|
||||
size === 'sm' && 'h-3 w-3',
|
||||
size === 'xs' && 'h-2 w-2',
|
||||
size === 'sm' && 'h-3.5 w-3.5',
|
||||
size === 'xs' && 'h-3 w-3',
|
||||
spin && 'animate-spin',
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,35 +1,73 @@
|
||||
import classnames from 'classnames';
|
||||
import { forwardRef } from 'react';
|
||||
import type { MouseEvent } from 'react';
|
||||
import { forwardRef, memo, useCallback } from 'react';
|
||||
import { useTimedBoolean } from '../../hooks/useTimedBoolean';
|
||||
import type { ButtonProps } from './Button';
|
||||
import { Button } from './Button';
|
||||
import type { IconProps } from './Icon';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
type Props = IconProps & ButtonProps & { iconClassName?: string; iconSize?: IconProps['size'] };
|
||||
type Props = IconProps &
|
||||
ButtonProps & {
|
||||
showConfirm?: boolean;
|
||||
iconClassName?: string;
|
||||
iconSize?: IconProps['size'];
|
||||
title: string;
|
||||
};
|
||||
|
||||
export const IconButton = forwardRef<HTMLButtonElement, Props>(function IconButton(
|
||||
{ icon, spin, className, iconClassName, size = 'md', iconSize, ...props }: Props,
|
||||
const _IconButton = forwardRef<HTMLButtonElement, Props>(function IconButton(
|
||||
{
|
||||
showConfirm,
|
||||
icon,
|
||||
spin,
|
||||
onClick,
|
||||
className,
|
||||
iconClassName,
|
||||
tabIndex,
|
||||
size = 'md',
|
||||
iconSize,
|
||||
...props
|
||||
}: Props,
|
||||
ref,
|
||||
) {
|
||||
const [confirmed, setConfirmed] = useTimedBoolean();
|
||||
const handleClick = useCallback(
|
||||
(e: MouseEvent<HTMLElement>) => {
|
||||
if (showConfirm) setConfirmed();
|
||||
onClick?.(e);
|
||||
},
|
||||
[onClick, setConfirmed, showConfirm],
|
||||
);
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
aria-hidden={icon === 'empty'}
|
||||
disabled={icon === 'empty'}
|
||||
tabIndex={tabIndex ?? icon === 'empty' ? -1 : undefined}
|
||||
onClick={handleClick}
|
||||
className={classnames(
|
||||
className,
|
||||
'text-gray-700 hover:text-gray-1000',
|
||||
'flex-shrink-0 text-gray-700 hover:text-gray-1000',
|
||||
'!px-0',
|
||||
size === 'md' && 'w-9',
|
||||
size === 'sm' && 'w-9',
|
||||
size === 'sm' && 'w-8',
|
||||
size === 'xs' && 'w-6',
|
||||
)}
|
||||
size={size}
|
||||
{...props}
|
||||
>
|
||||
<Icon
|
||||
size={iconSize}
|
||||
icon={icon}
|
||||
icon={confirmed ? 'check' : icon}
|
||||
spin={spin}
|
||||
className={classnames(iconClassName, props.disabled && 'opacity-70')}
|
||||
className={classnames(
|
||||
iconClassName,
|
||||
props.disabled && 'opacity-70',
|
||||
confirmed && 'text-green-600',
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
|
||||
export const IconButton = memo(_IconButton);
|
||||
|
||||
14
src-web/components/core/InlineCode.tsx
Normal file
14
src-web/components/core/InlineCode.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import classnames from 'classnames';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
export function InlineCode({ className, ...props }: HTMLAttributes<HTMLSpanElement>) {
|
||||
return (
|
||||
<code
|
||||
className={classnames(
|
||||
className,
|
||||
'font-mono text-sm bg-highlight border-0 border-gray-200 px-1.5 py-0.5 rounded text-gray-800 shadow-inner',
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,57 +1,102 @@
|
||||
import classnames from 'classnames';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { EditorView } from 'codemirror';
|
||||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
import { forwardRef, useCallback, useMemo, useState } from 'react';
|
||||
import type { EditorProps } from './Editor';
|
||||
import { Editor } from './Editor';
|
||||
import { IconButton } from './IconButton';
|
||||
import { HStack, VStack } from './Stacks';
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
label: string;
|
||||
hideLabel?: boolean;
|
||||
labelClassName?: string;
|
||||
containerClassName?: string;
|
||||
onChange?: (value: string) => void;
|
||||
useEditor?: Pick<EditorProps, 'contentType' | 'useTemplating'>;
|
||||
defaultValue?: string;
|
||||
leftSlot?: ReactNode;
|
||||
rightSlot?: ReactNode;
|
||||
size?: 'sm' | 'md';
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
export type InputProps = Omit<HTMLAttributes<HTMLInputElement>, 'onChange' | 'onFocus'> &
|
||||
Pick<EditorProps, 'contentType' | 'useTemplating' | 'autocomplete' | 'forceUpdateKey'> & {
|
||||
name: string;
|
||||
type?: 'text' | 'password';
|
||||
label: string;
|
||||
hideLabel?: boolean;
|
||||
labelClassName?: string;
|
||||
containerClassName?: string;
|
||||
onChange?: (value: string) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
defaultValue?: string;
|
||||
leftSlot?: ReactNode;
|
||||
rightSlot?: ReactNode;
|
||||
size?: 'sm' | 'md';
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
autoFocus?: boolean;
|
||||
validate?: (v: string) => boolean;
|
||||
require?: boolean;
|
||||
};
|
||||
|
||||
export const Input = forwardRef<EditorView | undefined, InputProps>(function Input(
|
||||
{
|
||||
label,
|
||||
type = 'text',
|
||||
hideLabel,
|
||||
className,
|
||||
containerClassName,
|
||||
labelClassName,
|
||||
onChange,
|
||||
placeholder,
|
||||
size = 'md',
|
||||
name,
|
||||
leftSlot,
|
||||
rightSlot,
|
||||
defaultValue,
|
||||
validate,
|
||||
require,
|
||||
onFocus,
|
||||
onBlur,
|
||||
forceUpdateKey,
|
||||
...props
|
||||
}: InputProps,
|
||||
ref,
|
||||
) {
|
||||
const [obscured, setObscured] = useState(type === 'password');
|
||||
const [currentValue, setCurrentValue] = useState(defaultValue ?? '');
|
||||
const [focused, setFocused] = useState(false);
|
||||
|
||||
const handleOnFocus = useCallback(() => {
|
||||
setFocused(true);
|
||||
onFocus?.();
|
||||
}, [onFocus]);
|
||||
|
||||
const handleOnBlur = useCallback(() => {
|
||||
setFocused(false);
|
||||
onBlur?.();
|
||||
}, [onBlur]);
|
||||
|
||||
export function Input({
|
||||
label,
|
||||
hideLabel,
|
||||
className,
|
||||
containerClassName,
|
||||
labelClassName,
|
||||
onChange,
|
||||
placeholder,
|
||||
size = 'md',
|
||||
useEditor,
|
||||
name,
|
||||
leftSlot,
|
||||
rightSlot,
|
||||
defaultValue,
|
||||
...props
|
||||
}: Props) {
|
||||
const id = `input-${name}`;
|
||||
const inputClassName = classnames(
|
||||
className,
|
||||
'!bg-transparent pl-3 pr-2 min-w-0 h-full w-full focus:outline-none placeholder:text-placeholder',
|
||||
!!leftSlot && '!pl-0.5',
|
||||
!!rightSlot && '!pr-0.5',
|
||||
'!bg-transparent min-w-0 h-full w-full focus:outline-none placeholder:text-placeholder',
|
||||
// Bump things over if the slots are occupied
|
||||
leftSlot && 'pl-0.5 -ml-2',
|
||||
rightSlot && 'pr-0.5 -mr-2',
|
||||
);
|
||||
|
||||
const isValid = useMemo(() => {
|
||||
if (require && !validateRequire(currentValue)) return false;
|
||||
if (validate && !validate(currentValue)) return false;
|
||||
return true;
|
||||
}, [currentValue, validate, require]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(value: string) => {
|
||||
setCurrentValue(value);
|
||||
onChange?.(value);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<VStack>
|
||||
<VStack className="w-full">
|
||||
<label
|
||||
htmlFor={id}
|
||||
className={classnames(
|
||||
labelClassName,
|
||||
'font-semibold text-sm uppercase text-gray-700',
|
||||
'font-semibold text-xs uppercase text-gray-700',
|
||||
hideLabel && 'sr-only',
|
||||
)}
|
||||
>
|
||||
@@ -62,35 +107,44 @@ export function Input({
|
||||
className={classnames(
|
||||
containerClassName,
|
||||
'relative w-full rounded-md text-gray-900',
|
||||
'border border-gray-200 focus-within:border-blue-400/40',
|
||||
size === 'md' && 'h-9',
|
||||
size === 'sm' && 'h-7',
|
||||
'border border-highlight',
|
||||
focused && 'border-focus',
|
||||
!isValid && '!border-invalid',
|
||||
size === 'md' && 'h-md leading-md',
|
||||
size === 'sm' && 'h-sm leading-sm',
|
||||
)}
|
||||
>
|
||||
{leftSlot}
|
||||
{useEditor ? (
|
||||
<Editor
|
||||
id={id}
|
||||
singleLine
|
||||
defaultValue={defaultValue}
|
||||
placeholder={placeholder}
|
||||
onChange={onChange}
|
||||
className={inputClassName}
|
||||
{...props}
|
||||
{...useEditor}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
id={id}
|
||||
onChange={(e) => onChange?.(e.currentTarget.value)}
|
||||
placeholder={placeholder}
|
||||
defaultValue={defaultValue}
|
||||
className={inputClassName}
|
||||
{...props}
|
||||
<Editor
|
||||
ref={ref}
|
||||
id={id}
|
||||
singleLine
|
||||
type={type === 'password' && !obscured ? 'text' : type}
|
||||
defaultValue={defaultValue}
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
placeholder={placeholder}
|
||||
onChange={handleChange}
|
||||
className={inputClassName}
|
||||
onFocus={handleOnFocus}
|
||||
onBlur={handleOnBlur}
|
||||
{...props}
|
||||
/>
|
||||
{type === 'password' && (
|
||||
<IconButton
|
||||
title={obscured ? `Show ${label}` : `Obscure ${label}`}
|
||||
size="xs"
|
||||
className="mr-0.5"
|
||||
iconSize="sm"
|
||||
icon={obscured ? 'eyeClosed' : 'eye'}
|
||||
onClick={() => setObscured((o) => !o)}
|
||||
/>
|
||||
)}
|
||||
{rightSlot}
|
||||
</HStack>
|
||||
</VStack>
|
||||
);
|
||||
});
|
||||
|
||||
function validateRequire(v: string) {
|
||||
return v.length > 0;
|
||||
}
|
||||
|
||||
347
src-web/components/core/PairEditor.tsx
Normal file
347
src-web/components/core/PairEditor.tsx
Normal file
@@ -0,0 +1,347 @@
|
||||
import classnames from 'classnames';
|
||||
import React, { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { XYCoord } from 'react-dnd';
|
||||
import { useDrag, useDrop } from 'react-dnd';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { DropMarker } from '../DropMarker';
|
||||
import { Checkbox } from './Checkbox';
|
||||
import type { GenericCompletionConfig } from './Editor/genericCompletion';
|
||||
import { Icon } from './Icon';
|
||||
import { IconButton } from './IconButton';
|
||||
import type { InputProps } from './Input';
|
||||
import { Input } from './Input';
|
||||
|
||||
export type PairEditorProps = {
|
||||
pairs: Pair[];
|
||||
onChange: (pairs: Pair[]) => void;
|
||||
forceUpdateKey?: string;
|
||||
className?: string;
|
||||
namePlaceholder?: string;
|
||||
valuePlaceholder?: string;
|
||||
nameAutocomplete?: GenericCompletionConfig;
|
||||
valueAutocomplete?: (name: string) => GenericCompletionConfig | undefined;
|
||||
nameValidate?: InputProps['validate'];
|
||||
valueValidate?: InputProps['validate'];
|
||||
};
|
||||
|
||||
type Pair = {
|
||||
enabled?: boolean;
|
||||
name: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type PairContainer = {
|
||||
pair: Pair;
|
||||
id: string;
|
||||
};
|
||||
|
||||
export const PairEditor = memo(function PairEditor({
|
||||
pairs: originalPairs,
|
||||
forceUpdateKey,
|
||||
nameAutocomplete,
|
||||
valueAutocomplete,
|
||||
namePlaceholder,
|
||||
valuePlaceholder,
|
||||
nameValidate,
|
||||
valueValidate,
|
||||
className,
|
||||
onChange,
|
||||
}: PairEditorProps) {
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
const [pairs, setPairs] = useState<PairContainer[]>(() => {
|
||||
// Remove empty headers on initial render
|
||||
const nonEmpty = originalPairs.filter((h) => !(h.name === '' && h.value === ''));
|
||||
const pairs = nonEmpty.map((pair) => newPairContainer(pair));
|
||||
return [...pairs, newPairContainer()];
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Remove empty headers on initial render
|
||||
// TODO: Make this not refresh the entire editor when forceUpdateKey changes, using some
|
||||
// sort of diff method or deterministic IDs based on array index and update key
|
||||
const nonEmpty = originalPairs.filter((h) => !(h.name === '' && h.value === ''));
|
||||
const pairs = nonEmpty.map((pair) => newPairContainer(pair));
|
||||
setPairs([...pairs, newPairContainer()]);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [forceUpdateKey]);
|
||||
|
||||
const setPairsAndSave = useCallback(
|
||||
(fn: (pairs: PairContainer[]) => PairContainer[]) => {
|
||||
setPairs((oldPairs) => {
|
||||
const pairs = fn(oldPairs).map((p) => p.pair);
|
||||
onChange(pairs);
|
||||
return fn(oldPairs);
|
||||
});
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleMove = useCallback<FormRowProps['onMove']>(
|
||||
(id, side) => {
|
||||
const dragIndex = pairs.findIndex((r) => r.id === id);
|
||||
setHoveredIndex(side === 'above' ? dragIndex : dragIndex + 1);
|
||||
},
|
||||
[pairs],
|
||||
);
|
||||
|
||||
const handleEnd = useCallback<FormRowProps['onEnd']>(
|
||||
(id: string) => {
|
||||
if (hoveredIndex === null) return;
|
||||
setHoveredIndex(null);
|
||||
|
||||
setPairsAndSave((pairs) => {
|
||||
const index = pairs.findIndex((p) => p.id === id);
|
||||
const pair = pairs[index];
|
||||
if (pair === undefined) return pairs;
|
||||
|
||||
const newPairs = pairs.filter((p) => p.id !== id);
|
||||
if (hoveredIndex > index) newPairs.splice(hoveredIndex - 1, 0, pair);
|
||||
else newPairs.splice(hoveredIndex, 0, pair);
|
||||
return newPairs;
|
||||
});
|
||||
},
|
||||
[hoveredIndex, setPairsAndSave],
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(pair: PairContainer) =>
|
||||
setPairsAndSave((pairs) => pairs.map((p) => (pair.id !== p.id ? p : pair))),
|
||||
[setPairsAndSave],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(pair: PairContainer) =>
|
||||
setPairsAndSave((oldPairs) => oldPairs.filter((p) => p.id !== pair.id)),
|
||||
[setPairsAndSave],
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(
|
||||
(pair: PairContainer) =>
|
||||
setPairs((pairs) => {
|
||||
const isLast = pair.id === pairs[pairs.length - 1]?.id;
|
||||
return isLast ? [...pairs, newPairContainer()] : pairs;
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
// Ensure there's always at least one pair
|
||||
useEffect(() => {
|
||||
if (pairs.length === 0) {
|
||||
setPairs((pairs) => [...pairs, newPairContainer()]);
|
||||
}
|
||||
}, [pairs]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classnames(
|
||||
className,
|
||||
'@container',
|
||||
'pb-2 grid',
|
||||
// Move over the width of the drag handle
|
||||
'-ml-3',
|
||||
)}
|
||||
>
|
||||
{pairs.map((p, i) => {
|
||||
const isLast = i === pairs.length - 1;
|
||||
return (
|
||||
<Fragment key={p.id}>
|
||||
{hoveredIndex === i && <DropMarker />}
|
||||
<FormRow
|
||||
pairContainer={p}
|
||||
className="py-1"
|
||||
isLast={isLast}
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
nameAutocomplete={nameAutocomplete}
|
||||
valueAutocomplete={valueAutocomplete}
|
||||
namePlaceholder={namePlaceholder}
|
||||
valuePlaceholder={valuePlaceholder}
|
||||
nameValidate={nameValidate}
|
||||
valueValidate={valueValidate}
|
||||
onChange={handleChange}
|
||||
onFocus={handleFocus}
|
||||
onDelete={isLast ? undefined : handleDelete}
|
||||
onEnd={handleEnd}
|
||||
onMove={handleMove}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
enum ItemTypes {
|
||||
ROW = 'pair-row',
|
||||
}
|
||||
|
||||
type FormRowProps = {
|
||||
className?: string;
|
||||
pairContainer: PairContainer;
|
||||
onMove: (id: string, side: 'above' | 'below') => void;
|
||||
onEnd: (id: string) => void;
|
||||
onChange: (pair: PairContainer) => void;
|
||||
onDelete?: (pair: PairContainer) => void;
|
||||
onFocus?: (pair: PairContainer) => void;
|
||||
isLast?: boolean;
|
||||
} & Pick<
|
||||
PairEditorProps,
|
||||
| 'nameAutocomplete'
|
||||
| 'valueAutocomplete'
|
||||
| 'namePlaceholder'
|
||||
| 'valuePlaceholder'
|
||||
| 'nameValidate'
|
||||
| 'valueValidate'
|
||||
| 'forceUpdateKey'
|
||||
>;
|
||||
|
||||
const FormRow = memo(function FormRow({
|
||||
className,
|
||||
pairContainer,
|
||||
onChange,
|
||||
onDelete,
|
||||
onFocus,
|
||||
onMove,
|
||||
onEnd,
|
||||
isLast,
|
||||
forceUpdateKey,
|
||||
nameAutocomplete,
|
||||
valueAutocomplete,
|
||||
namePlaceholder,
|
||||
valuePlaceholder,
|
||||
nameValidate,
|
||||
valueValidate,
|
||||
}: FormRowProps) {
|
||||
const { id } = pairContainer;
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleChangeEnabled = useMemo(
|
||||
() => (enabled: boolean) => onChange({ id, pair: { ...pairContainer.pair, enabled } }),
|
||||
[id, onChange, pairContainer.pair],
|
||||
);
|
||||
|
||||
const handleChangeName = useMemo(
|
||||
() => (name: string) => onChange({ id, pair: { ...pairContainer.pair, name } }),
|
||||
[onChange, id, pairContainer.pair],
|
||||
);
|
||||
|
||||
const handleChangeValue = useMemo(
|
||||
() => (value: string) => onChange({ id, pair: { ...pairContainer.pair, value } }),
|
||||
[onChange, id, pairContainer.pair],
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(() => onFocus?.(pairContainer), [onFocus, pairContainer]);
|
||||
const handleDelete = useCallback(() => onDelete?.(pairContainer), [onDelete, pairContainer]);
|
||||
|
||||
const [, connectDrop] = useDrop<PairContainer>(
|
||||
{
|
||||
accept: ItemTypes.ROW,
|
||||
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(pairContainer.id, hoverClientY < hoverMiddleY ? 'above' : 'below');
|
||||
},
|
||||
},
|
||||
[onMove],
|
||||
);
|
||||
|
||||
const [, connectDrag] = useDrag(
|
||||
{
|
||||
type: ItemTypes.ROW,
|
||||
item: () => pairContainer,
|
||||
collect: (m) => ({ isDragging: m.isDragging() }),
|
||||
end: () => onEnd(pairContainer.id),
|
||||
},
|
||||
[pairContainer, onEnd],
|
||||
);
|
||||
|
||||
connectDrag(ref);
|
||||
connectDrop(ref);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={classnames(
|
||||
className,
|
||||
'group grid grid-cols-[auto_auto_minmax(0,1fr)_auto]',
|
||||
'grid-rows-1 items-center',
|
||||
!pairContainer.pair.enabled && 'opacity-60',
|
||||
)}
|
||||
>
|
||||
{!isLast ? (
|
||||
<div
|
||||
className={classnames(
|
||||
'py-2 h-7 w-3 flex items-center',
|
||||
'justify-center opacity-0 hover:opacity-100',
|
||||
)}
|
||||
>
|
||||
<Icon icon="drag" className="pointer-events-none" />
|
||||
</div>
|
||||
) : (
|
||||
<span className="w-3" />
|
||||
)}
|
||||
<Checkbox
|
||||
disabled={isLast}
|
||||
checked={isLast ? false : !!pairContainer.pair.enabled}
|
||||
className={classnames('mr-2', isLast && '!opacity-disabled')}
|
||||
onChange={handleChangeEnabled}
|
||||
/>
|
||||
<div
|
||||
className={classnames(
|
||||
'grid items-center',
|
||||
'@xs:gap-2 @xs:!grid-rows-1 @xs:!grid-cols-[minmax(0,1fr)_minmax(0,1fr)]',
|
||||
'gap-0.5 grid-cols-1 grid-rows-2',
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
hideLabel
|
||||
size="sm"
|
||||
require={!isLast && !!pairContainer.pair.enabled && !!pairContainer.pair.value}
|
||||
validate={nameValidate}
|
||||
useTemplating
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
containerClassName={classnames(isLast && 'border-dashed')}
|
||||
defaultValue={pairContainer.pair.name}
|
||||
label="Name"
|
||||
name="name"
|
||||
onChange={handleChangeName}
|
||||
onFocus={handleFocus}
|
||||
placeholder={namePlaceholder ?? 'name'}
|
||||
autocomplete={nameAutocomplete}
|
||||
/>
|
||||
<Input
|
||||
hideLabel
|
||||
size="sm"
|
||||
containerClassName={classnames(isLast && 'border-dashed')}
|
||||
validate={valueValidate}
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
defaultValue={pairContainer.pair.value}
|
||||
label="Value"
|
||||
name="value"
|
||||
onChange={handleChangeValue}
|
||||
onFocus={handleFocus}
|
||||
placeholder={valuePlaceholder ?? 'value'}
|
||||
useTemplating
|
||||
autocomplete={valueAutocomplete?.(pairContainer.pair.name)}
|
||||
/>
|
||||
</div>
|
||||
<IconButton
|
||||
aria-hidden={!onDelete}
|
||||
disabled={!onDelete}
|
||||
color="custom"
|
||||
icon={onDelete ? 'trash' : 'empty'}
|
||||
size="sm"
|
||||
title="Delete header"
|
||||
onClick={handleDelete}
|
||||
className="ml-0.5 !opacity-0 group-hover:!opacity-100 focus-visible:!opacity-100"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const newPairContainer = (pair?: Pair): PairContainer => {
|
||||
return { pair: pair ?? { name: '', value: '', enabled: true }, id: uuid() };
|
||||
};
|
||||
46
src-web/components/core/RadioDropdown.tsx
Normal file
46
src-web/components/core/RadioDropdown.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { DropdownItemSeparator, DropdownProps } from './Dropdown';
|
||||
import { Dropdown } from './Dropdown';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
export type RadioDropdownItem<T = string | null> =
|
||||
| {
|
||||
type?: 'default';
|
||||
label: string;
|
||||
shortLabel?: string;
|
||||
value: T;
|
||||
}
|
||||
| DropdownItemSeparator;
|
||||
|
||||
export interface RadioDropdownProps<T = string | null> {
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
items: RadioDropdownItem<T>[];
|
||||
children: DropdownProps['children'];
|
||||
}
|
||||
|
||||
export function RadioDropdown<T = string | null>({
|
||||
value,
|
||||
items,
|
||||
onChange,
|
||||
children,
|
||||
}: RadioDropdownProps<T>) {
|
||||
const dropdownItems = useMemo(
|
||||
() =>
|
||||
items.map((item) => {
|
||||
if (item.type === 'separator') {
|
||||
return item;
|
||||
} else {
|
||||
return {
|
||||
label: item.label,
|
||||
shortLabel: item.shortLabel,
|
||||
onSelect: () => onChange(item.value),
|
||||
leftSlot: <Icon icon={value === item.value ? 'check' : 'empty'} />,
|
||||
};
|
||||
}
|
||||
}),
|
||||
[items, value, onChange],
|
||||
);
|
||||
|
||||
return <Dropdown items={dropdownItems}>{children}</Dropdown>;
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import * as S from '@radix-ui/react-scroll-area';
|
||||
import classnames from 'classnames';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ScrollArea({ children, className }: Props) {
|
||||
return (
|
||||
<S.Root className={classnames(className, 'group/scroll')} type="always">
|
||||
<S.Viewport>{children}</S.Viewport>
|
||||
<ScrollBar orientation="vertical" />
|
||||
<ScrollBar orientation="horizontal" />
|
||||
<S.Corner />
|
||||
</S.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function ScrollBar({ orientation }: { orientation: 'vertical' | 'horizontal' }) {
|
||||
return (
|
||||
<S.Scrollbar
|
||||
orientation={orientation}
|
||||
className={classnames(
|
||||
'flex bg-transparent rounded-full',
|
||||
orientation === 'vertical' && 'w-1.5',
|
||||
orientation === 'horizontal' && 'h-1.5 flex-col',
|
||||
)}
|
||||
>
|
||||
<S.Thumb className="flex-1 bg-gray-100 group-hover/scroll:bg-gray-200 rounded-full" />
|
||||
</S.Scrollbar>
|
||||
);
|
||||
}
|
||||
29
src-web/components/core/Separator.tsx
Normal file
29
src-web/components/core/Separator.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import classnames from 'classnames';
|
||||
|
||||
interface Props {
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
variant?: 'primary' | 'secondary';
|
||||
className?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function Separator({
|
||||
className,
|
||||
variant = 'primary',
|
||||
orientation = 'horizontal',
|
||||
label,
|
||||
}: Props) {
|
||||
return (
|
||||
<div role="separator" className={classnames(className, 'flex items-center')}>
|
||||
{label && <div className="text-xs text-gray-500 mx-2 whitespace-nowrap">{label}</div>}
|
||||
<div
|
||||
className={classnames(
|
||||
variant === 'primary' && 'bg-highlight',
|
||||
variant === 'secondary' && 'bg-highlightSecondary',
|
||||
orientation === 'horizontal' && 'w-full h-[1px]',
|
||||
orientation === 'vertical' && 'h-full w-[1px]',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,95 +1,74 @@
|
||||
import classnames from 'classnames';
|
||||
import type { ComponentType, ReactNode } from 'react';
|
||||
import { Children, Fragment } from 'react';
|
||||
import type { ComponentType, ForwardedRef, HTMLAttributes, ReactNode } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
const spaceClassesX = {
|
||||
0: 'pr-0',
|
||||
1: 'pr-1',
|
||||
2: 'pr-2',
|
||||
3: 'pr-3',
|
||||
4: 'pr-4',
|
||||
5: 'pr-5',
|
||||
6: 'pr-6',
|
||||
};
|
||||
|
||||
const spaceClassesY = {
|
||||
0: 'pt-0',
|
||||
1: 'pt-1',
|
||||
2: 'pt-2',
|
||||
3: 'pt-3',
|
||||
4: 'pt-4',
|
||||
5: 'pt-5',
|
||||
6: 'pt-6',
|
||||
const gapClasses = {
|
||||
0: 'gap-0',
|
||||
0.5: 'gap-0.5',
|
||||
1: 'gap-1',
|
||||
2: 'gap-2',
|
||||
3: 'gap-3',
|
||||
4: 'gap-4',
|
||||
5: 'gap-5',
|
||||
6: 'gap-6',
|
||||
};
|
||||
|
||||
interface HStackProps extends BaseStackProps {
|
||||
space?: keyof typeof spaceClassesX;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function HStack({ className, space, children, ...props }: HStackProps) {
|
||||
export const HStack = forwardRef(function HStack(
|
||||
{ className, space, children, ...props }: HStackProps,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
ref: ForwardedRef<any>,
|
||||
) {
|
||||
return (
|
||||
<BaseStack className={classnames(className, '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')}
|
||||
data-spacer=""
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
{c}
|
||||
</Fragment>
|
||||
))
|
||||
: children}
|
||||
<BaseStack
|
||||
ref={ref}
|
||||
className={classnames(className, 'flex-row', space != null && gapClasses[space])}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</BaseStack>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export interface VStackProps extends BaseStackProps {
|
||||
space?: keyof typeof spaceClassesY;
|
||||
export type VStackProps = BaseStackProps & {
|
||||
children: ReactNode;
|
||||
}
|
||||
};
|
||||
|
||||
export function VStack({ className, space, children, ...props }: VStackProps) {
|
||||
export const VStack = forwardRef(function VStack(
|
||||
{ className, space, children, ...props }: VStackProps,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
ref: ForwardedRef<any>,
|
||||
) {
|
||||
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')}
|
||||
data-spacer=""
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
{c}
|
||||
</Fragment>
|
||||
))
|
||||
: children}
|
||||
<BaseStack
|
||||
ref={ref}
|
||||
className={classnames(className, 'flex-col', space != null && gapClasses[space])}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</BaseStack>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
interface BaseStackProps {
|
||||
as?: ComponentType | 'ul';
|
||||
type BaseStackProps = HTMLAttributes<HTMLElement> & {
|
||||
as?: ComponentType | 'ul' | 'form';
|
||||
space?: keyof typeof gapClasses;
|
||||
alignItems?: 'start' | 'center';
|
||||
justifyContent?: 'start' | 'center' | 'end';
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
};
|
||||
|
||||
function BaseStack({ className, alignItems, justifyContent, children, as }: BaseStackProps) {
|
||||
const BaseStack = forwardRef(function BaseStack(
|
||||
{ className, alignItems, justifyContent, children, as, ...props }: BaseStackProps,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
ref: ForwardedRef<any>,
|
||||
) {
|
||||
const Component = as ?? 'div';
|
||||
return (
|
||||
<Component
|
||||
ref={ref}
|
||||
className={classnames(
|
||||
className,
|
||||
'flex',
|
||||
@@ -99,8 +78,9 @@ function BaseStack({ className, alignItems, justifyContent, children, as }: Base
|
||||
justifyContent === 'center' && 'justify-center',
|
||||
justifyContent === 'end' && 'justify-end',
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
.tab-content {
|
||||
&[data-state="inactive"] {
|
||||
@apply hidden;
|
||||
}
|
||||
}
|
||||
@@ -1,71 +1,134 @@
|
||||
import * as T from '@radix-ui/react-tabs';
|
||||
import classnames from 'classnames';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { memo, useCallback, useEffect, useRef } from 'react';
|
||||
import { Button } from '../Button';
|
||||
import { ScrollArea } from '../ScrollArea';
|
||||
import { Icon } from '../Icon';
|
||||
import type { RadioDropdownProps } from '../RadioDropdown';
|
||||
import { RadioDropdown } from '../RadioDropdown';
|
||||
import { HStack } from '../Stacks';
|
||||
|
||||
import './Tabs.css';
|
||||
export type TabItem =
|
||||
| {
|
||||
value: string;
|
||||
label: ReactNode;
|
||||
}
|
||||
| {
|
||||
value: string;
|
||||
options: Omit<RadioDropdownProps, 'children'>;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
defaultValue?: string;
|
||||
label: string;
|
||||
tabs: { value: string; label: ReactNode }[];
|
||||
value?: string;
|
||||
onChangeValue: (value: string) => void;
|
||||
tabs: TabItem[];
|
||||
tabListClassName?: string;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function Tabs({ defaultValue, label, children, tabs, className, tabListClassName }: Props) {
|
||||
const [value, setValue] = useState(defaultValue);
|
||||
export function Tabs({
|
||||
value,
|
||||
onChangeValue,
|
||||
label,
|
||||
children,
|
||||
tabs,
|
||||
className,
|
||||
tabListClassName,
|
||||
}: Props) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const handleTabChange = useCallback(
|
||||
(value: string) => {
|
||||
const tabs = ref.current?.querySelectorAll<HTMLDivElement>(`[data-tab]`);
|
||||
for (const tab of tabs ?? []) {
|
||||
const v = tab.getAttribute('data-tab');
|
||||
if (v === value) {
|
||||
tab.setAttribute('tabindex', '-1');
|
||||
tab.setAttribute('data-state', 'active');
|
||||
tab.setAttribute('aria-hidden', 'false');
|
||||
tab.style.display = 'block';
|
||||
} else {
|
||||
tab.setAttribute('data-state', 'inactive');
|
||||
tab.setAttribute('aria-hidden', 'true');
|
||||
tab.style.display = 'none';
|
||||
}
|
||||
}
|
||||
onChangeValue(value);
|
||||
},
|
||||
[onChangeValue],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (value === undefined) return;
|
||||
handleTabChange(value);
|
||||
}, [handleTabChange, value]);
|
||||
|
||||
return (
|
||||
<T.Root
|
||||
defaultValue={defaultValue}
|
||||
onValueChange={setValue}
|
||||
<div
|
||||
ref={ref}
|
||||
className={classnames(className, 'h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1')}
|
||||
>
|
||||
<T.List
|
||||
<div
|
||||
aria-label={label}
|
||||
className={classnames(
|
||||
tabListClassName,
|
||||
'h-auto flex items-center overflow-x-auto mb-1 pb-1',
|
||||
'flex items-center overflow-x-auto hide-scrollbars mt-1 mb-2',
|
||||
// Give space for button focus states within overflow boundary.
|
||||
'px-2 -mx-2',
|
||||
)}
|
||||
>
|
||||
{/*<ScrollArea className="w-full pb-2">*/}
|
||||
<HStack space={1}>
|
||||
{tabs.map((t) => (
|
||||
<TabTrigger key={t.value} value={t.value} active={t.value === value}>
|
||||
{t.label}
|
||||
</TabTrigger>
|
||||
))}
|
||||
<HStack space={1} className="flex-shrink-0">
|
||||
{tabs.map((t) => {
|
||||
const isActive = t.value === value;
|
||||
// const btnClassName = classnames(isActive ? 'bg-highlightSecondary' : 'text-gray-600');
|
||||
const btnClassName = classnames(isActive ? '' : 'text-gray-600', '!px-0 mr-4 ml-[1px]');
|
||||
|
||||
if ('options' in t) {
|
||||
const option = t.options.items.find(
|
||||
(i) => 'value' in i && i.value === t.options?.value,
|
||||
);
|
||||
return (
|
||||
<RadioDropdown
|
||||
key={t.value}
|
||||
items={t.options.items}
|
||||
value={t.options.value}
|
||||
onChange={t.options.onChange}
|
||||
>
|
||||
<Button
|
||||
color="custom"
|
||||
size="sm"
|
||||
onClick={isActive ? undefined : () => handleTabChange(t.value)}
|
||||
className={btnClassName}
|
||||
>
|
||||
{option && 'shortLabel' in option
|
||||
? option.shortLabel
|
||||
: option?.label ?? 'Unknown'}
|
||||
<Icon
|
||||
icon="triangleDown"
|
||||
className={classnames('-mr-1.5', isActive ? 'opacity-100' : 'opacity-20')}
|
||||
/>
|
||||
</Button>
|
||||
</RadioDropdown>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Button
|
||||
key={t.value}
|
||||
color="custom"
|
||||
size="sm"
|
||||
onClick={() => handleTabChange(t.value)}
|
||||
className={btnClassName}
|
||||
>
|
||||
{t.label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</HStack>
|
||||
{/*</ScrollArea>*/}
|
||||
</T.List>
|
||||
</div>
|
||||
{children}
|
||||
</T.Root>
|
||||
);
|
||||
}
|
||||
|
||||
interface TabTriggerProps {
|
||||
value: string;
|
||||
children: ReactNode;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export function TabTrigger({ value, children, active }: TabTriggerProps) {
|
||||
return (
|
||||
<T.Trigger value={value} asChild>
|
||||
<Button
|
||||
color="custom"
|
||||
size="sm"
|
||||
className={classnames(
|
||||
active ? 'bg-gray-100 text-gray-900' : 'text-gray-600 hover:text-gray-900',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
</T.Trigger>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -75,14 +138,18 @@ interface TabContentProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TabContent({ value, children, className }: TabContentProps) {
|
||||
export const TabContent = memo(function TabContent({
|
||||
value,
|
||||
children,
|
||||
className,
|
||||
}: TabContentProps) {
|
||||
return (
|
||||
<T.Content
|
||||
forceMount
|
||||
value={value}
|
||||
className={classnames(className, 'tab-content', 'w-full h-full overflow-auto')}
|
||||
<div
|
||||
tabIndex={-1}
|
||||
data-tab={value}
|
||||
className={classnames(className, 'tab-content', 'overflow-auto hidden w-full h-full')}
|
||||
>
|
||||
{children}
|
||||
</T.Content>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ export function Webview({ body, url, contentType }: Props) {
|
||||
return body.replace(/<head>/gi, `<head><base href="${url}"/>`);
|
||||
}
|
||||
return body;
|
||||
}, [body, contentType]);
|
||||
}, [url, body, contentType]);
|
||||
|
||||
return (
|
||||
<div className="px-2 pb-2">
|
||||
|
||||
@@ -10,7 +10,7 @@ export function WindowDragRegion({ className, ...props }: Props) {
|
||||
return (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className={classnames(className, 'w-full h-12 flex-shrink-0')}
|
||||
className={classnames(className, 'w-full flex-shrink-0')}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
48
src-web/hooks/Confirm.tsx
Normal file
48
src-web/hooks/Confirm.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { ButtonProps } from '../components/core/Button';
|
||||
import { Button } from '../components/core/Button';
|
||||
import { HStack } from '../components/core/Stacks';
|
||||
|
||||
export interface ConfirmProps {
|
||||
hide: () => void;
|
||||
onResult: (result: boolean) => void;
|
||||
variant?: 'delete' | 'confirm';
|
||||
}
|
||||
|
||||
const colors: Record<NonNullable<ConfirmProps['variant']>, ButtonProps['color']> = {
|
||||
delete: 'danger',
|
||||
confirm: 'primary',
|
||||
};
|
||||
|
||||
const confirmButtonTexts: Record<NonNullable<ConfirmProps['variant']>, string> = {
|
||||
delete: 'Delete',
|
||||
confirm: 'Confirm',
|
||||
};
|
||||
|
||||
export function Confirm({ hide, onResult, variant = 'confirm' }: ConfirmProps) {
|
||||
const focusRef = (el: HTMLButtonElement | null) => {
|
||||
setTimeout(() => {
|
||||
el?.focus();
|
||||
});
|
||||
};
|
||||
|
||||
const handleHide = () => {
|
||||
onResult(false);
|
||||
hide();
|
||||
};
|
||||
|
||||
const handleSuccess = () => {
|
||||
onResult(true);
|
||||
hide();
|
||||
};
|
||||
|
||||
return (
|
||||
<HStack space={2} justifyContent="end">
|
||||
<Button className="focus" color="gray" onClick={handleHide}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="focus" ref={focusRef} color={colors[variant]} onClick={handleSuccess}>
|
||||
{confirmButtonTexts[variant]}
|
||||
</Button>
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,9 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import type { HttpRequest } from '../lib/models';
|
||||
import { useActiveRequestId } from './useActiveRequestId';
|
||||
import { useRequests } from './useRequests';
|
||||
|
||||
export function useActiveRequest(): HttpRequest | null {
|
||||
const params = useParams<{ requestId?: string }>();
|
||||
const requestId = useActiveRequestId();
|
||||
const requests = useRequests();
|
||||
const [activeRequest, setActiveRequest] = useState<HttpRequest | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (requests.length === 0) {
|
||||
setActiveRequest(null);
|
||||
} else {
|
||||
setActiveRequest(requests.find((r) => r.id === params.requestId) ?? null);
|
||||
}
|
||||
}, [requests, params.requestId]);
|
||||
|
||||
return activeRequest;
|
||||
return requests.find((r) => r.id === requestId) ?? null;
|
||||
}
|
||||
|
||||
7
src-web/hooks/useActiveRequestId.ts
Normal file
7
src-web/hooks/useActiveRequestId.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import type { RouteParamsRequest } from './useRoutes';
|
||||
|
||||
export function useActiveRequestId(): string | null {
|
||||
const { requestId } = useParams<RouteParamsRequest>();
|
||||
return requestId ?? null;
|
||||
}
|
||||
@@ -1,20 +1,13 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useMemo } from 'react';
|
||||
import type { Workspace } from '../lib/models';
|
||||
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
|
||||
import { useWorkspaces } from './useWorkspaces';
|
||||
|
||||
export function useActiveWorkspace(): Workspace | null {
|
||||
const params = useParams<{ workspaceId?: string }>();
|
||||
const workspaceId = useActiveWorkspaceId();
|
||||
const workspaces = useWorkspaces();
|
||||
const [activeWorkspace, setActiveWorkspace] = useState<Workspace | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (workspaces.length === 0) {
|
||||
setActiveWorkspace(null);
|
||||
} else {
|
||||
setActiveWorkspace(workspaces.find((w) => w.id === params.workspaceId) ?? null);
|
||||
}
|
||||
}, [workspaces, params.workspaceId]);
|
||||
|
||||
return activeWorkspace;
|
||||
return useMemo(
|
||||
() => workspaces.find((w) => w.id === workspaceId) ?? null,
|
||||
[workspaces, workspaceId],
|
||||
);
|
||||
}
|
||||
|
||||
7
src-web/hooks/useActiveWorkspaceId.ts
Normal file
7
src-web/hooks/useActiveWorkspaceId.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import type { RouteParamsWorkspace } from './useRoutes';
|
||||
|
||||
export function useActiveWorkspaceId(): string | null {
|
||||
const { workspaceId } = useParams<RouteParamsWorkspace>();
|
||||
return workspaceId ?? null;
|
||||
}
|
||||
26
src-web/hooks/useConfirm.ts
Normal file
26
src-web/hooks/useConfirm.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { DialogProps } from '../components/core/Dialog';
|
||||
import { useDialog } from '../components/DialogContext';
|
||||
import type { ConfirmProps } from './Confirm';
|
||||
import { Confirm } from './Confirm';
|
||||
|
||||
export function useConfirm() {
|
||||
const dialog = useDialog();
|
||||
return ({
|
||||
title,
|
||||
description,
|
||||
variant,
|
||||
}: {
|
||||
title: DialogProps['title'];
|
||||
description?: DialogProps['description'];
|
||||
variant: ConfirmProps['variant'];
|
||||
}) =>
|
||||
new Promise((onResult: ConfirmProps['onResult']) => {
|
||||
dialog.show({
|
||||
title,
|
||||
description,
|
||||
hideX: true,
|
||||
size: 'sm',
|
||||
render: ({ hide }) => Confirm({ hide, variant, onResult }),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,23 +1,37 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { invoke } from '@tauri-apps/api';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { HttpRequest } from '../lib/models';
|
||||
import { useActiveWorkspace } from './useActiveWorkspace';
|
||||
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
|
||||
import { requestsQueryKey, useRequests } from './useRequests';
|
||||
import { useRoutes } from './useRoutes';
|
||||
|
||||
export function useCreateRequest({ navigateAfter }: { navigateAfter: boolean }) {
|
||||
const workspace = useActiveWorkspace();
|
||||
const navigate = useNavigate();
|
||||
return useMutation<string, unknown, Pick<HttpRequest, 'name'>>({
|
||||
mutationFn: async (patch) => {
|
||||
if (workspace === null) {
|
||||
const workspaceId = useActiveWorkspaceId();
|
||||
const routes = useRoutes();
|
||||
const requests = useRequests();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<HttpRequest, unknown, Partial<Pick<HttpRequest, 'name' | 'sortPriority'>>>({
|
||||
mutationFn: (patch) => {
|
||||
if (workspaceId === null) {
|
||||
throw new Error("Cannot create request when there's no active workspace");
|
||||
}
|
||||
return invoke('create_request', { ...patch, workspaceId: workspace?.id });
|
||||
const sortPriority = maxSortPriority(requests) + 1000;
|
||||
return invoke('create_request', { sortPriority, workspaceId, ...patch });
|
||||
},
|
||||
onSuccess: async (requestId) => {
|
||||
onSuccess: async (request) => {
|
||||
queryClient.setQueryData<HttpRequest[]>(
|
||||
requestsQueryKey({ workspaceId: request.workspaceId }),
|
||||
(requests) => [...(requests ?? []), request],
|
||||
);
|
||||
if (navigateAfter) {
|
||||
navigate(`/workspaces/${workspace?.id}/requests/${requestId}`);
|
||||
routes.navigate('request', { workspaceId: request.workspaceId, requestId: request.id });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function maxSortPriority(requests: HttpRequest[]) {
|
||||
if (requests.length === 0) return 1000;
|
||||
return Math.max(...requests.map((r) => r.sortPriority));
|
||||
}
|
||||
|
||||
24
src-web/hooks/useCreateWorkspace.ts
Normal file
24
src-web/hooks/useCreateWorkspace.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { invoke } from '@tauri-apps/api';
|
||||
import type { Workspace } from '../lib/models';
|
||||
import { useRoutes } from './useRoutes';
|
||||
import { workspacesQueryKey } from './useWorkspaces';
|
||||
|
||||
export function useCreateWorkspace({ navigateAfter }: { navigateAfter: boolean }) {
|
||||
const routes = useRoutes();
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<Workspace, unknown, Pick<Workspace, 'name'>>({
|
||||
mutationFn: (patch) => {
|
||||
return invoke('create_workspace', patch);
|
||||
},
|
||||
onSuccess: async (workspace) => {
|
||||
queryClient.setQueryData<Workspace[]>(workspacesQueryKey({}), (workspaces) => [
|
||||
...(workspaces ?? []),
|
||||
workspace,
|
||||
]);
|
||||
if (navigateAfter) {
|
||||
routes.navigate('workspace', { workspaceId: workspace.id });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
12
src-web/hooks/useDebouncedSetState.ts
Normal file
12
src-web/hooks/useDebouncedSetState.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { debounce } from '../lib/debounce';
|
||||
|
||||
export function useDebouncedSetState<T>(
|
||||
defaultValue: T,
|
||||
delay?: number,
|
||||
): [T, Dispatch<SetStateAction<T>>] {
|
||||
const [state, setState] = useState<T>(defaultValue);
|
||||
const debouncedSetState = useMemo(() => debounce(setState, delay), [delay]);
|
||||
return [state, debouncedSetState];
|
||||
}
|
||||
8
src-web/hooks/useDebouncedValue.ts
Normal file
8
src-web/hooks/useDebouncedValue.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useDebouncedSetState } from './useDebouncedSetState';
|
||||
|
||||
export function useDebouncedValue<T>(value: T, delay?: number) {
|
||||
const [state, setState] = useDebouncedSetState<T>(value, delay);
|
||||
useEffect(() => setState(value), [setState, value]);
|
||||
return state;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user