Compare commits

...

71 Commits

Author SHA1 Message Date
Gregory Schier
32b135dbaf Fix sending of ephemeral requests 2023-10-30 08:24:49 -07:00
Gregory Schier
0fc8d12a06 Fix GQL introspection and bearer auth templating 2023-10-30 08:07:34 -07:00
Gregory Schier
3c2bdab101 Fix button styles 2023-10-30 07:27:27 -07:00
Gregory Schier
8b5d7ae3ed Fix editor stale callbacks and recent item deletion 2023-10-30 07:07:14 -07:00
Gregory Schier
51949f4fbf Refactored some core UI 2023-10-30 06:35:52 -07:00
Gregory Schier
6013cd2329 Plugin module loading 2023-10-29 20:50:23 -07:00
Gregory Schier
eba28ade48 Bump version 2023-10-29 17:22:27 -07:00
Gregory Schier
44af1ddc8a Fix sidebar scroll 2023-10-29 17:19:03 -07:00
Gregory Schier
63c0d09df8 A bit more playing with JS runtime 2023-10-29 17:05:48 -07:00
Gregory Schier
f305633d94 Initial "Hello World" for plugins 2023-10-29 16:43:28 -07:00
Gregory Schier
13155f8591 Fix request creation 2023-10-29 12:05:05 -07:00
Gregory Schier
f2ac97aa62 Restore recent environment on workspace change
Fixes #6
2023-10-29 11:32:55 -07:00
Gregory Schier
18eb0027a1 Fix var complete and env dialog actions 2023-10-29 11:18:55 -07:00
Gregory Schier
9e2803fcfb Remove broken key/value enter/backspace logic 2023-10-29 10:45:05 -07:00
Gregory Schier
705e30b6e0 Delete key/value on backspace 2023-10-29 10:26:38 -07:00
Gregory Schier
f1260911ea Move workspace menu, better env mgmt, QoL 2023-10-29 09:45:16 -07:00
Gregory Schier
076ff63dbe Bump version 2023-10-28 23:41:58 -07:00
Gregory Schier
899092b4d2 Better listening for path changes 2023-10-28 23:41:24 -07:00
Gregory Schier
c2c3a28aab Bump version 2023-10-28 22:14:51 -07:00
Gregory Schier
25c0db502e Fixed auto-focus in prompt and env dropdown 2023-10-28 22:14:12 -07:00
Gregory Schier
6dcbe45a53 Clear selected sidebar index on drag-drop end 2023-10-28 21:47:00 -07:00
Gregory Schier
e2b46f25ff Revert debug name 2023-10-28 21:43:09 -07:00
Gregory Schier
981182be46 Fix drag-n-drop things 2023-10-28 21:42:35 -07:00
Gregory Schier
ad164ebd5e Persist window paths 2023-10-28 21:23:46 -07:00
Gregory Schier
cacdad8826 Bump version to 2023.1.0 2023-10-28 19:15:33 -07:00
Gregory Schier
77e5142a7c Update placeholders when env changes 2023-10-28 19:14:51 -07:00
Gregory Schier
613081728d Placeholder error and fix env nav 2023-10-28 19:08:31 -07:00
Gregory Schier
23e77dfec1 Recent requests/workspaces. Closes #1 2023-10-28 18:46:54 -07:00
Gregory Schier
6e273ae2a3 Fix recent requests loading on startup 2023-10-28 18:27:18 -07:00
Gregory Schier
4061094988 Add tauri window save state plugin 2023-10-28 13:14:27 -07:00
Gregory Schier
82b185e27f Fix rustfmt 2023-10-28 12:45:25 -07:00
Gregory Schier
27dc261639 Handle enabled/disabled variables and render multi 2023-10-28 11:36:40 -07:00
Gregory Schier
7e45fecf19 Remove unused Variable type 2023-10-28 11:31:45 -07:00
Gregory Schier
1a5053380b Variables under Environment, and render all props 2023-10-28 11:29:29 -07:00
Gregory Schier
408665c62d Native Codemirror cursor 2023-10-27 13:14:41 -07:00
Gregory Schier
65efee2048 Only wrap URLBar on focus and hotkey to open recent requests 2023-10-27 12:40:43 -07:00
Gregory Schier
3faa66a1fc Resizing window no longer changes sidebar visibility
Fixes #4
2023-10-27 11:21:59 -07:00
Gregory Schier
9dafe4f704 Auto-expand URL bar height 2023-10-27 10:57:07 -07:00
Gregory Schier
356eaf1713 Environment deletion and better actions menu 2023-10-26 16:18:47 -07:00
Gregory Schier
f8584f1537 Stop autocomplete from jumping around 2023-10-26 15:27:48 -07:00
Gregory Schier
6ad6cb34b0 Fix request creation from menu 2023-10-26 10:41:14 -07:00
Gregory Schier
32b27cd780 Send requests with active environment 2023-10-26 10:32:06 -07:00
Gregory Schier
0344a1e8c9 Move create request and fix slow HTML highlighting 2023-10-26 09:42:19 -07:00
Gregory Schier
0515271c12 Better project selector, Fixes #2, and a bunch more 2023-10-26 09:11:44 -07:00
Gregory Schier
5ae8d54ce0 Fixed some routing and introspection requests 2023-10-25 21:53:18 -07:00
Gregory Schier
33c406ce49 Environments in URL and better rendering 2023-10-25 11:13:00 -07:00
Gregory Schier
3b660ddbd0 Move responses dropdown to separate component 2023-10-25 07:59:10 -07:00
Gregory Schier
3132728a27 Fix dialog height 2023-10-25 00:02:51 -07:00
Gregory Schier
7063128342 Better style when no active environment 2023-10-24 23:58:12 -07:00
Gregory Schier
2187775462 Environment dropdown and actions 2023-10-24 09:17:29 -07:00
Gregory Schier
18adcd1004 Started on environment edit dialog 2023-10-23 21:00:36 -07:00
Gregory Schier
b0656d1e38 Hacky implementation of variable autocomplete 2023-10-23 10:31:21 -07:00
Gregory Schier
38e66047e0 Rendered first variable! 2023-10-22 22:30:29 -07:00
Gregory Schier
c24f049dac Updating environments! 2023-10-22 22:06:51 -07:00
Gregory Schier
53d13c8172 Update .gitignore 2023-10-22 20:40:00 -07:00
Gregory Schier
0727c6e437 Prettier and start of env editor 2023-10-22 20:38:57 -07:00
Gregory Schier
8328d20150 Environments data model 2023-10-22 18:28:56 -07:00
Gregory Schier
afe6a3bf57 Environment data model backend 2023-10-22 16:05:09 -07:00
Gregory Schier
d920632cbd Fix some eslint warnings 2023-10-22 11:02:39 -07:00
Gregory Schier
5c456fd4d5 Add APPLE_TEAM_ID 2023-10-18 14:12:08 -07:00
Gregory Schier
38c247e350 Revert artifacts things 2023-10-18 13:25:35 -07:00
Gregory Schier
0c8f72124a Bump cargo deps 2023-10-18 13:25:20 -07:00
Gregory Schier
80ed6b1525 Bump version 2023-10-18 12:14:38 -07:00
Gregory Schier
4424b3f208 Fix sidebar drag-n-drop 2023-10-18 11:58:58 -07:00
Gregory Schier
2c75abce09 Retry button on introspection errors 2023-06-12 13:20:42 -07:00
Gregory Schier
4e15eb197f Fix autocomplete doc font size 2023-05-31 21:32:48 -07:00
Gregory Schier
a7544b4f8c Persist introspection queries and also improve 2023-05-31 21:29:41 -07:00
Gregory Schier
d126aad172 Update tauri NPM 2023-05-29 12:49:50 -07:00
Gregory Schier
acc5c0de50 Fix graphql instrospection 2023-05-29 12:31:34 -07:00
Gregory Schier
3391da111d Change version 2023-04-27 16:53:39 -07:00
Gregory Schier
e37ce96956 Version 1.0.0 2023-04-27 16:47:49 -07:00
134 changed files with 6893 additions and 9768 deletions

