Compare commits
71 Commits
v2023.0.16
...
v2023.1.7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32b135dbaf | ||
|
|
0fc8d12a06 | ||
|
|
3c2bdab101 | ||
|
|
8b5d7ae3ed | ||
|
|
51949f4fbf | ||
|
|
6013cd2329 | ||
|
|
eba28ade48 | ||
|
|
44af1ddc8a | ||
|
|
63c0d09df8 | ||
|
|
f305633d94 | ||
|
|
13155f8591 | ||
|
|
f2ac97aa62 | ||
|
|
18eb0027a1 | ||
|
|
9e2803fcfb | ||
|
|
705e30b6e0 | ||
|
|
f1260911ea | ||
|
|
076ff63dbe | ||
|
|
899092b4d2 | ||
|
|
c2c3a28aab | ||
|
|
25c0db502e | ||
|
|
6dcbe45a53 | ||
|
|
e2b46f25ff | ||
|
|
981182be46 | ||
|
|
ad164ebd5e | ||
|
|
cacdad8826 | ||
|
|
77e5142a7c | ||
|
|
613081728d | ||
|
|
23e77dfec1 | ||
|
|
6e273ae2a3 | ||
|
|
4061094988 | ||
|
|
82b185e27f | ||
|
|
27dc261639 | ||
|
|
7e45fecf19 | ||
|
|
1a5053380b | ||
|
|
408665c62d | ||
|
|
65efee2048 | ||
|
|
3faa66a1fc | ||
|
|
9dafe4f704 | ||
|
|
356eaf1713 | ||
|
|
f8584f1537 | ||
|
|
6ad6cb34b0 | ||
|
|
32b27cd780 | ||
|
|
0344a1e8c9 | ||
|
|
0515271c12 | ||
|
|
5ae8d54ce0 | ||
|
|
33c406ce49 | ||
|
|
3b660ddbd0 | ||
|
|
3132728a27 | ||
|
|
7063128342 | ||
|
|
2187775462 | ||
|
|
18adcd1004 | ||
|
|
b0656d1e38 | ||
|
|
38e66047e0 | ||
|
|
c24f049dac | ||
|
|
53d13c8172 | ||
|
|
0727c6e437 | ||
|
|
8328d20150 | ||
|
|
afe6a3bf57 | ||
|
|
d920632cbd | ||
|
|
5c456fd4d5 | ||
|
|
38c247e350 | ||
|
|
0c8f72124a | ||
|
|
80ed6b1525 | ||
|
|
4424b3f208 | ||
|
|
2c75abce09 | ||
|
|
4e15eb197f | ||
|
|
a7544b4f8c | ||
|
|
d126aad172 | ||
|
|
acc5c0de50 | ||
|
|
3391da111d | ||
|
|
e37ce96956 |
3
.github/workflows/artifacts.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
||||
include:
|
||||
- os: macos-12
|
||||
target: aarch64-apple-darwin
|
||||
- os: macos-12
|
||||
- os: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
- os: windows-2022
|
||||
target: x86_64-pc-windows-msvc
|
||||
@@ -58,6 +58,7 @@ jobs:
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
with:
|
||||
tagName: 'v__VERSION__'
|
||||
|
||||
1
.gitignore
vendored
@@ -22,5 +22,6 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
.eslintcache
|
||||
|
||||
*.sqlite
|
||||
|
||||
1
.husky/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
_
|
||||
4
.husky/pre-commit
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
npx lint-staged
|
||||
5
.sqllsrc.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "yaak-dev",
|
||||
"adapter": "sqlite3",
|
||||
"filename": "src-tauri/db.sqlite"
|
||||
}
|
||||
7
Makefile
Normal file
@@ -0,0 +1,7 @@
|
||||
.PHONY: sqlx-prepare, dev
|
||||
|
||||
sqlx-prepare:
|
||||
cd src-tauri && cargo sqlx prepare --database-url 'sqlite://db.sqlite'
|
||||
|
||||
dev:
|
||||
npm run tauri-dev
|
||||
@@ -10,7 +10,7 @@ npm run tauri-dev
|
||||
|
||||
# Migration commands
|
||||
cd src-tauri
|
||||
cargo sqlx migrate add <name>
|
||||
cargo sqlx migrate add ${MIGRATION_NAME}
|
||||
cargo sqlx migrate run --database-url 'sqlite://db.sqlite?mode=rw'
|
||||
cargo sqlx prepare --database-url 'sqlite://db.sqlite'
|
||||
```
|
||||
|
||||
9751
package-lock.json
generated
18
package.json
@@ -13,11 +13,11 @@
|
||||
"build:icon": "tauri icon src-tauri/icons/icon.png",
|
||||
"build:frontend": "vite build",
|
||||
"test": "vitest",
|
||||
"coverage": "vitest run --coverage"
|
||||
"coverage": "vitest run --coverage",
|
||||
"prepare": "husky install"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "^6.2.1",
|
||||
"@codemirror/lang-html": "^6.4.2",
|
||||
"@codemirror/lang-javascript": "^6.1.4",
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
"@codemirror/lang-xml": "^6.0.2",
|
||||
@@ -33,11 +33,10 @@
|
||||
"@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",
|
||||
"@tauri-apps/api": "^1.5.1",
|
||||
"buffer": "^6.0.3",
|
||||
"classnames": "^2.3.2",
|
||||
"cm6-graphql": "^0.0.4-canary-b30a2325.0",
|
||||
"cm6-graphql": "^0.0.9",
|
||||
"codemirror": "^6.0.1",
|
||||
"focus-trap-react": "^10.1.1",
|
||||
"format-graphql": "^1.4.0",
|
||||
@@ -55,7 +54,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/nesting": "^0.0.0-insiders.565cd3e",
|
||||
"@tauri-apps/cli": "^1.2.2",
|
||||
"@tauri-apps/cli": "^1.5.4",
|
||||
"@types/node": "^18.7.10",
|
||||
"@types/papaparse": "^5.3.7",
|
||||
"@types/parse-color": "^1.0.1",
|
||||
@@ -65,6 +64,7 @@
|
||||
"@types/uuid": "^9.0.1",
|
||||
"@typescript-eslint/eslint-plugin": "^5.57.0",
|
||||
"@typescript-eslint/parser": "^5.57.0",
|
||||
"@vitejs/plugin-react": "^3.1.0",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"eslint": "^8.34.0",
|
||||
"eslint-config-prettier": "^8.6.0",
|
||||
@@ -72,6 +72,8 @@
|
||||
"eslint-plugin-jsx-a11y": "^6.7.1",
|
||||
"eslint-plugin-react": "^7.32.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"husky": "^8.0.3",
|
||||
"lint-staged": "^15.0.2",
|
||||
"postcss": "^8.4.21",
|
||||
"postcss-nesting": "^11.2.1",
|
||||
"prettier": "^2.8.4",
|
||||
@@ -81,5 +83,9 @@
|
||||
"vite-plugin-svgr": "^2.4.0",
|
||||
"vite-plugin-top-level-await": "^1.2.4",
|
||||
"vitest": "^0.29.2"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{ts,tsx}": "eslint --cache --fix",
|
||||
"*.{js,css,md}": "prettier --write"
|
||||
}
|
||||
}
|
||||
|
||||
1
rustfmt.toml
Normal file
@@ -0,0 +1 @@
|
||||
edition = "2018"
|
||||
3126
src-tauri/Cargo.lock
generated
@@ -15,23 +15,24 @@ tauri-build = { version = "1.2", features = [] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc = "0.2.7"
|
||||
cocoa = "0.24.1"
|
||||
cocoa = "0.25.0"
|
||||
|
||||
[dependencies]
|
||||
serde_json = { version = "1.0", features = ["raw_value"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
tauri = { version = "1.2", features = ["config-toml", "devtools", "fs-read-file", "os-all", "protocol-asset", "shell-open", "system-tray", "updater", "window-start-dragging"] }
|
||||
http = "0.2.8"
|
||||
reqwest = { version = "0.11.14", features = ["json"] }
|
||||
tokio = { version = "1.25.0", features = ["sync"] }
|
||||
futures = "0.3.26"
|
||||
deno_core = "0.179.0"
|
||||
deno_ast = { version = "0.25.0", features = ["transpiling"] }
|
||||
sqlx = { version = "0.6.2", features = ["sqlite", "runtime-tokio-rustls", "json", "chrono", "time", "offline"] }
|
||||
uuid = "1.3.0"
|
||||
rand = "0.8.5"
|
||||
chrono = { version = "0.4.23", features = ["serde"] }
|
||||
base64 = "0.21.0"
|
||||
boa_engine = "0.17.3"
|
||||
boa_runtime = "0.17.3"
|
||||
chrono = { version = "0.4.23", features = ["serde"] }
|
||||
futures = "0.3.26"
|
||||
http = "0.2.8"
|
||||
rand = "0.8.5"
|
||||
reqwest = { version = "0.11.14", features = ["json"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = { version = "1.0", features = ["raw_value"] }
|
||||
sqlx = { version = "0.6.2", features = ["sqlite", "runtime-tokio-rustls", "json", "chrono", "time", "offline"] }
|
||||
tauri = { version = "1.3", features = ["config-toml", "devtools", "fs-read-file", "os-all", "protocol-asset", "shell-open", "system-tray", "updater", "window-start-dragging"] }
|
||||
tauri-plugin-window-state = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
|
||||
tokio = { version = "1.25.0", features = ["sync"] }
|
||||
uuid = "1.3.0"
|
||||
|
||||
[features]
|
||||
# by default Tauri runs in production mode
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB |
15
src-tauri/migrations/20231022205109_environments.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE environments
|
||||
(
|
||||
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,
|
||||
workspace_id TEXT NOT NULL
|
||||
REFERENCES workspaces
|
||||
ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
data TEXT NOT NULL
|
||||
DEFAULT '{}'
|
||||
);
|
||||
2
src-tauri/migrations/20231028161007_variables.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE environments DROP COLUMN data;
|
||||
ALTER TABLE environments ADD COLUMN variables DEFAULT '[]' NOT NULL;
|
||||
3
src-tauri/plugins/hello-world/hello.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export function hello() {
|
||||
sayHello('Plugin');
|
||||
}
|
||||
7
src-tauri/plugins/hello-world/index.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { hello } from './hello.js';
|
||||
|
||||
export function entrypoint() {
|
||||
hello();
|
||||
console.log('Try JSON parse', JSON.parse(`{ "hello": 123 }`).hello);
|
||||
console.log('Try RegExp', '123'.match(/[\d]+/));
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
Deno.core.opAsync('op_hello', 'Deno');
|
||||
@@ -160,6 +160,16 @@
|
||||
},
|
||||
"query": "\n SELECT id, model, workspace_id, request_id, updated_at, created_at, url,\n status, status_reason, content_length, body, body_path, elapsed, error,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpResponseHeader>>\"\n FROM http_responses\n WHERE workspace_id = ?\n ORDER BY created_at DESC\n "
|
||||
},
|
||||
"3ec4710d28a7f38608c96798d971217ac97788bcb639089d0c5750c0d339bc9a": {
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
}
|
||||
},
|
||||
"query": "\n UPDATE environments\n SET (name, variables, updated_at) = (?, ?, CURRENT_TIMESTAMP)\n WHERE id = ?;\n "
|
||||
},
|
||||
"448a1d1f1866ab42c0f81fcf8eb2930bf21dfdd43ca4831bc1a198cf45ac3732": {
|
||||
"describe": {
|
||||
"columns": [],
|
||||
@@ -282,6 +292,60 @@
|
||||
},
|
||||
"query": "\n UPDATE http_responses SET (\n elapsed,\n url,\n status,\n status_reason,\n content_length,\n body,\n body_path,\n error,\n headers,\n updated_at\n ) = (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) WHERE id = ?;\n "
|
||||
},
|
||||
"689bcc92b914f50c14921faa796c07a256deb84c832fc3d90200b393fb159417": {
|
||||
"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": "variables!: sqlx::types::Json<Vec<EnvironmentVariable>>",
|
||||
"ordinal": 6,
|
||||
"type_info": "Null"
|
||||
}
|
||||
],
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
}
|
||||
},
|
||||
"query": "\n SELECT\n id,\n model,\n workspace_id,\n created_at,\n updated_at,\n name,\n variables AS \"variables!: sqlx::types::Json<Vec<EnvironmentVariable>>\"\n FROM environments\n WHERE id = ?\n "
|
||||
},
|
||||
"6f0cb5a6d1e8dbc8cdfcc3c7e7944b2c83c22cb795b9d6b98fe067dabec9680b": {
|
||||
"describe": {
|
||||
"columns": [
|
||||
@@ -388,6 +452,16 @@
|
||||
},
|
||||
"query": "\n DELETE FROM workspaces\n WHERE id = ?\n "
|
||||
},
|
||||
"86e32d6a6fadf35436f19b577a659c203a8d143cb3a8d6122951c5bf54a0888d": {
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 4
|
||||
}
|
||||
},
|
||||
"query": "\n INSERT INTO environments (id, workspace_id, name, variables)\n VALUES (?, ?, ?, ?)\n "
|
||||
},
|
||||
"8947a2a90478277c42fe9b06bc1fa98197642a4d281a3dbc101be2c9c1fec36c": {
|
||||
"describe": {
|
||||
"columns": [],
|
||||
@@ -398,6 +472,16 @@
|
||||
},
|
||||
"query": "\n INSERT INTO http_responses (\n id,\n request_id,\n workspace_id,\n elapsed,\n url,\n status,\n status_reason,\n content_length,\n body,\n body_path,\n headers\n )\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);\n "
|
||||
},
|
||||
"aeb0712785a9964d516dc8939bc54aa8206ad852e608b362d014b67a0f21b0ed": {
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
}
|
||||
},
|
||||
"query": "\n DELETE FROM environments\n WHERE id = ?\n "
|
||||
},
|
||||
"b19c275180909a39342b13c3cdcf993781636913ae590967f5508c46a56dc961": {
|
||||
"describe": {
|
||||
"columns": [],
|
||||
@@ -408,6 +492,60 @@
|
||||
},
|
||||
"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 "
|
||||
},
|
||||
"ba2b34a77723f24f86e4c3c45274dbfec6ca130e16e592f948844c037bdc0593": {
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "workspace_id",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "model",
|
||||
"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": "variables!: sqlx::types::Json<Vec<EnvironmentVariable>>",
|
||||
"ordinal": 6,
|
||||
"type_info": "Null"
|
||||
}
|
||||
],
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
}
|
||||
},
|
||||
"query": "\n SELECT id, workspace_id, model, created_at, updated_at, name,\n variables AS \"variables!: sqlx::types::Json<Vec<EnvironmentVariable>>\"\n FROM environments\n WHERE workspace_id = ?\n "
|
||||
},
|
||||
"c23c61b05a4c9e04ab0c1fc2c579d6f2a82a37aeed8addf9861b4985f2a5422e": {
|
||||
"describe": {
|
||||
"columns": [
|
||||
|
||||
@@ -7,11 +7,6 @@
|
||||
#[macro_use]
|
||||
extern crate objc;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::env::current_dir;
|
||||
use std::fs::{create_dir_all, File};
|
||||
use std::io::Write;
|
||||
|
||||
use base64::Engine;
|
||||
use http::header::{HeaderName, ACCEPT, USER_AGENT};
|
||||
use http::{HeaderMap, HeaderValue, Method};
|
||||
@@ -22,20 +17,22 @@ use sqlx::migrate::Migrator;
|
||||
use sqlx::sqlite::SqlitePoolOptions;
|
||||
use sqlx::types::Json;
|
||||
use sqlx::{Pool, Sqlite};
|
||||
use tauri::regex::Regex;
|
||||
use std::collections::HashMap;
|
||||
use std::env::current_dir;
|
||||
use std::fs::{create_dir_all, File};
|
||||
use std::io::Write;
|
||||
#[cfg(target_os = "macos")]
|
||||
use tauri::TitleBarStyle;
|
||||
use tauri::{AppHandle, Menu, MenuItem, RunEvent, State, Submenu, Window, WindowUrl, Wry};
|
||||
use tauri::{CustomMenuItem, Manager, WindowEvent};
|
||||
use tauri_plugin_window_state::{StateFlags, WindowExt};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use window_ext::WindowExt;
|
||||
|
||||
use crate::models::generate_id;
|
||||
use window_ext::TrafficLightWindowExt;
|
||||
|
||||
mod models;
|
||||
mod runtime;
|
||||
mod render;
|
||||
mod window_ext;
|
||||
mod plugin;
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct CustomResponse {
|
||||
@@ -68,43 +65,34 @@ async fn migrate_db(
|
||||
#[tauri::command]
|
||||
async fn send_ephemeral_request(
|
||||
request: models::HttpRequest,
|
||||
environment_id: Option<&str>,
|
||||
app_handle: AppHandle<Wry>,
|
||||
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||
) -> Result<models::HttpResponse, String> {
|
||||
let pool = &*db_instance.lock().await;
|
||||
let response = models::HttpResponse::default();
|
||||
return actually_send_ephemeral_request(request, &response, &app_handle, pool).await;
|
||||
let environment_id2 = environment_id.unwrap_or("n/a").to_string();
|
||||
return actually_send_ephemeral_request(request, &response, &environment_id2, &app_handle, pool)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn actually_send_ephemeral_request(
|
||||
request: models::HttpRequest,
|
||||
response: &models::HttpResponse,
|
||||
environment_id: &str,
|
||||
app_handle: &AppHandle<Wry>,
|
||||
pool: &Pool<Sqlite>,
|
||||
) -> Result<models::HttpResponse, String> {
|
||||
let start = std::time::Instant::now();
|
||||
let mut url_string = request.url.to_string();
|
||||
let environment = models::get_environment(environment_id, pool).await.ok();
|
||||
let environment_ref = environment.as_ref();
|
||||
|
||||
let variables: HashMap<&str, &str> = HashMap::new();
|
||||
// variables.insert("", "");
|
||||
|
||||
let re = Regex::new(r"\$\{\[\s*([^]\s]+)\s*]}").expect("Failed to create regex");
|
||||
url_string = re
|
||||
.replace(&url_string, |caps: &tauri::regex::Captures| {
|
||||
let key = caps.get(1).unwrap().as_str();
|
||||
match variables.get(key) {
|
||||
Some(v) => v,
|
||||
None => "",
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
let mut url_string = render::render(&request.url, environment.as_ref());
|
||||
|
||||
if !url_string.starts_with("http://") && !url_string.starts_with("https://") {
|
||||
url_string = format!("http://{}", url_string);
|
||||
}
|
||||
|
||||
println!("Sending request to {}", url_string);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.redirect(Policy::none())
|
||||
// .danger_accept_invalid_certs(true)
|
||||
@@ -119,57 +107,62 @@ async fn actually_send_ephemeral_request(
|
||||
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()) {
|
||||
|
||||
let name = render::render(&h.name, environment_ref);
|
||||
let value = render::render(&h.value, environment_ref);
|
||||
|
||||
let header_name = match HeaderName::from_bytes(name.as_bytes()) {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to create header name: {}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let header_value = match HeaderValue::from_str(h.value.as_str()) {
|
||||
let header_value = match HeaderValue::from_str(value.as_str()) {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to create header value: {}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
headers.insert(header_name, header_value);
|
||||
}
|
||||
|
||||
if let Some(b) = &request.authentication_type {
|
||||
let empty_value = &serde_json::to_value("").unwrap();
|
||||
let a = request.authentication.0;
|
||||
|
||||
if b == "basic" {
|
||||
let 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 raw_username = a
|
||||
.get("username")
|
||||
.unwrap_or(empty_value)
|
||||
.as_str()
|
||||
.unwrap_or("");
|
||||
let raw_password = a
|
||||
.get("password")
|
||||
.unwrap_or(empty_value)
|
||||
.as_str()
|
||||
.unwrap_or("");
|
||||
let username = render::render(raw_username, environment_ref);
|
||||
let password = render::render(raw_password, environment_ref);
|
||||
|
||||
let auth = format!("{username}:{password}");
|
||||
let encoded = base64::engine::general_purpose::STANDARD_NO_PAD.encode(auth);
|
||||
headers.insert(
|
||||
"Authorization",
|
||||
HeaderValue::from_str(&format!("Basic {}", encoded)).unwrap(),
|
||||
);
|
||||
} else if b == "bearer" {
|
||||
let token = request
|
||||
.authentication
|
||||
.0
|
||||
.get("token")
|
||||
.unwrap_or(empty_value)
|
||||
.as_str()
|
||||
.unwrap_or("");
|
||||
let raw_token = a.get("token").unwrap_or(empty_value).as_str().unwrap_or("");
|
||||
let token = render::render(raw_token, environment_ref);
|
||||
headers.insert(
|
||||
"Authorization",
|
||||
HeaderValue::from_str(&format!("Bearer {}", token)).unwrap(),
|
||||
HeaderValue::from_str(&format!("Bearer {token}")).unwrap(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -179,7 +172,10 @@ async fn actually_send_ephemeral_request(
|
||||
let builder = client.request(m, url_string.to_string()).headers(headers);
|
||||
|
||||
let sendable_req_result = match (request.body, request.body_type) {
|
||||
(Some(b), Some(_)) => builder.body(b).build(),
|
||||
(Some(raw_body), Some(_)) => {
|
||||
let body = render::render(&raw_body, environment_ref);
|
||||
builder.body(body).build()
|
||||
}
|
||||
_ => builder.build(),
|
||||
};
|
||||
|
||||
@@ -192,24 +188,6 @@ async fn actually_send_ephemeral_request(
|
||||
|
||||
let raw_response = client.execute(sendable_req).await;
|
||||
|
||||
let plugin_rel_path = "plugins/plugin.ts";
|
||||
let plugin_path = match app_handle.path_resolver().resolve_resource(plugin_rel_path) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
return response_err(
|
||||
response,
|
||||
format!("Plugin not found at {}", plugin_rel_path),
|
||||
&app_handle,
|
||||
pool,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = runtime::run_plugin_sync(plugin_path.to_str().unwrap()) {
|
||||
return response_err(response, e.to_string(), &app_handle, pool).await;
|
||||
}
|
||||
|
||||
match raw_response {
|
||||
Ok(v) => {
|
||||
let mut response = response.clone();
|
||||
@@ -233,7 +211,10 @@ async fn actually_send_ephemeral_request(
|
||||
let dir = app_handle.path_resolver().app_data_dir().unwrap();
|
||||
let base_dir = dir.join("responses");
|
||||
create_dir_all(base_dir.clone()).expect("Failed to create responses dir");
|
||||
let body_path = base_dir.join(response.id.clone());
|
||||
let body_path = match response.id == "" {
|
||||
false => base_dir.join(response.id.clone()),
|
||||
true => base_dir.join(uuid::Uuid::new_v4().to_string()),
|
||||
};
|
||||
let mut f = File::options()
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
@@ -273,6 +254,7 @@ async fn send_request(
|
||||
window: Window<Wry>,
|
||||
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||
request_id: &str,
|
||||
environment_id: Option<&str>,
|
||||
) -> Result<models::HttpResponse, String> {
|
||||
let pool = &*db_instance.lock().await;
|
||||
|
||||
@@ -285,10 +267,12 @@ async fn send_request(
|
||||
.expect("Failed to create response");
|
||||
|
||||
let response2 = response.clone();
|
||||
let environment_id2 = environment_id.unwrap_or("n/a").to_string();
|
||||
let app_handle2 = window.app_handle().clone();
|
||||
let pool2 = pool.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
actually_send_ephemeral_request(req, &response2, &app_handle2, &pool2)
|
||||
actually_send_ephemeral_request(req, &response2, &environment_id2, &app_handle2, &pool2)
|
||||
.await
|
||||
.expect("Failed to send request");
|
||||
});
|
||||
@@ -354,6 +338,22 @@ async fn create_workspace(
|
||||
emit_and_return(&window, "created_model", created_workspace)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn create_environment(
|
||||
workspace_id: &str,
|
||||
name: &str,
|
||||
variables: Vec<models::EnvironmentVariable>,
|
||||
window: Window<Wry>,
|
||||
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||
) -> Result<models::Environment, String> {
|
||||
let pool = &*db_instance.lock().await;
|
||||
let created_environment = models::create_environment(workspace_id, name, variables, pool)
|
||||
.await
|
||||
.expect("Failed to create environment");
|
||||
|
||||
emit_and_return(&window, "created_model", created_environment)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn create_request(
|
||||
workspace_id: &str,
|
||||
@@ -412,6 +412,26 @@ async fn update_workspace(
|
||||
emit_and_return(&window, "updated_model", updated_workspace)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn update_environment(
|
||||
environment: models::Environment,
|
||||
window: Window<Wry>,
|
||||
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||
) -> Result<models::Environment, String> {
|
||||
let pool = &*db_instance.lock().await;
|
||||
|
||||
let updated_environment = models::update_environment(
|
||||
environment.id.as_str(),
|
||||
environment.name.as_str(),
|
||||
environment.variables.0,
|
||||
pool,
|
||||
)
|
||||
.await
|
||||
.expect("Failed to update request");
|
||||
|
||||
emit_and_return(&window, "updated_model", updated_environment)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn update_request(
|
||||
request: models::HttpRequest,
|
||||
@@ -465,7 +485,20 @@ async fn delete_request(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn requests(
|
||||
async fn delete_environment(
|
||||
window: Window<Wry>,
|
||||
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||
environment_id: &str,
|
||||
) -> Result<models::Environment, String> {
|
||||
let pool = &*db_instance.lock().await;
|
||||
let req = models::delete_environment(environment_id, pool)
|
||||
.await
|
||||
.expect("Failed to delete environment");
|
||||
emit_and_return(&window, "deleted_model", req)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn list_requests(
|
||||
workspace_id: &str,
|
||||
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||
) -> Result<Vec<models::HttpRequest>, String> {
|
||||
@@ -475,6 +508,19 @@ async fn requests(
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn list_environments(
|
||||
workspace_id: &str,
|
||||
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||
) -> Result<Vec<models::Environment>, String> {
|
||||
let pool = &*db_instance.lock().await;
|
||||
let environments = models::find_environments(workspace_id, pool)
|
||||
.await
|
||||
.expect("Failed to find environments");
|
||||
|
||||
Ok(environments)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_request(
|
||||
id: &str,
|
||||
@@ -486,6 +532,17 @@ async fn get_request(
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_environment(
|
||||
id: &str,
|
||||
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||
) -> Result<models::Environment, String> {
|
||||
let pool = &*db_instance.lock().await;
|
||||
models::get_environment(id, pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_workspace(
|
||||
id: &str,
|
||||
@@ -498,7 +555,7 @@ async fn get_workspace(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn responses(
|
||||
async fn list_responses(
|
||||
request_id: &str,
|
||||
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||
) -> Result<Vec<models::HttpResponse>, String> {
|
||||
@@ -533,7 +590,7 @@ async fn delete_all_responses(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn workspaces(
|
||||
async fn list_workspaces(
|
||||
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||
) -> Result<Vec<models::Workspace>, String> {
|
||||
let pool = &*db_instance.lock().await;
|
||||
@@ -561,10 +618,10 @@ async fn new_window(window: Window<Wry>, url: &str) -> Result<(), String> {
|
||||
async fn delete_workspace(
|
||||
window: Window<Wry>,
|
||||
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||
id: &str,
|
||||
workspace_id: &str,
|
||||
) -> Result<models::Workspace, String> {
|
||||
let pool = &*db_instance.lock().await;
|
||||
let workspace = models::delete_workspace(id, pool)
|
||||
let workspace = models::delete_workspace(workspace_id, pool)
|
||||
.await
|
||||
.expect("Failed to delete workspace");
|
||||
emit_and_return(&window, "deleted_model", workspace)
|
||||
@@ -572,6 +629,7 @@ async fn delete_workspace(
|
||||
|
||||
fn main() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||
.setup(|app| {
|
||||
let dir = match is_dev() {
|
||||
true => current_dir().unwrap(),
|
||||
@@ -600,31 +658,39 @@ fn main() {
|
||||
})
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
new_window,
|
||||
workspaces,
|
||||
get_request,
|
||||
requests,
|
||||
send_request,
|
||||
send_ephemeral_request,
|
||||
duplicate_request,
|
||||
create_environment,
|
||||
create_request,
|
||||
get_workspace,
|
||||
create_workspace,
|
||||
delete_workspace,
|
||||
update_workspace,
|
||||
update_request,
|
||||
delete_request,
|
||||
responses,
|
||||
get_key_value,
|
||||
set_key_value,
|
||||
delete_response,
|
||||
delete_all_responses,
|
||||
delete_environment,
|
||||
delete_request,
|
||||
delete_response,
|
||||
delete_workspace,
|
||||
duplicate_request,
|
||||
get_key_value,
|
||||
get_environment,
|
||||
get_request,
|
||||
get_workspace,
|
||||
list_environments,
|
||||
list_requests,
|
||||
list_responses,
|
||||
list_workspaces,
|
||||
new_window,
|
||||
send_ephemeral_request,
|
||||
send_request,
|
||||
set_key_value,
|
||||
update_environment,
|
||||
update_request,
|
||||
update_workspace,
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while running tauri application")
|
||||
.run(|app_handle, event| match event {
|
||||
RunEvent::Ready => {
|
||||
create_window(app_handle, None);
|
||||
let w = create_window(app_handle, None);
|
||||
w.restore_state(StateFlags::all())
|
||||
.expect("Failed to restore window state");
|
||||
plugin::test_plugins(&app_handle);
|
||||
}
|
||||
|
||||
// ExitRequested { api, .. } => {
|
||||
@@ -695,7 +761,7 @@ fn create_window(handle: &AppHandle<Wry>, url: Option<&str>) -> Window<Wry> {
|
||||
let submenu = Submenu::new("Test Menu", test_menu);
|
||||
|
||||
let window_num = handle.windows().len();
|
||||
let window_id = format!("wnd_{}_{}", window_num, generate_id(None));
|
||||
let window_id = format!("wnd_{}", window_num);
|
||||
let menu = default_menu.add_submenu(submenu);
|
||||
let mut win_builder = tauri::WindowBuilder::new(
|
||||
handle,
|
||||
|
||||
@@ -18,6 +18,27 @@ pub struct Workspace {
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Environment {
|
||||
pub id: String,
|
||||
pub workspace_id: String,
|
||||
pub model: String,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
pub name: String,
|
||||
pub variables: Json<Vec<EnvironmentVariable>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EnvironmentVariable {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HttpRequestHeader {
|
||||
@@ -31,11 +52,11 @@ pub struct HttpRequestHeader {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HttpRequest {
|
||||
pub id: String,
|
||||
pub workspace_id: String,
|
||||
pub model: String,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
pub sort_priority: f64,
|
||||
pub workspace_id: String,
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub method: String,
|
||||
@@ -193,6 +214,106 @@ pub async fn create_workspace(
|
||||
get_workspace(&id, pool).await
|
||||
}
|
||||
|
||||
pub async fn find_environments(
|
||||
workspace_id: &str,
|
||||
pool: &Pool<Sqlite>,
|
||||
) -> Result<Vec<Environment>, sqlx::Error> {
|
||||
sqlx::query_as!(
|
||||
Environment,
|
||||
r#"
|
||||
SELECT id, workspace_id, model, created_at, updated_at, name,
|
||||
variables AS "variables!: sqlx::types::Json<Vec<EnvironmentVariable>>"
|
||||
FROM environments
|
||||
WHERE workspace_id = ?
|
||||
"#,
|
||||
workspace_id,
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create_environment(
|
||||
workspace_id: &str,
|
||||
name: &str,
|
||||
variables: Vec<EnvironmentVariable>,
|
||||
pool: &Pool<Sqlite>,
|
||||
) -> Result<Environment, sqlx::Error> {
|
||||
let id = generate_id(Some("en"));
|
||||
let trimmed_name = name.trim();
|
||||
let variables_json = Json(variables);
|
||||
sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO environments (id, workspace_id, name, variables)
|
||||
VALUES (?, ?, ?, ?)
|
||||
"#,
|
||||
id,
|
||||
workspace_id,
|
||||
trimmed_name,
|
||||
variables_json,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
get_environment(&id, pool).await
|
||||
}
|
||||
|
||||
pub async fn delete_environment(id: &str, pool: &Pool<Sqlite>) -> Result<Environment, sqlx::Error> {
|
||||
let env = get_environment(id, pool).await?;
|
||||
let _ = sqlx::query!(
|
||||
r#"
|
||||
DELETE FROM environments
|
||||
WHERE id = ?
|
||||
"#,
|
||||
id,
|
||||
)
|
||||
.execute(pool)
|
||||
.await;
|
||||
|
||||
Ok(env)
|
||||
}
|
||||
|
||||
pub async fn update_environment(
|
||||
id: &str,
|
||||
name: &str,
|
||||
variables: Vec<EnvironmentVariable>,
|
||||
pool: &Pool<Sqlite>,
|
||||
) -> Result<Environment, sqlx::Error> {
|
||||
let variables_json = Json(variables);
|
||||
sqlx::query!(
|
||||
r#"
|
||||
UPDATE environments
|
||||
SET (name, variables, updated_at) = (?, ?, CURRENT_TIMESTAMP)
|
||||
WHERE id = ?;
|
||||
"#,
|
||||
name,
|
||||
variables_json,
|
||||
id,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
get_environment(id, pool).await
|
||||
}
|
||||
|
||||
pub async fn get_environment(id: &str, pool: &Pool<Sqlite>) -> Result<Environment, sqlx::Error> {
|
||||
sqlx::query_as!(
|
||||
Environment,
|
||||
r#"
|
||||
SELECT
|
||||
id,
|
||||
model,
|
||||
workspace_id,
|
||||
created_at,
|
||||
updated_at,
|
||||
name,
|
||||
variables AS "variables!: sqlx::types::Json<Vec<EnvironmentVariable>>"
|
||||
FROM environments
|
||||
WHERE id = ?
|
||||
"#,
|
||||
id,
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn duplicate_request(id: &str, pool: &Pool<Sqlite>) -> Result<HttpRequest, sqlx::Error> {
|
||||
let existing = get_request(id, pool).await?;
|
||||
|
||||
@@ -354,6 +475,10 @@ pub async fn get_request(id: &str, pool: &Pool<Sqlite>) -> Result<HttpRequest, s
|
||||
|
||||
pub async fn delete_request(id: &str, pool: &Pool<Sqlite>) -> Result<HttpRequest, sqlx::Error> {
|
||||
let req = get_request(id, pool).await?;
|
||||
|
||||
// DB deletes will cascade but this will delete the files
|
||||
delete_all_responses(id, pool).await?;
|
||||
|
||||
let _ = sqlx::query!(
|
||||
r#"
|
||||
DELETE FROM http_requests
|
||||
@@ -364,8 +489,6 @@ pub async fn delete_request(id: &str, pool: &Pool<Sqlite>) -> Result<HttpRequest
|
||||
.execute(pool)
|
||||
.await;
|
||||
|
||||
delete_all_responses(id, pool).await?;
|
||||
|
||||
Ok(req)
|
||||
}
|
||||
|
||||
|
||||
97
src-tauri/src/plugin.rs
Normal file
@@ -0,0 +1,97 @@
|
||||
use boa_engine::{
|
||||
js_string,
|
||||
module::{ModuleLoader, SimpleModuleLoader},
|
||||
property::Attribute,
|
||||
Context, JsArgs, JsNativeError, JsValue, Module, NativeFunction, Source,
|
||||
};
|
||||
use boa_runtime::Console;
|
||||
use tauri::AppHandle;
|
||||
|
||||
pub fn test_plugins(app_handle: &AppHandle) {
|
||||
let plugin_dir = app_handle
|
||||
.path_resolver()
|
||||
.resolve_resource("plugins/hello-world")
|
||||
.expect("failed to resolve plugin directory resource");
|
||||
let plugin_entry_file = app_handle
|
||||
.path_resolver()
|
||||
.resolve_resource("plugins/hello-world/index.js")
|
||||
.expect("failed to resolve plugin entry point resource");
|
||||
|
||||
// Module loader for the specific plugin
|
||||
let loader = &SimpleModuleLoader::new(plugin_dir).expect("failed to create module loader");
|
||||
let dyn_loader: &dyn ModuleLoader = loader;
|
||||
|
||||
let context = &mut Context::builder()
|
||||
.module_loader(dyn_loader)
|
||||
.build()
|
||||
.expect("failed to create context");
|
||||
|
||||
add_runtime(context);
|
||||
add_globals(context);
|
||||
|
||||
let source = Source::from_filepath(&plugin_entry_file).expect("Error opening file");
|
||||
|
||||
// Can also pass a `Some(realm)` if you need to execute the module in another realm.
|
||||
let module = Module::parse(source, None, context).expect("failed to parse module");
|
||||
|
||||
// Insert parsed entrypoint into the module loader
|
||||
// TODO: Is this needed if loaded from file already?
|
||||
loader.insert(plugin_entry_file, module.clone());
|
||||
|
||||
let _promise_result = module
|
||||
.load_link_evaluate(context)
|
||||
.expect("failed to evaluate module");
|
||||
|
||||
// Very important to push forward the job queue after queueing promises.
|
||||
context.run_jobs();
|
||||
|
||||
// // Checking if the final promise didn't return an error.
|
||||
// match promise_result.state() {
|
||||
// PromiseState::Pending => return Err("module didn't execute!".into()),
|
||||
// PromiseState::Fulfilled(v) => {
|
||||
// assert_eq!(v, JsValue::undefined())
|
||||
// }
|
||||
// PromiseState::Rejected(err) => {
|
||||
// return Err(JsError::from_opaque(err).try_native(context)?.into())
|
||||
// }
|
||||
// }
|
||||
|
||||
let namespace = module.namespace(context);
|
||||
|
||||
let entrypoint_fn = namespace
|
||||
.get(js_string!("entrypoint"), context)
|
||||
.expect("failed to get entrypoint")
|
||||
.as_callable()
|
||||
.cloned()
|
||||
.ok_or_else(|| JsNativeError::typ().with_message("export wasn't a function!"))
|
||||
.expect("Failed to get entrypoint");
|
||||
|
||||
// Actually call the entrypoint function
|
||||
let _result = entrypoint_fn
|
||||
.call(&JsValue::undefined(), &[], context)
|
||||
.expect("Failed to call entrypoint");
|
||||
}
|
||||
|
||||
fn add_runtime(context: &mut Context<'_>) {
|
||||
let console = Console::init(context);
|
||||
context
|
||||
.register_global_property(js_string!(Console::NAME), console, Attribute::all())
|
||||
.expect("the console builtin shouldn't exist");
|
||||
}
|
||||
|
||||
fn add_globals(context: &mut Context<'_>) {
|
||||
context
|
||||
.register_global_builtin_callable(
|
||||
"sayHello",
|
||||
1,
|
||||
NativeFunction::from_fn_ptr(|_, args, context| {
|
||||
let value: String = args
|
||||
.get_or_undefined(0)
|
||||
.try_js_into(context)
|
||||
.expect("failed to convert arg");
|
||||
println!("Hello {}!", value);
|
||||
Ok(value.into())
|
||||
}),
|
||||
)
|
||||
.expect("failed to register global");
|
||||
}
|
||||
29
src-tauri/src/render.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
use crate::models::Environment;
|
||||
use std::collections::HashMap;
|
||||
use tauri::regex::Regex;
|
||||
|
||||
pub fn render(template: &str, environment: Option<&Environment>) -> String {
|
||||
match environment {
|
||||
Some(environment) => render_with_environment(template, environment),
|
||||
None => template.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_with_environment(template: &str, environment: &Environment) -> String {
|
||||
let mut map = HashMap::new();
|
||||
let variables = &environment.variables.0;
|
||||
for variable in variables {
|
||||
if !variable.enabled {
|
||||
continue;
|
||||
}
|
||||
map.insert(variable.name.as_str(), variable.value.as_str());
|
||||
}
|
||||
|
||||
Regex::new(r"\$\{\[\s*([^]\s]+)\s*]}")
|
||||
.expect("Failed to create regex")
|
||||
.replace_all(template, |caps: &tauri::regex::Captures| {
|
||||
let key = caps.get(1).unwrap().as_str();
|
||||
map.get(key).unwrap_or(&"")
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
(function (globalThis) {
|
||||
// Deno.core.print(Object.keys(Deno.core).join('\n'));
|
||||
|
||||
function argsToMessage(...args) {
|
||||
return args.map((arg) => JSON.stringify(arg)).join(' ');
|
||||
}
|
||||
|
||||
globalThis.console = {
|
||||
log: (...args) => {
|
||||
Deno.core.print(`[log]: ${argsToMessage(...args)}\n`, false);
|
||||
},
|
||||
error: (...args) => {
|
||||
Deno.core.print(`[err]: ${argsToMessage(...args)}\n`, true);
|
||||
},
|
||||
};
|
||||
})(globalThis);
|
||||
@@ -1,121 +0,0 @@
|
||||
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> {
|
||||
executor::block_on(run_plugin(file_path))
|
||||
}
|
||||
|
||||
pub async fn run_plugin(file_path: &str) -> Result<(), AnyError> {
|
||||
let extension = Extension::builder("runtime")
|
||||
.ops(vec![op_hello::decl()])
|
||||
.build();
|
||||
|
||||
// Initialize a runtime instance
|
||||
let mut runtime = JsRuntime::new(RuntimeOptions {
|
||||
module_loader: Some(Rc::new(TsModuleLoader)),
|
||||
extensions: vec![extension],
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
runtime
|
||||
.execute_script("<runtime>", include_str!("runtime.js"))
|
||||
.expect("Failed to execute runtime.js");
|
||||
|
||||
let current_dir = &std::env::current_dir().expect("Unable to get CWD");
|
||||
let main_module =
|
||||
deno_core::resolve_path(file_path, current_dir).expect("Failed to resolve path");
|
||||
let mod_id = runtime
|
||||
.load_main_module(&main_module, None)
|
||||
.await
|
||||
.expect("Failed to load main module");
|
||||
let result = runtime.mod_evaluate(mod_id);
|
||||
runtime
|
||||
.run_event_loop(false)
|
||||
.await
|
||||
.expect("Failed to run event loop");
|
||||
result.await?
|
||||
}
|
||||
|
||||
#[op]
|
||||
async fn op_hello(name: String) -> Result<String, AnyError> {
|
||||
let contents = format!("Hello {} from Rust!", name);
|
||||
println!("{}", contents);
|
||||
Ok(contents)
|
||||
}
|
||||
|
||||
struct TsModuleLoader;
|
||||
|
||||
impl deno_core::ModuleLoader for TsModuleLoader {
|
||||
fn resolve(
|
||||
&self,
|
||||
specifier: &str,
|
||||
referrer: &str,
|
||||
_kind: deno_core::ResolutionKind,
|
||||
) -> Result<deno_core::ModuleSpecifier, AnyError> {
|
||||
deno_core::resolve_import(specifier, referrer).map_err(|e| e.into())
|
||||
}
|
||||
|
||||
fn load(
|
||||
&self,
|
||||
module_specifier: &deno_core::ModuleSpecifier,
|
||||
_maybe_referrer: Option<deno_core::ModuleSpecifier>,
|
||||
_is_dyn_import: bool,
|
||||
) -> std::pin::Pin<Box<deno_core::ModuleSourceFuture>> {
|
||||
let module_specifier = module_specifier.clone();
|
||||
async move {
|
||||
let path = module_specifier
|
||||
.to_file_path()
|
||||
.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(&path);
|
||||
let (module_type, should_transpile) = match media_type {
|
||||
MediaType::JavaScript | MediaType::Mjs | MediaType::Cjs => {
|
||||
(ModuleType::JavaScript, false)
|
||||
}
|
||||
MediaType::Jsx => (ModuleType::JavaScript, true),
|
||||
MediaType::TypeScript
|
||||
| MediaType::Mts
|
||||
| MediaType::Cts
|
||||
| MediaType::Dts
|
||||
| MediaType::Dmts
|
||||
| MediaType::Dcts
|
||||
| MediaType::Tsx => (ModuleType::JavaScript, true),
|
||||
MediaType::Json => (ModuleType::Json, false),
|
||||
_ => panic!("Unknown extension {:?}", path.extension()),
|
||||
};
|
||||
|
||||
// Read the file, transpile if necessary.
|
||||
let code = std::fs::read_to_string(&path)?;
|
||||
let code = if should_transpile {
|
||||
let parsed = deno_ast::parse_module(ParseParams {
|
||||
specifier: module_specifier.to_string(),
|
||||
text_info: SourceTextInfo::from_string(code),
|
||||
media_type,
|
||||
capture_tokens: false,
|
||||
scope_analysis: false,
|
||||
maybe_syntax: None,
|
||||
})?;
|
||||
parsed.transpile(&Default::default())?.text
|
||||
} else {
|
||||
code
|
||||
};
|
||||
|
||||
// Load and return module.
|
||||
let module = ModuleSource {
|
||||
code: ModuleCode::from(code),
|
||||
module_type,
|
||||
module_url_specified: module_specifier.to_string(),
|
||||
module_url_found: module_specifier.to_string(),
|
||||
};
|
||||
Ok(module)
|
||||
}
|
||||
.boxed_local()
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,11 @@ use tauri::{Runtime, Window};
|
||||
const TRAFFIC_LIGHT_OFFSET_X: f64 = 13.0;
|
||||
const TRAFFIC_LIGHT_OFFSET_Y: f64 = 18.0;
|
||||
|
||||
pub trait WindowExt {
|
||||
pub trait TrafficLightWindowExt {
|
||||
fn position_traffic_lights(&self);
|
||||
}
|
||||
|
||||
impl<R: Runtime> WindowExt for Window<R> {
|
||||
impl<R: Runtime> TrafficLightWindowExt for Window<R> {
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn position_traffic_lights(&self) {
|
||||
// No-op
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
},
|
||||
"package": {
|
||||
"productName": "Yaak",
|
||||
"version": "2023.0.16"
|
||||
"version": "2023.1.7"
|
||||
},
|
||||
"tauri": {
|
||||
"windows": [],
|
||||
@@ -18,8 +18,10 @@
|
||||
"all": true
|
||||
},
|
||||
"protocol": {
|
||||
"assetScope": ["$APPDATA/responses/*"],
|
||||
"asset": true
|
||||
"assetScope": [
|
||||
"$APPDATA/responses/*"
|
||||
],
|
||||
"asset": true
|
||||
},
|
||||
"fs": {
|
||||
"readFile": true,
|
||||
@@ -49,13 +51,20 @@
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"identifier": "co.schier.yaak",
|
||||
"longDescription": "",
|
||||
"longDescription": "The best cross-platform visual API client",
|
||||
"resources": [
|
||||
"plugins/*",
|
||||
"migrations/*"
|
||||
"migrations/*",
|
||||
"plugins/*"
|
||||
],
|
||||
"shortDescription": "The best API client",
|
||||
"targets": [
|
||||
"deb",
|
||||
"appimage",
|
||||
"nsis",
|
||||
"app",
|
||||
"dmg",
|
||||
"updater"
|
||||
],
|
||||
"shortDescription": "",
|
||||
"targets": "all",
|
||||
"deb": {
|
||||
"depends": []
|
||||
},
|
||||
@@ -69,8 +78,7 @@
|
||||
"timestampUrl": ""
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
},
|
||||
"security": {},
|
||||
"systemTray": {
|
||||
"iconAsTemplate": true,
|
||||
"iconPath": "icons/icon.png"
|
||||
|
||||
@@ -5,7 +5,6 @@ import { DndProvider } from 'react-dnd';
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||
import { HelmetProvider } from 'react-helmet-async';
|
||||
import { AppRouter } from './AppRouter';
|
||||
import { DialogProvider } from './DialogContext';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
logger: undefined,
|
||||
@@ -24,12 +23,10 @@ export function App() {
|
||||
<MotionConfig transition={{ duration: 0.1 }}>
|
||||
<HelmetProvider>
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<DialogProvider>
|
||||
<Suspense>
|
||||
<AppRouter />
|
||||
{/*<ReactQueryDevtools initialIsOpen={false} />*/}
|
||||
</Suspense>
|
||||
</DialogProvider>
|
||||
</DndProvider>
|
||||
</HelmetProvider>
|
||||
</MotionConfig>
|
||||
|
||||
@@ -3,9 +3,11 @@ import { routePaths, useAppRoutes } from '../hooks/useAppRoutes';
|
||||
import { useRecentRequests } from '../hooks/useRecentRequests';
|
||||
import { useRequests } from '../hooks/useRequests';
|
||||
import { GlobalHooks } from './GlobalHooks';
|
||||
import RouteError from './RouteError';
|
||||
import Workspace from './Workspace';
|
||||
import Workspaces from './Workspaces';
|
||||
import { DialogProvider } from './DialogContext';
|
||||
import { useActiveEnvironmentId } from '../hooks/useActiveEnvironmentId';
|
||||
import RouteError from './RouteError';
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
@@ -22,12 +24,16 @@ const router = createBrowserRouter([
|
||||
element: <Workspaces />,
|
||||
},
|
||||
{
|
||||
path: routePaths.workspace({ workspaceId: ':workspaceId' }),
|
||||
path: routePaths.workspace({
|
||||
workspaceId: ':workspaceId',
|
||||
environmentId: ':environmentId',
|
||||
}),
|
||||
element: <WorkspaceOrRedirect />,
|
||||
},
|
||||
{
|
||||
path: routePaths.request({
|
||||
workspaceId: ':workspaceId',
|
||||
environmentId: ':environmentId',
|
||||
requestId: ':requestId',
|
||||
}),
|
||||
element: <Workspace />,
|
||||
@@ -42,6 +48,7 @@ export function AppRouter() {
|
||||
|
||||
function WorkspaceOrRedirect() {
|
||||
const recentRequests = useRecentRequests();
|
||||
const activeEnvironmentId = useActiveEnvironmentId();
|
||||
const requests = useRequests();
|
||||
const request = requests.find((r) => r.id === recentRequests[0]);
|
||||
const routes = useAppRoutes();
|
||||
@@ -50,18 +57,25 @@ function WorkspaceOrRedirect() {
|
||||
return <Workspace />;
|
||||
}
|
||||
|
||||
const { id: requestId, workspaceId } = request;
|
||||
const environmentId = activeEnvironmentId ?? undefined;
|
||||
|
||||
return (
|
||||
<Navigate
|
||||
to={routes.paths.request({ workspaceId: request.workspaceId, requestId: request.id })}
|
||||
to={routes.paths.request({
|
||||
workspaceId,
|
||||
environmentId,
|
||||
requestId,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Layout() {
|
||||
return (
|
||||
<>
|
||||
<DialogProvider>
|
||||
<Outlet />
|
||||
<GlobalHooks />
|
||||
</>
|
||||
</DialogProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ export function BasicAuth({ requestId, authentication }: Props) {
|
||||
return (
|
||||
<VStack className="my-2" space={2}>
|
||||
<Input
|
||||
useTemplating
|
||||
label="Username"
|
||||
name="username"
|
||||
size="sm"
|
||||
@@ -26,6 +27,7 @@ export function BasicAuth({ requestId, authentication }: Props) {
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
useTemplating
|
||||
label="Password"
|
||||
name="password"
|
||||
size="sm"
|
||||
|
||||
@@ -14,6 +14,9 @@ export function BearerAuth({ requestId, authentication }: Props) {
|
||||
return (
|
||||
<VStack className="my-2" space={2}>
|
||||
<Input
|
||||
useTemplating
|
||||
autocompleteVariables
|
||||
type="password"
|
||||
label="Token"
|
||||
name="token"
|
||||
size="sm"
|
||||
|
||||
@@ -54,9 +54,10 @@ export const DialogProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
|
||||
function DialogInstance({ id, render, ...props }: DialogEntry) {
|
||||
const { actions } = useContext(DialogContext);
|
||||
const children = render({ hide: () => actions.hide(id) });
|
||||
return (
|
||||
<Dialog open onClose={() => actions.hide(id)} {...props}>
|
||||
{render({ hide: () => actions.hide(id) })}
|
||||
{children}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import classnames from 'classnames';
|
||||
import classNames from 'classnames';
|
||||
import React, { memo } from 'react';
|
||||
|
||||
interface Props {
|
||||
@@ -9,7 +9,7 @@ export const DropMarker = memo(
|
||||
function DropMarker({ className }: Props) {
|
||||
return (
|
||||
<div
|
||||
className={classnames(
|
||||
className={classNames(
|
||||
className,
|
||||
'relative w-full h-0 overflow-visible pointer-events-none',
|
||||
)}
|
||||
|
||||
88
src-web/components/EnvironmentActionsDropdown.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import classNames from 'classnames';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { Button } from './core/Button';
|
||||
import type { DropdownItem } from './core/Dropdown';
|
||||
import { Dropdown } from './core/Dropdown';
|
||||
import { Icon } from './core/Icon';
|
||||
import { useEnvironments } from '../hooks/useEnvironments';
|
||||
import { useActiveEnvironment } from '../hooks/useActiveEnvironment';
|
||||
import { useDialog } from './DialogContext';
|
||||
import { EnvironmentEditDialog } from './EnvironmentEditDialog';
|
||||
import { useAppRoutes } from '../hooks/useAppRoutes';
|
||||
import { useCreateEnvironment } from '../hooks/useCreateEnvironment';
|
||||
import { usePrompt } from '../hooks/usePrompt';
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdown({
|
||||
className,
|
||||
}: Props) {
|
||||
const environments = useEnvironments();
|
||||
const activeEnvironment = useActiveEnvironment();
|
||||
const createEnvironment = useCreateEnvironment();
|
||||
const dialog = useDialog();
|
||||
const prompt = usePrompt();
|
||||
const routes = useAppRoutes();
|
||||
|
||||
const showEnvironmentDialog = useCallback(() => {
|
||||
dialog.show({
|
||||
title: 'Manage Environments',
|
||||
render: () => <EnvironmentEditDialog />,
|
||||
});
|
||||
}, [dialog]);
|
||||
|
||||
const items: DropdownItem[] = useMemo(
|
||||
() =>
|
||||
environments.length === 0
|
||||
? [
|
||||
{
|
||||
key: 'create',
|
||||
label: 'Create Environment',
|
||||
leftSlot: <Icon icon="plusCircle" />,
|
||||
onSelect: async () => {
|
||||
await createEnvironment.mutateAsync();
|
||||
showEnvironmentDialog();
|
||||
},
|
||||
},
|
||||
]
|
||||
: [
|
||||
...environments.map(
|
||||
(e) => ({
|
||||
key: e.id,
|
||||
label: e.name,
|
||||
rightSlot: e.id === activeEnvironment?.id ? <Icon icon="check" /> : undefined,
|
||||
onSelect: async () => {
|
||||
routes.setEnvironment(e);
|
||||
},
|
||||
}),
|
||||
[activeEnvironment?.id],
|
||||
),
|
||||
{ type: 'separator', label: 'Environments' },
|
||||
{
|
||||
key: 'edit',
|
||||
label: 'Manage Environments',
|
||||
leftSlot: <Icon icon="gear" />,
|
||||
onSelect: showEnvironmentDialog,
|
||||
},
|
||||
],
|
||||
[activeEnvironment, environments, routes, createEnvironment, showEnvironmentDialog],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown items={items}>
|
||||
<Button
|
||||
forDropdown
|
||||
size="sm"
|
||||
className={classNames(
|
||||
className,
|
||||
'text-gray-800 !px-2 truncate',
|
||||
activeEnvironment == null && 'text-opacity-disabled italic',
|
||||
)}
|
||||
>
|
||||
{activeEnvironment?.name ?? 'No Environment'}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
});
|
||||
162
src-web/components/EnvironmentEditDialog.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { useCreateEnvironment } from '../hooks/useCreateEnvironment';
|
||||
import { useEnvironments } from '../hooks/useEnvironments';
|
||||
import type { Environment } from '../lib/models';
|
||||
import { Button } from './core/Button';
|
||||
import classNames from 'classnames';
|
||||
import { useActiveEnvironment } from '../hooks/useActiveEnvironment';
|
||||
import { useAppRoutes } from '../hooks/useAppRoutes';
|
||||
import { PairEditor } from './core/PairEditor';
|
||||
import type { PairEditorProps } from './core/PairEditor';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useUpdateEnvironment } from '../hooks/useUpdateEnvironment';
|
||||
import { HStack, VStack } from './core/Stacks';
|
||||
import { IconButton } from './core/IconButton';
|
||||
import { useDeleteEnvironment } from '../hooks/useDeleteEnvironment';
|
||||
import type { DropdownItem } from './core/Dropdown';
|
||||
import { Dropdown } from './core/Dropdown';
|
||||
import { Icon } from './core/Icon';
|
||||
import { usePrompt } from '../hooks/usePrompt';
|
||||
import { InlineCode } from './core/InlineCode';
|
||||
import { useWindowSize } from 'react-use';
|
||||
import type { GenericCompletionConfig } from './core/Editor/genericCompletion';
|
||||
|
||||
export const EnvironmentEditDialog = function () {
|
||||
const routes = useAppRoutes();
|
||||
const environments = useEnvironments();
|
||||
const createEnvironment = useCreateEnvironment();
|
||||
const activeEnvironment = useActiveEnvironment();
|
||||
|
||||
const windowSize = useWindowSize();
|
||||
const showSidebar = windowSize.width > 500;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'h-full grid gap-x-8 grid-rows-[minmax(0,1fr)]',
|
||||
showSidebar ? 'grid-cols-[auto_minmax(0,1fr)]' : 'grid-cols-[minmax(0,1fr)]',
|
||||
)}
|
||||
>
|
||||
{showSidebar && (
|
||||
<aside className="grid grid-rows-[minmax(0,1fr)_auto] gap-y-0.5 h-full max-w-[250px] pr-4 border-r border-gray-100">
|
||||
<div className="min-w-0 h-full w-full overflow-y-scroll">
|
||||
{environments.map((e) => (
|
||||
<Button
|
||||
size="xs"
|
||||
color="custom"
|
||||
className={classNames(
|
||||
'w-full',
|
||||
'text-gray-600 hocus:text-gray-800',
|
||||
activeEnvironment?.id === e.id && 'bg-highlightSecondary !text-gray-900',
|
||||
)}
|
||||
justify="start"
|
||||
key={e.id}
|
||||
onClick={() => {
|
||||
routes.setEnvironment(e);
|
||||
}}
|
||||
>
|
||||
{e.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full"
|
||||
color="gray"
|
||||
onClick={() => createEnvironment.mutate()}
|
||||
>
|
||||
New Environment
|
||||
</Button>
|
||||
</aside>
|
||||
)}
|
||||
{activeEnvironment != null ? (
|
||||
<EnvironmentEditor environment={activeEnvironment} />
|
||||
) : (
|
||||
<div className="flex w-full h-full items-center justify-center text-gray-400 italic">
|
||||
select an environment
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const EnvironmentEditor = function ({ environment }: { environment: Environment }) {
|
||||
const environments = useEnvironments();
|
||||
const updateEnvironment = useUpdateEnvironment(environment.id);
|
||||
const deleteEnvironment = useDeleteEnvironment(environment);
|
||||
const handleChange = useCallback<PairEditorProps['onChange']>(
|
||||
(variables) => {
|
||||
updateEnvironment.mutate({ variables });
|
||||
},
|
||||
[updateEnvironment],
|
||||
);
|
||||
|
||||
const nameAutocomplete = useMemo<GenericCompletionConfig>(() => {
|
||||
const allVariableNames = environments.flatMap((e) => e.variables.map((v) => v.name));
|
||||
// Filter out empty strings and variables that already exist in the active environment
|
||||
const variableNames = allVariableNames.filter(
|
||||
(name) => name != '' && !environment.variables.find((v) => v.name === name),
|
||||
);
|
||||
return { options: variableNames.map((name) => ({ label: name, type: 'constant' })) };
|
||||
}, [environments, environment.variables]);
|
||||
|
||||
const prompt = usePrompt();
|
||||
const items = useMemo<DropdownItem[]>(
|
||||
() => [
|
||||
{
|
||||
key: 'rename',
|
||||
label: 'Rename',
|
||||
leftSlot: <Icon icon="pencil" size="sm" />,
|
||||
onSelect: async () => {
|
||||
const name = await prompt({
|
||||
title: 'Rename Environment',
|
||||
description: (
|
||||
<>
|
||||
Enter a new name for <InlineCode>{environment.name}</InlineCode>
|
||||
</>
|
||||
),
|
||||
name: 'name',
|
||||
label: 'Name',
|
||||
defaultValue: environment.name,
|
||||
});
|
||||
updateEnvironment.mutate({ name });
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
variant: 'danger',
|
||||
label: 'Delete',
|
||||
leftSlot: <Icon icon="trash" size="sm" />,
|
||||
onSelect: () => deleteEnvironment.mutate(),
|
||||
},
|
||||
],
|
||||
[deleteEnvironment, updateEnvironment, environment.name, prompt],
|
||||
);
|
||||
|
||||
const validateName = useCallback((name: string) => {
|
||||
// Empty just means the variable doesn't have a name yet, and is unusable
|
||||
if (name === '') return true;
|
||||
return name.match(/^[a-z_][a-z0-9_]*$/i) != null;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<VStack space={2}>
|
||||
<HStack space={2} className="justify-between">
|
||||
<h1 className="text-xl">{environment.name}</h1>
|
||||
<Dropdown items={items}>
|
||||
<IconButton icon="gear" title="Environment Actions" size="sm" className="!h-auto w-8" />
|
||||
</Dropdown>
|
||||
</HStack>
|
||||
<PairEditor
|
||||
nameAutocomplete={nameAutocomplete}
|
||||
nameAutocompleteVariables={false}
|
||||
namePlaceholder="VAR_NAME"
|
||||
valuePlaceholder="variable value"
|
||||
nameValidate={validateName}
|
||||
valueAutocompleteVariables={false}
|
||||
forceUpdateKey={environment.id}
|
||||
pairs={environment.variables}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
@@ -4,18 +4,36 @@ 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 { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
|
||||
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';
|
||||
import { useRecentRequests } from '../hooks/useRecentRequests';
|
||||
import { useRecentWorkspaces } from '../hooks/useRecentWorkspaces';
|
||||
import { useRecentEnvironments } from '../hooks/useRecentEnvironments';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useEffect } from 'react';
|
||||
import { setPathname } from '../lib/persistPathname';
|
||||
|
||||
export function GlobalHooks() {
|
||||
// Include here so they always update, even
|
||||
// if no component references them
|
||||
useRecentWorkspaces();
|
||||
useRecentEnvironments();
|
||||
useRecentRequests();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const { wasUpdatedExternally } = useRequestUpdateKey(null);
|
||||
|
||||
useTauriEvent<Model>('created_model', ({ payload, windowLabel }) => {
|
||||
// Listen for location changes and update the pathname
|
||||
const location = useLocation();
|
||||
useEffect(() => {
|
||||
setPathname(location.pathname);
|
||||
}, [location.pathname]);
|
||||
|
||||
useListenToTauriEvent<Model>('created_model', ({ payload, windowLabel }) => {
|
||||
if (shouldIgnoreEvent(payload, windowLabel)) return;
|
||||
|
||||
const queryKey =
|
||||
@@ -40,7 +58,7 @@ export function GlobalHooks() {
|
||||
}
|
||||
});
|
||||
|
||||
useTauriEvent<Model>('updated_model', ({ payload, windowLabel }) => {
|
||||
useListenToTauriEvent<Model>('updated_model', ({ payload, windowLabel }) => {
|
||||
if (shouldIgnoreEvent(payload, windowLabel)) return;
|
||||
|
||||
const queryKey =
|
||||
@@ -64,13 +82,14 @@ export function GlobalHooks() {
|
||||
}
|
||||
|
||||
if (!shouldIgnoreModel(payload)) {
|
||||
queryClient.setQueryData<Model[]>(queryKey, (values) =>
|
||||
values?.map((v) => (modelsEq(v, payload) ? payload : v)),
|
||||
queryClient.setQueryData<Model[]>(
|
||||
queryKey,
|
||||
(values) => values?.map((v) => (modelsEq(v, payload) ? payload : v)),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
useTauriEvent<Model>('deleted_model', ({ payload, windowLabel }) => {
|
||||
useListenToTauriEvent<Model>('deleted_model', ({ payload, windowLabel }) => {
|
||||
if (shouldIgnoreEvent(payload, windowLabel)) return;
|
||||
|
||||
if (shouldIgnoreModel(payload)) return;
|
||||
@@ -85,7 +104,7 @@ export function GlobalHooks() {
|
||||
queryClient.setQueryData(keyValueQueryKey(payload), undefined);
|
||||
}
|
||||
});
|
||||
useTauriEvent<number>('zoom', ({ payload: zoomDelta, windowLabel }) => {
|
||||
useListenToTauriEvent<number>('zoom', ({ payload: zoomDelta, windowLabel }) => {
|
||||
if (windowLabel !== appWindow.label) return;
|
||||
const fontSize = parseFloat(window.getComputedStyle(document.documentElement).fontSize);
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { HttpRequest } from '../lib/models';
|
||||
import { Button } from './core/Button';
|
||||
import type { EditorProps } from './core/Editor';
|
||||
import { Editor, formatGraphQL } from './core/Editor';
|
||||
import { FormattedError } from './core/FormattedError';
|
||||
import { Separator } from './core/Separator';
|
||||
import { useDialog } from './DialogContext';
|
||||
|
||||
@@ -24,8 +25,7 @@ interface GraphQLBody {
|
||||
|
||||
export function GraphQLEditor({ defaultValue, onChange, baseRequest, ...extraEditorProps }: Props) {
|
||||
const editorViewRef = useRef<EditorView>(null);
|
||||
const introspection = useIntrospectGraphQL(baseRequest);
|
||||
|
||||
const { schema, isLoading, error, refetch } = useIntrospectGraphQL(baseRequest);
|
||||
const { query, variables } = useMemo<GraphQLBody>(() => {
|
||||
if (defaultValue === undefined) {
|
||||
return { query: '', variables: {} };
|
||||
@@ -65,8 +65,8 @@ export function GraphQLEditor({ defaultValue, onChange, baseRequest, ...extraEdi
|
||||
// Refetch the schema when the URL changes
|
||||
useEffect(() => {
|
||||
if (editorViewRef.current === null) return;
|
||||
updateSchema(editorViewRef.current, introspection.data);
|
||||
}, [introspection.data]);
|
||||
updateSchema(editorViewRef.current, schema);
|
||||
}, [schema]);
|
||||
|
||||
const dialog = useDialog();
|
||||
|
||||
@@ -81,22 +81,38 @@ export function GraphQLEditor({ defaultValue, onChange, baseRequest, ...extraEdi
|
||||
placeholder="..."
|
||||
ref={editorViewRef}
|
||||
actions={
|
||||
(introspection.error || introspection.isLoading) && (
|
||||
(error || isLoading) && (
|
||||
<Button
|
||||
size="xs"
|
||||
color={introspection.error ? 'danger' : 'gray'}
|
||||
isLoading={introspection.isLoading}
|
||||
color={error ? 'danger' : 'gray'}
|
||||
isLoading={isLoading}
|
||||
onClick={() => {
|
||||
dialog.show({
|
||||
title: 'Introspection Failed',
|
||||
size: 'sm',
|
||||
id: 'introspection-failed',
|
||||
render: () => (
|
||||
<div className="whitespace-pre-wrap">{introspection.error?.message}</div>
|
||||
<div className="whitespace-pre-wrap">
|
||||
<FormattedError>{error ?? 'unknown'}</FormattedError>
|
||||
<div className="w-full mt-3">
|
||||
<Button
|
||||
onClick={() => {
|
||||
dialog.hide('introspection-failed');
|
||||
refetch();
|
||||
}}
|
||||
className="ml-auto"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
>
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{introspection.error ? 'Introspection Failed' : 'Introspecting'}
|
||||
{error ? 'Introspection Failed' : 'Introspecting'}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -111,6 +127,7 @@ export function GraphQLEditor({ defaultValue, onChange, baseRequest, ...extraEdi
|
||||
onChange={handleChangeVariables}
|
||||
placeholder="{}"
|
||||
useTemplating
|
||||
autocompleteVariables
|
||||
{...extraEditorProps}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,8 @@ type Props = {
|
||||
export function HeaderEditor({ headers, onChange, forceUpdateKey }: Props) {
|
||||
return (
|
||||
<PairEditor
|
||||
valueAutocompleteVariables
|
||||
nameAutocompleteVariables
|
||||
pairs={headers}
|
||||
onChange={onChange}
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
@@ -63,5 +65,7 @@ const validateHttpHeader = (v: string) => {
|
||||
return true;
|
||||
}
|
||||
|
||||
return v.match(/^[a-zA-Z0-9-_]+$/) !== null;
|
||||
// Template strings are not allowed so we replace them with a valid example string
|
||||
const withoutTemplateStrings = v.replace(/\$\{\[\s*[^\]\s]+\s*]}/gi, '123');
|
||||
return withoutTemplateStrings.match(/^[a-zA-Z0-9-_]+$/) !== null;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import classnames from 'classnames';
|
||||
import classNames from 'classnames';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import type { ReactNode } from 'react';
|
||||
@@ -10,6 +10,7 @@ interface Props {
|
||||
open: boolean;
|
||||
onClose?: () => void;
|
||||
zIndex?: keyof typeof zIndexes;
|
||||
variant?: 'default' | 'transparent';
|
||||
}
|
||||
|
||||
const zIndexes: Record<number, string> = {
|
||||
@@ -20,24 +21,36 @@ const zIndexes: Record<number, string> = {
|
||||
50: 'z-50',
|
||||
};
|
||||
|
||||
export function Overlay({ zIndex = 30, open, onClose, portalName, children }: Props) {
|
||||
export function Overlay({
|
||||
variant = 'default',
|
||||
zIndex = 30,
|
||||
open,
|
||||
onClose,
|
||||
portalName,
|
||||
children,
|
||||
}: Props) {
|
||||
return (
|
||||
<Portal name={portalName}>
|
||||
{open && (
|
||||
<FocusTrap>
|
||||
<motion.div
|
||||
className={classnames('fixed inset-0', zIndexes[zIndex])}
|
||||
className={classNames('fixed inset-0', zIndexes[zIndex])}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
onClick={onClose}
|
||||
className="absolute inset-0 bg-gray-600/30 dark:bg-black/30 backdrop-blur-sm"
|
||||
className={classNames(
|
||||
'absolute inset-0',
|
||||
variant === 'default' && 'bg-gray-600/30 dark:bg-black/30 backdrop-blur-sm',
|
||||
)}
|
||||
/>
|
||||
{/* Add region to still be able to drag the window */}
|
||||
<div data-tauri-drag-region className="absolute top-0 left-0 right-0 h-md" />
|
||||
{children}
|
||||
{variant !== 'transparent' && (
|
||||
<div data-tauri-drag-region className="absolute top-0 left-0 right-0 h-md" />
|
||||
)}
|
||||
<div className="bg-red-100">{children}</div>
|
||||
</motion.div>
|
||||
</FocusTrap>
|
||||
)}
|
||||
|
||||
@@ -9,17 +9,29 @@ import { Button } from './core/Button';
|
||||
import { CountBadge } from './core/CountBadge';
|
||||
import type { DropdownItem, DropdownRef } from './core/Dropdown';
|
||||
import { Dropdown } from './core/Dropdown';
|
||||
import classNames from 'classnames';
|
||||
import { useActiveEnvironmentId } from '../hooks/useActiveEnvironmentId';
|
||||
|
||||
export function RecentRequestsDropdown() {
|
||||
const dropdownRef = useRef<DropdownRef>(null);
|
||||
const activeRequest = useActiveRequest();
|
||||
const activeWorkspaceId = useActiveWorkspaceId();
|
||||
const recentRequestIds = useRecentRequests();
|
||||
const activeEnvironmentId = useActiveEnvironmentId();
|
||||
const requests = useRequests();
|
||||
const routes = useAppRoutes();
|
||||
const allRecentRequestIds = useRecentRequests();
|
||||
const recentRequestIds = useMemo(() => allRecentRequestIds.slice(1), [allRecentRequestIds]);
|
||||
|
||||
// Toggle the menu on Cmd+k
|
||||
useKey('k', (e) => {
|
||||
if (e.metaKey) {
|
||||
e.preventDefault();
|
||||
dropdownRef.current?.toggle(0);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle key-up
|
||||
useKeyPressEvent('Control', undefined, () => {
|
||||
// Key up
|
||||
dropdownRef.current?.select?.();
|
||||
});
|
||||
|
||||
@@ -29,8 +41,8 @@ export function RecentRequestsDropdown() {
|
||||
if (!e.ctrlKey || recentRequestIds.length === 0) return;
|
||||
|
||||
if (!dropdownRef.current?.isOpen) {
|
||||
// Set to 1 because the first item is the active request
|
||||
dropdownRef.current?.open(e.shiftKey ? -1 : 0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.shiftKey) dropdownRef.current?.prev?.();
|
||||
@@ -55,6 +67,7 @@ export function RecentRequestsDropdown() {
|
||||
onSelect: () => {
|
||||
routes.navigate('request', {
|
||||
requestId: request.id,
|
||||
environmentId: activeEnvironmentId ?? undefined,
|
||||
workspaceId: activeWorkspaceId,
|
||||
});
|
||||
},
|
||||
@@ -67,14 +80,16 @@ export function RecentRequestsDropdown() {
|
||||
}
|
||||
|
||||
return recentRequestItems.slice(0, 20);
|
||||
}, [activeWorkspaceId, recentRequestIds, requests, routes]);
|
||||
}, [activeWorkspaceId, activeEnvironmentId, recentRequestIds, requests, routes]);
|
||||
|
||||
return (
|
||||
<Dropdown ref={dropdownRef} items={items}>
|
||||
<Button
|
||||
disabled={activeRequest === null}
|
||||
size="sm"
|
||||
className="flex-[2] text-center text-gray-800 text-sm truncate pointer-events-none"
|
||||
className={classNames(
|
||||
'flex-[2] text-center text-gray-800 text-sm truncate pointer-events-none',
|
||||
activeRequest === null && 'text-opacity-disabled italic',
|
||||
)}
|
||||
>
|
||||
{activeRequest?.name ?? 'No Request'}
|
||||
</Button>
|
||||
|
||||
64
src-web/components/RecentResponsesDropdown.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useDeleteResponse } from '../hooks/useDeleteResponse';
|
||||
import { useDeleteResponses } from '../hooks/useDeleteResponses';
|
||||
import type { HttpResponse } from '../lib/models';
|
||||
import { Dropdown } from './core/Dropdown';
|
||||
import { pluralize } from '../lib/pluralize';
|
||||
import { HStack } from './core/Stacks';
|
||||
import { StatusTag } from './core/StatusTag';
|
||||
import { Icon } from './core/Icon';
|
||||
import { IconButton } from './core/IconButton';
|
||||
|
||||
interface Props {
|
||||
responses: HttpResponse[];
|
||||
activeResponse: HttpResponse;
|
||||
onPinnedResponse: (r: HttpResponse) => void;
|
||||
}
|
||||
|
||||
export const RecentResponsesDropdown = function ResponsePane({
|
||||
activeResponse,
|
||||
responses,
|
||||
onPinnedResponse,
|
||||
}: Props) {
|
||||
const deleteResponse = useDeleteResponse(activeResponse?.id ?? null);
|
||||
const deleteAllResponses = useDeleteResponses(activeResponse?.requestId);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
key: 'clear-single',
|
||||
label: 'Clear Response',
|
||||
onSelect: deleteResponse.mutate,
|
||||
disabled: responses.length === 0,
|
||||
},
|
||||
{
|
||||
key: 'clear-all',
|
||||
label: `Clear ${responses.length} ${pluralize('Response', responses.length)}`,
|
||||
onSelect: deleteAllResponses.mutate,
|
||||
hidden: responses.length <= 1,
|
||||
disabled: responses.length === 0,
|
||||
},
|
||||
{ type: 'separator', label: 'History' },
|
||||
...responses.slice(0, 20).map((r) => ({
|
||||
key: r.id,
|
||||
label: (
|
||||
<HStack space={2} alignItems="center">
|
||||
<StatusTag className="text-xs" response={r} />
|
||||
<span>•</span> <span className="font-mono text-xs">{r.elapsed}ms</span>
|
||||
</HStack>
|
||||
),
|
||||
leftSlot: activeResponse?.id === r.id ? <Icon icon="check" /> : <Icon icon="empty" />,
|
||||
onSelect: () => onPinnedResponse(r),
|
||||
})),
|
||||
]}
|
||||
>
|
||||
<IconButton
|
||||
title="Show response history"
|
||||
icon="triangleDown"
|
||||
className="ml-auto"
|
||||
size="sm"
|
||||
iconSize="md"
|
||||
/>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
@@ -2,12 +2,12 @@ import type { HTMLAttributes, ReactElement } from 'react';
|
||||
import React, { useRef } from 'react';
|
||||
import { useDeleteRequest } from '../hooks/useDeleteRequest';
|
||||
import { useDuplicateRequest } from '../hooks/useDuplicateRequest';
|
||||
import { useTauriEvent } from '../hooks/useTauriEvent';
|
||||
import { useTheme } from '../hooks/useTheme';
|
||||
import type { DropdownRef } from './core/Dropdown';
|
||||
import { Dropdown } from './core/Dropdown';
|
||||
import { HotKey } from './core/HotKey';
|
||||
import { Icon } from './core/Icon';
|
||||
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
|
||||
|
||||
interface Props {
|
||||
requestId: string;
|
||||
@@ -20,12 +20,12 @@ export function RequestActionsDropdown({ requestId, children }: Props) {
|
||||
const dropdownRef = useRef<DropdownRef>(null);
|
||||
const { appearance, toggleAppearance } = useTheme();
|
||||
|
||||
useTauriEvent('toggle_settings', () => {
|
||||
useListenToTauriEvent('toggle_settings', () => {
|
||||
dropdownRef.current?.toggle();
|
||||
});
|
||||
|
||||
// TODO: Put this somewhere better
|
||||
useTauriEvent('duplicate_request', () => {
|
||||
useListenToTauriEvent('duplicate_request', () => {
|
||||
duplicateRequest.mutate();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { invoke } from '@tauri-apps/api';
|
||||
import { appWindow } from '@tauri-apps/api/window';
|
||||
import classnames from 'classnames';
|
||||
import classNames from 'classnames';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { createGlobalState } from 'react-use';
|
||||
import { useActiveRequest } from '../hooks/useActiveRequest';
|
||||
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
|
||||
import { useTauriEvent } from '../hooks/useTauriEvent';
|
||||
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
|
||||
import { useUpdateRequest } from '../hooks/useUpdateRequest';
|
||||
import { tryFormatJson } from '../lib/formatters';
|
||||
import type { HttpHeader, HttpRequest } from '../lib/models';
|
||||
@@ -30,6 +30,7 @@ import { GraphQLEditor } from './GraphQLEditor';
|
||||
import { HeaderEditor } from './HeaderEditor';
|
||||
import { ParametersEditor } from './ParameterEditor';
|
||||
import { UrlBar } from './UrlBar';
|
||||
import { useActiveEnvironmentId } from '../hooks/useActiveEnvironmentId';
|
||||
|
||||
interface Props {
|
||||
style?: CSSProperties;
|
||||
@@ -42,6 +43,7 @@ const useActiveTab = createGlobalState<string>('body');
|
||||
export const RequestPane = memo(function RequestPane({ style, fullHeight, className }: Props) {
|
||||
const activeRequest = useActiveRequest();
|
||||
const activeRequestId = activeRequest?.id ?? null;
|
||||
const activeEnvironmentId = useActiveEnvironmentId();
|
||||
const updateRequest = useUpdateRequest(activeRequestId);
|
||||
const [activeTab, setActiveTab] = useActiveTab();
|
||||
const [forceUpdateHeaderEditorKey, setForceUpdateHeaderEditorKey] = useState<number>(0);
|
||||
@@ -140,19 +142,19 @@ export const RequestPane = memo(function RequestPane({ style, fullHeight, classN
|
||||
[updateRequest],
|
||||
);
|
||||
|
||||
useTauriEvent(
|
||||
useListenToTauriEvent(
|
||||
'send_request',
|
||||
async ({ windowLabel }) => {
|
||||
if (windowLabel !== appWindow.label) return;
|
||||
await invoke('send_request', { requestId: activeRequestId });
|
||||
await invoke('send_request', { requestId: activeRequestId, environmentId: activeEnvironmentId });
|
||||
},
|
||||
[activeRequestId],
|
||||
[activeRequestId, activeEnvironmentId],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={style}
|
||||
className={classnames(className, 'h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1')}
|
||||
className={classNames(className, 'h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1')}
|
||||
>
|
||||
{activeRequest && (
|
||||
<>
|
||||
@@ -202,6 +204,7 @@ export const RequestPane = memo(function RequestPane({ style, fullHeight, classN
|
||||
<Editor
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
useTemplating
|
||||
autocompleteVariables
|
||||
placeholder="..."
|
||||
className="!bg-gray-50"
|
||||
heightMode={fullHeight ? 'full' : 'auto'}
|
||||
@@ -214,6 +217,7 @@ export const RequestPane = memo(function RequestPane({ style, fullHeight, classN
|
||||
<Editor
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
useTemplating
|
||||
autocompleteVariables
|
||||
placeholder="..."
|
||||
className="!bg-gray-50"
|
||||
heightMode={fullHeight ? 'full' : 'auto'}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import useResizeObserver from '@react-hook/resize-observer';
|
||||
import classnames from 'classnames';
|
||||
import classNames from 'classnames';
|
||||
import type { CSSProperties, MouseEvent as ReactMouseEvent } from 'react';
|
||||
import React, { memo, useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useLocalStorage } from 'react-use';
|
||||
@@ -120,7 +120,7 @@ export const RequestResponse = memo(function RequestResponse({ style }: Props) {
|
||||
<ResizeHandle
|
||||
style={drag}
|
||||
isResizing={isResizing}
|
||||
className={classnames(vertical ? 'translate-y-0.5' : 'translate-x-0.5')}
|
||||
className={classNames(vertical ? 'translate-y-0.5' : 'translate-x-0.5')}
|
||||
onResizeStart={handleResizeStart}
|
||||
onReset={handleReset}
|
||||
side={vertical ? 'top' : 'left'}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import classnames from 'classnames';
|
||||
import classNames from 'classnames';
|
||||
import type { CSSProperties, MouseEvent as ReactMouseEvent } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
@@ -28,7 +28,7 @@ export function ResizeHandle({
|
||||
aria-hidden
|
||||
draggable
|
||||
style={style}
|
||||
className={classnames(
|
||||
className={classNames(
|
||||
className,
|
||||
'group z-10 flex',
|
||||
vertical ? 'w-full h-3 cursor-row-resize' : 'h-full w-3 cursor-col-resize',
|
||||
@@ -45,7 +45,7 @@ export function ResizeHandle({
|
||||
{/* Show global overlay with cursor style to ensure cursor remains the same when moving quickly */}
|
||||
{isResizing && (
|
||||
<div
|
||||
className={classnames(
|
||||
className={classNames(
|
||||
'fixed -left-20 -right-20 -top-20 -bottom-20',
|
||||
vertical && 'cursor-row-resize',
|
||||
!vertical && 'cursor-col-resize',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import classnames from 'classnames';
|
||||
import classNames from 'classnames';
|
||||
import type { HttpResponse } from '../lib/models';
|
||||
import { HStack } from './core/Stacks';
|
||||
|
||||
@@ -14,7 +14,7 @@ export function ResponseHeaders({ headers }: Props) {
|
||||
<HStack
|
||||
space={3}
|
||||
key={i}
|
||||
className={classnames(i > 0 ? 'border-t border-highlightSecondary py-1' : 'pb-1')}
|
||||
className={classNames(i > 0 ? 'border-t border-highlightSecondary py-1' : 'pb-1')}
|
||||
>
|
||||
<dd className="w-1/3 text-violet-600 select-text cursor-text">{h.name}</dd>
|
||||
<dt className="w-2/3 select-text cursor-text break-all">{h.value}</dt>
|
||||
|
||||
@@ -1,23 +1,17 @@
|
||||
import classnames from 'classnames';
|
||||
import classNames from 'classnames';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { memo, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, memo, useEffect, useMemo, useState } from 'react';
|
||||
import { createGlobalState } from 'react-use';
|
||||
import { useActiveRequestId } from '../hooks/useActiveRequestId';
|
||||
import { useDeleteResponse } from '../hooks/useDeleteResponse';
|
||||
import { useDeleteResponses } from '../hooks/useDeleteResponses';
|
||||
import { useLatestResponse } from '../hooks/useLatestResponse';
|
||||
import { useResponseContentType } from '../hooks/useResponseContentType';
|
||||
import { useResponses } from '../hooks/useResponses';
|
||||
import { useResponseViewMode } from '../hooks/useResponseViewMode';
|
||||
import type { HttpResponse } from '../lib/models';
|
||||
import { isResponseLoading } from '../lib/models';
|
||||
import { pluralize } from '../lib/pluralize';
|
||||
import { Banner } from './core/Banner';
|
||||
import { CountBadge } from './core/CountBadge';
|
||||
import { Dropdown } from './core/Dropdown';
|
||||
import { DurationTag } from './core/DurationTag';
|
||||
import { Icon } from './core/Icon';
|
||||
import { IconButton } from './core/IconButton';
|
||||
import { SizeTag } from './core/SizeTag';
|
||||
import { HStack } from './core/Stacks';
|
||||
import { StatusTag } from './core/StatusTag';
|
||||
@@ -29,6 +23,7 @@ import { CsvViewer } from './responseViewers/CsvViewer';
|
||||
import { ImageViewer } from './responseViewers/ImageViewer';
|
||||
import { TextViewer } from './responseViewers/TextViewer';
|
||||
import { WebPageViewer } from './responseViewers/WebPageViewer';
|
||||
import { RecentResponsesDropdown } from './RecentResponsesDropdown';
|
||||
|
||||
interface Props {
|
||||
style?: CSSProperties;
|
||||
@@ -46,8 +41,6 @@ export const ResponsePane = memo(function ResponsePane({ style, className }: Pro
|
||||
? responses.find((r) => r.id === pinnedResponseId) ?? null
|
||||
: latestResponse ?? null;
|
||||
const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId);
|
||||
const deleteResponse = useDeleteResponse(activeResponse?.id ?? null);
|
||||
const deleteAllResponses = useDeleteResponses(activeResponse?.requestId);
|
||||
const [activeTab, setActiveTab] = useActiveTab();
|
||||
|
||||
// Unset pinned response when a new one comes in
|
||||
@@ -55,6 +48,10 @@ export const ResponsePane = memo(function ResponsePane({ style, className }: Pro
|
||||
|
||||
const contentType = useResponseContentType(activeResponse);
|
||||
|
||||
const handlePinnedResponse = useCallback((r: HttpResponse) => {
|
||||
setPinnedResponseId(r.id);
|
||||
}, [setPinnedResponseId])
|
||||
|
||||
const tabs: TabItem[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -87,7 +84,7 @@ export const ResponsePane = memo(function ResponsePane({ style, className }: Pro
|
||||
return (
|
||||
<div
|
||||
style={style}
|
||||
className={classnames(
|
||||
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',
|
||||
@@ -99,7 +96,7 @@ export const ResponsePane = memo(function ResponsePane({ style, className }: Pro
|
||||
<>
|
||||
<HStack
|
||||
alignItems="center"
|
||||
className={classnames(
|
||||
className={classNames(
|
||||
'text-gray-700 text-sm w-full flex-shrink-0',
|
||||
// Remove a bit of space because the tabs have lots too
|
||||
'-mb-1.5',
|
||||
@@ -125,44 +122,11 @@ export const ResponsePane = memo(function ResponsePane({ style, className }: Pro
|
||||
</HStack>
|
||||
</div>
|
||||
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
key: 'clear-single',
|
||||
label: 'Clear Response',
|
||||
onSelect: deleteResponse.mutate,
|
||||
disabled: responses.length === 0,
|
||||
},
|
||||
{
|
||||
key: 'clear-all',
|
||||
label: `Clear ${responses.length} ${pluralize('Response', responses.length)}`,
|
||||
onSelect: deleteAllResponses.mutate,
|
||||
hidden: responses.length <= 1,
|
||||
disabled: responses.length === 0,
|
||||
},
|
||||
{ type: 'separator', label: 'History' },
|
||||
...responses.slice(0, 20).map((r) => ({
|
||||
key: r.id,
|
||||
label: (
|
||||
<HStack space={2}>
|
||||
<StatusTag className="text-xs" response={r} />
|
||||
<span>•</span> <span>{r.elapsed}ms</span>
|
||||
</HStack>
|
||||
),
|
||||
leftSlot:
|
||||
activeResponse?.id === r.id ? <Icon icon="check" /> : <Icon icon="empty" />,
|
||||
onSelect: () => setPinnedResponseId(r.id),
|
||||
})),
|
||||
]}
|
||||
>
|
||||
<IconButton
|
||||
title="Show response history"
|
||||
icon="triangleDown"
|
||||
className="ml-auto"
|
||||
size="sm"
|
||||
iconSize="md"
|
||||
/>
|
||||
</Dropdown>
|
||||
<RecentResponsesDropdown
|
||||
responses={responses}
|
||||
activeResponse={activeResponse}
|
||||
onPinnedResponse={handlePinnedResponse}
|
||||
/>
|
||||
</HStack>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import { useRouteError } from 'react-router-dom';
|
||||
import { useAppRoutes } from '../hooks/useAppRoutes';
|
||||
import { Button } from './core/Button';
|
||||
import { FormattedError } from './core/FormattedError';
|
||||
import { Heading } from './core/Heading';
|
||||
import { VStack } from './core/Stacks';
|
||||
|
||||
export default function RouteError() {
|
||||
const error = useRouteError();
|
||||
console.log("Error", error);
|
||||
const stringified = JSON.stringify(error);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const message = (error as any).message ?? stringified;
|
||||
const routes = useAppRoutes();
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<VStack space={5} className="max-w-[30rem] !h-auto">
|
||||
<VStack space={5} className="max-w-[50rem] !h-auto">
|
||||
<Heading>Route Error 🔥</Heading>
|
||||
<pre className="text-sm select-auto cursor-text bg-gray-100 p-3 rounded whitespace-normal">
|
||||
{message}
|
||||
</pre>
|
||||
<FormattedError>{message}</FormattedError>
|
||||
<VStack space={2}>
|
||||
<Button
|
||||
color="primary"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import classnames from 'classnames';
|
||||
import classNames from 'classnames';
|
||||
import type { ForwardedRef } from 'react';
|
||||
import React, { forwardRef, Fragment, memo, useCallback, useMemo, useRef, useState } from 'react';
|
||||
import type { XYCoord } from 'react-dnd';
|
||||
@@ -10,15 +10,17 @@ import { useDeleteAnyRequest } from '../hooks/useDeleteAnyRequest';
|
||||
import { useLatestResponse } from '../hooks/useLatestResponse';
|
||||
import { useRequests } from '../hooks/useRequests';
|
||||
import { useSidebarHidden } from '../hooks/useSidebarHidden';
|
||||
import { useTauriEvent } from '../hooks/useTauriEvent';
|
||||
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
|
||||
import { useUpdateAnyRequest } from '../hooks/useUpdateAnyRequest';
|
||||
import { useUpdateRequest } from '../hooks/useUpdateRequest';
|
||||
import type { HttpRequest } from '../lib/models';
|
||||
import { isResponseLoading } from '../lib/models';
|
||||
import { Icon } from './core/Icon';
|
||||
import { VStack } from './core/Stacks';
|
||||
import { StatusTag } from './core/StatusTag';
|
||||
import { DropMarker } from './DropMarker';
|
||||
import { useActiveEnvironmentId } from '../hooks/useActiveEnvironmentId';
|
||||
import { useCreateRequest } from '../hooks/useCreateRequest';
|
||||
import { VStack } from './core/Stacks';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
@@ -30,8 +32,10 @@ enum ItemTypes {
|
||||
|
||||
export const Sidebar = memo(function Sidebar({ className }: Props) {
|
||||
const { hidden } = useSidebarHidden();
|
||||
const createRequest = useCreateRequest();
|
||||
const sidebarRef = useRef<HTMLDivElement>(null);
|
||||
const activeRequestId = useActiveRequestId();
|
||||
const activeEnvironmentId = useActiveEnvironmentId();
|
||||
const unorderedRequests = useRequests();
|
||||
const deleteAnyRequest = useDeleteAnyRequest();
|
||||
const routes = useAppRoutes();
|
||||
@@ -42,6 +46,9 @@ export const Sidebar = memo(function Sidebar({ className }: Props) {
|
||||
const [hasFocus, setHasFocus] = useState<boolean>(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState<number>();
|
||||
|
||||
// TODO: Move these listeners to a central place
|
||||
useListenToTauriEvent('new_request', async () => createRequest.mutate({}));
|
||||
|
||||
const focusActiveRequest = useCallback(
|
||||
(forcedIndex?: number) => {
|
||||
const index = forcedIndex ?? requests.findIndex((r) => r.id === activeRequestId);
|
||||
@@ -58,13 +65,21 @@ export const Sidebar = memo(function Sidebar({ className }: Props) {
|
||||
const index = requests.findIndex((r) => r.id === requestId);
|
||||
const request = requests[index];
|
||||
if (!request) return;
|
||||
routes.navigate('request', { requestId, workspaceId: request.workspaceId });
|
||||
routes.navigate('request', {
|
||||
requestId,
|
||||
workspaceId: request.workspaceId,
|
||||
environmentId: activeEnvironmentId ?? undefined,
|
||||
});
|
||||
setSelectedIndex(index);
|
||||
focusActiveRequest(index);
|
||||
},
|
||||
[focusActiveRequest, requests, routes],
|
||||
[focusActiveRequest, requests, routes, activeEnvironmentId],
|
||||
);
|
||||
|
||||
const handleClearSelected = useCallback(() => {
|
||||
setSelectedIndex(undefined);
|
||||
}, [setSelectedIndex]);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
if (hasFocus) return;
|
||||
focusActiveRequest();
|
||||
@@ -87,7 +102,7 @@ export const Sidebar = memo(function Sidebar({ className }: Props) {
|
||||
useKeyPressEvent('Backspace', handleDeleteKey);
|
||||
useKeyPressEvent('Delete', handleDeleteKey);
|
||||
|
||||
useTauriEvent(
|
||||
useListenToTauriEvent(
|
||||
'focus_sidebar',
|
||||
() => {
|
||||
if (hidden || hasFocus) return;
|
||||
@@ -102,7 +117,11 @@ export const Sidebar = memo(function Sidebar({ className }: Props) {
|
||||
const request = requests[selectedIndex ?? -1];
|
||||
if (!request || request.id === activeRequestId) return;
|
||||
e.preventDefault();
|
||||
routes.navigate('request', { requestId: request.id, workspaceId: request.workspaceId });
|
||||
routes.navigate('request', {
|
||||
requestId: request.id,
|
||||
workspaceId: request.workspaceId,
|
||||
environmentId: activeEnvironmentId ?? undefined,
|
||||
});
|
||||
});
|
||||
|
||||
useKey(
|
||||
@@ -134,8 +153,9 @@ export const Sidebar = memo(function Sidebar({ className }: Props) {
|
||||
);
|
||||
|
||||
return (
|
||||
<div aria-hidden={hidden} className="relative h-full">
|
||||
<div
|
||||
<div aria-hidden={hidden} className="h-full">
|
||||
<VStack
|
||||
as="ul"
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
dir="ltr"
|
||||
@@ -143,21 +163,19 @@ export const Sidebar = memo(function Sidebar({ className }: Props) {
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
tabIndex={hidden ? -1 : 0}
|
||||
className={classnames(className, 'h-full relative grid grid-rows-[minmax(0,1fr)_auto]')}
|
||||
className={classNames(
|
||||
className,
|
||||
'h-full pb-3 overflow-y-scroll overflow-x-visible hide-scrollbars pt-2',
|
||||
)}
|
||||
>
|
||||
<VStack
|
||||
as="ul"
|
||||
className="relative py-3 overflow-y-auto overflow-x-visible"
|
||||
draggable={false}
|
||||
>
|
||||
<SidebarItems
|
||||
selectedIndex={selectedIndex}
|
||||
requests={requests}
|
||||
focused={hasFocus}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
</VStack>
|
||||
</div>
|
||||
<SidebarItems
|
||||
selectedIndex={selectedIndex}
|
||||
requests={requests}
|
||||
focused={hasFocus}
|
||||
onSelect={handleSelect}
|
||||
onClearSelected={handleClearSelected}
|
||||
/>
|
||||
</VStack>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -167,9 +185,16 @@ interface SidebarItemsProps {
|
||||
focused: boolean;
|
||||
selectedIndex?: number;
|
||||
onSelect: (requestId: string) => void;
|
||||
onClearSelected: () => void;
|
||||
}
|
||||
|
||||
function SidebarItems({ requests, focused, selectedIndex, onSelect }: SidebarItemsProps) {
|
||||
function SidebarItems({
|
||||
requests,
|
||||
focused,
|
||||
selectedIndex,
|
||||
onSelect,
|
||||
onClearSelected,
|
||||
}: SidebarItemsProps) {
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
const updateRequest = useUpdateAnyRequest();
|
||||
|
||||
@@ -185,6 +210,7 @@ function SidebarItems({ requests, focused, selectedIndex, onSelect }: SidebarIte
|
||||
(requestId) => {
|
||||
if (hoveredIndex === null) return;
|
||||
setHoveredIndex(null);
|
||||
onClearSelected();
|
||||
|
||||
const index = requests.findIndex((r) => r.id === requestId);
|
||||
const request = requests[index];
|
||||
@@ -194,8 +220,10 @@ function SidebarItems({ requests, focused, selectedIndex, onSelect }: SidebarIte
|
||||
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;
|
||||
// Do a simple find because the math is too hard
|
||||
const newIndex = newRequests.findIndex((r) => r.id === requestId) ?? 0;
|
||||
const beforePriority = newRequests[newIndex - 1]?.sortPriority ?? 0;
|
||||
const afterPriority = newRequests[newIndex + 1]?.sortPriority ?? 0;
|
||||
|
||||
const shouldUpdateAll = afterPriority - beforePriority < 1;
|
||||
if (shouldUpdateAll) {
|
||||
@@ -210,7 +238,7 @@ function SidebarItems({ requests, focused, selectedIndex, onSelect }: SidebarIte
|
||||
updateRequest.mutate({ id: requestId, update });
|
||||
}
|
||||
},
|
||||
[hoveredIndex, requests, updateRequest],
|
||||
[hoveredIndex, requests, updateRequest, onClearSelected],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -242,6 +270,7 @@ type SidebarItemProps = {
|
||||
useProminentStyles?: boolean;
|
||||
selected?: boolean;
|
||||
onSelect: (requestId: string) => void;
|
||||
draggable?: boolean;
|
||||
};
|
||||
|
||||
const _SidebarItem = forwardRef(function SidebarItem(
|
||||
@@ -255,8 +284,8 @@ const _SidebarItem = forwardRef(function SidebarItem(
|
||||
const isActive = activeRequestId === requestId;
|
||||
|
||||
const handleSubmitNameEdit = useCallback(
|
||||
async (el: HTMLInputElement) => {
|
||||
await updateRequest.mutate((r) => ({ ...r, name: el.value }));
|
||||
(el: HTMLInputElement) => {
|
||||
updateRequest.mutate((r) => ({ ...r, name: el.value }));
|
||||
setEditing(false);
|
||||
},
|
||||
[updateRequest],
|
||||
@@ -273,7 +302,7 @@ const _SidebarItem = forwardRef(function SidebarItem(
|
||||
switch (e.key) {
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
await handleSubmitNameEdit(e.currentTarget);
|
||||
handleSubmitNameEdit(e.currentTarget);
|
||||
break;
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
@@ -288,7 +317,7 @@ const _SidebarItem = forwardRef(function SidebarItem(
|
||||
|
||||
const handleBlur = useCallback(
|
||||
(e: React.FocusEvent<HTMLInputElement>) => {
|
||||
handleSubmitNameEdit(e.currentTarget).catch(console.error);
|
||||
handleSubmitNameEdit(e.currentTarget);
|
||||
},
|
||||
[handleSubmitNameEdit],
|
||||
);
|
||||
@@ -298,18 +327,15 @@ const _SidebarItem = forwardRef(function SidebarItem(
|
||||
}, [onSelect, requestId]);
|
||||
|
||||
return (
|
||||
<li ref={ref} className={classnames(className, 'block group/item px-2 pb-0.5')}>
|
||||
<li ref={ref} className={classNames(className, 'block group/item px-2 pb-0.5')}>
|
||||
<button
|
||||
tabIndex={-1}
|
||||
color="custom"
|
||||
// tabIndex={-1} // Will prevent drag-n-drop
|
||||
onClick={handleSelect}
|
||||
disabled={editing}
|
||||
draggable={false} // Item should drag, not the link
|
||||
onDoubleClick={handleStartEditing}
|
||||
data-active={isActive}
|
||||
data-selected={selected}
|
||||
className={classnames(
|
||||
// 'outline-none',
|
||||
className={classNames(
|
||||
'w-full flex items-center text-sm h-xs px-2 rounded-md transition-colors',
|
||||
editing && 'ring-1 focus-within:ring-focus',
|
||||
isActive && 'bg-highlight text-gray-800',
|
||||
@@ -326,7 +352,7 @@ const _SidebarItem = forwardRef(function SidebarItem(
|
||||
onKeyDown={handleInputKeyDown}
|
||||
/>
|
||||
) : (
|
||||
<span className={classnames('truncate', !requestName && 'text-gray-400 italic')}>
|
||||
<span className={classNames('truncate', !requestName && 'text-gray-400 italic')}>
|
||||
{requestName || 'New Request'}
|
||||
</span>
|
||||
)}
|
||||
@@ -343,6 +369,7 @@ const _SidebarItem = forwardRef(function SidebarItem(
|
||||
</li>
|
||||
);
|
||||
});
|
||||
|
||||
const SidebarItem = memo(_SidebarItem);
|
||||
|
||||
type DraggableSidebarItemProps = SidebarItemProps & {
|
||||
@@ -367,7 +394,7 @@ const DraggableSidebarItem = memo(function DraggableSidebarItem({
|
||||
const [, connectDrop] = useDrop<DragItem, void>(
|
||||
{
|
||||
accept: ItemTypes.REQUEST,
|
||||
hover: (item, monitor) => {
|
||||
hover: (_, monitor) => {
|
||||
if (!ref.current) return;
|
||||
const hoverBoundingRect = ref.current?.getBoundingClientRect();
|
||||
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
|
||||
@@ -396,7 +423,8 @@ const DraggableSidebarItem = memo(function DraggableSidebarItem({
|
||||
return (
|
||||
<SidebarItem
|
||||
ref={ref}
|
||||
className={classnames(isDragging && 'opacity-20')}
|
||||
draggable
|
||||
className={classNames(isDragging && 'opacity-20')}
|
||||
requestName={requestName}
|
||||
requestId={requestId}
|
||||
{...props}
|
||||
|
||||
@@ -1,37 +1,30 @@
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useCreateRequest } from '../hooks/useCreateRequest';
|
||||
import { memo } from 'react';
|
||||
import { useSidebarHidden } from '../hooks/useSidebarHidden';
|
||||
import { useTauriEvent } from '../hooks/useTauriEvent';
|
||||
import { IconButton } from './core/IconButton';
|
||||
import { useCreateRequest } from '../hooks/useCreateRequest';
|
||||
import { HStack } from './core/Stacks';
|
||||
|
||||
export const SidebarActions = memo(function SidebarActions() {
|
||||
const createRequest = useCreateRequest();
|
||||
const { hidden, toggle } = useSidebarHidden();
|
||||
const createRequest = useCreateRequest({ navigateAfter: true });
|
||||
|
||||
const handleCreateRequest = useCallback(() => {
|
||||
createRequest.mutate({});
|
||||
}, [createRequest]);
|
||||
|
||||
useTauriEvent('new_request', () => {
|
||||
createRequest.mutate({});
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<HStack>
|
||||
{hidden && (
|
||||
<IconButton
|
||||
onClick={toggle}
|
||||
className="pointer-events-auto"
|
||||
size="sm"
|
||||
title="Show sidebar"
|
||||
icon={hidden ? 'leftPanelHidden' : 'leftPanelVisible'}
|
||||
/>
|
||||
)}
|
||||
<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"
|
||||
title="Create Request"
|
||||
onClick={() => createRequest.mutate({})}
|
||||
/>
|
||||
</>
|
||||
</HStack>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import classnames from 'classnames';
|
||||
import classNames from 'classnames';
|
||||
import type { EditorView } from 'codemirror';
|
||||
import type { FormEvent } from 'react';
|
||||
import { memo, useCallback, useRef } from 'react';
|
||||
import { memo, useCallback, useRef, useState } from 'react';
|
||||
import { useIsResponseLoading } from '../hooks/useIsResponseLoading';
|
||||
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
|
||||
import { useSendRequest } from '../hooks/useSendRequest';
|
||||
import { useTauriEvent } from '../hooks/useTauriEvent';
|
||||
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
|
||||
import { useUpdateRequest } from '../hooks/useUpdateRequest';
|
||||
import type { HttpRequest } from '../lib/models';
|
||||
import { IconButton } from './core/IconButton';
|
||||
@@ -20,6 +20,7 @@ export const UrlBar = memo(function UrlBar({ id: requestId, url, method, classNa
|
||||
const inputRef = useRef<EditorView>(null);
|
||||
const sendRequest = useSendRequest(requestId);
|
||||
const updateRequest = useUpdateRequest(requestId);
|
||||
const [isFocused, setIsFocused] = useState<boolean>(false);
|
||||
const handleMethodChange = useCallback(
|
||||
(method: string) => updateRequest.mutate({ method }),
|
||||
[updateRequest],
|
||||
@@ -39,22 +40,25 @@ export const UrlBar = memo(function UrlBar({ id: requestId, url, method, classNa
|
||||
[sendRequest],
|
||||
);
|
||||
|
||||
useTauriEvent('focus_url', () => {
|
||||
useListenToTauriEvent('focus_url', () => {
|
||||
inputRef.current?.focus();
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className={classnames('url-bar', className)}>
|
||||
<form onSubmit={handleSubmit} className={classNames('url-bar', className)}>
|
||||
<Input
|
||||
autocompleteVariables
|
||||
ref={inputRef}
|
||||
size="sm"
|
||||
size={isFocused ? 'auto' : 'sm'}
|
||||
hideLabel
|
||||
useTemplating
|
||||
contentType="url"
|
||||
className="px-0"
|
||||
className="px-0 py-0.5"
|
||||
name="url"
|
||||
label="Enter URL"
|
||||
forceUpdateKey={updateKey}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
containerClassName="shadow shadow-gray-100 dark:shadow-gray-50"
|
||||
onChange={handleUrlChange}
|
||||
defaultValue={url}
|
||||
@@ -63,7 +67,7 @@ export const UrlBar = memo(function UrlBar({ id: requestId, url, method, classNa
|
||||
<RequestMethodDropdown
|
||||
method={method}
|
||||
onChange={handleMethodChange}
|
||||
className="mx-0.5 h-full my-1"
|
||||
className="!h-auto mx-0.5 my-0.5"
|
||||
/>
|
||||
}
|
||||
rightSlot={
|
||||
@@ -72,7 +76,7 @@ export const UrlBar = memo(function UrlBar({ id: requestId, url, method, classNa
|
||||
iconSize="sm"
|
||||
title="Send Request"
|
||||
type="submit"
|
||||
className="w-8 mr-0.5"
|
||||
className="!h-auto w-8 mr-0.5 my-0.5"
|
||||
icon={loading ? 'update' : 'paperPlane'}
|
||||
spin={loading}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import classnames from 'classnames';
|
||||
import classNames from 'classnames';
|
||||
import { motion } from 'framer-motion';
|
||||
import type {
|
||||
CSSProperties,
|
||||
@@ -6,12 +6,12 @@ import type {
|
||||
MouseEvent as ReactMouseEvent,
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useWindowSize } from 'react-use';
|
||||
import { useOsInfo } from '../hooks/useOsInfo';
|
||||
import { useSidebarHidden } from '../hooks/useSidebarHidden';
|
||||
import { useSidebarWidth } from '../hooks/useSidebarWidth';
|
||||
import { useTauriEvent } from '../hooks/useTauriEvent';
|
||||
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
|
||||
import { WINDOW_FLOATING_SIDEBAR_WIDTH } from '../lib/constants';
|
||||
import { Button } from './core/Button';
|
||||
import { HStack } from './core/Stacks';
|
||||
@@ -29,7 +29,7 @@ const drag = { gridArea: 'drag' };
|
||||
|
||||
export default function Workspace() {
|
||||
const { setWidth, width, resetWidth } = useSidebarWidth();
|
||||
const { show, hide, hidden, toggle } = useSidebarHidden();
|
||||
const { hide, show, hidden, toggle } = useSidebarHidden();
|
||||
|
||||
const windowSize = useWindowSize();
|
||||
const [floating, setFloating] = useState<boolean>(false);
|
||||
@@ -38,20 +38,13 @@ export default function Workspace() {
|
||||
null,
|
||||
);
|
||||
|
||||
useTauriEvent('toggle_sidebar', toggle);
|
||||
useListenToTauriEvent('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
|
||||
if (shouldHide) setFloating(true);
|
||||
else if (!shouldHide) setFloating(false);
|
||||
}, [windowSize.width]);
|
||||
|
||||
const unsub = () => {
|
||||
@@ -71,7 +64,14 @@ export default function Workspace() {
|
||||
moveState.current = {
|
||||
move: async (e: MouseEvent) => {
|
||||
e.preventDefault(); // Prevent text selection and things
|
||||
setWidth(startWidth + (e.clientX - mouseStartX));
|
||||
const newWidth = startWidth + (e.clientX - mouseStartX);
|
||||
if (newWidth < 100) {
|
||||
hide();
|
||||
resetWidth();
|
||||
} else {
|
||||
show();
|
||||
setWidth(newWidth);
|
||||
}
|
||||
},
|
||||
up: (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -83,7 +83,7 @@ export default function Workspace() {
|
||||
document.documentElement.addEventListener('mouseup', moveState.current.up);
|
||||
setIsResizing(true);
|
||||
},
|
||||
[setWidth, width],
|
||||
[setWidth, resetWidth, width, hide, show],
|
||||
);
|
||||
|
||||
const sideWidth = hidden ? 0 : width;
|
||||
@@ -113,7 +113,7 @@ export default function Workspace() {
|
||||
return (
|
||||
<div
|
||||
style={styles}
|
||||
className={classnames(
|
||||
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
|
||||
@@ -121,11 +121,11 @@ export default function Workspace() {
|
||||
)}
|
||||
>
|
||||
{floating ? (
|
||||
<Overlay open={!hidden} portalName="sidebar" onClose={hide}>
|
||||
<Overlay open={!hidden} portalName="sidebar" onClose={hide} zIndex={10}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className={classnames(
|
||||
className={classNames(
|
||||
'absolute top-0 left-0 bottom-0 bg-gray-100 border-r border-highlight w-[14rem]',
|
||||
'grid grid-rows-[auto_1fr]',
|
||||
)}
|
||||
@@ -140,7 +140,7 @@ export default function Workspace() {
|
||||
</Overlay>
|
||||
) : (
|
||||
<>
|
||||
<div style={side} className={classnames('overflow-hidden bg-gray-100')}>
|
||||
<div style={side} className={classNames('overflow-hidden bg-gray-100')}>
|
||||
<Sidebar className="border-r border-highlight" />
|
||||
</div>
|
||||
<ResizeHandle
|
||||
@@ -173,7 +173,7 @@ function HeaderSize({ className, ...props }: HeaderSizeProps) {
|
||||
const platform = useOsInfo();
|
||||
return (
|
||||
<div
|
||||
className={classnames(
|
||||
className={classNames(
|
||||
className,
|
||||
'h-md pt-[1px] flex items-center w-full pr-3 pl-20 border-b',
|
||||
platform?.osType === 'Darwin' && 'pl-20',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { invoke } from '@tauri-apps/api';
|
||||
import classnames from 'classnames';
|
||||
import classNames from 'classnames';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
|
||||
import { useAppRoutes } from '../hooks/useAppRoutes';
|
||||
@@ -9,18 +9,21 @@ import { usePrompt } from '../hooks/usePrompt';
|
||||
import { useUpdateWorkspace } from '../hooks/useUpdateWorkspace';
|
||||
import { useWorkspaces } from '../hooks/useWorkspaces';
|
||||
import { Button } from './core/Button';
|
||||
import type { ButtonProps } from './core/Button';
|
||||
import type { DropdownItem } from './core/Dropdown';
|
||||
import { Dropdown } from './core/Dropdown';
|
||||
import { Icon } from './core/Icon';
|
||||
import { InlineCode } from './core/InlineCode';
|
||||
import { HStack } from './core/Stacks';
|
||||
import { useDialog } from './DialogContext';
|
||||
import { getRecentEnvironments } from '../hooks/useRecentEnvironments';
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
};
|
||||
type Props = Pick<ButtonProps, 'className' | 'justify' | 'forDropdown'>;
|
||||
|
||||
export const WorkspaceActionsDropdown = memo(function WorkspaceDropdown({ className }: Props) {
|
||||
export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
|
||||
className,
|
||||
...buttonProps
|
||||
}: Props) {
|
||||
const workspaces = useWorkspaces();
|
||||
const activeWorkspace = useActiveWorkspace();
|
||||
const activeWorkspaceId = activeWorkspace?.id ?? null;
|
||||
@@ -32,9 +35,10 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceDropdown({ classN
|
||||
const routes = useAppRoutes();
|
||||
|
||||
const items: DropdownItem[] = useMemo(() => {
|
||||
const workspaceItems = workspaces.map((w) => ({
|
||||
const workspaceItems: DropdownItem[] = workspaces.map((w) => ({
|
||||
key: w.id,
|
||||
label: w.name,
|
||||
rightSlot: w.id === activeWorkspaceId ? <Icon icon="check" /> : undefined,
|
||||
onSelect: async () => {
|
||||
dialog.show({
|
||||
id: 'open-workspace',
|
||||
@@ -51,26 +55,28 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceDropdown({ classN
|
||||
<Button
|
||||
className="focus"
|
||||
color="gray"
|
||||
onClick={() => {
|
||||
rightSlot={<Icon icon="openNewWindow" />}
|
||||
onClick={async () => {
|
||||
hide();
|
||||
routes.navigate('workspace', { workspaceId: w.id });
|
||||
const environmentId = (await getRecentEnvironments(w.id))[0];
|
||||
await invoke('new_window', {
|
||||
url: routes.paths.workspace({ workspaceId: w.id, environmentId }),
|
||||
});
|
||||
}}
|
||||
>
|
||||
This Window
|
||||
New Window
|
||||
</Button>
|
||||
<Button
|
||||
autoFocus
|
||||
className="focus"
|
||||
color="gray"
|
||||
rightSlot={<Icon icon="openNewWindow" />}
|
||||
onClick={async () => {
|
||||
hide();
|
||||
await invoke('new_window', {
|
||||
url: routes.paths.workspace({ workspaceId: w.id }),
|
||||
});
|
||||
const environmentId = (await getRecentEnvironments(w.id))[0];
|
||||
routes.navigate('workspace', { workspaceId: w.id, environmentId });
|
||||
}}
|
||||
>
|
||||
New Window
|
||||
This Window
|
||||
</Button>
|
||||
</HStack>
|
||||
);
|
||||
@@ -136,22 +142,24 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceDropdown({ classN
|
||||
},
|
||||
];
|
||||
}, [
|
||||
workspaces,
|
||||
activeWorkspace?.name,
|
||||
activeWorkspaceId,
|
||||
createWorkspace,
|
||||
deleteWorkspace.mutate,
|
||||
dialog,
|
||||
routes,
|
||||
prompt,
|
||||
routes,
|
||||
updateWorkspace,
|
||||
createWorkspace,
|
||||
workspaces,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Dropdown items={items}>
|
||||
<Button
|
||||
size="sm"
|
||||
className={classnames(className, 'text-gray-800 !px-2 truncate')}
|
||||
forDropdown
|
||||
size="sm"
|
||||
className={classNames(className, 'text-gray-800 !px-2 truncate')}
|
||||
{...buttonProps}
|
||||
>
|
||||
{activeWorkspace?.name}
|
||||
</Button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import classnames from 'classnames';
|
||||
import classNames from 'classnames';
|
||||
import { memo } from 'react';
|
||||
import { useActiveRequest } from '../hooks/useActiveRequest';
|
||||
import { IconButton } from './core/IconButton';
|
||||
@@ -6,6 +6,7 @@ import { HStack } from './core/Stacks';
|
||||
import { RecentRequestsDropdown } from './RecentRequestsDropdown';
|
||||
import { RequestActionsDropdown } from './RequestActionsDropdown';
|
||||
import { SidebarActions } from './SidebarActions';
|
||||
import { EnvironmentActionsDropdown } from './EnvironmentActionsDropdown';
|
||||
import { WorkspaceActionsDropdown } from './WorkspaceActionsDropdown';
|
||||
|
||||
interface Props {
|
||||
@@ -19,11 +20,12 @@ export const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Prop
|
||||
<HStack
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
className={classnames(className, 'w-full h-full')}
|
||||
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" />
|
||||
<WorkspaceActionsDropdown />
|
||||
<EnvironmentActionsDropdown className="pointer-events-auto" />
|
||||
</HStack>
|
||||
<div className="pointer-events-none">
|
||||
<RecentRequestsDropdown />
|
||||
|
||||
@@ -2,15 +2,24 @@ import { Navigate } from 'react-router-dom';
|
||||
import { useAppRoutes } from '../hooks/useAppRoutes';
|
||||
import { useWorkspaces } from '../hooks/useWorkspaces';
|
||||
import { Heading } from './core/Heading';
|
||||
import { useRecentWorkspaces } from '../hooks/useRecentWorkspaces';
|
||||
|
||||
export default function Workspaces() {
|
||||
const routes = useAppRoutes();
|
||||
const recentWorkspaceIds = useRecentWorkspaces();
|
||||
const workspaces = useWorkspaces();
|
||||
const workspace = workspaces[0];
|
||||
|
||||
if (workspace === undefined) {
|
||||
const loading = workspaces.length === 0 && recentWorkspaceIds.length === 0;
|
||||
if (loading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const workspaceId = recentWorkspaceIds[0] ?? workspaces[0]?.id ?? null;
|
||||
|
||||
if (workspaceId === null) {
|
||||
return <Heading>There are no workspaces</Heading>;
|
||||
}
|
||||
|
||||
return <Navigate to={routes.paths.workspace({ workspaceId: workspace.id })} />;
|
||||
// TODO: Somehow get recent environmentId for the workspace in here too
|
||||
return <Navigate to={routes.paths.workspace({ workspaceId })} />;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import classnames from 'classnames';
|
||||
import classNames from 'classnames';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
@@ -9,7 +9,7 @@ export function Banner({ children, className }: Props) {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className={classnames(
|
||||
className={classNames(
|
||||
className,
|
||||
'border border-red-500 bg-red-300/10 text-red-800 px-3 py-2 rounded select-auto cursor-text',
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import classnames from 'classnames';
|
||||
import classNames from 'classnames';
|
||||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
import { forwardRef, memo, useMemo } from 'react';
|
||||
import { Icon } from './Icon';
|
||||
@@ -15,6 +15,7 @@ const colorStyles = {
|
||||
};
|
||||
|
||||
export type ButtonProps = HTMLAttributes<HTMLButtonElement> & {
|
||||
innerClassName?: string;
|
||||
color?: keyof typeof colorStyles;
|
||||
isLoading?: boolean;
|
||||
size?: 'sm' | 'md' | 'xs';
|
||||
@@ -23,6 +24,7 @@ export type ButtonProps = HTMLAttributes<HTMLButtonElement> & {
|
||||
forDropdown?: boolean;
|
||||
disabled?: boolean;
|
||||
title?: string;
|
||||
leftSlot?: ReactNode;
|
||||
rightSlot?: ReactNode;
|
||||
};
|
||||
|
||||
@@ -31,12 +33,14 @@ const _Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
||||
{
|
||||
isLoading,
|
||||
className,
|
||||
innerClassName,
|
||||
children,
|
||||
forDropdown,
|
||||
color,
|
||||
type = 'button',
|
||||
justify = 'center',
|
||||
size = 'md',
|
||||
leftSlot,
|
||||
rightSlot,
|
||||
disabled,
|
||||
...props
|
||||
@@ -45,11 +49,11 @@ const _Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
||||
) {
|
||||
const classes = useMemo(
|
||||
() =>
|
||||
classnames(
|
||||
classNames(
|
||||
className,
|
||||
'flex-shrink-0 outline-none whitespace-nowrap',
|
||||
'focus-visible-or-class:ring',
|
||||
'rounded-md flex items-center',
|
||||
'whitespace-nowrap outline-none',
|
||||
'flex-shrink-0 flex items-center',
|
||||
'focus-visible-or-class:ring rounded-md',
|
||||
disabled ? 'pointer-events-none opacity-disabled' : 'pointer-events-auto',
|
||||
colorStyles[color || 'default'],
|
||||
justify === 'start' && 'justify-start',
|
||||
@@ -63,8 +67,12 @@ const _Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
||||
|
||||
return (
|
||||
<button ref={ref} type={type} className={classes} disabled={disabled} {...props}>
|
||||
{isLoading && <Icon icon="update" size={size} className="animate-spin mr-1" />}
|
||||
{children}
|
||||
{isLoading ? (
|
||||
<Icon icon="update" size={size} className="animate-spin mr-1" />
|
||||
) : leftSlot ? (
|
||||
<div className="mr-1">{leftSlot}</div>
|
||||
) : null}
|
||||
<div className={classNames('max-w-[15em] truncate w-full text-left', innerClassName)}>{children}</div>
|
||||
{rightSlot && <div className="ml-1">{rightSlot}</div>}
|
||||
{forDropdown && <Icon icon="chevronDown" size={size} className="ml-1 -mr-1" />}
|
||||
</button>
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import classnames from 'classnames';
|
||||
import classNames from 'classnames';
|
||||
import { useCallback } from 'react';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
interface Props {
|
||||
checked: boolean;
|
||||
title: string;
|
||||
onChange: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Checkbox({ checked, onChange, className, disabled }: Props) {
|
||||
export function Checkbox({ checked, onChange, className, disabled, title }: Props) {
|
||||
const handleClick = useCallback(() => {
|
||||
onChange(!checked);
|
||||
}, [onChange, checked]);
|
||||
@@ -20,7 +21,8 @@ export function Checkbox({ checked, onChange, className, disabled }: Props) {
|
||||
aria-checked={checked ? 'true' : 'false'}
|
||||
disabled={disabled}
|
||||
onClick={handleClick}
|
||||
className={classnames(
|
||||
title={title}
|
||||
className={classNames(
|
||||
className,
|
||||
'flex-shrink-0 w-4 h-4 border border-gray-200 rounded',
|
||||
'focus:border-focus',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import classnames from 'classnames';
|
||||
import classNames from 'classnames';
|
||||
|
||||
interface Props {
|
||||
count: number;
|
||||
@@ -10,7 +10,7 @@ export function CountBadge({ count, className }: Props) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className={classnames(
|
||||
className={classNames(
|
||||
className,
|
||||
'opacity-70 border border-highlight text-3xs rounded mb-0.5 px-1 ml-1 h-4 font-mono',
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import classnames from 'classnames';
|
||||
import classNames from 'classnames';
|
||||
import { motion } from 'framer-motion';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
@@ -11,7 +11,7 @@ export interface DialogProps {
|
||||
children: ReactNode;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: ReactNode;
|
||||
title?: ReactNode;
|
||||
description?: ReactNode;
|
||||
className?: string;
|
||||
size?: 'sm' | 'md' | 'full' | 'dynamic';
|
||||
@@ -51,23 +51,28 @@ export function Dialog({
|
||||
<motion.div
|
||||
initial={{ top: 5, scale: 0.97 }}
|
||||
animate={{ top: 0, scale: 1 }}
|
||||
className={classnames(
|
||||
className={classNames(
|
||||
className,
|
||||
'gap-2 grid grid-rows-[auto_minmax(0,1fr)]',
|
||||
'relative bg-gray-50 pointer-events-auto',
|
||||
'max-h-[80vh] p-5 rounded-lg overflow-auto',
|
||||
'p-5 rounded-lg overflow-auto',
|
||||
'dark:border border-highlight shadow shadow-black/10',
|
||||
size === 'sm' && 'w-[25rem]',
|
||||
size === 'md' && 'w-[45rem]',
|
||||
size === 'full' && 'w-[80vw]',
|
||||
'max-w-[90vw] max-h-[calc(100vh-8em)]',
|
||||
size === 'sm' && 'w-[25rem] max-h-[80vh]',
|
||||
size === 'md' && 'w-[45rem] max-h-[80vh]',
|
||||
size === 'full' && 'w-[100vw] h-[100vh]',
|
||||
size === 'dynamic' && 'min-w-[30vw] max-w-[80vw]',
|
||||
)}
|
||||
>
|
||||
<Heading className="text-xl font-semibold w-full" id={titleId}>
|
||||
{title}
|
||||
</Heading>
|
||||
{title ? (
|
||||
<Heading className="text-xl font-semibold w-full" id={titleId}>
|
||||
{title}
|
||||
</Heading>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
{description && <p id={descriptionId}>{description}</p>}
|
||||
<div className="mt-4">{children}</div>
|
||||
|
||||
<div className="h-full w-full">{children}</div>
|
||||
{/*Put close at the end so that it's the last thing to be tabbed to*/}
|
||||
{!hideX && (
|
||||
<IconButton
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import classnames from 'classnames';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import classNames from 'classnames';
|
||||
import { motion } from 'framer-motion';
|
||||
import type { CSSProperties, HTMLAttributes, MouseEvent, ReactElement, ReactNode } from 'react';
|
||||
import React, {
|
||||
@@ -13,11 +12,11 @@ import React, {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useKey, useKeyPressEvent } from 'react-use';
|
||||
import { Portal } from '../Portal';
|
||||
import { useKey, useKeyPressEvent, useWindowSize } from 'react-use';
|
||||
import { Button } from './Button';
|
||||
import { Separator } from './Separator';
|
||||
import { VStack } from './Stacks';
|
||||
import { Overlay } from '../Overlay';
|
||||
|
||||
export type DropdownItemSeparator = {
|
||||
type: 'separator';
|
||||
@@ -46,7 +45,7 @@ export interface DropdownProps {
|
||||
export interface DropdownRef {
|
||||
isOpen: boolean;
|
||||
open: (activeIndex?: number) => void;
|
||||
toggle: () => void;
|
||||
toggle: (activeIndex?: number) => void;
|
||||
close?: () => void;
|
||||
next?: () => void;
|
||||
prev?: () => void;
|
||||
@@ -65,8 +64,11 @@ export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown
|
||||
useImperativeHandle(ref, () => ({
|
||||
...menuRef.current,
|
||||
isOpen: open,
|
||||
toggle: () => setOpen(!open),
|
||||
open: (activeIndex?: number) => {
|
||||
toggle(activeIndex?: number) {
|
||||
if (!open) this.open(activeIndex);
|
||||
else setOpen(false);
|
||||
},
|
||||
open(activeIndex?: number) {
|
||||
if (activeIndex === undefined) {
|
||||
setDefaultSelectedIndex(undefined);
|
||||
} else {
|
||||
@@ -104,10 +106,12 @@ export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown
|
||||
buttonRef.current?.setAttribute('aria-expanded', open.toString());
|
||||
}, [open]);
|
||||
|
||||
const windowSize = useWindowSize();
|
||||
const triggerRect = useMemo(() => {
|
||||
windowSize; // Make TS happy with this dep
|
||||
if (!open) return null;
|
||||
return buttonRef.current?.getBoundingClientRect();
|
||||
}, [open]);
|
||||
}, [open, windowSize]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -264,61 +268,59 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Portal name="dropdown">
|
||||
<FocusTrap>
|
||||
<div>
|
||||
<div tabIndex={-1} aria-hidden className="fixed inset-0" onClick={onClose} />
|
||||
<motion.div
|
||||
tabIndex={0}
|
||||
onKeyDown={handleMenuKeyDown}
|
||||
initial={{ opacity: 0, y: -5, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
dir="ltr"
|
||||
ref={containerRef}
|
||||
style={containerStyles}
|
||||
className={classnames(className, 'outline-none mt-1 pointer-events-auto fixed z-50')}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
style={triangleStyles}
|
||||
className="bg-gray-50 absolute rotate-45 border-gray-200 border-t border-l"
|
||||
/>
|
||||
{containerStyles && (
|
||||
<VStack
|
||||
space={0.5}
|
||||
ref={initMenu}
|
||||
style={menuStyles}
|
||||
className={classnames(
|
||||
className,
|
||||
'h-auto bg-gray-50 rounded-md shadow-lg dark:shadow-gray-0 py-1.5 border',
|
||||
'border-gray-200 overflow-auto mb-1 mx-0.5',
|
||||
)}
|
||||
>
|
||||
{items.map((item, i) => {
|
||||
if (item.type === 'separator') {
|
||||
return <Separator key={i} className="my-1.5" label={item.label} />;
|
||||
}
|
||||
if (item.hidden) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<MenuItem
|
||||
focused={i === selectedIndex}
|
||||
onFocus={handleFocus}
|
||||
onSelect={handleSelect}
|
||||
key={item.key}
|
||||
item={item}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</FocusTrap>
|
||||
</Portal>
|
||||
<Overlay open variant="transparent" portalName="dropdown" zIndex={50}>
|
||||
<div>
|
||||
<div tabIndex={-1} aria-hidden className="fixed inset-0 z-30" onClick={onClose} />
|
||||
<motion.div
|
||||
tabIndex={0}
|
||||
onKeyDown={handleMenuKeyDown}
|
||||
initial={{ opacity: 0, y: -5, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
dir="ltr"
|
||||
ref={containerRef}
|
||||
style={containerStyles}
|
||||
className={classNames(className, 'outline-none mt-1 pointer-events-auto fixed z-50')}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
style={triangleStyles}
|
||||
className="bg-gray-50 absolute rotate-45 border-gray-200 border-t border-l"
|
||||
/>
|
||||
{containerStyles && (
|
||||
<VStack
|
||||
space={0.5}
|
||||
ref={initMenu}
|
||||
style={menuStyles}
|
||||
className={classNames(
|
||||
className,
|
||||
'h-auto bg-gray-50 rounded-md shadow-lg dark:shadow-gray-0 py-1.5 border',
|
||||
'border-gray-200 overflow-auto mb-1 mx-0.5',
|
||||
)}
|
||||
>
|
||||
{items.map((item, i) => {
|
||||
if (item.type === 'separator') {
|
||||
return <Separator key={i} className="my-1.5" label={item.label} />;
|
||||
}
|
||||
if (item.hidden) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<MenuItem
|
||||
focused={i === selectedIndex}
|
||||
onFocus={handleFocus}
|
||||
onSelect={handleSelect}
|
||||
key={item.key}
|
||||
item={item}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</Overlay>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -356,7 +358,9 @@ function MenuItem({ className, focused, onFocus, item, onSelect, ...props }: Men
|
||||
onFocus={handleFocus}
|
||||
onClick={handleClick}
|
||||
justify="start"
|
||||
className={classnames(
|
||||
leftSlot={item.leftSlot && <div className="pr-2 flex justify-start">{item.leftSlot}</div>}
|
||||
rightSlot={item.rightSlot && <div className="ml-auto pl-3">{item.rightSlot}</div>}
|
||||
className={classNames(
|
||||
className,
|
||||
'min-w-[8rem] outline-none px-2 mx-1.5 flex text-sm text-gray-700 whitespace-nowrap',
|
||||
'focus:bg-highlight focus:text-gray-900 rounded',
|
||||
@@ -364,16 +368,14 @@ function MenuItem({ className, focused, onFocus, item, onSelect, ...props }: Men
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{item.leftSlot && <div className="pr-2 flex justify-start">{item.leftSlot}</div>}
|
||||
<div
|
||||
className={classnames(
|
||||
className={classNames(
|
||||
// Add padding on right when no right slot, for some visual balance
|
||||
!item.rightSlot && 'pr-4',
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</div>
|
||||
{item.rightSlot && <div className="ml-auto pl-3">{item.rightSlot}</div>}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,203 +1,216 @@
|
||||
.cm-wrapper {
|
||||
@apply h-full overflow-hidden;
|
||||
@apply h-full overflow-hidden;
|
||||
|
||||
.cm-editor {
|
||||
@apply w-full block text-base;
|
||||
|
||||
* {
|
||||
@apply cursor-text;
|
||||
}
|
||||
|
||||
&.cm-focused {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
.cm-content {
|
||||
@apply py-0;
|
||||
}
|
||||
|
||||
.cm-line {
|
||||
@apply text-gray-800 caret-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-selection;
|
||||
}
|
||||
|
||||
/* Style gutters */
|
||||
.cm-gutters {
|
||||
@apply border-0 text-gray-500/50;
|
||||
|
||||
.cm-gutterElement {
|
||||
@apply cursor-default;
|
||||
}
|
||||
}
|
||||
|
||||
.placeholder-widget {
|
||||
@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;
|
||||
|
||||
/* Bring above on hover */
|
||||
@apply hover:z-10 relative;
|
||||
|
||||
-webkit-text-security: none;
|
||||
|
||||
&.placeholder-widget-error {
|
||||
@apply bg-red-300/40 border-red-300 border-opacity-40;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.cm-singleline {
|
||||
.cm-editor {
|
||||
@apply w-full h-auto;
|
||||
}
|
||||
|
||||
.cm-scroller {
|
||||
@apply font-mono text-[0.8rem] overflow-hidden;
|
||||
}
|
||||
|
||||
.cm-line {
|
||||
@apply px-2 overflow-hidden;
|
||||
}
|
||||
}
|
||||
|
||||
&.cm-multiline {
|
||||
&.cm-full-height {
|
||||
@apply relative;
|
||||
|
||||
.cm-editor {
|
||||
@apply inset-0 absolute;
|
||||
position: absolute !important;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-editor {
|
||||
@apply w-full block text-base;
|
||||
|
||||
* {
|
||||
@apply cursor-text;
|
||||
}
|
||||
|
||||
&.cm-focused {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
.cm-content {
|
||||
@apply py-0;
|
||||
}
|
||||
|
||||
.cm-line {
|
||||
@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-selection;
|
||||
}
|
||||
|
||||
/* Style gutters */
|
||||
.cm-gutters {
|
||||
@apply border-0 text-gray-500/50;
|
||||
|
||||
.cm-gutterElement {
|
||||
@apply cursor-default;
|
||||
}
|
||||
}
|
||||
|
||||
.placeholder-widget {
|
||||
@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;
|
||||
|
||||
/* Bring above on hover */
|
||||
@apply hover:z-10 relative;
|
||||
}
|
||||
@apply h-full;
|
||||
}
|
||||
|
||||
&.cm-singleline {
|
||||
.cm-editor {
|
||||
@apply w-full h-auto;
|
||||
}
|
||||
.cm-scroller {
|
||||
@apply font-mono text-[0.75rem];
|
||||
|
||||
.cm-scroller {
|
||||
@apply font-mono text-[0.8rem] overflow-hidden;
|
||||
}
|
||||
|
||||
.cm-line {
|
||||
@apply px-2 overflow-hidden;
|
||||
}
|
||||
}
|
||||
|
||||
&.cm-multiline {
|
||||
&.cm-full-height {
|
||||
@apply relative;
|
||||
|
||||
.cm-editor {
|
||||
@apply inset-0 absolute;
|
||||
position: absolute !important;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-editor {
|
||||
@apply h-full;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
@apply rounded-lg;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Obscure text for password fields */
|
||||
.cm-wrapper.cm-obscure-text .cm-line {
|
||||
-webkit-text-security: disc;
|
||||
-webkit-text-security: disc;
|
||||
}
|
||||
|
||||
.cm-editor .cm-gutterElement {
|
||||
@apply flex items-center;
|
||||
transition: color var(--transition-duration);
|
||||
@apply flex items-center;
|
||||
transition: color var(--transition-duration);
|
||||
}
|
||||
|
||||
.cm-editor .fold-gutter-icon {
|
||||
@apply pt-[0.25em] 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 {
|
||||
@apply block w-1.5 h-1.5 border-transparent -rotate-45
|
||||
@apply block w-1.5 h-1.5 border-transparent -rotate-45
|
||||
border-l border-b border-l-[currentColor] border-b-[currentColor] content-[''];
|
||||
}
|
||||
|
||||
.cm-editor .fold-gutter-icon[data-open] {
|
||||
@apply pt-[0.38em] pl-[0.3em];
|
||||
@apply pt-[0.38em] pl-[0.3em];
|
||||
}
|
||||
|
||||
.cm-editor .fold-gutter-icon[data-open]::after {
|
||||
@apply rotate-[-135deg];
|
||||
@apply rotate-[-135deg];
|
||||
}
|
||||
|
||||
.cm-editor .fold-gutter-icon:hover {
|
||||
@apply text-gray-900 bg-gray-300/50;
|
||||
@apply text-gray-900 bg-gray-300/50;
|
||||
}
|
||||
|
||||
.cm-editor .cm-foldPlaceholder {
|
||||
@apply px-2 border border-gray-400/50 bg-gray-300/50 cursor-default;
|
||||
@apply hover:text-gray-800 hover:border-gray-400;
|
||||
@apply px-2 border border-gray-400/50 bg-gray-300/50 cursor-default;
|
||||
@apply hover:text-gray-800 hover:border-gray-400;
|
||||
}
|
||||
|
||||
.cm-editor .cm-activeLineGutter {
|
||||
@apply bg-transparent;
|
||||
@apply bg-transparent;
|
||||
}
|
||||
|
||||
.cm-wrapper:not(.cm-readonly) .cm-editor {
|
||||
&.cm-focused .cm-activeLineGutter {
|
||||
@apply text-gray-600;
|
||||
}
|
||||
&.cm-focused .cm-activeLineGutter {
|
||||
@apply text-gray-600;
|
||||
}
|
||||
|
||||
.cm-cursor {
|
||||
@apply border-l-2 border-gray-800;
|
||||
}
|
||||
.cm-cursor {
|
||||
@apply border-l-2 border-gray-800;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-singleline .cm-editor {
|
||||
.cm-content {
|
||||
@apply h-full flex items-center;
|
||||
.cm-content {
|
||||
@apply h-full flex items-center;
|
||||
|
||||
/* Break characters on line wrapping mode, useful for URL field.
|
||||
* We can make this dynamic if we need it to be configurable later
|
||||
*/
|
||||
&.cm-lineWrapping {
|
||||
@apply break-all;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* NOTE: Extra selector required to override default styles */
|
||||
.cm-tooltip.cm-tooltip {
|
||||
@apply shadow-lg bg-gray-50 rounded text-gray-700 border border-gray-200 z-50 pointer-events-auto text-sm;
|
||||
@apply shadow-lg bg-gray-50 rounded text-gray-700 border border-gray-200 z-50 pointer-events-auto text-[0.75rem];
|
||||
|
||||
&.cm-completionInfo-right {
|
||||
@apply ml-1;
|
||||
&.cm-completionInfo-right {
|
||||
@apply ml-1 -mt-0.5 text-sm;
|
||||
}
|
||||
|
||||
&.cm-completionInfo-right-narrow {
|
||||
@apply ml-1;
|
||||
}
|
||||
|
||||
* {
|
||||
@apply transition-none;
|
||||
}
|
||||
|
||||
&.cm-tooltip-autocomplete {
|
||||
& > ul {
|
||||
@apply p-1 max-h-[40vh];
|
||||
}
|
||||
|
||||
&.cm-completionInfo-right-narrow {
|
||||
@apply ml-1;
|
||||
& > ul > li {
|
||||
@apply cursor-default px-2 rounded-sm text-gray-600 h-7 flex items-center;
|
||||
}
|
||||
|
||||
* {
|
||||
@apply transition-none;
|
||||
& > ul > li[aria-selected] {
|
||||
@apply bg-highlight text-gray-900;
|
||||
}
|
||||
|
||||
&.cm-tooltip-autocomplete {
|
||||
& > ul {
|
||||
@apply p-1 max-h-[40vh];
|
||||
}
|
||||
|
||||
& > ul > li {
|
||||
@apply cursor-default px-2 rounded-sm text-gray-600 h-7 flex items-center;
|
||||
}
|
||||
|
||||
& > ul > li[aria-selected] {
|
||||
@apply bg-highlight text-gray-900;
|
||||
}
|
||||
|
||||
.cm-completionIcon {
|
||||
@apply text-sm flex items-center pb-0.5 flex-shrink-0;
|
||||
}
|
||||
|
||||
.cm-completionLabel {
|
||||
@apply text-gray-700;
|
||||
}
|
||||
|
||||
.cm-completionDetail {
|
||||
@apply ml-auto pl-6;
|
||||
}
|
||||
.cm-completionIcon {
|
||||
@apply text-sm flex items-center pb-0.5 mr-2 flex-shrink-0;
|
||||
}
|
||||
|
||||
.cm-completionLabel {
|
||||
@apply text-gray-700;
|
||||
}
|
||||
|
||||
.cm-completionDetail {
|
||||
@apply ml-auto pl-6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Add default icon. Needs low priority so it can be overwritten */
|
||||
.cm-completionIcon::after {
|
||||
content: '𝑥';
|
||||
content: '𝑥';
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { defaultKeymap } from '@codemirror/commands';
|
||||
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 classNames from 'classnames';
|
||||
import { EditorView } from 'codemirror';
|
||||
import type { MutableRefObject, ReactNode } from 'react';
|
||||
import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef } from 'react';
|
||||
@@ -12,6 +12,7 @@ import './Editor.css';
|
||||
import { baseExtensions, getLanguageExtension, multiLineExtensions } from './extensions';
|
||||
import type { GenericCompletionConfig } from './genericCompletion';
|
||||
import { singleLineExt } from './singleLine';
|
||||
import { useActiveEnvironment } from '../../../hooks/useActiveEnvironment';
|
||||
|
||||
// Export some things so all the code-split parts are in this file
|
||||
export { buildClientSchema, getIntrospectionQuery } from 'graphql/utilities';
|
||||
@@ -27,6 +28,7 @@ export interface EditorProps {
|
||||
contentType?: string | null;
|
||||
forceUpdateKey?: string;
|
||||
autoFocus?: boolean;
|
||||
autoSelect?: boolean;
|
||||
defaultValue?: string | null;
|
||||
placeholder?: string;
|
||||
tooltipContainer?: HTMLElement;
|
||||
@@ -34,10 +36,12 @@ export interface EditorProps {
|
||||
onChange?: (value: string) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
onKeyDown?: (e: KeyboardEvent) => void;
|
||||
singleLine?: boolean;
|
||||
wrapLines?: boolean;
|
||||
format?: (v: string) => string;
|
||||
autocomplete?: GenericCompletionConfig;
|
||||
autocompleteVariables?: boolean;
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
@@ -48,6 +52,7 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
|
||||
heightMode,
|
||||
contentType,
|
||||
autoFocus,
|
||||
autoSelect,
|
||||
placeholder,
|
||||
useTemplating,
|
||||
defaultValue,
|
||||
@@ -55,15 +60,20 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
|
||||
onChange,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onKeyDown,
|
||||
className,
|
||||
singleLine,
|
||||
format,
|
||||
autocomplete,
|
||||
autocompleteVariables,
|
||||
actions,
|
||||
wrapLines,
|
||||
}: EditorProps,
|
||||
ref,
|
||||
) {
|
||||
const e = useActiveEnvironment();
|
||||
const environment = autocompleteVariables ? e : null;
|
||||
|
||||
const cm = useRef<{ view: EditorView; languageCompartment: Compartment } | null>(null);
|
||||
useImperativeHandle(ref, () => cm.current?.view);
|
||||
|
||||
@@ -85,6 +95,12 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
|
||||
handleBlur.current = onBlur;
|
||||
}, [onBlur]);
|
||||
|
||||
// Use ref so we can update the onChange handler without re-initializing the editor
|
||||
const handleKeyDown = useRef<EditorProps['onKeyDown']>(onKeyDown);
|
||||
useEffect(() => {
|
||||
handleKeyDown.current = onKeyDown;
|
||||
}, [onKeyDown]);
|
||||
|
||||
// Update placeholder
|
||||
const placeholderCompartment = useRef(new Compartment());
|
||||
useEffect(() => {
|
||||
@@ -108,9 +124,9 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
|
||||
useEffect(() => {
|
||||
if (cm.current === null) return;
|
||||
const { view, languageCompartment } = cm.current;
|
||||
const ext = getLanguageExtension({ contentType, useTemplating, autocomplete });
|
||||
const ext = getLanguageExtension({ contentType, environment, useTemplating, autocomplete });
|
||||
view.dispatch({ effects: languageCompartment.reconfigure(ext) });
|
||||
}, [contentType, autocomplete, useTemplating]);
|
||||
}, [contentType, autocomplete, useTemplating, environment]);
|
||||
|
||||
useEffect(() => {
|
||||
if (cm.current === null) return;
|
||||
@@ -131,7 +147,12 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
|
||||
let view: EditorView;
|
||||
try {
|
||||
const languageCompartment = new Compartment();
|
||||
const langExt = getLanguageExtension({ contentType, useTemplating, autocomplete });
|
||||
const langExt = getLanguageExtension({
|
||||
contentType,
|
||||
useTemplating,
|
||||
autocomplete,
|
||||
environment,
|
||||
});
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: `${defaultValue ?? ''}`,
|
||||
@@ -146,6 +167,7 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
|
||||
onChange: handleChange,
|
||||
onFocus: handleFocus,
|
||||
onBlur: handleBlur,
|
||||
onKeyDown: handleKeyDown,
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -153,7 +175,12 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
|
||||
view = new EditorView({ state, parent: container });
|
||||
cm.current = { view, languageCompartment };
|
||||
syncGutterBg({ parent: container, className });
|
||||
if (autoFocus) view.focus();
|
||||
if (autoFocus) {
|
||||
view.focus();
|
||||
}
|
||||
if (autoSelect) {
|
||||
view.dispatch({ selection: { anchor: 0, head: view.state.doc.length } });
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Failed to initialize Codemirror', e);
|
||||
}
|
||||
@@ -164,7 +191,7 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
|
||||
const cmContainer = (
|
||||
<div
|
||||
ref={initEditorRef}
|
||||
className={classnames(
|
||||
className={classNames(
|
||||
className,
|
||||
'cm-wrapper text-base bg-gray-50',
|
||||
type === 'password' && 'cm-obscure-text',
|
||||
@@ -215,11 +242,13 @@ function getExtensions({
|
||||
onChange,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onKeyDown,
|
||||
}: Pick<EditorProps, 'singleLine' | 'readOnly'> & {
|
||||
container: HTMLDivElement | null;
|
||||
onChange: MutableRefObject<EditorProps['onChange']>;
|
||||
onFocus: MutableRefObject<EditorProps['onFocus']>;
|
||||
onBlur: MutableRefObject<EditorProps['onBlur']>;
|
||||
onKeyDown: MutableRefObject<EditorProps['onKeyDown']>;
|
||||
}) {
|
||||
// TODO: Ensure tooltips render inside the dialog if we are in one.
|
||||
const parent =
|
||||
@@ -236,29 +265,13 @@ function getExtensions({
|
||||
...(readOnly
|
||||
? [EditorState.readOnly.of(true), EditorView.contentAttributes.of({ tabindex: '-1' })]
|
||||
: []),
|
||||
...(singleLine
|
||||
? [
|
||||
EditorView.domEventHandlers({
|
||||
focus: (e, view) => {
|
||||
// select all text on focus, like a regular input does
|
||||
view.dispatch({ selection: { anchor: 0, head: view.state.doc.length } });
|
||||
},
|
||||
keydown: (e) => {
|
||||
// Submit nearest form on enter if there is one
|
||||
if (e.key === 'Enter') {
|
||||
const el = e.currentTarget as HTMLElement;
|
||||
const form = el.closest('form');
|
||||
form?.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
|
||||
}
|
||||
},
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
|
||||
// Handle onFocus
|
||||
// NOTE: These *must* be anonymous functions so the references update properly
|
||||
EditorView.domEventHandlers({
|
||||
focus: onFocus.current,
|
||||
blur: onBlur.current,
|
||||
focus: () => onFocus.current?.(),
|
||||
blur: () => onBlur.current?.(),
|
||||
keydown: e => onKeyDown.current?.(e),
|
||||
}),
|
||||
|
||||
// Handle onChange
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
completionKeymap,
|
||||
} from '@codemirror/autocomplete';
|
||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
|
||||
import { html } from '@codemirror/lang-html';
|
||||
import { javascript } from '@codemirror/lang-javascript';
|
||||
import { json } from '@codemirror/lang-json';
|
||||
import { xml } from '@codemirror/lang-xml';
|
||||
@@ -37,6 +36,7 @@ import type { EditorProps } from './index';
|
||||
import { text } from './text/extension';
|
||||
import { twig } from './twig/extension';
|
||||
import { url } from './url/extension';
|
||||
import type { Environment } from '../../../lib/models';
|
||||
|
||||
export const myHighlightStyle = HighlightStyle.define([
|
||||
{
|
||||
@@ -86,7 +86,7 @@ const syntaxExtensions: Record<string, LanguageSupport> = {
|
||||
'application/graphql': graphqlLanguageSupport(),
|
||||
'application/json': json(),
|
||||
'application/javascript': javascript(),
|
||||
'text/html': html(),
|
||||
'text/html': xml(), // HTML as xml because HTML is oddly slow
|
||||
'application/xml': xml(),
|
||||
'text/xml': xml(),
|
||||
url: url(),
|
||||
@@ -95,24 +95,27 @@ const syntaxExtensions: Record<string, LanguageSupport> = {
|
||||
export function getLanguageExtension({
|
||||
contentType,
|
||||
useTemplating = false,
|
||||
environment,
|
||||
autocomplete,
|
||||
}: Pick<EditorProps, 'contentType' | 'useTemplating' | 'autocomplete'>) {
|
||||
if (contentType === 'application/graphql') {
|
||||
}: { environment: Environment | null } & Pick<
|
||||
EditorProps,
|
||||
'contentType' | 'useTemplating' | 'autocomplete'
|
||||
>) {
|
||||
const justContentType = contentType?.split(';')[0] ?? contentType ?? '';
|
||||
if (justContentType === 'application/graphql') {
|
||||
return graphql();
|
||||
}
|
||||
const justContentType = contentType?.split(';')[0] ?? contentType ?? '';
|
||||
const base = syntaxExtensions[justContentType] ?? text();
|
||||
if (!useTemplating) {
|
||||
return base ? base : [];
|
||||
return base;
|
||||
}
|
||||
|
||||
return twig(base, autocomplete);
|
||||
return twig(base, environment, autocomplete);
|
||||
}
|
||||
|
||||
export const baseExtensions = [
|
||||
highlightSpecialChars(),
|
||||
history(),
|
||||
drawSelection(),
|
||||
dropCursor(),
|
||||
bracketMatching(),
|
||||
// TODO: Figure out how to debounce showing of autocomplete in a good way
|
||||
@@ -144,6 +147,7 @@ export const multiLineExtensions = [
|
||||
},
|
||||
}),
|
||||
EditorState.allowMultipleSelections.of(true),
|
||||
drawSelection(),
|
||||
indentOnInput(),
|
||||
closeBrackets(),
|
||||
rectangularSelection(),
|
||||
|
||||
@@ -17,13 +17,19 @@ export interface GenericCompletionConfig {
|
||||
|
||||
export function genericCompletion({ options, minMatch = 1 }: GenericCompletionConfig) {
|
||||
return function completions(context: CompletionContext) {
|
||||
const toMatch = context.matchBefore(/^.*/);
|
||||
if (toMatch === null) return null;
|
||||
const toMatch = context.matchBefore(/\w*/);
|
||||
|
||||
// Only match if we're at the start of the line
|
||||
if (toMatch === null || toMatch.from > 0) 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' };
|
||||
return {
|
||||
validFor: () => true, // Not really sure why this is all it needs
|
||||
from: toMatch.from,
|
||||
options: optionsWithoutExactMatches,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import type { DecorationSet, ViewUpdate } from '@codemirror/view';
|
||||
import { Decoration, EditorView, MatchDecorator, ViewPlugin, WidgetType } from '@codemirror/view';
|
||||
|
||||
class PlaceholderWidget extends WidgetType {
|
||||
constructor(readonly name: string) {
|
||||
super();
|
||||
}
|
||||
eq(other: PlaceholderWidget) {
|
||||
return this.name == other.name;
|
||||
}
|
||||
toDOM() {
|
||||
const elt = document.createElement('span');
|
||||
elt.className = 'placeholder-widget';
|
||||
elt.textContent = this.name;
|
||||
return elt;
|
||||
}
|
||||
ignoreEvent() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This is a custom MatchDecorator that will not decorate a match if the selection is inside it
|
||||
*/
|
||||
class BetterMatchDecorator extends MatchDecorator {
|
||||
updateDeco(update: ViewUpdate, deco: DecorationSet): DecorationSet {
|
||||
if (!update.startState.selection.eq(update.state.selection)) {
|
||||
return super.createDeco(update.view);
|
||||
} else {
|
||||
return super.updateDeco(update, deco);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const placeholderMatcher = new BetterMatchDecorator({
|
||||
regexp: /\$\{\[\s*([^\]\s]+)\s*]}/g,
|
||||
decoration(match, view, matchStartPos) {
|
||||
const matchEndPos = matchStartPos + match[0].length - 1;
|
||||
|
||||
// Don't decorate if the cursor is inside the match
|
||||
for (const r of view.state.selection.ranges) {
|
||||
if (r.from > matchStartPos && r.to <= matchEndPos) return null;
|
||||
}
|
||||
|
||||
const groupMatch = match[1];
|
||||
if (groupMatch == null) {
|
||||
// Should never happen, but make TS happy
|
||||
console.warn('Group match was empty', match);
|
||||
return null;
|
||||
}
|
||||
|
||||
return Decoration.replace({
|
||||
inclusive: true,
|
||||
widget: new PlaceholderWidget(groupMatch),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const placeholders = ViewPlugin.fromClass(
|
||||
class {
|
||||
placeholders: DecorationSet;
|
||||
constructor(view: EditorView) {
|
||||
this.placeholders = placeholderMatcher.createDeco(view);
|
||||
}
|
||||
update(update: ViewUpdate) {
|
||||
this.placeholders = placeholderMatcher.updateDeco(update, this.placeholders);
|
||||
}
|
||||
},
|
||||
{
|
||||
decorations: (instance) => instance.placeholders,
|
||||
provide: (plugin) =>
|
||||
EditorView.atomicRanges.of((view) => {
|
||||
return view.plugin(plugin)?.placeholders || Decoration.none;
|
||||
}),
|
||||
},
|
||||
);
|
||||
@@ -6,8 +6,8 @@ export function singleLineExt() {
|
||||
(tr: Transaction): TransactionSpec | TransactionSpec[] => {
|
||||
if (!tr.isUserEvent('input')) return tr;
|
||||
|
||||
const trs: TransactionSpec[] = [];
|
||||
tr.changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
|
||||
const specs: TransactionSpec[] = [];
|
||||
tr.changes.iterChanges((_, toA, fromB, toB, inserted) => {
|
||||
let insert = '';
|
||||
let newlinesRemoved = 0;
|
||||
for (const line of inserted) {
|
||||
@@ -21,9 +21,10 @@ export function singleLineExt() {
|
||||
const selection = EditorSelection.create([cursor], 0);
|
||||
|
||||
const changes = [{ from: fromB, to: toA, insert }];
|
||||
trs.push({ ...tr, selection, changes });
|
||||
specs.push({ ...tr, selection, changes });
|
||||
});
|
||||
return trs;
|
||||
|
||||
return specs;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,46 +1,54 @@
|
||||
import type { CompletionContext } from '@codemirror/autocomplete';
|
||||
import { w } from '@tauri-apps/api/clipboard-79413165';
|
||||
|
||||
const openTag = '${[ ';
|
||||
const closeTag = ' ]}';
|
||||
|
||||
const variables: { name: string }[] = [
|
||||
// TODO: Put variables here
|
||||
];
|
||||
interface TwigCompletionOption {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface TwigCompletionConfig {
|
||||
options: TwigCompletionOption[];
|
||||
}
|
||||
|
||||
const MIN_MATCH_VAR = 2;
|
||||
const MIN_MATCH_NAME = 3;
|
||||
const MIN_MATCH_NAME = 1;
|
||||
|
||||
export function completions(context: CompletionContext) {
|
||||
const toStartOfName = context.matchBefore(/\w*/);
|
||||
const toStartOfVariable = context.matchBefore(/\$\{?\[?\s*\w*/);
|
||||
const toMatch = toStartOfVariable ?? toStartOfName ?? null;
|
||||
export function twigCompletion({ options }: TwigCompletionConfig) {
|
||||
return function completions(context: CompletionContext) {
|
||||
const toStartOfName = context.matchBefore(/\w*/);
|
||||
const toStartOfVariable = context.matchBefore(/\$\{?\[?\s*\w*/);
|
||||
const toMatch = toStartOfVariable ?? toStartOfName ?? null;
|
||||
|
||||
if (toMatch === null) return null;
|
||||
if (toMatch === null) return null;
|
||||
|
||||
const matchLen = toMatch.to - toMatch.from;
|
||||
const matchLen = toMatch.to - toMatch.from;
|
||||
|
||||
const failedVarLen = toStartOfVariable !== null && matchLen < MIN_MATCH_VAR;
|
||||
if (failedVarLen && !context.explicit) {
|
||||
return null;
|
||||
}
|
||||
const failedVarLen = toStartOfVariable !== null && matchLen < MIN_MATCH_VAR;
|
||||
if (failedVarLen && !context.explicit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const failedNameLen = toStartOfVariable === null && matchLen < MIN_MATCH_NAME;
|
||||
if (failedNameLen && !context.explicit) {
|
||||
return null;
|
||||
}
|
||||
const failedNameLen = toStartOfVariable === null && matchLen < MIN_MATCH_NAME;
|
||||
if (failedNameLen && !context.explicit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// TODO: Figure out how to make autocomplete stay open if opened explicitly. It sucks when you explicitly
|
||||
// open it, then it closes when you type the next character.
|
||||
return {
|
||||
from: toMatch.from,
|
||||
options: variables
|
||||
.map((v) => ({
|
||||
label: toStartOfVariable ? `${openTag}${v.name}${closeTag}` : v.name,
|
||||
apply: `${openTag}${v.name}${closeTag}`,
|
||||
type: 'variable',
|
||||
matchLen,
|
||||
}))
|
||||
// Filter out exact matches
|
||||
.filter((o) => o.label !== toMatch.text),
|
||||
// TODO: Figure out how to make autocomplete stay open if opened explicitly. It sucks when you explicitly
|
||||
// open it, then it closes when you type the next character.
|
||||
return {
|
||||
validFor: () => true, // Not really sure why this is all it needs
|
||||
from: toMatch.from,
|
||||
options: options
|
||||
.map((v) => ({
|
||||
label: toStartOfVariable ? `${openTag}${v.name}${closeTag}` : v.name,
|
||||
apply: `${openTag}${v.name}${closeTag}`,
|
||||
type: 'variable',
|
||||
matchLen: matchLen,
|
||||
}))
|
||||
// Filter out exact matches
|
||||
.filter((o) => o.label !== toMatch.text),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,12 +3,20 @@ import { LRLanguage } from '@codemirror/language';
|
||||
import { parseMixed } from '@lezer/common';
|
||||
import type { GenericCompletionConfig } from '../genericCompletion';
|
||||
import { genericCompletion } from '../genericCompletion';
|
||||
import { placeholders } from '../placeholder';
|
||||
import { placeholders } from './placeholder';
|
||||
import { textLanguageName } from '../text/extension';
|
||||
import { completions } from './completion';
|
||||
import { twigCompletion } from './completion';
|
||||
import { parser as twigParser } from './twig';
|
||||
import type { Environment } from '../../../../lib/models';
|
||||
|
||||
export function twig(
|
||||
base: LanguageSupport,
|
||||
environment: Environment | null,
|
||||
autocomplete?: GenericCompletionConfig,
|
||||
) {
|
||||
const variables = environment?.variables.filter(v => v.enabled) ?? [];
|
||||
const completions = twigCompletion({ options: variables });
|
||||
|
||||
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 });
|
||||
@@ -21,7 +29,7 @@ export function twig(base: LanguageSupport, autocomplete?: GenericCompletionConf
|
||||
completion,
|
||||
completionBase,
|
||||
base.support,
|
||||
placeholders,
|
||||
placeholders(variables),
|
||||
...additionalCompletion,
|
||||
];
|
||||
}
|
||||
|
||||
88
src-web/components/core/Editor/twig/placeholder.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { DecorationSet, ViewUpdate } from '@codemirror/view';
|
||||
import { Decoration, EditorView, MatchDecorator, ViewPlugin, WidgetType } from '@codemirror/view';
|
||||
|
||||
class PlaceholderWidget extends WidgetType {
|
||||
constructor(
|
||||
readonly name: string,
|
||||
readonly isExistingVariable: boolean,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
eq(other: PlaceholderWidget) {
|
||||
return this.name == other.name && this.isExistingVariable == other.isExistingVariable;
|
||||
}
|
||||
toDOM() {
|
||||
const elt = document.createElement('span');
|
||||
elt.className = `placeholder-widget ${
|
||||
!this.isExistingVariable ? 'placeholder-widget-error' : ''
|
||||
}`;
|
||||
elt.textContent = this.name;
|
||||
return elt;
|
||||
}
|
||||
ignoreEvent() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This is a custom MatchDecorator that will not decorate a match if the selection is inside it
|
||||
*/
|
||||
class BetterMatchDecorator extends MatchDecorator {
|
||||
updateDeco(update: ViewUpdate, deco: DecorationSet): DecorationSet {
|
||||
if (!update.startState.selection.eq(update.state.selection)) {
|
||||
return super.createDeco(update.view);
|
||||
} else {
|
||||
return super.updateDeco(update, deco);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const placeholders = function (variables: { name: string }[]) {
|
||||
const placeholderMatcher = new BetterMatchDecorator({
|
||||
regexp: /\$\{\[\s*([^\]\s]+)\s*]}/g,
|
||||
decoration(match, view, matchStartPos) {
|
||||
const matchEndPos = matchStartPos + match[0].length - 1;
|
||||
|
||||
// Don't decorate if the cursor is inside the match
|
||||
for (const r of view.state.selection.ranges) {
|
||||
if (r.from > matchStartPos && r.to <= matchEndPos) {
|
||||
return Decoration.replace({});
|
||||
}
|
||||
}
|
||||
|
||||
const groupMatch = match[1];
|
||||
if (groupMatch == null) {
|
||||
// Should never happen, but make TS happy
|
||||
console.warn('Group match was empty', match);
|
||||
return Decoration.replace({});
|
||||
}
|
||||
|
||||
return Decoration.replace({
|
||||
inclusive: true,
|
||||
widget: new PlaceholderWidget(
|
||||
groupMatch,
|
||||
variables.some((v) => v.name === groupMatch),
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return ViewPlugin.fromClass(
|
||||
class {
|
||||
placeholders: DecorationSet;
|
||||
constructor(view: EditorView) {
|
||||
this.placeholders = placeholderMatcher.createDeco(view);
|
||||
}
|
||||
update(update: ViewUpdate) {
|
||||
this.placeholders = placeholderMatcher.updateDeco(update, this.placeholders);
|
||||
}
|
||||
},
|
||||
{
|
||||
decorations: (instance) => instance.placeholders,
|
||||
provide: (plugin) =>
|
||||
EditorView.atomicRanges.of((view) => {
|
||||
return view.plugin(plugin)?.placeholders || Decoration.none;
|
||||
}),
|
||||
},
|
||||
);
|
||||
};
|
||||
18
src-web/components/core/FormattedError.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import classNames from 'classnames';
|
||||
|
||||
interface Props {
|
||||
children: string;
|
||||
}
|
||||
|
||||
export function FormattedError({ children }: Props) {
|
||||
return (
|
||||
<pre
|
||||
className={classNames(
|
||||
'text-sm select-auto cursor-text bg-gray-100 p-3 rounded',
|
||||
'whitespace-normal border border-red-500 border-dashed',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import classnames from 'classnames';
|
||||
import classNames from 'classnames';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
export function Heading({ className, children, ...props }: HTMLAttributes<HTMLHeadingElement>) {
|
||||
return (
|
||||
<h1 className={classnames(className, 'text-2xl font-semibold text-gray-900 mb-3')} {...props}>
|
||||
<h1 className={classNames(className, 'text-2xl font-semibold text-gray-900 mb-3')} {...props}>
|
||||
{children}
|
||||
</h1>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import classnames from 'classnames';
|
||||
import classNames from 'classnames';
|
||||
|
||||
interface Props {
|
||||
modifier: 'Meta' | 'Control' | 'Shift';
|
||||
@@ -13,7 +13,7 @@ const keys: Record<Props['modifier'], string> = {
|
||||
|
||||
export function HotKey({ modifier, keyName }: Props) {
|
||||
return (
|
||||
<span className={classnames('text-sm text-gray-600')}>
|
||||
<span className={classNames('text-sm text-gray-600')}>
|
||||
{keys[modifier]}
|
||||
{keyName}
|
||||
</span>
|
||||
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
TriangleRightIcon,
|
||||
UpdateIcon,
|
||||
} from '@radix-ui/react-icons';
|
||||
import classnames from 'classnames';
|
||||
import classNames from 'classnames';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { memo } from 'react';
|
||||
import { ReactComponent as LeftPanelHiddenIcon } from '../../assets/icons/LeftPanelHiddenIcon.svg';
|
||||
@@ -95,7 +95,7 @@ export const Icon = memo(function Icon({ icon, spin, size = 'md', className }: I
|
||||
const Component = icons[icon] ?? icons.question;
|
||||
return (
|
||||
<Component
|
||||
className={classnames(
|
||||
className={classNames(
|
||||
className,
|
||||
'text-inherit',
|
||||
size === 'md' && 'h-4 w-4',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import classnames from 'classnames';
|
||||
import classNames from 'classnames';
|
||||
import type { MouseEvent } from 'react';
|
||||
import { forwardRef, useCallback } from 'react';
|
||||
import { useTimedBoolean } from '../../hooks/useTimedBoolean';
|
||||
@@ -45,7 +45,8 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(function IconButt
|
||||
disabled={icon === 'empty'}
|
||||
tabIndex={tabIndex ?? icon === 'empty' ? -1 : undefined}
|
||||
onClick={handleClick}
|
||||
className={classnames(
|
||||
innerClassName="flex items-center justify-center"
|
||||
className={classNames(
|
||||
className,
|
||||
'flex-shrink-0 text-gray-700 hover:text-gray-1000',
|
||||
'!px-0',
|
||||
@@ -60,7 +61,7 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(function IconButt
|
||||
size={iconSize}
|
||||
icon={confirmed ? 'check' : icon}
|
||||
spin={spin}
|
||||
className={classnames(
|
||||
className={classNames(
|
||||
iconClassName,
|
||||
props.disabled && 'opacity-70',
|
||||
confirmed && 'text-green-600',
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import classnames from 'classnames';
|
||||
import classNames from 'classnames';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
export function InlineCode({ className, ...props }: HTMLAttributes<HTMLSpanElement>) {
|
||||
return (
|
||||
<code
|
||||
className={classnames(
|
||||
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',
|
||||
)}
|
||||
|
||||
@@ -1,14 +1,27 @@
|
||||
import classnames from 'classnames';
|
||||
import classNames from 'classnames';
|
||||
import type { EditorView } from 'codemirror';
|
||||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
import { forwardRef, useCallback, useMemo, useState } from 'react';
|
||||
import { forwardRef, useCallback, useMemo, useRef, useState } from 'react';
|
||||
import type { EditorProps } from './Editor';
|
||||
import { Editor } from './Editor';
|
||||
import { IconButton } from './IconButton';
|
||||
import { HStack, VStack } from './Stacks';
|
||||
|
||||
export type InputProps = Omit<HTMLAttributes<HTMLInputElement>, 'onChange' | 'onFocus'> &
|
||||
Pick<EditorProps, 'contentType' | 'useTemplating' | 'autocomplete' | 'forceUpdateKey'> & {
|
||||
export type InputProps = Omit<
|
||||
HTMLAttributes<HTMLInputElement>,
|
||||
'onChange' | 'onFocus' | 'onKeyDown'
|
||||
> &
|
||||
Pick<
|
||||
EditorProps,
|
||||
| 'contentType'
|
||||
| 'useTemplating'
|
||||
| 'autocomplete'
|
||||
| 'forceUpdateKey'
|
||||
| 'autoFocus'
|
||||
| 'autoSelect'
|
||||
| 'autocompleteVariables'
|
||||
| 'onKeyDown'
|
||||
> & {
|
||||
name: string;
|
||||
type?: 'text' | 'password';
|
||||
label: string;
|
||||
@@ -21,34 +34,33 @@ export type InputProps = Omit<HTMLAttributes<HTMLInputElement>, 'onChange' | 'on
|
||||
defaultValue?: string;
|
||||
leftSlot?: ReactNode;
|
||||
rightSlot?: ReactNode;
|
||||
size?: 'sm' | 'md';
|
||||
size?: 'sm' | 'md' | 'auto';
|
||||
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,
|
||||
hideLabel,
|
||||
label,
|
||||
labelClassName,
|
||||
leftSlot,
|
||||
name,
|
||||
onBlur,
|
||||
onChange,
|
||||
onFocus,
|
||||
placeholder,
|
||||
require,
|
||||
rightSlot,
|
||||
size = 'md',
|
||||
type = 'text',
|
||||
validate,
|
||||
...props
|
||||
}: InputProps,
|
||||
ref,
|
||||
@@ -68,12 +80,9 @@ export const Input = forwardRef<EditorView | undefined, InputProps>(function Inp
|
||||
}, [onBlur]);
|
||||
|
||||
const id = `input-${name}`;
|
||||
const inputClassName = classnames(
|
||||
const inputClassName = classNames(
|
||||
className,
|
||||
'!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',
|
||||
'!bg-transparent min-w-0 h-auto w-full focus:outline-none placeholder:text-placeholder',
|
||||
);
|
||||
|
||||
const isValid = useMemo(() => {
|
||||
@@ -90,11 +99,26 @@ export const Input = forwardRef<EditorView | undefined, InputProps>(function Inp
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Submit nearest form on Enter key press
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key !== 'Enter') return;
|
||||
|
||||
const form = wrapperRef.current?.closest('form');
|
||||
if (!isValid || form == null) return;
|
||||
|
||||
form?.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
|
||||
},
|
||||
[isValid],
|
||||
);
|
||||
|
||||
return (
|
||||
<VStack className="w-full">
|
||||
<VStack ref={wrapperRef} className="w-full">
|
||||
<label
|
||||
htmlFor={id}
|
||||
className={classnames(
|
||||
className={classNames(
|
||||
labelClassName,
|
||||
'font-semibold text-xs uppercase text-gray-700',
|
||||
hideLabel && 'sr-only',
|
||||
@@ -103,32 +127,44 @@ export const Input = forwardRef<EditorView | undefined, InputProps>(function Inp
|
||||
{label}
|
||||
</label>
|
||||
<HStack
|
||||
alignItems="center"
|
||||
className={classnames(
|
||||
alignItems="stretch"
|
||||
className={classNames(
|
||||
containerClassName,
|
||||
'relative w-full rounded-md text-gray-900',
|
||||
'border',
|
||||
focused ? 'border-focus' : 'border-highlight',
|
||||
!isValid && '!border-invalid',
|
||||
size === 'md' && 'h-md leading-md',
|
||||
size === 'sm' && 'h-sm leading-sm',
|
||||
size === 'md' && 'h-md',
|
||||
size === 'sm' && 'h-sm',
|
||||
size === 'auto' && 'min-h-sm',
|
||||
)}
|
||||
>
|
||||
{leftSlot}
|
||||
<Editor
|
||||
ref={ref}
|
||||
id={id}
|
||||
singleLine
|
||||
type={type === 'password' && !obscured ? 'text' : type}
|
||||
defaultValue={defaultValue}
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
placeholder={placeholder}
|
||||
onChange={handleChange}
|
||||
className={inputClassName}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
{...props}
|
||||
/>
|
||||
<HStack
|
||||
alignItems="center"
|
||||
className={classNames(
|
||||
'w-full min-w-0',
|
||||
leftSlot && 'pl-0.5 -ml-2',
|
||||
rightSlot && 'pr-0.5 -mr-2',
|
||||
)}
|
||||
>
|
||||
<Editor
|
||||
ref={ref}
|
||||
id={id}
|
||||
singleLine
|
||||
wrapLines={size === 'auto'}
|
||||
onKeyDown={handleKeyDown}
|
||||
type={type === 'password' && !obscured ? 'text' : type}
|
||||
defaultValue={defaultValue}
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
placeholder={placeholder}
|
||||
onChange={handleChange}
|
||||
className={inputClassName}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
{...props}
|
||||
/>
|
||||
</HStack>
|
||||
{type === 'password' && (
|
||||
<IconButton
|
||||
title={obscured ? `Show ${label}` : `Obscure ${label}`}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import classnames from 'classnames';
|
||||
import React, { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { 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';
|
||||
@@ -10,6 +10,7 @@ import { Icon } from './Icon';
|
||||
import { IconButton } from './IconButton';
|
||||
import type { InputProps } from './Input';
|
||||
import { Input } from './Input';
|
||||
import type { EditorView } from 'codemirror';
|
||||
|
||||
export type PairEditorProps = {
|
||||
pairs: Pair[];
|
||||
@@ -20,11 +21,14 @@ export type PairEditorProps = {
|
||||
valuePlaceholder?: string;
|
||||
nameAutocomplete?: GenericCompletionConfig;
|
||||
valueAutocomplete?: (name: string) => GenericCompletionConfig | undefined;
|
||||
nameAutocompleteVariables?: boolean;
|
||||
valueAutocompleteVariables?: boolean;
|
||||
nameValidate?: InputProps['validate'];
|
||||
valueValidate?: InputProps['validate'];
|
||||
};
|
||||
|
||||
type Pair = {
|
||||
export type Pair = {
|
||||
id?: string;
|
||||
enabled?: boolean;
|
||||
name: string;
|
||||
value: string;
|
||||
@@ -36,17 +40,20 @@ type PairContainer = {
|
||||
};
|
||||
|
||||
export const PairEditor = memo(function PairEditor({
|
||||
pairs: originalPairs,
|
||||
className,
|
||||
forceUpdateKey,
|
||||
nameAutocomplete,
|
||||
valueAutocomplete,
|
||||
nameAutocompleteVariables,
|
||||
namePlaceholder,
|
||||
valuePlaceholder,
|
||||
nameValidate,
|
||||
valueValidate,
|
||||
className,
|
||||
onChange,
|
||||
pairs: originalPairs,
|
||||
valueAutocomplete,
|
||||
valueAutocompleteVariables,
|
||||
valuePlaceholder,
|
||||
valueValidate,
|
||||
}: PairEditorProps) {
|
||||
const [forceFocusPairId, setForceFocusPairId] = useState<string | null>(null);
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
const [pairs, setPairs] = useState<PairContainer[]>(() => {
|
||||
// Remove empty headers on initial render
|
||||
@@ -111,14 +118,21 @@ export const PairEditor = memo(function PairEditor({
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(pair: PairContainer) =>
|
||||
setPairsAndSave((oldPairs) => oldPairs.filter((p) => p.id !== pair.id)),
|
||||
[setPairsAndSave],
|
||||
(pair: PairContainer, focusPrevious: boolean) => {
|
||||
if (focusPrevious) {
|
||||
const index = pairs.findIndex((p) => p.id === pair.id);
|
||||
const id = pairs[index - 1]?.id ?? null;
|
||||
setForceFocusPairId(id);
|
||||
}
|
||||
return setPairsAndSave((oldPairs) => oldPairs.filter((p) => p.id !== pair.id));
|
||||
},
|
||||
[setPairsAndSave, setForceFocusPairId, pairs],
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(
|
||||
(pair: PairContainer) =>
|
||||
setPairs((pairs) => {
|
||||
setForceFocusPairId(null); // Remove focus override when something focused
|
||||
const isLast = pair.id === pairs[pairs.length - 1]?.id;
|
||||
return isLast ? [...pairs, newPairContainer()] : pairs;
|
||||
}),
|
||||
@@ -134,12 +148,14 @@ export const PairEditor = memo(function PairEditor({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classnames(
|
||||
className={classNames(
|
||||
className,
|
||||
'@container',
|
||||
'pb-2 grid overflow-auto max-h-full',
|
||||
// Move over the width of the drag handle
|
||||
'-ml-3',
|
||||
// Pad to make room for the drag divider
|
||||
'pt-0.5',
|
||||
)}
|
||||
>
|
||||
{pairs.map((p, i) => {
|
||||
@@ -151,16 +167,20 @@ export const PairEditor = memo(function PairEditor({
|
||||
pairContainer={p}
|
||||
className="py-1"
|
||||
isLast={isLast}
|
||||
nameAutocompleteVariables={nameAutocompleteVariables}
|
||||
valueAutocompleteVariables={valueAutocompleteVariables}
|
||||
forceFocusPairId={forceFocusPairId}
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
nameAutocomplete={nameAutocomplete}
|
||||
valueAutocomplete={valueAutocomplete}
|
||||
namePlaceholder={namePlaceholder}
|
||||
valuePlaceholder={valuePlaceholder}
|
||||
namePlaceholder={isLast ? namePlaceholder : ''}
|
||||
valuePlaceholder={isLast ? valuePlaceholder : ''}
|
||||
nameValidate={nameValidate}
|
||||
valueValidate={valueValidate}
|
||||
showDelete={!isLast}
|
||||
onChange={handleChange}
|
||||
onFocus={handleFocus}
|
||||
onDelete={isLast ? undefined : handleDelete}
|
||||
onDelete={handleDelete}
|
||||
onEnd={handleEnd}
|
||||
onMove={handleMove}
|
||||
/>
|
||||
@@ -178,16 +198,21 @@ enum ItemTypes {
|
||||
type FormRowProps = {
|
||||
className?: string;
|
||||
pairContainer: PairContainer;
|
||||
forceFocusPairId?: string | null;
|
||||
showDelete?: boolean;
|
||||
onMove: (id: string, side: 'above' | 'below') => void;
|
||||
onEnd: (id: string) => void;
|
||||
onChange: (pair: PairContainer) => void;
|
||||
onDelete?: (pair: PairContainer) => void;
|
||||
onDelete?: (pair: PairContainer, focusPrevious: boolean) => void;
|
||||
onFocus?: (pair: PairContainer) => void;
|
||||
onSubmit?: (pair: PairContainer) => void;
|
||||
isLast?: boolean;
|
||||
} & Pick<
|
||||
PairEditorProps,
|
||||
| 'nameAutocomplete'
|
||||
| 'valueAutocomplete'
|
||||
| 'nameAutocompleteVariables'
|
||||
| 'valueAutocompleteVariables'
|
||||
| 'namePlaceholder'
|
||||
| 'valuePlaceholder'
|
||||
| 'nameValidate'
|
||||
@@ -197,23 +222,34 @@ type FormRowProps = {
|
||||
|
||||
const FormRow = memo(function FormRow({
|
||||
className,
|
||||
pairContainer,
|
||||
forceFocusPairId,
|
||||
forceUpdateKey,
|
||||
isLast,
|
||||
nameAutocomplete,
|
||||
namePlaceholder,
|
||||
nameAutocompleteVariables,
|
||||
valueAutocompleteVariables,
|
||||
nameValidate,
|
||||
onChange,
|
||||
onDelete,
|
||||
onEnd,
|
||||
onFocus,
|
||||
onMove,
|
||||
onEnd,
|
||||
isLast,
|
||||
forceUpdateKey,
|
||||
nameAutocomplete,
|
||||
pairContainer,
|
||||
showDelete,
|
||||
valueAutocomplete,
|
||||
namePlaceholder,
|
||||
valuePlaceholder,
|
||||
nameValidate,
|
||||
valueValidate,
|
||||
}: FormRowProps) {
|
||||
const { id } = pairContainer;
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const nameInputRef = useRef<EditorView>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (forceFocusPairId === pairContainer.id) {
|
||||
nameInputRef.current?.focus();
|
||||
}
|
||||
}, [forceFocusPairId, pairContainer.id]);
|
||||
|
||||
const handleChangeEnabled = useMemo(
|
||||
() => (enabled: boolean) => onChange({ id, pair: { ...pairContainer.pair, enabled } }),
|
||||
@@ -231,12 +267,15 @@ const FormRow = memo(function FormRow({
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(() => onFocus?.(pairContainer), [onFocus, pairContainer]);
|
||||
const handleDelete = useCallback(() => onDelete?.(pairContainer), [onDelete, pairContainer]);
|
||||
const handleDelete = useCallback(
|
||||
() => onDelete?.(pairContainer, false),
|
||||
[onDelete, pairContainer],
|
||||
);
|
||||
|
||||
const [, connectDrop] = useDrop<PairContainer>(
|
||||
{
|
||||
accept: ItemTypes.ROW,
|
||||
hover: (item, monitor) => {
|
||||
hover: (_, monitor) => {
|
||||
if (!ref.current) return;
|
||||
const hoverBoundingRect = ref.current?.getBoundingClientRect();
|
||||
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
|
||||
@@ -264,7 +303,7 @@ const FormRow = memo(function FormRow({
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={classnames(
|
||||
className={classNames(
|
||||
className,
|
||||
'group grid grid-cols-[auto_auto_minmax(0,1fr)_auto]',
|
||||
'grid-rows-1 items-center',
|
||||
@@ -273,7 +312,7 @@ const FormRow = memo(function FormRow({
|
||||
>
|
||||
{!isLast ? (
|
||||
<div
|
||||
className={classnames(
|
||||
className={classNames(
|
||||
'py-2 h-7 w-3 flex items-center',
|
||||
'justify-center opacity-0 hover:opacity-100',
|
||||
)}
|
||||
@@ -284,26 +323,28 @@ const FormRow = memo(function FormRow({
|
||||
<span className="w-3" />
|
||||
)}
|
||||
<Checkbox
|
||||
title={pairContainer.pair.enabled ? 'disable entry' : 'Enable item'}
|
||||
disabled={isLast}
|
||||
checked={isLast ? false : !!pairContainer.pair.enabled}
|
||||
className={classnames('mr-2', isLast && '!opacity-disabled')}
|
||||
className={classNames('mr-2', isLast && '!opacity-disabled')}
|
||||
onChange={handleChangeEnabled}
|
||||
/>
|
||||
<div
|
||||
className={classnames(
|
||||
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
|
||||
ref={nameInputRef}
|
||||
hideLabel
|
||||
useTemplating
|
||||
size="sm"
|
||||
require={!isLast && !!pairContainer.pair.enabled && !!pairContainer.pair.value}
|
||||
validate={nameValidate}
|
||||
useTemplating
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
containerClassName={classnames(isLast && 'border-dashed')}
|
||||
containerClassName={classNames(isLast && 'border-dashed')}
|
||||
defaultValue={pairContainer.pair.name}
|
||||
label="Name"
|
||||
name="name"
|
||||
@@ -311,11 +352,13 @@ const FormRow = memo(function FormRow({
|
||||
onFocus={handleFocus}
|
||||
placeholder={namePlaceholder ?? 'name'}
|
||||
autocomplete={nameAutocomplete}
|
||||
autocompleteVariables={nameAutocompleteVariables}
|
||||
/>
|
||||
<Input
|
||||
hideLabel
|
||||
useTemplating
|
||||
size="sm"
|
||||
containerClassName={classnames(isLast && 'border-dashed')}
|
||||
containerClassName={classNames(isLast && 'border-dashed')}
|
||||
validate={valueValidate}
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
defaultValue={pairContainer.pair.value}
|
||||
@@ -324,24 +367,26 @@ const FormRow = memo(function FormRow({
|
||||
onChange={handleChangeValue}
|
||||
onFocus={handleFocus}
|
||||
placeholder={valuePlaceholder ?? 'value'}
|
||||
useTemplating
|
||||
autocomplete={valueAutocomplete?.(pairContainer.pair.name)}
|
||||
autocompleteVariables={valueAutocompleteVariables}
|
||||
/>
|
||||
</div>
|
||||
<IconButton
|
||||
aria-hidden={!onDelete}
|
||||
disabled={!onDelete}
|
||||
aria-hidden={!showDelete}
|
||||
disabled={!showDelete}
|
||||
color="custom"
|
||||
icon={onDelete ? 'trash' : 'empty'}
|
||||
icon={showDelete ? 'trash' : 'empty'}
|
||||
size="sm"
|
||||
title="Delete header"
|
||||
onClick={handleDelete}
|
||||
className="ml-0.5 !opacity-0 group-hover:!opacity-100 focus-visible:!opacity-100"
|
||||
onClick={showDelete ? handleDelete : undefined}
|
||||
className="ml-0.5 group-hover:!opacity-100 focus-visible:!opacity-100"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const newPairContainer = (pair?: Pair): PairContainer => {
|
||||
return { pair: pair ?? { name: '', value: '', enabled: true }, id: uuid() };
|
||||
const newPairContainer = (initialPair?: Pair): PairContainer => {
|
||||
const id = initialPair?.id ?? uuid();
|
||||
const pair = initialPair ?? { name: '', value: '', enabled: true };
|
||||
return { id, pair };
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import classnames from 'classnames';
|
||||
import classNames from 'classnames';
|
||||
|
||||
interface Props {
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
@@ -14,10 +14,10 @@ export function Separator({
|
||||
label,
|
||||
}: Props) {
|
||||
return (
|
||||
<div role="separator" className={classnames(className, 'flex items-center')}>
|
||||
<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(
|
||||
className={classNames(
|
||||
variant === 'primary' && 'bg-highlight',
|
||||
variant === 'secondary' && 'bg-highlightSecondary',
|
||||
orientation === 'horizontal' && 'w-full h-[1px]',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import classnames from 'classnames';
|
||||
import classNames from 'classnames';
|
||||
import type { ComponentType, ForwardedRef, HTMLAttributes, ReactNode } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
@@ -25,7 +25,7 @@ export const HStack = forwardRef(function HStack(
|
||||
return (
|
||||
<BaseStack
|
||||
ref={ref}
|
||||
className={classnames(className, 'flex-row', space != null && gapClasses[space])}
|
||||
className={classNames(className, 'flex-row', space != null && gapClasses[space])}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
@@ -45,7 +45,7 @@ export const VStack = forwardRef(function VStack(
|
||||
return (
|
||||
<BaseStack
|
||||
ref={ref}
|
||||
className={classnames(className, 'flex-col', space != null && gapClasses[space])}
|
||||
className={classNames(className, 'flex-col', space != null && gapClasses[space])}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
@@ -56,8 +56,8 @@ export const VStack = forwardRef(function VStack(
|
||||
type BaseStackProps = HTMLAttributes<HTMLElement> & {
|
||||
as?: ComponentType | 'ul' | 'form';
|
||||
space?: keyof typeof gapClasses;
|
||||
alignItems?: 'start' | 'center';
|
||||
justifyContent?: 'start' | 'center' | 'end';
|
||||
alignItems?: 'start' | 'center' | 'stretch';
|
||||
justifyContent?: 'start' | 'center' | 'end' | 'between';
|
||||
};
|
||||
|
||||
const BaseStack = forwardRef(function BaseStack(
|
||||
@@ -69,14 +69,16 @@ const BaseStack = forwardRef(function BaseStack(
|
||||
return (
|
||||
<Component
|
||||
ref={ref}
|
||||
className={classnames(
|
||||
className={classNames(
|
||||
className,
|
||||
'flex',
|
||||
alignItems === 'center' && 'items-center',
|
||||
alignItems === 'start' && 'items-start',
|
||||
alignItems === 'stretch' && 'items-stretch',
|
||||
justifyContent === 'start' && 'justify-start',
|
||||
justifyContent === 'center' && 'justify-center',
|
||||
justifyContent === 'end' && 'justify-end',
|
||||
justifyContent === 'between' && 'justify-between',
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import classnames from 'classnames';
|
||||
import classNames from 'classnames';
|
||||
import type { HttpResponse } from '../../lib/models';
|
||||
|
||||
interface Props {
|
||||
@@ -12,7 +12,7 @@ export function StatusTag({ response, className, showReason }: Props) {
|
||||
const label = error ? 'ERR' : status;
|
||||
return (
|
||||
<span
|
||||
className={classnames(
|
||||
className={classNames(
|
||||
className,
|
||||
'font-mono',
|
||||
status >= 0 && status < 100 && 'text-red-600',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import classnames from 'classnames';
|
||||
import classNames from 'classnames';
|
||||
import type { ReactNode } from 'react';
|
||||
import { memo, useCallback, useEffect, useRef } from 'react';
|
||||
import { Button } from '../Button';
|
||||
@@ -67,11 +67,11 @@ export function Tabs({
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={classnames(className, 'h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1')}
|
||||
className={classNames(className, 'h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1')}
|
||||
>
|
||||
<div
|
||||
aria-label={label}
|
||||
className={classnames(
|
||||
className={classNames(
|
||||
tabListClassName,
|
||||
'flex items-center overflow-x-auto overflow-y-visible hide-scrollbars mt-1 mb-2',
|
||||
// Give space for button focus states within overflow boundary.
|
||||
@@ -81,7 +81,7 @@ export function Tabs({
|
||||
<HStack space={2} className="flex-shrink-0">
|
||||
{tabs.map((t) => {
|
||||
const isActive = t.value === value;
|
||||
const btnClassName = classnames(
|
||||
const btnClassName = classNames(
|
||||
isActive ? '' : 'text-gray-600 hover:text-gray-800',
|
||||
'!px-2 ml-[1px]',
|
||||
);
|
||||
@@ -102,14 +102,16 @@ export function Tabs({
|
||||
size="sm"
|
||||
onClick={isActive ? undefined : () => handleTabChange(t.value)}
|
||||
className={btnClassName}
|
||||
rightSlot={
|
||||
<Icon
|
||||
icon="triangleDown"
|
||||
className={classNames('-mr-1.5', isActive ? 'opacity-100' : 'opacity-20')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{option && 'shortLabel' in option
|
||||
? option.shortLabel
|
||||
: option?.label ?? 'Unknown'}
|
||||
<Icon
|
||||
icon="triangleDown"
|
||||
className={classnames('-mr-1.5', isActive ? 'opacity-100' : 'opacity-20')}
|
||||
/>
|
||||
</Button>
|
||||
</RadioDropdown>
|
||||
);
|
||||
@@ -149,7 +151,7 @@ export const TabContent = memo(function TabContent({
|
||||
<div
|
||||
tabIndex={-1}
|
||||
data-tab={value}
|
||||
className={classnames(className, 'tab-content', 'hidden w-full h-full')}
|
||||
className={classNames(className, 'tab-content', 'hidden w-full h-full')}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import classnames from 'classnames';
|
||||
import classNames from 'classnames';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
@@ -10,7 +10,7 @@ export function WindowDragRegion({ className, ...props }: Props) {
|
||||
return (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className={classnames(className, 'w-full flex-shrink-0')}
|
||||
className={classNames(className, 'w-full flex-shrink-0')}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import classnames from 'classnames';
|
||||
import classNames from 'classnames';
|
||||
import Papa from 'papaparse';
|
||||
import { useMemo } from 'react';
|
||||
import { useResponseBodyText } from '../../hooks/useResponseBodyText';
|
||||
@@ -21,10 +21,10 @@ export function CsvViewer({ response, className }: Props) {
|
||||
|
||||
return (
|
||||
<div className="overflow-auto h-full">
|
||||
<table className={classnames(className, 'text-sm')}>
|
||||
<table className={classNames(className, 'text-sm')}>
|
||||
<tbody>
|
||||
{parsed.data.map((row, i) => (
|
||||
<tr key={i} className={classnames('border-l border-t', i > 0 && 'border-b')}>
|
||||
<tr key={i} className={classNames('border-l border-t', i > 0 && 'border-b')}>
|
||||
{row.map((col, j) => (
|
||||
<td key={j} className="border-r px-1.5">
|
||||
{col}
|
||||
|
||||