Compare commits

...

123 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
Gregory Schier
c51831c975 Fix window methods on Linux/Windows 2023-04-27 16:27:02 -07:00
Gregory Schier
180aa39de4 Cross platform window controls 2023-04-27 10:19:49 -07:00
Gregory Schier
3bd780782e Fix ubuntu build 2023-04-26 17:00:30 -07:00
Gregory Schier
f9ba2f79c2 Windows and Linux 2023-04-26 16:54:51 -07:00
Gregory Schier
d9493de2be Bump version 2023-04-26 16:47:50 -07:00
Gregory Schier
bc9a623742 Very basic CSV viewer 2023-04-22 21:53:04 +08:00
Gregory Schier
532edbf274 Truncate response files 2023-04-14 14:15:33 -07:00
Gregory Schier
1585692328 Randomly offset new windows 2023-04-14 14:05:23 -07:00
Gregory Schier
083f565b12 Fix text encoding and delete responses 2023-04-14 13:50:41 -07:00
Gregory Schier
f7f7438c9e Delete response files 2023-04-14 12:17:11 -07:00
Gregory Schier
19934a93bb Readonly editor disable tabindex 2023-04-13 22:36:11 -07:00
Gregory Schier
577cfe5bdc Fix imageview padding 2023-04-13 22:33:47 -07:00
Gregory Schier
43ac6afae1 Duration and size tags 2023-04-13 20:50:17 -07:00
Gregory Schier
8cc11703d3 Comment 2023-04-13 18:55:32 -07:00
Gregory Schier
4f7a116378 Always store response on filesystem 2023-04-13 18:52:56 -07:00
Gregory Schier
513793d9ce Support binary responses! 2023-04-13 18:48:40 -07:00
Gregory Schier
67f32b6734 Blur backdrop 2023-04-11 16:12:26 -07:00
Gregory Schier
66813d67fe Autofocus buttons 2023-04-11 14:04:23 -07:00
Gregory Schier
a38691ed53 Better opening workspaces and redirect workspace to recent request 2023-04-11 11:11:36 -07:00
Gregory Schier
deeefdcfbf Button disabled style opacity 2023-04-10 16:03:45 -07:00
Gregory Schier
db292511b1 Dropdown keys and pointer events 2023-04-10 16:02:29 -07:00
Gregory Schier
1a5334c1ce Upgrade deno core 2023-04-10 11:16:25 -07:00
Gregory Schier
11002abe39 Tweak response history 2023-04-09 23:15:51 -07:00
Gregory Schier
d922dcb062 Fixed multi-window model sync 2023-04-09 22:32:47 -07:00
Gregory Schier
6fcaa18e86 Tweak recent requests 2023-04-09 22:25:00 -07:00
Gregory Schier
7664c941dd Toggle settings 2023-04-09 22:12:16 -07:00
Gregory Schier
6f5cb528c6 Fix sidebar request focus 2023-04-09 22:03:41 -07:00
Gregory Schier
ebb78922f0 More stuff on sidebar 2023-04-09 21:52:04 -07:00
Gregory Schier
2285fe9f1c Small tweaks 2023-04-09 15:32:13 -07:00
Gregory Schier
38ba8625d8 Request history navigator 2023-04-09 15:26:54 -07:00
Gregory Schier
ab5681c7ad Enter name on create workspace 2023-04-09 12:27:02 -07:00
Gregory Schier
f66dcb9267 Rename workspace 2023-04-09 12:23:41 -07:00
Gregory Schier
1b6cfbac77 Sidebar hover transitions 2023-04-06 16:30:46 -07:00
Gregory Schier
4c27e788ea Remove some more key value usage 2023-04-06 16:26:56 -07:00
Gregory Schier
769da0b052 A bunch of tweaks 2023-04-06 16:05:25 -07:00
Gregory Schier
6b60c86300 macOS 12 2023-04-06 08:39:30 -07:00
Gregory Schier
30c1b5e8c7 Remove system tray icon 2023-04-06 08:15:40 -07:00
Gregory Schier
10af9b6f99 Minor tweaks 2023-04-04 17:21:02 -07:00
Gregory Schier
aa8c066f2d Fix some things 2023-04-04 16:56:45 -07:00
Gregory Schier
b913b74449 Editor line wrapping support (not used yet) 2023-04-04 16:40:37 -07:00
Gregory Schier
b71adce50b remove janky last location tracking 2023-04-04 16:23:08 -07:00
Gregory Schier
0fbb44c701 Fix resize cursor 2023-04-04 16:12:45 -07:00
Gregory Schier
de335e8637 Better button styles 2023-04-04 15:40:25 -07:00
Gregory Schier
2999f63a4c Bump version 2023-04-04 13:56:24 -07:00
Gregory Schier
2abc5e6f0b Some small fixes 2023-04-04 13:56:14 -07:00
Gregory Schier
639de4321e A few fixes 2023-04-04 13:31:48 -07:00
Gregory Schier
b3c461afdd Better status tags and delete request on key 2023-04-04 12:36:30 -07:00
Gregory Schier
7d154800a0 Remove expects from request sending 2023-04-04 08:14:32 -07:00
Gregory Schier
b48ed0399e Fix web view height 2023-04-04 07:51:41 -07:00
Gregory Schier
c5d6e7d74a Fix autocomplete spacing 2023-04-04 07:51:19 -07:00
Gregory Schier
e82f915363 Fix input focus border 2023-04-03 12:19:37 -07:00
Gregory Schier
3128e9ce76 Hot keys and cleanup 2023-04-03 07:59:49 -07:00
166 changed files with 8880 additions and 10509 deletions

View File

@@ -8,9 +8,9 @@ module.exports = {
"plugin:@typescript-eslint/recommended",
"eslint-config-prettier"
],
parser: '@typescript-eslint/parser',
parser: "@typescript-eslint/parser",
parserOptions: {
project: ['./tsconfig.json'],
project: ["./tsconfig.json"]
},
ignorePatterns: ["src-tauri/**/*"],
settings: {
@@ -25,13 +25,13 @@ module.exports = {
}
},
rules: {
"jsx-a11y/no-autofocus": "warn",
"jsx-a11y/no-autofocus": "off",
"react/react-in-jsx-scope": "off",
"import/no-unresolved": "off",
"@typescript-eslint/consistent-type-imports": ["error", {
prefer: "type-imports",
disallowTypeAnnotations: true,
fixStyle: "separate-type-imports"
}],
}]
}
};

View File

