Compare commits

...

132 Commits

Author SHA1 Message Date
Gregory Schier
7c2de3c360 Add proper target 2023-04-02 15:42:19 -07:00
Gregory Schier
3a3b187cd0 Try universal binary 2023-04-02 15:33:13 -07:00
Gregory Schier
3226bbe083 Fix version 2023-04-02 15:25:24 -07:00
Gregory Schier
a1e4e0e6c9 Bump version 2023-04-02 14:54:41 -07:00
Gregory Schier
b3aa8b893b Notorization (hopefully) 2023-04-02 14:53:49 -07:00
Gregory Schier
f057139634 Change tabs again 2023-04-02 11:11:53 -07:00
Gregory Schier
71a2b11ab4 Better response headers 2023-04-02 10:45:41 -07:00
Gregory Schier
587254a0e7 Show response headers 2023-04-01 23:43:22 -07:00
Gregory Schier
9f4de66f3c Some more refactoring 2023-04-01 21:48:30 -07:00
Gregory Schier
b0d8908724 Refactor debounce and tauri event listeners 2023-04-01 21:39:46 -07:00
Gregory Schier
15c22d98c6 Fix dropdown and dialog key handling 2023-04-01 21:04:39 -07:00
Gregory Schier
3105ae0edc Refactor sidebar display 2023-04-01 20:58:53 -07:00
Gregory Schier
11a89f06c1 Better GraphQL schema fetching 2023-04-01 17:53:36 -07:00
Gregory Schier
9cbe24e740 More eslint fixes 2023-04-01 15:48:37 -07:00
Gregory Schier
bfbed13b8f Add React hooks eslint 2023-04-01 15:26:57 -07:00
Gregory Schier
2268de6321 Fix Tauri listeners 2023-04-01 00:02:17 -07:00
Gregory Schier
dd99aa7fcd Memo editor 2023-03-31 23:19:15 -07:00
Gregory Schier
be436bb706 Fix request duplication 2023-03-31 22:54:32 -07:00
Gregory Schier
bd48726f44 Fix tauri listeners causing too many updates 2023-03-31 22:48:34 -07:00
Gregory Schier
10bea83f98 Remove import 2023-03-31 22:42:41 -07:00
Gregory Schier
8122b4fb84 Fix 2023-03-31 22:42:26 -07:00
Gregory Schier
3ae57fb2d8 Upgrade Deno 2023-03-31 22:42:08 -07:00
Gregory Schier
6dc3eecca4 Tweak 2023-03-31 16:14:25 -07:00
Gregory Schier
9d1d732154 Fix send hotkey 2023-03-31 16:13:34 -07:00
Gregory Schier
8a117415b7 Better schema fetching 2023-03-31 16:02:09 -07:00
Gregory Schier
d36623ebc9 Finally fix the editor! 2023-03-31 15:56:35 -07:00
Gregory Schier
94a3ae3696 Fix editor blurring bug! 2023-03-31 13:53:28 -07:00
Gregory Schier
2836a28988 Better model updates 2023-03-31 13:21:02 -07:00
Gregory Schier
946d7dc89e Fix text obscuring 2023-03-30 17:22:52 -07:00
Gregory Schier
af6300f18b Button ring colors 2023-03-30 17:17:07 -07:00
Gregory Schier
905cb4b18e Remove dummy button 2023-03-30 17:12:38 -07:00
Gregory Schier
305ed09547 Confirm deletions 2023-03-30 17:09:11 -07:00
Gregory Schier
643356bad3 Dedicated event for model creation 2023-03-30 16:49:49 -07:00
Gregory Schier
e458675627 Unify text selection color 2023-03-30 16:36:24 -07:00
Gregory Schier
91e3853692 Some icon tweaks 2023-03-30 16:29:14 -07:00
Gregory Schier
5f0876a136 Fix strict mode editor blur bug 2023-03-30 10:38:33 -07:00
Gregory Schier
3a38127fb4 Better tauri listeners and stuff 2023-03-30 09:05:54 -07:00
Gregory Schier
f3b6070235 Remove updated_by, remember last location 2023-03-30 08:11:51 -07:00
Gregory Schier
5e6e78eb9e Remove hardcoded window config 2023-03-29 22:16:21 -07:00
Gregory Schier
9b66a1d1a8 Fix build 2023-03-29 22:15:55 -07:00
Gregory Schier
e954d0d7bc Remove unused import 2023-03-29 21:53:49 -07:00
Gregory Schier
dab2df7e79 Better multi-window updates 2023-03-29 21:53:20 -07:00
Gregory Schier
bc40e22008 Fixed key/value stuff 2023-03-29 14:46:36 -07:00
Gregory Schier
eef262c398 Fix bundle parts 2023-03-29 14:00:34 -07:00
Gregory Schier
8eab6e14db Fix(ish) multiwindow updates 2023-03-29 11:15:37 -07:00
Gregory Schier
ded33a110a Obscure text 2023-03-29 10:16:51 -07:00
Gregory Schier
e448a7602a Simple auth schemes 2023-03-29 09:03:38 -07:00
Gregory Schier
4c22215ca5 Good start to multi-window 2023-03-28 18:29:40 -07:00
Gregory Schier
4f501abb72 Focus traps for dialog and dropdown 2023-03-26 23:07:09 -07:00
Gregory Schier
b2dcc38982 Confirmation Dialogs 2023-03-26 12:02:20 -07:00
Gregory Schier
11b719955b Floating sidebar 2023-03-26 10:09:28 -07:00
Gregory Schier
d563ac63db Panel icons 2023-03-25 23:29:04 -07:00
Gregory Schier
6d826064c6 Update dialog 2023-03-25 21:59:18 -07:00
Gregory Schier
d30b9d6518 Optimistically-update key values 2023-03-25 21:54:00 -07:00
Gregory Schier
8da3364d0f More tweaks 2023-03-25 21:40:14 -07:00
Gregory Schier
07c372b7f5 Animate dropdown 2023-03-25 21:36:17 -07:00
Gregory Schier
7e01f38253 Animate sidebar transition 2023-03-25 21:31:52 -07:00
Gregory Schier
ba637009a7 Refactor and improve layout resizing 2023-03-25 21:16:10 -07:00
Gregory Schier
da7388e510 Even better layouts 2023-03-25 18:33:01 -07:00
Gregory Schier
3ec88fc896 Better grid layouts 2023-03-25 18:12:09 -07:00
Gregory Schier
1c9381b2bd Global layout component 2023-03-25 13:26:31 -07:00
Gregory Schier
06349b8d5b Better dropdown separator 2023-03-25 11:06:05 -07:00
Gregory Schier
6dc7dc6ad2 Fix sidebar drag 2023-03-24 08:37:52 -07:00
Gregory Schier
f981a15ec3 Upgrade TYpescript 2023-03-23 15:37:36 -07:00
Gregory Schier
8b648c0301 Fix resize 2023-03-23 07:47:58 -07:00
Gregory Schier
83ce09075b Style tweak 2023-03-21 23:59:09 -07:00
Gregory Schier
168dfb9f6b GraphQL autocomplete and duplicate request 2023-03-21 23:54:45 -07:00
Gregory Schier
9b8961c23d Tweak sidebar drag resizer 2023-03-21 19:36:32 -07:00
Gregory Schier
89bca42ee6 Minor style tweaks 2023-03-21 18:31:05 -07:00
Gregory Schier
07d2a43a17 Pull out resize bar 2023-03-21 16:53:49 -07:00
Gregory Schier
c84f2afd09 Resize titlebar and tweak things 2023-03-21 16:42:52 -07:00
Gregory Schier
df4dbaecc8 Remove icon generation from build script 2023-03-21 14:21:07 -07:00
Gregory Schier
d9bf03cefe query client cache and better body types 2023-03-21 11:38:37 -07:00
Gregory Schier
39223e8d89 Fix workspace deletion 2023-03-21 09:32:15 -07:00
Gregory Schier
67925e18b2 Use proper gray for syntax 2023-03-20 17:15:12 -07:00
Gregory Schier
89ad65513d fix import 2023-03-20 17:13:14 -07:00
Gregory Schier
90166ddfa3 Minor tweaks 2023-03-20 17:12:19 -07:00
Gregory Schier
0981b23faf Fix URL bar spacing 2023-03-20 17:01:29 -07:00
Gregory Schier
664f3b4d87 Better radio dropdown type 2023-03-20 16:54:26 -07:00
Gregory Schier
dc97b91a4e Typesafe routing and CM line height issue 2023-03-20 16:47:36 -07:00
Gregory Schier
d310272d19 Better tab dropdown handling 2023-03-20 14:14:30 -07:00
Gregory Schier
f1be3f01e1 Fix request creation priority 2023-03-20 13:56:03 -07:00
Gregory Schier
c57b6e1d73 Remove log 2023-03-20 13:49:35 -07:00
Gregory Schier
a938dc45f0 Handle "no body" case 2023-03-20 13:49:21 -07:00
Gregory Schier
bb139744a1 Small fix 2023-03-20 13:37:14 -07:00
Gregory Schier
3aa3e09552 Fix pointer window drag 2023-03-20 13:34:49 -07:00
Gregory Schier
74abfd21b8 Fix extra dropdown element 2023-03-20 13:19:23 -07:00
Gregory Schier
e703817ba2 Remove most of Radix UI 2023-03-20 13:16:58 -07:00
Gregory Schier
80dd1e457b Better Header validation 2023-03-20 01:38:05 -07:00
Gregory Schier
ea9f8d3ab2 Tweak sidebar 2023-03-20 01:30:45 -07:00
Gregory Schier
fa222bdf12 Fix pair editor container 2023-03-20 01:18:44 -07:00
Gregory Schier
45b360dabd Fix input thingy 2023-03-20 01:14:13 -07:00
Gregory Schier
5923399359 Container queries! 2023-03-20 01:08:41 -07:00
Gregory Schier
f4600f3e90 Better pair editor delete button 2023-03-20 00:30:42 -07:00
Gregory Schier
f883837685 Pair validation 2023-03-20 00:17:29 -07:00
Gregory Schier
b58bc409f0 Don't send disabled headers 2023-03-20 00:05:19 -07:00
Gregory Schier
e893e539bb Small tweak 2023-03-20 00:04:40 -07:00
Gregory Schier
90294fbb5d Pair checkboxes and fix twig indent 2023-03-20 00:03:33 -07:00
Gregory Schier
ae65f222bc Rewrote twig grammar 2023-03-19 22:12:11 -07:00
Gregory Schier
1b9813fb4c Re-order of pair editor 2023-03-19 13:28:57 -07:00
Gregory Schier
b708b5ae41 Better header editor and added completion data 2023-03-19 11:09:21 -07:00
Gregory Schier
df136fa915 A couple tweaks 2023-03-19 01:01:13 -07:00
Gregory Schier
f8329f5b8d Persist sort priority! 2023-03-19 00:48:09 -07:00
Gregory Schier
21141090de Create new workspace, and more optimizations 2023-03-18 19:36:31 -07:00
Gregory Schier
c0d9740a7d Optimized a few components 2023-03-18 18:49:01 -07:00
Gregory Schier
afcf630443 Fix sidebar drag-n-drop 2023-03-18 18:09:36 -07:00
Gregory Schier
1fe2c9826a Got drag opacity working 2023-03-18 15:06:38 -07:00
Gregory Schier
7272b80a3f Good start to drag-n-drop sidebar! 2023-03-18 14:41:07 -07:00
Gregory Schier
92114b7368 Fix mixed parser 2023-03-17 17:57:43 -07:00
Gregory Schier
f39d3e7eed Dropdown highlight 2023-03-17 17:32:24 -07:00
Gregory Schier
cbe0d27a5e Beginnings of autocomplete for headers 2023-03-17 16:51:20 -07:00
Gregory Schier
cd39699467 Flatten migrations, kvs lib, fix tabs 2023-03-17 08:36:21 -07:00
Gregory Schier
b3ea67aacf Sidebar item dropdown 2023-03-16 15:37:53 -07:00
Gregory Schier
db4ed9797c Sidebar dragging 2023-03-16 14:34:49 -07:00
Gregory Schier
1ea7d7d685 Add devtools toggle hotkey 2023-03-16 11:25:38 -07:00
Gregory Schier
2df725b57a Adjust window sizes 2023-03-16 11:16:23 -07:00
Gregory Schier
74e6648249 Store appearance in k/v 2023-03-16 11:01:30 -07:00
Gregory Schier
1026350d9c Hotkeys and view mode kvs 2023-03-16 09:24:28 -07:00
Gregory Schier
98fb87874d Some fixes 2023-03-15 23:33:46 -07:00
Gregory Schier
41fc3afdc1 Got key values working 2023-03-15 23:24:41 -07:00
Gregory Schier
83dbf46ba4 Fix editor padding 2023-03-15 17:29:35 -07:00
Gregory Schier
0b2e35bdde Minor style updates 2023-03-15 17:25:04 -07:00
Gregory Schier
d90a7331c9 Add stuff to app header 2023-03-15 16:35:19 -07:00
Gregory Schier
264e64a996 Better request delete and formatting 2023-03-15 09:41:38 -07:00
Gregory Schier
8915915c47 Fix graphql and other things 2023-03-15 09:06:56 -07:00
Gregory Schier
951ed787fa Header editor to pair editor 2023-03-15 08:09:45 -07:00
Gregory Schier
64ef6b0c22 Better header editor 2023-03-15 07:54:04 -07:00
Gregory Schier
ef18377b3c Strict mode and tweak layout padding 2023-03-14 20:19:45 -07:00
Gregory Schier
5904b6fded Add GraphQL variables editor 2023-03-14 19:56:02 -07:00
Gregory Schier
f4401e77bb GraphQL query editor transformer works! 2023-03-14 19:08:18 -07:00
Gregory Schier
efa5455a7b Add body type to request and tab dropdown 2023-03-14 11:18:56 -07:00
Gregory Schier
619c8d9e72 Improved header editor 2023-03-14 00:54:41 -07:00
150 changed files with 7754 additions and 4415 deletions

View File

@@ -1,31 +1,37 @@
module.exports = {
extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:import/recommended',
'plugin:jsx-a11y/recommended',
'plugin:@typescript-eslint/recommended',
'eslint-config-prettier',
],
ignorePatterns: ['src-tauri/**/*'],
settings: {
react: {
version: 'detect',
},
'import/resolver': {
node: {
paths: ['src-web'],
extensions: ['.js', '.jsx', '.ts', '.tsx'],
},
},
},
rules: {
"jsx-a11y/no-autofocus": "warn",
"react/react-in-jsx-scope": "off",
"@typescript-eslint/consistent-type-imports": ["error", {
prefer: "type-imports",
disallowTypeAnnotations: true,
fixStyle: "separate-type-imports",
}]
extends: [
"eslint:recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"plugin:import/recommended",
"plugin:jsx-a11y/recommended",
"plugin:@typescript-eslint/recommended",
"eslint-config-prettier"
],
parser: '@typescript-eslint/parser',
parserOptions: {
project: ['./tsconfig.json'],
},
ignorePatterns: ["src-tauri/**/*"],
settings: {
react: {
version: "detect"
},
"import/resolver": {
node: {
paths: ["src-web"],
extensions: [".ts", ".tsx"]
}
}
},
rules: {
"jsx-a11y/no-autofocus": "warn",
"react/react-in-jsx-scope": "off",
"import/no-unresolved": "off",
"@typescript-eslint/consistent-type-imports": ["error", {
prefer: "type-imports",
disallowTypeAnnotations: true,
fixStyle: "separate-type-imports"
}],
}
};

View File

@@ -10,7 +10,8 @@ jobs:
strategy:
fail-fast: false
matrix:
platform: [ ubuntu-latest, macos-latest, windows-latest ]
# platform: [ ubuntu-latest, macos-latest, windows-latest ]
platform: [ macos-latest ]
steps:
- uses: actions/checkout@v3
@@ -27,6 +28,10 @@ jobs:
with:
node-version: 18
cache: 'npm'
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
targets: 'aarch64-apple-darwin,x86_64-apple-darwin'
- name: install dependencies (ubuntu only)
if: matrix.platform == 'ubuntu-latest'
run: |
@@ -41,9 +46,16 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
ENABLE_CODE_SIGNING: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
with:
tagName: v__VERSION__
releaseName: 'App v__VERSION__'
tagName: 'v__VERSION__'
releaseName: 'Release __VERSION__'
releaseBody: 'See the assets to download this version and install.'
releaseDraft: true
releaseDraft: false
prerelease: false
args: '--target universal-apple-darwin'

View File

@@ -6,9 +6,7 @@
<script value="tauri-dev" />
</scripts>
<node-interpreter value="project" />
<envs>
<env name="DATABASE_URL" value="sqlite://$USER_HOME$/Library/Application%20Support/co.schier.yaak/db.sqlite" />
</envs>
<envs />
<method v="2" />
</configuration>
</component>
</component>

View File

@@ -1,3 +1,16 @@
# Tauri REST Client
# Yaak Network Toolkit
It's a REST client, yo.
The most fun you'll ever have working with APIs.
## Common Commands
```sh
# Start dev app
npm run tauri-dev
# Migration commands
cd src-tauri
cargo sqlx migrate add <name>
cargo sqlx migrate run --database-url 'sqlite://db.sqlite?mode=rw'
cargo sqlx prepare --database-url 'sqlite://db.sqlite'
```

View File

@@ -1,17 +1,28 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Yaak App</title>
<!-- <script src="http://localhost:8097"></script>-->
</head>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Yaak App</title>
<script src="http://localhost:8097"></script>
<style>
body {
background-color: white;
}
<body>
<div id="root"></div>
<div id="cm-portal" class="cm-portal" style="pointer-events: auto"></div>
<div id="radix-portal" class="cm-portal"></div>
<script type="module" src="/src-web/main.tsx"></script>
</body>
@media (prefers-color-scheme: dark) {
body {
background-color: black;
}
}
</style>
</head>
<body>
<div id="root"></div>
<div id="cm-portal" class="cm-portal"></div>
<div id="react-portal"></div>
<div id="radix-portal" class="cm-portal"></div>
<script type="module" src="/src-web/main.tsx"></script>
</body>
</html>

3197
package-lock.json generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,11 @@
{
"name": "tauri-app",
"name": "yaak-app",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"tauri-dev": "tauri dev",
"tauri-build": "npm run build:icon && tauri build",
"tauri-dev": "YAAK_ENV=development tauri dev",
"tauri-build": "tauri build",
"build": "npm run build:frontend",
"dev": "vite dev",
"lint": "tsc && eslint . --ext .ts,.tsx",
@@ -25,27 +25,30 @@
"@lezer/generator": "^1.2.2",
"@lezer/highlight": "^1.1.3",
"@lezer/lr": "^1.3.3",
"@radix-ui/react-dialog": "^1.0.2",
"@radix-ui/react-dropdown-menu": "^2.0.2",
"@radix-ui/react-icons": "^1.2.0",
"@radix-ui/react-popover": "1.0.3",
"@radix-ui/react-scroll-area": "^1.0.2",
"@radix-ui/react-separator": "^1.0.1",
"@radix-ui/react-tabs": "^1.0.3",
"@tanstack/react-query": "^4.24.10",
"@react-hook/resize-observer": "^1.2.6",
"@tailwindcss/container-queries": "^0.1.0",
"@tanstack/query-sync-storage-persister": "^4.27.1",
"@tanstack/react-query": "^4.28.0",
"@tanstack/react-query-devtools": "^4.28.0",
"@tanstack/react-query-persist-client": "^4.28.0",
"@tauri-apps/api": "^1.2.0",
"@vitejs/plugin-react": "^3.1.0",
"classnames": "^2.3.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.8.1",
"cm6-graphql": "^0.0.4-canary-b30a2325.0",
"codemirror": "^6.0.1",
"focus-trap-react": "^10.1.1",
"format-graphql": "^1.4.0",
"framer-motion": "^9.0.4",
"parse-color": "^1.0.0",
"react": "^18.2.0",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^18.2.0",
"react-helmet-async": "^1.3.0",
"react-use": "^17.4.0"
"react-router-dom": "^6.8.1",
"react-use": "^17.4.0",
"uuid": "^9.0.0"
},
"devDependencies": {
"@tailwindcss/nesting": "^0.0.0-insiders.565cd3e",
@@ -53,22 +56,25 @@
"@types/node": "^18.7.10",
"@types/parse-color": "^1.0.1",
"@types/parse-json": "^4.0.0",
"@types/react-dom": "^18.0.6",
"@typescript-eslint/eslint-plugin": "^5.52.0",
"@typescript-eslint/parser": "^5.52.0",
"@types/react": "^18.0.31",
"@types/react-dom": "^18.0.11",
"@types/uuid": "^9.0.1",
"@typescript-eslint/eslint-plugin": "^5.57.0",
"@typescript-eslint/parser": "^5.57.0",
"autoprefixer": "^10.4.13",
"concurrently": "^7.6.0",
"eslint": "^8.34.0",
"eslint-config-prettier": "^8.6.0",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-react": "^7.32.2",
"eslint-plugin-react-hooks": "^4.6.0",
"postcss": "^8.4.21",
"postcss-nesting": "^11.2.1",
"prettier": "^2.8.4",
"tailwindcss": "^3.2.7",
"typescript": "^4.6.4",
"typescript": "^5.0.2",
"vite": "^4.0.0",
"vite-plugin-svgr": "^2.4.0",
"vite-plugin-top-level-await": "^1.2.4",
"vitest": "^0.29.2"
}

1024
src-tauri/Cargo.lock generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -7,29 +7,31 @@ license = "MIT"
repository = "https://github.com/gschier/yaak-app"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[profile.release]
strip = true # Automatically strip symbols from the binary.
[build-dependencies]
tauri-build = { version = "1.2", features = [] }
[target.'cfg(target_os = "macos")'.dependencies]
objc = { version = "0.2.7" }
cocoa = { version = "0.24.1" }
objc = "0.2.7"
cocoa = "0.24.1"
[dependencies]
serde_json = { version = "1.0", features = ["raw_value"] }
serde = { version = "1.0", features = ["derive"] }
tauri = { version = "1.2", features = ["config-toml", "devtools", "shell-open", "system-tray", "updater", "window-start-dragging"] }
http = { version = "0.2.8" }
http = "0.2.8"
reqwest = { version = "0.11.14", features = ["json"] }
tokio = { version = "1.25.0", features = ["sync"] }
futures = { version = "0.3.26" }
deno_core = { version = "0.174.0" }
deno_ast = { version = "0.24.0", features = ["transpiling"] }
futures = "0.3.26"
deno_core = "0.178.0"
deno_ast = { version = "0.25.0", features = ["transpiling"] }
sqlx = { version = "0.6.2", features = ["sqlite", "runtime-tokio-rustls", "json", "chrono", "time", "offline"] }
uuid = { version = "1.3.0" }
rand = { version = "0.8.5" }
uuid = "1.3.0"
rand = "0.8.5"
chrono = { version = "0.4.23", features = ["serde"] }
base64 = "0.21.0"
[features]
# by default Tauri runs in production mode

View File

Binary file not shown.

View File

@@ -1,39 +1,65 @@
CREATE TABLE key_values
(
model TEXT DEFAULT 'key_value' NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
deleted_at DATETIME,
namespace TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
PRIMARY KEY (namespace, key)
);
CREATE TABLE workspaces
(
id TEXT NOT NULL PRIMARY KEY,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
id TEXT NOT NULL
PRIMARY KEY,
model TEXT DEFAULT 'workspace' NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
deleted_at DATETIME,
name TEXT NOT NULL,
description TEXT NOT NULL
name TEXT NOT NULL,
description TEXT NOT NULL
);
CREATE TABLE http_requests
(
id TEXT NOT NULL PRIMARY KEY,
workspace_id TEXT NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
id TEXT NOT NULL
PRIMARY KEY,
model TEXT DEFAULT 'http_request' NOT NULL,
workspace_id TEXT NOT NULL
REFERENCES workspaces
ON DELETE CASCADE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
deleted_at DATETIME,
name TEXT NOT NULL,
url TEXT NOT NULL,
method TEXT NOT NULL,
headers TEXT NOT NULL,
body TEXT
name TEXT NOT NULL,
url TEXT NOT NULL,
method TEXT NOT NULL,
headers TEXT NOT NULL,
body TEXT,
body_type TEXT
);
CREATE TABLE http_responses
(
id TEXT NOT NULL PRIMARY KEY,
request_id TEXT NOT NULL REFERENCES http_requests (id) ON DELETE CASCADE,
workspace_id TEXT NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
id TEXT NOT NULL
PRIMARY KEY,
model TEXT DEFAULT 'http_response' NOT NULL,
request_id TEXT NOT NULL
REFERENCES http_requests
ON DELETE CASCADE,
workspace_id TEXT NOT NULL
REFERENCES workspaces
ON DELETE CASCADE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
deleted_at DATETIME,
elapsed INTEGER NOT NULL,
status INTEGER NOT NULL,
elapsed INTEGER NOT NULL,
status INTEGER NOT NULL,
status_reason TEXT,
url TEXT NOT NULL,
body TEXT NOT NULL,
headers TEXT NOT NULL
url TEXT NOT NULL,
body TEXT NOT NULL,
headers TEXT NOT NULL,
error TEXT
);

View File

@@ -1 +0,0 @@
ALTER TABLE http_responses ADD COLUMN error TEXT NULL;

View File

@@ -0,0 +1 @@
ALTER TABLE main.http_requests ADD COLUMN sort_priority REAL NOT NULL DEFAULT 0;

View File

@@ -0,0 +1,2 @@
ALTER TABLE http_requests ADD COLUMN authentication TEXT NOT NULL DEFAULT '{}';
ALTER TABLE http_requests ADD COLUMN authentication_type TEXT;

View File