View File

@@ -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
View File

@@ -22,5 +22,6 @@ dist-ssr
*.njsproj
*.sln
*.sw?
.eslintcache
*.sqlite

1
.husky/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
_

4
.husky/pre-commit Executable file
View File

@@ -0,0 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npx lint-staged

2
.nvmrc
View File

@@ -1 +1 @@
18
20

5
.sqllsrc.json Normal file
View File

@@ -0,0 +1,5 @@
{
"name": "yaak-dev",
"adapter": "sqlite3",
"filename": "src-tauri/db.sqlite"
}

7
Makefile Normal file
View 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

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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
View File

@@ -0,0 +1 @@
edition = "2018"

3126
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

View 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 '{}'
);

View File

@@ -0,0 +1,2 @@
ALTER TABLE environments DROP COLUMN data;
ALTER TABLE environments ADD COLUMN variables DEFAULT '[]' NOT NULL;

View File

@@ -0,0 +1,3 @@
export function hello() {
sayHello('Plugin');
}

View 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]+/));
}

View File

@@ -1 +0,0 @@
Deno.core.opAsync('op_hello', 'Deno');

View File

@@ -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": [

View File

@@ -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,

View File

@@ -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
View 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
View 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()
}

View File

@@ -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);

View File

@@ -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()
}
}