@@ -5,19 +5,26 @@ on:
jobs:
build-artifacts:
runs-on: ${{ matrix.platform }}
strategy:
fail-fast: false
matrix:
# platform: [ ubuntu-latest, macos-latest, windows-latest ]
platform: [ macos-latest ]
include:
- os: macos-12
target: aarch64-apple-darwin
- os: macos-latest
target: x86_64-apple-darwin
- os: windows-2022
target: x86_64-pc-windows-msvc
- os: ubuntu-20.04
target: x86_64-unknown-linux-gnu
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v3
- uses: dtolnay/rust-toolchain@stable
with:
targets: 'aarch64-apple-darwin,x86_64-apple-darwin'
targets: ${{ matrix.target }}
- name: Cache Rust
uses: actions/cache@v2
with:
@@ -31,7 +38,7 @@ jobs:
node-version: 18
cache: 'npm'
- name: install dependencies (ubuntu only)
if: matrix.platform == 'ubuntu-latest'
if: matrix.os == 'ubuntu-20.04'
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf
@@ -51,11 +58,12 @@ 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__'
releaseName: 'Release __VERSION__'
releaseBody: 'See the assets to download this version and install.'
releaseDraft: false
releaseBody: '<!-- Release Notes -->'
releaseDraft: true
prerelease: false
args: '--target universal-apple-darwin'
args: '--target ${{ matrix.target }}'

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'
```

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Yaak App</title>
<script src="http://localhost:8097"></script>
<!-- <script src="http://localhost:8097"></script>-->
<style>
body {
background-color: white;

9790
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,14 +33,15 @@
"@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",
"framer-motion": "^9.0.4",
"papaparse": "^5.4.1",
"parse-color": "^1.0.0",
"react": "^18.2.0",
"react-dnd": "^16.0.1",
@@ -53,8 +54,9 @@
},
"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",
"@types/parse-json": "^4.0.0",
"@types/react": "^18.0.31",
@@ -62,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",
@@ -69,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",
@@ -78,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"

3138
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", "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.178.0"
deno_ast = { version = "0.25.0", features = ["transpiling"] }
sqlx = { version = "0.6.2", features = ["sqlite", "runtime-tokio-rustls", "json", "chrono", "time", "offline"] }
uuid = "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,5 @@
DELETE FROM main.http_responses;
ALTER TABLE http_responses DROP COLUMN body;
ALTER TABLE http_responses ADD COLUMN body BLOB;
ALTER TABLE http_responses ADD COLUMN body_path TEXT;
ALTER TABLE http_responses ADD COLUMN content_length INTEGER;

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

@@ -58,25 +58,117 @@
},
"query": "\n DELETE FROM http_responses\n WHERE id = ?\n "
},
"0fa36011553f7ca91113459a5cefd47f990f9b548a95e475ffd6e4b017059488": {
"26072725d536c3cfdffd9a681d17c0ee2f246ca98e0459630a2430236d3bbdd2": {
"describe": {
"columns": [],
"nullable": [],
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "model",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "workspace_id",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "request_id",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "updated_at",
"ordinal": 4,
"type_info": "Datetime"
},
{
"name": "created_at",
"ordinal": 5,
"type_info": "Datetime"
},
{
"name": "url",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "status",
"ordinal": 7,
"type_info": "Int64"
},
{
"name": "status_reason",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "content_length",
"ordinal": 9,
"type_info": "Int64"
},
{
"name": "body",
"ordinal": 10,
"type_info": "Blob"
},
{
"name": "body_path",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "elapsed",
"ordinal": 12,
"type_info": "Int64"
},
{
"name": "error",
"ordinal": 13,
"type_info": "Text"
},
{
"name": "headers!: sqlx::types::Json<Vec<HttpResponseHeader>>",
"ordinal": 14,
"type_info": "Text"
}
],
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false,
true,
true,
true,
true,
false,
true,
false
],
"parameters": {
"Right": 1
}
},
"query": "\n DELETE FROM http_responses\n WHERE request_id = ?\n "
"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 "
},
"318ed5a1126fe00719393cf4e6c788ee5a265af88b7253f61a475f78c6774ef6": {
"3ec4710d28a7f38608c96798d971217ac97788bcb639089d0c5750c0d339bc9a": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 9
"Right": 3
}
},
"query": "\n INSERT INTO http_responses (\n id,\n request_id,\n workspace_id,\n elapsed,\n url,\n status,\n status_reason,\n body,\n headers\n )\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);\n "
"query": "\n UPDATE environments\n SET (name, variables, updated_at) = (?, ?, CURRENT_TIMESTAMP)\n WHERE id = ?;\n "
},
"448a1d1f1866ab42c0f81fcf8eb2930bf21dfdd43ca4831bc1a198cf45ac3732": {
"describe": {
@@ -88,6 +180,172 @@
},
"query": "\n DELETE FROM http_requests\n WHERE id = ?\n "
},
"5aa070e61995f8b1724efaa94c5f0cef5a4be6efda5d70354ad449d7d4b5aee4": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "model",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "workspace_id",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "request_id",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "updated_at",
"ordinal": 4,
"type_info": "Datetime"
},
{
"name": "created_at",
"ordinal": 5,
"type_info": "Datetime"
},
{
"name": "url",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "status",
"ordinal": 7,
"type_info": "Int64"
},
{
"name": "status_reason",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "content_length",
"ordinal": 9,
"type_info": "Int64"
},
{
"name": "body",
"ordinal": 10,
"type_info": "Blob"
},
{
"name": "body_path",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "elapsed",
"ordinal": 12,
"type_info": "Int64"
},
{
"name": "error",
"ordinal": 13,
"type_info": "Text"
},
{
"name": "headers!: sqlx::types::Json<Vec<HttpResponseHeader>>",
"ordinal": 14,
"type_info": "Text"
}
],
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false,
true,
true,
true,
true,
false,
true,
false
],
"parameters": {
"Right": 1
}
},
"query": "\n SELECT id, model, workspace_id, request_id, updated_at, created_at, url,\n status, status_reason, content_length, body, body_path, elapsed, error,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpResponseHeader>>\"\n FROM http_responses\n WHERE request_id = ?\n ORDER BY created_at DESC\n "
},
"62475fd9483fb5eda01c937949da2ef66ac7005b4be06b87aa6210d462348aca": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 10
}
},
"query": "\n UPDATE http_responses SET (\n elapsed,\n url,\n status,\n status_reason,\n content_length,\n body,\n body_path,\n error,\n headers,\n updated_at\n ) = (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) WHERE id = ?;\n "
},
"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": [
@@ -194,15 +452,35 @@
},
"query": "\n DELETE FROM workspaces\n WHERE id = ?\n "
},
"a83698dcf9a815b881097133edb31a34ba25e7c6c114d463c495342a85371639": {
"86e32d6a6fadf35436f19b577a659c203a8d143cb3a8d6122951c5bf54a0888d": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 8
"Right": 4
}
},
"query": "\n UPDATE http_responses SET (elapsed, url, status, status_reason, body, error, headers, updated_at) =\n (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) WHERE id = ?;\n "
"query": "\n INSERT INTO environments (id, workspace_id, name, variables)\n VALUES (?, ?, ?, ?)\n "
},
"8947a2a90478277c42fe9b06bc1fa98197642a4d281a3dbc101be2c9c1fec36c": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 11
}
},
"query": "\n INSERT INTO http_responses (\n id,\n request_id,\n workspace_id,\n elapsed,\n url,\n status,\n status_reason,\n content_length,\n body,\n body_path,\n headers\n )\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);\n "
},
"aeb0712785a9964d516dc8939bc54aa8206ad852e608b362d014b67a0f21b0ed": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 1
}
},
"query": "\n DELETE FROM environments\n WHERE id = ?\n "
},
"b19c275180909a39342b13c3cdcf993781636913ae590967f5508c46a56dc961": {
"describe": {
@@ -214,6 +492,162 @@
},
"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": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "model",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "workspace_id",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "request_id",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "updated_at",
"ordinal": 4,
"type_info": "Datetime"
},
{
"name": "created_at",
"ordinal": 5,
"type_info": "Datetime"
},
{
"name": "url",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "status",
"ordinal": 7,
"type_info": "Int64"
},
{
"name": "status_reason",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "content_length",
"ordinal": 9,
"type_info": "Int64"
},
{
"name": "body",
"ordinal": 10,
"type_info": "Blob"
},
{
"name": "body_path",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "elapsed",
"ordinal": 12,
"type_info": "Int64"
},
{
"name": "error",
"ordinal": 13,
"type_info": "Text"
},
{
"name": "headers!: sqlx::types::Json<Vec<HttpResponseHeader>>",
"ordinal": 14,
"type_info": "Text"
}
],
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false,
true,
true,
true,
true,
false,
true,
false
],
"parameters": {
"Right": 1
}
},
"query": "\n SELECT id, model, workspace_id, request_id, updated_at, created_at, url,\n status, status_reason, content_length, body, body_path, elapsed, error,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpResponseHeader>>\"\n FROM http_responses\n WHERE id = ?\n "
},
"caf3f21bf291dfbd36446592066e96c1f83abe96f6ea9211a3e049eb9c58a8c8": {
"describe": {
"columns": [
@@ -406,96 +840,6 @@
},
"query": "\n SELECT\n id,\n model,\n workspace_id,\n created_at,\n updated_at,\n name,\n url,\n method,\n body,\n body_type,\n authentication AS \"authentication!: Json<HashMap<String, JsonValue>>\",\n authentication_type,\n sort_priority,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpRequestHeader>>\"\n FROM http_requests\n WHERE id = ?\n "
},
"d5ad6d5f82fe837fa9215bd4619ec18a7c95b3088d4fbf9825f2d1d28069d1ce": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "model",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "workspace_id",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "request_id",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "updated_at",
"ordinal": 4,
"type_info": "Datetime"
},
{
"name": "created_at",
"ordinal": 5,
"type_info": "Datetime"
},
{
"name": "status",
"ordinal": 6,
"type_info": "Int64"
},
{
"name": "status_reason",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "body",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "elapsed",
"ordinal": 9,
"type_info": "Int64"
},
{
"name": "url",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "error",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "headers!: sqlx::types::Json<Vec<HttpResponseHeader>>",
"ordinal": 12,
"type_info": "Text"
}
],
"nullable": [
false,
false,
false,
false,
false,
false,
false,
true,
false,
false,
false,
true,
false
],
"parameters": {
"Right": 1
}
},
"query": "\n SELECT id, model, workspace_id, request_id, updated_at,\n created_at, status, status_reason, body, elapsed, url, error,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpResponseHeader>>\"\n FROM http_responses\n WHERE request_id = ?\n ORDER BY created_at ASC\n "
},
"d80c09497771e3641022e73ec6c6a87e73a551f88a948a5445d754922b82b50b": {
"describe": {
"columns": [],
@@ -506,95 +850,15 @@
},
"query": "\n INSERT INTO key_values (namespace, key, value)\n VALUES (?, ?, ?) ON CONFLICT DO UPDATE SET\n updated_at = CURRENT_TIMESTAMP,\n value = excluded.value\n "
},
"e3ade0a69348d512e47e964bded9d7d890b92fdc1e01c6c22fa5e91f943639f2": {
"e0f41023d877d94b7609ce910a71bd89c4827a558654b8ae14d85e6ba86990cf": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "model",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "workspace_id",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "request_id",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "updated_at",
"ordinal": 4,
"type_info": "Datetime"
},
{
"name": "created_at",
"ordinal": 5,
"type_info": "Datetime"
},
{
"name": "status",
"ordinal": 6,
"type_info": "Int64"
},
{
"name": "status_reason",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "body",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "elapsed",
"ordinal": 9,
"type_info": "Int64"
},
{
"name": "url",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "error",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "headers!: sqlx::types::Json<Vec<HttpResponseHeader>>",
"ordinal": 12,
"type_info": "Text"
}
],
"nullable": [
false,
false,
false,
false,
false,
false,
false,
true,
false,
false,
false,
true,
false
],
"columns": [],
"nullable": [],
"parameters": {
"Right": 1
"Right": 2
}
},
"query": "\n SELECT id, model, workspace_id, request_id, updated_at, created_at,\n status, status_reason, body, elapsed, url, error,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpResponseHeader>>\"\n FROM http_responses\n WHERE id = ?\n "
"query": "\n UPDATE workspaces SET (name, updated_at) =\n (?, CURRENT_TIMESTAMP) WHERE id = ?;\n "
},
"f116d8cf9aad828135bb8c3a4c8b8e6b857ae13303989e9133a33b2d1cf20e96": {
"describe": {

View File

@@ -7,29 +7,32 @@
#[macro_use]
extern crate objc;
use std::collections::HashMap;
use std::env::current_dir;
use std::fs::create_dir_all;
use base64::Engine;
use http::header::{HeaderName, ACCEPT, USER_AGENT};
use http::{HeaderMap, HeaderValue, Method};
use rand::random;
use reqwest::redirect::Policy;
use serde::Serialize;
use sqlx::migrate::Migrator;
use sqlx::sqlite::SqlitePoolOptions;
use sqlx::types::Json;
use sqlx::{Pool, Sqlite};
use tauri::regex::Regex;
use tauri::{AppHandle, Menu, MenuItem, RunEvent, State, Submenu, TitleBarStyle, Window, Wry};
use tauri::{CustomMenuItem, Manager, SystemTray, SystemTrayEvent, SystemTrayMenu, WindowEvent};
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 window_ext::TrafficLightWindowExt;
mod models;
mod runtime;
mod render;
mod window_ext;
mod plugin;
#[derive(serde::Serialize)]
pub struct CustomResponse {
@@ -62,48 +65,37 @@ 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 mut variables = HashMap::new();
variables.insert("PROJECT_ID", "project_123");
variables.insert("TOKEN", "s3cret");
variables.insert("DOMAIN", "schier.co");
variables.insert("BASE_URL", "https://schier.co");
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)
.build()
.expect("Failed to build client");
@@ -115,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(),
);
}
}
@@ -175,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(),
};
@@ -188,13 +188,6 @@ async fn actually_send_ephemeral_request(
let raw_response = client.execute(sendable_req).await;
let p = app_handle
.path_resolver()
.resolve_resource("plugins/plugin.ts")
.expect("failed to resolve resource");
runtime::run_plugin_sync(p.to_str().unwrap()).unwrap();
match raw_response {
Ok(v) => {
let mut response = response.clone();
@@ -210,9 +203,41 @@ async fn actually_send_ephemeral_request(
.collect(),
);
response.url = v.url().to_string();
response.body = v.text().await.expect("Failed to get body");
let body_bytes = v.bytes().await.expect("Failed to get body").to_vec();
response.content_length = Some(body_bytes.len() as i64);
{
// Write body to FS
let dir = app_handle.path_resolver().app_data_dir().unwrap();
let base_dir = dir.join("responses");
create_dir_all(base_dir.clone()).expect("Failed to create responses dir");
let body_path = match response.id == "" {
false => base_dir.join(response.id.clone()),
true => base_dir.join(uuid::Uuid::new_v4().to_string()),
};
let mut f = File::options()
.create(true)
.truncate(true)
.write(true)
.open(&body_path)
.expect("Failed to open file");
f.write_all(body_bytes.as_slice())
.expect("Failed to write to file");
response.body_path = Some(
body_path
.to_str()
.expect("Failed to get body path")
.to_string(),
);
}
// Also store body directly on the model, if small enough
if body_bytes.len() < 100_000 {
response.body = Some(body_bytes);
}
response.elapsed = start.elapsed().as_millis() as i64;
response = models::update_response_if_id(response, pool)
response = models::update_response_if_id(&response, pool)
.await
.expect("Failed to update response");
if request.id != "" {
@@ -229,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;
@@ -236,15 +262,17 @@ async fn send_request(
.await
.expect("Failed to get request");
let response = models::create_response(&req.id, 0, "", 0, None, "", vec![], pool)
let response = models::create_response(&req.id, 0, "", 0, None, None, None, None, vec![], pool)
.await
.expect("Failed to create response");
let response2 = response.clone();
let 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");
});
@@ -260,7 +288,7 @@ async fn response_err(
) -> Result<models::HttpResponse, String> {
let mut response = response.clone();
response.error = Some(error.clone());
response = models::update_response_if_id(response, pool)
response = models::update_response_if_id(&response, pool)
.await
.expect("Failed to update response");
emit_side_effect(app_handle, "updated_model", &response);
@@ -310,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,
@@ -353,6 +397,41 @@ async fn duplicate_request(
emit_and_return(&window, "updated_model", request)
}
#[tauri::command]
async fn update_workspace(
workspace: models::Workspace,
window: Window<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<models::Workspace, String> {
let pool = &*db_instance.lock().await;
let updated_workspace = models::update_workspace(workspace, pool)
.await
.expect("Failed to update request");
emit_and_return(&window, "updated_model", updated_workspace)
}
#[tauri::command]
async fn update_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,
@@ -406,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> {
@@ -416,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,
@@ -428,7 +533,29 @@ async fn get_request(
}
#[tauri::command]
async fn responses(
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,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<models::Workspace, String> {
let pool = &*db_instance.lock().await;
models::get_workspace(id, pool)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn list_responses(
request_id: &str,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<Vec<models::HttpResponse>, String> {
@@ -463,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;
@@ -481,31 +608,28 @@ async fn workspaces(
}
}
#[tauri::command]
async fn new_window(window: Window<Wry>, url: &str) -> Result<(), String> {
create_window(&window.app_handle(), Some(url));
Ok(())
}
#[tauri::command]
async fn delete_workspace(
window: Window<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
id: &str,
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)
}
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
}
fn main() {
let quit = CustomMenuItem::new("quit".to_string(), "Quit");
let tray_menu = SystemTrayMenu::new().add_item(quit);
let system_tray = SystemTray::new().with_menu(tray_menu);
tauri::Builder::default()
.system_tray(system_tray)
.plugin(tauri_plugin_window_state::Builder::default().build())
.setup(|app| {
let dir = match is_dev() {
true => current_dir().unwrap(),
@@ -533,44 +657,40 @@ fn main() {
Ok(())
})
})
.on_system_tray_event(|app, event| {
if let SystemTrayEvent::MenuItemClick { id, .. } = event {
match id.as_str() {
"quit" => {
std::process::exit(0);
}
"hide" => {
let window = app.get_window("main").unwrap();
window.hide().unwrap();
}
_ => {}
};
}
})
.invoke_handler(tauri::generate_handler![
greet,
workspaces,
get_request,
requests,
send_request,
send_ephemeral_request,
duplicate_request,
create_environment,
create_request,
create_workspace,
delete_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);
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, .. } => {
@@ -584,7 +704,7 @@ fn is_dev() -> bool {
env.unwrap_or("production") != "production"
}
fn create_window(handle: &AppHandle<Wry>) -> Window<Wry> {
fn create_window(handle: &AppHandle<Wry>, url: Option<&str>) -> Window<Wry> {
let default_menu = Menu::os_default("Yaak".to_string().as_str());
let mut test_menu = Menu::new()
.add_item(
@@ -608,6 +728,22 @@ fn create_window(handle: &AppHandle<Wry>) -> Window<Wry> {
.add_item(
CustomMenuItem::new("focus_url".to_string(), "Focus URL").accelerator("CmdOrCtrl+l"),
)
.add_item(
CustomMenuItem::new("new_request".to_string(), "New Request")
.accelerator("CmdOrCtrl+n"),
)
.add_item(
CustomMenuItem::new("toggle_settings".to_string(), "Toggle Settings")
.accelerator("CmdOrCtrl+,"),
)
.add_item(
CustomMenuItem::new("duplicate_request".to_string(), "Duplicate Request")
.accelerator("CmdOrCtrl+d"),
)
.add_item(
CustomMenuItem::new("focus_sidebar".to_string(), "Focus Sidebar")
.accelerator("CmdOrCtrl+1"),
)
.add_item(CustomMenuItem::new("new_window".to_string(), "New Window"));
if is_dev() {
test_menu = test_menu
@@ -627,19 +763,34 @@ fn create_window(handle: &AppHandle<Wry>) -> Window<Wry> {
let window_num = handle.windows().len();
let window_id = format!("wnd_{}", window_num);
let menu = default_menu.add_submenu(submenu);
let win = tauri::WindowBuilder::new(handle, window_id, tauri::WindowUrl::App("".into()))
.menu(menu)
.fullscreen(false)
.resizable(true)
.inner_size(1100.0, 600.0)
.hidden_title(true)
.title(match is_dev() {
true => "Yaak Dev",
false => "Yaak",
})
.title_bar_style(TitleBarStyle::Overlay)
.build()
.expect("failed to build window");
let mut win_builder = tauri::WindowBuilder::new(
handle,
window_id,
WindowUrl::App(url.unwrap_or_default().into()),
)
.menu(menu)
.fullscreen(false)
.resizable(true)
.inner_size(1100.0, 600.0)
.position(
// Randomly offset so windows don't stack exactly
100.0 + random::<f64>() * 30.0,
100.0 + random::<f64>() * 30.0,
)
.title(match is_dev() {
true => "Yaak Dev",
false => "Yaak",
});
// Add macOS-only things
#[cfg(target_os = "macos")]
{
win_builder = win_builder
.hidden_title(true)
.title_bar_style(TitleBarStyle::Overlay);
}
let win = win_builder.build().expect("failed to build window");
let win2 = win.clone();
let handle2 = handle.clone();
@@ -651,9 +802,13 @@ fn create_window(handle: &AppHandle<Wry>) -> Window<Wry> {
"zoom_out" => win2.emit("zoom", -1).unwrap(),
"toggle_sidebar" => win2.emit("toggle_sidebar", true).unwrap(),
"focus_url" => win2.emit("focus_url", true).unwrap(),
"focus_sidebar" => win2.emit("focus_sidebar", true).unwrap(),
"send_request" => win2.emit("send_request", true).unwrap(),
"new_request" => _ = win2.emit("new_request", true).unwrap(),
"toggle_settings" => _ = win2.emit("toggle_settings", true).unwrap(),
"duplicate_request" => _ = win2.emit("duplicate_request", true).unwrap(),
"refresh" => win2.eval("location.reload()").unwrap(),
"new_window" => _ = create_window(&handle2),
"new_window" => _ = create_window(&handle2, None),
"toggle_devtools" => {
if win2.is_devtools_open() {
win2.close_devtools();
@@ -667,7 +822,6 @@ fn create_window(handle: &AppHandle<Wry>) -> Window<Wry> {
let win3 = win.clone();
win.on_window_event(move |e| {
let apply_offset = || {
#[cfg(target_os = "macos")]
win3.position_traffic_lights();
};
@@ -682,9 +836,7 @@ fn create_window(handle: &AppHandle<Wry>) -> Window<Wry> {
}
});
#[cfg(target_os = "macos")]
win.position_traffic_lights();
win
}

View File

@@ -1,4 +1,5 @@
use std::collections::HashMap;
use std::fs;
use rand::distributions::{Alphanumeric, DistString};
use serde::{Deserialize, Serialize};
@@ -17,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 {
@@ -30,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,
@@ -63,10 +85,12 @@ pub struct HttpResponse {
pub updated_at: NaiveDateTime,
pub error: Option<String>,
pub url: String,
pub content_length: Option<i64>,
pub elapsed: i64,
pub status: i64,
pub status_reason: Option<String>,
pub body: String,
pub body: Option<Vec<u8>>,
pub body_path: Option<String>,
pub headers: Json<Vec<HttpResponseHeader>>,
}
@@ -151,9 +175,7 @@ pub async fn get_workspace(id: &str, pool: &Pool<Sqlite>) -> Result<Workspace, s
}
pub async fn delete_workspace(id: &str, pool: &Pool<Sqlite>) -> Result<Workspace, sqlx::Error> {
let workspace = get_workspace(id, pool)
.await
.expect("Failed to get request to delete");
let workspace = get_workspace(id, pool).await?;
let _ = sqlx::query!(
r#"
DELETE FROM workspaces
@@ -163,6 +185,11 @@ pub async fn delete_workspace(id: &str, pool: &Pool<Sqlite>) -> Result<Workspace
)
.execute(pool)
.await;
for r in find_responses_by_workspace_id(id, pool).await? {
delete_response(&r.id, pool).await?;
}
Ok(workspace)
}
@@ -171,7 +198,7 @@ pub async fn create_workspace(
description: &str,
pool: &Pool<Sqlite>,
) -> Result<Workspace, sqlx::Error> {
let id = generate_id("wk");
let id = generate_id(Some("wk"));
sqlx::query!(
r#"
INSERT INTO workspaces (id, name, description)
@@ -182,16 +209,113 @@ pub async fn create_workspace(
description,
)
.execute(pool)
.await
.expect("Failed to insert new workspace");
.await?;
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
.expect("Failed to get request to duplicate");
let existing = get_request(id, pool).await?;
// TODO: Figure out how to make this better
let b2;
@@ -238,12 +362,13 @@ pub async fn upsert_request(
let id = match id {
Some(v) => v,
None => {
generated_id = generate_id("rq");
generated_id = generate_id(Some("rq"));
generated_id.as_str()
}
};
let headers_json = Json(headers);
let auth_json = Json(authentication);
let trimmed_name = name.trim();
sqlx::query!(
r#"
INSERT INTO http_requests (
@@ -274,7 +399,7 @@ pub async fn upsert_request(
"#,
id,
workspace_id,
name,
trimmed_name,
url,
method,
body,
@@ -285,8 +410,7 @@ pub async fn upsert_request(
sort_priority,
)
.execute(pool)
.await
.expect("Failed to insert new request");
.await?;
get_request(id, pool).await
}
@@ -350,9 +474,11 @@ 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
.expect("Failed to get request to delete");
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
@@ -372,14 +498,14 @@ pub async fn create_response(
url: &str,
status: i64,
status_reason: Option<&str>,
body: &str,
content_length: Option<i64>,
body: Option<Vec<u8>>,
body_path: Option<&str>,
headers: Vec<HttpResponseHeader>,
pool: &Pool<Sqlite>,
) -> Result<HttpResponse, sqlx::Error> {
let req = get_request(request_id, pool)
.await
.expect("Failed to get request");
let id = generate_id("rp");
let req = get_request(request_id, pool).await?;
let id = generate_id(Some("rp"));
let headers_json = Json(headers);
sqlx::query!(
r#"
@@ -391,10 +517,12 @@ pub async fn create_response(
url,
status,
status_reason,
content_length,
body,
body_path,
headers
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
"#,
id,
request_id,
@@ -403,57 +531,88 @@ pub async fn create_response(
url,
status,
status_reason,
content_length,
body,
body_path,
headers_json,
)
.execute(pool)
.await
.expect("Failed to insert new response");
.await?;
get_response(&id, pool).await
}
pub async fn update_response_if_id(
response: HttpResponse,
response: &HttpResponse,
pool: &Pool<Sqlite>,
) -> Result<HttpResponse, sqlx::Error> {
if response.id == "" {
return Ok(response);
return Ok(response.clone());
}
return update_response(response, pool).await;
}
pub async fn update_response(
response: HttpResponse,
pub async fn update_workspace(
workspace: Workspace,
pool: &Pool<Sqlite>,
) -> Result<HttpResponse, sqlx::Error> {
let headers_json = Json(response.headers);
) -> Result<Workspace, sqlx::Error> {
let trimmed_name = workspace.name.trim();
sqlx::query!(
r#"
UPDATE http_responses SET (elapsed, url, status, status_reason, body, error, headers, updated_at) =
(?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) WHERE id = ?;
UPDATE workspaces SET (name, updated_at) =
(?, CURRENT_TIMESTAMP) WHERE id = ?;
"#,
trimmed_name,
workspace.id,
)
.execute(pool)
.await?;
get_workspace(&workspace.id, pool).await
}
pub async fn update_response(
response: &HttpResponse,
pool: &Pool<Sqlite>,
) -> Result<HttpResponse, sqlx::Error> {
let headers_json = Json(&response.headers);
sqlx::query!(
r#"
UPDATE http_responses SET (
elapsed,
url,
status,
status_reason,
content_length,
body,
body_path,
error,
headers,
updated_at
) = (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) WHERE id = ?;
"#,
response.elapsed,
response.url,
response.status,
response.status_reason,
response.content_length,
response.body,
response.body_path,
response.error,
headers_json,
response.id,
)
.execute(pool)
.await
.expect("Failed to update response");
.await?;
get_response(&response.id, pool).await
}
pub async fn get_response(id: &str, pool: &Pool<Sqlite>) -> Result<HttpResponse, sqlx::Error> {
sqlx::query_as_unchecked!(
sqlx::query_as!(
HttpResponse,
r#"
SELECT id, model, workspace_id, request_id, updated_at, created_at,
status, status_reason, body, elapsed, url, error,
SELECT id, model, workspace_id, request_id, updated_at, created_at, url,
status, status_reason, content_length, body, body_path, elapsed, error,
headers AS "headers!: sqlx::types::Json<Vec<HttpResponseHeader>>"
FROM http_responses
WHERE id = ?
@@ -471,12 +630,12 @@ pub async fn find_responses(
sqlx::query_as!(
HttpResponse,
r#"
SELECT id, model, workspace_id, request_id, updated_at,
created_at, status, status_reason, body, elapsed, url, error,
SELECT id, model, workspace_id, request_id, updated_at, created_at, url,
status, status_reason, content_length, body, body_path, elapsed, error,
headers AS "headers!: sqlx::types::Json<Vec<HttpResponseHeader>>"
FROM http_responses
WHERE request_id = ?
ORDER BY created_at ASC
ORDER BY created_at DESC
"#,
request_id,
)
@@ -484,10 +643,35 @@ pub async fn find_responses(
.await
}
pub async fn find_responses_by_workspace_id(
workspace_id: &str,
pool: &Pool<Sqlite>,
) -> Result<Vec<HttpResponse>, sqlx::Error> {
sqlx::query_as!(
HttpResponse,
r#"
SELECT id, model, workspace_id, request_id, updated_at, created_at, url,
status, status_reason, content_length, body, body_path, elapsed, error,
headers AS "headers!: sqlx::types::Json<Vec<HttpResponseHeader>>"
FROM http_responses
WHERE workspace_id = ?
ORDER BY created_at DESC
"#,
workspace_id,
)
.fetch_all(pool)
.await
}
pub async fn delete_response(id: &str, pool: &Pool<Sqlite>) -> Result<HttpResponse, sqlx::Error> {
let resp = get_response(id, pool)
.await
.expect("Failed to get response to delete");
let resp = get_response(id, pool).await?;
// Delete the body file if it exists
if let Some(p) = resp.body_path.clone() {
if let Err(e) = fs::remove_file(p) {
println!("Failed to delete body file: {}", e);
};
}
let _ = sqlx::query!(
r#"
@@ -506,22 +690,16 @@ pub async fn delete_all_responses(
request_id: &str,
pool: &Pool<Sqlite>,
) -> Result<(), sqlx::Error> {
let _ = sqlx::query!(
r#"
DELETE FROM http_responses
WHERE request_id = ?
"#,
request_id,
)
.execute(pool)
.await;
for r in find_responses(request_id, pool).await? {
delete_response(&r.id, pool).await?;
}
Ok(())
}
pub fn generate_id(prefix: &str) -> String {
format!(
"{prefix}_{}",
Alphanumeric.sample_string(&mut rand::thread_rng(), 10)
)
pub fn generate_id(prefix: Option<&str>) -> String {
let id = Alphanumeric.sample_string(&mut rand::thread_rng(), 10);
return match prefix {
None => id,
Some(p) => format!("{p}_{id}"),
};
}

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

@@ -1,13 +1,18 @@
use tauri::{Runtime, Window};
const TRAFFIC_LIGHT_OFFSET_X: f64 = 10.0;
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
}
#[cfg(target_os = "macos")]
fn position_traffic_lights(&self) {
use cocoa::appkit::{NSView, NSWindow, NSWindowButton};

View File

@@ -8,15 +8,26 @@
},
"package": {
"productName": "Yaak",
"version": "2023.0.10"
"version": "2023.1.7"
},
"tauri": {
"windows": [],
"allowlist": {
"all": false,
"os": {
"all": true
},
"protocol": {
"assetScope": [
"$APPDATA/responses/*"
],
"asset": true
},
"fs": {
"readFile": true,
"scope": [
"$RESOURCE/*"
"$RESOURCE/*",
"$APPDATA/responses/*"
]
},
"shell": {
@@ -40,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": []
},

View File

@@ -1,54 +1,32 @@
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { persistQueryClient } from '@tanstack/react-query-persist-client';
import { MotionConfig } from 'framer-motion';
import { Suspense } from 'react';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { HelmetProvider } from 'react-helmet-async';
import { AppRouter } from './AppRouter';
import { DialogProvider } from './DialogContext';
import { TauriListeners } from './TauriListeners';
const queryClient = new QueryClient({
logger: undefined,
defaultOptions: {
queries: {
retry: false,
cacheTime: 1000 * 60 * 60 * 24, // 24 hours
refetchOnWindowFocus: true,
networkMode: 'offlineFirst',
// It's a desktop app, so this isn't necessary
refetchOnWindowFocus: false,
},
},
});
const localStoragePersister = createSyncStoragePersister({
storage: window.localStorage,
throttleTime: 1000, // 1 second
});
persistQueryClient({
queryClient,
persister: localStoragePersister,
maxAge: 1000 * 60 * 60 * 24, // 24 hours
});
export function App() {
return (
<QueryClientProvider client={queryClient}>
<MotionConfig transition={{ duration: 0.1 }}>
<HelmetProvider>
<DndProvider backend={HTML5Backend}>
<DialogProvider>
<Suspense>
<AppRouter />
<TauriListeners />
<ReactQueryDevtools initialIsOpen={false} />
{/*<ReactQueryDevtools initialIsOpen={false} />*/}
</Suspense>
</DialogProvider>
</DndProvider>
</HelmetProvider>
</MotionConfig>

View File

@@ -1,22 +1,19 @@
import { useEffect } from 'react';
import {
createBrowserRouter,
Navigate,
Outlet,
RouterProvider,
useLocation,
} from 'react-router-dom';
import { routePaths } from '../hooks/useRoutes';
import { setLastLocation } from '../lib/lastLocation';
import RouteError from './RouteError';
import { createBrowserRouter, Navigate, Outlet, RouterProvider } from 'react-router-dom';
import { routePaths, useAppRoutes } from '../hooks/useAppRoutes';
import { useRecentRequests } from '../hooks/useRecentRequests';
import { useRequests } from '../hooks/useRequests';
import { GlobalHooks } from './GlobalHooks';
import Workspace from './Workspace';
import Workspaces from './Workspaces';
import { DialogProvider } from './DialogContext';
import { useActiveEnvironmentId } from '../hooks/useActiveEnvironmentId';
import RouteError from './RouteError';
const router = createBrowserRouter([
{
path: '/',
errorElement: <RouteError />,
element: <RouterRoot />,
element: <Layout />,
children: [
{
path: '/',
@@ -27,12 +24,16 @@ const router = createBrowserRouter([
element: <Workspaces />,
},
{
path: routePaths.workspace({ workspaceId: ':workspaceId' }),
element: <Workspace />,
path: routePaths.workspace({
workspaceId: ':workspaceId',
environmentId: ':environmentId',
}),
element: <WorkspaceOrRedirect />,
},
{
path: routePaths.request({
workspaceId: ':workspaceId',
environmentId: ':environmentId',
requestId: ':requestId',
}),
element: <Workspace />,
@@ -45,10 +46,36 @@ export function AppRouter() {
return <RouterProvider router={router} />;
}
function RouterRoot() {
const { pathname } = useLocation();
useEffect(() => {
setLastLocation(pathname).catch(console.error);
}, [pathname]);
return <Outlet />;
function WorkspaceOrRedirect() {
const recentRequests = useRecentRequests();
const activeEnvironmentId = useActiveEnvironmentId();
const requests = useRequests();
const request = requests.find((r) => r.id === recentRequests[0]);
const routes = useAppRoutes();
if (request === undefined) {
return <Workspace />;
}
const { id: requestId, workspaceId } = request;
const environmentId = activeEnvironmentId ?? undefined;
return (
<Navigate
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

@@ -45,13 +45,21 @@ export const DialogProvider = ({ children }: { children: React.ReactNode }) => {
return (
<DialogContext.Provider value={state}>
{children}
{dialogs.map(({ id, render, ...props }) => (
<Dialog open key={id} onClose={() => actions.hide(id)} {...props}>
{render({ hide: () => actions.hide(id) })}
</Dialog>
{dialogs.map((props: DialogEntry) => (
<DialogInstance key={props.id} {...props} />
))}
</DialogContext.Provider>
);
};
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}>
{children}
</Dialog>
);
}
export const useDialog = () => useContext(DialogContext).actions;

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();
export function TauriListeners() {
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 =
@@ -30,18 +48,17 @@ export function TauriListeners() {
: null;
if (queryKey === null) {
if (payload.model) {
console.log('Unrecognized created model:', payload);
}
console.log('Unrecognized created model:', payload);
return;
}
if (!shouldIgnoreModel(payload)) {
queryClient.setQueryData<Model[]>(queryKey, (values) => [...(values ?? []), payload]);
// Order newest first
queryClient.setQueryData<Model[]>(queryKey, (values) => [payload, ...(values ?? [])]);
}
});
useTauriEvent<Model>('updated_model', ({ payload, windowLabel }) => {
useListenToTauriEvent<Model>('updated_model', ({ payload, windowLabel }) => {
if (shouldIgnoreEvent(payload, windowLabel)) return;
const queryKey =
@@ -56,9 +73,7 @@ export function TauriListeners() {
: null;
if (queryKey === null) {
if (payload.model) {
console.log('Unrecognized updated model:', payload);
}
console.log('Unrecognized updated model:', payload);
return;
}
@@ -67,13 +82,14 @@ export function TauriListeners() {
}
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;
@@ -88,7 +104,7 @@ export function TauriListeners() {
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);
@@ -115,7 +131,8 @@ const shouldIgnoreEvent = (payload: Model, windowLabel: string) =>
windowLabel === appWindow.label && payload.model !== 'http_response';
const shouldIgnoreModel = (payload: Model) => {
if (payload.model === 'http_response') return false;
if (payload.model === 'key_value' && payload.namespace === NAMESPACE_NO_SYNC) return false;
return true;
if (payload.model === 'key_value') {
return payload.namespace === NAMESPACE_NO_SYNC;
}
return false;
};

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,13 +65,13 @@ 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();
return (
<div className="pb-2 h-full grid grid-rows-[minmax(0,100%)_auto_auto_minmax(0,auto)]">
<div className="pb-2 h-full w-full grid grid-cols-1 grid-rows-[minmax(0,100%)_auto_auto_minmax(0,auto)]">
<Editor
contentType="application/graphql"
defaultValue={query ?? ''}
@@ -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,7 +1,7 @@
import classnames from 'classnames';
import classNames from 'classnames';
import FocusTrap from 'focus-trap-react';
import type { ReactNode } from 'react';
import { motion } from 'framer-motion';
import type { ReactNode } from 'react';
import { Portal } from './Portal';
interface Props {
@@ -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/60 dark:bg-black/50"
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

@@ -0,0 +1,98 @@
import { useMemo, useRef } from 'react';
import { useKey, useKeyPressEvent } from 'react-use';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useActiveWorkspaceId } from '../hooks/useActiveWorkspaceId';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { useRecentRequests } from '../hooks/useRecentRequests';
import { useRequests } from '../hooks/useRequests';
import { Button } from './core/Button';
import { CountBadge } from './core/CountBadge';
import type { DropdownItem, DropdownRef } from './core/Dropdown';
import { Dropdown } from './core/Dropdown';
import classNames from 'classnames';
import { useActiveEnvironmentId } from '../hooks/useActiveEnvironmentId';
export function RecentRequestsDropdown() {
const dropdownRef = useRef<DropdownRef>(null);
const activeRequest = useActiveRequest();
const activeWorkspaceId = useActiveWorkspaceId();
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, () => {
dropdownRef.current?.select?.();
});
useKey(
'Tab',
(e) => {
if (!e.ctrlKey || recentRequestIds.length === 0) return;
if (!dropdownRef.current?.isOpen) {
dropdownRef.current?.open(e.shiftKey ? -1 : 0);
return;
}
if (e.shiftKey) dropdownRef.current?.prev?.();
else dropdownRef.current?.next?.();
},
undefined,
[recentRequestIds.length],
);
const items = useMemo<DropdownItem[]>(() => {
if (activeWorkspaceId === null) return [];
const recentRequestItems: DropdownItem[] = [];
for (const id of recentRequestIds) {
const request = requests.find((r) => r.id === id);
if (request === undefined) continue;
recentRequestItems.push({
key: request.id,
label: request.name,
leftSlot: <CountBadge className="!ml-0 px-0 w-5" count={recentRequestItems.length + 1} />,
onSelect: () => {
routes.navigate('request', {
requestId: request.id,
environmentId: activeEnvironmentId ?? undefined,
workspaceId: activeWorkspaceId,
});
},
});
}
// No recent requests to show
if (recentRequestItems.length === 0) {
return [];
}
return recentRequestItems.slice(0, 20);
}, [activeWorkspaceId, activeEnvironmentId, recentRequestIds, requests, routes]);
return (
<Dropdown ref={dropdownRef} items={items}>
<Button
size="sm"
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>
</Dropdown>
);
}

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

@@ -1,11 +1,13 @@
import type { HTMLAttributes, ReactElement } from 'react';
import { useConfirm } from '../hooks/useConfirm';
import React, { useRef } from 'react';
import { useDeleteRequest } from '../hooks/useDeleteRequest';
import { useDuplicateRequest } from '../hooks/useDuplicateRequest';
import { useRequest } from '../hooks/useRequest';
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 { InlineCode } from './core/InlineCode';
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
interface Props {
requestId: string;
@@ -13,37 +15,45 @@ interface Props {
}
export function RequestActionsDropdown({ requestId, children }: Props) {
const request = useRequest(requestId ?? null);
const deleteRequest = useDeleteRequest(requestId ?? null);
const deleteRequest = useDeleteRequest(requestId);
const duplicateRequest = useDuplicateRequest({ id: requestId, navigateAfter: true });
const confirm = useConfirm();
const dropdownRef = useRef<DropdownRef>(null);
const { appearance, toggleAppearance } = useTheme();
useListenToTauriEvent('toggle_settings', () => {
dropdownRef.current?.toggle();
});
// TODO: Put this somewhere better
useListenToTauriEvent('duplicate_request', () => {
duplicateRequest.mutate();
});
return (
<Dropdown
ref={dropdownRef}
items={[
{
key: 'duplicate',
label: 'Duplicate',
onSelect: duplicateRequest.mutate,
leftSlot: <Icon icon="copy" />,
rightSlot: <HotKey modifier="Meta" keyName="D" />,
},
{
key: 'delete',
label: 'Delete',
onSelect: async () => {
const confirmed = await confirm({
title: 'Delete Request',
variant: 'delete',
description: (
<>
Are you sure you want to delete <InlineCode>{request?.name}</InlineCode>?
</>
),
});
if (confirmed) {
deleteRequest.mutate();
}
},
onSelect: deleteRequest.mutate,
variant: 'danger',
leftSlot: <Icon icon="trash" />,
},
{ type: 'separator', label: 'Yaak Settings' },
{
key: 'appearance',
label: appearance === 'dark' ? 'Light Theme' : 'Dark Theme',
onSelect: toggleAppearance,
leftSlot: <Icon icon={appearance === 'dark' ? 'sun' : 'moon'} />,
},
]}
>
{children}

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 && (
<>
@@ -162,7 +164,7 @@ export const RequestPane = memo(function RequestPane({ style, fullHeight, classN
label="Request"
onChangeValue={setActiveTab}
tabs={tabs}
tabListClassName="mt-2"
tabListClassName="mt-2 !mb-1.5"
>
<TabContent value="auth">
{activeRequest.authenticationType === AUTH_TYPE_BASIC ? (
@@ -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,8 +1,9 @@
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 { useKeyValue } from '../hooks/useKeyValue';
import { useLocalStorage } from 'react-use';
import { useActiveWorkspaceId } from '../hooks/useActiveWorkspaceId';
import { clamp } from '../lib/clamp';
import { RequestPane } from './RequestPane';
import { ResizeHandle } from './ResizeHandle';
@@ -24,10 +25,10 @@ const STACK_VERTICAL_WIDTH = 650;
export const RequestResponse = memo(function RequestResponse({ style }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const [vertical, setVertical] = useState<boolean>(false);
const widthKv = useKeyValue<number>({ key: 'body_width', defaultValue: DEFAULT });
const heightKv = useKeyValue<number>({ key: 'body_height', defaultValue: DEFAULT });
const width = widthKv.value ?? DEFAULT;
const height = heightKv.value ?? DEFAULT;
const [widthRaw, setWidth] = useLocalStorage<number>(`body_width::${useActiveWorkspaceId()}`);
const [heightRaw, setHeight] = useLocalStorage<number>(`body_height::${useActiveWorkspaceId()}`);
const width = widthRaw ?? DEFAULT;
const height = heightRaw ?? DEFAULT;
const [isResizing, setIsResizing] = useState<boolean>(false);
const moveState = useRef<{ move: (e: MouseEvent) => void; up: (e: MouseEvent) => void } | null>(
null,
@@ -63,8 +64,8 @@ export const RequestResponse = memo(function RequestResponse({ style }: Props) {
};
const handleReset = useCallback(
() => (vertical ? heightKv.set(DEFAULT) : widthKv.set(DEFAULT)),
[heightKv, vertical, widthKv],
() => (vertical ? setHeight(DEFAULT) : setWidth(DEFAULT)),
[setHeight, vertical, setWidth],
);
const handleResizeStart = useCallback(
@@ -89,7 +90,7 @@ export const RequestResponse = memo(function RequestResponse({ style }: Props) {
MIN_HEIGHT_PX,
maxHeightPx,
);
heightKv.set(newHeightPx / containerRect.height);
setHeight(newHeightPx / containerRect.height);
} else {
const maxWidthPx = containerRect.width - MIN_WIDTH_PX;
const newWidthPx = clamp(
@@ -97,7 +98,7 @@ export const RequestResponse = memo(function RequestResponse({ style }: Props) {
MIN_WIDTH_PX,
maxWidthPx,
);
widthKv.set(newWidthPx / containerRect.width);
setWidth(newWidthPx / containerRect.width);
}
},
up: (e: MouseEvent) => {
@@ -110,7 +111,7 @@ export const RequestResponse = memo(function RequestResponse({ style }: Props) {
document.documentElement.addEventListener('mouseup', moveState.current.up);
setIsResizing(true);
},
[width, height, vertical, heightKv, widthKv],
[width, height, vertical, setHeight, setWidth],
);
return (
@@ -119,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,10 +28,10 @@ export function ResizeHandle({
aria-hidden
draggable
style={style}
className={classnames(
className={classNames(
className,
'group z-10 flex cursor-ew-resize',
vertical ? 'w-full h-3 cursor-ns-resize' : 'h-full w-3 cursor-ew-resize',
'group z-10 flex',
vertical ? 'w-full h-3 cursor-row-resize' : 'h-full w-3 cursor-col-resize',
justify === 'center' && 'justify-center',
justify === 'end' && 'justify-end',
justify === 'start' && 'justify-start',
@@ -45,10 +45,10 @@ export function ResizeHandle({
{/* Show global overlay with cursor style to ensure cursor remains the same when moving quickly */}
{isResizing && (
<div
className={classnames(
'fixed -left-20 -right-20 -top-20 -bottom-20 cursor-ew-resize',
vertical && 'cursor-ns-resize',
!vertical && 'cursor-ew-resize',
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';
@@ -8,13 +8,13 @@ interface Props {
export function ResponseHeaders({ headers }: Props) {
return (
<dl className="text-xs w-full font-mono">
<dl className="text-xs w-full h-full font-mono overflow-auto">
{headers.map((h, i) => {
return (
<HStack
space={3}
key={i}
className={classnames(i > 0 && 'border-t border-highlightSecondary', 'py-1')}
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,27 +1,29 @@
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 { tryFormatJson } from '../lib/formatters';
import type { HttpResponse } from '../lib/models';
import { pluralize } from '../lib/pluralize';
import { isResponseLoading } from '../lib/models';
import { Banner } from './core/Banner';
import { CountBadge } from './core/CountBadge';
import { Dropdown } from './core/Dropdown';
import { Editor } from './core/Editor';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { DurationTag } from './core/DurationTag';
import { SizeTag } from './core/SizeTag';
import { HStack } from './core/Stacks';
import { StatusColor } from './core/StatusColor';
import { StatusTag } from './core/StatusTag';
import type { TabItem } from './core/Tabs/Tabs';
import { TabContent, Tabs } from './core/Tabs/Tabs';
import { Webview } from './core/Webview';
import { EmptyStateText } from './EmptyStateText';
import { ResponseHeaders } from './ResponseHeaders';
import { CsvViewer } from './responseViewers/CsvViewer';
import { ImageViewer } from './responseViewers/ImageViewer';
import { TextViewer } from './responseViewers/TextViewer';
import { WebPageViewer } from './responseViewers/WebPageViewer';
import { RecentResponsesDropdown } from './RecentResponsesDropdown';
interface Props {
style?: CSSProperties;
@@ -33,28 +35,37 @@ const useActiveTab = createGlobalState<string>('body');
export const ResponsePane = memo(function ResponsePane({ style, className }: Props) {
const [pinnedResponseId, setPinnedResponseId] = useState<string | null>(null);
const activeRequestId = useActiveRequestId();
const latestResponse = useLatestResponse(activeRequestId);
const responses = useResponses(activeRequestId);
const activeResponse: HttpResponse | null = pinnedResponseId
? responses.find((r) => r.id === pinnedResponseId) ?? null
: responses[responses.length - 1] ?? null;
const [viewMode, toggleViewMode] = useResponseViewMode(activeResponse?.requestId);
const deleteResponse = useDeleteResponse(activeResponse?.id ?? null);
const deleteAllResponses = useDeleteResponses(activeResponse?.requestId);
: latestResponse ?? null;
const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId);
const [activeTab, setActiveTab] = useActiveTab();
// Unset pinned response when a new one comes in
useEffect(() => setPinnedResponseId(null), [responses.length]);
const contentType = useMemo(
() =>
activeResponse?.headers.find((h) => h.name.toLowerCase() === 'content-type')?.value ??
'text/plain',
[activeResponse],
);
const contentType = useResponseContentType(activeResponse);
const tabs = useMemo(
const handlePinnedResponse = useCallback((r: HttpResponse) => {
setPinnedResponseId(r.id);
}, [setPinnedResponseId])
const tabs: TabItem[] = useMemo(
() => [
{ label: 'Body', value: 'body' },
{
value: 'body',
label: 'Preview',
options: {
value: viewMode,
onChange: setViewMode,
items: [
{ label: 'Pretty', value: 'pretty' },
{ label: 'Raw', value: 'raw' },
],
},
},
{
label: (
<div className="flex items-center">
@@ -67,118 +78,85 @@ export const ResponsePane = memo(function ResponsePane({ style, className }: Pro
value: 'headers',
},
],
[activeResponse?.headers],
[activeResponse?.headers, setViewMode, viewMode],
);
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',
'shadow shadow-gray-100 dark:shadow-gray-0 relative',
)}
>
<HStack
alignItems="center"
className={classnames(
'italic text-gray-700 text-sm w-full flex-shrink-0',
// Remove a bit of space because the tabs have lots too
'-mb-1.5',
)}
>
{activeResponse && (
<>
<div className="whitespace-nowrap p-3 py-2">
<StatusColor statusCode={activeResponse.status}>
{activeResponse.status}
{activeResponse.statusReason && ` ${activeResponse.statusReason}`}
</StatusColor>
&nbsp;&bull;&nbsp;
{activeResponse.elapsed}ms &nbsp;&bull;&nbsp;
{Math.round(activeResponse.body.length / 1000)} KB
</div>
{activeResponse?.error && <Banner className="m-2">{activeResponse.error}</Banner>}
{activeResponse && !activeResponse.error && !isResponseLoading(activeResponse) && (
<>
<HStack
alignItems="center"
className={classNames(
'text-gray-700 text-sm w-full flex-shrink-0',
// Remove a bit of space because the tabs have lots too
'-mb-1.5',
)}
>
{activeResponse && (
<HStack alignItems="center" className="w-full">
<div className="whitespace-nowrap px-3">
<HStack space={2}>
<StatusTag showReason response={activeResponse} />
{activeResponse.elapsed > 0 && (
<>
<span>&bull;</span>
<DurationTag millis={activeResponse.elapsed} />
</>
)}
{!!activeResponse.contentLength && (
<>
<span>&bull;</span>
<SizeTag contentLength={activeResponse.contentLength} />
</>
)}
</HStack>
</div>
<Dropdown
items={[
{
label: viewMode === 'pretty' ? 'View Raw' : 'View Prettified',
onSelect: toggleViewMode,
},
{ type: 'separator', label: 'Actions' },
{
label: 'Clear Response',
onSelect: deleteResponse.mutate,
disabled: responses.length === 0,
},
{
label: `Clear ${responses.length} ${pluralize('Response', responses.length)}`,
onSelect: deleteAllResponses.mutate,
hidden: responses.length <= 1,
disabled: responses.length === 0,
},
{ type: 'separator', label: 'History' },
...responses.slice(0, 10).map((r) => ({
label: r.status + ' - ' + r.elapsed + ' ms',
leftSlot: activeResponse?.id === r.id ? <Icon icon="check" /> : <></>,
onSelect: () => setPinnedResponseId(r.id),
})),
]}
>
<IconButton
title="Show response history"
icon="triangleDown"
className="ml-auto"
size="sm"
iconSize="md"
/>
</Dropdown>
</>
)}
</HStack>
<RecentResponsesDropdown
responses={responses}
activeResponse={activeResponse}
onPinnedResponse={handlePinnedResponse}
/>
</HStack>
)}
</HStack>
{activeResponse?.error ? (
<Banner className="m-2">{activeResponse.error}</Banner>
) : (
<Tabs
value={activeTab}
onChangeValue={setActiveTab}
label="Response"
className="px-3"
tabs={tabs}
>
<TabContent value="body">
{activeResponse === null ? (
<EmptyStateText>No Response</EmptyStateText>
) : viewMode === 'pretty' && contentType.includes('html') ? (
<Webview
body={activeResponse.body}
contentType={contentType}
url={activeResponse.url}
/>
) : viewMode === 'pretty' && contentType.includes('json') ? (
<Editor
readOnly
forceUpdateKey={`pretty::${activeResponse.updatedAt}`}
className="bg-gray-50 dark:!bg-gray-100"
defaultValue={tryFormatJson(activeResponse?.body)}
contentType={contentType}
/>
) : activeResponse?.body ? (
<Editor
readOnly
forceUpdateKey={activeResponse.updatedAt}
className="bg-gray-50 dark:!bg-gray-100"
defaultValue={activeResponse?.body}
contentType={contentType}
/>
) : null}
</TabContent>
<TabContent value="headers">
<ResponseHeaders headers={activeResponse?.headers ?? []} />
</TabContent>
</Tabs>
<Tabs
value={activeTab}
onChangeValue={setActiveTab}
label="Response"
tabs={tabs}
className="ml-3 mr-1"
tabListClassName="mt-1.5"
>
<TabContent value="headers">
<ResponseHeaders headers={activeResponse?.headers ?? []} />
</TabContent>
<TabContent value="body">
{!activeResponse.contentLength ? (
<EmptyStateText>Empty Body</EmptyStateText>
) : viewMode === 'pretty' && contentType?.includes('html') ? (
<WebPageViewer response={activeResponse} />
) : contentType?.startsWith('image') ? (
<ImageViewer className="pb-2" response={activeResponse} />
) : contentType?.match(/csv|tab-separated/) ? (
<CsvViewer className="pb-2" response={activeResponse} />
) : (
<TextViewer response={activeResponse} pretty={viewMode === 'pretty'} />
)}
</TabContent>
</Tabs>
</>
)}
</div>
);

View File

@@ -1,23 +1,35 @@
import { useRouteError } from 'react-router-dom';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { Button } from './core/Button';
import { FormattedError } from './core/FormattedError';
import { Heading } from './core/Heading';
import { VStack } from './core/Stacks';
export default function RouteError() {
const error = useRouteError();
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>
<Button to="/" color="primary">
Go Home
</Button>
<FormattedError>{message}</FormattedError>
<VStack space={2}>
<Button
color="primary"
onClick={() => {
routes.navigate('workspaces');
}}
>
Go Home
</Button>
<Button color="secondary" onClick={() => window.location.reload()}>
Refresh
</Button>
</VStack>
</VStack>
</div>
);

View File

@@ -1,19 +1,26 @@
import classnames from 'classnames';
import type { ForwardedRef, KeyboardEvent } from 'react';
import classNames from 'classnames';
import type { ForwardedRef } from 'react';
import React, { forwardRef, Fragment, memo, useCallback, useMemo, useRef, useState } from 'react';
import type { XYCoord } from 'react-dnd';
import { useDrag, useDrop } from 'react-dnd';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useKey, useKeyPressEvent } from 'react-use';
import { useActiveRequestId } from '../hooks/useActiveRequestId';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { useDeleteAnyRequest } from '../hooks/useDeleteAnyRequest';
import { useLatestResponse } from '../hooks/useLatestResponse';
import { useRequests } from '../hooks/useRequests';
import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
import { useUpdateAnyRequest } from '../hooks/useUpdateAnyRequest';
import { useUpdateRequest } from '../hooks/useUpdateRequest';
import type { HttpRequest } from '../lib/models';
import { Button } from './core/Button';
import { IconButton } from './core/IconButton';
import { HStack, VStack } from './core/Stacks';
import { isResponseLoading } from '../lib/models';
import { Icon } from './core/Icon';
import { StatusTag } from './core/StatusTag';
import { DropMarker } from './DropMarker';
import { RequestActionsDropdown } from './RequestActionsDropdown';
import { ToggleThemeButton } from './ToggleThemeButton';
import { useActiveEnvironmentId } from '../hooks/useActiveEnvironmentId';
import { useCreateRequest } from '../hooks/useCreateRequest';
import { VStack } from './core/Stacks';
interface Props {
className?: string;
@@ -24,42 +31,170 @@ 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 activeRequest = useActiveRequest();
const deleteAnyRequest = useDeleteAnyRequest();
const routes = useAppRoutes();
const requests = useMemo(
() => [...unorderedRequests].sort((a, b) => a.sortPriority - b.sortPriority),
[unorderedRequests],
);
const [hasFocus, setHasFocus] = useState<boolean>(false);
const [selectedIndex, setSelectedIndex] = useState<number>();
// 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);
if (index < 0) return;
setSelectedIndex(index >= 0 ? index : undefined);
setHasFocus(true);
sidebarRef.current?.focus();
},
[activeRequestId, requests],
);
const handleSelect = useCallback(
(requestId: string) => {
const index = requests.findIndex((r) => r.id === requestId);
const request = requests[index];
if (!request) return;
routes.navigate('request', {
requestId,
workspaceId: request.workspaceId,
environmentId: activeEnvironmentId ?? undefined,
});
setSelectedIndex(index);
focusActiveRequest(index);
},
[focusActiveRequest, requests, routes, activeEnvironmentId],
);
const handleClearSelected = useCallback(() => {
setSelectedIndex(undefined);
}, [setSelectedIndex]);
const handleFocus = useCallback(() => {
if (hasFocus) return;
focusActiveRequest();
}, [focusActiveRequest, hasFocus]);
const handleBlur = useCallback(() => setHasFocus(false), []);
const handleDeleteKey = useCallback(
(e: KeyboardEvent) => {
if (!hasFocus) return;
e.preventDefault();
const selectedRequest = requests[selectedIndex ?? -1];
if (selectedRequest === undefined) return;
deleteAnyRequest.mutate(selectedRequest.id);
},
[deleteAnyRequest, hasFocus, requests, selectedIndex],
);
useKeyPressEvent('Backspace', handleDeleteKey);
useKeyPressEvent('Delete', handleDeleteKey);
useListenToTauriEvent(
'focus_sidebar',
() => {
if (hidden || hasFocus) return;
// Select 0 index on focus if none selected
focusActiveRequest(selectedIndex ?? 0);
},
[focusActiveRequest, hidden, activeRequestId],
);
useKeyPressEvent('Enter', (e) => {
if (!hasFocus) return;
const request = requests[selectedIndex ?? -1];
if (!request || request.id === activeRequestId) return;
e.preventDefault();
routes.navigate('request', {
requestId: request.id,
workspaceId: request.workspaceId,
environmentId: activeEnvironmentId ?? undefined,
});
});
useKey(
'ArrowUp',
() => {
if (!hasFocus) return;
let newIndex = (selectedIndex ?? requests.length) - 1;
if (newIndex < 0) {
newIndex = requests.length - 1;
}
setSelectedIndex(newIndex);
},
undefined,
[hasFocus, requests, selectedIndex],
);
useKey(
'ArrowDown',
() => {
if (!hasFocus) return;
let newIndex = (selectedIndex ?? -1) + 1;
if (newIndex > requests.length - 1) {
newIndex = 0;
}
setSelectedIndex(newIndex);
},
undefined,
[hasFocus, requests, selectedIndex],
);
return (
<div className="relative h-full">
<div
<div aria-hidden={hidden} className="h-full">
<VStack
as="ul"
role="menu"
aria-orientation="vertical"
dir="ltr"
ref={sidebarRef}
className={classnames(className, 'h-full relative grid grid-rows-[minmax(0,1fr)_auto]')}
onFocus={handleFocus}
onBlur={handleBlur}
tabIndex={hidden ? -1 : 0}
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 activeRequestId={activeRequest?.id} requests={requests} />
</VStack>
<HStack className="mx-1 pb-1" alignItems="center" justifyContent="end">
<ToggleThemeButton />
</HStack>
</div>
<SidebarItems
selectedIndex={selectedIndex}
requests={requests}
focused={hasFocus}
onSelect={handleSelect}
onClearSelected={handleClearSelected}
/>
</VStack>
</div>
);
});
interface SidebarItemsProps {
requests: HttpRequest[];
focused: boolean;
selectedIndex?: number;
onSelect: (requestId: string) => void;
onClearSelected: () => void;
}
function SidebarItems({
requests,
activeRequestId,
}: {
requests: HttpRequest[];
activeRequestId?: string;
}) {
focused,
selectedIndex,
onSelect,
onClearSelected,
}: SidebarItemsProps) {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const updateRequest = useUpdateAnyRequest();
@@ -75,6 +210,7 @@ function SidebarItems({
(requestId) => {
if (hoveredIndex === null) return;
setHoveredIndex(null);
onClearSelected();
const index = requests.findIndex((r) => r.id === requestId);
const request = requests[index];
@@ -84,8 +220,10 @@ function SidebarItems({
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) {
@@ -100,7 +238,7 @@ function SidebarItems({
updateRequest.mutate({ id: requestId, update });
}
},
[hoveredIndex, requests, updateRequest],
[hoveredIndex, requests, updateRequest, onClearSelected],
);
return (
@@ -110,12 +248,13 @@ function SidebarItems({
{hoveredIndex === i && <DropMarker />}
<DraggableSidebarItem
key={r.id}
selected={selectedIndex === i}
requestId={r.id}
requestName={r.name}
workspaceId={r.workspaceId}
active={r.id === activeRequestId}
onMove={handleMove}
onEnd={handleEnd}
useProminentStyles={focused}
onSelect={onSelect}
/>
</Fragment>
))}
@@ -128,20 +267,25 @@ type SidebarItemProps = {
className?: string;
requestId: string;
requestName: string;
workspaceId: string;
active?: boolean;
useProminentStyles?: boolean;
selected?: boolean;
onSelect: (requestId: string) => void;
draggable?: boolean;
};
const _SidebarItem = forwardRef(function SidebarItem(
{ className, requestName, requestId, workspaceId, active }: SidebarItemProps,
{ className, requestName, requestId, useProminentStyles, selected, onSelect }: SidebarItemProps,
ref: ForwardedRef<HTMLLIElement>,
) {
const latestResponse = useLatestResponse(requestId);
const updateRequest = useUpdateRequest(requestId);
const [editing, setEditing] = useState<boolean>(false);
const activeRequestId = useActiveRequestId();
const isActive = activeRequestId === requestId;
const handleSubmitNameEdit = useCallback(
async (el: HTMLInputElement) => {
await updateRequest.mutate((r) => ({ ...r, name: el.value }));
(el: HTMLInputElement) => {
updateRequest.mutate((r) => ({ ...r, name: el.value }));
setEditing(false);
},
[updateRequest],
@@ -152,23 +296,13 @@ const _SidebarItem = forwardRef(function SidebarItem(
el?.select();
}, []);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLElement>) => {
// Hitting enter on active request during keyboard nav will start edit
if (active && e.key === 'Enter') {
e.preventDefault();
setEditing(true);
}
},
[active],
);
const handleInputKeyDown = useCallback(
async (e: KeyboardEvent<HTMLInputElement>) => {
async (e: React.KeyboardEvent<HTMLInputElement>) => {
e.stopPropagation();
switch (e.key) {
case 'Enter':
e.preventDefault();
await handleSubmitNameEdit(e.currentTarget);
handleSubmitNameEdit(e.currentTarget);
break;
case 'Escape':
e.preventDefault();
@@ -179,57 +313,63 @@ const _SidebarItem = forwardRef(function SidebarItem(
[handleSubmitNameEdit],
);
const handleStartEditing = useCallback(() => setEditing(true), [setEditing]);
const handleBlur = useCallback(
(e: React.FocusEvent<HTMLInputElement>) => {
handleSubmitNameEdit(e.currentTarget);
},
[handleSubmitNameEdit],
);
const handleSelect = useCallback(() => {
onSelect(requestId);
}, [onSelect, requestId]);
return (
<li ref={ref} className={classnames(className, 'block group/item px-2 pb-0.5')}>
<div className="relative">
<Button
color="custom"
size="sm"
to={`/workspaces/${workspaceId}/requests/${requestId}`}
draggable={false} // Item should drag, not the link
onDoubleClick={() => setEditing(true)}
onClick={active ? () => setEditing(true) : undefined}
justify="start"
onKeyDown={handleKeyDown}
className={classnames(
editing && 'focus-within:border-focus',
active
? 'bg-highlight text-gray-900'
: 'text-gray-600 group-hover/item:text-gray-800 active:bg-highlightSecondary',
// Move out of the way when trash is shown
'group-hover/item:pr-7',
)}
>
{editing ? (
<input
ref={handleFocus}
defaultValue={requestName}
className="bg-transparent outline-none w-full"
onBlur={(e) => handleSubmitNameEdit(e.currentTarget)}
onKeyDown={handleInputKeyDown}
/>
) : (
<span className={classnames('truncate', !requestName && 'text-gray-400 italic')}>
{requestName || 'New Request'}
</span>
)}
</Button>
<RequestActionsDropdown requestId={requestId}>
<IconButton
color="custom"
size="sm"
title="Request Options"
icon="dotsH"
className={classnames(
'absolute right-0 top-0 transition-opacity !opacity-0',
'group-hover/item:!opacity-100 focus-visible:!opacity-100',
)}
<li ref={ref} className={classNames(className, 'block group/item px-2 pb-0.5')}>
<button
// tabIndex={-1} // Will prevent drag-n-drop
onClick={handleSelect}
disabled={editing}
onDoubleClick={handleStartEditing}
data-active={isActive}
data-selected={selected}
className={classNames(
'w-full flex items-center text-sm h-xs px-2 rounded-md transition-colors',
editing && 'ring-1 focus-within:ring-focus',
isActive && 'bg-highlight text-gray-800',
!isActive && 'text-gray-600 group-hover/item:text-gray-800 active:bg-highlightSecondary',
selected && useProminentStyles && '!bg-violet-500/20 text-gray-900',
)}
>
{editing ? (
<input
ref={handleFocus}
defaultValue={requestName}
className="bg-transparent outline-none w-full"
onBlur={handleBlur}
onKeyDown={handleInputKeyDown}
/>
</RequestActionsDropdown>
</div>
) : (
<span className={classNames('truncate', !requestName && 'text-gray-400 italic')}>
{requestName || 'New Request'}
</span>
)}
{latestResponse && (
<div className="ml-auto">
{isResponseLoading(latestResponse) ? (
<Icon spin size="sm" icon="update" />
) : (
<StatusTag className="text-2xs dark:opacity-80" response={latestResponse} />
)}
</div>
)}
</button>
</li>
);
});
const SidebarItem = memo(_SidebarItem);
type DraggableSidebarItemProps = SidebarItemProps & {
@@ -239,24 +379,22 @@ type DraggableSidebarItemProps = SidebarItemProps & {
type DragItem = {
id: string;
workspaceId: string;
requestName: string;
};
const DraggableSidebarItem = memo(function DraggableSidebarItem({
requestName,
requestId,
workspaceId,
active,
onMove,
onEnd,
...props
}: DraggableSidebarItemProps) {
const ref = useRef<HTMLLIElement>(null);
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;
@@ -271,7 +409,7 @@ const DraggableSidebarItem = memo(function DraggableSidebarItem({
const [{ isDragging }, connectDrag] = useDrag<DragItem, unknown, { isDragging: boolean }>(
() => ({
type: ItemTypes.REQUEST,
item: () => ({ id: requestId, requestName, workspaceId }),
item: () => ({ id: requestId, requestName }),
collect: (m) => ({ isDragging: m.isDragging() }),
options: { dropEffect: 'move' },
end: () => onEnd(requestId),
@@ -285,11 +423,11 @@ const DraggableSidebarItem = memo(function DraggableSidebarItem({
return (
<SidebarItem
ref={ref}
className={classnames(isDragging && 'opacity-20')}
draggable
className={classNames(isDragging && 'opacity-20')}
requestName={requestName}
requestId={requestId}
workspaceId={workspaceId}
active={active}
{...props}
/>
);
});

View File

@@ -1,31 +1,30 @@
import { memo, useCallback } from 'react';
import { useCreateRequest } from '../hooks/useCreateRequest';
import { memo } from 'react';
import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { IconButton } from './core/IconButton';
import { useCreateRequest } from '../hooks/useCreateRequest';
import { HStack } from './core/Stacks';
export const SidebarActions = memo(function SidebarDisplayToggle() {
export const SidebarActions = memo(function SidebarActions() {
const createRequest = useCreateRequest();
const { hidden, toggle } = useSidebarHidden();
const createRequest = useCreateRequest({ navigateAfter: true });
const handleCreateRequest = useCallback(() => {
createRequest.mutate({ name: 'New Request' });
}, [createRequest]);
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,14 +0,0 @@
import React from 'react';
import { useTheme } from '../hooks/useTheme';
import { IconButton } from './core/IconButton';
export function ToggleThemeButton() {
const { appearance, toggleAppearance } = useTheme();
return (
<IconButton
title={appearance === 'dark' ? 'Enable light mode' : 'Enable dark mode'}
icon={appearance === 'dark' ? 'moon' : 'sun'}
onClick={toggleAppearance}
/>
);
}

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,10 +76,9 @@ 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}
disabled={loading}
/>
}
/>

View File

@@ -1,4 +1,4 @@
import classnames from 'classnames';
import classNames from 'classnames';
import { motion } from 'framer-motion';
import type {
CSSProperties,
@@ -6,11 +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';
@@ -27,8 +28,8 @@ const body = { gridArea: 'body' };
const drag = { gridArea: 'drag' };
export default function Workspace() {
const { set: setWidth, value: width, reset: resetWidth } = useSidebarWidth();
const { show, hide, hidden, toggle } = useSidebarHidden();
const { setWidth, width, resetWidth } = useSidebarWidth();
const { hide, show, hidden, toggle } = useSidebarHidden();
const windowSize = useWindowSize();
const [floating, setFloating] = useState<boolean>(false);
@@ -37,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 = () => {
@@ -70,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();
@@ -82,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;
@@ -112,26 +113,19 @@ 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
!isResizing && 'transition-all',
)}
>
<HeaderSize
data-tauri-drag-region
className="w-full bg-gray-50 border-b border-b-highlight text-gray-900"
style={head}
>
<WorkspaceHeader className="pointer-events-none" />
</HeaderSize>
{floating ? (
<Overlay open={!hidden} portalName="sidebar" onClose={hide}>
<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]',
)}
@@ -146,11 +140,8 @@ export default function Workspace() {
</Overlay>
) : (
<>
<div
style={side}
className={classnames('overflow-hidden bg-gray-100 border-r border-highlight')}
>
<Sidebar />
<div style={side} className={classNames('overflow-hidden bg-gray-100')}>
<Sidebar className="border-r border-highlight" />
</div>
<ResizeHandle
className="-translate-x-3"
@@ -162,6 +153,13 @@ export default function Workspace() {
/>
</>
)}
<HeaderSize
data-tauri-drag-region
className="w-full bg-gray-50 border-b border-b-highlight text-gray-900"
style={head}
>
<WorkspaceHeader className="pointer-events-none" />
</HeaderSize>
<RequestResponse style={body} />
</div>
);
@@ -172,11 +170,13 @@ interface HeaderSizeProps extends HTMLAttributes<HTMLDivElement> {
}
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',
)}
{...props}
/>

View File

@@ -1,86 +1,165 @@
import classnames from 'classnames';
import { invoke } from '@tauri-apps/api';
import classNames from 'classnames';
import { memo, useMemo } from 'react';
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
import { useConfirm } from '../hooks/useConfirm';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { useCreateWorkspace } from '../hooks/useCreateWorkspace';
import { useDeleteWorkspace } from '../hooks/useDeleteWorkspace';
import { useRoutes } from '../hooks/useRoutes';
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;
const createWorkspace = useCreateWorkspace({ navigateAfter: true });
const deleteWorkspace = useDeleteWorkspace(activeWorkspaceId);
const routes = useRoutes();
const confirm = useConfirm();
const updateWorkspace = useUpdateWorkspace(activeWorkspaceId);
const deleteWorkspace = useDeleteWorkspace(activeWorkspace);
const dialog = useDialog();
const prompt = usePrompt();
const routes = useAppRoutes();
const items: DropdownItem[] = useMemo(() => {
const workspaceItems = workspaces.map((w) => ({
const workspaceItems: DropdownItem[] = workspaces.map((w) => ({
key: w.id,
label: w.name,
leftSlot: activeWorkspaceId === w.id ? <Icon icon="check" /> : <Icon icon="empty" />,
onSelect: () => {
if (w.id === activeWorkspaceId) return;
routes.navigate('workspace', { workspaceId: w.id });
rightSlot: w.id === activeWorkspaceId ? <Icon icon="check" /> : undefined,
onSelect: async () => {
dialog.show({
id: 'open-workspace',
size: 'sm',
title: 'Open Workspace',
description: (
<>
Where would you like to open <InlineCode>{w.name}</InlineCode>?
</>
),
render: ({ hide }) => {
return (
<HStack space={2} justifyContent="end" className="mt-6">
<Button
className="focus"
color="gray"
rightSlot={<Icon icon="openNewWindow" />}
onClick={async () => {
hide();
const environmentId = (await getRecentEnvironments(w.id))[0];
await invoke('new_window', {
url: routes.paths.workspace({ workspaceId: w.id, environmentId }),
});
}}
>
New Window
</Button>
<Button
autoFocus
className="focus"
color="gray"
onClick={async () => {
hide();
const environmentId = (await getRecentEnvironments(w.id))[0];
routes.navigate('workspace', { workspaceId: w.id, environmentId });
}}
>
This Window
</Button>
</HStack>
);
},
});
},
}));
const activeWorkspaceItems: DropdownItem[] =
workspaces.length <= 1
? []
: [
...workspaceItems,
{
type: 'separator',
label: activeWorkspace?.name,
},
];
return [
...workspaceItems,
...activeWorkspaceItems,
{
type: 'separator',
label: 'Actions',
},
{
label: 'New Workspace',
leftSlot: <Icon icon="plus" />,
onSelect: () => createWorkspace.mutate({ name: 'New Workspace' }),
},
{
label: 'Delete Workspace',
leftSlot: <Icon icon="trash" />,
key: 'rename',
label: 'Rename',
leftSlot: <Icon icon="pencil" />,
onSelect: async () => {
const confirmed = await confirm({
title: 'Delete Workspace',
variant: 'delete',
const name = await prompt({
title: 'Rename Workspace',
description: (
<>
Are you sure you want to delete <InlineCode>{activeWorkspace?.name}</InlineCode>?
Enter a new name for <InlineCode>{activeWorkspace?.name}</InlineCode>
</>
),
name: 'name',
label: 'Name',
defaultValue: activeWorkspace?.name,
});
if (confirmed) {
deleteWorkspace.mutate();
}
updateWorkspace.mutate({ name });
},
},
{
key: 'delete',
label: 'Delete',
leftSlot: <Icon icon="trash" />,
onSelect: deleteWorkspace.mutate,
variant: 'danger',
},
{ type: 'separator' },
{
key: 'create-workspace',
label: 'Create Workspace',
leftSlot: <Icon icon="plus" />,
onSelect: async () => {
const name = await prompt({
name: 'name',
label: 'Name',
defaultValue: '',
description: 'Enter a name for the new workspace',
title: 'Create Workspace',
});
createWorkspace.mutate({ name });
},
},
];
}, [
workspaces,
activeWorkspaceId,
routes,
createWorkspace,
confirm,
activeWorkspace?.name,
deleteWorkspace,
activeWorkspaceId,
createWorkspace,
deleteWorkspace.mutate,
dialog,
prompt,
routes,
updateWorkspace,
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,10 +1,12 @@
import classnames from 'classnames';
import classNames from 'classnames';
import { memo } from 'react';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { IconButton } from './core/IconButton';
import { HStack } from './core/Stacks';
import { RecentRequestsDropdown } from './RecentRequestsDropdown';
import { RequestActionsDropdown } from './RequestActionsDropdown';
import { SidebarActions } from './SidebarActions';
import { EnvironmentActionsDropdown } from './EnvironmentActionsDropdown';
import { WorkspaceActionsDropdown } from './WorkspaceActionsDropdown';
interface Props {
@@ -13,18 +15,20 @@ interface Props {
export const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Props) {
const activeRequest = useActiveRequest();
return (
<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="flex-[2] text-center text-gray-800 text-sm truncate pointer-events-none">
{activeRequest?.name}
<div className="pointer-events-none">
<RecentRequestsDropdown />
</div>
<div className="flex-1 flex justify-end -mr-2 pointer-events-none">
{activeRequest && (

View File

@@ -1,16 +1,25 @@
import { Navigate } from 'react-router-dom';
import { useRoutes } from '../hooks/useRoutes';
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 = useRoutes();
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,7 +1,6 @@
import classnames from 'classnames';
import type { HTMLAttributes } from 'react';
import classNames from 'classnames';
import type { HTMLAttributes, ReactNode } from 'react';
import { forwardRef, memo, useMemo } from 'react';
import { Link } from 'react-router-dom';
import { Icon } from './Icon';
const colorStyles = {
@@ -15,8 +14,8 @@ const colorStyles = {
danger: 'bg-red-400 text-white enabled:hocus:bg-red-500 ring-red-500/50',
};
export type ButtonProps = HTMLAttributes<HTMLElement> & {
to?: string;
export type ButtonProps = HTMLAttributes<HTMLButtonElement> & {
innerClassName?: string;
color?: keyof typeof colorStyles;
isLoading?: boolean;
size?: 'sm' | 'md' | 'xs';
@@ -25,31 +24,37 @@ export type ButtonProps = HTMLAttributes<HTMLElement> & {
forDropdown?: boolean;
disabled?: boolean;
title?: string;
leftSlot?: ReactNode;
rightSlot?: ReactNode;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const _Button = forwardRef<any, ButtonProps>(function Button(
const _Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{
to,
isLoading,
className,
innerClassName,
children,
forDropdown,
color,
type = 'button',
justify = 'center',
size = 'md',
leftSlot,
rightSlot,
disabled,
...props
}: ButtonProps,
ref,
) {
const classes = useMemo(
() =>
classnames(
classNames(
className,
'opacity-90 hover:opacity-100',
'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',
justify === 'center' && 'justify-center',
@@ -57,25 +62,21 @@ const _Button = forwardRef<any, ButtonProps>(function Button(
size === 'sm' && 'h-sm px-2.5 text-sm',
size === 'xs' && 'h-xs px-2 text-sm',
),
[color, size, justify, className],
[className, disabled, color, justify, size],
);
if (typeof to === 'string') {
return (
<Link ref={ref} to={to} className={classes} {...props}>
{children}
{forDropdown && <Icon icon="chevronDown" className="ml-1 -mr-1" />}
</Link>
);
} else {
return (
<button ref={ref} className={classes} {...props}>
{isLoading && <Icon icon="update" size={size} className="animate-spin mr-1" />}
{children}
{forDropdown && <Icon icon="chevronDown" size={size} className="ml-1 -mr-1" />}
</button>
);
}
return (
<button ref={ref} type={type} className={classes} disabled={disabled} {...props}>
{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>
);
});
export const Button = memo(_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,17 +1,21 @@
import classNames from 'classnames';
interface Props {
count: number;
className?: string;
}
export function CountBadge({ count }: Props) {
export function CountBadge({ count, className }: Props) {
if (count === 0) return null;
return (
<>
<div
aria-hidden
className="opacity-70 border border-highlight text-3xs rounded mb-0.5 px-1 ml-1 h-4 font-mono"
>
{count}
</div>
</>
<div
aria-hidden
className={classNames(
className,
'opacity-70 border border-highlight text-3xs rounded mb-0.5 px-1 ml-1 h-4 font-mono',
)}
>
{count}
</div>
);
}

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,17 +51,29 @@ 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',
'dark:border border-gray-200 shadow-md shadow-black/10',
size === 'sm' && 'w-[25rem]',
size === 'md' && 'w-[45rem]',
size === 'full' && 'w-[80vw]',
'p-5 rounded-lg overflow-auto',
'dark:border border-highlight shadow shadow-black/10',
'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]',
)}
>
{title ? (
<Heading className="text-xl font-semibold w-full" id={titleId}>
{title}
</Heading>
) : (
<span />
)}
{description && <p id={descriptionId}>{description}</p>}
<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
onClick={onClose}
@@ -72,11 +84,6 @@ export function Dialog({
className="ml-auto absolute right-1 top-1"
/>
)}
<Heading className="text-xl font-semibold w-full" id={titleId}>
{title}
</Heading>
{description && <p id={descriptionId}>{description}</p>}
<div className="mt-6">{children}</div>
</motion.div>
</div>
</div>

View File

@@ -1,12 +1,22 @@
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 { Children, cloneElement, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useKeyPressEvent } from 'react-use';
import { Portal } from '../Portal';
import React, {
Children,
cloneElement,
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
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';
@@ -15,8 +25,10 @@ export type DropdownItemSeparator = {
export type DropdownItem =
| {
key: string;
type?: 'default';
label: string;
label: ReactNode;
variant?: 'danger';
disabled?: boolean;
hidden?: boolean;
leftSlot?: ReactNode;
@@ -30,21 +42,55 @@ export interface DropdownProps {
items: DropdownItem[];
}
export function Dropdown({ children, items }: DropdownProps) {
export interface DropdownRef {
isOpen: boolean;
open: (activeIndex?: number) => void;
toggle: (activeIndex?: number) => void;
close?: () => void;
next?: () => void;
prev?: () => void;
select?: () => void;
}
export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown(
{ children, items }: DropdownProps,
ref,
) {
const [open, setOpen] = useState<boolean>(false);
const ref = useRef<HTMLButtonElement>(null);
const [defaultSelectedIndex, setDefaultSelectedIndex] = useState<number>();
const buttonRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<Omit<DropdownRef, 'open'>>(null);
useImperativeHandle(ref, () => ({
...menuRef.current,
isOpen: open,
toggle(activeIndex?: number) {
if (!open) this.open(activeIndex);
else setOpen(false);
},
open(activeIndex?: number) {
if (activeIndex === undefined) {
setDefaultSelectedIndex(undefined);
} else {
setDefaultSelectedIndex(activeIndex >= 0 ? activeIndex : items.length + activeIndex);
}
setOpen(true);
},
}));
const child = useMemo(() => {
const existingChild = Children.only(children);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const props: any = {
...existingChild.props,
ref,
ref: buttonRef,
'aria-haspopup': 'true',
onClick:
existingChild.props?.onClick ??
((e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setDefaultSelectedIndex(undefined);
setOpen((o) => !o);
}),
};
@@ -53,37 +99,50 @@ export function Dropdown({ children, items }: DropdownProps) {
const handleClose = useCallback(() => {
setOpen(false);
ref.current?.focus();
buttonRef.current?.focus();
}, []);
useEffect(() => {
ref.current?.setAttribute('aria-expanded', open.toString());
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 ref.current?.getBoundingClientRect();
}, [open]);
return buttonRef.current?.getBoundingClientRect();
}, [open, windowSize]);
return (
<>
{child}
{open && triggerRect && (
<Menu items={items} triggerRect={triggerRect} onClose={handleClose} />
<Menu
ref={menuRef}
defaultSelectedIndex={defaultSelectedIndex}
items={items}
triggerRect={triggerRect}
onClose={handleClose}
/>
)}
</>
);
}
});
interface MenuProps {
className?: string;
defaultSelectedIndex?: number;
items: DropdownProps['items'];
triggerRect: DOMRect;
onClose: () => void;
}
function Menu({ className, items, onClose, triggerRect }: MenuProps) {
const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuProps>(function Menu(
{ className, items, onClose, triggerRect, defaultSelectedIndex }: MenuProps,
ref,
) {
const containerRef = useRef<HTMLDivElement | null>(null);
const [selectedIndex, setSelectedIndex] = useState<number | null>(defaultSelectedIndex ?? null);
const [menuStyles, setMenuStyles] = useState<CSSProperties>({});
// Calculate the max height so we can scroll
@@ -94,13 +153,23 @@ function Menu({ className, items, onClose, triggerRect }: MenuProps) {
setMenuStyles({ maxHeight: windowBox.height - menuBox.top - 5 });
}, []);
// Close menu on space bar
const handleMenuKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === ' ') {
e.preventDefault();
onClose();
}
},
[onClose],
);
useKeyPressEvent('Escape', (e) => {
e.preventDefault();
onClose();
});
useKeyPressEvent('ArrowUp', (e) => {
e.preventDefault();
const handlePrev = useCallback(() => {
setSelectedIndex((currIndex) => {
let nextIndex = (currIndex ?? 0) - 1;
const maxTries = items.length;
@@ -115,10 +184,9 @@ function Menu({ className, items, onClose, triggerRect }: MenuProps) {
}
return nextIndex;
});
});
}, [items]);
useKeyPressEvent('ArrowDown', (e) => {
e.preventDefault();
const handleNext = useCallback(() => {
setSelectedIndex((currIndex) => {
let nextIndex = (currIndex ?? -1) + 1;
const maxTries = items.length;
@@ -133,8 +201,44 @@ function Menu({ className, items, onClose, triggerRect }: MenuProps) {
}
return nextIndex;
});
}, [items]);
useKey('ArrowUp', (e) => {
e.preventDefault();
handlePrev();
});
useKey('ArrowDown', (e) => {
e.preventDefault();
handleNext();
});
const handleSelect = useCallback(
(i: DropdownItem) => {
onClose();
setSelectedIndex(null);
if (i.type !== 'separator') {
i.onSelect?.();
}
},
[onClose],
);
useImperativeHandle(
ref,
() => ({
close: onClose,
prev: handlePrev,
next: handleNext,
select: () => {
const item = items[selectedIndex ?? -1] ?? null;
if (!item) return;
handleSelect(item);
},
}),
[handleNext, handlePrev, handleSelect, items, onClose, selectedIndex],
);
const { containerStyles, triangleStyles } = useMemo<{
containerStyles: CSSProperties;
triangleStyles: CSSProperties;
@@ -153,17 +257,6 @@ function Menu({ className, items, onClose, triggerRect }: MenuProps) {
return { containerStyles, triangleStyles };
}, [triggerRect]);
const handleSelect = useCallback(
(i: DropdownItem) => {
onClose();
setSelectedIndex(null);
if (i.type !== 'separator') {
i.onSelect?.();
}
},
[onClose],
);
const handleFocus = useCallback(
(i: DropdownItem) => {
const index = items.findIndex((item) => item === i) ?? null;
@@ -172,65 +265,64 @@ function Menu({ className, items, onClose, triggerRect }: MenuProps) {
[items],
);
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
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}
initial={{ opacity: 0, y: -5, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
role="menu"
aria-orientation="vertical"
dir="ltr"
ref={containerRef}
style={containerStyles}
className={classnames(className, 'outline-none mt-1 pointer-events-auto fixed z-50')}
>
<span
aria-hidden
style={triangleStyles}
className="bg-gray-50 absolute rotate-45 border-gray-200 border-t border-l"
/>
{containerStyles && (
<VStack
space={0.5}
ref={initMenu}
style={menuStyles}
className={classnames(
className,
'h-auto bg-gray-50 rounded-md shadow-lg dark:shadow-gray-0 py-1.5 border',
'border-gray-200 overflow-auto mb-1 mx-0.5',
)}
>
{items.map((item, i) => {
if (item.type === 'separator') {
return <Separator key={i} className="my-1.5" label={item.label} />;
}
if (item.hidden) {
return null;
}
return (
<MenuItem
focused={i === selectedIndex}
onFocus={handleFocus}
onSelect={handleSelect}
key={i + item.label}
item={item}
/>
);
})}
</VStack>
)}
</motion.div>
</div>
</FocusTrap>
</Portal>
<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>
);
}
});
interface MenuItemProps {
className?: string;
@@ -257,23 +349,33 @@ function MenuItem({ className, focused, onFocus, item, onSelect, ...props }: Men
if (item.type === 'separator') return <Separator className="my-1.5" />;
return (
<button
<Button
ref={initRef}
size="xs"
tabIndex={-1}
onMouseEnter={(e) => e.currentTarget.focus()}
onMouseLeave={(e) => e.currentTarget.blur()}
onFocus={handleFocus}
onClick={handleClick}
className={classnames(
justify="start"
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 h-7 flex items-center text-sm text-gray-700 whitespace-nowrap pr-4',
'min-w-[8rem] outline-none px-2 mx-1.5 flex text-sm text-gray-700 whitespace-nowrap',
'focus:bg-highlight focus:text-gray-900 rounded',
item.variant === 'danger' && 'text-red-600',
)}
{...props}
>
{item.leftSlot && <div className="w-6">{item.leftSlot}</div>}
<div>{item.label}</div>
{item.rightSlot && <div className="ml-auto pl-3">{item.rightSlot}</div>}
</button>
<div
className={classNames(
// Add padding on right when no right slot, for some visual balance
!item.rightSlot && 'pr-4',
)}
>
{item.label}
</div>
</Button>
);
}

View File

@@ -0,0 +1,25 @@
interface Props {
millis: number;
}
export function DurationTag({ millis }: Props) {
let num;
let unit;
if (millis > 1000 * 60) {
num = millis / 1000 / 60;
unit = 'min';
} else if (millis > 1000) {
num = millis / 1000;
unit = 's';
} else {
num = millis;
unit = 'ms';
}
return (
<span title={`${millis} milliseconds`}>
{Math.round(num * 10) / 10} {unit}
</span>
);
}

View File

@@ -1,207 +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 opacity-95;
.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;
}
& > ul > li:hover {
@apply text-gray-800;
}
.cm-completionIcon {
@apply text-sm flex items-center pb-0.5;
}
.cm-completionLabel {
}
.cm-completionDetail {
@apply ml-auto;
}
.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';
@@ -24,19 +25,23 @@ export interface EditorProps {
type?: 'text' | 'password';
className?: string;
heightMode?: 'auto' | 'full';
contentType?: string;
contentType?: string | null;
forceUpdateKey?: string;
autoFocus?: boolean;
defaultValue?: string;
autoSelect?: boolean;
defaultValue?: string | null;
placeholder?: string;
tooltipContainer?: HTMLElement;
useTemplating?: boolean;
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;
}
@@ -47,6 +52,7 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
heightMode,
contentType,
autoFocus,
autoSelect,
placeholder,
useTemplating,
defaultValue,
@@ -54,14 +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);
@@ -83,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(() => {
@@ -93,13 +111,22 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
cm.current?.view.dispatch({ effects: effect });
}, [placeholder]);
// Update wrap lines
const wrapLinesCompartment = useRef(new Compartment());
useEffect(() => {
if (cm.current === null) return;
const ext = wrapLines ? [EditorView.lineWrapping] : [];
const effect = wrapLinesCompartment.current.reconfigure(ext);
cm.current?.view.dispatch({ effects: effect });
}, [wrapLines]);
// Update language extension when contentType changes
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;
@@ -120,22 +147,27 @@ 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 ?? ''}`,
extensions: [
languageCompartment.of(langExt),
placeholderCompartment.current.of(
placeholderExt(placeholderElFromText(placeholder ?? '')),
),
placeholderCompartment.current.of([]),
wrapLinesCompartment.current.of([]),
...getExtensions({
container,
readOnly,
singleLine,
onChange: handleChange,
onFocus: handleFocus,
onBlur: handleBlur,
readOnly,
singleLine,
onKeyDown: handleKeyDown,
}),
],
});
@@ -143,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);
}
@@ -154,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',
@@ -205,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 =
@@ -223,30 +262,16 @@ function getExtensions({
keymap.of(singleLine ? defaultKeymap.filter((k) => k.key !== 'Enter') : defaultKeymap),
...(singleLine ? [singleLineExt()] : []),
...(!singleLine ? [multiLineExtensions] : []),
...(readOnly ? [EditorState.readOnly.of(true)] : []),
...(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 }));
}
},
}),
]
...(readOnly
? [EditorState.readOnly.of(true), EditorView.contentAttributes.of({ tabindex: '-1' })]
: []),
// 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,56 +1,54 @@
import type { CompletionContext } from '@codemirror/autocomplete';
import { w } from '@tauri-apps/api/clipboard-79413165';
const openTag = '${[ ';
const closeTag = ' ]}';
const variables = [
{ name: 'DOMAIN' },
{ name: 'BASE_URL' },
{ name: 'CONTENT_THINGY' },
{ name: 'TOKEN' },
{ name: 'PROJECT_ID' },
{ name: 'DUMMY' },
{ name: 'DUMMY_2' },
{ name: 'STRIPE_PUB_KEY' },
{ name: 'RAILWAY_TOKEN' },
{ name: 'SECRET' },
{ name: 'PORT' },
];
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,15 +1,21 @@
import classnames from 'classnames';
import type { HTMLAttributes } from 'react';
import classNames from 'classnames';
export function HotKey({ children }: HTMLAttributes<HTMLSpanElement>) {
interface Props {
modifier: 'Meta' | 'Control' | 'Shift';
keyName: string;
}
const keys: Record<Props['modifier'], string> = {
Control: '⌃',
Meta: '⌘',
Shift: '⇧',
};
export function HotKey({ modifier, keyName }: Props) {
return (
<span
className={classnames(
'bg-gray-400 bg-opacity-20 px-1.5 py-0.5 rounded text-sm',
'font-mono text-gray-500 tracking-widest',
)}
>
{children}
<span className={classNames('text-sm text-gray-600')}>
{keys[modifier]}
{keyName}
</span>
);
}

View File

@@ -22,7 +22,9 @@ import {
MagicWandIcon,
MagnifyingGlassIcon,
MoonIcon,
OpenInNewWindowIcon,
PaperPlaneIcon,
Pencil2Icon,
PlusCircledIcon,
PlusIcon,
QuestionMarkIcon,
@@ -34,7 +36,8 @@ 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';
import { ReactComponent as LeftPanelVisibleIcon } from '../../assets/icons/LeftPanelVisibleIcon.svg';
@@ -64,7 +67,9 @@ const icons = {
magicWand: MagicWandIcon,
magnifyingGlass: MagnifyingGlassIcon,
moon: MoonIcon,
openNewWindow: OpenInNewWindowIcon,
paperPlane: PaperPlaneIcon,
pencil: Pencil2Icon,
plus: PlusIcon,
plusCircle: PlusCircledIcon,
question: QuestionMarkIcon,
@@ -76,7 +81,7 @@ const icons = {
triangleRight: TriangleRightIcon,
update: UpdateIcon,
x: Cross2Icon,
empty: () => <span />,
empty: (props: HTMLAttributes<HTMLSpanElement>) => <span {...props} />,
};
export interface IconProps {
@@ -90,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,6 +1,6 @@
import classnames from 'classnames';
import classNames from 'classnames';
import type { MouseEvent } from 'react';
import { forwardRef, memo, useCallback } from 'react';
import { forwardRef, useCallback } from 'react';
import { useTimedBoolean } from '../../hooks/useTimedBoolean';
import type { ButtonProps } from './Button';
import { Button } from './Button';
@@ -15,7 +15,7 @@ type Props = IconProps &
title: string;
};
const _IconButton = forwardRef<HTMLButtonElement, Props>(function IconButton(
export const IconButton = forwardRef<HTMLButtonElement, Props>(function IconButton(
{
showConfirm,
icon,
@@ -32,7 +32,7 @@ const _IconButton = forwardRef<HTMLButtonElement, Props>(function IconButton(
) {
const [confirmed, setConfirmed] = useTimedBoolean();
const handleClick = useCallback(
(e: MouseEvent<HTMLElement>) => {
(e: MouseEvent<HTMLButtonElement>) => {
if (showConfirm) setConfirmed();
onClick?.(e);
},
@@ -45,7 +45,8 @@ const _IconButton = forwardRef<HTMLButtonElement, Props>(function IconButton(
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 @@ const _IconButton = forwardRef<HTMLButtonElement, Props>(function IconButton(
size={iconSize}
icon={confirmed ? 'check' : icon}
spin={spin}
className={classnames(
className={classNames(
iconClassName,
props.disabled && 'opacity-70',
confirmed && 'text-green-600',
@@ -69,5 +70,3 @@ const _IconButton = forwardRef<HTMLButtonElement, Props>(function IconButton(
</Button>
);
});
export const IconButton = memo(_IconButton);

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,
@@ -57,23 +69,20 @@ export const Input = forwardRef<EditorView | undefined, InputProps>(function Inp
const [currentValue, setCurrentValue] = useState(defaultValue ?? '');
const [focused, setFocused] = useState(false);
const handleOnFocus = useCallback(() => {
const handleFocus = useCallback(() => {
setFocused(true);
onFocus?.();
}, [onFocus]);
const handleOnBlur = useCallback(() => {
const handleBlur = useCallback(() => {
setFocused(false);
onBlur?.();
}, [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 border-highlight',
focused && 'border-focus',
'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={handleOnFocus}
onBlur={handleOnBlur}
{...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}`}

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