@@ -1,5 +1,53 @@
{
"db": "SQLite",
"06aaf8f4a17566f1d25da2a60f0baf4b5fc28c3cf0c001a84e25edf9eab3c7e3": {
"describe": {
"columns": [
{
"name": "model",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 1,
"type_info": "Datetime"
},
{
"name": "updated_at",
"ordinal": 2,
"type_info": "Datetime"
},
{
"name": "namespace",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "key",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "value",
"ordinal": 5,
"type_info": "Text"
}
],
"nullable": [
false,
false,
false,
false,
false,
false
],
"parameters": {
"Right": 2
}
},
"query": "\n SELECT model, created_at, updated_at, namespace, key, value\n FROM key_values\n WHERE namespace = ? AND key = ?\n "
},
"07d1a1c7b4f3d9625a766e60fd57bb779b71dae30e5bbce34885a911a5a42428": {
"describe": {
"columns": [],
@@ -20,77 +68,15 @@
},
"query": "\n DELETE FROM http_responses\n WHERE request_id = ?\n "
},
"3d2a542964d946ff9854d053b1adf04985d97a6de27b713188505c1f99c77707": {
"318ed5a1126fe00719393cf4e6c788ee5a265af88b7253f61a475f78c6774ef6": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "workspace_id",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 2,
"type_info": "Datetime"
},
{
"name": "updated_at",
"ordinal": 3,
"type_info": "Datetime"
},
{
"name": "deleted_at",
"ordinal": 4,
"type_info": "Datetime"
},
{
"name": "name",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "url",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "method",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "body",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "headers!: sqlx::types::Json<Vec<HttpRequestHeader>>",
"ordinal": 9,
"type_info": "Text"
}
],
"nullable": [
false,
false,
false,
false,
true,
false,
false,
false,
true,
false
],
"columns": [],
"nullable": [],
"parameters": {
"Right": 1
"Right": 9
}
},
"query": "\n SELECT id, workspace_id, created_at, updated_at, deleted_at, name, url, method, body,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpRequestHeader>>\"\n FROM http_requests\n WHERE workspace_id = ?\n "
"query": "\n INSERT INTO http_responses (\n id,\n request_id,\n workspace_id,\n elapsed,\n url,\n status,\n status_reason,\n body,\n headers\n )\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);\n "
},
"448a1d1f1866ab42c0f81fcf8eb2930bf21dfdd43ca4831bc1a198cf45ac3732": {
"describe": {
@@ -102,7 +88,7 @@
},
"query": "\n DELETE FROM http_requests\n WHERE id = ?\n "
},
"7ec60cbc3c9f26e8af86a21ef6b66e564f4fa518925c92308b04f882237a244e": {
"6f0cb5a6d1e8dbc8cdfcc3c7e7944b2c83c22cb795b9d6b98fe067dabec9680b": {
"describe": {
"columns": [
{
@@ -111,22 +97,22 @@
"type_info": "Text"
},
{
"name": "workspace_id",
"name": "model",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "created_at",
"name": "workspace_id",
"ordinal": 2,
"type_info": "Datetime"
"type_info": "Text"
},
{
"name": "updated_at",
"name": "created_at",
"ordinal": 3,
"type_info": "Datetime"
},
{
"name": "deleted_at",
"name": "updated_at",
"ordinal": 4,
"type_info": "Datetime"
},
@@ -151,95 +137,28 @@
"type_info": "Text"
},
{
"name": "headers!: sqlx::types::Json<Vec<HttpRequestHeader>>",
"name": "body_type",
"ordinal": 9,
"type_info": "Text"
}
],
"nullable": [
false,
false,
false,
false,
true,
false,
false,
false,
true,
false
],
"parameters": {
"Right": 1
}
},
"query": "\n SELECT id, workspace_id, created_at, updated_at, deleted_at, name, url, method, body,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpRequestHeader>>\"\n FROM http_requests\n WHERE id = ?\n ORDER BY created_at DESC\n "
},
"7f623d0e8f1ddad33d356e2d159b776a2bef1a238cb9200d74eb0c5e3983df85": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "workspace_id",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "request_id",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "updated_at",
"ordinal": 3,
"type_info": "Datetime"
},
{
"name": "deleted_at",
"ordinal": 4,
"type_info": "Datetime"
},
{
"name": "created_at",
"ordinal": 5,
"type_info": "Datetime"
},
{
"name": "status",
"ordinal": 6,
"type_info": "Int64"
},
{
"name": "status_reason",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "body",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "elapsed",
"ordinal": 9,
"type_info": "Int64"
},
{
"name": "url",
"name": "authentication!: Json<HashMap<String, JsonValue>>",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "error",
"name": "authentication_type",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "headers!: sqlx::types::Json<Vec<HttpResponseHeader>>",
"name": "sort_priority",
"ordinal": 12,
"type_info": "Float"
},
{
"name": "headers!: sqlx::types::Json<Vec<HttpRequestHeader>>",
"ordinal": 13,
"type_info": "Text"
}
],
@@ -248,79 +167,32 @@
false,
false,
false,
true,
false,
false,
true,
false,
false,
false,
true,
true,
false,
true,
false,
false
],
"parameters": {
"Right": 1
}
},
"query": "\n SELECT id, workspace_id, request_id, updated_at, deleted_at,\n created_at, status, status_reason, body, elapsed, url, error,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpResponseHeader>>\"\n FROM http_responses\n WHERE request_id = ?\n ORDER BY created_at ASC\n "
"query": "\n SELECT\n id,\n model,\n workspace_id,\n created_at,\n updated_at,\n name,\n url,\n method,\n body,\n body_type,\n authentication AS \"authentication!: Json<HashMap<String, JsonValue>>\",\n authentication_type,\n sort_priority,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpRequestHeader>>\"\n FROM http_requests\n WHERE workspace_id = ?\n "
},
"8069c0bd326f659faca7b45b03e5317d7339a168f4cd7776d9f84304bb7ae7ac": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 1,
"type_info": "Datetime"
},
{
"name": "updated_at",
"ordinal": 2,
"type_info": "Datetime"
},
{
"name": "deleted_at",
"ordinal": 3,
"type_info": "Datetime"
},
{
"name": "name",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "description",
"ordinal": 5,
"type_info": "Text"
}
],
"nullable": [
false,
false,
false,
true,
false,
false
],
"parameters": {
"Right": 0
}
},
"query": "\n SELECT id, created_at, updated_at, deleted_at, name, description\n FROM workspaces\n "
},
"a097740ea4ab772ec6f9d8a5144d6871e0b172130d5abe4da61e663155d2bf25": {
"84be2b954870ab181738656ecd4d03fca2ff21012947014c79626abfce8e999b": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 7
"Right": 1
}
},
"query": "\n INSERT INTO http_requests (id, workspace_id, name, url, method, body, headers, updated_at)\n VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)\n ON CONFLICT (id) DO UPDATE SET\n updated_at = CURRENT_TIMESTAMP,\n name = excluded.name,\n method = excluded.method,\n headers = excluded.headers,\n body = excluded.body,\n url = excluded.url\n "
"query": "\n DELETE FROM workspaces\n WHERE id = ?\n "
},
"a83698dcf9a815b881097133edb31a34ba25e7c6c114d463c495342a85371639": {
"describe": {
@@ -332,27 +204,17 @@
},
"query": "\n UPDATE http_responses SET (elapsed, url, status, status_reason, body, error, headers, updated_at) =\n (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) WHERE id = ?;\n "
},
"e767522f92c8c49cd2e563e58737a05092daf9b1dc763bacc82a5c14d696d78e": {
"b19c275180909a39342b13c3cdcf993781636913ae590967f5508c46a56dc961": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 9
"Right": 11
}
},
"query": "\n INSERT INTO http_responses (id, request_id, workspace_id, elapsed, url, status, status_reason, body, headers)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);\n "
"query": "\n INSERT INTO http_requests (\n id,\n workspace_id,\n name,\n url,\n method,\n body,\n body_type,\n authentication,\n authentication_type,\n headers,\n sort_priority\n )\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n ON CONFLICT (id) DO UPDATE SET\n updated_at = CURRENT_TIMESTAMP,\n name = excluded.name,\n method = excluded.method,\n headers = excluded.headers,\n body = excluded.body,\n body_type = excluded.body_type,\n authentication = excluded.authentication,\n authentication_type = excluded.authentication_type,\n url = excluded.url,\n sort_priority = excluded.sort_priority\n "
},
"f116d8cf9aad828135bb8c3a4c8b8e6b857ae13303989e9133a33b2d1cf20e96": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 3
}
},
"query": "\n INSERT INTO workspaces (id, name, description)\n VALUES (?, ?, ?)\n "
},
"fa5011146663ba5675764f33bf55a86b8274aa18737aff427fd1e3fb74ef3535": {
"caf3f21bf291dfbd36446592066e96c1f83abe96f6ea9211a3e049eb9c58a8c8": {
"describe": {
"columns": [
{
@@ -361,17 +223,17 @@
"type_info": "Text"
},
{
"name": "created_at",
"name": "model",
"ordinal": 1,
"type_info": "Datetime"
"type_info": "Text"
},
{
"name": "updated_at",
"name": "created_at",
"ordinal": 2,
"type_info": "Datetime"
},
{
"name": "deleted_at",
"name": "updated_at",
"ordinal": 3,
"type_info": "Datetime"
},
@@ -390,7 +252,7 @@
false,
false,
false,
true,
false,
false,
false
],
@@ -398,9 +260,9 @@
"Right": 1
}
},
"query": "\n SELECT id, created_at, updated_at, deleted_at, name, description\n FROM workspaces\n WHERE id = ?\n "
"query": "\n SELECT id, model, created_at, updated_at, name, description\n FROM workspaces WHERE id = ?\n "
},
"fb2c2328678bbdcb64b79ced26f3d7a1b08d315ef6dedfe4d5ae4231c861b079": {
"cea4cae52f16ec78aca9a47b17117422d4f165e5a3b308c70fd1a180382475ea": {
"describe": {
"columns": [
{
@@ -409,14 +271,14 @@
"type_info": "Text"
},
{
"name": "workspace_id",
"name": "model",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "request_id",
"name": "created_at",
"ordinal": 2,
"type_info": "Text"
"type_info": "Datetime"
},
{
"name": "updated_at",
@@ -424,7 +286,151 @@
"type_info": "Datetime"
},
{
"name": "deleted_at",
"name": "name",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "description",
"ordinal": 5,
"type_info": "Text"
}
],
"nullable": [
false,
false,
false,
false,
false,
false
],
"parameters": {
"Right": 0
}
},
"query": "\n SELECT id, model, created_at, updated_at, name, description\n FROM workspaces\n "
},
"ced098adb79c0ee64e223b6e02371ef253920a2c342275de0fa9c181529a4adc": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "model",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "workspace_id",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 3,
"type_info": "Datetime"
},
{
"name": "updated_at",
"ordinal": 4,
"type_info": "Datetime"
},
{
"name": "name",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "url",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "method",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "body",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "body_type",
"ordinal": 9,
"type_info": "Text"
},
{
"name": "authentication!: Json<HashMap<String, JsonValue>>",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "authentication_type",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "sort_priority",
"ordinal": 12,
"type_info": "Float"
},
{
"name": "headers!: sqlx::types::Json<Vec<HttpRequestHeader>>",
"ordinal": 13,
"type_info": "Text"
}
],
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false,
true,
true,
false,
true,
false,
false
],
"parameters": {
"Right": 1
}
},
"query": "\n SELECT\n id,\n model,\n workspace_id,\n created_at,\n updated_at,\n name,\n url,\n method,\n body,\n body_type,\n authentication AS \"authentication!: Json<HashMap<String, JsonValue>>\",\n authentication_type,\n sort_priority,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpRequestHeader>>\"\n FROM http_requests\n WHERE id = ?\n "
},
"d5ad6d5f82fe837fa9215bd4619ec18a7c95b3088d4fbf9825f2d1d28069d1ce": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "model",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "workspace_id",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "request_id",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "updated_at",
"ordinal": 4,
"type_info": "Datetime"
},
@@ -474,7 +480,7 @@
false,
false,
false,
true,
false,
false,
false,
true,
@@ -488,6 +494,116 @@
"Right": 1
}
},
"query": "\n SELECT id, workspace_id, request_id, updated_at, deleted_at, created_at,\n status, status_reason, body, elapsed, url, error,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpResponseHeader>>\"\n FROM http_responses\n WHERE id = ?\n "
"query": "\n SELECT id, model, workspace_id, request_id, updated_at,\n created_at, status, status_reason, body, elapsed, url, error,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpResponseHeader>>\"\n FROM http_responses\n WHERE request_id = ?\n ORDER BY created_at ASC\n "
},
"d80c09497771e3641022e73ec6c6a87e73a551f88a948a5445d754922b82b50b": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 3
}
},
"query": "\n INSERT INTO key_values (namespace, key, value)\n VALUES (?, ?, ?) ON CONFLICT DO UPDATE SET\n updated_at = CURRENT_TIMESTAMP,\n value = excluded.value\n "
},
"e3ade0a69348d512e47e964bded9d7d890b92fdc1e01c6c22fa5e91f943639f2": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "model",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "workspace_id",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "request_id",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "updated_at",
"ordinal": 4,
"type_info": "Datetime"
},
{
"name": "created_at",
"ordinal": 5,
"type_info": "Datetime"
},
{
"name": "status",
"ordinal": 6,
"type_info": "Int64"
},
{
"name": "status_reason",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "body",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "elapsed",
"ordinal": 9,
"type_info": "Int64"
},
{
"name": "url",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "error",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "headers!: sqlx::types::Json<Vec<HttpResponseHeader>>",
"ordinal": 12,
"type_info": "Text"
}
],
"nullable": [
false,
false,
false,
false,
false,
false,
false,
true,
false,
false,
false,
true,
false
],
"parameters": {
"Right": 1
}
},
"query": "\n SELECT id, model, workspace_id, request_id, updated_at, created_at,\n status, status_reason, body, elapsed, url, error,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpResponseHeader>>\"\n FROM http_responses\n WHERE id = ?\n "
},
"f116d8cf9aad828135bb8c3a4c8b8e6b857ae13303989e9133a33b2d1cf20e96": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 3
}
},
"query": "\n INSERT INTO workspaces (id, name, description)\n VALUES (?, ?, ?)\n "
}
}

View File