View File

@@ -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

View File

@@ -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"

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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"

View File

@@ -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"

View File

@@ -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>
);
}

View File

@@ -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',
)}

View 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>
);
});

View 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>
);
};

View File

@@ -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);

View File

@@ -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>

View File

@@ -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;
};

View File

@@ -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>
)}

View File

@@ -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>

View 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>&bull;</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>
);
};

View File

@@ -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();
});

View File

@@ -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'}

View File

@@ -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'}

View File

@@ -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',

View File

@@ -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>

View File

@@ -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>&bull;</span> <span>{r.elapsed}ms</span>
</HStack>
),
leftSlot:
activeResponse?.id === r.id ? <Icon icon="check" /> : <Icon icon="empty" />,
onSelect: () => setPinnedResponseId(r.id),
})),
]}
>
<IconButton
title="Show response history"
icon="triangleDown"
className="ml-auto"
size="sm"
iconSize="md"
/>
</Dropdown>
<RecentResponsesDropdown
responses={responses}
activeResponse={activeResponse}
onPinnedResponse={handlePinnedResponse}
/>
</HStack>
)}
</HStack>

View File

@@ -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"

View File

@@ -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}

View File

@@ -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>
);
});

View File

@@ -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}
/>

View File

@@ -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',

View File

@@ -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>

View File

@@ -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 />

View File

@@ -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 })} />;
}

View File

@@ -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',
)}

View File

@@ -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>

View File

@@ -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',

View File

@@ -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',
)}

View File

@@ -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

View File

@@ -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>
);
}

View File

@@ -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: '𝑥';
}

View File

@@ -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

View File

@@ -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(),

View File

@@ -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,
};
};
}

View File

@@ -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;
}),
},
);

View File

@@ -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;
},
);
}

View File

@@ -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),
};
};
}

View File

@@ -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,
];
}

View 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;
}),
},
);
};

View 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>
);
}

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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',

View File

@@ -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',

View File

@@ -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',
)}

View File

@@ -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}`}

View File

@@ -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 };
};

View File

@@ -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]',

View File

@@ -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}
>

View File

@@ -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',

View File

@@ -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>

View File

@@ -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}
/>
);

View File

@@ -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}

Some files were not shown because too many files have changed in this diff Show More