@@ -1,6 +1,6 @@
#![cfg_attr(
all(not(debug_assertions), target_os = "windows"),
windows_subsystem = "windows"
all(not(debug_assertions), target_os = "windows"),
windows_subsystem = "windows"
)]
#[cfg(target_os = "macos")]
@@ -8,17 +8,20 @@ windows_subsystem = "windows"
extern crate objc;
use std::collections::HashMap;
use std::env::current_dir;
use std::fs::create_dir_all;
use http::header::{ACCEPT, HeaderName, USER_AGENT};
use base64::Engine;
use http::header::{HeaderName, ACCEPT, USER_AGENT};
use http::{HeaderMap, HeaderValue, Method};
use reqwest::redirect::Policy;
use serde::Serialize;
use sqlx::migrate::Migrator;
use sqlx::sqlite::SqlitePoolOptions;
use sqlx::types::Json;
use sqlx::{Pool, Sqlite};
use tauri::regex::Regex;
use tauri::{AppHandle, Menu, State, Submenu, Wry};
use tauri::{AppHandle, Menu, MenuItem, RunEvent, State, Submenu, TitleBarStyle, Window, Wry};
use tauri::{CustomMenuItem, Manager, SystemTray, SystemTrayEvent, SystemTrayMenu, WindowEvent};
use tokio::sync::Mutex;
@@ -49,32 +52,32 @@ async fn migrate_db(
.path_resolver()
.resolve_resource("migrations")
.expect("failed to resolve resource");
println!("Running migrations at {}", p.to_string_lossy());
let m = Migrator::new(p).await.expect("Failed to load migrations");
m.run(pool).await.expect("Failed to run migrations");
println!("Migrations ran");
println!("Migrations complete");
Ok(())
}
#[tauri::command]
async fn send_request(
async fn send_ephemeral_request(
request: models::HttpRequest,
app_handle: AppHandle<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
request_id: &str,
) -> Result<String, String> {
) -> Result<models::HttpResponse, String> {
let pool = &*db_instance.lock().await;
let response = models::HttpResponse::default();
return actually_send_ephemeral_request(request, &response, &app_handle, pool).await;
}
let req = models::get_request(request_id, pool)
.await
.expect("Failed to get request");
let mut response = models::create_response(&req.id, 0, "", 0, None, "", vec![], pool)
.await
.expect("Failed to create response");
app_handle.emit_all("updated_response", &response).unwrap();
async fn actually_send_ephemeral_request(
request: models::HttpRequest,
response: &models::HttpResponse,
app_handle: &AppHandle<Wry>,
pool: &Pool<Sqlite>,
) -> Result<models::HttpResponse, String> {
let start = std::time::Instant::now();
let mut url_string = req.url.to_string();
let mut url_string = request.url.to_string();
let mut variables = HashMap::new();
variables.insert("PROJECT_ID", "project_123");
@@ -97,6 +100,8 @@ async fn send_request(
url_string = format!("http://{}", url_string);
}
println!("Sending request to {}", url_string);
let client = reqwest::Client::builder()
.redirect(Policy::none())
.build()
@@ -106,10 +111,13 @@ async fn send_request(
headers.insert(USER_AGENT, HeaderValue::from_static("yaak"));
headers.insert(ACCEPT, HeaderValue::from_static("*/*"));
for h in req.headers.0 {
for h in request.headers.0 {
if h.name.is_empty() && h.value.is_empty() {
continue;
}
if !h.enabled {
continue;
}
let header_name = match HeaderName::from_bytes(h.name.as_bytes()) {
Ok(n) => n,
Err(e) => {
@@ -127,23 +135,58 @@ async fn send_request(
headers.insert(header_name, header_value);
}
let m =
Method::from_bytes(req.method.to_uppercase().as_bytes()).expect("Failed to create method");
if let Some(b) = &request.authentication_type {
let empty_value = &serde_json::to_value("").unwrap();
if b == "basic" {
let a = request.authentication.0;
let auth = format!(
"{}:{}",
a.get("username")
.unwrap_or(empty_value)
.as_str()
.unwrap_or(""),
a.get("password")
.unwrap_or(empty_value)
.as_str()
.unwrap_or(""),
);
let encoded = base64::engine::general_purpose::STANDARD_NO_PAD.encode(auth);
headers.insert(
"Authorization",
HeaderValue::from_str(&format!("Basic {}", encoded)).unwrap(),
);
} else if b == "bearer" {
let token = request
.authentication
.0
.get("token")
.unwrap_or(empty_value)
.as_str()
.unwrap_or("");
headers.insert(
"Authorization",
HeaderValue::from_str(&format!("Bearer {}", token)).unwrap(),
);
}
}
let m = Method::from_bytes(request.method.to_uppercase().as_bytes())
.expect("Failed to create method");
let builder = client.request(m, url_string.to_string()).headers(headers);
let sendable_req_result = match req.body {
Some(b) => builder.body(b).build(),
None => builder.build(),
let sendable_req_result = match (request.body, request.body_type) {
(Some(b), Some(_)) => builder.body(b).build(),
_ => builder.build(),
};
let sendable_req = match sendable_req_result {
Ok(r) => r,
Err(e) => {
return response_err(response, e.to_string(), app_handle, pool).await;
return response_err(response, e.to_string(), &app_handle, pool).await;
}
};
let resp = client.execute(sendable_req).await;
let raw_response = client.execute(sendable_req).await;
let p = app_handle
.path_resolver()
@@ -152,8 +195,9 @@ async fn send_request(
runtime::run_plugin_sync(p.to_str().unwrap()).unwrap();
match resp {
match raw_response {
Ok(v) => {
let mut response = response.clone();
response.status = v.status().as_u16() as i64;
response.status_reason = v.status().canonical_reason().map(|s| s.to_string());
response.headers = Json(
@@ -168,57 +212,153 @@ async fn send_request(
response.url = v.url().to_string();
response.body = v.text().await.expect("Failed to get body");
response.elapsed = start.elapsed().as_millis() as i64;
response = models::update_response(response, pool)
response = models::update_response_if_id(response, pool)
.await
.expect("Failed to update response");
app_handle.emit_all("updated_response", &response).unwrap();
Ok(response.id)
if request.id != "" {
emit_side_effect(app_handle, "updated_model", &response);
}
Ok(response)
}
Err(e) => response_err(response, e.to_string(), app_handle, pool).await,
}
}
#[tauri::command]
async fn send_request(
window: Window<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
request_id: &str,
) -> Result<models::HttpResponse, String> {
let pool = &*db_instance.lock().await;
let req = models::get_request(request_id, pool)
.await
.expect("Failed to get request");
let response = models::create_response(&req.id, 0, "", 0, None, "", vec![], pool)
.await
.expect("Failed to create response");
let response2 = response.clone();
let app_handle2 = window.app_handle().clone();
let pool2 = pool.clone();
tokio::spawn(async move {
actually_send_ephemeral_request(req, &response2, &app_handle2, &pool2)
.await
.expect("Failed to send request");
});
emit_and_return(&window, "created_model", response)
}
async fn response_err(
mut response: models::HttpResponse,
response: &models::HttpResponse,
error: String,
app_handle: AppHandle<Wry>,
app_handle: &AppHandle<Wry>,
pool: &Pool<Sqlite>,
) -> Result<String, String> {
) -> Result<models::HttpResponse, String> {
let mut response = response.clone();
response.error = Some(error.clone());
response = models::update_response(response, pool)
response = models::update_response_if_id(response, pool)
.await
.expect("Failed to update response");
app_handle.emit_all("updated_response", &response).unwrap();
Ok(response.id)
emit_side_effect(app_handle, "updated_model", &response);
Ok(response)
}
#[tauri::command]
async fn get_key_value(
namespace: &str,
key: &str,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<Option<models::KeyValue>, ()> {
let pool = &*db_instance.lock().await;
let result = models::get_key_value(namespace, key, pool).await;
Ok(result)
}
#[tauri::command]
async fn set_key_value(
namespace: &str,
key: &str,
value: &str,
window: Window<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<models::KeyValue, String> {
let pool = &*db_instance.lock().await;
let (key_value, created) = models::set_key_value(namespace, key, value, pool).await;
if created {
emit_and_return(&window, "created_model", key_value)
} else {
emit_and_return(&window, "updated_model", key_value)
}
}
#[tauri::command]
async fn create_workspace(
name: &str,
window: Window<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<models::Workspace, String> {
let pool = &*db_instance.lock().await;
let created_workspace = models::create_workspace(name, "", pool)
.await
.expect("Failed to create workspace");
emit_and_return(&window, "created_model", created_workspace)
}
#[tauri::command]
async fn create_request(
workspace_id: &str,
name: &str,
app_handle: AppHandle<Wry>,
sort_priority: f64,
window: Window<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<String, String> {
) -> Result<models::HttpRequest, String> {
let pool = &*db_instance.lock().await;
let headers = Vec::new();
let created_request =
models::upsert_request(None, workspace_id, name, "GET", None, "", headers, pool)
.await
.expect("Failed to create request");
let created_request = models::upsert_request(
None,
workspace_id,
name,
"GET",
None,
None,
HashMap::new(),
None,
"",
headers,
sort_priority,
pool,
)
.await
.expect("Failed to create request");
app_handle
.emit_all("updated_request", &created_request)
.unwrap();
emit_and_return(&window, "created_model", created_request)
}
Ok(created_request.id)
#[tauri::command]
async fn duplicate_request(
id: &str,
window: Window<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<models::HttpRequest, String> {
let pool = &*db_instance.lock().await;
let request = models::duplicate_request(id, pool)
.await
.expect("Failed to duplicate request");
emit_and_return(&window, "updated_model", request)
}
#[tauri::command]
async fn update_request(
request: models::HttpRequest,
app_handle: AppHandle<Wry>,
window: Window<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<(), String> {
) -> Result<models::HttpRequest, String> {
let pool = &*db_instance.lock().await;
// TODO: Figure out how to make this better
@@ -231,29 +371,30 @@ async fn update_request(
None => None,
};
// TODO: Figure out how to make this better
let updated_request = models::upsert_request(
Some(request.id.as_str()),
request.workspace_id.as_str(),
request.name.as_str(),
request.method.as_str(),
body,
request.body_type,
request.authentication.0,
request.authentication_type,
request.url.as_str(),
request.headers.0,
request.sort_priority,
pool,
)
.await
.expect("Failed to update request");
.await
.expect("Failed to update request");
app_handle
.emit_all("updated_request", updated_request)
.unwrap();
Ok(())
emit_and_return(&window, "updated_model", updated_request)
}
#[tauri::command]
async fn delete_request(
app_handle: AppHandle<Wry>,
window: Window<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
request_id: &str,
) -> Result<models::HttpRequest, String> {
@@ -261,9 +402,7 @@ async fn delete_request(
let req = models::delete_request(request_id, pool)
.await
.expect("Failed to delete request");
app_handle.emit_all("deleted_request", request_id).unwrap();
Ok(req)
emit_and_return(&window, "deleted_model", req)
}
#[tauri::command]
@@ -277,6 +416,17 @@ async fn requests(
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn get_request(
id: &str,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<models::HttpRequest, String> {
let pool = &*db_instance.lock().await;
models::get_request(id, pool)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn responses(
request_id: &str,
@@ -291,12 +441,14 @@ async fn responses(
#[tauri::command]
async fn delete_response(
id: &str,
window: Window<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<(), String> {
) -> Result<models::HttpResponse, String> {
let pool = &*db_instance.lock().await;
models::delete_response(id, pool)
let response = models::delete_response(id, pool)
.await
.map_err(|e| e.to_string())
.expect("Failed to delete response");
emit_and_return(&window, "deleted_model", response)
}
#[tauri::command]
@@ -319,15 +471,29 @@ async fn workspaces(
.await
.expect("Failed to find workspaces");
if workspaces.is_empty() {
let workspace = models::create_workspace("Default", "This is the default workspace", pool)
.await
.expect("Failed to create workspace");
let workspace =
models::create_workspace("My Project", "This is the default workspace", pool)
.await
.expect("Failed to create workspace");
Ok(vec![workspace])
} else {
Ok(workspaces)
}
}
#[tauri::command]
async fn delete_workspace(
window: Window<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
id: &str,
) -> Result<models::Workspace, String> {
let pool = &*db_instance.lock().await;
let workspace = models::delete_workspace(id, pool)
.await
.expect("Failed to delete workspace");
emit_and_return(&window, "deleted_model", workspace)
}
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
@@ -338,43 +504,32 @@ fn main() {
let tray_menu = SystemTrayMenu::new().add_item(quit);
let system_tray = SystemTray::new().with_menu(tray_menu);
let default_menu = Menu::os_default("Yaak".to_string().as_str());
let submenu = Submenu::new("Test Menu", Menu::new()
.add_item(CustomMenuItem::new("zoom_reset".to_string(), "Zoom to Actual Size").accelerator("CmdOrCtrl+0"))
.add_item(CustomMenuItem::new("zoom_in".to_string(), "Zoom In").accelerator("CmdOrCtrl+Plus"))
.add_item(CustomMenuItem::new("zoom_out".to_string(), "Zoom Out").accelerator("CmdOrCtrl+-")),
);
let menu = default_menu.add_submenu(submenu);
tauri::Builder::default()
.menu(menu)
.system_tray(system_tray)
.setup(|app| {
let win = app.get_window("main").unwrap();
let dir = match is_dev() {
true => current_dir().unwrap(),
false => app.path_resolver().app_data_dir().unwrap(),
};
#[cfg(target_os = "macos")]
win.position_traffic_lights();
Ok(())
})
.setup(|app| {
let dir = app.path_resolver().app_data_dir().unwrap();
create_dir_all(dir.clone()).expect("Problem creating App directory!");
let p = dir.join("db.sqlite");
let p_string = p.to_string_lossy().replace(' ', "%20");
let url = format!("sqlite://{}?mode=rwc", p_string);
println!("DB PATH: {}", p_string);
println!("Connecting to database at {}", url);
tauri::async_runtime::block_on(async move {
let pool = SqlitePoolOptions::new()
.connect(url.as_str())
.await
.expect("Failed to connect to database");
// Setup the DB handle
let m = Mutex::new(pool);
migrate_db(app.handle(), &m)
.await
.expect("Failed to migrate database");
app.manage(m);
Ok(())
})
})
@@ -392,42 +547,158 @@ fn main() {
};
}
})
.on_menu_event(|event| {
match event.menu_item_id() {
"quit" => std::process::exit(0),
"close" => event.window().close().unwrap(),
"zoom_reset" => event.window().emit("zoom", 0).unwrap(),
"zoom_in" => event.window().emit("zoom", 1).unwrap(),
"zoom_out" => event.window().emit("zoom", -1).unwrap(),
_ => {}
};
})
.on_window_event(|e| {
let apply_offset = || {
let win = e.window();
#[cfg(target_os = "macos")]
win.position_traffic_lights();
};
match e.event() {
WindowEvent::Resized(..) => apply_offset(),
WindowEvent::ThemeChanged(..) => apply_offset(),
_ => {}
}
})
.invoke_handler(tauri::generate_handler![
greet,
workspaces,
get_request,
requests,
send_request,
send_ephemeral_request,
duplicate_request,
create_request,
create_workspace,
delete_workspace,
update_request,
delete_request,
responses,
get_key_value,
set_key_value,
delete_response,
delete_all_responses,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
.build(tauri::generate_context!())
.expect("error while running tauri application")
.run(|app_handle, event| match event {
RunEvent::Ready => {
create_window(app_handle);
}
// ExitRequested { api, .. } => {
// }
_ => {}
});
}
fn is_dev() -> bool {
let env = option_env!("YAAK_ENV");
env.unwrap_or("production") != "production"
}
fn create_window(handle: &AppHandle<Wry>) -> Window<Wry> {
let default_menu = Menu::os_default("Yaak".to_string().as_str());
let mut test_menu = Menu::new()
.add_item(
CustomMenuItem::new("send_request".to_string(), "Send Request")
.accelerator("CmdOrCtrl+r"),
)
.add_item(
CustomMenuItem::new("zoom_reset".to_string(), "Zoom to Actual Size")
.accelerator("CmdOrCtrl+0"),
)
.add_item(
CustomMenuItem::new("zoom_in".to_string(), "Zoom In").accelerator("CmdOrCtrl+Plus"),
)
.add_item(
CustomMenuItem::new("zoom_out".to_string(), "Zoom Out").accelerator("CmdOrCtrl+-"),
)
.add_item(
CustomMenuItem::new("toggle_sidebar".to_string(), "Toggle Sidebar")
.accelerator("CmdOrCtrl+b"),
)
.add_item(
CustomMenuItem::new("focus_url".to_string(), "Focus URL").accelerator("CmdOrCtrl+l"),
)
.add_item(CustomMenuItem::new("new_window".to_string(), "New Window"));
if is_dev() {
test_menu = test_menu
.add_native_item(MenuItem::Separator)
.add_item(
CustomMenuItem::new("refresh".to_string(), "Refresh")
.accelerator("CmdOrCtrl + Shift + r"),
)
.add_item(
CustomMenuItem::new("toggle_devtools".to_string(), "Open Devtools")
.accelerator("CmdOrCtrl + Option + i"),
);
}
let submenu = Submenu::new("Test Menu", test_menu);
let window_num = handle.windows().len();
let window_id = format!("wnd_{}", window_num);
let menu = default_menu.add_submenu(submenu);
let win = tauri::WindowBuilder::new(handle, window_id, tauri::WindowUrl::App("".into()))
.menu(menu)
.fullscreen(false)
.resizable(true)
.inner_size(1100.0, 600.0)
.hidden_title(true)
.title(match is_dev() {
true => "Yaak Dev",
false => "Yaak",
})
.title_bar_style(TitleBarStyle::Overlay)
.build()
.expect("failed to build window");
let win2 = win.clone();
let handle2 = handle.clone();
win.on_menu_event(move |event| match event.menu_item_id() {
"quit" => std::process::exit(0),
"close" => win2.close().unwrap(),
"zoom_reset" => win2.emit("zoom", 0).unwrap(),
"zoom_in" => win2.emit("zoom", 1).unwrap(),
"zoom_out" => win2.emit("zoom", -1).unwrap(),
"toggle_sidebar" => win2.emit("toggle_sidebar", true).unwrap(),
"focus_url" => win2.emit("focus_url", true).unwrap(),
"send_request" => win2.emit("send_request", true).unwrap(),
"refresh" => win2.eval("location.reload()").unwrap(),
"new_window" => _ = create_window(&handle2),
"toggle_devtools" => {
if win2.is_devtools_open() {
win2.close_devtools();
} else {
win2.open_devtools();
}
}
_ => {}
});
let win3 = win.clone();
win.on_window_event(move |e| {
let apply_offset = || {
#[cfg(target_os = "macos")]
win3.position_traffic_lights();
};
match e {
WindowEvent::Resized(..) => apply_offset(),
WindowEvent::ThemeChanged(..) => apply_offset(),
WindowEvent::CloseRequested { .. } => {
println!("CLOSE REQUESTED");
// api.prevent_close();
}
_ => {}
}
});
#[cfg(target_os = "macos")]
win.position_traffic_lights();
win
}
/// Emit an event to all windows, with a source window
fn emit_and_return<S: Serialize + Clone, E>(
current_window: &Window<Wry>,
event: &str,
payload: S,
) -> Result<S, E> {
current_window.emit_all(event, &payload).unwrap();
Ok(payload)
}
/// Emit an event to all windows, used for side-effects where there is no source window to attribute. This
fn emit_side_effect<S: Serialize + Clone>(app_handle: &AppHandle<Wry>, event: &str, payload: S) {
app_handle.emit_all(event, &payload).unwrap();
}

View File

@@ -1,16 +1,18 @@
use std::collections::HashMap;
use rand::distributions::{Alphanumeric, DistString};
use serde::{Deserialize, Serialize};
use sqlx::types::chrono::NaiveDateTime;
use sqlx::types::Json;
use sqlx::types::{Json, JsonValue};
use sqlx::{Pool, Sqlite};
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Workspace {
pub id: String,
pub model: String,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
pub deleted_at: Option<NaiveDateTime>,
pub name: String,
pub description: String,
}
@@ -18,6 +20,8 @@ pub struct Workspace {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HttpRequestHeader {
#[serde(default)]
pub enabled: bool,
pub name: String,
pub value: String,
}
@@ -26,14 +30,18 @@ pub struct HttpRequestHeader {
#[serde(rename_all = "camelCase")]
pub struct HttpRequest {
pub id: String,
pub model: String,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
pub deleted_at: Option<NaiveDateTime>,
pub sort_priority: f64,
pub workspace_id: String,
pub name: String,
pub url: String,
pub method: String,
pub body: Option<String>,
pub body_type: Option<String>,
pub authentication: Json<HashMap<String, JsonValue>>,
pub authentication_type: Option<String>,
pub headers: Json<Vec<HttpRequestHeader>>,
}
@@ -44,15 +52,15 @@ pub struct HttpResponseHeader {
pub value: String,
}
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize)]
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct HttpResponse {
pub id: String,
pub model: String,
pub workspace_id: String,
pub request_id: String,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
pub deleted_at: Option<NaiveDateTime>,
pub error: Option<String>,
pub url: String,
pub elapsed: i64,
@@ -62,11 +70,66 @@ pub struct HttpResponse {
pub headers: Json<Vec<HttpResponseHeader>>,
}
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct KeyValue {
pub model: String,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
pub namespace: String,
pub key: String,
pub value: String,
}
pub async fn set_key_value(
namespace: &str,
key: &str,
value: &str,
pool: &Pool<Sqlite>,
) -> (KeyValue, bool) {
let existing = get_key_value(namespace, key, pool).await;
sqlx::query!(
r#"
INSERT INTO key_values (namespace, key, value)
VALUES (?, ?, ?) ON CONFLICT DO UPDATE SET
updated_at = CURRENT_TIMESTAMP,
value = excluded.value
"#,
namespace,
key,
value,
)
.execute(pool)
.await
.expect("Failed to insert key value");
let kv = get_key_value(namespace, key, pool)
.await
.expect("Failed to get key value");
return (kv, existing.is_none());
}
pub async fn get_key_value(namespace: &str, key: &str, pool: &Pool<Sqlite>) -> Option<KeyValue> {
sqlx::query_as!(
KeyValue,
r#"
SELECT model, created_at, updated_at, namespace, key, value
FROM key_values
WHERE namespace = ? AND key = ?
"#,
namespace,
key,
)
.fetch_one(pool)
.await
.ok()
}
pub async fn find_workspaces(pool: &Pool<Sqlite>) -> Result<Vec<Workspace>, sqlx::Error> {
sqlx::query_as!(
Workspace,
r#"
SELECT id, created_at, updated_at, deleted_at, name, description
SELECT id, model, created_at, updated_at, name, description
FROM workspaces
"#,
)
@@ -78,9 +141,8 @@ pub async fn get_workspace(id: &str, pool: &Pool<Sqlite>) -> Result<Workspace, s
sqlx::query_as!(
Workspace,
r#"
SELECT id, created_at, updated_at, deleted_at, name, description
FROM workspaces
WHERE id = ?
SELECT id, model, created_at, updated_at, name, description
FROM workspaces WHERE id = ?
"#,
id,
)
@@ -88,6 +150,22 @@ pub async fn get_workspace(id: &str, pool: &Pool<Sqlite>) -> Result<Workspace, s
.await
}
pub async fn delete_workspace(id: &str, pool: &Pool<Sqlite>) -> Result<Workspace, sqlx::Error> {
let workspace = get_workspace(id, pool)
.await
.expect("Failed to get request to delete");
let _ = sqlx::query!(
r#"
DELETE FROM workspaces
WHERE id = ?
"#,
id,
)
.execute(pool)
.await;
Ok(workspace)
}
pub async fn create_workspace(
name: &str,
description: &str,
@@ -110,14 +188,50 @@ pub async fn create_workspace(
get_workspace(&id, pool).await
}
pub async fn duplicate_request(id: &str, pool: &Pool<Sqlite>) -> Result<HttpRequest, sqlx::Error> {
let existing = get_request(id, pool)
.await
.expect("Failed to get request to duplicate");
// TODO: Figure out how to make this better
let b2;
let body = match existing.body {
Some(b) => {
b2 = b;
Some(b2.as_str())
}
None => None,
};
upsert_request(
None,
existing.workspace_id.as_str(),
existing.name.as_str(),
existing.method.as_str(),
body,
existing.body_type,
existing.authentication.0,
existing.authentication_type,
existing.url.as_str(),
existing.headers.0,
existing.sort_priority + 0.001,
pool,
)
.await
}
pub async fn upsert_request(
id: Option<&str>,
workspace_id: &str,
name: &str,
method: &str,
body: Option<&str>,
body_type: Option<String>,
authentication: HashMap<String, JsonValue>,
authentication_type: Option<String>,
url: &str,
headers: Vec<HttpRequestHeader>,
sort_priority: f64,
pool: &Pool<Sqlite>,
) -> Result<HttpRequest, sqlx::Error> {
let generated_id;
@@ -129,17 +243,34 @@ pub async fn upsert_request(
}
};
let headers_json = Json(headers);
let auth_json = Json(authentication);
sqlx::query!(
r#"
INSERT INTO http_requests (id, workspace_id, name, url, method, body, headers, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
INSERT INTO http_requests (
id,
workspace_id,
name,
url,
method,
body,
body_type,
authentication,
authentication_type,
headers,
sort_priority
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET
updated_at = CURRENT_TIMESTAMP,
name = excluded.name,
method = excluded.method,
headers = excluded.headers,
body = excluded.body,
url = excluded.url
body_type = excluded.body_type,
authentication = excluded.authentication,
authentication_type = excluded.authentication_type,
url = excluded.url,
sort_priority = excluded.sort_priority
"#,
id,
workspace_id,
@@ -147,7 +278,11 @@ pub async fn upsert_request(
url,
method,
body,
body_type,
auth_json,
authentication_type,
headers_json,
sort_priority,
)
.execute(pool)
.await
@@ -162,7 +297,20 @@ pub async fn find_requests(
sqlx::query_as!(
HttpRequest,
r#"
SELECT id, workspace_id, created_at, updated_at, deleted_at, name, url, method, body,
SELECT
id,
model,
workspace_id,
created_at,
updated_at,
name,
url,
method,
body,
body_type,
authentication AS "authentication!: Json<HashMap<String, JsonValue>>",
authentication_type,
sort_priority,
headers AS "headers!: sqlx::types::Json<Vec<HttpRequestHeader>>"
FROM http_requests
WHERE workspace_id = ?
@@ -177,11 +325,23 @@ pub async fn get_request(id: &str, pool: &Pool<Sqlite>) -> Result<HttpRequest, s
sqlx::query_as!(
HttpRequest,
r#"
SELECT id, workspace_id, created_at, updated_at, deleted_at, name, url, method, body,
SELECT
id,
model,
workspace_id,
created_at,
updated_at,
name,
url,
method,
body,
body_type,
authentication AS "authentication!: Json<HashMap<String, JsonValue>>",
authentication_type,
sort_priority,
headers AS "headers!: sqlx::types::Json<Vec<HttpRequestHeader>>"
FROM http_requests
WHERE id = ?
ORDER BY created_at DESC
"#,
id,
)
@@ -223,7 +383,17 @@ pub async fn create_response(
let headers_json = Json(headers);
sqlx::query!(
r#"
INSERT INTO http_responses (id, request_id, workspace_id, elapsed, url, status, status_reason, body, headers)
INSERT INTO http_responses (
id,
request_id,
workspace_id,
elapsed,
url,
status,
status_reason,
body,
headers
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);
"#,
id,
@@ -243,6 +413,16 @@ pub async fn create_response(
get_response(&id, pool).await
}
pub async fn update_response_if_id(
response: HttpResponse,
pool: &Pool<Sqlite>,
) -> Result<HttpResponse, sqlx::Error> {
if response.id == "" {
return Ok(response);
}
return update_response(response, pool).await;
}
pub async fn update_response(
response: HttpResponse,
pool: &Pool<Sqlite>,
@@ -272,7 +452,7 @@ pub async fn get_response(id: &str, pool: &Pool<Sqlite>) -> Result<HttpResponse,
sqlx::query_as_unchecked!(
HttpResponse,
r#"
SELECT id, workspace_id, request_id, updated_at, deleted_at, created_at,
SELECT id, model, workspace_id, request_id, updated_at, created_at,
status, status_reason, body, elapsed, url, error,
headers AS "headers!: sqlx::types::Json<Vec<HttpResponseHeader>>"
FROM http_responses
@@ -291,7 +471,7 @@ pub async fn find_responses(
sqlx::query_as!(
HttpResponse,
r#"
SELECT id, workspace_id, request_id, updated_at, deleted_at,
SELECT id, model, workspace_id, request_id, updated_at,
created_at, status, status_reason, body, elapsed, url, error,
headers AS "headers!: sqlx::types::Json<Vec<HttpResponseHeader>>"
FROM http_responses
@@ -304,7 +484,11 @@ pub async fn find_responses(
.await
}
pub async fn delete_response(id: &str, pool: &Pool<Sqlite>) -> Result<(), sqlx::Error> {
pub async fn delete_response(id: &str, pool: &Pool<Sqlite>) -> Result<HttpResponse, sqlx::Error> {
let resp = get_response(id, pool)
.await
.expect("Failed to get response to delete");
let _ = sqlx::query!(
r#"
DELETE FROM http_responses
@@ -315,7 +499,7 @@ pub async fn delete_response(id: &str, pool: &Pool<Sqlite>) -> Result<(), sqlx::
.execute(pool)
.await;
Ok(())
Ok(resp)
}
pub async fn delete_all_responses(

View File

@@ -1,9 +1,9 @@
use deno_ast::{MediaType, ParseParams, SourceTextInfo};
use deno_core::error::AnyError;
use deno_core::{op, Extension, JsRuntime, ModuleSource, ModuleType, RuntimeOptions};
use std::rc::Rc;
use deno_ast::{MediaType, ParseParams, SourceTextInfo};
use deno_core::error::AnyError;
use deno_core::futures::FutureExt;
use deno_core::{op, Extension, JsRuntime, ModuleCode, ModuleSource, ModuleType, RuntimeOptions};
use futures::executor;
pub fn run_plugin_sync(file_path: &str) -> Result<(), AnyError> {
@@ -26,7 +26,9 @@ pub async fn run_plugin(file_path: &str) -> Result<(), AnyError> {
.execute_script("<runtime>", include_str!("runtime.js"))
.expect("Failed to execute runtime.js");
let main_module = deno_core::resolve_path(file_path).expect("Failed to resolve path");
let current_dir = &std::env::current_dir().expect("Unable to get CWD");
let main_module =
deno_core::resolve_path(file_path, current_dir).expect("Failed to resolve path");
let mod_id = runtime
.load_main_module(&main_module, None)
.await
@@ -66,12 +68,14 @@ impl deno_core::ModuleLoader for TsModuleLoader {
) -> std::pin::Pin<Box<deno_core::ModuleSourceFuture>> {
let module_specifier = module_specifier.clone();
async move {
let path = module_specifier.to_file_path().unwrap();
let path = module_specifier
.to_file_path()
.expect("Failed to convert to file path");
// Determine what the MediaType is (this is done based on the file
// extension) and whether transpiling is required.
let media_type = MediaType::from(&path);
let (module_type, should_transpile) = match MediaType::from(&path) {
let media_type = MediaType::from_path(&path);
let (module_type, should_transpile) = match media_type {
MediaType::JavaScript | MediaType::Mjs | MediaType::Cjs => {
(ModuleType::JavaScript, false)
}
@@ -105,7 +109,7 @@ impl deno_core::ModuleLoader for TsModuleLoader {
// Load and return module.
let module = ModuleSource {
code: code.into_bytes().into_boxed_slice(),
code: ModuleCode::from(code),
module_type,
module_url_specified: module_specifier.to_string(),
module_url_found: module_specifier.to_string(),

View File

@@ -1,7 +1,7 @@
use tauri::{Runtime, Window};
const TRAFFIC_LIGHT_OFFSET_X: f64 = 15.0;
const TRAFFIC_LIGHT_OFFSET_Y: f64 = 22.0;
const TRAFFIC_LIGHT_OFFSET_X: f64 = 10.0;
const TRAFFIC_LIGHT_OFFSET_Y: f64 = 18.0;
pub trait WindowExt {
fn position_traffic_lights(&self);

View File

@@ -8,20 +8,10 @@
},
"package": {
"productName": "Yaak",
"version": "0.0.2"
"version": "2023.0.4"
},
"tauri": {
"windows": [
{
"fullscreen": false,
"height": 800,
"hiddenTitle": true,
"resizable": true,
"title": "Yaak",
"titleBarStyle": "Overlay",
"width": 1400
}
],
"windows": [],
"allowlist": {
"all": false,
"fs": {
@@ -78,7 +68,7 @@
"active": true,
"dialog": true,
"endpoints": [
"https://update.yaak.app/check/{{target}}/{{current_version}}"
"https://update.yaak.app/check/{{target}}/{{arch}}/{{current_version}}"
],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEMxRDJFREQ1MjExQjdGN0IKUldSN2Z4c2gxZTNTd1FHNCtmYnFXMHVVQzhuNkJOM1cwOFBodmdLall3ckhKenpKUytHSTR1MlkK"
}

View File

Binary file not shown.

View File

@@ -0,0 +1,5 @@
<svg width="100%" height="100%" viewBox="0 0 15 15" version="1.1" xmlns="http://www.w3.org/2000/svg"
style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path fill="currentColor"
d="M2.5,1C1.672,1 1,1.672 1,2.5L1,12.5C1,13.328 1.672,14 2.5,14L12.5,14C13.328,14 14,13.328 14,12.5L14,2.5C14,1.672 13.328,1 12.5,1L2.5,1ZM12.5,13C12.776,13 13,12.776 13,12.5L13,2.5C13,2.224 12.776,2 12.5,2L6,2L6,13L12.5,13ZM2.5,2L5,2L5,13L2.5,13C2.224,13 2,12.776 2,12.5L2,2.5C2,2.224 2.224,2 2.5,2Z"/>
</svg>

After

Width:  |  Height:  |  Size: 553 B

View File

@@ -0,0 +1,6 @@
<svg width="100%" height="100%" viewBox="0 0 15 15" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<rect x="0" y="0" width="15" height="15" style="fill:none;"/>
<g transform="matrix(1,0,0,1,-16,-8.88178e-16)">
<path fill="currentColor" d="M18.5,1C17.672,1 17,1.672 17,2.5L17,12.5C17,13.328 17.672,14 18.5,14L28.5,14C29.328,14 30,13.328 30,12.5L30,2.5C30,1.672 29.328,1 28.5,1L18.5,1ZM28.5,13C28.776,13 29,12.776 29,12.5L29,2.5C29,2.224 28.776,2 28.5,2L22,2L22,13L28.5,13ZM18,11.535L21,12.285L21,13L18.5,13C18.224,13 18,12.776 18,12.5L18,11.535ZM18,10.504L21,11.254L21,9.81L18,9.06L18,10.504ZM18,8.029L21,8.779L21,7.327L18,6.577L18,8.029ZM18,5.546L21,6.296L21,4.833L18,4.083L18,5.546ZM21,3.802L18,3.052L18,2.5C18,2.224 18.224,2 18.5,2L21,2L21,3.802Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1006 B

View File

@@ -1,79 +1,39 @@
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { listen } from '@tauri-apps/api/event';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { persistQueryClient } from '@tanstack/react-query-persist-client';
import { MotionConfig } from 'framer-motion';
import { Suspense } from 'react';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { HelmetProvider } from 'react-helmet-async';
import { AppRouter } from './AppRouter';
import { requestsQueryKey } from '../hooks/useRequests';
import { responsesQueryKey } from '../hooks/useResponses';
import { DEFAULT_FONT_SIZE } from '../lib/constants';
import type { HttpRequest, HttpResponse } from '../lib/models';
import { convertDates } from '../lib/models';
import { DialogProvider } from './DialogContext';
import { TauriListeners } from './TauriListeners';
const queryClient = new QueryClient();
const queryClient = new QueryClient({
logger: undefined,
defaultOptions: {
queries: {
retry: false,
cacheTime: 1000 * 60 * 60 * 24, // 24 hours
networkMode: 'offlineFirst',
await listen('updated_request', ({ payload: request }: { payload: HttpRequest }) => {
queryClient.setQueryData(
requestsQueryKey(request.workspaceId),
(requests: HttpRequest[] = []) => {
const newRequests = [];
let found = false;
for (const r of requests) {
if (r.id === request.id) {
found = true;
newRequests.push(convertDates(request));
} else {
newRequests.push(r);
}
}
if (!found) {
newRequests.push(convertDates(request));
}
return newRequests;
// It's a desktop app, so this isn't necessary
refetchOnWindowFocus: false,
},
);
},
});
await listen('deleted_request', ({ payload: request }: { payload: HttpRequest }) => {
queryClient.setQueryData(requestsQueryKey(request.workspaceId), (requests: HttpRequest[] = []) =>
requests.filter((r) => r.id !== request.id),
);
const localStoragePersister = createSyncStoragePersister({
storage: window.localStorage,
throttleTime: 1000, // 1 second
});
await listen('updated_response', ({ payload: response }: { payload: HttpResponse }) => {
queryClient.setQueryData(
responsesQueryKey(response.requestId),
(responses: HttpResponse[] = []) => {
const newResponses = [];
let found = false;
for (const r of responses) {
if (r.id === response.id) {
found = true;
newResponses.push(convertDates(response));
} else {
newResponses.push(r);
}
}
if (!found) {
newResponses.push(convertDates(response));
}
return newResponses;
},
);
});
await listen('zoom', ({ payload: zoomDelta }: { payload: number }) => {
const fontSize = parseFloat(window.getComputedStyle(document.documentElement).fontSize);
let newFontSize;
if (zoomDelta === 0) {
newFontSize = DEFAULT_FONT_SIZE;
} else if (zoomDelta > 0) {
newFontSize = Math.min(fontSize * 1.1, DEFAULT_FONT_SIZE * 5);
} else if (zoomDelta < 0) {
newFontSize = Math.max(fontSize * 0.9, DEFAULT_FONT_SIZE * 0.4);
}
document.documentElement.style.fontSize = `${newFontSize}px`;
persistQueryClient({
queryClient,
persister: localStoragePersister,
maxAge: 1000 * 60 * 60 * 24, // 24 hours
});
export function App() {
@@ -81,7 +41,15 @@ export function App() {
<QueryClientProvider client={queryClient}>
<MotionConfig transition={{ duration: 0.1 }}>
<HelmetProvider>
<AppRouter />
<DndProvider backend={HTML5Backend}>
<DialogProvider>
<Suspense>
<AppRouter />
<TauriListeners />
<ReactQueryDevtools initialIsOpen={false} />
</Suspense>
</DialogProvider>
</DndProvider>
</HelmetProvider>
</MotionConfig>
</QueryClientProvider>

View File

@@ -1,25 +1,40 @@
import { lazy, Suspense } from 'react';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
const Workspaces = lazy(() => import('./Workspaces'));
const Workspace = lazy(() => import('./Workspace'));
const RouteError = lazy(() => import('./RouteError'));
import { useEffect } from 'react';
import {
createBrowserRouter,
Navigate,
Outlet,
RouterProvider,
useLocation,
} from 'react-router-dom';
import { routePaths } from '../hooks/useRoutes';
import { setLastLocation } from '../lib/lastLocation';
import RouteError from './RouteError';
import Workspace from './Workspace';
import Workspaces from './Workspaces';
const router = createBrowserRouter([
{
path: '/',
errorElement: <RouteError />,
element: <RouterRoot />,
children: [
{
path: '/',
element: <Navigate to={routePaths.workspaces()} replace={true} />,
},
{
path: routePaths.workspaces(),
element: <Workspaces />,
},
{
path: '/workspaces/:workspaceId',
path: routePaths.workspace({ workspaceId: ':workspaceId' }),
element: <Workspace />,
},
{
path: '/workspaces/:workspaceId/requests/:requestId',
path: routePaths.request({
workspaceId: ':workspaceId',
requestId: ':requestId',
}),
element: <Workspace />,
},
],
@@ -27,9 +42,13 @@ const router = createBrowserRouter([
]);
export function AppRouter() {
return (
<Suspense>
<RouterProvider router={router} />
</Suspense>
);
return <RouterProvider router={router} />;
}
function RouterRoot() {
const { pathname } = useLocation();
useEffect(() => {
setLastLocation(pathname).catch(console.error);
}, [pathname]);
return <Outlet />;
}

View File

@@ -0,0 +1,43 @@
import { useUpdateRequest } from '../hooks/useUpdateRequest';
import type { HttpRequest } from '../lib/models';
import { Input } from './core/Input';
import { VStack } from './core/Stacks';
interface Props {
requestId: string;
authentication: HttpRequest['authentication'];
}
export function BasicAuth({ requestId, authentication }: Props) {
const updateRequest = useUpdateRequest(requestId);
return (
<VStack className="my-2" space={2}>
<Input
label="Username"
name="username"
size="sm"
defaultValue={`${authentication.username}`}
onChange={(username: string) => {
updateRequest.mutate((r) => ({
...r,
authentication: { password: r.authentication.password, username },
}));
}}
/>
<Input
label="Password"
name="password"
size="sm"
type="password"
defaultValue={`${authentication.password}`}
onChange={(password: string) => {
updateRequest.mutate((r) => ({
...r,
authentication: { username: r.authentication.username, password },
}));
}}
/>
</VStack>
);
}

View File

@@ -0,0 +1,30 @@
import { useUpdateRequest } from '../hooks/useUpdateRequest';
import type { HttpRequest } from '../lib/models';
import { Input } from './core/Input';
import { VStack } from './core/Stacks';
interface Props {
requestId: string;
authentication: HttpRequest['authentication'];
}
export function BearerAuth({ requestId, authentication }: Props) {
const updateRequest = useUpdateRequest(requestId);
return (
<VStack className="my-2" space={2}>
<Input
label="Token"
name="token"
size="sm"
defaultValue={`${authentication.token}`}
onChange={(token: string) => {
updateRequest.mutate((r) => ({
...r,
authentication: { token },
}));
}}
/>
</VStack>
);
}

View File

@@ -0,0 +1,57 @@
import React, { createContext, useContext, useMemo, useState } from 'react';
import type { DialogProps } from './core/Dialog';
import { Dialog } from './core/Dialog';
type DialogEntry = {
id: string;
render: ({ hide }: { hide: () => void }) => React.ReactNode;
} & Pick<DialogProps, 'title' | 'description' | 'hideX' | 'className' | 'size'>;
type DialogEntryOptionalId = Omit<DialogEntry, 'id'> & { id?: string };
interface State {
dialogs: DialogEntry[];
actions: Actions;
}
interface Actions {
show: (d: DialogEntryOptionalId) => void;
hide: (id: string) => void;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const DialogContext = createContext<State>({} as any);
export const DialogProvider = ({ children }: { children: React.ReactNode }) => {
const [dialogs, setDialogs] = useState<State['dialogs']>([]);
const actions = useMemo<Actions>(
() => ({
show: ({ id: oid, ...props }: DialogEntryOptionalId) => {
const id = oid ?? Math.random().toString(36).slice(2);
setDialogs((a) => [...a.filter((d) => d.id !== id), { id, ...props }]);
},
hide: (id: string) => {
setDialogs((a) => a.filter((d) => d.id !== id));
},
}),
[],
);
const state: State = {
dialogs,
actions,
};
return (
<DialogContext.Provider value={state}>
{children}
{dialogs.map(({ id, render, ...props }) => (
<Dialog open key={id} onClose={() => actions.hide(id)} {...props}>
{render({ hide: () => actions.hide(id) })}
</Dialog>
))}
</DialogContext.Provider>
);
};
export const useDialog = () => useContext(DialogContext).actions;

View File

@@ -0,0 +1,22 @@
import classnames from 'classnames';
import React, { memo } from 'react';
interface Props {
className?: string;
}
export const DropMarker = memo(
function DropMarker({ className }: Props) {
return (
<div
className={classnames(
className,
'relative w-full h-0 overflow-visible pointer-events-none',
)}
>
<div className="absolute z-50 left-2 right-2 -bottom-[0.1rem] h-[0.2rem] bg-blue-500/50 rounded-full" />
</div>
);
},
() => true,
);

View File

@@ -0,0 +1,13 @@
import type { ReactNode } from 'react';
interface Props {
children: ReactNode;
}
export function EmptyStateText({ children }: Props) {
return (
<div className="rounded-lg border border-dashed border-highlight h-full text-gray-400 flex items-center justify-center">
{children}
</div>
);
}

View File

@@ -0,0 +1,118 @@
import { updateSchema } from 'cm6-graphql';
import type { EditorView } from 'codemirror';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useIntrospectGraphQL } from '../hooks/useIntrospectGraphQL';
import type { HttpRequest } from '../lib/models';
import { Button } from './core/Button';
import type { EditorProps } from './core/Editor';
import { Editor, formatGraphQL } from './core/Editor';
import { Separator } from './core/Separator';
import { useDialog } from './DialogContext';
type Props = Pick<
EditorProps,
'heightMode' | 'onChange' | 'defaultValue' | 'className' | 'forceUpdateKey'
> & {
baseRequest: HttpRequest;
};
interface GraphQLBody {
query: string;
variables?: Record<string, string | number | boolean | null>;
operationName?: string;
}
export function GraphQLEditor({ defaultValue, onChange, baseRequest, ...extraEditorProps }: Props) {
const editorViewRef = useRef<EditorView>(null);
const introspection = useIntrospectGraphQL(baseRequest);
const { query, variables } = useMemo<GraphQLBody>(() => {
if (defaultValue === undefined) {
return { query: '', variables: {} };
}
try {
const p = JSON.parse(defaultValue ?? '{}');
const query = p.query ?? '';
const variables = p.variables;
const operationName = p.operationName;
return { query, variables, operationName };
} catch (err) {
return { query: 'failed to parse' };
}
}, [defaultValue]);
const handleChange = useCallback(
(b: GraphQLBody) => onChange?.(JSON.stringify(b, null, 2)),
[onChange],
);
const handleChangeQuery = useCallback(
(query: string) => handleChange({ query, variables }),
[handleChange, variables],
);
const handleChangeVariables = useCallback(
(variables: string) => {
try {
handleChange({ query, variables: JSON.parse(variables) });
} catch (e) {
// Meh, not much we can do here
}
},
[handleChange, query],
);
// Refetch the schema when the URL changes
useEffect(() => {
if (editorViewRef.current === null) return;
updateSchema(editorViewRef.current, introspection.data);
}, [introspection.data]);
const dialog = useDialog();
return (
<div className="pb-2 h-full grid grid-rows-[minmax(0,100%)_auto_auto_minmax(0,auto)]">
<Editor
contentType="application/graphql"
defaultValue={query ?? ''}
format={formatGraphQL}
heightMode="auto"
onChange={handleChangeQuery}
placeholder="..."
ref={editorViewRef}
actions={
(introspection.error || introspection.isLoading) && (
<Button
size="xs"
color={introspection.error ? 'danger' : 'gray'}
isLoading={introspection.isLoading}
onClick={() => {
dialog.show({
title: 'Introspection Failed',
size: 'sm',
render: () => (
<div className="whitespace-pre-wrap">{introspection.error?.message}</div>
),
});
}}
>
{introspection.error ? 'Introspection Failed' : 'Introspecting'}
</Button>
)
}
{...extraEditorProps}
/>
<Separator variant="primary" />
<p className="pt-1 text-gray-500 text-sm">Variables</p>
<Editor
contentType="application/json"
defaultValue={JSON.stringify(variables, null, 2)}
heightMode="auto"
onChange={handleChangeVariables}
placeholder="{}"
useTemplating
{...extraEditorProps}
/>
</div>
);
}

View File

@@ -1,117 +1,67 @@
import classnames from 'classnames';
import { useEffect, useState } from 'react';
import { useUpdateRequest } from '../hooks/useUpdateRequest';
import type { HttpHeader, HttpRequest } from '../lib/models';
import { IconButton } from './core/IconButton';
import { Input } from './core/Input';
import { VStack } from './core/Stacks';
import { charsets } from '../lib/data/charsets';
import { connections } from '../lib/data/connections';
import { encodings } from '../lib/data/encodings';
import { headerNames } from '../lib/data/headerNames';
import { mimeTypes } from '../lib/data/mimetypes';
import type { HttpRequest } from '../lib/models';
import type { GenericCompletionConfig } from './core/Editor/genericCompletion';
import type { PairEditorProps } from './core/PairEditor';
import { PairEditor } from './core/PairEditor';
interface Props {
request: HttpRequest;
className?: string;
}
type PairWithId = { header: Partial<HttpHeader>; id: string };
export function HeaderEditor({ request, className }: Props) {
const updateRequest = useUpdateRequest(request);
const saveHeaders = (pairs: PairWithId[]) => {
const headers = pairs.map((p) => ({ name: '', value: '', ...p.header }));
updateRequest.mutate({ headers });
};
const newPair = () => {
return { header: { name: '', value: '' }, id: Math.random().toString() };
};
const [pairs, setPairs] = useState<PairWithId[]>(
request.headers.map((h) => ({ header: h, id: Math.random().toString() })),
);
const setPairsAndSave = (fn: (pairs: PairWithId[]) => PairWithId[]) => {
setPairs((oldPairs) => {
const newPairs = fn(oldPairs);
saveHeaders(newPairs);
return newPairs;
});
};
const handleChangeHeader = (pair: PairWithId) => {
setPairsAndSave((pairs) =>
pairs.map((p) =>
pair.id !== p.id ? p : { id: p.id, header: { ...p.header, ...pair.header } },
),
);
};
useEffect(() => {
const lastPair = pairs[pairs.length - 1];
if (lastPair === undefined) {
setPairs([newPair()]);
return;
}
if (lastPair.header.name !== '' || lastPair.header.value !== '') {
setPairsAndSave((pairs) => [...pairs, newPair()]);
}
}, [pairs]);
const handleDelete = (pair: PairWithId) => {
setPairsAndSave((oldPairs) => oldPairs.filter((p) => p.id !== pair.id));
};
type Props = {
forceUpdateKey: string;
headers: HttpRequest['headers'];
onChange: (headers: HttpRequest['headers']) => void;
};
export function HeaderEditor({ headers, onChange, forceUpdateKey }: Props) {
return (
<div className={classnames(className, 'pb-6 grid')}>
<VStack space={2}>
{pairs.map((p, i) => (
<FormRow
key={p.id}
pair={p}
onChange={handleChangeHeader}
onDelete={i < pairs.length - 1 ? handleDelete : undefined}
/>
))}
</VStack>
</div>
<PairEditor
pairs={headers}
onChange={onChange}
forceUpdateKey={forceUpdateKey}
nameValidate={validateHttpHeader}
nameAutocomplete={nameAutocomplete}
valueAutocomplete={valueAutocomplete}
namePlaceholder="Header-Name"
/>
);
}
function FormRow({
pair,
onChange,
onDelete,
}: {
pair: PairWithId;
onChange: (pair: PairWithId) => void;
onDelete?: (pair: PairWithId) => void;
}) {
return (
<div className="group grid grid-cols-[1fr_1fr_2.5rem] grid-rows-1 gap-2 items-center">
<Input
hideLabel
useEditor={{ useTemplating: true }}
name="name"
label="Name"
placeholder="name"
defaultValue={pair.header.name}
onChange={(name) => onChange({ id: pair.id, header: { name } })}
/>
<Input
hideLabel
name="value"
label="Value"
useEditor={{ useTemplating: true }}
placeholder="value"
defaultValue={pair.header.value}
onChange={(value) => onChange({ id: pair.id, header: { value } })}
/>
{onDelete && (
<IconButton
icon="trash"
onClick={() => onDelete(pair)}
className="invisible group-hover:visible"
/>
)}
</div>
);
}
const MIN_MATCH = 3;
const headerOptionsMap: Record<string, string[]> = {
'content-type': mimeTypes,
accept: ['*/*', ...mimeTypes],
'accept-encoding': encodings,
connection: connections,
'accept-charset': charsets,
};
const valueAutocomplete = (headerName: string): GenericCompletionConfig | undefined => {
const name = headerName.toLowerCase().trim();
const options: GenericCompletionConfig['options'] =
headerOptionsMap[name]?.map((o) => ({
label: o,
type: 'constant',
boost: 1, // Put above other completions
})) ?? [];
return { minMatch: MIN_MATCH, options };
};
const nameAutocomplete: PairEditorProps['nameAutocomplete'] = {
minMatch: MIN_MATCH,
options: headerNames.map((t) => ({
label: t,
type: 'constant',
boost: 1, // Put above other completions
})),
};
const validateHttpHeader = (v: string) => {
if (v === '') {
return true;
}
return v.match(/^[a-zA-Z0-9-_]+$/) !== null;
};

View File

@@ -0,0 +1,46 @@
import classnames from 'classnames';
import FocusTrap from 'focus-trap-react';
import type { ReactNode } from 'react';
import { motion } from 'framer-motion';
import { Portal } from './Portal';
interface Props {
children: ReactNode;
portalName: string;
open: boolean;
onClose?: () => void;
zIndex?: keyof typeof zIndexes;
}
const zIndexes: Record<number, string> = {
10: 'z-10',
20: 'z-20',
30: 'z-30',
40: 'z-40',
50: 'z-50',
};
export function Overlay({ zIndex = 30, open, onClose, portalName, children }: Props) {
return (
<Portal name={portalName}>
{open && (
<FocusTrap>
<motion.div
className={classnames('fixed inset-0', zIndexes[zIndex])}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
>
<div
aria-hidden
onClick={onClose}
className="absolute inset-0 bg-gray-600/60 dark:bg-black/50"
/>
{/* Add region to still be able to drag the window */}
<div data-tauri-drag-region className="absolute top-0 left-0 right-0 h-md" />
{children}
</motion.div>
</FocusTrap>
)}
</Portal>
);
}

View File

@@ -0,0 +1,19 @@
import type { HttpRequest } from '../lib/models';
import { PairEditor } from './core/PairEditor';
type Props = {
forceUpdateKey: string;
parameters: { name: string; value: string }[];
onChange: (headers: HttpRequest['headers']) => void;
};
export function ParametersEditor({ parameters, forceUpdateKey, onChange }: Props) {
return (
<PairEditor
forceUpdateKey={forceUpdateKey}
pairs={parameters}
onChange={onChange}
namePlaceholder="name"
/>
);
}

View File

@@ -0,0 +1,12 @@
import type { ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { usePortal } from '../hooks/usePortal';
interface Props {
children: ReactNode;
name: string;
}
export function Portal({ children, name }: Props) {
const portal = usePortal(name);
return createPortal(children, portal);
}

View File

@@ -0,0 +1,52 @@
import type { HTMLAttributes, ReactElement } from 'react';
import { useConfirm } from '../hooks/useConfirm';
import { useDeleteRequest } from '../hooks/useDeleteRequest';
import { useDuplicateRequest } from '../hooks/useDuplicateRequest';
import { useRequest } from '../hooks/useRequest';
import { Dropdown } from './core/Dropdown';
import { Icon } from './core/Icon';
import { InlineCode } from './core/InlineCode';
interface Props {
requestId: string;
children: ReactElement<HTMLAttributes<HTMLButtonElement>>;
}
export function RequestActionsDropdown({ requestId, children }: Props) {
const request = useRequest(requestId ?? null);
const deleteRequest = useDeleteRequest(requestId ?? null);
const duplicateRequest = useDuplicateRequest({ id: requestId, navigateAfter: true });
const confirm = useConfirm();
return (
<Dropdown
items={[
{
label: 'Duplicate',
onSelect: duplicateRequest.mutate,
leftSlot: <Icon icon="copy" />,
},
{
label: 'Delete',
onSelect: async () => {
const confirmed = await confirm({
title: 'Delete Request',
variant: 'delete',
description: (
<>
Are you sure you want to delete <InlineCode>{request?.name}</InlineCode>?
</>
),
});
if (confirmed) {
deleteRequest.mutate();
}
},
leftSlot: <Icon icon="trash" />,
},
]}
>
{children}
</Dropdown>
);
}

View File

@@ -0,0 +1,28 @@
import { memo } from 'react';
import { Button } from './core/Button';
import { RadioDropdown } from './core/RadioDropdown';
type Props = {
method: string;
className?: string;
onChange: (method: string) => void;
};
const methodItems = ['GET', 'PUT', 'POST', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD'].map((m) => ({
value: m,
label: m,
}));
export const RequestMethodDropdown = memo(function RequestMethodDropdown({
method,
onChange,
className,
}: Props) {
return (
<RadioDropdown value={method} items={methodItems} onChange={onChange}>
<Button size="xs" className={className}>
{method.toUpperCase()}
</Button>
</RadioDropdown>
);
});

View File

@@ -1,64 +1,241 @@
import { invoke } from '@tauri-apps/api';
import { appWindow } from '@tauri-apps/api/window';
import classnames from 'classnames';
import type { CSSProperties } from 'react';
import { memo, useCallback, useMemo, useState } from 'react';
import { createGlobalState } from 'react-use';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useSendRequest } from '../hooks/useSendRequest';
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
import { useTauriEvent } from '../hooks/useTauriEvent';
import { useUpdateRequest } from '../hooks/useUpdateRequest';
import { tryFormatJson } from '../lib/formatters';
import type { HttpHeader, HttpRequest } from '../lib/models';
import {
AUTH_TYPE_BASIC,
AUTH_TYPE_BEARER,
AUTH_TYPE_NONE,
BODY_TYPE_GRAPHQL,
BODY_TYPE_JSON,
BODY_TYPE_NONE,
BODY_TYPE_XML,
} from '../lib/models';
import { BasicAuth } from './BasicAuth';
import { BearerAuth } from './BearerAuth';
import { CountBadge } from './core/CountBadge';
import { Editor } from './core/Editor';
import { HeaderEditor } from './HeaderEditor';
import type { TabItem } from './core/Tabs/Tabs';
import { TabContent, Tabs } from './core/Tabs/Tabs';
import { EmptyStateText } from './EmptyStateText';
import { GraphQLEditor } from './GraphQLEditor';
import { HeaderEditor } from './HeaderEditor';
import { ParametersEditor } from './ParameterEditor';
import { UrlBar } from './UrlBar';
interface Props {
style?: CSSProperties;
fullHeight: boolean;
className?: string;
}
export function RequestPane({ fullHeight, className }: Props) {
const activeRequest = useActiveRequest();
const updateRequest = useUpdateRequest(activeRequest);
const sendRequest = useSendRequest(activeRequest);
const useActiveTab = createGlobalState<string>('body');
if (activeRequest === null) return null;
export const RequestPane = memo(function RequestPane({ style, fullHeight, className }: Props) {
const activeRequest = useActiveRequest();
const activeRequestId = activeRequest?.id ?? null;
const updateRequest = useUpdateRequest(activeRequestId);
const [activeTab, setActiveTab] = useActiveTab();
const [forceUpdateHeaderEditorKey, setForceUpdateHeaderEditorKey] = useState<number>(0);
const { updateKey: forceUpdateKey } = useRequestUpdateKey(activeRequest?.id ?? null);
const tabs: TabItem[] = useMemo(
() =>
activeRequest === null
? []
: [
{
value: 'body',
options: {
value: activeRequest.bodyType,
items: [
{ label: 'JSON', value: BODY_TYPE_JSON },
{ label: 'XML', value: BODY_TYPE_XML },
{ label: 'GraphQL', value: BODY_TYPE_GRAPHQL },
{ type: 'separator' },
{ label: 'No Body', shortLabel: 'Body', value: BODY_TYPE_NONE },
],
onChange: async (bodyType) => {
const patch: Partial<HttpRequest> = { bodyType };
if (bodyType === BODY_TYPE_NONE) {
patch.headers = activeRequest?.headers.filter(
(h) => h.name.toLowerCase() !== 'content-type',
);
} else if (bodyType == BODY_TYPE_GRAPHQL || bodyType === BODY_TYPE_JSON) {
patch.method = 'POST';
patch.headers = [
...(activeRequest?.headers.filter(
(h) => h.name.toLowerCase() !== 'content-type',
) ?? []),
{
name: 'Content-Type',
value: 'application/json',
enabled: true,
},
];
}
// Force update header editor so any changed headers are reflected
setTimeout(() => setForceUpdateHeaderEditorKey((u) => u + 1), 100);
await updateRequest.mutate(patch);
},
},
},
// { value: 'params', label: 'URL Params' },
{
value: 'headers',
label: (
<div className="flex items-center">
Headers
<CountBadge count={activeRequest.headers.filter((h) => h.name).length} />
</div>
),
},
{
value: 'auth',
label: 'Auth',
options: {
value: activeRequest.authenticationType,
items: [
{ label: 'Basic Auth', shortLabel: 'Basic', value: AUTH_TYPE_BASIC },
{ label: 'Bearer Token', shortLabel: 'Bearer', value: AUTH_TYPE_BEARER },
{ type: 'separator' },
{ label: 'No Authentication', shortLabel: 'Auth', value: AUTH_TYPE_NONE },
],
onChange: async (authenticationType) => {
let authentication: HttpRequest['authentication'] = activeRequest?.authentication;
if (authenticationType === AUTH_TYPE_BASIC) {
authentication = {
username: authentication.username ?? '',
password: authentication.password ?? '',
};
} else if (authenticationType === AUTH_TYPE_BEARER) {
authentication = {
token: authentication.token ?? '',
};
}
await updateRequest.mutate({ authenticationType, authentication });
},
},
},
],
[activeRequest, updateRequest],
);
const handleBodyChange = useCallback(
(body: string) => updateRequest.mutate({ body }),
[updateRequest],
);
const handleHeadersChange = useCallback(
(headers: HttpHeader[]) => updateRequest.mutate({ headers }),
[updateRequest],
);
useTauriEvent(
'send_request',
async ({ windowLabel }) => {
if (windowLabel !== appWindow.label) return;
await invoke('send_request', { requestId: activeRequestId });
},
[activeRequestId],
);
return (
<div className={classnames(className, 'py-2 grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1')}>
<div className="pl-2">
<UrlBar
key={activeRequest.id}
method={activeRequest.method}
url={activeRequest.url}
loading={sendRequest.isLoading}
onMethodChange={(method) => updateRequest.mutate({ method })}
onUrlChange={(url) => updateRequest.mutate({ url })}
sendRequest={sendRequest.mutate}
/>
</div>
<Tabs
tabs={[
{ value: 'body', label: 'JSON' },
{ value: 'params', label: 'Params' },
{ value: 'headers', label: 'Headers' },
{ value: 'auth', label: 'Auth' },
]}
className="mt-2"
tabListClassName="px-2"
defaultValue="body"
label="Request body"
>
<TabContent value="headers" className="pl-2">
<HeaderEditor key={activeRequest.id} request={activeRequest} />
</TabContent>
<TabContent value="body">
<Editor
key={activeRequest.id}
className="!bg-gray-50"
heightMode={fullHeight ? 'full' : 'auto'}
useTemplating
defaultValue={activeRequest.body ?? ''}
contentType="application/graphql+json"
onChange={(body) => updateRequest.mutate({ body })}
/>
</TabContent>
</Tabs>
<div
style={style}
className={classnames(className, 'h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1')}
>
{activeRequest && (
<>
<UrlBar id={activeRequest.id} url={activeRequest.url} method={activeRequest.method} />
<Tabs
value={activeTab}
label="Request"
onChangeValue={setActiveTab}
tabs={tabs}
tabListClassName="mt-2"
>
<TabContent value="auth">
{activeRequest.authenticationType === AUTH_TYPE_BASIC ? (
<BasicAuth
key={forceUpdateKey}
requestId={activeRequest.id}
authentication={activeRequest.authentication}
/>
) : activeRequest.authenticationType === AUTH_TYPE_BEARER ? (
<BearerAuth
key={forceUpdateKey}
requestId={activeRequest.id}
authentication={activeRequest.authentication}
/>
) : (
<EmptyStateText>
No Authentication {activeRequest.authenticationType}
</EmptyStateText>
)}
</TabContent>
<TabContent value="headers">
<HeaderEditor
forceUpdateKey={`${forceUpdateHeaderEditorKey}::${forceUpdateKey}`}
headers={activeRequest.headers}
onChange={handleHeadersChange}
/>
</TabContent>
<TabContent value="params">
<ParametersEditor
forceUpdateKey={forceUpdateKey}
parameters={[]}
onChange={() => null}
/>
</TabContent>
<TabContent value="body">
{activeRequest.bodyType === BODY_TYPE_JSON ? (
<Editor
forceUpdateKey={forceUpdateKey}
useTemplating
placeholder="..."
className="!bg-gray-50"
heightMode={fullHeight ? 'full' : 'auto'}
defaultValue={activeRequest.body ?? ''}
contentType="application/json"
onChange={handleBodyChange}
format={(v) => tryFormatJson(v)}
/>
) : activeRequest.bodyType === BODY_TYPE_XML ? (
<Editor
forceUpdateKey={forceUpdateKey}
useTemplating
placeholder="..."
className="!bg-gray-50"
heightMode={fullHeight ? 'full' : 'auto'}
defaultValue={activeRequest.body ?? ''}
contentType="text/xml"
onChange={handleBodyChange}
/>
) : activeRequest.bodyType === BODY_TYPE_GRAPHQL ? (
<GraphQLEditor
forceUpdateKey={forceUpdateKey}
baseRequest={activeRequest}
className="!bg-gray-50"
defaultValue={activeRequest?.body ?? ''}
onChange={handleBodyChange}
/>
) : (
<EmptyStateText>No Body</EmptyStateText>
)}
</TabContent>
</Tabs>
</>
)}
</div>
);
}
});

View File

@@ -0,0 +1,131 @@
import useResizeObserver from '@react-hook/resize-observer';
import classnames from 'classnames';
import type { CSSProperties, MouseEvent as ReactMouseEvent } from 'react';
import React, { memo, useCallback, useMemo, useRef, useState } from 'react';
import { useKeyValue } from '../hooks/useKeyValue';
import { clamp } from '../lib/clamp';
import { RequestPane } from './RequestPane';
import { ResizeHandle } from './ResizeHandle';
import { ResponsePane } from './ResponsePane';
interface Props {
style: CSSProperties;
}
const rqst = { gridArea: 'rqst' };
const resp = { gridArea: 'resp' };
const drag = { gridArea: 'drag' };
const DEFAULT = 0.5;
const MIN_WIDTH_PX = 10;
const MIN_HEIGHT_PX = 30;
const STACK_VERTICAL_WIDTH = 650;
export const RequestResponse = memo(function RequestResponse({ style }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const [vertical, setVertical] = useState<boolean>(false);
const widthKv = useKeyValue<number>({ key: 'body_width', defaultValue: DEFAULT });
const heightKv = useKeyValue<number>({ key: 'body_height', defaultValue: DEFAULT });
const width = widthKv.value ?? DEFAULT;
const height = heightKv.value ?? DEFAULT;
const [isResizing, setIsResizing] = useState<boolean>(false);
const moveState = useRef<{ move: (e: MouseEvent) => void; up: (e: MouseEvent) => void } | null>(
null,
);
useResizeObserver(containerRef, ({ contentRect }) => {
setVertical(contentRect.width < STACK_VERTICAL_WIDTH);
});
const styles = useMemo<CSSProperties>(
() => ({
...style,
gridTemplate: vertical
? `
' ${rqst.gridArea}' minmax(0,${1 - height}fr)
' ${drag.gridArea}' 0
' ${resp.gridArea}' minmax(0,${height}fr)
/ 1fr
`
: `
' ${rqst.gridArea} ${drag.gridArea} ${resp.gridArea}' minmax(0,1fr)
/ ${1 - width}fr 0 ${width}fr
`,
}),
[vertical, width, height, style],
);
const unsub = () => {
if (moveState.current !== null) {
document.documentElement.removeEventListener('mousemove', moveState.current.move);
document.documentElement.removeEventListener('mouseup', moveState.current.up);
}
};
const handleReset = useCallback(
() => (vertical ? heightKv.set(DEFAULT) : widthKv.set(DEFAULT)),
[heightKv, vertical, widthKv],
);
const handleResizeStart = useCallback(
(e: ReactMouseEvent<HTMLDivElement>) => {
if (containerRef.current === null) return;
unsub();
const containerRect = containerRef.current.getBoundingClientRect();
const mouseStartX = e.clientX;
const mouseStartY = e.clientY;
const startWidth = containerRect.width * width;
const startHeight = containerRect.height * height;
moveState.current = {
move: (e: MouseEvent) => {
e.preventDefault(); // Prevent text selection and things
if (vertical) {
const maxHeightPx = containerRect.height - MIN_HEIGHT_PX;
const newHeightPx = clamp(
startHeight - (e.clientY - mouseStartY),
MIN_HEIGHT_PX,
maxHeightPx,
);
heightKv.set(newHeightPx / containerRect.height);
} else {
const maxWidthPx = containerRect.width - MIN_WIDTH_PX;
const newWidthPx = clamp(
startWidth - (e.clientX - mouseStartX),
MIN_WIDTH_PX,
maxWidthPx,
);
widthKv.set(newWidthPx / containerRect.width);
}
},
up: (e: MouseEvent) => {
e.preventDefault();
unsub();
setIsResizing(false);
},
};
document.documentElement.addEventListener('mousemove', moveState.current.move);
document.documentElement.addEventListener('mouseup', moveState.current.up);
setIsResizing(true);
},
[width, height, vertical, heightKv, widthKv],
);
return (
<div ref={containerRef} className="grid gap-1.5 w-full h-full p-3" style={styles}>
<RequestPane style={rqst} fullHeight={!vertical} />
<ResizeHandle
style={drag}
isResizing={isResizing}
className={classnames(vertical ? 'translate-y-0.5' : 'translate-x-0.5')}
onResizeStart={handleResizeStart}
onReset={handleReset}
side={vertical ? 'top' : 'left'}
justify="center"
/>
<ResponsePane style={resp} />
</div>
);
});

View File

@@ -0,0 +1,57 @@
import classnames from 'classnames';
import type { CSSProperties, MouseEvent as ReactMouseEvent } from 'react';
import React from 'react';
interface ResizeBarProps {
style?: CSSProperties;
className?: string;
barClassName?: string;
isResizing: boolean;
onResizeStart: (e: ReactMouseEvent<HTMLDivElement>) => void;
onReset?: () => void;
side: 'left' | 'right' | 'top';
justify: 'center' | 'end' | 'start';
}
export function ResizeHandle({
style,
justify,
className,
onResizeStart,
onReset,
isResizing,
side,
}: ResizeBarProps) {
const vertical = side === 'top';
return (
<div
aria-hidden
draggable
style={style}
className={classnames(
className,
'group z-10 flex cursor-ew-resize',
vertical ? 'w-full h-3 cursor-ns-resize' : 'h-full w-3 cursor-ew-resize',
justify === 'center' && 'justify-center',
justify === 'end' && 'justify-end',
justify === 'start' && 'justify-start',
side === 'right' && 'right-0',
side === 'left' && 'left-0',
side === 'top' && 'top-0',
)}
onDragStart={onResizeStart}
onDoubleClick={onReset}
>
{/* Show global overlay with cursor style to ensure cursor remains the same when moving quickly */}
{isResizing && (
<div
className={classnames(
'fixed -left-20 -right-20 -top-20 -bottom-20 cursor-ew-resize',
vertical && 'cursor-ns-resize',
!vertical && 'cursor-ew-resize',
)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,26 @@
import classnames from 'classnames';
import type { HttpResponse } from '../lib/models';
import { HStack } from './core/Stacks';
interface Props {
headers: HttpResponse['headers'];
}
export function ResponseHeaders({ headers }: Props) {
return (
<dl className="text-xs w-full font-mono">
{headers.map((h, i) => {
return (
<HStack
space={3}
key={i}
className={classnames(i > 0 && 'border-t border-highlightSecondary', 'py-1')}
>
<dd className="w-1/3 text-violet-600 select-text cursor-text">{h.name}</dd>
<dt className="w-2/3 select-text cursor-text break-all">{h.value}</dt>
</HStack>
);
})}
</dl>
);
}

View File

@@ -1,35 +1,49 @@
import classnames from 'classnames';
import type { CSSProperties } from 'react';
import { memo, useEffect, useMemo, useState } from 'react';
import { createGlobalState } from 'react-use';
import { useActiveRequestId } from '../hooks/useActiveRequestId';
import { useDeleteResponse } from '../hooks/useDeleteResponse';
import { useDeleteResponses } from '../hooks/useDeleteResponses';
import { useDeleteResponse } from '../hooks/useResponseDelete';
import { useResponses } from '../hooks/useResponses';
import { useResponseViewMode } from '../hooks/useResponseViewMode';
import { tryFormatJson } from '../lib/formatters';
import type { HttpResponse } from '../lib/models';
import { pluralize } from '../lib/pluralize';
import { Banner } from './core/Banner';
import { CountBadge } from './core/CountBadge';
import { Dropdown } from './core/Dropdown';
import { Editor } from './core/Editor';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { HStack } from './core/Stacks';
import { StatusColor } from './core/StatusColor';
import { TabContent, Tabs } from './core/Tabs/Tabs';
import { Webview } from './core/Webview';
import { EmptyStateText } from './EmptyStateText';
import { ResponseHeaders } from './ResponseHeaders';
interface Props {
style?: CSSProperties;
className?: string;
}
export const ResponsePane = memo(function ResponsePane({ className }: Props) {
const [activeResponseId, setActiveResponseId] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<'pretty' | 'raw'>('pretty');
const responses = useResponses();
const activeResponse: HttpResponse | null = activeResponseId
? responses.find((r) => r.id === activeResponseId) ?? null
: responses[responses.length - 1] ?? null;
const deleteResponse = useDeleteResponse(activeResponse);
const deleteAllResponses = useDeleteResponses(activeResponse?.requestId);
const useActiveTab = createGlobalState<string>('body');
useEffect(() => {
setActiveResponseId(null);
}, [responses.length]);
export const ResponsePane = memo(function ResponsePane({ style, className }: Props) {
const [pinnedResponseId, setPinnedResponseId] = useState<string | null>(null);
const activeRequestId = useActiveRequestId();
const responses = useResponses(activeRequestId);
const activeResponse: HttpResponse | null = pinnedResponseId
? responses.find((r) => r.id === pinnedResponseId) ?? null
: responses[responses.length - 1] ?? null;
const [viewMode, toggleViewMode] = useResponseViewMode(activeResponse?.requestId);
const deleteResponse = useDeleteResponse(activeResponse?.id ?? null);
const deleteAllResponses = useDeleteResponses(activeResponse?.requestId);
const [activeTab, setActiveTab] = useActiveTab();
// Unset pinned response when a new one comes in
useEffect(() => setPinnedResponseId(null), [responses.length]);
const contentType = useMemo(
() =>
@@ -38,28 +52,45 @@ export const ResponsePane = memo(function ResponsePane({ className }: Props) {
[activeResponse],
);
if (activeResponse === null) {
return null;
}
const tabs = useMemo(
() => [
{ label: 'Body', value: 'body' },
{
label: (
<div className="flex items-center">
Headers
<CountBadge
count={activeResponse?.headers.filter((h) => h.name && h.value).length ?? 0}
/>
</div>
),
value: 'headers',
},
],
[activeResponse?.headers],
);
return (
<div className="p-2">
<div
<div
style={style}
className={classnames(
className,
'bg-gray-50 max-h-full h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1',
'dark:bg-gray-100 rounded-md border border-highlight',
'shadow shadow-gray-100 dark:shadow-gray-0 relative',
)}
>
<HStack
alignItems="center"
className={classnames(
className,
'max-h-full h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1 ',
'dark:bg-gray-100 rounded-md overflow-hidden border border-gray-200',
'shadow shadow-gray-100 dark:shadow-gray-0',
'italic text-gray-700 text-sm w-full flex-shrink-0',
// Remove a bit of space because the tabs have lots too
'-mb-1.5',
)}
>
{/*<HStack as={WindowDragRegion} items="center" className="pl-1.5 pr-1">*/}
{/*</HStack>*/}
<HStack
alignItems="center"
className="italic text-gray-700 text-sm w-full mb-1 flex-shrink-0 pl-2"
>
{activeResponse && activeResponse.status > 0 && (
<div className="whitespace-nowrap">
{activeResponse && (
<>
<div className="whitespace-nowrap p-3 py-2">
<StatusColor statusCode={activeResponse.status}>
{activeResponse.status}
{activeResponse.statusReason && ` ${activeResponse.statusReason}`}
@@ -68,64 +99,87 @@ export const ResponsePane = memo(function ResponsePane({ className }: Props) {
{activeResponse.elapsed}ms &nbsp;&bull;&nbsp;
{Math.round(activeResponse.body.length / 1000)} KB
</div>
)}
<HStack alignItems="center" className="ml-auto h-8">
<IconButton
icon={viewMode === 'pretty' ? 'eye' : 'code'}
size="sm"
className="ml-1"
onClick={() => setViewMode((m) => (m === 'pretty' ? 'raw' : 'pretty'))}
/>
<Dropdown
items={[
{
label: viewMode === 'pretty' ? 'View Raw' : 'View Prettified',
onSelect: toggleViewMode,
},
{ type: 'separator', label: 'Actions' },
{
label: 'Clear Response',
onSelect: deleteResponse.mutate,
disabled: responses.length === 0,
},
{
label: 'Clear All Responses',
label: `Clear ${responses.length} ${pluralize('Response', responses.length)}`,
onSelect: deleteAllResponses.mutate,
hidden: responses.length <= 1,
disabled: responses.length === 0,
},
'-----',
{ type: 'separator', label: 'History' },
...responses.slice(0, 10).map((r) => ({
label: r.status + ' - ' + r.elapsed + ' ms',
leftSlot: activeResponse?.id === r.id ? <Icon icon="check" /> : <></>,
onSelect: () => setActiveResponseId(r.id),
onSelect: () => setPinnedResponseId(r.id),
})),
]}
>
<IconButton icon="clock" className="ml-auto" size="sm" />
<IconButton
title="Show response history"
icon="triangleDown"
className="ml-auto"
size="sm"
iconSize="md"
/>
</Dropdown>
</HStack>
</HStack>
</>
)}
</HStack>
{activeResponse?.error ? (
<div className="p-1">
<div className="text-white bg-red-500 px-3 py-2 rounded">{activeResponse.error}</div>
</div>
) : viewMode === 'pretty' && contentType.includes('html') ? (
<Webview body={activeResponse.body} contentType={contentType} url={activeResponse.url} />
) : viewMode === 'pretty' && contentType.includes('json') ? (
<Editor
readOnly
key={`${contentType}:${activeResponse.updatedAt}:pretty`}
className="bg-gray-50 dark:!bg-gray-100"
defaultValue={tryFormatJson(activeResponse?.body)}
contentType={contentType}
/>
) : activeResponse?.body ? (
<Editor
readOnly
key={`${contentType}:${activeResponse.updatedAt}`}
className="bg-gray-50 dark:!bg-gray-100"
defaultValue={activeResponse?.body}
contentType={contentType}
/>
) : null}
</div>
{activeResponse?.error ? (
<Banner className="m-2">{activeResponse.error}</Banner>
) : (
<Tabs
value={activeTab}
onChangeValue={setActiveTab}
label="Response"
className="px-3"
tabs={tabs}
>
<TabContent value="body">
{activeResponse === null ? (
<EmptyStateText>No Response</EmptyStateText>
) : viewMode === 'pretty' && contentType.includes('html') ? (
<Webview
body={activeResponse.body}
contentType={contentType}
url={activeResponse.url}
/>
) : viewMode === 'pretty' && contentType.includes('json') ? (
<Editor
readOnly
forceUpdateKey={`pretty::${activeResponse.updatedAt}`}
className="bg-gray-50 dark:!bg-gray-100"
defaultValue={tryFormatJson(activeResponse?.body)}
contentType={contentType}
/>
) : activeResponse?.body ? (
<Editor
readOnly
forceUpdateKey={activeResponse.updatedAt}
className="bg-gray-50 dark:!bg-gray-100"
defaultValue={activeResponse?.body}
contentType={contentType}
/>
) : null}
</TabContent>
<TabContent value="headers">
<ResponseHeaders headers={activeResponse?.headers ?? []} />
</TabContent>
</Tabs>
)}
</div>
);
});

View File

@@ -6,6 +6,7 @@ import { VStack } from './core/Stacks';
export default function RouteError() {
const error = useRouteError();
const stringified = JSON.stringify(error);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const message = (error as any).message ?? stringified;
return (
<div className="flex items-center justify-center h-full">

View File

@@ -1,118 +1,295 @@
import classnames from 'classnames';
import { useState } from 'react';
import type { ForwardedRef, KeyboardEvent } from 'react';
import React, { forwardRef, Fragment, memo, useCallback, useMemo, useRef, useState } from 'react';
import type { XYCoord } from 'react-dnd';
import { useDrag, useDrop } from 'react-dnd';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useCreateRequest } from '../hooks/useCreateRequest';
import { useDeleteRequest } from '../hooks/useDeleteRequest';
import { useRequests } from '../hooks/useRequests';
import { useTheme } from '../hooks/useTheme';
import { useUpdateAnyRequest } from '../hooks/useUpdateAnyRequest';
import { useUpdateRequest } from '../hooks/useUpdateRequest';
import type { HttpRequest } from '../lib/models';
import { Button } from './core/Button';
import { IconButton } from './core/IconButton';
import { HStack, VStack } from './core/Stacks';
import { WindowDragRegion } from './core/WindowDragRegion';
import { DropMarker } from './DropMarker';
import { RequestActionsDropdown } from './RequestActionsDropdown';
import { ToggleThemeButton } from './ToggleThemeButton';
interface Props {
className?: string;
}
export function Sidebar({ className }: Props) {
const requests = useRequests();
const activeRequest = useActiveRequest();
const deleteRequest = useDeleteRequest(activeRequest);
const createRequest = useCreateRequest({ navigateAfter: true });
const { appearance, toggleAppearance } = useTheme();
return (
<div
className={classnames(
className,
'w-[15rem] bg-gray-100 h-full border-r border-gray-200 relative grid grid-rows-[auto,1fr]',
)}
>
<HStack as={WindowDragRegion} alignItems="center" justifyContent="end">
<IconButton
className="mx-1"
icon="plusCircle"
onClick={async () => {
await createRequest.mutate({ name: 'Test Request' });
}}
/>
</HStack>
<VStack as="ul" className="py-3 px-2 overflow-auto h-full" space={1}>
{requests.map((r) => (
<SidebarItem key={r.id} request={r} active={r.id === activeRequest?.id} />
))}
{/*<Colors />*/}
enum ItemTypes {
REQUEST = 'request',
}
<HStack
className="absolute bottom-1 left-1 right-0 mx-1"
alignItems="center"
justifyContent="end"
export const Sidebar = memo(function Sidebar({ className }: Props) {
const sidebarRef = useRef<HTMLDivElement>(null);
const unorderedRequests = useRequests();
const activeRequest = useActiveRequest();
const requests = useMemo(
() => [...unorderedRequests].sort((a, b) => a.sortPriority - b.sortPriority),
[unorderedRequests],
);
return (
<div className="relative h-full">
<div
ref={sidebarRef}
className={classnames(className, 'h-full relative grid grid-rows-[minmax(0,1fr)_auto]')}
>
<VStack
as="ul"
className="relative py-3 overflow-y-auto overflow-x-visible"
draggable={false}
>
<IconButton icon="trash" onClick={() => deleteRequest.mutate()} />
<IconButton icon={appearance === 'dark' ? 'moon' : 'sun'} onClick={toggleAppearance} />
<SidebarItems activeRequestId={activeRequest?.id} requests={requests} />
</VStack>
<HStack className="mx-1 pb-1" alignItems="center" justifyContent="end">
<ToggleThemeButton />
</HStack>
</VStack>
</div>
</div>
);
}
});
function SidebarItem({ request, active }: { request: HttpRequest; active: boolean }) {
const updateRequest = useUpdateRequest(request);
const [editing, setEditing] = useState<boolean>(false);
const handleSubmitNameEdit = async (el: HTMLInputElement) => {
await updateRequest.mutate({ name: el.value });
setEditing(false);
};
function SidebarItems({
requests,
activeRequestId,
}: {
requests: HttpRequest[];
activeRequestId?: string;
}) {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const updateRequest = useUpdateAnyRequest();
const handleFocus = (el: HTMLInputElement | null) => {
el?.focus();
el?.select();
};
const handleMove = useCallback<DraggableSidebarItemProps['onMove']>(
(id, side) => {
const dragIndex = requests.findIndex((r) => r.id === id);
setHoveredIndex(side === 'above' ? dragIndex : dragIndex + 1);
},
[requests],
);
const handleEnd = useCallback<DraggableSidebarItemProps['onEnd']>(
(requestId) => {
if (hoveredIndex === null) return;
setHoveredIndex(null);
const index = requests.findIndex((r) => r.id === requestId);
const request = requests[index];
if (request === undefined) return;
const newRequests = requests.filter((r) => r.id !== requestId);
if (hoveredIndex > index) newRequests.splice(hoveredIndex - 1, 0, request);
else newRequests.splice(hoveredIndex, 0, request);
const beforePriority = newRequests[hoveredIndex - 1]?.sortPriority ?? 0;
const afterPriority = newRequests[hoveredIndex + 1]?.sortPriority ?? 0;
const shouldUpdateAll = afterPriority - beforePriority < 1;
if (shouldUpdateAll) {
newRequests.forEach(({ id }, i) => {
const sortPriority = i * 1000;
const update = (r: HttpRequest) => ({ ...r, sortPriority });
updateRequest.mutate({ id, update });
});
} else {
const sortPriority = afterPriority - (afterPriority - beforePriority) / 2;
const update = (r: HttpRequest) => ({ ...r, sortPriority });
updateRequest.mutate({ id: requestId, update });
}
},
[hoveredIndex, requests, updateRequest],
);
return (
<li>
<Button
color="custom"
size="sm"
className={classnames(
editing && 'focus-within:border-blue-400/40',
active
? 'bg-gray-200/70 text-gray-900'
: 'text-gray-600 hover:text-gray-800 active:bg-gray-200/30',
)}
onKeyDown={(e) => {
// Hitting enter on active request during keyboard nav will start edit
if (active && e.key === 'Enter') {
e.preventDefault();
setEditing(true);
}
}}
to={`/workspaces/${request.workspaceId}/requests/${request.id}`}
onDoubleClick={() => setEditing(true)}
justify="start"
>
{editing ? (
<input
ref={handleFocus}
defaultValue={request.name}
className="bg-transparent outline-none w-full"
onBlur={(e) => handleSubmitNameEdit(e.currentTarget)}
onKeyDown={async (e) => {
switch (e.key) {
case 'Enter':
await handleSubmitNameEdit(e.currentTarget);
break;
case 'Escape':
setEditing(false);
break;
}
}}
<>
{requests.map((r, i) => (
<Fragment key={r.id}>
{hoveredIndex === i && <DropMarker />}
<DraggableSidebarItem
key={r.id}
requestId={r.id}
requestName={r.name}
workspaceId={r.workspaceId}
active={r.id === activeRequestId}
onMove={handleMove}
onEnd={handleEnd}
/>
) : (
<span className="truncate">{request.name || request.url}</span>
)}
</Button>
</li>
</Fragment>
))}
{hoveredIndex === requests.length && <DropMarker />}
</>
);
}
type SidebarItemProps = {
className?: string;
requestId: string;
requestName: string;
workspaceId: string;
active?: boolean;
};
const _SidebarItem = forwardRef(function SidebarItem(
{ className, requestName, requestId, workspaceId, active }: SidebarItemProps,
ref: ForwardedRef<HTMLLIElement>,
) {
const updateRequest = useUpdateRequest(requestId);
const [editing, setEditing] = useState<boolean>(false);
const handleSubmitNameEdit = useCallback(
async (el: HTMLInputElement) => {
await updateRequest.mutate((r) => ({ ...r, name: el.value }));
setEditing(false);
},
[updateRequest],
);
const handleFocus = useCallback((el: HTMLInputElement | null) => {
el?.focus();
el?.select();
}, []);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLElement>) => {
// Hitting enter on active request during keyboard nav will start edit
if (active && e.key === 'Enter') {
e.preventDefault();
setEditing(true);
}
},
[active],
);
const handleInputKeyDown = useCallback(
async (e: KeyboardEvent<HTMLInputElement>) => {
switch (e.key) {
case 'Enter':
e.preventDefault();
await handleSubmitNameEdit(e.currentTarget);
break;
case 'Escape':
e.preventDefault();
setEditing(false);
break;
}
},
[handleSubmitNameEdit],
);
return (
<li ref={ref} className={classnames(className, 'block group/item px-2 pb-0.5')}>
<div className="relative">
<Button
color="custom"
size="sm"
to={`/workspaces/${workspaceId}/requests/${requestId}`}
draggable={false} // Item should drag, not the link
onDoubleClick={() => setEditing(true)}
onClick={active ? () => setEditing(true) : undefined}
justify="start"
onKeyDown={handleKeyDown}
className={classnames(
editing && 'focus-within:border-focus',
active
? 'bg-highlight text-gray-900'
: 'text-gray-600 group-hover/item:text-gray-800 active:bg-highlightSecondary',
// Move out of the way when trash is shown
'group-hover/item:pr-7',
)}
>
{editing ? (
<input
ref={handleFocus}
defaultValue={requestName}
className="bg-transparent outline-none w-full"
onBlur={(e) => handleSubmitNameEdit(e.currentTarget)}
onKeyDown={handleInputKeyDown}
/>
) : (
<span className={classnames('truncate', !requestName && 'text-gray-400 italic')}>
{requestName || 'New Request'}
</span>
)}
</Button>
<RequestActionsDropdown requestId={requestId}>
<IconButton
color="custom"
size="sm"
title="Request Options"
icon="dotsH"
className={classnames(
'absolute right-0 top-0 transition-opacity !opacity-0',
'group-hover/item:!opacity-100 focus-visible:!opacity-100',
)}
/>
</RequestActionsDropdown>
</div>
</li>
);
});
const SidebarItem = memo(_SidebarItem);
type DraggableSidebarItemProps = SidebarItemProps & {
onMove: (id: string, side: 'above' | 'below') => void;
onEnd: (id: string) => void;
};
type DragItem = {
id: string;
workspaceId: string;
requestName: string;
};
const DraggableSidebarItem = memo(function DraggableSidebarItem({
requestName,
requestId,
workspaceId,
active,
onMove,
onEnd,
}: DraggableSidebarItemProps) {
const ref = useRef<HTMLLIElement>(null);
const [, connectDrop] = useDrop<DragItem, void>(
{
accept: ItemTypes.REQUEST,
hover: (item, monitor) => {
if (!ref.current) return;
const hoverBoundingRect = ref.current?.getBoundingClientRect();
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
const clientOffset = monitor.getClientOffset();
const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top;
onMove(requestId, hoverClientY < hoverMiddleY ? 'above' : 'below');
},
},
[onMove],
);
const [{ isDragging }, connectDrag] = useDrag<DragItem, unknown, { isDragging: boolean }>(
() => ({
type: ItemTypes.REQUEST,
item: () => ({ id: requestId, requestName, workspaceId }),
collect: (m) => ({ isDragging: m.isDragging() }),
options: { dropEffect: 'move' },
end: () => onEnd(requestId),
}),
[onEnd],
);
connectDrag(ref);
connectDrop(ref);
return (
<SidebarItem
ref={ref}
className={classnames(isDragging && 'opacity-20')}
requestName={requestName}
requestId={requestId}
workspaceId={workspaceId}
active={active}
/>
);
});

View File

@@ -0,0 +1,31 @@
import { memo, useCallback } from 'react';
import { useCreateRequest } from '../hooks/useCreateRequest';
import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { IconButton } from './core/IconButton';
export const SidebarActions = memo(function SidebarDisplayToggle() {
const { hidden, toggle } = useSidebarHidden();
const createRequest = useCreateRequest({ navigateAfter: true });
const handleCreateRequest = useCallback(() => {
createRequest.mutate({ name: 'New Request' });
}, [createRequest]);
return (
<>
<IconButton
onClick={toggle}
className="pointer-events-auto"
size="sm"
title="Show sidebar"
icon={hidden ? 'leftPanelHidden' : 'leftPanelVisible'}
/>
<IconButton
onClick={handleCreateRequest}
className="pointer-events-auto"
size="sm"
title="Show sidebar"
icon="plusCircle"
/>
</>
);
});

View File

@@ -0,0 +1,121 @@
import { useQueryClient } from '@tanstack/react-query';
import { appWindow } from '@tauri-apps/api/window';
import { keyValueQueryKey } from '../hooks/useKeyValue';
import { requestsQueryKey } from '../hooks/useRequests';
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
import { responsesQueryKey } from '../hooks/useResponses';
import { useTauriEvent } from '../hooks/useTauriEvent';
import { workspacesQueryKey } from '../hooks/useWorkspaces';
import { DEFAULT_FONT_SIZE } from '../lib/constants';
import { NAMESPACE_NO_SYNC } from '../lib/keyValueStore';
import type { HttpRequest, HttpResponse, Model, Workspace } from '../lib/models';
import { modelsEq } from '../lib/models';
export function TauriListeners() {
const queryClient = useQueryClient();
const { wasUpdatedExternally } = useRequestUpdateKey(null);
useTauriEvent<Model>('created_model', ({ payload, windowLabel }) => {
if (shouldIgnoreEvent(payload, windowLabel)) return;
const queryKey =
payload.model === 'http_request'
? requestsQueryKey(payload)
: payload.model === 'http_response'
? responsesQueryKey(payload)
: payload.model === 'workspace'
? workspacesQueryKey(payload)
: payload.model === 'key_value'
? keyValueQueryKey(payload)
: null;
if (queryKey === null) {
if (payload.model) {
console.log('Unrecognized created model:', payload);
}
return;
}
if (!shouldIgnoreModel(payload)) {
queryClient.setQueryData<Model[]>(queryKey, (values) => [...(values ?? []), payload]);
}
});
useTauriEvent<Model>('updated_model', ({ payload, windowLabel }) => {
if (shouldIgnoreEvent(payload, windowLabel)) return;
const queryKey =
payload.model === 'http_request'
? requestsQueryKey(payload)
: payload.model === 'http_response'
? responsesQueryKey(payload)
: payload.model === 'workspace'
? workspacesQueryKey(payload)
: payload.model === 'key_value'
? keyValueQueryKey(payload)
: null;
if (queryKey === null) {
if (payload.model) {
console.log('Unrecognized updated model:', payload);
}
return;
}
if (payload.model === 'http_request') {
wasUpdatedExternally(payload.id);
}
if (!shouldIgnoreModel(payload)) {
queryClient.setQueryData<Model[]>(queryKey, (values) =>
values?.map((v) => (modelsEq(v, payload) ? payload : v)),
);
}
});
useTauriEvent<Model>('deleted_model', ({ payload, windowLabel }) => {
if (shouldIgnoreEvent(payload, windowLabel)) return;
if (shouldIgnoreModel(payload)) return;
if (payload.model === 'workspace') {
queryClient.setQueryData<Workspace[]>(workspacesQueryKey(), removeById(payload));
} else if (payload.model === 'http_request') {
queryClient.setQueryData<HttpRequest[]>(requestsQueryKey(payload), removeById(payload));
} else if (payload.model === 'http_response') {
queryClient.setQueryData<HttpResponse[]>(responsesQueryKey(payload), removeById(payload));
} else if (payload.model === 'key_value') {
queryClient.setQueryData(keyValueQueryKey(payload), undefined);
}
});
useTauriEvent<number>('zoom', ({ payload: zoomDelta, windowLabel }) => {
if (windowLabel !== appWindow.label) return;
const fontSize = parseFloat(window.getComputedStyle(document.documentElement).fontSize);
let newFontSize;
if (zoomDelta === 0) {
newFontSize = DEFAULT_FONT_SIZE;
} else if (zoomDelta > 0) {
newFontSize = Math.min(fontSize * 1.1, DEFAULT_FONT_SIZE * 5);
} else if (zoomDelta < 0) {
newFontSize = Math.max(fontSize * 0.9, DEFAULT_FONT_SIZE * 0.4);
}
document.documentElement.style.fontSize = `${newFontSize}px`;
});
return null;
}
function removeById<T extends { id: string }>(model: T) {
return (entries: T[] | undefined) => entries?.filter((e) => e.id !== model.id);
}
const shouldIgnoreEvent = (payload: Model, windowLabel: string) =>
windowLabel === appWindow.label && payload.model !== 'http_response';
const shouldIgnoreModel = (payload: Model) => {
if (payload.model === 'http_response') return false;
if (payload.model === 'key_value' && payload.namespace === NAMESPACE_NO_SYNC) return false;
return true;
};

View File

@@ -0,0 +1,14 @@
import React from 'react';
import { useTheme } from '../hooks/useTheme';
import { IconButton } from './core/IconButton';
export function ToggleThemeButton() {
const { appearance, toggleAppearance } = useTheme();
return (
<IconButton
title={appearance === 'dark' ? 'Enable light mode' : 'Enable dark mode'}
icon={appearance === 'dark' ? 'moon' : 'sun'}
onClick={toggleAppearance}
/>
);
}

View File

@@ -1,67 +1,84 @@
import { Button } from './core/Button';
import { DropdownMenuRadio } from './core/Dropdown';
import classnames from 'classnames';
import type { EditorView } from 'codemirror';
import type { FormEvent } from 'react';
import { memo, useCallback, useRef } from 'react';
import { useIsResponseLoading } from '../hooks/useIsResponseLoading';
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
import { useSendRequest } from '../hooks/useSendRequest';
import { useTauriEvent } from '../hooks/useTauriEvent';
import { useUpdateRequest } from '../hooks/useUpdateRequest';
import type { HttpRequest } from '../lib/models';
import { IconButton } from './core/IconButton';
import { Input } from './core/Input';
import { RequestMethodDropdown } from './RequestMethodDropdown';
interface Props {
sendRequest: () => void;
loading: boolean;
method: string;
url: string;
onMethodChange: (method: string) => void;
onUrlChange: (url: string) => void;
}
type Props = Pick<HttpRequest, 'id' | 'url' | 'method'> & {
className?: string;
};
export const UrlBar = memo(function UrlBar({ id: requestId, url, method, className }: Props) {
const inputRef = useRef<EditorView>(null);
const sendRequest = useSendRequest(requestId);
const updateRequest = useUpdateRequest(requestId);
const handleMethodChange = useCallback(
(method: string) => updateRequest.mutate({ method }),
[updateRequest],
);
const handleUrlChange = useCallback(
(url: string) => updateRequest.mutate({ url }),
[updateRequest],
);
const loading = useIsResponseLoading(requestId);
const { updateKey } = useRequestUpdateKey(requestId);
const handleSubmit = useCallback(
async (e: FormEvent) => {
e.preventDefault();
sendRequest();
},
[sendRequest],
);
useTauriEvent('focus_url', () => {
inputRef.current?.focus();
});
export function UrlBar({ sendRequest, loading, onMethodChange, method, onUrlChange, url }: Props) {
return (
<form
onSubmit={async (e) => {
e.preventDefault();
sendRequest();
}}
className="w-full flex items-center"
>
<form onSubmit={handleSubmit} className={classnames('url-bar', className)}>
<Input
ref={inputRef}
size="sm"
hideLabel
useEditor={{ useTemplating: true, contentType: 'url' }}
useTemplating
contentType="url"
className="px-0"
name="url"
label="Enter URL"
containerClassName="shadow shadow-gray-100 dark:shadow-gray-0"
onChange={onUrlChange}
forceUpdateKey={updateKey}
containerClassName="shadow shadow-gray-100 dark:shadow-gray-50"
onChange={handleUrlChange}
defaultValue={url}
placeholder="Enter a URL..."
placeholder="https://example.com"
leftSlot={
<DropdownMenuRadio
onValueChange={(v) => onMethodChange(v.value)}
value={method.toUpperCase()}
items={[
{ label: 'GET', value: 'GET' },
{ label: 'PUT', value: 'PUT' },
{ label: 'POST', value: 'POST' },
{ label: 'PATCH', value: 'PATCH' },
{ label: 'DELETE', value: 'DELETE' },
{ label: 'OPTIONS', value: 'OPTIONS' },
{ label: 'HEAD', value: 'HEAD' },
]}
>
<Button type="button" disabled={loading} size="sm" className="mx-0.5" justify="start">
{method.toUpperCase()}
</Button>
</DropdownMenuRadio>
<RequestMethodDropdown
method={method}
onChange={handleMethodChange}
className="mx-0.5 h-full my-1"
/>
}
rightSlot={
<IconButton
size="xs"
iconSize="sm"
title="Send Request"
type="submit"
className="mr-0.5"
size="sm"
className="w-8 mr-0.5"
icon={loading ? 'update' : 'paperPlane'}
spin={loading}
disabled={loading}
title="Send Request"
/>
}
/>
</form>
);
}
});

View File

@@ -1,40 +1,184 @@
import classnames from 'classnames';
import { motion } from 'framer-motion';
import type {
CSSProperties,
HTMLAttributes,
MouseEvent as ReactMouseEvent,
ReactNode,
} from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useWindowSize } from 'react-use';
import { RequestPane } from './RequestPane';
import { ResponsePane } from './ResponsePane';
import { Sidebar } from './Sidebar';
import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { useSidebarWidth } from '../hooks/useSidebarWidth';
import { useTauriEvent } from '../hooks/useTauriEvent';
import { WINDOW_FLOATING_SIDEBAR_WIDTH } from '../lib/constants';
import { Button } from './core/Button';
import { HStack } from './core/Stacks';
import { WindowDragRegion } from './core/WindowDragRegion';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { Overlay } from './Overlay';
import { RequestResponse } from './RequestResponse';
import { ResizeHandle } from './ResizeHandle';
import { Sidebar } from './Sidebar';
import { SidebarActions } from './SidebarActions';
import { WorkspaceHeader } from './WorkspaceHeader';
const side = { gridArea: 'side' };
const head = { gridArea: 'head' };
const body = { gridArea: 'body' };
const drag = { gridArea: 'drag' };
export default function Workspace() {
const activeRequest = useActiveRequest();
const { width } = useWindowSize();
const isH = width > 900;
const { set: setWidth, value: width, reset: resetWidth } = useSidebarWidth();
const { show, hide, hidden, toggle } = useSidebarHidden();
const windowSize = useWindowSize();
const [floating, setFloating] = useState<boolean>(false);
const [isResizing, setIsResizing] = useState<boolean>(false);
const moveState = useRef<{ move: (e: MouseEvent) => void; up: (e: MouseEvent) => void } | null>(
null,
);
useTauriEvent('toggle_sidebar', toggle);
// float/un-float sidebar on window resize
useEffect(() => {
const shouldHide = windowSize.width <= WINDOW_FLOATING_SIDEBAR_WIDTH;
if (shouldHide && !hidden) {
setFloating(true);
hide();
} else if (!shouldHide && hidden) {
setFloating(false);
show();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [windowSize.width]);
const unsub = () => {
if (moveState.current !== null) {
document.documentElement.removeEventListener('mousemove', moveState.current.move);
document.documentElement.removeEventListener('mouseup', moveState.current.up);
}
};
const handleResizeStart = useCallback(
(e: ReactMouseEvent<HTMLDivElement>) => {
if (width === undefined) return;
unsub();
const mouseStartX = e.clientX;
const startWidth = width;
moveState.current = {
move: async (e: MouseEvent) => {
e.preventDefault(); // Prevent text selection and things
setWidth(startWidth + (e.clientX - mouseStartX));
},
up: (e: MouseEvent) => {
e.preventDefault();
unsub();
setIsResizing(false);
},
};
document.documentElement.addEventListener('mousemove', moveState.current.move);
document.documentElement.addEventListener('mouseup', moveState.current.up);
setIsResizing(true);
},
[setWidth, width],
);
const sideWidth = hidden ? 0 : width;
const styles = useMemo<CSSProperties>(
() => ({
gridTemplate: floating
? `
' ${head.gridArea}' auto
' ${body.gridArea}' minmax(0,1fr)
/ 1fr`
: `
' ${head.gridArea} ${head.gridArea} ${head.gridArea}' auto
' ${side.gridArea} ${drag.gridArea} ${body.gridArea}' minmax(0,1fr)
/ ${sideWidth}px 0 1fr`,
}),
[sideWidth, floating],
);
if (windowSize.width <= 100) {
return (
<div>
<Button>Send</Button>
</div>
);
}
return (
<div className="grid grid-cols-[auto_1fr] grid-rows-1 h-full text-gray-900">
<Sidebar />
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full">
<HStack
as={WindowDragRegion}
className="px-3 bg-gray-50 text-gray-900 border-b border-b-gray-200 pt-[1px]"
alignItems="center"
>
{activeRequest?.name}
</HStack>
<div
className={classnames(
'grid',
isH
? 'grid-cols-[1fr_1fr] grid-rows-1'
: 'grid-cols-1 grid-rows-[minmax(0,auto)_minmax(0,100%)]',
)}
>
<RequestPane fullHeight={isH} className={classnames(!isH && 'pr-2 pb-0')} />
<ResponsePane />
</div>
</div>
<div
style={styles}
className={classnames(
'grid w-full h-full',
// Animate sidebar width changes but only when not resizing
// because it's too slow to animate on mouse move
!isResizing && 'transition-all',
)}
>
<HeaderSize
data-tauri-drag-region
className="w-full bg-gray-50 border-b border-b-highlight text-gray-900"
style={head}
>
<WorkspaceHeader className="pointer-events-none" />
</HeaderSize>
{floating ? (
<Overlay open={!hidden} portalName="sidebar" onClose={hide}>
<motion.div
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
className={classnames(
'absolute top-0 left-0 bottom-0 bg-gray-100 border-r border-highlight w-[14rem]',
'grid grid-rows-[auto_1fr]',
)}
>
<HeaderSize className="border-transparent">
<HStack space={0.5}>
<SidebarActions />
</HStack>
</HeaderSize>
<Sidebar />
</motion.div>
</Overlay>
) : (
<>
<div
style={side}
className={classnames('overflow-hidden bg-gray-100 border-r border-highlight')}
>
<Sidebar />
</div>
<ResizeHandle
className="-translate-x-3"
justify="end"
side="right"
isResizing={isResizing}
onResizeStart={handleResizeStart}
onReset={resetWidth}
/>
</>
)}
<RequestResponse style={body} />
</div>
);
}
interface HeaderSizeProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode;
}
function HeaderSize({ className, ...props }: HeaderSizeProps) {
return (
<div
className={classnames(
className,
'h-md pt-[1px] flex items-center w-full pr-3 pl-20 border-b',
)}
{...props}
/>
);
}

View File

@@ -0,0 +1,89 @@
import classnames from 'classnames';
import { memo, useMemo } from 'react';
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
import { useConfirm } from '../hooks/useConfirm';
import { useCreateWorkspace } from '../hooks/useCreateWorkspace';
import { useDeleteWorkspace } from '../hooks/useDeleteWorkspace';
import { useRoutes } from '../hooks/useRoutes';
import { useWorkspaces } from '../hooks/useWorkspaces';
import { Button } from './core/Button';
import type { DropdownItem } from './core/Dropdown';
import { Dropdown } from './core/Dropdown';
import { Icon } from './core/Icon';
import { InlineCode } from './core/InlineCode';
type Props = {
className?: string;
};
export const WorkspaceActionsDropdown = memo(function WorkspaceDropdown({ className }: Props) {
const workspaces = useWorkspaces();
const activeWorkspace = useActiveWorkspace();
const activeWorkspaceId = activeWorkspace?.id ?? null;
const createWorkspace = useCreateWorkspace({ navigateAfter: true });
const deleteWorkspace = useDeleteWorkspace(activeWorkspaceId);
const routes = useRoutes();
const confirm = useConfirm();
const items: DropdownItem[] = useMemo(() => {
const workspaceItems = workspaces.map((w) => ({
label: w.name,
leftSlot: activeWorkspaceId === w.id ? <Icon icon="check" /> : <Icon icon="empty" />,
onSelect: () => {
if (w.id === activeWorkspaceId) return;
routes.navigate('workspace', { workspaceId: w.id });
},
}));
return [
...workspaceItems,
{
type: 'separator',
label: 'Actions',
},
{
label: 'New Workspace',
leftSlot: <Icon icon="plus" />,
onSelect: () => createWorkspace.mutate({ name: 'New Workspace' }),
},
{
label: 'Delete Workspace',
leftSlot: <Icon icon="trash" />,
onSelect: async () => {
const confirmed = await confirm({
title: 'Delete Workspace',
variant: 'delete',
description: (
<>
Are you sure you want to delete <InlineCode>{activeWorkspace?.name}</InlineCode>?
</>
),
});
if (confirmed) {
deleteWorkspace.mutate();
}
},
},
];
}, [
workspaces,
activeWorkspaceId,
routes,
createWorkspace,
confirm,
activeWorkspace?.name,
deleteWorkspace,
]);
return (
<Dropdown items={items}>
<Button
size="sm"
className={classnames(className, 'text-gray-800 !px-2 truncate')}
forDropdown
>
{activeWorkspace?.name}
</Button>
</Dropdown>
);
});

View File

@@ -0,0 +1,43 @@
import classnames from 'classnames';
import { memo } from 'react';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { IconButton } from './core/IconButton';
import { HStack } from './core/Stacks';
import { RequestActionsDropdown } from './RequestActionsDropdown';
import { SidebarActions } from './SidebarActions';
import { WorkspaceActionsDropdown } from './WorkspaceActionsDropdown';
interface Props {
className?: string;
}
export const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Props) {
const activeRequest = useActiveRequest();
return (
<HStack
justifyContent="center"
alignItems="center"
className={classnames(className, 'w-full h-full')}
>
<HStack space={0.5} className="flex-1 pointer-events-none" alignItems="center">
<SidebarActions />
<WorkspaceActionsDropdown className="pointer-events-auto" />
</HStack>
<div className="flex-[2] text-center text-gray-800 text-sm truncate pointer-events-none">
{activeRequest?.name}
</div>
<div className="flex-1 flex justify-end -mr-2 pointer-events-none">
{activeRequest && (
<RequestActionsDropdown requestId={activeRequest?.id}>
<IconButton
size="sm"
title="Request Options"
icon="gear"
className="pointer-events-auto"
/>
</RequestActionsDropdown>
)}
</div>
</HStack>
);
});

View File

@@ -1,18 +1,16 @@
import { Button } from './core/Button';
import { Heading } from './core/Heading';
import { VStack } from './core/Stacks';
import { Navigate } from 'react-router-dom';
import { useRoutes } from '../hooks/useRoutes';
import { useWorkspaces } from '../hooks/useWorkspaces';
import { Heading } from './core/Heading';
export default function Workspaces() {
const routes = useRoutes();
const workspaces = useWorkspaces();
return (
<VStack as="ul" className="p-12">
<Heading>Workspaces</Heading>
{workspaces.map((w) => (
<Button key={w.id} color="gray" to={`/workspaces/${w.id}`}>
{w.name}
</Button>
))}
</VStack>
);
const workspace = workspaces[0];
if (workspace === undefined) {
return <Heading>There are no workspaces</Heading>;
}
return <Navigate to={routes.paths.workspace({ workspaceId: workspace.id })} />;
}

View File

@@ -0,0 +1,21 @@
import classnames from 'classnames';
import type { ReactNode } from 'react';
interface Props {
children: ReactNode;
className?: string;
}
export function Banner({ children, className }: Props) {
return (
<div>
<div
className={classnames(
className,
'border border-red-500 bg-red-300/10 text-red-800 px-3 py-2 rounded select-auto cursor-text',
)}
>
{children}
</div>
</div>
);
}

View File

@@ -1,32 +1,37 @@
import classnames from 'classnames';
import type { HTMLAttributes } from 'react';
import { forwardRef } from 'react';
import { forwardRef, memo, useMemo } from 'react';
import { Link } from 'react-router-dom';
import { Icon } from './Icon';
const colorStyles = {
custom: '',
default: 'text-gray-700 enabled:hover:bg-gray-700/10 enabled:hover:text-gray-1000',
gray: 'text-gray-800 bg-gray-100 enabled:hover:bg-gray-500/20 enabled:hover:text-gray-1000',
primary: 'bg-blue-400 text-white hover:bg-blue-500',
secondary: 'bg-violet-400 text-white hover:bg-violet-500',
warning: 'bg-orange-400 text-white hover:bg-orange-500',
danger: 'bg-red-400 text-white hover:bg-red-500',
custom: 'ring-blue-500/50',
default:
'text-gray-700 enabled:hocus:bg-gray-700/10 enabled:hocus:text-gray-1000 ring-blue-500/50',
gray: 'text-gray-800 bg-highlight enabled:hocus:bg-gray-500/20 enabled:hocus:text-gray-1000 ring-blue-500/50',
primary: 'bg-blue-400 text-white enabled:hocus:bg-blue-500 ring-blue-500/50',
secondary: 'bg-violet-400 text-white enabled:hocus:bg-violet-500 ring-violet-500/50',
warning: 'bg-orange-400 text-white enabled:hocus:bg-orange-500 ring-orange-500/50',
danger: 'bg-red-400 text-white enabled:hocus:bg-red-500 ring-red-500/50',
};
export type ButtonProps = HTMLAttributes<HTMLElement> & {
to?: string;
color?: keyof typeof colorStyles;
size?: 'sm' | 'md';
isLoading?: boolean;
size?: 'sm' | 'md' | 'xs';
justify?: 'start' | 'center';
type?: 'button' | 'submit';
forDropdown?: boolean;
disabled?: boolean;
title?: string;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const Button = forwardRef<any, ButtonProps>(function Button(
const _Button = forwardRef<any, ButtonProps>(function Button(
{
to,
isLoading,
className,
children,
forDropdown,
@@ -37,48 +42,40 @@ export const Button = forwardRef<any, ButtonProps>(function Button(
}: ButtonProps,
ref,
) {
const classes = useMemo(
() =>
classnames(
className,
'opacity-90 hover:opacity-100',
'outline-none whitespace-nowrap',
'focus-visible-or-class:ring',
'rounded-md flex items-center',
colorStyles[color || 'default'],
justify === 'start' && 'justify-start',
justify === 'center' && 'justify-center',
size === 'md' && 'h-md px-3',
size === 'sm' && 'h-sm px-2.5 text-sm',
size === 'xs' && 'h-xs px-2 text-sm',
),
[color, size, justify, className],
);
if (typeof to === 'string') {
return (
<Link
ref={ref}
to={to}
className={classnames(
className,
'outline-none',
'border border-transparent focus-visible:border-blue-300',
'rounded-md flex items-center',
colorStyles[color || 'default'],
justify === 'start' && 'justify-start',
justify === 'center' && 'justify-center',
size === 'md' && 'h-9 px-3',
size === 'sm' && 'h-7 px-2.5 text-sm',
)}
{...props}
>
<Link ref={ref} to={to} className={classes} {...props}>
{children}
{forDropdown && <Icon icon="triangleDown" className="ml-1 -mr-1" />}
{forDropdown && <Icon icon="chevronDown" className="ml-1 -mr-1" />}
</Link>
);
} else {
return (
<button
ref={ref}
className={classnames(
className,
'outline-none',
'border border-transparent focus-visible:border-blue-300',
'rounded-md flex items-center',
colorStyles[color || 'default'],
justify === 'start' && 'justify-start',
justify === 'center' && 'justify-center',
size === 'md' && 'h-9 px-3',
size === 'sm' && 'h-7 px-2.5 text-sm',
)}
{...props}
>
<button ref={ref} className={classes} {...props}>
{isLoading && <Icon icon="update" size={size} className="animate-spin mr-1" />}
{children}
{forDropdown && <Icon icon="triangleDown" className="ml-1 -mr-1" />}
{forDropdown && <Icon icon="chevronDown" size={size} className="ml-1 -mr-1" />}
</button>
);
}
});
export const Button = memo(_Button);

View File

@@ -0,0 +1,38 @@
import classnames from 'classnames';
import { useCallback } from 'react';
import { Icon } from './Icon';
interface Props {
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
className?: string;
}
export function Checkbox({ checked, onChange, className, disabled }: Props) {
const handleClick = useCallback(() => {
onChange(!checked);
}, [onChange, checked]);
return (
<button
role="checkbox"
aria-checked={checked ? 'true' : 'false'}
disabled={disabled}
onClick={handleClick}
className={classnames(
className,
'flex-shrink-0 w-4 h-4 border border-gray-200 rounded',
'focus:border-focus',
'disabled:opacity-disabled',
checked && 'bg-gray-200/10',
// Remove focus style
'outline-none',
)}
>
<div className="flex items-center justify-center">
<Icon size="sm" icon={checked ? 'check' : 'empty'} />
</div>
</button>
);
}

View File

@@ -0,0 +1,17 @@
interface Props {
count: number;
}
export function CountBadge({ count }: Props) {
if (count === 0) return null;
return (
<>
<div
aria-hidden
className="opacity-70 border border-highlight text-3xs rounded mb-0.5 px-1 ml-1 h-4 font-mono"
>
{count}
</div>
</>
);
}

View File

@@ -1,58 +1,85 @@
import * as D from '@radix-ui/react-dialog';
import classnames from 'classnames';
import { motion } from 'framer-motion';
import type { ReactNode } from 'react';
import { useMemo } from 'react';
import { useKeyPressEvent } from 'react-use';
import { Overlay } from '../Overlay';
import { Heading } from './Heading';
import { IconButton } from './IconButton';
import { HStack, VStack } from './Stacks';
interface Props {
export interface DialogProps {
children: ReactNode;
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description?: string;
onClose: () => void;
title: ReactNode;
description?: ReactNode;
className?: string;
wide?: boolean;
size?: 'sm' | 'md' | 'full' | 'dynamic';
hideX?: boolean;
}
export function Dialog({
children,
className,
wide,
size = 'full',
open,
onOpenChange,
onClose,
title,
description,
}: Props) {
hideX,
}: DialogProps) {
const titleId = useMemo(() => Math.random().toString(36).slice(2), []);
const descriptionId = useMemo(
() => (description ? Math.random().toString(36).slice(2) : undefined),
[description],
);
useKeyPressEvent('Escape', (e) => {
e.preventDefault();
onClose();
});
return (
<D.Root open={open} onOpenChange={onOpenChange}>
<D.Portal container={document.querySelector<HTMLElement>('#radix-portal')}>
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
<D.Overlay className="fixed inset-0 bg-gray-600/60 dark:bg-black/50" />
<D.Content>
<div
className={classnames(
className,
'absolute top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%] bg-gray-100',
'w-[20rem] max-h-[80vh] p-5 rounded-lg overflow-auto',
'dark:border border-gray-200 shadow-md shadow-black/10',
wide && 'w-[80vw] max-w-[50rem]',
)}
>
<D.Close asChild className="ml-auto absolute right-1 top-1">
<IconButton aria-label="Close" icon="x" size="sm" />
</D.Close>
<VStack space={3}>
<HStack alignItems="center" className="pb-3">
<D.Title className="text-xl font-semibold">{title}</D.Title>
</HStack>
{description && <D.Description>{description}</D.Description>}
<div>{children}</div>
</VStack>
</div>
</D.Content>
</motion.div>
</D.Portal>
</D.Root>
<Overlay open={open} onClose={onClose} portalName="dialog">
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div
role="dialog"
aria-labelledby={titleId}
aria-describedby={descriptionId}
className="pointer-events-auto"
>
<motion.div
initial={{ top: 5, scale: 0.97 }}
animate={{ top: 0, scale: 1 }}
className={classnames(
className,
'relative bg-gray-50 pointer-events-auto',
'max-h-[80vh] p-5 rounded-lg overflow-auto',
'dark:border border-gray-200 shadow-md shadow-black/10',
size === 'sm' && 'w-[25rem]',
size === 'md' && 'w-[45rem]',
size === 'full' && 'w-[80vw]',
size === 'dynamic' && 'min-w-[30vw] max-w-[80vw]',
)}
>
{!hideX && (
<IconButton
onClick={onClose}
title="Close dialog"
aria-label="Close"
icon="x"
size="sm"
className="ml-auto absolute right-1 top-1"
/>
)}
<Heading className="text-xl font-semibold w-full" id={titleId}>
{title}
</Heading>
{description && <p id={descriptionId}>{description}</p>}
<div className="mt-6">{children}</div>
</motion.div>
</div>
</div>
</Overlay>
);
}

View File

@@ -1,23 +0,0 @@
import * as Separator from '@radix-ui/react-separator';
import classnames from 'classnames';
interface Props {
orientation?: 'horizontal' | 'vertical';
decorative?: boolean;
className?: string;
}
export function Divider({ className, orientation = 'horizontal', decorative }: Props) {
return (
<Separator.Root
className={classnames(
className,
'bg-gray-50',
orientation === 'horizontal' && 'w-full h-[1px]',
orientation === 'vertical' && 'h-full w-[1px]',
)}
orientation={orientation}
decorative={decorative}
/>
);
}

View File

@@ -1,306 +1,279 @@
import * as D from '@radix-ui/react-dropdown-menu';
import { CheckIcon } from '@radix-ui/react-icons';
import classnames from 'classnames';
import FocusTrap from 'focus-trap-react';
import { motion } from 'framer-motion';
import type { ReactNode, ForwardedRef } from 'react';
import { forwardRef, useImperativeHandle, useLayoutEffect, useState } from 'react';
import type { CSSProperties, HTMLAttributes, MouseEvent, ReactElement, ReactNode } from 'react';
import { Children, cloneElement, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useKeyPressEvent } from 'react-use';
import { Portal } from '../Portal';
import { Separator } from './Separator';
import { VStack } from './Stacks';
interface DropdownMenuRadioProps {
children: ReactNode;
onValueChange: ((v: { label: string; value: string }) => void) | null;
value: string;
export type DropdownItemSeparator = {
type: 'separator';
label?: string;
items: {
label: string;
value: string;
}[];
}
};
export function DropdownMenuRadio({
children,
items,
onValueChange,
label,
value,
}: DropdownMenuRadioProps) {
const handleChange = (value: string) => {
const item = items.find((item) => item.value === value);
if (item && onValueChange) {
onValueChange(item);
export type DropdownItem =
| {
type?: 'default';
label: string;
disabled?: boolean;
hidden?: boolean;
leftSlot?: ReactNode;
rightSlot?: ReactNode;
onSelect?: () => void;
}
};
return (
<D.Root>
<DropdownMenuTrigger>{children}</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent>
{label && <DropdownMenuLabel>{label}</DropdownMenuLabel>}
<D.DropdownMenuRadioGroup onValueChange={handleChange} value={value}>
{items.map((item) => (
<DropdownMenuRadioItem key={item.value} value={item.value}>
{item.label}
</DropdownMenuRadioItem>
))}
</D.DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenuPortal>
</D.Root>
);
}
| DropdownItemSeparator;
export interface DropdownProps {
children: ReactNode;
items: (
| {
label: string;
onSelect?: () => void;
disabled?: boolean;
leftSlot?: ReactNode;
}
| '-----'
)[];
children: ReactElement<HTMLAttributes<HTMLButtonElement>>;
items: DropdownItem[];
}
export function Dropdown({ children, items }: DropdownProps) {
return (
<D.Root>
<DropdownMenuTrigger>{children}</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent>
{items.map((item, i) => {
if (item === '-----') {
return <DropdownMenuSeparator key={i} />;
} else {
return (
<DropdownMenuItem
key={i}
onSelect={() => item.onSelect?.()}
disabled={item.disabled}
leftSlot={item.leftSlot}
>
{item.label}
</DropdownMenuItem>
);
}
})}
</DropdownMenuContent>
</DropdownMenuPortal>
</D.Root>
);
}
interface DropdownMenuPortalProps {
children: ReactNode;
}
function DropdownMenuPortal({ children }: DropdownMenuPortalProps) {
const container = document.querySelector<Element>('#radix-portal');
if (container === null) return null;
return (
<D.Portal>
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
{children}
</motion.div>
</D.Portal>
);
}
const DropdownMenuContent = forwardRef<HTMLDivElement, D.DropdownMenuContentProps>(
function DropdownMenuContent(
{ className, children, ...props }: D.DropdownMenuContentProps,
ref: ForwardedRef<HTMLDivElement>,
) {
const [styles, setStyles] = useState<{ maxHeight: number }>();
const [divRef, setDivRef] = useState<HTMLDivElement | null>(null);
useImperativeHandle<HTMLDivElement | null, HTMLDivElement | null>(ref, () => divRef);
const initDivRef = (ref: HTMLDivElement | null) => {
setDivRef(ref);
const [open, setOpen] = useState<boolean>(false);
const ref = useRef<HTMLButtonElement>(null);
const child = useMemo(() => {
const existingChild = Children.only(children);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const props: any = {
...existingChild.props,
ref,
'aria-haspopup': 'true',
onClick:
existingChild.props?.onClick ??
((e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setOpen((o) => !o);
}),
};
return cloneElement(existingChild, props);
}, [children]);
// Calculate the max height so we can scroll
useLayoutEffect(() => {
if (divRef === null) return;
// Needs to be in a setTimeout because the ref is not positioned yet
// TODO: Make this better?
setTimeout(() => {
const windowBox = document.documentElement.getBoundingClientRect();
const menuBox = divRef.getBoundingClientRect();
const styles = { maxHeight: windowBox.height - menuBox.top - 5 - 45 };
setStyles(styles);
});
}, [divRef]);
const handleClose = useCallback(() => {
setOpen(false);
ref.current?.focus();
}, []);
return (
<D.Content
ref={initDivRef}
align="start"
className={classnames(
className,
'bg-gray-50 rounded-md shadow-lg p-1.5 border border-gray-200',
'overflow-auto m-1',
)}
style={styles}
{...props}
>
{children}
</D.Content>
);
},
);
useEffect(() => {
ref.current?.setAttribute('aria-expanded', open.toString());
}, [open]);
type DropdownMenuItemProps = D.DropdownMenuItemProps & ItemInnerProps;
const triggerRect = useMemo(() => {
if (!open) return null;
return ref.current?.getBoundingClientRect();
}, [open]);
function DropdownMenuItem({
leftSlot,
rightSlot,
className,
children,
disabled,
...props
}: DropdownMenuItemProps) {
return (
<D.Item
asChild
disabled={disabled}
className={classnames(className, disabled && 'opacity-30')}
{...props}
>
<ItemInner leftSlot={leftSlot} rightSlot={rightSlot}>
{children}
</ItemInner>
</D.Item>
<>
{child}
{open && triggerRect && (
<Menu items={items} triggerRect={triggerRect} onClose={handleClose} />
)}
</>
);
}
// type DropdownMenuCheckboxItemProps = DropdownMenu.DropdownMenuCheckboxItemProps & ItemInnerProps;
//
// function DropdownMenuCheckboxItem({
// leftSlot,
// rightSlot,
// children,
// ...props
// }: DropdownMenuCheckboxItemProps) {
// return (
// <DropdownMenu.CheckboxItem asChild {...props}>
// <ItemInner leftSlot={leftSlot} rightSlot={rightSlot}>
// {children}
// </ItemInner>
// </DropdownMenu.CheckboxItem>
// );
// }
interface MenuProps {
className?: string;
items: DropdownProps['items'];
triggerRect: DOMRect;
onClose: () => void;
}
// type DropdownMenuSubTriggerProps = DropdownMenu.DropdownMenuSubTriggerProps & ItemInnerProps;
//
// function DropdownMenuSubTrigger({
// leftSlot,
// rightSlot,
// children,
// ...props
// }: DropdownMenuSubTriggerProps) {
// return (
// <DropdownMenu.SubTrigger asChild {...props}>
// <ItemInner leftSlot={leftSlot} rightSlot={rightSlot}>
// {children}
// </ItemInner>
// </DropdownMenu.SubTrigger>
// );
// }
function Menu({ className, items, onClose, triggerRect }: MenuProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const [menuStyles, setMenuStyles] = useState<CSSProperties>({});
type DropdownMenuRadioItemProps = Omit<D.DropdownMenuRadioItemProps & ItemInnerProps, 'leftSlot'>;
// Calculate the max height so we can scroll
const initMenu = useCallback((el: HTMLDivElement | null) => {
if (el === null) return {};
const windowBox = document.documentElement.getBoundingClientRect();
const menuBox = el.getBoundingClientRect();
setMenuStyles({ maxHeight: windowBox.height - menuBox.top - 5 });
}, []);
function DropdownMenuRadioItem({ rightSlot, children, ...props }: DropdownMenuRadioItemProps) {
return (
<D.RadioItem asChild {...props}>
<ItemInner
leftSlot={
<D.ItemIndicator>
<CheckIcon />
</D.ItemIndicator>
useKeyPressEvent('Escape', (e) => {
e.preventDefault();
onClose();
});
useKeyPressEvent('ArrowUp', (e) => {
e.preventDefault();
setSelectedIndex((currIndex) => {
let nextIndex = (currIndex ?? 0) - 1;
const maxTries = items.length;
for (let i = 0; i < maxTries; i++) {
if (items[nextIndex]?.type === 'separator') {
nextIndex--;
} else if (nextIndex < 0) {
nextIndex = items.length - 1;
} else {
break;
}
rightSlot={rightSlot}
>
{children}
</ItemInner>
</D.RadioItem>
}
return nextIndex;
});
});
useKeyPressEvent('ArrowDown', (e) => {
e.preventDefault();
setSelectedIndex((currIndex) => {
let nextIndex = (currIndex ?? -1) + 1;
const maxTries = items.length;
for (let i = 0; i < maxTries; i++) {
if (items[nextIndex]?.type === 'separator') {
nextIndex++;
} else if (nextIndex >= items.length) {
nextIndex = 0;
} else {
break;
}
}
return nextIndex;
});
});
const { containerStyles, triangleStyles } = useMemo<{
containerStyles: CSSProperties;
triangleStyles: CSSProperties;
}>(() => {
const docWidth = document.documentElement.getBoundingClientRect().width;
const spaceRemaining = docWidth - triggerRect.left;
const top = triggerRect?.bottom + 5;
const onRight = spaceRemaining < 200;
const containerStyles = onRight
? { top, right: docWidth - triggerRect?.right }
: { top, left: triggerRect?.left };
const size = { top: '-0.2rem', width: '0.4rem', height: '0.4rem' };
const triangleStyles = onRight
? { right: triggerRect.width / 2, marginRight: '-0.2rem', ...size }
: { left: triggerRect.width / 2, marginLeft: '-0.2rem', ...size };
return { containerStyles, triangleStyles };
}, [triggerRect]);
const handleSelect = useCallback(
(i: DropdownItem) => {
onClose();
setSelectedIndex(null);
if (i.type !== 'separator') {
i.onSelect?.();
}
},
[onClose],
);
}
// const DropdownMenuSubContent = forwardRef<HTMLDivElement, DropdownMenu.DropdownMenuSubContentProps>(
// function DropdownMenuSubContent(
// { className, ...props }: DropdownMenu.DropdownMenuSubContentProps,
// ref,
// ) {
// return (
// <DropdownMenu.SubContent
// ref={ref}
// alignOffset={0}
// sideOffset={4}
// className={classnames(className, dropdownMenuClasses)}
// {...props}
// />
// );
// },
// );
const handleFocus = useCallback(
(i: DropdownItem) => {
const index = items.findIndex((item) => item === i) ?? null;
setSelectedIndex(index);
},
[items],
);
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
function DropdownMenuLabel({ className, children, ...props }: D.DropdownMenuLabelProps) {
return (
<D.Label asChild {...props}>
<ItemInner noHover className={classnames(className, 'opacity-50 uppercase text-sm')}>
{children}
</ItemInner>
</D.Label>
<Portal name="dropdown">
<FocusTrap>
<div>
<div tabIndex={-1} aria-hidden className="fixed inset-0" onClick={onClose} />
<motion.div
tabIndex={0}
initial={{ opacity: 0, y: -5, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
role="menu"
aria-orientation="vertical"
dir="ltr"
ref={containerRef}
style={containerStyles}
className={classnames(className, 'outline-none mt-1 pointer-events-auto fixed z-50')}
>
<span
aria-hidden
style={triangleStyles}
className="bg-gray-50 absolute rotate-45 border-gray-200 border-t border-l"
/>
{containerStyles && (
<VStack
space={0.5}
ref={initMenu}
style={menuStyles}
className={classnames(
className,
'h-auto bg-gray-50 rounded-md shadow-lg dark:shadow-gray-0 py-1.5 border',
'border-gray-200 overflow-auto mb-1 mx-0.5',
)}
>
{items.map((item, i) => {
if (item.type === 'separator') {
return <Separator key={i} className="my-1.5" label={item.label} />;
}
if (item.hidden) {
return null;
}
return (
<MenuItem
focused={i === selectedIndex}
onFocus={handleFocus}
onSelect={handleSelect}
key={i + item.label}
item={item}
/>
);
})}
</VStack>
)}
</motion.div>
</div>
</FocusTrap>
</Portal>
);
}
function DropdownMenuSeparator({ className, ...props }: D.DropdownMenuSeparatorProps) {
return (
<D.Separator
className={classnames(className, 'h-[1px] bg-gray-400 bg-opacity-30 my-1')}
{...props}
/>
);
}
type DropdownMenuTriggerProps = D.DropdownMenuTriggerProps & {
children: ReactNode;
interface MenuItemProps {
className?: string;
};
item: DropdownItem;
onSelect: (item: DropdownItem) => void;
onFocus: (item: DropdownItem) => void;
focused: boolean;
}
function DropdownMenuTrigger({ children, className, ...props }: DropdownMenuTriggerProps) {
return (
<D.Trigger asChild className={classnames(className)} {...props}>
{children}
</D.Trigger>
function MenuItem({ className, focused, onFocus, item, onSelect, ...props }: MenuItemProps) {
const handleClick = useCallback(() => onSelect?.(item), [item, onSelect]);
const handleFocus = useCallback(() => onFocus?.(item), [item, onFocus]);
const initRef = useCallback(
(el: HTMLButtonElement | null) => {
if (el === null) return;
if (focused) {
setTimeout(() => el.focus(), 0);
}
},
[focused],
);
}
interface ItemInnerProps {
leftSlot?: ReactNode;
rightSlot?: ReactNode;
children: ReactNode;
noHover?: boolean;
className?: string;
}
if (item.type === 'separator') return <Separator className="my-1.5" />;
const ItemInner = forwardRef<HTMLDivElement, ItemInnerProps>(function ItemInner(
{ leftSlot, rightSlot, children, className, noHover, ...props }: ItemInnerProps,
ref,
) {
return (
<div
ref={ref}
<button
ref={initRef}
tabIndex={-1}
onMouseEnter={(e) => e.currentTarget.focus()}
onMouseLeave={(e) => e.currentTarget.blur()}
onFocus={handleFocus}
onClick={handleClick}
className={classnames(
className,
'outline-none px-2 py-1.5 flex items-center text-sm text-gray-700 whitespace-nowrap pr-4',
!noHover && 'focus:bg-gray-50 focus:text-gray-900 rounded',
'min-w-[8rem] outline-none px-2 mx-1.5 h-7 flex items-center text-sm text-gray-700 whitespace-nowrap pr-4',
'focus:bg-highlight focus:text-gray-900 rounded',
)}
{...props}
>
{leftSlot && <div className="w-6">{leftSlot}</div>}
<div>{children}</div>
{rightSlot && <div className="ml-auto pl-3">{rightSlot}</div>}
</div>
{item.leftSlot && <div className="w-6">{item.leftSlot}</div>}
<div>{item.label}</div>
{item.rightSlot && <div className="ml-auto pl-3">{item.rightSlot}</div>}
</button>
);
});
}

View File

@@ -12,25 +12,34 @@
outline: none !important;
}
.cm-content {
@apply py-0;
}
.cm-line {
@apply text-gray-900 pl-1 pr-1.5;
@apply text-gray-800 pl-1 pr-1.5;
}
.cm-placeholder {
@apply text-placeholder;
}
.cm-scroller {
/* Inherit line-height from outside */
line-height: inherit;
}
/* Don't show selection on blurred input */
.cm-selectionBackground {
@apply bg-transparent;
}
&.cm-focused .cm-selectionBackground {
@apply bg-gray-400;
@apply bg-selection;
}
/* Style gutters */
.cm-gutters {
@apply border-0 text-gray-500/60;
@apply border-0 text-gray-500/50 opacity-95;
.cm-gutterElement {
@apply cursor-default;
@@ -38,7 +47,7 @@
}
.placeholder-widget {
@apply text-[0.9em] text-gray-800 dark:text-gray-900 px-1 rounded cursor-default dark:shadow;
@apply text-xs text-gray-800 dark:text-gray-900 px-1 rounded cursor-default dark:shadow;
/* NOTE: Background and border are translucent so we can see text selection through it */
@apply bg-gray-300/40 border border-gray-300 border-opacity-40 hover:border-opacity-80;
@@ -50,17 +59,15 @@
&.cm-singleline {
.cm-editor {
@apply h-full w-full;
@apply w-full h-auto;
}
.cm-scroller {
@apply font-mono flex text-[0.8rem];
align-items: center !important;
overflow: hidden !important;
@apply font-mono text-[0.8rem] overflow-hidden;
}
.cm-line {
@apply px-0;
@apply px-2 overflow-hidden;
}
}
@@ -80,16 +87,29 @@
.cm-scroller {
@apply font-mono text-[0.75rem];
/*
* Round corners or they'll stick out of the editor bounds of editor is rounded.
* Could potentially be pushed up from the editor like we do with bg color but this
* is probably fine.
*/
@apply rounded-lg;
}
}
}
/* Obscure text for password fields */
.cm-wrapper.cm-obscure-text .cm-line {
-webkit-text-security: disc;
}
.cm-editor .cm-gutterElement {
@apply flex items-center;
transition: color var(--transition-duration);
}
.cm-editor .fold-gutter-icon {
@apply pt-[0.3em] pl-[0.4em] px-[0.4em] h-4 cursor-pointer rounded;
@apply pt-[0.25em] pl-[0.4em] px-[0.4em] h-4 cursor-pointer rounded;
}
.cm-editor .fold-gutter-icon::after {
@@ -98,7 +118,7 @@
}
.cm-editor .fold-gutter-icon[data-open] {
@apply pt-[0.4em] pl-[0.3em];
@apply pt-[0.38em] pl-[0.3em];
}
.cm-editor .fold-gutter-icon[data-open]::after {
@@ -120,7 +140,7 @@
.cm-wrapper:not(.cm-readonly) .cm-editor {
&.cm-focused .cm-activeLineGutter {
@apply text-gray-800;
@apply text-gray-600;
}
.cm-cursor {
@@ -130,13 +150,21 @@
.cm-singleline .cm-editor {
.cm-content {
@apply h-full flex items-center;
@apply h-full flex items-center;
}
}
/* NOTE: Extra selector required to override default styles */
.cm-tooltip.cm-tooltip {
@apply shadow-lg bg-gray-50 rounded overflow-hidden text-gray-900 border border-gray-200 z-50 pointer-events-auto;
@apply shadow-lg bg-gray-50 rounded text-gray-700 border border-gray-200 z-50 pointer-events-auto text-sm;
&.cm-completionInfo-right {
@apply ml-1;
}
&.cm-completionInfo-right-narrow {
@apply ml-1;
}
* {
@apply transition-none;
@@ -152,7 +180,7 @@
}
& > ul > li[aria-selected] {
@apply bg-gray-100 text-gray-900;
@apply bg-highlight text-gray-900;
}
& > ul > li:hover {
@@ -162,5 +190,18 @@
.cm-completionIcon {
@apply text-sm flex items-center pb-0.5;
}
.cm-completionLabel {
}
.cm-completionDetail {
@apply ml-auto;
}
}
}
/* Add default icon. Needs low priority so it can be overwritten */
.cm-completionIcon::after {
content: '𝑥';
}

View File

@@ -1,131 +1,216 @@
import { defaultKeymap } from '@codemirror/commands';
import { Compartment, EditorState } from '@codemirror/state';
import { Compartment, EditorState, Transaction } from '@codemirror/state';
import type { ViewUpdate } from '@codemirror/view';
import { keymap, placeholder as placeholderExt, tooltips } from '@codemirror/view';
import classnames from 'classnames';
import { EditorView } from 'codemirror';
import { formatSdl } from 'format-graphql';
import { useEffect, useRef } from 'react';
import { useDebounce, useUnmount } from 'react-use';
import { debounce } from '../../../lib/debounce';
import type { MutableRefObject, ReactNode } from 'react';
import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef } from 'react';
import { IconButton } from '../IconButton';
import { HStack } from '../Stacks';
import './Editor.css';
import { baseExtensions, getLanguageExtension, multiLineExtensions } from './extensions';
import type { GenericCompletionConfig } from './genericCompletion';
import { singleLineExt } from './singleLine';
export interface _EditorProps {
// Export some things so all the code-split parts are in this file
export { buildClientSchema, getIntrospectionQuery } from 'graphql/utilities';
export { graphql } from 'cm6-graphql';
export { formatSdl } from 'format-graphql';
export interface EditorProps {
id?: string;
readOnly?: boolean;
type?: 'text' | 'password';
className?: string;
heightMode?: 'auto' | 'full';
contentType?: string;
forceUpdateKey?: string;
autoFocus?: boolean;
defaultValue?: string;
placeholder?: string;
tooltipContainer?: HTMLElement;
useTemplating?: boolean;
onChange?: (value: string) => void;
onFocus?: () => void;
onBlur?: () => void;
singleLine?: boolean;
format?: (v: string) => string;
autocomplete?: GenericCompletionConfig;
actions?: ReactNode;
}
export function _Editor({
readOnly,
heightMode,
contentType,
autoFocus,
placeholder,
useTemplating,
defaultValue,
onChange,
className,
singleLine,
}: _EditorProps) {
const cm = useRef<{ view: EditorView; langHolder: Compartment } | null>(null);
const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
{
readOnly,
type = 'text',
heightMode,
contentType,
autoFocus,
placeholder,
useTemplating,
defaultValue,
forceUpdateKey,
onChange,
onFocus,
onBlur,
className,
singleLine,
format,
autocomplete,
actions,
}: EditorProps,
ref,
) {
const cm = useRef<{ view: EditorView; languageCompartment: Compartment } | null>(null);
useImperativeHandle(ref, () => cm.current?.view);
// Unmount the editor
useUnmount(() => {
cm.current?.view.destroy();
cm.current = null;
});
// Use ref so we can update the onChange handler without re-initializing the editor
const handleChange = useRef<EditorProps['onChange']>(onChange);
useEffect(() => {
handleChange.current = onChange;
}, [onChange]);
// Use ref so we can update the onChange handler without re-initializing the editor
const handleFocus = useRef<EditorProps['onFocus']>(onFocus);
useEffect(() => {
handleFocus.current = onFocus;
}, [onFocus]);
// Use ref so we can update the onChange handler without re-initializing the editor
const handleBlur = useRef<EditorProps['onBlur']>(onBlur);
useEffect(() => {
handleBlur.current = onBlur;
}, [onBlur]);
// Update placeholder
const placeholderCompartment = useRef(new Compartment());
useEffect(() => {
if (cm.current === null) return;
const effect = placeholderCompartment.current.reconfigure(
placeholderExt(placeholderElFromText(placeholder ?? '')),
);
cm.current?.view.dispatch({ effects: effect });
}, [placeholder]);
// Update language extension when contentType changes
useEffect(() => {
if (cm.current === null) return;
const { view, langHolder } = cm.current;
const ext = getLanguageExtension({ contentType, useTemplating });
view.dispatch({ effects: langHolder.reconfigure(ext) });
}, [contentType]);
const { view, languageCompartment } = cm.current;
const ext = getLanguageExtension({ contentType, useTemplating, autocomplete });
view.dispatch({ effects: languageCompartment.reconfigure(ext) });
}, [contentType, autocomplete, useTemplating]);
// Initialize the editor
const initDivRef = (el: HTMLDivElement | null) => {
if (el === null || cm.current !== null) return;
useEffect(() => {
if (cm.current === null) return;
const { view } = cm.current;
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: defaultValue ?? '' } });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [forceUpdateKey]);
// Initialize the editor when ref mounts
const initEditorRef = useCallback((container: HTMLDivElement | null) => {
if (container === null) {
cm.current?.view.destroy();
cm.current = null;
return;
}
let view: EditorView;
try {
const langHolder = new Compartment();
const langExt = getLanguageExtension({ contentType, useTemplating });
const languageCompartment = new Compartment();
const langExt = getLanguageExtension({ contentType, useTemplating, autocomplete });
const state = EditorState.create({
doc: `${defaultValue ?? ''}`,
extensions: [
langHolder.of(langExt),
languageCompartment.of(langExt),
placeholderCompartment.current.of(
placeholderExt(placeholderElFromText(placeholder ?? '')),
),
...getExtensions({
container: el,
container,
onChange: handleChange,
onFocus: handleFocus,
onBlur: handleBlur,
readOnly,
placeholder,
singleLine,
onChange,
contentType,
useTemplating,
}),
],
});
const view = new EditorView({ state, parent: el });
cm.current = { view, langHolder };
syncGutterBg({ parent: el, className });
view = new EditorView({ state, parent: container });
cm.current = { view, languageCompartment };
syncGutterBg({ parent: container, className });
if (autoFocus) view.focus();
} catch (e) {
console.log('Failed to initialize Codemirror', e);
}
};
return (
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const cmContainer = (
<div
ref={initDivRef}
ref={initEditorRef}
className={classnames(
className,
'cm-wrapper text-base bg-gray-50',
type === 'password' && 'cm-obscure-text',
heightMode === 'auto' ? 'cm-auto-height' : 'cm-full-height',
singleLine ? 'cm-singleline' : 'cm-multiline',
readOnly && 'cm-readonly',
)}
>
{contentType?.includes('graphql') && (
<IconButton
icon="eye"
className="absolute right-3 bottom-3 z-10"
onClick={() => {
const doc = cm.current?.view.state.doc ?? '';
const insert = formatSdl(doc.toString());
cm.current?.view.dispatch({ changes: { from: 0, to: doc.length, insert } });
}}
/>
/>
);
if (singleLine) {
return cmContainer;
}
return (
<div className="group relative h-full w-full">
{cmContainer}
{format && (
<HStack space={0.5} alignItems="center" className="absolute bottom-2 right-0 ">
{actions}
<IconButton
showConfirm
size="sm"
title="Reformat contents"
icon="magicWand"
className="transition-opacity opacity-0 group-hover:opacity-70"
onClick={() => {
if (cm.current === null) return;
const { doc } = cm.current.view.state;
const insert = format(doc.toString());
// Update editor and blur because the cursor will reset anyway
cm.current.view.dispatch({ changes: { from: 0, to: doc.length, insert } });
cm.current.view.contentDOM.blur();
}}
/>
</HStack>
)}
</div>
);
}
});
export const Editor = memo(_Editor);
function getExtensions({
container,
readOnly,
singleLine,
placeholder,
onChange,
contentType,
useTemplating,
}: Pick<
_EditorProps,
'singleLine' | 'onChange' | 'contentType' | 'useTemplating' | 'placeholder' | 'readOnly'
> & { container: HTMLDivElement | null }) {
const ext = getLanguageExtension({ contentType, useTemplating });
onFocus,
onBlur,
}: Pick<EditorProps, 'singleLine' | 'readOnly'> & {
container: HTMLDivElement | null;
onChange: MutableRefObject<EditorProps['onChange']>;
onFocus: MutableRefObject<EditorProps['onFocus']>;
onBlur: MutableRefObject<EditorProps['onBlur']>;
}) {
// TODO: Ensure tooltips render inside the dialog if we are in one.
const parent =
container?.closest<HTMLDivElement>('[role="dialog"]') ??
@@ -138,9 +223,7 @@ function getExtensions({
keymap.of(singleLine ? defaultKeymap.filter((k) => k.key !== 'Enter') : defaultKeymap),
...(singleLine ? [singleLineExt()] : []),
...(!singleLine ? [multiLineExtensions] : []),
...(ext ? [ext] : []),
...(readOnly ? [EditorState.readOnly.of(true)] : []),
...(placeholder ? [placeholderExt(placeholder)] : []),
...(singleLine
? [
EditorView.domEventHandlers({
@@ -160,15 +243,35 @@ function getExtensions({
]
: []),
// Handle onFocus
EditorView.domEventHandlers({
focus: onFocus.current,
blur: onBlur.current,
}),
// Handle onChange
EditorView.updateListener.of((update) => {
if (typeof onChange === 'function' && update.docChanged) {
onChange(update.state.doc.toString());
if (onChange && update.docChanged && isViewUpdateFromUserInput(update)) {
onChange.current?.(update.state.doc.toString());
}
}),
];
}
function isViewUpdateFromUserInput(viewUpdate: ViewUpdate) {
// Make sure document has changed, ensuring user events like selections don't count.
if (viewUpdate.docChanged) {
// Check transactions for any that are direct user input, not changes from Y.js or another extension.
for (const transaction of viewUpdate.transactions) {
// Not using Transaction.isUserEvent because that only checks for a specific User event type ( "input", "delete", etc.). Checking the annotation directly allows for any type of user event.
const userEventType = transaction.annotation(Transaction.userEvent);
if (userEventType) return userEventType;
}
}
return false;
}
const syncGutterBg = ({
parent,
className = '',
@@ -186,3 +289,9 @@ const syncGutterBg = ({
gutterEl?.classList.add(...bgClasses);
}
};
const placeholderElFromText = (text: string) => {
const el = document.createElement('div');
el.innerHTML = text.replace('\n', '<br/>');
return el;
};

View File

@@ -32,24 +32,29 @@ import {
rectangularSelection,
} from '@codemirror/view';
import { tags as t } from '@lezer/highlight';
import { graphqlLanguageSupport } from 'cm6-graphql';
import { debouncedAutocompletionDisplay } from './autocomplete';
import { graphql, graphqlLanguageSupport } from 'cm6-graphql';
import type { EditorProps } from './index';
import { text } from './text/extension';
import { twig } from './twig/extension';
import { url } from './url/extension';
export const myHighlightStyle = HighlightStyle.define([
{
tag: [t.documentMeta, t.blockComment, t.lineComment, t.docComment, t.comment],
color: '#757b93',
color: 'hsl(var(--color-gray-600))',
fontStyle: 'italic',
},
{
tag: [t.paren],
color: 'hsl(var(--color-gray-900))',
},
{
tag: [t.name, t.tagName, t.angleBracket, t.docString, t.number],
color: 'hsl(var(--color-blue-600))',
},
{ tag: [t.variableName], color: 'hsl(var(--color-green-600))' },
{ tag: [t.bool], color: 'hsl(var(--color-pink-600))' },
{ tag: [t.attributeName], color: 'hsl(var(--color-violet-600))' },
{ tag: [t.attributeName, t.propertyName], color: 'hsl(var(--color-violet-600))' },
{ tag: [t.attributeValue], color: 'hsl(var(--color-orange-600))' },
{ tag: [t.string], color: 'hsl(var(--color-yellow-600))' },
{ tag: [t.keyword, t.meta, t.operator], color: 'hsl(var(--color-red-600))' },
@@ -78,7 +83,7 @@ export const myHighlightStyle = HighlightStyle.define([
// ]);
const syntaxExtensions: Record<string, LanguageSupport> = {
'application/graphql+json': graphqlLanguageSupport(),
'application/graphql': graphqlLanguageSupport(),
'application/json': json(),
'application/javascript': javascript(),
'text/html': html(),
@@ -89,18 +94,19 @@ const syntaxExtensions: Record<string, LanguageSupport> = {
export function getLanguageExtension({
contentType,
useTemplating,
}: {
contentType?: string;
useTemplating?: boolean;
}) {
useTemplating = false,
autocomplete,
}: Pick<EditorProps, 'contentType' | 'useTemplating' | 'autocomplete'>) {
if (contentType === 'application/graphql') {
return graphql();
}
const justContentType = contentType?.split(';')[0] ?? contentType ?? '';
const base = syntaxExtensions[justContentType] ?? json();
const base = syntaxExtensions[justContentType] ?? text();
if (!useTemplating) {
return [base];
return base ? base : [];
}
return twig(base);
return twig(base, autocomplete);
}
export const baseExtensions = [
@@ -112,7 +118,14 @@ export const baseExtensions = [
// TODO: Figure out how to debounce showing of autocomplete in a good way
// debouncedAutocompletionDisplay({ millis: 1000 }),
// autocompletion({ closeOnBlur: true, interactionDelay: 200, activateOnTyping: false }),
autocompletion({ closeOnBlur: true, interactionDelay: 300 }),
autocompletion({
// closeOnBlur: false,
interactionDelay: 200,
compareCompletions: (a, b) => {
// Don't sort completions at all, only on boost
return (a.boost ?? 0) - (b.boost ?? 0);
},
}),
syntaxHighlighting(myHighlightStyle),
EditorState.allowMultipleSelections.of(true),
];

View File

@@ -0,0 +1,29 @@
import type { CompletionContext } from '@codemirror/autocomplete';
export interface GenericCompletionOption {
label: string;
type: 'constant' | 'variable';
/** When given, should be a number from -99 to 99 that adjusts
* how this completion is ranked compared to other completions
* that match the input as well as this one. A negative number
* moves it down the list, a positive number moves it up. */
boost?: number;
}
export interface GenericCompletionConfig {
minMatch?: number;
options: GenericCompletionOption[];
}
export function genericCompletion({ options, minMatch = 1 }: GenericCompletionConfig) {
return function completions(context: CompletionContext) {
const toMatch = context.matchBefore(/^.*/);
if (toMatch === null) return null;
const matchedMinimumLength = toMatch.to - toMatch.from >= minMatch;
if (!matchedMinimumLength && !context.explicit) return null;
const optionsWithoutExactMatches = options.filter((o) => o.label !== toMatch.text);
return { from: toMatch.from, options: optionsWithoutExactMatches, info: 'hello' };
};
}

View File

@@ -1,6 +1,12 @@
import { memo } from 'react';
import { _Editor } from './Editor';
import type { _EditorProps } from './Editor';
import * as editor from './Editor';
export type EditorProps = _EditorProps;
export const Editor = memo(_Editor);
export type { EditorProps } from './Editor';
// TODO: Figure out why code-splitting breaks production build from
// showing any content
// const editor = await import('./Editor');
export const Editor = editor.Editor;
export const graphql = editor.graphql;
export const getIntrospectionQuery = editor.getIntrospectionQuery;
export const buildClientSchema = editor.buildClientSchema;
export const formatGraphQL = editor.formatSdl;

View File

@@ -0,0 +1,14 @@
import { LanguageSupport, LRLanguage } from '@codemirror/language';
import { parser } from './text';
export const textLanguageName = 'text';
const textLanguage = LRLanguage.define({
name: textLanguageName,
parser,
languageData: {},
});
export function text() {
return new LanguageSupport(textLanguage);
}

View File

@@ -0,0 +1,5 @@
@top Template { Text }
@tokens {
Text { ![]+ }
}

View File

@@ -0,0 +1,4 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
export const
Template = 1,
Text = 2

View File

@@ -0,0 +1,16 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
import {LRParser} from "@lezer/lr"
export const parser = LRParser.deserialize({
version: 14,
states: "[OQOPOOQOOOOO",
stateData: "V~OQPO~O",
goto: "QPP",
nodeNames: "⚠ Template Text",
maxTerm: 3,
skippedNodes: [0],
repeatNodeCount: 0,
tokenData: "p~RRO;'S[;'S;=`j<%lO[~aRQ~O;'S[;'S;=`j<%lO[~mP;=`<%l[",
tokenizers: [0],
topRules: {"Template":[0,1]},
tokenPrec: 0
})

View File

@@ -6,6 +6,7 @@ const closeTag = ' ]}';
const variables = [
{ name: 'DOMAIN' },
{ name: 'BASE_URL' },
{ name: 'CONTENT_THINGY' },
{ name: 'TOKEN' },
{ name: 'PROJECT_ID' },
{ name: 'DUMMY' },
@@ -17,7 +18,7 @@ const variables = [
];
const MIN_MATCH_VAR = 2;
const MIN_MATCH_NAME = 4;
const MIN_MATCH_NAME = 3;
export function completions(context: CompletionContext) {
const toStartOfName = context.matchBefore(/\w*/);

View File

@@ -1,35 +1,41 @@
import { LanguageSupport, LRLanguage } from '@codemirror/language';
import type { LanguageSupport } from '@codemirror/language';
import { LRLanguage } from '@codemirror/language';
import { parseMixed } from '@lezer/common';
import type { GenericCompletionConfig } from '../genericCompletion';
import { genericCompletion } from '../genericCompletion';
import { placeholders } from '../placeholder';
import { textLanguageName } from '../text/extension';
import { completions } from './completion';
import { placeholders } from '../widgets';
import { parser as twigParser } from './twig';
export function twig(base?: LanguageSupport) {
const language = mixedOrPlainLanguage(base);
const completion = language.data.of({
autocomplete: completions,
});
const languageSupport = new LanguageSupport(language, [completion]);
export function twig(base: LanguageSupport, autocomplete?: GenericCompletionConfig) {
const language = mixLanguage(base);
const completion = language.data.of({ autocomplete: completions });
const completionBase = base.language.data.of({ autocomplete: completions });
const additionalCompletion = autocomplete
? [language.data.of({ autocomplete: genericCompletion(autocomplete) })]
: [];
if (base) {
const completion2 = base.language.data.of({ autocomplete: completions });
const languageSupport2 = new LanguageSupport(base.language, [completion2]);
return [languageSupport, languageSupport2, placeholders, base.support];
} else {
return [languageSupport, placeholders];
}
return [
language,
completion,
completionBase,
base.support,
placeholders,
...additionalCompletion,
];
}
function mixedOrPlainLanguage(base?: LanguageSupport): LRLanguage {
function mixLanguage(base: LanguageSupport): LRLanguage {
const name = 'twig';
if (base == null) {
return LRLanguage.define({ name, parser: twigParser });
}
const parser = twigParser.configure({
wrap: parseMixed((node) => {
if (!node.type.isTop) return null;
// If the base language is text, we can overwrite at the top
if (base.language.name !== textLanguageName && !node.type.isTop) {
return null;
}
return {
parser: base.language.parser,
overlay: (node) => node.type.name === 'Text',

View File

@@ -1,7 +1,7 @@
import { styleTags, tags as t } from '@lezer/highlight';
export const highlight = styleTags({
'if endif': t.controlKeyword,
'${[ ]}': t.meta,
DirectiveContent: t.variableName,
Open: t.tagName,
Close: t.tagName,
Content: t.keyword,
});

View File

@@ -1,19 +1,17 @@
@top Template { (directive | Text)* }
@top Template { (Tag | Text)* }
directive {
Insert
@local tokens {
Close { "]}" }
@else Content
}
@skip {space} {
Insert { "${[" DirectiveContent "]}" }
@skip { } {
Open { "${[" }
Tag { Open (Content)+ Close }
}
@tokens {
Text { ![$] Text? }
space { @whitespace+ }
DirectiveContent { ![\]}] DirectiveContent? }
@precedence { space DirectiveContent }
"${[" "]}"
}
@external propSource highlight from "./highlight"

View File

@@ -1,6 +1,8 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
export const
Template = 1,
Insert = 2,
DirectiveContent = 4,
Tag = 2,
Open = 3,
Content = 4,
Close = 5,
Text = 6

View File

@@ -1,18 +1,18 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
import {LRParser} from "@lezer/lr"
import {LRParser, LocalTokenGroup} from "@lezer/lr"
import {highlight} from "./highlight"
export const parser = LRParser.deserialize({
version: 14,
states: "zQVOPOOO_QQO'#C^OOOO'#Cc'#CcQVOPOOOdQQO,58xOOOO-E6a-E6aOOOO1G.d1G.d",
stateData: "l~OYOS~ORPOUQO~OSSO~OTUO~OYS~",
goto: "cWPPXPPPP]TQORQRORTR",
nodeNames: "⚠ Template Insert ${[ DirectiveContent ]} Text",
states: "!^QQOPOOOOOO'#C_'#C_OYOQO'#C^OOOO'#Cc'#CcQQOPOOOOOO'#Cd'#CdO_OQO,58xOOOO-E6a-E6aOOOO-E6b-E6bOOOO1G.d1G.d",
stateData: "g~OUROYPO~OSTO~OSTOTXO~O",
goto: "nXPPY^PPPbhTROSTQOSQSORVSQUQRWU",
nodeNames: "⚠ Template Tag Open Content Close Text",
maxTerm: 10,
propSources: [highlight],
skippedNodes: [0],
repeatNodeCount: 1,
tokenData: ")gRRmOX!|X^$y^p!|pq$yqt!|tu&}u#P!|#P#Q(k#Q#q!|#q#r$[#r#y!|#y#z$y#z$f!|$f$g$y$g#BY!|#BY#BZ$y#BZ$IS!|$IS$I_$y$I_$I|!|$I|$JO$y$JO$JT!|$JT$JU$y$JU$KV!|$KV$KW$y$KW&FU!|&FU&FV$y&FV;'S!|;'S;=`$s<%lO!|R#TXUPSQOt!|tu#pu#P!|#P#Q$[#Q#q!|#q#r$[#r;'S!|;'S;=`$s<%lO!|Q#uTSQO#P#p#Q#q#p#r;'S#p;'S;=`$U<%lO#pQ$XP;=`<%l#pP$aSUPOt$[u;'S$[;'S;=`$m<%lO$[P$pP;=`<%l$[R$vP;=`<%l!|R%SmUPYQSQOX!|X^$y^p!|pq$yqt!|tu#pu#P!|#P#Q$[#Q#q!|#q#r$[#r#y!|#y#z$y#z$f!|$f$g$y$g#BY!|#BY#BZ$y#BZ$IS!|$IS$I_$y$I_$I|!|$I|$JO$y$JO$JT!|$JT$JU$y$JU$KV!|$KV$KW$y$KW&FU!|&FU&FV$y&FV;'S!|;'S;=`$s<%lO!|R'SVSQO#P#p#Q#o#p#o#p'i#p#q#p#r;'S#p;'S;=`$U<%lO#pR'nVSQO!}#p!}#O(T#O#P#p#Q#q#p#r;'S#p;'S;=`$U<%lO#pR([TRPSQO#P#p#Q#q#p#r;'S#p;'S;=`$U<%lO#pR(pUUPOt$[u#q$[#q#r)S#r;'S$[;'S;=`$m<%lO$[R)ZSTQUPOt$[u;'S$[;'S;=`$m<%lO$[",
tokenizers: [0, 1],
repeatNodeCount: 2,
tokenData: "![~RTOtbtuyu;'Sb;'S;=`s<%lOb~gSU~Otbu;'Sb;'S;=`s<%lOb~vP;=`<%lb~|P#o#p!P~!SP!}#O!V~![OY~",
tokenizers: [1, new LocalTokenGroup("b~RP#P#QU~XP#q#r[~aOT~~", 17, 4)],
topRules: {"Template":[0,1]},
tokenPrec: 25
tokenPrec: 0
})

View File

@@ -1,19 +1,9 @@
import type { CompletionContext } from '@codemirror/autocomplete';
import { genericCompletion } from '../genericCompletion';
const options = [
{ label: 'http://', type: 'constant' },
{ label: 'https://', type: 'constant' },
];
const MIN_MATCH = 1;
export function completions(context: CompletionContext) {
const toMatch = context.matchBefore(/^[\w:/]*/);
if (toMatch === null) return null;
const matchedMinimumLength = toMatch.to - toMatch.from >= MIN_MATCH;
if (!matchedMinimumLength && !context.explicit) return null;
const optionsWithoutExactMatches = options.filter((o) => o.label !== toMatch.text);
return { from: toMatch.from, options: optionsWithoutExactMatches };
}
export const completions = genericCompletion({
options: [
{ label: 'http://', type: 'constant' },
{ label: 'https://', type: 'constant' },
],
minMatch: 1,
});

View File

@@ -1,12 +1,7 @@
import classnames from 'classnames';
import type { ReactNode } from 'react';
import type { HTMLAttributes } from 'react';
type Props = {
className?: string;
children?: ReactNode;
};
export function Heading({ className, children, ...props }: Props) {
export function Heading({ className, children, ...props }: HTMLAttributes<HTMLHeadingElement>) {
return (
<h1 className={classnames(className, 'text-2xl font-semibold text-gray-900 mb-3')} {...props}>
{children}

View File

@@ -1,41 +1,68 @@
import {
ArchiveIcon,
CameraIcon,
CheckboxIcon,
CheckIcon,
ChevronDownIcon,
ClockIcon,
CodeIcon,
ColorWheelIcon,
CopyIcon,
Cross2Icon,
DividerHorizontalIcon,
DotsHorizontalIcon,
DotsVerticalIcon,
DragHandleDots2Icon,
EyeClosedIcon,
EyeOpenIcon,
GearIcon,
HamburgerMenuIcon,
HomeIcon,
MoonIcon,
ListBulletIcon,
MagicWandIcon,
MagnifyingGlassIcon,
MoonIcon,
PaperPlaneIcon,
PlusCircledIcon,
PlusIcon,
QuestionMarkIcon,
RowsIcon,
SunIcon,
TrashIcon,
TriangleDownIcon,
TriangleLeftIcon,
TriangleRightIcon,
UpdateIcon,
RowsIcon,
} from '@radix-ui/react-icons';
import classnames from 'classnames';
import { memo } from 'react';
import { ReactComponent as LeftPanelHiddenIcon } from '../../assets/icons/LeftPanelHiddenIcon.svg';
import { ReactComponent as LeftPanelVisibleIcon } from '../../assets/icons/LeftPanelVisibleIcon.svg';
const icons = {
archive: ArchiveIcon,
camera: CameraIcon,
check: CheckIcon,
checkbox: CheckboxIcon,
clock: ClockIcon,
chevronDown: ChevronDownIcon,
code: CodeIcon,
colorWheel: ColorWheelIcon,
copy: CopyIcon,
dividerH: DividerHorizontalIcon,
dotsH: DotsHorizontalIcon,
dotsV: DotsVerticalIcon,
drag: DragHandleDots2Icon,
eye: EyeOpenIcon,
eyeClosed: EyeClosedIcon,
gear: GearIcon,
hamburger: HamburgerMenuIcon,
home: HomeIcon,
leftPanelHidden: LeftPanelHiddenIcon,
leftPanelVisible: LeftPanelVisibleIcon,
listBullet: ListBulletIcon,
magicWand: MagicWandIcon,
magnifyingGlass: MagnifyingGlassIcon,
moon: MoonIcon,
paperPlane: PaperPlaneIcon,
plus: PlusIcon,
@@ -49,6 +76,7 @@ const icons = {
triangleRight: TriangleRightIcon,
update: UpdateIcon,
x: Cross2Icon,
empty: () => <span />,
};
export interface IconProps {
@@ -58,7 +86,7 @@ export interface IconProps {
spin?: boolean;
}
export function Icon({ icon, spin, size = 'md', className }: IconProps) {
export const Icon = memo(function Icon({ icon, spin, size = 'md', className }: IconProps) {
const Component = icons[icon] ?? icons.question;
return (
<Component
@@ -66,10 +94,10 @@ export function Icon({ icon, spin, size = 'md', className }: IconProps) {
className,
'text-inherit',
size === 'md' && 'h-4 w-4',
size === 'sm' && 'h-3 w-3',
size === 'xs' && 'h-2 w-2',
size === 'sm' && 'h-3.5 w-3.5',
size === 'xs' && 'h-3 w-3',
spin && 'animate-spin',
)}
/>
);
}
});

View File

@@ -1,35 +1,73 @@
import classnames from 'classnames';
import { forwardRef } from 'react';
import type { MouseEvent } from 'react';
import { forwardRef, memo, useCallback } from 'react';
import { useTimedBoolean } from '../../hooks/useTimedBoolean';
import type { ButtonProps } from './Button';
import { Button } from './Button';
import type { IconProps } from './Icon';
import { Icon } from './Icon';
type Props = IconProps & ButtonProps & { iconClassName?: string; iconSize?: IconProps['size'] };
type Props = IconProps &
ButtonProps & {
showConfirm?: boolean;
iconClassName?: string;
iconSize?: IconProps['size'];
title: string;
};
export const IconButton = forwardRef<HTMLButtonElement, Props>(function IconButton(
{ icon, spin, className, iconClassName, size = 'md', iconSize, ...props }: Props,
const _IconButton = forwardRef<HTMLButtonElement, Props>(function IconButton(
{
showConfirm,
icon,
spin,
onClick,
className,
iconClassName,
tabIndex,
size = 'md',
iconSize,
...props
}: Props,
ref,
) {
const [confirmed, setConfirmed] = useTimedBoolean();
const handleClick = useCallback(
(e: MouseEvent<HTMLElement>) => {
if (showConfirm) setConfirmed();
onClick?.(e);
},
[onClick, setConfirmed, showConfirm],
);
return (
<Button
ref={ref}
aria-hidden={icon === 'empty'}
disabled={icon === 'empty'}
tabIndex={tabIndex ?? icon === 'empty' ? -1 : undefined}
onClick={handleClick}
className={classnames(
className,
'text-gray-700 hover:text-gray-1000',
'flex-shrink-0 text-gray-700 hover:text-gray-1000',
'!px-0',
size === 'md' && 'w-9',
size === 'sm' && 'w-9',
size === 'sm' && 'w-8',
size === 'xs' && 'w-6',
)}
size={size}
{...props}
>
<Icon
size={iconSize}
icon={icon}
icon={confirmed ? 'check' : icon}
spin={spin}
className={classnames(iconClassName, props.disabled && 'opacity-70')}
className={classnames(
iconClassName,
props.disabled && 'opacity-70',
confirmed && 'text-green-600',
)}
/>
</Button>
);
});
export const IconButton = memo(_IconButton);

View File

@@ -0,0 +1,14 @@
import classnames from 'classnames';
import type { HTMLAttributes } from 'react';
export function InlineCode({ className, ...props }: HTMLAttributes<HTMLSpanElement>) {
return (
<code
className={classnames(
className,
'font-mono text-sm bg-highlight border-0 border-gray-200 px-1.5 py-0.5 rounded text-gray-800 shadow-inner',
)}
{...props}
/>
);
}

View File

@@ -1,57 +1,102 @@
import classnames from 'classnames';
import type { ReactNode } from 'react';
import type { EditorView } from 'codemirror';
import type { HTMLAttributes, ReactNode } from 'react';
import { forwardRef, useCallback, useMemo, useState } from 'react';
import type { EditorProps } from './Editor';
import { Editor } from './Editor';
import { IconButton } from './IconButton';
import { HStack, VStack } from './Stacks';
interface Props {
name: string;
label: string;
hideLabel?: boolean;
labelClassName?: string;
containerClassName?: string;
onChange?: (value: string) => void;
useEditor?: Pick<EditorProps, 'contentType' | 'useTemplating'>;
defaultValue?: string;
leftSlot?: ReactNode;
rightSlot?: ReactNode;
size?: 'sm' | 'md';
className?: string;
placeholder?: string;
autoFocus?: boolean;
}
export type InputProps = Omit<HTMLAttributes<HTMLInputElement>, 'onChange' | 'onFocus'> &
Pick<EditorProps, 'contentType' | 'useTemplating' | 'autocomplete' | 'forceUpdateKey'> & {
name: string;
type?: 'text' | 'password';
label: string;
hideLabel?: boolean;
labelClassName?: string;
containerClassName?: string;
onChange?: (value: string) => void;
onFocus?: () => void;
onBlur?: () => void;
defaultValue?: string;
leftSlot?: ReactNode;
rightSlot?: ReactNode;
size?: 'sm' | 'md';
className?: string;
placeholder?: string;
autoFocus?: boolean;
validate?: (v: string) => boolean;
require?: boolean;
};
export const Input = forwardRef<EditorView | undefined, InputProps>(function Input(
{
label,
type = 'text',
hideLabel,
className,
containerClassName,
labelClassName,
onChange,
placeholder,
size = 'md',
name,
leftSlot,
rightSlot,
defaultValue,
validate,
require,
onFocus,
onBlur,
forceUpdateKey,
...props
}: InputProps,
ref,
) {
const [obscured, setObscured] = useState(type === 'password');
const [currentValue, setCurrentValue] = useState(defaultValue ?? '');
const [focused, setFocused] = useState(false);
const handleOnFocus = useCallback(() => {
setFocused(true);
onFocus?.();
}, [onFocus]);
const handleOnBlur = useCallback(() => {
setFocused(false);
onBlur?.();
}, [onBlur]);
export function Input({
label,
hideLabel,
className,
containerClassName,
labelClassName,
onChange,
placeholder,
size = 'md',
useEditor,
name,
leftSlot,
rightSlot,
defaultValue,
...props
}: Props) {
const id = `input-${name}`;
const inputClassName = classnames(
className,
'!bg-transparent pl-3 pr-2 min-w-0 h-full w-full focus:outline-none placeholder:text-placeholder',
!!leftSlot && '!pl-0.5',
!!rightSlot && '!pr-0.5',
'!bg-transparent min-w-0 h-full w-full focus:outline-none placeholder:text-placeholder',
// Bump things over if the slots are occupied
leftSlot && 'pl-0.5 -ml-2',
rightSlot && 'pr-0.5 -mr-2',
);
const isValid = useMemo(() => {
if (require && !validateRequire(currentValue)) return false;
if (validate && !validate(currentValue)) return false;
return true;
}, [currentValue, validate, require]);
const handleChange = useCallback(
(value: string) => {
setCurrentValue(value);
onChange?.(value);
},
[onChange],
);
return (
<VStack>
<VStack className="w-full">
<label
htmlFor={id}
className={classnames(
labelClassName,
'font-semibold text-sm uppercase text-gray-700',
'font-semibold text-xs uppercase text-gray-700',
hideLabel && 'sr-only',
)}
>
@@ -62,35 +107,44 @@ export function Input({
className={classnames(
containerClassName,
'relative w-full rounded-md text-gray-900',
'border border-gray-200 focus-within:border-blue-400/40',
size === 'md' && 'h-9',
size === 'sm' && 'h-7',
'border border-highlight',
focused && 'border-focus',
!isValid && '!border-invalid',
size === 'md' && 'h-md leading-md',
size === 'sm' && 'h-sm leading-sm',
)}
>
{leftSlot}
{useEditor ? (
<Editor
id={id}
singleLine
defaultValue={defaultValue}
placeholder={placeholder}
onChange={onChange}
className={inputClassName}
{...props}
{...useEditor}
/>
) : (
<input
id={id}
onChange={(e) => onChange?.(e.currentTarget.value)}
placeholder={placeholder}
defaultValue={defaultValue}
className={inputClassName}
{...props}
<Editor
ref={ref}
id={id}
singleLine
type={type === 'password' && !obscured ? 'text' : type}
defaultValue={defaultValue}
forceUpdateKey={forceUpdateKey}
placeholder={placeholder}
onChange={handleChange}
className={inputClassName}
onFocus={handleOnFocus}
onBlur={handleOnBlur}
{...props}
/>
{type === 'password' && (
<IconButton
title={obscured ? `Show ${label}` : `Obscure ${label}`}
size="xs"
className="mr-0.5"
iconSize="sm"
icon={obscured ? 'eyeClosed' : 'eye'}
onClick={() => setObscured((o) => !o)}
/>
)}
{rightSlot}
</HStack>
</VStack>
);
});
function validateRequire(v: string) {
return v.length > 0;
}

View File

@@ -0,0 +1,347 @@
import classnames from 'classnames';
import React, { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { XYCoord } from 'react-dnd';
import { useDrag, useDrop } from 'react-dnd';
import { v4 as uuid } from 'uuid';
import { DropMarker } from '../DropMarker';
import { Checkbox } from './Checkbox';
import type { GenericCompletionConfig } from './Editor/genericCompletion';
import { Icon } from './Icon';
import { IconButton } from './IconButton';
import type { InputProps } from './Input';
import { Input } from './Input';
export type PairEditorProps = {
pairs: Pair[];
onChange: (pairs: Pair[]) => void;
forceUpdateKey?: string;
className?: string;
namePlaceholder?: string;
valuePlaceholder?: string;
nameAutocomplete?: GenericCompletionConfig;
valueAutocomplete?: (name: string) => GenericCompletionConfig | undefined;
nameValidate?: InputProps['validate'];
valueValidate?: InputProps['validate'];
};
type Pair = {
enabled?: boolean;
name: string;
value: string;
};
type PairContainer = {
pair: Pair;
id: string;
};
export const PairEditor = memo(function PairEditor({
pairs: originalPairs,
forceUpdateKey,
nameAutocomplete,
valueAutocomplete,
namePlaceholder,
valuePlaceholder,
nameValidate,
valueValidate,
className,
onChange,
}: PairEditorProps) {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const [pairs, setPairs] = useState<PairContainer[]>(() => {
// Remove empty headers on initial render
const nonEmpty = originalPairs.filter((h) => !(h.name === '' && h.value === ''));
const pairs = nonEmpty.map((pair) => newPairContainer(pair));
return [...pairs, newPairContainer()];
});
useEffect(() => {
// Remove empty headers on initial render
// TODO: Make this not refresh the entire editor when forceUpdateKey changes, using some
// sort of diff method or deterministic IDs based on array index and update key
const nonEmpty = originalPairs.filter((h) => !(h.name === '' && h.value === ''));
const pairs = nonEmpty.map((pair) => newPairContainer(pair));
setPairs([...pairs, newPairContainer()]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [forceUpdateKey]);
const setPairsAndSave = useCallback(
(fn: (pairs: PairContainer[]) => PairContainer[]) => {
setPairs((oldPairs) => {
const pairs = fn(oldPairs).map((p) => p.pair);
onChange(pairs);
return fn(oldPairs);
});
},
[onChange],
);
const handleMove = useCallback<FormRowProps['onMove']>(
(id, side) => {
const dragIndex = pairs.findIndex((r) => r.id === id);
setHoveredIndex(side === 'above' ? dragIndex : dragIndex + 1);
},
[pairs],
);
const handleEnd = useCallback<FormRowProps['onEnd']>(
(id: string) => {
if (hoveredIndex === null) return;
setHoveredIndex(null);
setPairsAndSave((pairs) => {
const index = pairs.findIndex((p) => p.id === id);
const pair = pairs[index];
if (pair === undefined) return pairs;
const newPairs = pairs.filter((p) => p.id !== id);
if (hoveredIndex > index) newPairs.splice(hoveredIndex - 1, 0, pair);
else newPairs.splice(hoveredIndex, 0, pair);
return newPairs;
});
},
[hoveredIndex, setPairsAndSave],
);
const handleChange = useCallback(
(pair: PairContainer) =>
setPairsAndSave((pairs) => pairs.map((p) => (pair.id !== p.id ? p : pair))),
[setPairsAndSave],
);
const handleDelete = useCallback(
(pair: PairContainer) =>
setPairsAndSave((oldPairs) => oldPairs.filter((p) => p.id !== pair.id)),
[setPairsAndSave],
);
const handleFocus = useCallback(
(pair: PairContainer) =>
setPairs((pairs) => {
const isLast = pair.id === pairs[pairs.length - 1]?.id;
return isLast ? [...pairs, newPairContainer()] : pairs;
}),
[],
);
// Ensure there's always at least one pair
useEffect(() => {
if (pairs.length === 0) {
setPairs((pairs) => [...pairs, newPairContainer()]);
}
}, [pairs]);
return (
<div
className={classnames(
className,
'@container',
'pb-2 grid',
// Move over the width of the drag handle
'-ml-3',
)}
>
{pairs.map((p, i) => {
const isLast = i === pairs.length - 1;
return (
<Fragment key={p.id}>
{hoveredIndex === i && <DropMarker />}
<FormRow
pairContainer={p}
className="py-1"
isLast={isLast}
forceUpdateKey={forceUpdateKey}
nameAutocomplete={nameAutocomplete}
valueAutocomplete={valueAutocomplete}
namePlaceholder={namePlaceholder}
valuePlaceholder={valuePlaceholder}
nameValidate={nameValidate}
valueValidate={valueValidate}
onChange={handleChange}
onFocus={handleFocus}
onDelete={isLast ? undefined : handleDelete}
onEnd={handleEnd}
onMove={handleMove}
/>
</Fragment>
);
})}
</div>
);
});
enum ItemTypes {
ROW = 'pair-row',
}
type FormRowProps = {
className?: string;
pairContainer: PairContainer;
onMove: (id: string, side: 'above' | 'below') => void;
onEnd: (id: string) => void;
onChange: (pair: PairContainer) => void;
onDelete?: (pair: PairContainer) => void;
onFocus?: (pair: PairContainer) => void;
isLast?: boolean;
} & Pick<
PairEditorProps,
| 'nameAutocomplete'
| 'valueAutocomplete'
| 'namePlaceholder'
| 'valuePlaceholder'
| 'nameValidate'
| 'valueValidate'
| 'forceUpdateKey'
>;
const FormRow = memo(function FormRow({
className,
pairContainer,
onChange,
onDelete,
onFocus,
onMove,
onEnd,
isLast,
forceUpdateKey,
nameAutocomplete,
valueAutocomplete,
namePlaceholder,
valuePlaceholder,
nameValidate,
valueValidate,
}: FormRowProps) {
const { id } = pairContainer;
const ref = useRef<HTMLDivElement>(null);
const handleChangeEnabled = useMemo(
() => (enabled: boolean) => onChange({ id, pair: { ...pairContainer.pair, enabled } }),
[id, onChange, pairContainer.pair],
);
const handleChangeName = useMemo(
() => (name: string) => onChange({ id, pair: { ...pairContainer.pair, name } }),
[onChange, id, pairContainer.pair],
);
const handleChangeValue = useMemo(
() => (value: string) => onChange({ id, pair: { ...pairContainer.pair, value } }),
[onChange, id, pairContainer.pair],
);
const handleFocus = useCallback(() => onFocus?.(pairContainer), [onFocus, pairContainer]);
const handleDelete = useCallback(() => onDelete?.(pairContainer), [onDelete, pairContainer]);
const [, connectDrop] = useDrop<PairContainer>(
{
accept: ItemTypes.ROW,
hover: (item, monitor) => {
if (!ref.current) return;
const hoverBoundingRect = ref.current?.getBoundingClientRect();
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
const clientOffset = monitor.getClientOffset();
const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top;
onMove(pairContainer.id, hoverClientY < hoverMiddleY ? 'above' : 'below');
},
},
[onMove],
);
const [, connectDrag] = useDrag(
{
type: ItemTypes.ROW,
item: () => pairContainer,
collect: (m) => ({ isDragging: m.isDragging() }),
end: () => onEnd(pairContainer.id),
},
[pairContainer, onEnd],
);
connectDrag(ref);
connectDrop(ref);
return (
<div
ref={ref}
className={classnames(
className,
'group grid grid-cols-[auto_auto_minmax(0,1fr)_auto]',
'grid-rows-1 items-center',
!pairContainer.pair.enabled && 'opacity-60',
)}
>
{!isLast ? (
<div
className={classnames(
'py-2 h-7 w-3 flex items-center',
'justify-center opacity-0 hover:opacity-100',
)}
>
<Icon icon="drag" className="pointer-events-none" />
</div>
) : (
<span className="w-3" />
)}
<Checkbox
disabled={isLast}
checked={isLast ? false : !!pairContainer.pair.enabled}
className={classnames('mr-2', isLast && '!opacity-disabled')}
onChange={handleChangeEnabled}
/>
<div
className={classnames(
'grid items-center',
'@xs:gap-2 @xs:!grid-rows-1 @xs:!grid-cols-[minmax(0,1fr)_minmax(0,1fr)]',
'gap-0.5 grid-cols-1 grid-rows-2',
)}
>
<Input
hideLabel
size="sm"
require={!isLast && !!pairContainer.pair.enabled && !!pairContainer.pair.value}
validate={nameValidate}
useTemplating
forceUpdateKey={forceUpdateKey}
containerClassName={classnames(isLast && 'border-dashed')}
defaultValue={pairContainer.pair.name}
label="Name"
name="name"
onChange={handleChangeName}
onFocus={handleFocus}
placeholder={namePlaceholder ?? 'name'}
autocomplete={nameAutocomplete}
/>
<Input
hideLabel
size="sm"
containerClassName={classnames(isLast && 'border-dashed')}
validate={valueValidate}
forceUpdateKey={forceUpdateKey}
defaultValue={pairContainer.pair.value}
label="Value"
name="value"
onChange={handleChangeValue}
onFocus={handleFocus}
placeholder={valuePlaceholder ?? 'value'}
useTemplating
autocomplete={valueAutocomplete?.(pairContainer.pair.name)}
/>
</div>
<IconButton
aria-hidden={!onDelete}
disabled={!onDelete}
color="custom"
icon={onDelete ? 'trash' : 'empty'}
size="sm"
title="Delete header"
onClick={handleDelete}
className="ml-0.5 !opacity-0 group-hover:!opacity-100 focus-visible:!opacity-100"
/>
</div>
);
});
const newPairContainer = (pair?: Pair): PairContainer => {
return { pair: pair ?? { name: '', value: '', enabled: true }, id: uuid() };
};

View File

@@ -0,0 +1,46 @@
import { useMemo } from 'react';
import type { DropdownItemSeparator, DropdownProps } from './Dropdown';
import { Dropdown } from './Dropdown';
import { Icon } from './Icon';
export type RadioDropdownItem<T = string | null> =
| {
type?: 'default';
label: string;
shortLabel?: string;
value: T;
}
| DropdownItemSeparator;
export interface RadioDropdownProps<T = string | null> {
value: T;
onChange: (value: T) => void;
items: RadioDropdownItem<T>[];
children: DropdownProps['children'];
}
export function RadioDropdown<T = string | null>({
value,
items,
onChange,
children,
}: RadioDropdownProps<T>) {
const dropdownItems = useMemo(
() =>
items.map((item) => {
if (item.type === 'separator') {
return item;
} else {
return {
label: item.label,
shortLabel: item.shortLabel,
onSelect: () => onChange(item.value),
leftSlot: <Icon icon={value === item.value ? 'check' : 'empty'} />,
};
}
}),
[items, value, onChange],
);
return <Dropdown items={dropdownItems}>{children}</Dropdown>;
}

View File

@@ -1,34 +0,0 @@
import * as S from '@radix-ui/react-scroll-area';
import classnames from 'classnames';
import type { ReactNode } from 'react';
interface Props {
children: ReactNode;
className?: string;
}
export function ScrollArea({ children, className }: Props) {
return (
<S.Root className={classnames(className, 'group/scroll')} type="always">
<S.Viewport>{children}</S.Viewport>
<ScrollBar orientation="vertical" />
<ScrollBar orientation="horizontal" />
<S.Corner />
</S.Root>
);
}
function ScrollBar({ orientation }: { orientation: 'vertical' | 'horizontal' }) {
return (
<S.Scrollbar
orientation={orientation}
className={classnames(
'flex bg-transparent rounded-full',
orientation === 'vertical' && 'w-1.5',
orientation === 'horizontal' && 'h-1.5 flex-col',
)}
>
<S.Thumb className="flex-1 bg-gray-100 group-hover/scroll:bg-gray-200 rounded-full" />
</S.Scrollbar>
);
}

View File

@@ -0,0 +1,29 @@
import classnames from 'classnames';
interface Props {
orientation?: 'horizontal' | 'vertical';
variant?: 'primary' | 'secondary';
className?: string;
label?: string;
}
export function Separator({
className,
variant = 'primary',
orientation = 'horizontal',
label,
}: Props) {
return (
<div role="separator" className={classnames(className, 'flex items-center')}>
{label && <div className="text-xs text-gray-500 mx-2 whitespace-nowrap">{label}</div>}
<div
className={classnames(
variant === 'primary' && 'bg-highlight',
variant === 'secondary' && 'bg-highlightSecondary',
orientation === 'horizontal' && 'w-full h-[1px]',
orientation === 'vertical' && 'h-full w-[1px]',
)}
/>
</div>
);
}

View File

@@ -1,95 +1,74 @@
import classnames from 'classnames';
import type { ComponentType, ReactNode } from 'react';
import { Children, Fragment } from 'react';
import type { ComponentType, ForwardedRef, HTMLAttributes, ReactNode } from 'react';
import { forwardRef } from 'react';
const spaceClassesX = {
0: 'pr-0',
1: 'pr-1',
2: 'pr-2',
3: 'pr-3',
4: 'pr-4',
5: 'pr-5',
6: 'pr-6',
};
const spaceClassesY = {
0: 'pt-0',
1: 'pt-1',
2: 'pt-2',
3: 'pt-3',
4: 'pt-4',
5: 'pt-5',
6: 'pt-6',
const gapClasses = {
0: 'gap-0',
0.5: 'gap-0.5',
1: 'gap-1',
2: 'gap-2',
3: 'gap-3',
4: 'gap-4',
5: 'gap-5',
6: 'gap-6',
};
interface HStackProps extends BaseStackProps {
space?: keyof typeof spaceClassesX;
children?: ReactNode;
}
export function HStack({ className, space, children, ...props }: HStackProps) {
export const HStack = forwardRef(function HStack(
{ className, space, children, ...props }: HStackProps,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ref: ForwardedRef<any>,
) {
return (
<BaseStack className={classnames(className, 'flex-row')} {...props}>
{space
? Children.toArray(children)
.filter(Boolean) // Remove null/false/undefined children
.map((c, i) => (
<Fragment key={i}>
{i > 0 ? (
<div
className={classnames(spaceClassesX[space], 'pointer-events-none')}
data-spacer=""
aria-hidden
/>
) : null}
{c}
</Fragment>
))
: children}
<BaseStack
ref={ref}
className={classnames(className, 'flex-row', space != null && gapClasses[space])}
{...props}
>
{children}
</BaseStack>
);
}
});
export interface VStackProps extends BaseStackProps {
space?: keyof typeof spaceClassesY;
export type VStackProps = BaseStackProps & {
children: ReactNode;
}
};
export function VStack({ className, space, children, ...props }: VStackProps) {
export const VStack = forwardRef(function VStack(
{ className, space, children, ...props }: VStackProps,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ref: ForwardedRef<any>,
) {
return (
<BaseStack className={classnames(className, 'w-full h-full flex-col')} {...props}>
{space
? Children.toArray(children)
.filter(Boolean) // Remove null/false/undefined children
.map((c, i) => (
<Fragment key={i}>
{i > 0 ? (
<div
className={classnames(spaceClassesY[space], 'pointer-events-none')}
data-spacer=""
aria-hidden
/>
) : null}
{c}
</Fragment>
))
: children}
<BaseStack
ref={ref}
className={classnames(className, 'flex-col', space != null && gapClasses[space])}
{...props}
>
{children}
</BaseStack>
);
}
});
interface BaseStackProps {
as?: ComponentType | 'ul';
type BaseStackProps = HTMLAttributes<HTMLElement> & {
as?: ComponentType | 'ul' | 'form';
space?: keyof typeof gapClasses;
alignItems?: 'start' | 'center';
justifyContent?: 'start' | 'center' | 'end';
className?: string;
children?: ReactNode;
}
};
function BaseStack({ className, alignItems, justifyContent, children, as }: BaseStackProps) {
const BaseStack = forwardRef(function BaseStack(
{ className, alignItems, justifyContent, children, as, ...props }: BaseStackProps,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ref: ForwardedRef<any>,
) {
const Component = as ?? 'div';
return (
<Component
ref={ref}
className={classnames(
className,
'flex',
@@ -99,8 +78,9 @@ function BaseStack({ className, alignItems, justifyContent, children, as }: Base
justifyContent === 'center' && 'justify-center',
justifyContent === 'end' && 'justify-end',
)}
{...props}
>
{children}
</Component>
);
}
});

View File

@@ -1,5 +0,0 @@
.tab-content {
&[data-state="inactive"] {
@apply hidden;
}
}

View File

@@ -1,71 +1,134 @@
import * as T from '@radix-ui/react-tabs';
import classnames from 'classnames';
import type { ReactNode } from 'react';
import { useState } from 'react';
import { memo, useCallback, useEffect, useRef } from 'react';
import { Button } from '../Button';
import { ScrollArea } from '../ScrollArea';
import { Icon } from '../Icon';
import type { RadioDropdownProps } from '../RadioDropdown';
import { RadioDropdown } from '../RadioDropdown';
import { HStack } from '../Stacks';
import './Tabs.css';
export type TabItem =
| {
value: string;
label: ReactNode;
}
| {
value: string;
options: Omit<RadioDropdownProps, 'children'>;
};
interface Props {
defaultValue?: string;
label: string;
tabs: { value: string; label: ReactNode }[];
value?: string;
onChangeValue: (value: string) => void;
tabs: TabItem[];
tabListClassName?: string;
className?: string;
children: ReactNode;
}
export function Tabs({ defaultValue, label, children, tabs, className, tabListClassName }: Props) {
const [value, setValue] = useState(defaultValue);
export function Tabs({
value,
onChangeValue,
label,
children,
tabs,
className,
tabListClassName,
}: Props) {
const ref = useRef<HTMLDivElement | null>(null);
const handleTabChange = useCallback(
(value: string) => {
const tabs = ref.current?.querySelectorAll<HTMLDivElement>(`[data-tab]`);
for (const tab of tabs ?? []) {
const v = tab.getAttribute('data-tab');
if (v === value) {
tab.setAttribute('tabindex', '-1');
tab.setAttribute('data-state', 'active');
tab.setAttribute('aria-hidden', 'false');
tab.style.display = 'block';
} else {
tab.setAttribute('data-state', 'inactive');
tab.setAttribute('aria-hidden', 'true');
tab.style.display = 'none';
}
}
onChangeValue(value);
},
[onChangeValue],
);
useEffect(() => {
if (value === undefined) return;
handleTabChange(value);
}, [handleTabChange, value]);
return (
<T.Root
defaultValue={defaultValue}
onValueChange={setValue}
<div
ref={ref}
className={classnames(className, 'h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1')}
>
<T.List
<div
aria-label={label}
className={classnames(
tabListClassName,
'h-auto flex items-center overflow-x-auto mb-1 pb-1',
'flex items-center overflow-x-auto hide-scrollbars mt-1 mb-2',
// Give space for button focus states within overflow boundary.
'px-2 -mx-2',
)}
>
{/*<ScrollArea className="w-full pb-2">*/}
<HStack space={1}>
{tabs.map((t) => (
<TabTrigger key={t.value} value={t.value} active={t.value === value}>
{t.label}
</TabTrigger>
))}
<HStack space={1} className="flex-shrink-0">
{tabs.map((t) => {
const isActive = t.value === value;
// const btnClassName = classnames(isActive ? 'bg-highlightSecondary' : 'text-gray-600');
const btnClassName = classnames(isActive ? '' : 'text-gray-600', '!px-0 mr-4 ml-[1px]');
if ('options' in t) {
const option = t.options.items.find(
(i) => 'value' in i && i.value === t.options?.value,
);
return (
<RadioDropdown
key={t.value}
items={t.options.items}
value={t.options.value}
onChange={t.options.onChange}
>
<Button
color="custom"
size="sm"
onClick={isActive ? undefined : () => handleTabChange(t.value)}
className={btnClassName}
>
{option && 'shortLabel' in option
? option.shortLabel
: option?.label ?? 'Unknown'}
<Icon
icon="triangleDown"
className={classnames('-mr-1.5', isActive ? 'opacity-100' : 'opacity-20')}
/>
</Button>
</RadioDropdown>
);
} else {
return (
<Button
key={t.value}
color="custom"
size="sm"
onClick={() => handleTabChange(t.value)}
className={btnClassName}
>
{t.label}
</Button>
);
}
})}
</HStack>
{/*</ScrollArea>*/}
</T.List>
</div>
{children}
</T.Root>
);
}
interface TabTriggerProps {
value: string;
children: ReactNode;
active?: boolean;
}
export function TabTrigger({ value, children, active }: TabTriggerProps) {
return (
<T.Trigger value={value} asChild>
<Button
color="custom"
size="sm"
className={classnames(
active ? 'bg-gray-100 text-gray-900' : 'text-gray-600 hover:text-gray-900',
)}
>
{children}
</Button>
</T.Trigger>
</div>
);
}
@@ -75,14 +138,18 @@ interface TabContentProps {
className?: string;
}
export function TabContent({ value, children, className }: TabContentProps) {
export const TabContent = memo(function TabContent({
value,
children,
className,
}: TabContentProps) {
return (
<T.Content
forceMount
value={value}
className={classnames(className, 'tab-content', 'w-full h-full overflow-auto')}
<div
tabIndex={-1}
data-tab={value}
className={classnames(className, 'tab-content', 'overflow-auto hidden w-full h-full')}
>
{children}
</T.Content>
</div>
);
}
});

View File

@@ -13,7 +13,7 @@ export function Webview({ body, url, contentType }: Props) {
return body.replace(/<head>/gi, `<head><base href="${url}"/>`);
}
return body;
}, [body, contentType]);
}, [url, body, contentType]);
return (
<div className="px-2 pb-2">

View File

@@ -10,7 +10,7 @@ export function WindowDragRegion({ className, ...props }: Props) {
return (
<div
data-tauri-drag-region
className={classnames(className, 'w-full h-12 flex-shrink-0')}
className={classnames(className, 'w-full flex-shrink-0')}
{...props}
/>
);

48
src-web/hooks/Confirm.tsx Normal file
View File

@@ -0,0 +1,48 @@
import type { ButtonProps } from '../components/core/Button';
import { Button } from '../components/core/Button';
import { HStack } from '../components/core/Stacks';
export interface ConfirmProps {
hide: () => void;
onResult: (result: boolean) => void;
variant?: 'delete' | 'confirm';
}
const colors: Record<NonNullable<ConfirmProps['variant']>, ButtonProps['color']> = {
delete: 'danger',
confirm: 'primary',
};
const confirmButtonTexts: Record<NonNullable<ConfirmProps['variant']>, string> = {
delete: 'Delete',
confirm: 'Confirm',
};
export function Confirm({ hide, onResult, variant = 'confirm' }: ConfirmProps) {
const focusRef = (el: HTMLButtonElement | null) => {
setTimeout(() => {
el?.focus();
});
};
const handleHide = () => {
onResult(false);
hide();
};
const handleSuccess = () => {
onResult(true);
hide();
};
return (
<HStack space={2} justifyContent="end">
<Button className="focus" color="gray" onClick={handleHide}>
Cancel
</Button>
<Button className="focus" ref={focusRef} color={colors[variant]} onClick={handleSuccess}>
{confirmButtonTexts[variant]}
</Button>
</HStack>
);
}

View File

@@ -1,20 +1,9 @@
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import type { HttpRequest } from '../lib/models';
import { useActiveRequestId } from './useActiveRequestId';
import { useRequests } from './useRequests';
export function useActiveRequest(): HttpRequest | null {
const params = useParams<{ requestId?: string }>();
const requestId = useActiveRequestId();
const requests = useRequests();
const [activeRequest, setActiveRequest] = useState<HttpRequest | null>(null);
useEffect(() => {
if (requests.length === 0) {
setActiveRequest(null);
} else {
setActiveRequest(requests.find((r) => r.id === params.requestId) ?? null);
}
}, [requests, params.requestId]);
return activeRequest;
return requests.find((r) => r.id === requestId) ?? null;
}

View File

@@ -0,0 +1,7 @@
import { useParams } from 'react-router-dom';
import type { RouteParamsRequest } from './useRoutes';
export function useActiveRequestId(): string | null {
const { requestId } = useParams<RouteParamsRequest>();
return requestId ?? null;
}

View File

@@ -1,20 +1,13 @@
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useMemo } from 'react';
import type { Workspace } from '../lib/models';
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
import { useWorkspaces } from './useWorkspaces';
export function useActiveWorkspace(): Workspace | null {
const params = useParams<{ workspaceId?: string }>();
const workspaceId = useActiveWorkspaceId();
const workspaces = useWorkspaces();
const [activeWorkspace, setActiveWorkspace] = useState<Workspace | null>(null);
useEffect(() => {
if (workspaces.length === 0) {
setActiveWorkspace(null);
} else {
setActiveWorkspace(workspaces.find((w) => w.id === params.workspaceId) ?? null);
}
}, [workspaces, params.workspaceId]);
return activeWorkspace;
return useMemo(
() => workspaces.find((w) => w.id === workspaceId) ?? null,
[workspaces, workspaceId],
);
}

View File

@@ -0,0 +1,7 @@
import { useParams } from 'react-router-dom';
import type { RouteParamsWorkspace } from './useRoutes';
export function useActiveWorkspaceId(): string | null {
const { workspaceId } = useParams<RouteParamsWorkspace>();
return workspaceId ?? null;
}

View File

@@ -0,0 +1,26 @@
import type { DialogProps } from '../components/core/Dialog';
import { useDialog } from '../components/DialogContext';
import type { ConfirmProps } from './Confirm';
import { Confirm } from './Confirm';
export function useConfirm() {
const dialog = useDialog();
return ({
title,
description,
variant,
}: {
title: DialogProps['title'];
description?: DialogProps['description'];
variant: ConfirmProps['variant'];
}) =>
new Promise((onResult: ConfirmProps['onResult']) => {
dialog.show({
title,
description,
hideX: true,
size: 'sm',
render: ({ hide }) => Confirm({ hide, variant, onResult }),
});
});
}

View File

@@ -1,23 +1,37 @@
import { useMutation } from '@tanstack/react-query';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import { useNavigate } from 'react-router-dom';
import type { HttpRequest } from '../lib/models';
import { useActiveWorkspace } from './useActiveWorkspace';
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
import { requestsQueryKey, useRequests } from './useRequests';
import { useRoutes } from './useRoutes';
export function useCreateRequest({ navigateAfter }: { navigateAfter: boolean }) {
const workspace = useActiveWorkspace();
const navigate = useNavigate();
return useMutation<string, unknown, Pick<HttpRequest, 'name'>>({
mutationFn: async (patch) => {
if (workspace === null) {
const workspaceId = useActiveWorkspaceId();
const routes = useRoutes();
const requests = useRequests();
const queryClient = useQueryClient();
return useMutation<HttpRequest, unknown, Partial<Pick<HttpRequest, 'name' | 'sortPriority'>>>({
mutationFn: (patch) => {
if (workspaceId === null) {
throw new Error("Cannot create request when there's no active workspace");
}
return invoke('create_request', { ...patch, workspaceId: workspace?.id });
const sortPriority = maxSortPriority(requests) + 1000;
return invoke('create_request', { sortPriority, workspaceId, ...patch });
},
onSuccess: async (requestId) => {
onSuccess: async (request) => {
queryClient.setQueryData<HttpRequest[]>(
requestsQueryKey({ workspaceId: request.workspaceId }),
(requests) => [...(requests ?? []), request],
);
if (navigateAfter) {
navigate(`/workspaces/${workspace?.id}/requests/${requestId}`);
routes.navigate('request', { workspaceId: request.workspaceId, requestId: request.id });
}
},
});
}
function maxSortPriority(requests: HttpRequest[]) {
if (requests.length === 0) return 1000;
return Math.max(...requests.map((r) => r.sortPriority));
}

View File

@@ -0,0 +1,24 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import type { Workspace } from '../lib/models';
import { useRoutes } from './useRoutes';
import { workspacesQueryKey } from './useWorkspaces';
export function useCreateWorkspace({ navigateAfter }: { navigateAfter: boolean }) {
const routes = useRoutes();
const queryClient = useQueryClient();
return useMutation<Workspace, unknown, Pick<Workspace, 'name'>>({
mutationFn: (patch) => {
return invoke('create_workspace', patch);
},
onSuccess: async (workspace) => {
queryClient.setQueryData<Workspace[]>(workspacesQueryKey({}), (workspaces) => [
...(workspaces ?? []),
workspace,
]);
if (navigateAfter) {
routes.navigate('workspace', { workspaceId: workspace.id });
}
},
});
}

View File

@@ -0,0 +1,12 @@
import type { Dispatch, SetStateAction } from 'react';
import { useMemo, useState } from 'react';
import { debounce } from '../lib/debounce';
export function useDebouncedSetState<T>(
defaultValue: T,
delay?: number,
): [T, Dispatch<SetStateAction<T>>] {
const [state, setState] = useState<T>(defaultValue);
const debouncedSetState = useMemo(() => debounce(setState, delay), [delay]);
return [state, debouncedSetState];
}

View File

@@ -0,0 +1,8 @@
import { useEffect } from 'react';
import { useDebouncedSetState } from './useDebouncedSetState';
export function useDebouncedValue<T>(value: T, delay?: number) {
const [state, setState] = useDebouncedSetState<T>(value, delay);
useEffect(() => setState(value), [setState, value]);
return state;
}

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