Compare commits
103 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdf89ac288 | ||
|
|
debd3c8185 | ||
|
|
f81a3ae8e7 | ||
|
|
7d4e9894c3 | ||
|
|
4bf22d8a60 | ||
|
|
8be4971a23 | ||
|
|
359e916b73 | ||
|
|
68058f3e41 | ||
|
|
0c6fa3e634 | ||
|
|
0fa25c6335 | ||
|
|
5684479f1d | ||
|
|
2d1603601c | ||
|
|
f5394b2210 | ||
|
|
833db5df06 | ||
|
|
525ac7e980 | ||
|
|
44a747c80a | ||
|
|
2056e7f40a | ||
|
|
9b6c1ad364 | ||
|
|
34987bcacb | ||
|
|
b62c11222a | ||
|
|
b3cee3ace3 | ||
|
|
222c054c95 | ||
|
|
46f18a2491 | ||
|
|
f2ca8e2753 | ||
|
|
b0d243c378 | ||
|
|
6161fb86c8 | ||
|
|
b09cc91fe5 | ||
|
|
ef1638cbb3 | ||
|
|
00ef8743f2 | ||
|
|
68222659e3 | ||
|
|
69420a4bba | ||
|
|
0161bbaeb1 | ||
|
|
948dbfe3cc | ||
|
|
338ba8b189 | ||
|
|
ca4655b441 | ||
|
|
bf37499428 | ||
|
|
0b94b57e2a | ||
|
|
fc40aead98 | ||
|
|
7d7f934e6a | ||
|
|
d5fbf4d622 | ||
|
|
e4f6c919dc | ||
|
|
4d806ff2b1 | ||
|
|
bf8f12274f | ||
|
|
f4f438d9fe | ||
|
|
2434f373be | ||
|
|
2bb2061f97 | ||
|
|
2c011a5c2a | ||
|
|
f66b0ccea1 | ||
|
|
665dd8447d | ||
|
|
1b61ce31e6 | ||
|
|
ef4d960698 | ||
|
|
b6d557b632 | ||
|
|
b700bd356c | ||
|
|
620dd7d3ef | ||
|
|
6575121902 | ||
|
|
7c1755a0dc | ||
|
|
8ad301a666 | ||
|
|
ae24cd4939 | ||
|
|
7152e1845e | ||
|
|
96c1dd4081 | ||
|
|
87c7b3a663 | ||
|
|
c1be46a539 | ||
|
|
4655e0018b | ||
|
|
da5ba2e3be | ||
|
|
aaf95f565f | ||
|
|
f32b984e77 | ||
|
|
548aa4c7cd | ||
|
|
0ccceaac77 | ||
|
|
70f534f1d8 | ||
|
|
61fe95b300 | ||
|
|
915e0e8613 | ||
|
|
aace2580da | ||
|
|
3d36905664 | ||
|
|
0d671423da | ||
|
|
aebfcb9437 | ||
|
|
be7ef7beb1 | ||
|
|
d77ed0c5cc | ||
|
|
e57e7bcec5 | ||
|
|
a637842ce4 | ||
|
|
fc54ec49af | ||
|
|
5c43d8510a | ||
|
|
83f84ded8d | ||
|
|
5658da34a2 | ||
|
|
38e8ef6535 | ||
|
|
8c89b06238 | ||
|
|
d85c021305 | ||
|
|
83bb18df03 | ||
|
|
93105a3e89 | ||
|
|
ba3b899115 | ||
|
|
fcfbc1d1da | ||
|
|
72486b448c | ||
|
|
7dea1b7870 | ||
|
|
4de2c496c9 | ||
|
|
9e1393a392 | ||
|
|
0901690ed6 | ||
|
|
95303648cc | ||
|
|
1dbb08c045 | ||
|
|
00a7d9a180 | ||
|
|
6c549dc086 | ||
|
|
dc368e326a | ||
|
|
e42188a627 | ||
|
|
7a6a337eff | ||
|
|
3907344884 |
@@ -20,6 +20,12 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
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",
|
||||
}]
|
||||
},
|
||||
};
|
||||
|
||||
49
.github/workflows/artifacts.yml
vendored
Normal file
@@ -0,0 +1,49 @@
|
||||
name: Generate Artifacts
|
||||
on:
|
||||
push:
|
||||
tags: [ v* ]
|
||||
|
||||
jobs:
|
||||
build-artifacts:
|
||||
runs-on: ${{ matrix.platform }}
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform: [ ubuntu-latest, macos-latest, windows-latest ]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Cache Rust
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
./src-tauri/target
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('src-tauri/Cargo.lock') }}
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
cache: 'npm'
|
||||
- name: install dependencies (ubuntu only)
|
||||
if: matrix.platform == 'ubuntu-latest'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
- uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
with:
|
||||
tagName: v__VERSION__
|
||||
releaseName: 'App v__VERSION__'
|
||||
releaseBody: 'See the assets to download this version and install.'
|
||||
releaseDraft: true
|
||||
prerelease: false
|
||||
2
.gitignore
vendored
@@ -23,4 +23,4 @@ dist-ssr
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
.rsw
|
||||
*.sqlite
|
||||
|
||||
14
.run/Dev Desktop.run.xml
Normal file
@@ -0,0 +1,14 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="Dev Desktop" type="js.build_tools.npm">
|
||||
<package-json value="$PROJECT_DIR$/package.json" />
|
||||
<command value="run" />
|
||||
<scripts>
|
||||
<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>
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -4,12 +4,14 @@
|
||||
<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>Tauri + React + TS</title>
|
||||
<title>Yaak App</title>
|
||||
<!-- <script src="http://localhost:8097"></script>-->
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<div id="radix-portal"></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>
|
||||
</html>
|
||||
|
||||
3637
package-lock.json
generated
70
package.json
@@ -4,48 +4,72 @@
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "rsw build && tsc && vite build",
|
||||
"dev": "vite",
|
||||
"lint": "eslint . --ext .ts,.tsx",
|
||||
"preview": "vite preview",
|
||||
"tauri-dev": "concurrently -n app,rsw \"tauri dev\" \"rsw watch\""
|
||||
"tauri-dev": "tauri dev",
|
||||
"tauri-build": "npm run build:icon && tauri build",
|
||||
"build": "npm run build:frontend",
|
||||
"dev": "vite dev",
|
||||
"lint": "tsc && eslint . --ext .ts,.tsx",
|
||||
"build:icon": "tauri icon src-tauri/icons/icon.png",
|
||||
"build:frontend": "vite build",
|
||||
"test": "vitest",
|
||||
"coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "^6.2.1",
|
||||
"@codemirror/lang-html": "^6.4.2",
|
||||
"@codemirror/lang-javascript": "^6.1.4",
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
"@codemirror/lang-xml": "^6.0.2",
|
||||
"@codemirror/language": "^6.6.0",
|
||||
"@codemirror/search": "^6.2.3",
|
||||
"@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",
|
||||
"@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",
|
||||
"format-graphql": "^1.4.0",
|
||||
"framer-motion": "^9.0.4",
|
||||
"parse-color": "^1.0.0",
|
||||
"react-helmet-async": "^1.3.0",
|
||||
"react-use": "^17.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/nesting": "^0.0.0-insiders.565cd3e",
|
||||
"@tauri-apps/cli": "^1.2.2",
|
||||
"@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",
|
||||
"classnames": "^2.3.2",
|
||||
"codemirror": "^6.0.1",
|
||||
"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",
|
||||
"framer-motion": "^9.0.4",
|
||||
"prettier": "^2.8.4",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-helmet-async": "^1.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^1.2.2",
|
||||
"@types/node": "^18.7.10",
|
||||
"@types/react": "^18.0.15",
|
||||
"@types/react-dom": "^18.0.6",
|
||||
"@vitejs/plugin-react": "^3.0.0",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"concurrently": "^7.6.0",
|
||||
"postcss": "^8.4.21",
|
||||
"postcss-nesting": "^11.2.1",
|
||||
"prettier": "^2.8.4",
|
||||
"tailwindcss": "^3.2.7",
|
||||
"typescript": "^4.6.4",
|
||||
"vite": "^4.0.0",
|
||||
"vite-plugin-rsw": "^2.0.11",
|
||||
"vite-plugin-top-level-await": "^1.2.4"
|
||||
"vite-plugin-top-level-await": "^1.2.4",
|
||||
"vitest": "^0.29.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
plugins: [
|
||||
require('@tailwindcss/nesting')(require('postcss-nesting')),
|
||||
require('tailwindcss'),
|
||||
require('autoprefixer'),
|
||||
]
|
||||
}
|
||||
|
||||
39
rsw.toml
@@ -1,39 +0,0 @@
|
||||
name = "rsw"
|
||||
version = "0.1.0"
|
||||
|
||||
#! time interval for file changes to trigger wasm-pack build, default `50` milliseconds
|
||||
interval = 50
|
||||
|
||||
#! link
|
||||
#! npm link @see https://docs.npmjs.com/cli/v8/commands/npm-link
|
||||
#! yarn link @see https://classic.yarnpkg.com/en/docs/cli/link
|
||||
#! pnpm link @see https://pnpm.io/cli/link
|
||||
#! The link command will only be executed if `[[crates]] link = true`
|
||||
#! cli: `npm` | `yarn` | `pnpm`, default is `npm`
|
||||
cli = "npm"
|
||||
|
||||
#! ---------------------------
|
||||
|
||||
#! rsw new <name>
|
||||
[new]
|
||||
#! @see https://rustwasm.github.io/docs/wasm-pack/commands/new.html
|
||||
#! using: `wasm-pack` | `rsw` | `user`, default is `wasm-pack`
|
||||
#! 1. wasm-pack: `rsw new <name> --template <template> --mode <normal|noinstall|force>`
|
||||
#! 2. rsw: `rsw new <name>`, built-in templates
|
||||
#! 3. user: `rsw new <name>`, if `dir` is not configured, use `wasm-pack new <name>` to initialize the project
|
||||
using = "wasm-pack"
|
||||
#! this field needs to be configured when `using = "user"`
|
||||
#! `using = "wasm-pack"` or `using = "rsw"`, this field will be ignored
|
||||
#! copy all files in this directory
|
||||
dir = "my-template"
|
||||
|
||||
#! ################# NPM Package #################
|
||||
|
||||
#! When there is only `name`, other fields will use the default configuration
|
||||
|
||||
#! 📦 -------- package: @rsw --------
|
||||
[[crates]]
|
||||
#! npm package name (path: $ROOT/@rsw)
|
||||
name = "src-wasm/hello"
|
||||
#! run `npm link`: `true` | `false`, default is `false`
|
||||
link = true
|
||||
762
src-tauri/Cargo.lock
generated
@@ -1,10 +1,10 @@
|
||||
[package]
|
||||
name = "tauri-app"
|
||||
name = "yaak-app"
|
||||
version = "0.0.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
license = ""
|
||||
repository = ""
|
||||
description = "A network protocol testing utility app"
|
||||
authors = ["Gregory Schier"]
|
||||
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
|
||||
@@ -12,19 +12,25 @@ edition = "2021"
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "1.2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
serde_json = { version = "1.0" }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
tauri = { version = "1.2", features = ["config-toml", "devtools", "shell-open", "system-tray", "window-start-dragging"] }
|
||||
http = { version = "0.2.8" }
|
||||
reqwest = { version = "0.11.14", features = ["json"] }
|
||||
tokio = { version = "1.25.0", features = ["full"] }
|
||||
futures = { version = "0.3.26" }
|
||||
deno_core = { version = "0.171.0" }
|
||||
deno_ast = { version = "0.24.0", features = ["transpiling"] }
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc = { version = "0.2.7" }
|
||||
cocoa = { version = "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" }
|
||||
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"] }
|
||||
sqlx = { version = "0.6.2", features = ["sqlite", "runtime-tokio-rustls", "json", "chrono", "time", "offline"] }
|
||||
uuid = { version = "1.3.0" }
|
||||
rand = { version = "0.8.5" }
|
||||
chrono = { version = "0.4.23", features = ["serde"] }
|
||||
|
||||
[features]
|
||||
# by default Tauri runs in production mode
|
||||
# when `tauri dev` runs it is executed with `cargo run --no-default-features` if `devPath` is an URL
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
tauri_build::build()
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.3 KiB |
BIN
src-tauri/icons/icon.afdesign
Normal file
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 106 KiB |
39
src-tauri/migrations/20230225181302_init.sql
Normal file
@@ -0,0 +1,39 @@
|
||||
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,
|
||||
deleted_at DATETIME,
|
||||
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,
|
||||
deleted_at DATETIME,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
method TEXT NOT NULL,
|
||||
headers TEXT NOT NULL,
|
||||
body 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,
|
||||
deleted_at DATETIME,
|
||||
elapsed INTEGER NOT NULL,
|
||||
status INTEGER NOT NULL,
|
||||
status_reason TEXT,
|
||||
url TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
headers TEXT NOT NULL
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE http_responses ADD COLUMN error TEXT NULL;
|
||||
@@ -1,5 +1 @@
|
||||
console.log('---------------------------');
|
||||
console.log('- 👋 Hello from plugin.ts -');
|
||||
console.log('---------------------------');
|
||||
|
||||
Deno.core.ops.op_hello('World');
|
||||
Deno.core.opAsync('op_hello', 'Deno');
|
||||
|
||||
493
src-tauri/sqlx-data.json
Normal file
@@ -0,0 +1,493 @@
|
||||
{
|
||||
"db": "SQLite",
|
||||
"07d1a1c7b4f3d9625a766e60fd57bb779b71dae30e5bbce34885a911a5a42428": {
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
}
|
||||
},
|
||||
"query": "\n DELETE FROM http_responses\n WHERE id = ?\n "
|
||||
},
|
||||
"0fa36011553f7ca91113459a5cefd47f990f9b548a95e475ffd6e4b017059488": {
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
}
|
||||
},
|
||||
"query": "\n DELETE FROM http_responses\n WHERE request_id = ?\n "
|
||||
},
|
||||
"3d2a542964d946ff9854d053b1adf04985d97a6de27b713188505c1f99c77707": {
|
||||
"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
|
||||
],
|
||||
"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 workspace_id = ?\n "
|
||||
},
|
||||
"448a1d1f1866ab42c0f81fcf8eb2930bf21dfdd43ca4831bc1a198cf45ac3732": {
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
}
|
||||
},
|
||||
"query": "\n DELETE FROM http_requests\n WHERE id = ?\n "
|
||||
},
|
||||
"7ec60cbc3c9f26e8af86a21ef6b66e564f4fa518925c92308b04f882237a244e": {
|
||||
"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
|
||||
],
|
||||
"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",
|
||||
"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,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
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 "
|
||||
},
|
||||
"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": {
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 7
|
||||
}
|
||||
},
|
||||
"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 "
|
||||
},
|
||||
"a83698dcf9a815b881097133edb31a34ba25e7c6c114d463c495342a85371639": {
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 8
|
||||
}
|
||||
},
|
||||
"query": "\n UPDATE http_responses SET (elapsed, url, status, status_reason, body, error, headers, updated_at) =\n (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) WHERE id = ?;\n "
|
||||
},
|
||||
"e767522f92c8c49cd2e563e58737a05092daf9b1dc763bacc82a5c14d696d78e": {
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 9
|
||||
}
|
||||
},
|
||||
"query": "\n INSERT INTO http_responses (id, request_id, workspace_id, elapsed, url, status, status_reason, body, headers)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);\n "
|
||||
},
|
||||
"f116d8cf9aad828135bb8c3a4c8b8e6b857ae13303989e9133a33b2d1cf20e96": {
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
}
|
||||
},
|
||||
"query": "\n INSERT INTO workspaces (id, name, description)\n VALUES (?, ?, ?)\n "
|
||||
},
|
||||
"fa5011146663ba5675764f33bf55a86b8274aa18737aff427fd1e3fb74ef3535": {
|
||||
"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": 1
|
||||
}
|
||||
},
|
||||
"query": "\n SELECT id, created_at, updated_at, deleted_at, name, description\n FROM workspaces\n WHERE id = ?\n "
|
||||
},
|
||||
"fb2c2328678bbdcb64b79ced26f3d7a1b08d315ef6dedfe4d5ae4231c861b079": {
|
||||
"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",
|
||||
"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,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false
|
||||
],
|
||||
"parameters": {
|
||||
"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 "
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
use http::header::{HeaderName, USER_AGENT};
|
||||
use http::{HeaderMap, HeaderValue, Method};
|
||||
use reqwest::redirect::Policy;
|
||||
use std::collections::HashMap;
|
||||
use tauri::{AppHandle, Wry};
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct CustomResponse {
|
||||
status: String,
|
||||
body: String,
|
||||
url: String,
|
||||
method: String,
|
||||
elapsed: u128,
|
||||
elapsed2: u128,
|
||||
headers: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn send_request(
|
||||
app_handle: AppHandle<Wry>,
|
||||
url: &str,
|
||||
method: &str,
|
||||
) -> Result<CustomResponse, String> {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
let mut abs_url = url.to_string();
|
||||
if !abs_url.starts_with("http://") && !abs_url.starts_with("https://") {
|
||||
abs_url = format!("http://{}", url);
|
||||
}
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.redirect(Policy::none())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
// headers.insert(CONTENT_TYPE, HeaderValue::from_static("image/png"));
|
||||
headers.insert(USER_AGENT, HeaderValue::from_static("reqwest"));
|
||||
headers.insert("x-foo-bar", HeaderValue::from_static("hi mom"));
|
||||
headers.insert(
|
||||
HeaderName::from_static("x-api-key"),
|
||||
HeaderValue::from_static("123-123-123"),
|
||||
);
|
||||
|
||||
let m = Method::from_bytes(method.to_uppercase().as_bytes()).unwrap();
|
||||
let req = client
|
||||
.request(m, abs_url.to_string())
|
||||
.headers(headers)
|
||||
.build();
|
||||
|
||||
let req = match req {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
println!("Error: {}", e);
|
||||
return Err(e.to_string());
|
||||
}
|
||||
};
|
||||
|
||||
let resp = client.execute(req).await;
|
||||
|
||||
let elapsed = start.elapsed().as_millis();
|
||||
|
||||
let p = app_handle
|
||||
.path_resolver()
|
||||
.resolve_resource("plugins/plugin.ts")
|
||||
.expect("failed to resolve resource");
|
||||
|
||||
crate::runtime::run_plugin_sync(p.to_str().unwrap()).unwrap();
|
||||
|
||||
match resp {
|
||||
Ok(v) => {
|
||||
let url = v.url().to_string();
|
||||
let status = v.status().to_string();
|
||||
let method = method.to_string();
|
||||
let headers = v
|
||||
.headers()
|
||||
.iter()
|
||||
.map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap().to_string()))
|
||||
.collect::<HashMap<String, String>>();
|
||||
let body = v.text().await.unwrap();
|
||||
let elapsed2 = start.elapsed().as_millis();
|
||||
Ok(CustomResponse {
|
||||
status,
|
||||
body,
|
||||
elapsed,
|
||||
elapsed2,
|
||||
method,
|
||||
url,
|
||||
headers,
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Error: {}", e);
|
||||
Err(e.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn greet(name: &str) -> String {
|
||||
format!("Hello, {}! You've been greeted from Rust!", name)
|
||||
}
|
||||
@@ -1,51 +1,412 @@
|
||||
#![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")]
|
||||
#[macro_use]
|
||||
extern crate objc;
|
||||
|
||||
mod commands;
|
||||
use std::collections::HashMap;
|
||||
use std::fs::create_dir_all;
|
||||
|
||||
use http::header::{ACCEPT, HeaderName, USER_AGENT};
|
||||
use http::{HeaderMap, HeaderValue, Method};
|
||||
use reqwest::redirect::Policy;
|
||||
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::{CustomMenuItem, Manager, SystemTray, SystemTrayEvent, SystemTrayMenu, WindowEvent};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use window_ext::WindowExt;
|
||||
|
||||
mod models;
|
||||
mod runtime;
|
||||
mod window_ext;
|
||||
|
||||
use tauri::{
|
||||
CustomMenuItem, Manager, SystemTray, SystemTrayEvent, SystemTrayMenu, SystemTrayMenuItem,
|
||||
WindowEvent,
|
||||
};
|
||||
use window_ext::WindowExt;
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct CustomResponse {
|
||||
status: u16,
|
||||
body: String,
|
||||
url: String,
|
||||
method: String,
|
||||
elapsed: u128,
|
||||
elapsed2: u128,
|
||||
headers: HashMap<String, String>,
|
||||
pub status_reason: Option<&'static str>,
|
||||
}
|
||||
|
||||
async fn migrate_db(
|
||||
app_handle: AppHandle<Wry>,
|
||||
db_instance: &Mutex<Pool<Sqlite>>,
|
||||
) -> Result<(), String> {
|
||||
let pool = &*db_instance.lock().await;
|
||||
let p = app_handle
|
||||
.path_resolver()
|
||||
.resolve_resource("migrations")
|
||||
.expect("failed to resolve resource");
|
||||
let m = Migrator::new(p).await.expect("Failed to load migrations");
|
||||
m.run(pool).await.expect("Failed to run migrations");
|
||||
println!("Migrations ran");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn send_request(
|
||||
app_handle: AppHandle<Wry>,
|
||||
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||
request_id: &str,
|
||||
) -> Result<String, String> {
|
||||
let pool = &*db_instance.lock().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();
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
let mut url_string = req.url.to_string();
|
||||
|
||||
let mut variables = HashMap::new();
|
||||
variables.insert("PROJECT_ID", "project_123");
|
||||
variables.insert("TOKEN", "s3cret");
|
||||
variables.insert("DOMAIN", "schier.co");
|
||||
variables.insert("BASE_URL", "https://schier.co");
|
||||
|
||||
let re = Regex::new(r"\$\{\[\s*([^]\s]+)\s*]}").expect("Failed to create regex");
|
||||
url_string = re
|
||||
.replace(&url_string, |caps: &tauri::regex::Captures| {
|
||||
let key = caps.get(1).unwrap().as_str();
|
||||
match variables.get(key) {
|
||||
Some(v) => v,
|
||||
None => "",
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
|
||||
if !url_string.starts_with("http://") && !url_string.starts_with("https://") {
|
||||
url_string = format!("http://{}", url_string);
|
||||
}
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.redirect(Policy::none())
|
||||
.build()
|
||||
.expect("Failed to build client");
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(USER_AGENT, HeaderValue::from_static("yaak"));
|
||||
headers.insert(ACCEPT, HeaderValue::from_static("*/*"));
|
||||
|
||||
for h in req.headers.0 {
|
||||
if h.name.is_empty() && h.value.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let header_name = match HeaderName::from_bytes(h.name.as_bytes()) {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to create header name: {}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let header_value = match HeaderValue::from_str(h.value.as_str()) {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to create header value: {}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
headers.insert(header_name, header_value);
|
||||
}
|
||||
|
||||
let m =
|
||||
Method::from_bytes(req.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 = match sendable_req_result {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return response_err(response, e.to_string(), app_handle, pool).await;
|
||||
}
|
||||
};
|
||||
|
||||
let resp = client.execute(sendable_req).await;
|
||||
|
||||
let p = app_handle
|
||||
.path_resolver()
|
||||
.resolve_resource("plugins/plugin.ts")
|
||||
.expect("failed to resolve resource");
|
||||
|
||||
runtime::run_plugin_sync(p.to_str().unwrap()).unwrap();
|
||||
|
||||
match resp {
|
||||
Ok(v) => {
|
||||
response.status = v.status().as_u16() as i64;
|
||||
response.status_reason = v.status().canonical_reason().map(|s| s.to_string());
|
||||
response.headers = Json(
|
||||
v.headers()
|
||||
.iter()
|
||||
.map(|(k, v)| models::HttpResponseHeader {
|
||||
name: k.as_str().to_string(),
|
||||
value: v.to_str().unwrap().to_string(),
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
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)
|
||||
.await
|
||||
.expect("Failed to update response");
|
||||
app_handle.emit_all("updated_response", &response).unwrap();
|
||||
Ok(response.id)
|
||||
}
|
||||
Err(e) => response_err(response, e.to_string(), app_handle, pool).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn response_err(
|
||||
mut response: models::HttpResponse,
|
||||
error: String,
|
||||
app_handle: AppHandle<Wry>,
|
||||
pool: &Pool<Sqlite>,
|
||||
) -> Result<String, String> {
|
||||
response.error = Some(error.clone());
|
||||
response = models::update_response(response, pool)
|
||||
.await
|
||||
.expect("Failed to update response");
|
||||
app_handle.emit_all("updated_response", &response).unwrap();
|
||||
Ok(response.id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn create_request(
|
||||
workspace_id: &str,
|
||||
name: &str,
|
||||
app_handle: AppHandle<Wry>,
|
||||
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||
) -> Result<String, 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");
|
||||
|
||||
app_handle
|
||||
.emit_all("updated_request", &created_request)
|
||||
.unwrap();
|
||||
|
||||
Ok(created_request.id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn update_request(
|
||||
request: models::HttpRequest,
|
||||
app_handle: AppHandle<Wry>,
|
||||
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||
) -> Result<(), String> {
|
||||
let pool = &*db_instance.lock().await;
|
||||
|
||||
// TODO: Figure out how to make this better
|
||||
let b2;
|
||||
let body = match request.body {
|
||||
Some(b) => {
|
||||
b2 = b;
|
||||
Some(b2.as_str())
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
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.url.as_str(),
|
||||
request.headers.0,
|
||||
pool,
|
||||
)
|
||||
.await
|
||||
.expect("Failed to update request");
|
||||
|
||||
app_handle
|
||||
.emit_all("updated_request", updated_request)
|
||||
.unwrap();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn delete_request(
|
||||
app_handle: AppHandle<Wry>,
|
||||
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||
request_id: &str,
|
||||
) -> Result<models::HttpRequest, String> {
|
||||
let pool = &*db_instance.lock().await;
|
||||
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)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn requests(
|
||||
workspace_id: &str,
|
||||
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||
) -> Result<Vec<models::HttpRequest>, String> {
|
||||
let pool = &*db_instance.lock().await;
|
||||
models::find_requests(workspace_id, pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn responses(
|
||||
request_id: &str,
|
||||
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||
) -> Result<Vec<models::HttpResponse>, String> {
|
||||
let pool = &*db_instance.lock().await;
|
||||
models::find_responses(request_id, pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn delete_response(
|
||||
id: &str,
|
||||
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||
) -> Result<(), String> {
|
||||
let pool = &*db_instance.lock().await;
|
||||
models::delete_response(id, pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn delete_all_responses(
|
||||
request_id: &str,
|
||||
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||
) -> Result<(), String> {
|
||||
let pool = &*db_instance.lock().await;
|
||||
models::delete_all_responses(request_id, pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn workspaces(
|
||||
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||
) -> Result<Vec<models::Workspace>, String> {
|
||||
let pool = &*db_instance.lock().await;
|
||||
let workspaces = models::find_workspaces(pool)
|
||||
.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");
|
||||
Ok(vec![workspace])
|
||||
} else {
|
||||
Ok(workspaces)
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn greet(name: &str) -> String {
|
||||
format!("Hello, {}! You've been greeted from Rust!", name)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// here `"quit".to_string()` defines the menu item id, and the second parameter is the menu item label.
|
||||
let quit = CustomMenuItem::new("quit".to_string(), "Quit");
|
||||
let tray_menu = SystemTrayMenu::new().add_item(quit);
|
||||
let system_tray = SystemTray::new().with_menu(tray_menu);
|
||||
|
||||
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();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
win.position_traffic_lights();
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.on_system_tray_event(|app, event| match event {
|
||||
SystemTrayEvent::MenuItemClick { id, .. } => match id.as_str() {
|
||||
"quit" => {
|
||||
std::process::exit(0);
|
||||
}
|
||||
"hide" => {
|
||||
let window = app.get_window("main").unwrap();
|
||||
window.hide().unwrap();
|
||||
}
|
||||
.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);
|
||||
tauri::async_runtime::block_on(async move {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.connect(url.as_str())
|
||||
.await
|
||||
.expect("Failed to connect to database");
|
||||
let m = Mutex::new(pool);
|
||||
migrate_db(app.handle(), &m)
|
||||
.await
|
||||
.expect("Failed to migrate database");
|
||||
app.manage(m);
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
.on_system_tray_event(|app, event| {
|
||||
if let SystemTrayEvent::MenuItemClick { id, .. } = event {
|
||||
match id.as_str() {
|
||||
"quit" => {
|
||||
std::process::exit(0);
|
||||
}
|
||||
"hide" => {
|
||||
let window = app.get_window("main").unwrap();
|
||||
window.hide().unwrap();
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
}
|
||||
})
|
||||
.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();
|
||||
};
|
||||
|
||||
@@ -56,8 +417,16 @@ fn main() {
|
||||
}
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::send_request,
|
||||
commands::greet
|
||||
greet,
|
||||
workspaces,
|
||||
requests,
|
||||
send_request,
|
||||
create_request,
|
||||
update_request,
|
||||
delete_request,
|
||||
responses,
|
||||
delete_response,
|
||||
delete_all_responses,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
343
src-tauri/src/models.rs
Normal file
@@ -0,0 +1,343 @@
|
||||
use rand::distributions::{Alphanumeric, DistString};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::types::chrono::NaiveDateTime;
|
||||
use sqlx::types::Json;
|
||||
use sqlx::{Pool, Sqlite};
|
||||
|
||||
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Workspace {
|
||||
pub id: String,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
pub deleted_at: Option<NaiveDateTime>,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HttpRequestHeader {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HttpRequest {
|
||||
pub id: String,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
pub deleted_at: Option<NaiveDateTime>,
|
||||
pub workspace_id: String,
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub method: String,
|
||||
pub body: Option<String>,
|
||||
pub headers: Json<Vec<HttpRequestHeader>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HttpResponseHeader {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HttpResponse {
|
||||
pub id: 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,
|
||||
pub status: i64,
|
||||
pub status_reason: Option<String>,
|
||||
pub body: String,
|
||||
pub headers: Json<Vec<HttpResponseHeader>>,
|
||||
}
|
||||
|
||||
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
|
||||
FROM workspaces
|
||||
"#,
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_workspace(id: &str, pool: &Pool<Sqlite>) -> Result<Workspace, sqlx::Error> {
|
||||
sqlx::query_as!(
|
||||
Workspace,
|
||||
r#"
|
||||
SELECT id, created_at, updated_at, deleted_at, name, description
|
||||
FROM workspaces
|
||||
WHERE id = ?
|
||||
"#,
|
||||
id,
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create_workspace(
|
||||
name: &str,
|
||||
description: &str,
|
||||
pool: &Pool<Sqlite>,
|
||||
) -> Result<Workspace, sqlx::Error> {
|
||||
let id = generate_id("wk");
|
||||
sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO workspaces (id, name, description)
|
||||
VALUES (?, ?, ?)
|
||||
"#,
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("Failed to insert new workspace");
|
||||
|
||||
get_workspace(&id, pool).await
|
||||
}
|
||||
|
||||
pub async fn upsert_request(
|
||||
id: Option<&str>,
|
||||
workspace_id: &str,
|
||||
name: &str,
|
||||
method: &str,
|
||||
body: Option<&str>,
|
||||
url: &str,
|
||||
headers: Vec<HttpRequestHeader>,
|
||||
pool: &Pool<Sqlite>,
|
||||
) -> Result<HttpRequest, sqlx::Error> {
|
||||
let generated_id;
|
||||
let id = match id {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
generated_id = generate_id("rq");
|
||||
generated_id.as_str()
|
||||
}
|
||||
};
|
||||
let headers_json = Json(headers);
|
||||
sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO http_requests (id, workspace_id, name, url, method, body, headers, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
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
|
||||
"#,
|
||||
id,
|
||||
workspace_id,
|
||||
name,
|
||||
url,
|
||||
method,
|
||||
body,
|
||||
headers_json,
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("Failed to insert new request");
|
||||
get_request(id, pool).await
|
||||
}
|
||||
|
||||
pub async fn find_requests(
|
||||
workspace_id: &str,
|
||||
pool: &Pool<Sqlite>,
|
||||
) -> Result<Vec<HttpRequest>, sqlx::Error> {
|
||||
sqlx::query_as!(
|
||||
HttpRequest,
|
||||
r#"
|
||||
SELECT id, workspace_id, created_at, updated_at, deleted_at, name, url, method, body,
|
||||
headers AS "headers!: sqlx::types::Json<Vec<HttpRequestHeader>>"
|
||||
FROM http_requests
|
||||
WHERE workspace_id = ?
|
||||
"#,
|
||||
workspace_id,
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_request(id: &str, pool: &Pool<Sqlite>) -> Result<HttpRequest, sqlx::Error> {
|
||||
sqlx::query_as!(
|
||||
HttpRequest,
|
||||
r#"
|
||||
SELECT id, workspace_id, created_at, updated_at, deleted_at, name, url, method, body,
|
||||
headers AS "headers!: sqlx::types::Json<Vec<HttpRequestHeader>>"
|
||||
FROM http_requests
|
||||
WHERE id = ?
|
||||
ORDER BY created_at DESC
|
||||
"#,
|
||||
id,
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_request(id: &str, pool: &Pool<Sqlite>) -> Result<HttpRequest, sqlx::Error> {
|
||||
let req = get_request(id, pool)
|
||||
.await
|
||||
.expect("Failed to get request to delete");
|
||||
let _ = sqlx::query!(
|
||||
r#"
|
||||
DELETE FROM http_requests
|
||||
WHERE id = ?
|
||||
"#,
|
||||
id,
|
||||
)
|
||||
.execute(pool)
|
||||
.await;
|
||||
|
||||
Ok(req)
|
||||
}
|
||||
|
||||
pub async fn create_response(
|
||||
request_id: &str,
|
||||
elapsed: i64,
|
||||
url: &str,
|
||||
status: i64,
|
||||
status_reason: Option<&str>,
|
||||
body: &str,
|
||||
headers: Vec<HttpResponseHeader>,
|
||||
pool: &Pool<Sqlite>,
|
||||
) -> Result<HttpResponse, sqlx::Error> {
|
||||
let req = get_request(request_id, pool)
|
||||
.await
|
||||
.expect("Failed to get request");
|
||||
let id = generate_id("rp");
|
||||
let headers_json = Json(headers);
|
||||
sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO http_responses (id, request_id, workspace_id, elapsed, url, status, status_reason, body, headers)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);
|
||||
"#,
|
||||
id,
|
||||
request_id,
|
||||
req.workspace_id,
|
||||
elapsed,
|
||||
url,
|
||||
status,
|
||||
status_reason,
|
||||
body,
|
||||
headers_json,
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("Failed to insert new response");
|
||||
|
||||
get_response(&id, pool).await
|
||||
}
|
||||
|
||||
pub async fn update_response(
|
||||
response: HttpResponse,
|
||||
pool: &Pool<Sqlite>,
|
||||
) -> Result<HttpResponse, sqlx::Error> {
|
||||
let headers_json = Json(response.headers);
|
||||
sqlx::query!(
|
||||
r#"
|
||||
UPDATE http_responses SET (elapsed, url, status, status_reason, body, error, headers, updated_at) =
|
||||
(?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) WHERE id = ?;
|
||||
"#,
|
||||
response.elapsed,
|
||||
response.url,
|
||||
response.status,
|
||||
response.status_reason,
|
||||
response.body,
|
||||
response.error,
|
||||
headers_json,
|
||||
response.id,
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("Failed to update response");
|
||||
get_response(&response.id, pool).await
|
||||
}
|
||||
|
||||
pub async fn get_response(id: &str, pool: &Pool<Sqlite>) -> Result<HttpResponse, sqlx::Error> {
|
||||
sqlx::query_as_unchecked!(
|
||||
HttpResponse,
|
||||
r#"
|
||||
SELECT id, workspace_id, request_id, updated_at, deleted_at, created_at,
|
||||
status, status_reason, body, elapsed, url, error,
|
||||
headers AS "headers!: sqlx::types::Json<Vec<HttpResponseHeader>>"
|
||||
FROM http_responses
|
||||
WHERE id = ?
|
||||
"#,
|
||||
id,
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_responses(
|
||||
request_id: &str,
|
||||
pool: &Pool<Sqlite>,
|
||||
) -> Result<Vec<HttpResponse>, sqlx::Error> {
|
||||
sqlx::query_as!(
|
||||
HttpResponse,
|
||||
r#"
|
||||
SELECT id, workspace_id, request_id, updated_at, deleted_at,
|
||||
created_at, status, status_reason, body, elapsed, url, error,
|
||||
headers AS "headers!: sqlx::types::Json<Vec<HttpResponseHeader>>"
|
||||
FROM http_responses
|
||||
WHERE request_id = ?
|
||||
ORDER BY created_at ASC
|
||||
"#,
|
||||
request_id,
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_response(id: &str, pool: &Pool<Sqlite>) -> Result<(), sqlx::Error> {
|
||||
let _ = sqlx::query!(
|
||||
r#"
|
||||
DELETE FROM http_responses
|
||||
WHERE id = ?
|
||||
"#,
|
||||
id,
|
||||
)
|
||||
.execute(pool)
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_all_responses(
|
||||
request_id: &str,
|
||||
pool: &Pool<Sqlite>,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
let _ = sqlx::query!(
|
||||
r#"
|
||||
DELETE FROM http_responses
|
||||
WHERE request_id = ?
|
||||
"#,
|
||||
request_id,
|
||||
)
|
||||
.execute(pool)
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn generate_id(prefix: &str) -> String {
|
||||
format!(
|
||||
"{prefix}_{}",
|
||||
Alphanumeric.sample_string(&mut rand::thread_rng(), 10)
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
(function (globalThis) {
|
||||
Deno.core.initializeAsyncOps();
|
||||
// Deno.core.print(Object.keys(Deno.core).join('\n'));
|
||||
|
||||
function argsToMessage(...args) {
|
||||
return args.map((arg) => JSON.stringify(arg)).join(' ');
|
||||
|
||||
@@ -24,12 +24,18 @@ pub async fn run_plugin(file_path: &str) -> Result<(), AnyError> {
|
||||
|
||||
runtime
|
||||
.execute_script("<runtime>", include_str!("runtime.js"))
|
||||
.unwrap();
|
||||
.expect("Failed to execute runtime.js");
|
||||
|
||||
let main_module = deno_core::resolve_path(file_path)?;
|
||||
let mod_id = runtime.load_main_module(&main_module, None).await?;
|
||||
let main_module = deno_core::resolve_path(file_path).expect("Failed to resolve path");
|
||||
let mod_id = runtime
|
||||
.load_main_module(&main_module, None)
|
||||
.await
|
||||
.expect("Failed to load main module");
|
||||
let result = runtime.mod_evaluate(mod_id);
|
||||
runtime.run_event_loop(false).await?;
|
||||
runtime
|
||||
.run_event_loop(false)
|
||||
.await
|
||||
.expect("Failed to run event loop");
|
||||
result.await?
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
use tauri::{Runtime, Window};
|
||||
|
||||
const TRAFFIC_LIGHT_OFFSET_X: f64 = 15.0;
|
||||
const TRAFFIC_LIGHT_OFFSET_Y: f64 = 20.0;
|
||||
const TRAFFIC_LIGHT_OFFSET_Y: f64 = 22.0;
|
||||
|
||||
pub trait WindowExt {
|
||||
#[cfg(target_os = "macos")]
|
||||
fn position_traffic_lights(&self);
|
||||
}
|
||||
|
||||
|
||||
86
src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"build": {
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devPath": "http://localhost:1420",
|
||||
"distDir": "../dist",
|
||||
"withGlobalTauri": false
|
||||
},
|
||||
"package": {
|
||||
"productName": "Yaak",
|
||||
"version": "0.0.2"
|
||||
},
|
||||
"tauri": {
|
||||
"windows": [
|
||||
{
|
||||
"fullscreen": false,
|
||||
"height": 800,
|
||||
"hiddenTitle": true,
|
||||
"resizable": true,
|
||||
"title": "Yaak",
|
||||
"titleBarStyle": "Overlay",
|
||||
"width": 1400
|
||||
}
|
||||
],
|
||||
"allowlist": {
|
||||
"all": false,
|
||||
"fs": {
|
||||
"scope": [
|
||||
"$RESOURCE/*"
|
||||
]
|
||||
},
|
||||
"shell": {
|
||||
"all": false,
|
||||
"open": true
|
||||
},
|
||||
"window": {
|
||||
"startDragging": true
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"category": "DeveloperTool",
|
||||
"copyright": "",
|
||||
"externalBin": [],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"identifier": "co.schier.yaak",
|
||||
"longDescription": "",
|
||||
"resources": [
|
||||
"plugins/*",
|
||||
"migrations/*"
|
||||
],
|
||||
"shortDescription": "",
|
||||
"targets": "all",
|
||||
"deb": {
|
||||
"depends": []
|
||||
},
|
||||
"macOS": {
|
||||
"exceptionDomain": "",
|
||||
"frameworks": []
|
||||
},
|
||||
"windows": {
|
||||
"digestAlgorithm": "sha256",
|
||||
"timestampUrl": ""
|
||||
}
|
||||
},
|
||||
"security": {},
|
||||
"systemTray": {
|
||||
"iconAsTemplate": true,
|
||||
"iconPath": "icons/icon.png"
|
||||
},
|
||||
"updater": {
|
||||
"active": true,
|
||||
"dialog": true,
|
||||
"endpoints": [
|
||||
"https://update.yaak.app/check/{{target}}/{{current_version}}"
|
||||
],
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEMxRDJFREQ1MjExQjdGN0IKUldSN2Z4c2gxZTNTd1FHNCtmYnFXMHVVQzhuNkJOM1cwOFBodmdLall3ckhKenpKUytHSTR1MlkK"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
[build]
|
||||
beforeDevCommand = "npm run dev"
|
||||
beforeBuildCommand = "npm run build"
|
||||
devPath = "http://localhost:1420"
|
||||
distDir = "../dist"
|
||||
withGlobalTauri = false
|
||||
|
||||
[package]
|
||||
productName = "Twosomnia"
|
||||
version = "0.0.1"
|
||||
|
||||
[tauri.allowlist]
|
||||
all = false
|
||||
|
||||
[tauri.allowlist.shell]
|
||||
all = false
|
||||
open = true
|
||||
|
||||
[tauri.allowlist.window]
|
||||
startDragging = true
|
||||
|
||||
[tauri.allowlist.fs]
|
||||
scope = [ "$RESOURCE/*" ]
|
||||
|
||||
[tauri.bundle]
|
||||
active = true
|
||||
category = "DeveloperTool"
|
||||
copyright = ""
|
||||
externalBin = [ ]
|
||||
icon = [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
identifier = "co.schier.twosomnia"
|
||||
longDescription = ""
|
||||
resources = [ "plugins/*" ]
|
||||
shortDescription = ""
|
||||
targets = "all"
|
||||
|
||||
[tauri.bundle.deb]
|
||||
depends = [ ]
|
||||
|
||||
[tauri.bundle.macOS]
|
||||
exceptionDomain = ""
|
||||
frameworks = [ ]
|
||||
|
||||
[tauri.bundle.windows]
|
||||
digestAlgorithm = "sha256"
|
||||
timestampUrl = ""
|
||||
|
||||
[tauri.security]
|
||||
|
||||
[tauri.updater]
|
||||
active = false
|
||||
endpoints = [ ]
|
||||
pubkey = ""
|
||||
dialog = true
|
||||
|
||||
[[tauri.windows]]
|
||||
fullscreen = false
|
||||
height = 800
|
||||
resizable = true
|
||||
title = "Twosomnia"
|
||||
width = 1_400
|
||||
titleBarStyle = "Overlay"
|
||||
hiddenTitle = true
|
||||
|
||||
[tauri.systemTray]
|
||||
iconPath = "icons/icon.png"
|
||||
iconAsTemplate = true
|
||||
6
src-wasm/hello/.gitignore
vendored
@@ -1,6 +0,0 @@
|
||||
/target
|
||||
**/*.rs.bk
|
||||
Cargo.lock
|
||||
bin/
|
||||
pkg/
|
||||
wasm-pack.log
|
||||
@@ -1,33 +0,0 @@
|
||||
[package]
|
||||
name = "hello"
|
||||
version = "0.1.0"
|
||||
authors = ["Gregory Schier <gschier1990@gmail.com>"]
|
||||
edition = "2018"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[features]
|
||||
default = ["console_error_panic_hook"]
|
||||
|
||||
[dependencies]
|
||||
wasm-bindgen = "0.2.63"
|
||||
|
||||
# The `console_error_panic_hook` crate provides better debugging of panics by
|
||||
# logging them with `console.error`. This is great for development, but requires
|
||||
# all the `std::fmt` and `std::panicking` infrastructure, so isn't great for
|
||||
# code size when deploying.
|
||||
console_error_panic_hook = { version = "0.1.6", optional = true }
|
||||
|
||||
# `wee_alloc` is a tiny allocator for wasm that is only ~1K in code size
|
||||
# compared to the default allocator's ~10K. It is slower than the default
|
||||
# allocator, however.
|
||||
wee_alloc = { version = "0.4.5", optional = true }
|
||||
wasm-bindgen-futures = "0.4.34"
|
||||
|
||||
[dev-dependencies]
|
||||
wasm-bindgen-test = "0.3.13"
|
||||
|
||||
[profile.release]
|
||||
# Tell `rustc` to optimize for small code size.
|
||||
opt-level = "s"
|
||||
@@ -1,21 +0,0 @@
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
mod utils;
|
||||
|
||||
// When the `wee_alloc` feature is enabled, use `wee_alloc` as the global allocator.
|
||||
#[cfg(feature = "wee_alloc")]
|
||||
#[global_allocator]
|
||||
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
|
||||
|
||||
#[wasm_bindgen]
|
||||
extern "C" {
|
||||
fn alert(s: &str);
|
||||
|
||||
#[wasm_bindgen(js_namespace = console)]
|
||||
fn log(s: &str);
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn greet() {
|
||||
log("Hello from Rust WASM!");
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
pub fn set_panic_hook() {
|
||||
// When the `console_error_panic_hook` feature is enabled, we can call the
|
||||
// `set_panic_hook` function at least once during initialization, and then
|
||||
// we will get better error messages if our code ever panics.
|
||||
//
|
||||
// For more details see
|
||||
// https://github.com/rustwasm/console_error_panic_hook#readme
|
||||
#[cfg(feature = "console_error_panic_hook")]
|
||||
console_error_panic_hook::set_once();
|
||||
}
|
||||
137
src-web/App.tsx
@@ -1,137 +0,0 @@
|
||||
import { FormEvent, useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/tauri';
|
||||
import Editor from './components/Editor/Editor';
|
||||
import { Input } from './components/Input';
|
||||
import { HStack, VStack } from './components/Stacks';
|
||||
import { Button } from './components/Button';
|
||||
import { DropdownMenuRadio } from './components/Dropdown';
|
||||
import { WindowDragRegion } from './components/WindowDragRegion';
|
||||
import { IconButton } from './components/IconButton';
|
||||
|
||||
interface Response {
|
||||
url: string;
|
||||
method: string;
|
||||
body: string;
|
||||
status: string;
|
||||
elapsed: number;
|
||||
elapsed2: number;
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [response, setResponse] = useState<Response | null>(null);
|
||||
const [url, setUrl] = useState('https://go-server.schier.dev/debug');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [method, setMethod] = useState<string>('get');
|
||||
|
||||
async function sendRequest(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const resp = (await invoke('send_request', { method, url })) as Response;
|
||||
if (resp.body.includes('<head>')) {
|
||||
resp.body = resp.body.replace(/<head>/gi, `<head><base href="${resp.url}"/>`);
|
||||
}
|
||||
setLoading(false);
|
||||
setResponse(resp);
|
||||
} catch (err) {
|
||||
setLoading(false);
|
||||
setError(`${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
const contentType = response?.headers['content-type']?.split(';')[0] ?? 'text/plain';
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-[auto_1fr] h-full">
|
||||
<nav className="w-52 bg-gray-50 h-full border-r border-gray-500/10">
|
||||
<HStack as={WindowDragRegion} className="pl-24 px-1" items="center" justify="end">
|
||||
<IconButton icon="archive" size="sm" />
|
||||
<DropdownMenuRadio
|
||||
onValueChange={null}
|
||||
value={'get'}
|
||||
items={[
|
||||
{ label: 'This is a cool one', value: 'get' },
|
||||
{ label: 'But this one is better', value: 'put' },
|
||||
{ label: 'This one is just alright', value: 'post' },
|
||||
]}
|
||||
>
|
||||
<IconButton icon="camera" size="sm" />
|
||||
</DropdownMenuRadio>
|
||||
</HStack>
|
||||
</nav>
|
||||
<VStack className="w-full">
|
||||
<HStack as={WindowDragRegion} items="center" className="pl-4 pr-1">
|
||||
<h5>Hello, Friend!</h5>
|
||||
<IconButton icon="gear" className="ml-auto" size="sm" />
|
||||
</HStack>
|
||||
<VStack className="p-4 max-w-[35rem] mx-auto" space={3}>
|
||||
<HStack as="form" className="items-end" onSubmit={sendRequest} space={2}>
|
||||
<DropdownMenuRadio
|
||||
onValueChange={setMethod}
|
||||
value={method}
|
||||
items={[
|
||||
{ label: 'GET', value: 'get' },
|
||||
{ label: 'PUT', value: 'put' },
|
||||
{ label: 'POST', value: 'post' },
|
||||
]}
|
||||
>
|
||||
<Button disabled={loading} color="secondary" forDropdown>
|
||||
{method.toUpperCase()}
|
||||
</Button>
|
||||
</DropdownMenuRadio>
|
||||
<HStack>
|
||||
<Input
|
||||
hideLabel
|
||||
name="url"
|
||||
label="Enter URL"
|
||||
className="rounded-r-none font-mono"
|
||||
onChange={(e) => setUrl(e.currentTarget.value)}
|
||||
value={url}
|
||||
placeholder="Enter a URL..."
|
||||
/>
|
||||
<Button
|
||||
className="mr-1 rounded-l-none -ml-3"
|
||||
color="primary"
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Sending...' : 'Send'}
|
||||
</Button>
|
||||
</HStack>
|
||||
</HStack>
|
||||
{error && <div className="text-white bg-red-500 px-4 py-1 rounded">{error}</div>}
|
||||
{response !== null && (
|
||||
<>
|
||||
<div className="my-1 italic text-gray-500 text-sm">
|
||||
{response?.method.toUpperCase()}
|
||||
•
|
||||
{response?.status}
|
||||
•
|
||||
{response?.elapsed}ms •
|
||||
{response?.elapsed2}ms
|
||||
</div>
|
||||
{contentType.includes('html') ? (
|
||||
<iframe
|
||||
title="Response preview"
|
||||
srcDoc={response.body}
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
className="h-[70vh] w-full rounded-lg"
|
||||
/>
|
||||
) : response?.body ? (
|
||||
<Editor value={response?.body} contentType={contentType} />
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</VStack>
|
||||
</VStack>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
89
src-web/components/App.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { MotionConfig } from 'framer-motion';
|
||||
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';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
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;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
await listen('deleted_request', ({ payload: request }: { payload: HttpRequest }) => {
|
||||
queryClient.setQueryData(requestsQueryKey(request.workspaceId), (requests: HttpRequest[] = []) =>
|
||||
requests.filter((r) => r.id !== request.id),
|
||||
);
|
||||
});
|
||||
|
||||
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`;
|
||||
});
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MotionConfig transition={{ duration: 0.1 }}>
|
||||
<HelmetProvider>
|
||||
<AppRouter />
|
||||
</HelmetProvider>
|
||||
</MotionConfig>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
35
src-web/components/AppRouter.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
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'));
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: '/',
|
||||
errorElement: <RouteError />,
|
||||
children: [
|
||||
{
|
||||
path: '/',
|
||||
element: <Workspaces />,
|
||||
},
|
||||
{
|
||||
path: '/workspaces/:workspaceId',
|
||||
element: <Workspace />,
|
||||
},
|
||||
{
|
||||
path: '/workspaces/:workspaceId/requests/:requestId',
|
||||
element: <Workspace />,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
export function AppRouter() {
|
||||
return (
|
||||
<Suspense>
|
||||
<RouterProvider router={router} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import classnames from 'classnames';
|
||||
import { ButtonHTMLAttributes, forwardRef } from 'react';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
color?: 'primary' | 'secondary';
|
||||
size?: 'sm' | 'md';
|
||||
forDropdown?: boolean;
|
||||
};
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
||||
{ className, children, size = 'md', forDropdown, color, ...props }: ButtonProps,
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={classnames(
|
||||
className,
|
||||
'rounded-md text-white flex items-center',
|
||||
{ 'h-10 px-4': size === 'md' },
|
||||
{ 'h-8 px-3': size === 'sm' },
|
||||
{ 'hover:bg-gray-500/[0.1] active:bg-gray-500/[0.15]': color === undefined },
|
||||
{ 'bg-blue-500 hover:bg-blue-500/90 active:bg-blue-500/80': color === 'primary' },
|
||||
{ 'bg-violet-500 hover:bg-violet-500/90 active:bg-violet-500/80': color === 'secondary' },
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{forDropdown && <Icon icon="triangle-down" className="ml-1 -mr-1" />}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
@@ -1,311 +0,0 @@
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||
import { DropdownMenuRadioGroup } from '@radix-ui/react-dropdown-menu';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronRightIcon,
|
||||
DotFilledIcon,
|
||||
HamburgerMenuIcon,
|
||||
} from '@radix-ui/react-icons';
|
||||
import { forwardRef, HTMLAttributes, ReactNode, useState } from 'react';
|
||||
import { Button } from './Button';
|
||||
import classnames from 'classnames';
|
||||
import { HotKey } from './HotKey';
|
||||
|
||||
interface DropdownMenuRadioProps {
|
||||
children: ReactNode;
|
||||
onValueChange: ((value: string) => void) | null;
|
||||
value: string;
|
||||
items: {
|
||||
label: string;
|
||||
value: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export function DropdownMenuRadio({
|
||||
children,
|
||||
items,
|
||||
onValueChange,
|
||||
value,
|
||||
}: DropdownMenuRadioProps) {
|
||||
return (
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenuTrigger>{children}</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuRadioGroup onValueChange={onValueChange ?? undefined} value={value}>
|
||||
{items.map((item) => (
|
||||
<DropdownMenuRadioItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenu.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function Dropdown() {
|
||||
const [bookmarksChecked, setBookmarksChecked] = useState(true);
|
||||
const [urlsChecked, setUrlsChecked] = useState(false);
|
||||
const [person, setPerson] = useState('pedro');
|
||||
|
||||
return (
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<Button aria-label="Customise options">
|
||||
<HamburgerMenuIcon />
|
||||
</Button>
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem rightSlot={<HotKey>⌘T</HotKey>}>New Tab</DropdownMenuItem>
|
||||
<DropdownMenuItem rightSlot={<HotKey>⌘N</HotKey>}>New Window</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled rightSlot={<HotKey>⇧⌘N</HotKey>}>
|
||||
New Private Window
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenuSubTrigger rightSlot={<ChevronRightIcon />}>
|
||||
More Tools
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem rightSlot={<HotKey>⌘S</HotKey>}>Save Page As…</DropdownMenuItem>
|
||||
<DropdownMenuItem>Create Shortcut…</DropdownMenuItem>
|
||||
<DropdownMenuItem>Name Window…</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>Developer Tools</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenu.Sub>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={bookmarksChecked}
|
||||
onCheckedChange={(v) => setBookmarksChecked(!!v)}
|
||||
rightSlot={<HotKey>⌘B</HotKey>}
|
||||
leftSlot={
|
||||
<DropdownMenu.ItemIndicator className="DropdownMenuItemIndicator">
|
||||
<CheckIcon />
|
||||
</DropdownMenu.ItemIndicator>
|
||||
}
|
||||
>
|
||||
Show Bookmarks
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={urlsChecked}
|
||||
onCheckedChange={(v) => setUrlsChecked(!!v)}
|
||||
leftSlot={
|
||||
<DropdownMenu.ItemIndicator className="DropdownMenuItemIndicator">
|
||||
<CheckIcon />
|
||||
</DropdownMenu.ItemIndicator>
|
||||
}
|
||||
>
|
||||
Show Full URLs
|
||||
</DropdownMenuCheckboxItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuLabel>People</DropdownMenuLabel>
|
||||
<DropdownMenu.RadioGroup value={person} onValueChange={setPerson}>
|
||||
<DropdownMenuRadioItem value="pedro">Pedro Duarte</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem className="DropdownMenuRadioItem" value="colm">
|
||||
Colm Tuite
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenu.RadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenu.Root>
|
||||
);
|
||||
}
|
||||
|
||||
const dropdownMenuClasses = 'bg-background rounded-md shadow-lg p-1.5 border border-gray-100';
|
||||
|
||||
interface DropdownMenuPortalProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({ children }: DropdownMenuPortalProps) {
|
||||
return (
|
||||
<DropdownMenu.Portal container={document.querySelector<HTMLElement>('#radix-portal')}>
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
|
||||
{children}
|
||||
</motion.div>
|
||||
</DropdownMenu.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
const DropdownMenuContent = forwardRef<HTMLDivElement, DropdownMenu.DropdownMenuContentProps>(
|
||||
function DropdownMenuContent(
|
||||
{ className, children, ...props }: DropdownMenu.DropdownMenuContentProps,
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<DropdownMenu.Content
|
||||
ref={ref}
|
||||
align="start"
|
||||
className={classnames(className, dropdownMenuClasses, 'mt-1')}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</DropdownMenu.Content>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
type DropdownMenuItemProps = DropdownMenu.DropdownMenuItemProps & ItemInnerProps;
|
||||
|
||||
function DropdownMenuItem({
|
||||
leftSlot,
|
||||
rightSlot,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: DropdownMenuItemProps) {
|
||||
return (
|
||||
<DropdownMenu.Item
|
||||
asChild
|
||||
className={classnames(className, { 'opacity-30': props.disabled })}
|
||||
{...props}
|
||||
>
|
||||
<ItemInner leftSlot={leftSlot} rightSlot={rightSlot}>
|
||||
{children}
|
||||
</ItemInner>
|
||||
</DropdownMenu.Item>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuRadioItemProps = Omit<
|
||||
DropdownMenu.DropdownMenuRadioItemProps & ItemInnerProps,
|
||||
'leftSlot'
|
||||
>;
|
||||
|
||||
function DropdownMenuRadioItem({ rightSlot, children, ...props }: DropdownMenuRadioItemProps) {
|
||||
return (
|
||||
<DropdownMenu.RadioItem asChild {...props}>
|
||||
<ItemInner
|
||||
leftSlot={
|
||||
<DropdownMenu.ItemIndicator>
|
||||
<DotFilledIcon />
|
||||
</DropdownMenu.ItemIndicator>
|
||||
}
|
||||
rightSlot={rightSlot}
|
||||
>
|
||||
{children}
|
||||
</ItemInner>
|
||||
</DropdownMenu.RadioItem>
|
||||
);
|
||||
}
|
||||
|
||||
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}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
function DropdownMenuLabel({ className, children, ...props }: DropdownMenu.DropdownMenuLabelProps) {
|
||||
return (
|
||||
<DropdownMenu.Label asChild {...props}>
|
||||
<ItemInner noHover className={classnames(className, 'opacity-50 uppercase text-sm')}>
|
||||
{children}
|
||||
</ItemInner>
|
||||
</DropdownMenu.Label>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({ className, ...props }: DropdownMenu.DropdownMenuSeparatorProps) {
|
||||
return (
|
||||
<DropdownMenu.Separator
|
||||
className={classnames(className, 'h-[1px] bg-gray-400 bg-opacity-30 my-1')}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({ className, ...props }: DropdownMenu.DropdownMenuTriggerProps) {
|
||||
return (
|
||||
<DropdownMenu.Trigger
|
||||
asChild
|
||||
className={classnames(className, 'focus:outline-none')}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface ItemInnerProps extends HTMLAttributes<HTMLDivElement> {
|
||||
leftSlot?: ReactNode;
|
||||
rightSlot?: ReactNode;
|
||||
children: ReactNode;
|
||||
noHover?: boolean;
|
||||
}
|
||||
|
||||
const ItemInner = forwardRef<HTMLDivElement, ItemInnerProps>(function ItemInner(
|
||||
{ leftSlot, rightSlot, children, className, noHover, ...props }: ItemInnerProps,
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={classnames(
|
||||
className,
|
||||
'outline-none px-2 py-1.5 flex items-center text-sm text-gray-700',
|
||||
{
|
||||
'focus:bg-gray-50 focus:text-gray-900 rounded': !noHover,
|
||||
},
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="w-7">{leftSlot}</div>
|
||||
<div>{children}</div>
|
||||
{rightSlot && <div className="ml-auto pl-3">{rightSlot}</div>}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,54 +0,0 @@
|
||||
.cm-editor {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: var(--border-radius-lg);
|
||||
}
|
||||
|
||||
.cm-editor .cm-scroller {
|
||||
border-radius: var(--border-radius-lg);
|
||||
background-color: hsl(var(--color-gray-50) / 0.5);
|
||||
}
|
||||
|
||||
.cm-editor .cm-line {
|
||||
padding-left: 1em;
|
||||
padding-right: 1.5em;
|
||||
color: hsl(var(--color-gray-900));
|
||||
}
|
||||
|
||||
.cm-editor .cm-gutters {
|
||||
background-color: transparent;
|
||||
border-right: 0;
|
||||
color: hsl(var(--color-gray-300));
|
||||
}
|
||||
|
||||
.cm-editor .cm-foldPlaceholder {
|
||||
background-color: hsl(var(--color-gray-100));
|
||||
border: 1px solid hsl(var(--color-gray-200));
|
||||
padding: 0 0.3em;
|
||||
}
|
||||
|
||||
.cm-editor .cm-activeLineGutter,
|
||||
.cm-editor .cm-activeLine {
|
||||
background-color: hsl(var(--color-gray-50));
|
||||
}
|
||||
|
||||
.cm-editor * {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.cm-editor.cm-focused {
|
||||
outline: 0;
|
||||
box-shadow: 0 0 0 2pt rgba(180, 180, 180, 0.1);
|
||||
}
|
||||
|
||||
.cm-editor .cm-cursor {
|
||||
border-left: 2px solid red;
|
||||
}
|
||||
|
||||
.cm-editor .cm-selectionBackground {
|
||||
background-color: hsl(var(--color-gray-100));
|
||||
}
|
||||
|
||||
.cm-editor.cm-focused .cm-selectionBackground {
|
||||
background-color: hsl(var(--color-gray-100));
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import useCodeMirror from '../../hooks/useCodemirror';
|
||||
import './Editor.css';
|
||||
|
||||
interface Props {
|
||||
contentType: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export default function Editor(props: Props) {
|
||||
const { ref } = useCodeMirror({ value: props.value, contentType: props.contentType });
|
||||
return <div ref={ref} className="m-0 text-sm overflow-hidden" />;
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import classnames from 'classnames';
|
||||
import {HTMLAttributes} from 'react';
|
||||
|
||||
const colsClasses = {
|
||||
none: 'grid-cols-none',
|
||||
1: 'grid-cols-1',
|
||||
2: 'grid-cols-2',
|
||||
};
|
||||
|
||||
const rowsClasses = {
|
||||
none: 'grid-rows-none',
|
||||
1: 'grid-rows-1',
|
||||
2: 'grid-rows-2',
|
||||
};
|
||||
|
||||
const gapClasses = {
|
||||
0: 'gap-0',
|
||||
1: 'gap-1',
|
||||
2: 'gap-2',
|
||||
};
|
||||
|
||||
type Props = HTMLAttributes<HTMLElement> & {
|
||||
rows?: keyof typeof rowsClasses;
|
||||
cols?: keyof typeof colsClasses;
|
||||
gap?: keyof typeof gapClasses;
|
||||
};
|
||||
|
||||
export function Grid({ className, cols, gap, ...props }: Props) {
|
||||
return (
|
||||
<div
|
||||
className={classnames(className, 'grid', cols && colsClasses[cols], gap && gapClasses[gap])}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
117
src-web/components/HeaderEditor.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
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';
|
||||
|
||||
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));
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { ComponentType } from 'react';
|
||||
import {
|
||||
ArchiveIcon,
|
||||
CameraIcon,
|
||||
ChevronDownIcon,
|
||||
GearIcon,
|
||||
HomeIcon,
|
||||
TriangleDownIcon,
|
||||
} from '@radix-ui/react-icons';
|
||||
import classnames from 'classnames';
|
||||
|
||||
type IconName = 'archive' | 'home' | 'camera' | 'gear' | 'triangle-down';
|
||||
|
||||
const icons: Record<IconName, ComponentType> = {
|
||||
archive: ArchiveIcon,
|
||||
home: HomeIcon,
|
||||
camera: CameraIcon,
|
||||
gear: GearIcon,
|
||||
'triangle-down': TriangleDownIcon,
|
||||
};
|
||||
|
||||
export interface IconProps {
|
||||
icon: IconName;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Icon({ icon, className }: IconProps) {
|
||||
const Component = icons[icon];
|
||||
return (
|
||||
<div className={classnames(className, 'flex items-center')}>
|
||||
<Component />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { forwardRef } from 'react';
|
||||
import { Icon, IconProps } from './Icon';
|
||||
import { Button, ButtonProps } from './Button';
|
||||
|
||||
type Props = ButtonProps & IconProps;
|
||||
|
||||
export const IconButton = forwardRef<HTMLButtonElement, Props>(function IconButton(
|
||||
{ icon, ...props }: Props,
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<Button ref={ref} className="group" {...props}>
|
||||
<Icon icon={icon} className="text-gray-700 group-hover:text-gray-900" />
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
import { InputHTMLAttributes } from 'react';
|
||||
import classnames from 'classnames';
|
||||
import { VStack } from './Stacks';
|
||||
|
||||
interface Props extends InputHTMLAttributes<HTMLInputElement> {
|
||||
name: string;
|
||||
label: string;
|
||||
hideLabel?: boolean;
|
||||
labelClassName?: string;
|
||||
}
|
||||
|
||||
export function Input({ label, labelClassName, hideLabel, className, name, ...props }: Props) {
|
||||
const id = `input-${name}`;
|
||||
return (
|
||||
<VStack className="w-full">
|
||||
<label
|
||||
htmlFor={name}
|
||||
className={classnames(labelClassName, 'font-semibold text-sm uppercase text-gray-700', {
|
||||
'sr-only': hideLabel,
|
||||
})}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
className={classnames(
|
||||
className,
|
||||
'w-0 min-w-[100%] border-2 border-gray-100 bg-gray-50 h-10 pl-3 pr-2 rounded-md text-sm focus:outline-none',
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
64
src-web/components/RequestPane.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import classnames from 'classnames';
|
||||
import { useActiveRequest } from '../hooks/useActiveRequest';
|
||||
import { useSendRequest } from '../hooks/useSendRequest';
|
||||
import { useUpdateRequest } from '../hooks/useUpdateRequest';
|
||||
import { Editor } from './core/Editor';
|
||||
import { HeaderEditor } from './HeaderEditor';
|
||||
import { TabContent, Tabs } from './core/Tabs/Tabs';
|
||||
import { UrlBar } from './UrlBar';
|
||||
|
||||
interface Props {
|
||||
fullHeight: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RequestPane({ fullHeight, className }: Props) {
|
||||
const activeRequest = useActiveRequest();
|
||||
const updateRequest = useUpdateRequest(activeRequest);
|
||||
const sendRequest = useSendRequest(activeRequest);
|
||||
|
||||
if (activeRequest === null) return null;
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
131
src-web/components/ResponsePane.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import classnames from 'classnames';
|
||||
import { memo, useEffect, useMemo, useState } from 'react';
|
||||
import { useDeleteResponses } from '../hooks/useDeleteResponses';
|
||||
import { useDeleteResponse } from '../hooks/useResponseDelete';
|
||||
import { useResponses } from '../hooks/useResponses';
|
||||
import { tryFormatJson } from '../lib/formatters';
|
||||
import type { HttpResponse } from '../lib/models';
|
||||
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 { Webview } from './core/Webview';
|
||||
|
||||
interface Props {
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveResponseId(null);
|
||||
}, [responses.length]);
|
||||
|
||||
const contentType = useMemo(
|
||||
() =>
|
||||
activeResponse?.headers.find((h) => h.name.toLowerCase() === 'content-type')?.value ??
|
||||
'text/plain',
|
||||
[activeResponse],
|
||||
);
|
||||
|
||||
if (activeResponse === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-2">
|
||||
<div
|
||||
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',
|
||||
)}
|
||||
>
|
||||
{/*<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">
|
||||
<StatusColor statusCode={activeResponse.status}>
|
||||
{activeResponse.status}
|
||||
{activeResponse.statusReason && ` ${activeResponse.statusReason}`}
|
||||
</StatusColor>
|
||||
•
|
||||
{activeResponse.elapsed}ms •
|
||||
{Math.round(activeResponse.body.length / 1000)} KB
|
||||
</div>
|
||||
)}
|
||||
|
||||
<HStack alignItems="center" className="ml-auto h-8">
|
||||
<IconButton
|
||||
icon={viewMode === 'pretty' ? 'eye' : 'code'}
|
||||
size="sm"
|
||||
className="ml-1"
|
||||
onClick={() => setViewMode((m) => (m === 'pretty' ? 'raw' : 'pretty'))}
|
||||
/>
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
label: 'Clear Response',
|
||||
onSelect: deleteResponse.mutate,
|
||||
disabled: responses.length === 0,
|
||||
},
|
||||
{
|
||||
label: 'Clear All Responses',
|
||||
onSelect: deleteAllResponses.mutate,
|
||||
disabled: responses.length === 0,
|
||||
},
|
||||
'-----',
|
||||
...responses.slice(0, 10).map((r) => ({
|
||||
label: r.status + ' - ' + r.elapsed + ' ms',
|
||||
leftSlot: activeResponse?.id === r.id ? <Icon icon="check" /> : <></>,
|
||||
onSelect: () => setActiveResponseId(r.id),
|
||||
})),
|
||||
]}
|
||||
>
|
||||
<IconButton icon="clock" className="ml-auto" size="sm" />
|
||||
</Dropdown>
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
23
src-web/components/RouteError.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { useRouteError } from 'react-router-dom';
|
||||
import { Button } from './core/Button';
|
||||
import { Heading } from './core/Heading';
|
||||
import { VStack } from './core/Stacks';
|
||||
|
||||
export default function RouteError() {
|
||||
const error = useRouteError();
|
||||
const stringified = JSON.stringify(error);
|
||||
const message = (error as any).message ?? stringified;
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<VStack space={5} className="max-w-[30rem] !h-auto">
|
||||
<Heading>Route Error 🔥</Heading>
|
||||
<pre className="text-sm select-auto cursor-text bg-gray-100 p-3 rounded whitespace-normal">
|
||||
{message}
|
||||
</pre>
|
||||
<Button to="/" color="primary">
|
||||
Go Home
|
||||
</Button>
|
||||
</VStack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
118
src-web/components/Sidebar.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import classnames from 'classnames';
|
||||
import { useState } from 'react';
|
||||
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 { 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';
|
||||
|
||||
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 />*/}
|
||||
|
||||
<HStack
|
||||
className="absolute bottom-1 left-1 right-0 mx-1"
|
||||
alignItems="center"
|
||||
justifyContent="end"
|
||||
>
|
||||
<IconButton icon="trash" onClick={() => deleteRequest.mutate()} />
|
||||
<IconButton icon={appearance === 'dark' ? 'moon' : 'sun'} onClick={toggleAppearance} />
|
||||
</HStack>
|
||||
</VStack>
|
||||
</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);
|
||||
};
|
||||
|
||||
const handleFocus = (el: HTMLInputElement | null) => {
|
||||
el?.focus();
|
||||
el?.select();
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="truncate">{request.name || request.url}</span>
|
||||
)}
|
||||
</Button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
67
src-web/components/UrlBar.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Button } from './core/Button';
|
||||
import { DropdownMenuRadio } from './core/Dropdown';
|
||||
import { IconButton } from './core/IconButton';
|
||||
import { Input } from './core/Input';
|
||||
|
||||
interface Props {
|
||||
sendRequest: () => void;
|
||||
loading: boolean;
|
||||
method: string;
|
||||
url: string;
|
||||
onMethodChange: (method: string) => void;
|
||||
onUrlChange: (url: string) => void;
|
||||
}
|
||||
|
||||
export function UrlBar({ sendRequest, loading, onMethodChange, method, onUrlChange, url }: Props) {
|
||||
return (
|
||||
<form
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
sendRequest();
|
||||
}}
|
||||
className="w-full flex items-center"
|
||||
>
|
||||
<Input
|
||||
hideLabel
|
||||
useEditor={{ useTemplating: true, contentType: 'url' }}
|
||||
className="px-0"
|
||||
name="url"
|
||||
label="Enter URL"
|
||||
containerClassName="shadow shadow-gray-100 dark:shadow-gray-0"
|
||||
onChange={onUrlChange}
|
||||
defaultValue={url}
|
||||
placeholder="Enter a URL..."
|
||||
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>
|
||||
}
|
||||
rightSlot={
|
||||
<IconButton
|
||||
type="submit"
|
||||
className="mr-0.5"
|
||||
size="sm"
|
||||
icon={loading ? 'update' : 'paperPlane'}
|
||||
spin={loading}
|
||||
disabled={loading}
|
||||
title="Send Request"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import classnames from 'classnames';
|
||||
import { HTMLAttributes } from 'react';
|
||||
|
||||
type Props = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export function WindowDragRegion({ className, ...props }: Props) {
|
||||
return (
|
||||
<div
|
||||
className={classnames(className, 'w-full h-11 border-b border-gray-500/10')}
|
||||
data-tauri-drag-region=""
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
40
src-web/components/Workspace.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import classnames from 'classnames';
|
||||
import { useWindowSize } from 'react-use';
|
||||
import { RequestPane } from './RequestPane';
|
||||
import { ResponsePane } from './ResponsePane';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { HStack } from './core/Stacks';
|
||||
import { WindowDragRegion } from './core/WindowDragRegion';
|
||||
import { useActiveRequest } from '../hooks/useActiveRequest';
|
||||
|
||||
export default function Workspace() {
|
||||
const activeRequest = useActiveRequest();
|
||||
const { width } = useWindowSize();
|
||||
const isH = width > 900;
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
18
src-web/components/Workspaces.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Button } from './core/Button';
|
||||
import { Heading } from './core/Heading';
|
||||
import { VStack } from './core/Stacks';
|
||||
import { useWorkspaces } from '../hooks/useWorkspaces';
|
||||
|
||||
export default function Workspaces() {
|
||||
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>
|
||||
);
|
||||
}
|
||||
84
src-web/components/core/Button.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import classnames from 'classnames';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { forwardRef } 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',
|
||||
};
|
||||
|
||||
export type ButtonProps = HTMLAttributes<HTMLElement> & {
|
||||
to?: string;
|
||||
color?: keyof typeof colorStyles;
|
||||
size?: 'sm' | 'md';
|
||||
justify?: 'start' | 'center';
|
||||
type?: 'button' | 'submit';
|
||||
forDropdown?: boolean;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const Button = forwardRef<any, ButtonProps>(function Button(
|
||||
{
|
||||
to,
|
||||
className,
|
||||
children,
|
||||
forDropdown,
|
||||
color,
|
||||
justify = 'center',
|
||||
size = 'md',
|
||||
...props
|
||||
}: ButtonProps,
|
||||
ref,
|
||||
) {
|
||||
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}
|
||||
>
|
||||
{children}
|
||||
{forDropdown && <Icon icon="triangleDown" 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}
|
||||
>
|
||||
{children}
|
||||
{forDropdown && <Icon icon="triangleDown" className="ml-1 -mr-1" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
});
|
||||
58
src-web/components/core/Dialog.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import * as D from '@radix-ui/react-dialog';
|
||||
import classnames from 'classnames';
|
||||
import { motion } from 'framer-motion';
|
||||
import type { ReactNode } from 'react';
|
||||
import { IconButton } from './IconButton';
|
||||
import { HStack, VStack } from './Stacks';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
description?: string;
|
||||
className?: string;
|
||||
wide?: boolean;
|
||||
}
|
||||
|
||||
export function Dialog({
|
||||
children,
|
||||
className,
|
||||
wide,
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
}: Props) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
23
src-web/components/core/Divider.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
306
src-web/components/core/Dropdown.tsx
Normal file
@@ -0,0 +1,306 @@
|
||||
import * as D from '@radix-ui/react-dropdown-menu';
|
||||
import { CheckIcon } from '@radix-ui/react-icons';
|
||||
import classnames from 'classnames';
|
||||
import { motion } from 'framer-motion';
|
||||
import type { ReactNode, ForwardedRef } from 'react';
|
||||
import { forwardRef, useImperativeHandle, useLayoutEffect, useState } from 'react';
|
||||
|
||||
interface DropdownMenuRadioProps {
|
||||
children: ReactNode;
|
||||
onValueChange: ((v: { label: string; value: string }) => void) | null;
|
||||
value: string;
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export interface DropdownProps {
|
||||
children: ReactNode;
|
||||
items: (
|
||||
| {
|
||||
label: string;
|
||||
onSelect?: () => void;
|
||||
disabled?: boolean;
|
||||
leftSlot?: ReactNode;
|
||||
}
|
||||
| '-----'
|
||||
)[];
|
||||
}
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
// 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]);
|
||||
|
||||
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>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
type DropdownMenuItemProps = D.DropdownMenuItemProps & ItemInnerProps;
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// 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>
|
||||
// );
|
||||
// }
|
||||
|
||||
// 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>
|
||||
// );
|
||||
// }
|
||||
|
||||
type DropdownMenuRadioItemProps = Omit<D.DropdownMenuRadioItemProps & ItemInnerProps, 'leftSlot'>;
|
||||
|
||||
function DropdownMenuRadioItem({ rightSlot, children, ...props }: DropdownMenuRadioItemProps) {
|
||||
return (
|
||||
<D.RadioItem asChild {...props}>
|
||||
<ItemInner
|
||||
leftSlot={
|
||||
<D.ItemIndicator>
|
||||
<CheckIcon />
|
||||
</D.ItemIndicator>
|
||||
}
|
||||
rightSlot={rightSlot}
|
||||
>
|
||||
{children}
|
||||
</ItemInner>
|
||||
</D.RadioItem>
|
||||
);
|
||||
}
|
||||
|
||||
// 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}
|
||||
// />
|
||||
// );
|
||||
// },
|
||||
// );
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function DropdownMenuTrigger({ children, className, ...props }: DropdownMenuTriggerProps) {
|
||||
return (
|
||||
<D.Trigger asChild className={classnames(className)} {...props}>
|
||||
{children}
|
||||
</D.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
interface ItemInnerProps {
|
||||
leftSlot?: ReactNode;
|
||||
rightSlot?: ReactNode;
|
||||
children: ReactNode;
|
||||
noHover?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ItemInner = forwardRef<HTMLDivElement, ItemInnerProps>(function ItemInner(
|
||||
{ leftSlot, rightSlot, children, className, noHover, ...props }: ItemInnerProps,
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
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',
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{leftSlot && <div className="w-6">{leftSlot}</div>}
|
||||
<div>{children}</div>
|
||||
{rightSlot && <div className="ml-auto pl-3">{rightSlot}</div>}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
166
src-web/components/core/Editor/Editor.css
Normal file
@@ -0,0 +1,166 @@
|
||||
.cm-wrapper {
|
||||
@apply h-full overflow-hidden;
|
||||
|
||||
.cm-editor {
|
||||
@apply w-full block text-base;
|
||||
|
||||
* {
|
||||
@apply cursor-text;
|
||||
}
|
||||
|
||||
&.cm-focused {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
.cm-line {
|
||||
@apply text-gray-900 pl-1 pr-1.5;
|
||||
}
|
||||
|
||||
.cm-placeholder {
|
||||
@apply text-placeholder;
|
||||
}
|
||||
|
||||
/* Don't show selection on blurred input */
|
||||
.cm-selectionBackground {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
&.cm-focused .cm-selectionBackground {
|
||||
@apply bg-gray-400;
|
||||
}
|
||||
|
||||
/* Style gutters */
|
||||
.cm-gutters {
|
||||
@apply border-0 text-gray-500/60;
|
||||
|
||||
.cm-gutterElement {
|
||||
@apply cursor-default;
|
||||
}
|
||||
}
|
||||
|
||||
.placeholder-widget {
|
||||
@apply text-[0.9em] text-gray-800 dark:text-gray-900 px-1 rounded cursor-default dark:shadow;
|
||||
|
||||
/* NOTE: Background and border are translucent so we can see text selection through it */
|
||||
@apply bg-gray-300/40 border border-gray-300 border-opacity-40 hover:border-opacity-80;
|
||||
|
||||
/* Bring above on hover */
|
||||
@apply hover:z-10 relative;
|
||||
}
|
||||
}
|
||||
|
||||
&.cm-singleline {
|
||||
.cm-editor {
|
||||
@apply h-full w-full;
|
||||
}
|
||||
|
||||
.cm-scroller {
|
||||
@apply font-mono flex text-[0.8rem];
|
||||
align-items: center !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.cm-line {
|
||||
@apply px-0;
|
||||
}
|
||||
}
|
||||
|
||||
&.cm-multiline {
|
||||
&.cm-full-height {
|
||||
@apply relative;
|
||||
|
||||
.cm-editor {
|
||||
@apply inset-0 absolute;
|
||||
position: absolute !important;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-editor {
|
||||
@apply h-full;
|
||||
}
|
||||
|
||||
.cm-scroller {
|
||||
@apply font-mono text-[0.75rem];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cm-editor .cm-gutterElement {
|
||||
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;
|
||||
}
|
||||
|
||||
.cm-editor .fold-gutter-icon::after {
|
||||
@apply block w-1.5 h-1.5 border-transparent -rotate-45
|
||||
border-l border-b border-l-[currentColor] border-b-[currentColor] content-[''];
|
||||
}
|
||||
|
||||
.cm-editor .fold-gutter-icon[data-open] {
|
||||
@apply pt-[0.4em] pl-[0.3em];
|
||||
}
|
||||
|
||||
.cm-editor .fold-gutter-icon[data-open]::after {
|
||||
@apply rotate-[-135deg];
|
||||
}
|
||||
|
||||
.cm-editor .fold-gutter-icon:hover {
|
||||
@apply text-gray-900 bg-gray-300/50;
|
||||
}
|
||||
|
||||
.cm-editor .cm-foldPlaceholder {
|
||||
@apply px-2 border border-gray-400/50 bg-gray-300/50 cursor-default;
|
||||
@apply hover:text-gray-800 hover:border-gray-400;
|
||||
}
|
||||
|
||||
.cm-editor .cm-activeLineGutter {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
|
||||
.cm-wrapper:not(.cm-readonly) .cm-editor {
|
||||
&.cm-focused .cm-activeLineGutter {
|
||||
@apply text-gray-800;
|
||||
}
|
||||
|
||||
.cm-cursor {
|
||||
@apply border-l-2 border-gray-800;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-singleline .cm-editor {
|
||||
.cm-content {
|
||||
@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 transition-none;
|
||||
}
|
||||
|
||||
&.cm-tooltip-autocomplete {
|
||||
& > ul {
|
||||
@apply p-1 max-h-[40vh];
|
||||
}
|
||||
|
||||
& > ul > li {
|
||||
@apply cursor-default px-2 rounded-sm text-gray-600 h-7 flex items-center;
|
||||
}
|
||||
|
||||
& > ul > li[aria-selected] {
|
||||
@apply bg-gray-100 text-gray-900;
|
||||
}
|
||||
|
||||
& > ul > li:hover {
|
||||
@apply text-gray-800;
|
||||
}
|
||||
|
||||
.cm-completionIcon {
|
||||
@apply text-sm flex items-center pb-0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
188
src-web/components/core/Editor/Editor.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import { defaultKeymap } from '@codemirror/commands';
|
||||
import { Compartment, EditorState } from '@codemirror/state';
|
||||
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 { IconButton } from '../IconButton';
|
||||
import './Editor.css';
|
||||
import { baseExtensions, getLanguageExtension, multiLineExtensions } from './extensions';
|
||||
import { singleLineExt } from './singleLine';
|
||||
|
||||
export interface _EditorProps {
|
||||
id?: string;
|
||||
readOnly?: boolean;
|
||||
className?: string;
|
||||
heightMode?: 'auto' | 'full';
|
||||
contentType?: string;
|
||||
autoFocus?: boolean;
|
||||
defaultValue?: string;
|
||||
placeholder?: string;
|
||||
tooltipContainer?: HTMLElement;
|
||||
useTemplating?: boolean;
|
||||
onChange?: (value: string) => void;
|
||||
singleLine?: boolean;
|
||||
}
|
||||
|
||||
export function _Editor({
|
||||
readOnly,
|
||||
heightMode,
|
||||
contentType,
|
||||
autoFocus,
|
||||
placeholder,
|
||||
useTemplating,
|
||||
defaultValue,
|
||||
onChange,
|
||||
className,
|
||||
singleLine,
|
||||
}: _EditorProps) {
|
||||
const cm = useRef<{ view: EditorView; langHolder: Compartment } | null>(null);
|
||||
|
||||
// Unmount the editor
|
||||
useUnmount(() => {
|
||||
cm.current?.view.destroy();
|
||||
cm.current = null;
|
||||
});
|
||||
|
||||
// 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]);
|
||||
|
||||
// Initialize the editor
|
||||
const initDivRef = (el: HTMLDivElement | null) => {
|
||||
if (el === null || cm.current !== null) return;
|
||||
|
||||
try {
|
||||
const langHolder = new Compartment();
|
||||
const langExt = getLanguageExtension({ contentType, useTemplating });
|
||||
const state = EditorState.create({
|
||||
doc: `${defaultValue ?? ''}`,
|
||||
extensions: [
|
||||
langHolder.of(langExt),
|
||||
...getExtensions({
|
||||
container: el,
|
||||
readOnly,
|
||||
placeholder,
|
||||
singleLine,
|
||||
onChange,
|
||||
contentType,
|
||||
useTemplating,
|
||||
}),
|
||||
],
|
||||
});
|
||||
const view = new EditorView({ state, parent: el });
|
||||
cm.current = { view, langHolder };
|
||||
syncGutterBg({ parent: el, className });
|
||||
if (autoFocus) view.focus();
|
||||
} catch (e) {
|
||||
console.log('Failed to initialize Codemirror', e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={initDivRef}
|
||||
className={classnames(
|
||||
className,
|
||||
'cm-wrapper text-base bg-gray-50',
|
||||
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 } });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 });
|
||||
|
||||
// TODO: Ensure tooltips render inside the dialog if we are in one.
|
||||
const parent =
|
||||
container?.closest<HTMLDivElement>('[role="dialog"]') ??
|
||||
document.querySelector<HTMLDivElement>('#cm-portal') ??
|
||||
undefined;
|
||||
|
||||
return [
|
||||
...baseExtensions,
|
||||
tooltips({ parent }),
|
||||
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({
|
||||
focus: (e, view) => {
|
||||
// select all text on focus, like a regular input does
|
||||
view.dispatch({ selection: { anchor: 0, head: view.state.doc.length } });
|
||||
},
|
||||
keydown: (e) => {
|
||||
// Submit nearest form on enter if there is one
|
||||
if (e.key === 'Enter') {
|
||||
const el = e.currentTarget as HTMLElement;
|
||||
const form = el.closest('form');
|
||||
form?.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
|
||||
}
|
||||
},
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
|
||||
// Handle onChange
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (typeof onChange === 'function' && update.docChanged) {
|
||||
onChange(update.state.doc.toString());
|
||||
}
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
const syncGutterBg = ({
|
||||
parent,
|
||||
className = '',
|
||||
}: {
|
||||
parent: HTMLDivElement;
|
||||
className?: string;
|
||||
}) => {
|
||||
const gutterEl = parent.querySelector<HTMLDivElement>('.cm-gutters');
|
||||
const classList = className?.split(/\s+/) ?? [];
|
||||
const bgClasses = classList
|
||||
.filter((c) => c.match(/(^|:)?bg-.+/)) // Find bg-* classes
|
||||
.map((c) => c.replace(/^bg-/, '!bg-')) // !important
|
||||
.map((c) => c.replace(/^dark:bg-/, 'dark:!bg-')); // !important
|
||||
if (gutterEl) {
|
||||
gutterEl?.classList.add(...bgClasses);
|
||||
}
|
||||
};
|
||||
35
src-web/components/core/Editor/autocomplete.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { closeCompletion, startCompletion } from '@codemirror/autocomplete';
|
||||
import { EditorView } from 'codemirror';
|
||||
import { debounce } from '../../../lib/debounce';
|
||||
|
||||
/*
|
||||
* Debounce autocomplete until user stops typing for `millis` milliseconds.
|
||||
*/
|
||||
export function debouncedAutocompletionDisplay({ millis }: { millis: number }) {
|
||||
// TODO: Figure out how to show completion without setting context.explicit = true
|
||||
const debouncedStartCompletion = debounce(function (view: EditorView) {
|
||||
startCompletion(view);
|
||||
}, millis);
|
||||
|
||||
return EditorView.updateListener.of(({ view, docChanged }) => {
|
||||
// const completions = currentCompletions(view.state);
|
||||
// const status = completionStatus(view.state);
|
||||
|
||||
if (!view.hasFocus) {
|
||||
debouncedStartCompletion.cancel();
|
||||
closeCompletion(view);
|
||||
return;
|
||||
}
|
||||
|
||||
if (view.state.doc.length === 0) {
|
||||
debouncedStartCompletion.cancel();
|
||||
closeCompletion(view);
|
||||
return;
|
||||
}
|
||||
|
||||
// If the document hasn't changed, we don't need to do anything
|
||||
if (docChanged) {
|
||||
debouncedStartCompletion(view);
|
||||
}
|
||||
});
|
||||
}
|
||||
149
src-web/components/core/Editor/extensions.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import {
|
||||
autocompletion,
|
||||
closeBrackets,
|
||||
closeBracketsKeymap,
|
||||
completionKeymap,
|
||||
} from '@codemirror/autocomplete';
|
||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
|
||||
import { html } from '@codemirror/lang-html';
|
||||
import { javascript } from '@codemirror/lang-javascript';
|
||||
import { json } from '@codemirror/lang-json';
|
||||
import { xml } from '@codemirror/lang-xml';
|
||||
import type { LanguageSupport } from '@codemirror/language';
|
||||
import {
|
||||
bracketMatching,
|
||||
foldGutter,
|
||||
foldKeymap,
|
||||
HighlightStyle,
|
||||
indentOnInput,
|
||||
syntaxHighlighting,
|
||||
} from '@codemirror/language';
|
||||
import { lintKeymap } from '@codemirror/lint';
|
||||
import { highlightSelectionMatches, searchKeymap } from '@codemirror/search';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import {
|
||||
crosshairCursor,
|
||||
drawSelection,
|
||||
dropCursor,
|
||||
highlightActiveLineGutter,
|
||||
highlightSpecialChars,
|
||||
keymap,
|
||||
lineNumbers,
|
||||
rectangularSelection,
|
||||
} from '@codemirror/view';
|
||||
import { tags as t } from '@lezer/highlight';
|
||||
import { graphqlLanguageSupport } from 'cm6-graphql';
|
||||
import { debouncedAutocompletionDisplay } from './autocomplete';
|
||||
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',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
{
|
||||
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.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))' },
|
||||
]);
|
||||
|
||||
// export const defaultHighlightStyle = HighlightStyle.define([
|
||||
// { tag: t.meta, color: '#404740' },
|
||||
// { tag: t.link, textDecoration: 'underline' },
|
||||
// { tag: t.heading, textDecoration: 'underline', fontWeight: 'bold' },
|
||||
// { tag: t.emphasis, fontStyle: 'italic' },
|
||||
// { tag: t.strong, fontWeight: 'bold' },
|
||||
// { tag: t.strikethrough, textDecoration: 'line-through' },
|
||||
// { tag: t.keyword, color: '#708' },
|
||||
// { tag: [t.atom, t.bool, t.url, t.contentSeparator, t.labelName], color: '#219' },
|
||||
// { tag: [t.literal, t.inserted], color: '#164' },
|
||||
// { tag: [t.string, t.deleted], color: '#a11' },
|
||||
// { tag: [t.regexp, t.escape, t.special(t.string)], color: '#e40' },
|
||||
// { tag: t.definition(t.variableName), color: '#00f' },
|
||||
// { tag: t.local(t.variableName), color: '#30a' },
|
||||
// { tag: [t.typeName, t.namespace], color: '#085' },
|
||||
// { tag: t.className, color: '#167' },
|
||||
// { tag: [t.special(t.variableName), t.macroName], color: '#256' },
|
||||
// { tag: t.definition(t.propertyName), color: '#00c' },
|
||||
// { tag: t.comment, color: '#940' },
|
||||
// { tag: t.invalid, color: '#f00' },
|
||||
// ]);
|
||||
|
||||
const syntaxExtensions: Record<string, LanguageSupport> = {
|
||||
'application/graphql+json': graphqlLanguageSupport(),
|
||||
'application/json': json(),
|
||||
'application/javascript': javascript(),
|
||||
'text/html': html(),
|
||||
'application/xml': xml(),
|
||||
'text/xml': xml(),
|
||||
url: url(),
|
||||
};
|
||||
|
||||
export function getLanguageExtension({
|
||||
contentType,
|
||||
useTemplating,
|
||||
}: {
|
||||
contentType?: string;
|
||||
useTemplating?: boolean;
|
||||
}) {
|
||||
const justContentType = contentType?.split(';')[0] ?? contentType ?? '';
|
||||
const base = syntaxExtensions[justContentType] ?? json();
|
||||
if (!useTemplating) {
|
||||
return [base];
|
||||
}
|
||||
|
||||
return twig(base);
|
||||
}
|
||||
|
||||
export const baseExtensions = [
|
||||
highlightSpecialChars(),
|
||||
history(),
|
||||
drawSelection(),
|
||||
dropCursor(),
|
||||
bracketMatching(),
|
||||
// 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 }),
|
||||
syntaxHighlighting(myHighlightStyle),
|
||||
EditorState.allowMultipleSelections.of(true),
|
||||
];
|
||||
|
||||
export const multiLineExtensions = [
|
||||
lineNumbers(),
|
||||
foldGutter({
|
||||
markerDOM: (open) => {
|
||||
const el = document.createElement('div');
|
||||
el.classList.add('fold-gutter-icon');
|
||||
el.tabIndex = -1;
|
||||
if (open) {
|
||||
el.setAttribute('data-open', '');
|
||||
}
|
||||
return el;
|
||||
},
|
||||
}),
|
||||
EditorState.allowMultipleSelections.of(true),
|
||||
indentOnInput(),
|
||||
closeBrackets(),
|
||||
rectangularSelection(),
|
||||
crosshairCursor(),
|
||||
highlightActiveLineGutter(),
|
||||
highlightSelectionMatches({ minSelectionLength: 2 }),
|
||||
keymap.of([
|
||||
...closeBracketsKeymap,
|
||||
...defaultKeymap,
|
||||
...searchKeymap,
|
||||
...historyKeymap,
|
||||
...foldKeymap,
|
||||
...completionKeymap,
|
||||
...lintKeymap,
|
||||
]),
|
||||
];
|
||||
6
src-web/components/core/Editor/index.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { memo } from 'react';
|
||||
import { _Editor } from './Editor';
|
||||
import type { _EditorProps } from './Editor';
|
||||
|
||||
export type EditorProps = _EditorProps;
|
||||
export const Editor = memo(_Editor);
|
||||
29
src-web/components/core/Editor/singleLine.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { Transaction, TransactionSpec } from '@codemirror/state';
|
||||
import { EditorSelection, EditorState } from '@codemirror/state';
|
||||
|
||||
export function singleLineExt() {
|
||||
return EditorState.transactionFilter.of(
|
||||
(tr: Transaction): TransactionSpec | TransactionSpec[] => {
|
||||
if (!tr.isUserEvent('input')) return tr;
|
||||
|
||||
const trs: TransactionSpec[] = [];
|
||||
tr.changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
|
||||
let insert = '';
|
||||
let newlinesRemoved = 0;
|
||||
for (const line of inserted) {
|
||||
const newLine = line.replace('\n', '');
|
||||
newlinesRemoved += line.length - newLine.length;
|
||||
insert += newLine;
|
||||
}
|
||||
|
||||
// Update cursor position based on how many newlines were removed
|
||||
const cursor = EditorSelection.cursor(toB - newlinesRemoved);
|
||||
const selection = EditorSelection.create([cursor], 0);
|
||||
|
||||
const changes = [{ from: fromB, to: toA, insert }];
|
||||
trs.push({ ...tr, selection, changes });
|
||||
});
|
||||
return trs;
|
||||
},
|
||||
);
|
||||
}
|
||||
55
src-web/components/core/Editor/twig/completion.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { CompletionContext } from '@codemirror/autocomplete';
|
||||
|
||||
const openTag = '${[ ';
|
||||
const closeTag = ' ]}';
|
||||
|
||||
const variables = [
|
||||
{ name: 'DOMAIN' },
|
||||
{ name: 'BASE_URL' },
|
||||
{ name: 'TOKEN' },
|
||||
{ name: 'PROJECT_ID' },
|
||||
{ name: 'DUMMY' },
|
||||
{ name: 'DUMMY_2' },
|
||||
{ name: 'STRIPE_PUB_KEY' },
|
||||
{ name: 'RAILWAY_TOKEN' },
|
||||
{ name: 'SECRET' },
|
||||
{ name: 'PORT' },
|
||||
];
|
||||
|
||||
const MIN_MATCH_VAR = 2;
|
||||
const MIN_MATCH_NAME = 4;
|
||||
|
||||
export function completions(context: CompletionContext) {
|
||||
const toStartOfName = context.matchBefore(/\w*/);
|
||||
const toStartOfVariable = context.matchBefore(/\$\{?\[?\s*\w*/);
|
||||
const toMatch = toStartOfVariable ?? toStartOfName ?? null;
|
||||
|
||||
if (toMatch === null) return null;
|
||||
|
||||
const matchLen = toMatch.to - toMatch.from;
|
||||
|
||||
const failedVarLen = toStartOfVariable !== null && matchLen < MIN_MATCH_VAR;
|
||||
if (failedVarLen && !context.explicit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const failedNameLen = toStartOfVariable === null && matchLen < MIN_MATCH_NAME;
|
||||
if (failedNameLen && !context.explicit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// TODO: Figure out how to make autocomplete stay open if opened explicitly. It sucks when you explicitly
|
||||
// open it, then it closes when you type the next character.
|
||||
return {
|
||||
from: toMatch.from,
|
||||
options: variables
|
||||
.map((v) => ({
|
||||
label: toStartOfVariable ? `${openTag}${v.name}${closeTag}` : v.name,
|
||||
apply: `${openTag}${v.name}${closeTag}`,
|
||||
type: 'variable',
|
||||
matchLen,
|
||||
}))
|
||||
// Filter out exact matches
|
||||
.filter((o) => o.label !== toMatch.text),
|
||||
};
|
||||
}
|
||||
41
src-web/components/core/Editor/twig/extension.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { LanguageSupport, LRLanguage } from '@codemirror/language';
|
||||
import { parseMixed } from '@lezer/common';
|
||||
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]);
|
||||
|
||||
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];
|
||||
}
|
||||
}
|
||||
|
||||
function mixedOrPlainLanguage(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;
|
||||
return {
|
||||
parser: base.language.parser,
|
||||
overlay: (node) => node.type.name === 'Text',
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
return LRLanguage.define({ name, parser });
|
||||
}
|
||||
7
src-web/components/core/Editor/twig/highlight.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { styleTags, tags as t } from '@lezer/highlight';
|
||||
|
||||
export const highlight = styleTags({
|
||||
'if endif': t.controlKeyword,
|
||||
'${[ ]}': t.meta,
|
||||
DirectiveContent: t.variableName,
|
||||
});
|
||||
19
src-web/components/core/Editor/twig/twig.grammar
Normal file
@@ -0,0 +1,19 @@
|
||||
@top Template { (directive | Text)* }
|
||||
|
||||
directive {
|
||||
Insert
|
||||
}
|
||||
|
||||
@skip {space} {
|
||||
Insert { "${[" DirectiveContent "]}" }
|
||||
}
|
||||
|
||||
@tokens {
|
||||
Text { ![$] Text? }
|
||||
space { @whitespace+ }
|
||||
DirectiveContent { ![\]}] DirectiveContent? }
|
||||
@precedence { space DirectiveContent }
|
||||
"${[" "]}"
|
||||
}
|
||||
|
||||
@external propSource highlight from "./highlight"
|
||||
6
src-web/components/core/Editor/twig/twig.terms.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
export const
|
||||
Template = 1,
|
||||
Insert = 2,
|
||||
DirectiveContent = 4,
|
||||
Text = 6
|
||||
18
src-web/components/core/Editor/twig/twig.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
import {LRParser} 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",
|
||||
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],
|
||||
topRules: {"Template":[0,1]},
|
||||
tokenPrec: 25
|
||||
})
|
||||
19
src-web/components/core/Editor/url/completion.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { CompletionContext } from '@codemirror/autocomplete';
|
||||
|
||||
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 };
|
||||
}
|
||||
14
src-web/components/core/Editor/url/extension.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { LanguageSupport, LRLanguage } from '@codemirror/language';
|
||||
import { completions } from './completion';
|
||||
import { parser } from './url';
|
||||
|
||||
const urlLanguage = LRLanguage.define({
|
||||
parser,
|
||||
languageData: {},
|
||||
});
|
||||
|
||||
const completion = urlLanguage.data.of({ autocomplete: completions });
|
||||
|
||||
export function url() {
|
||||
return new LanguageSupport(urlLanguage, [completion]);
|
||||
}
|
||||
9
src-web/components/core/Editor/url/highlight.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { styleTags, tags as t } from '@lezer/highlight';
|
||||
|
||||
export const highlight = styleTags({
|
||||
Protocol: t.comment,
|
||||
// Port: t.attributeName,
|
||||
// Host: t.variableName,
|
||||
// Path: t.bool,
|
||||
// Query: t.string,
|
||||
});
|
||||
18
src-web/components/core/Editor/url/url.grammar
Normal file
@@ -0,0 +1,18 @@
|
||||
@top url { Protocol? Host Port? Path? Query? }
|
||||
|
||||
Query {
|
||||
"?" queryPair ("&" queryPair)*
|
||||
}
|
||||
|
||||
@tokens {
|
||||
Protocol { $[a-zA-Z]+ "://" }
|
||||
Path { ("/" $[a-zA-Z0-9\-_.]*)+ }
|
||||
queryPair { ($[a-zA-Z0-9]+ ("=" $[a-zA-Z0-9]*)?) }
|
||||
Port { ":" $[0-9]+ }
|
||||
Host { $[a-zA-Z0-9-_.]+ }
|
||||
|
||||
// Protocol/host overlaps, so give proto explicit precedence
|
||||
@precedence { Protocol, Host }
|
||||
}
|
||||
|
||||
@external propSource highlight from "./highlight"
|
||||
8
src-web/components/core/Editor/url/url.terms.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
export const
|
||||
url = 1,
|
||||
Protocol = 2,
|
||||
Host = 3,
|
||||
Port = 4,
|
||||
Path = 5,
|
||||
Query = 6
|
||||
18
src-web/components/core/Editor/url/url.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
import {LRParser} from "@lezer/lr"
|
||||
import {highlight} from "./highlight"
|
||||
export const parser = LRParser.deserialize({
|
||||
version: 14,
|
||||
states: "!jOQOPOOQYOPOOOTOPOOOeOQO'#CbQOOOOOQ`OPOOQ]OPOOOjOPO,58|OrOQO'#CcOwOPO1G.hOOOO,58},58}OOOO-E6a-E6a",
|
||||
stateData: "!S~OQQORPO~OSUOTTOXRO~OYVO~OZWOWUa~OYYO~OZWOWUi~OQR~",
|
||||
goto: "dWPPPPPPX^VSPTUQXVRZX",
|
||||
nodeNames: "⚠ url Protocol Host Port Path Query",
|
||||
maxTerm: 11,
|
||||
propSources: [highlight],
|
||||
skippedNodes: [0],
|
||||
repeatNodeCount: 1,
|
||||
tokenData: "%[~RYvwq}!Ov!O!Pv!P!Q!_!Q![!y![!]#u!a!b$T!c!}$Y#R#Sv#T#o$Y~vOZ~P{URP}!Ov!O!Pv!Q![v!c!}v#R#Sv#T#ov~!dVT~}!O!_!O!P!_!P!Q!_!Q![!_!c!}!_#R#S!_#T#o!_R#QVYQRP}!Ov!O!Pv!Q![!y!_!`#g!c!}!y#R#Sv#T#o!yQ#lRYQ!Q![#g!c!}#g#T#o#g~#xP!Q![#{~$QPS~!Q![#{~$YOX~R$aWYQRP}!Ov!O!Pv!Q![!y![!]$y!_!`#g!c!}$Y#R#Sv#T#o$YP$|P!P!Q%PP%SP!P!Q%VP%[OQP",
|
||||
tokenizers: [0, 1],
|
||||
topRules: {"url":[0,1]},
|
||||
tokenPrec: 47
|
||||
})
|
||||
76
src-web/components/core/Editor/widgets.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { DecorationSet, ViewUpdate } from '@codemirror/view';
|
||||
import { Decoration, EditorView, MatchDecorator, ViewPlugin, WidgetType } from '@codemirror/view';
|
||||
|
||||
class PlaceholderWidget extends WidgetType {
|
||||
constructor(readonly name: string) {
|
||||
super();
|
||||
}
|
||||
eq(other: PlaceholderWidget) {
|
||||
return this.name == other.name;
|
||||
}
|
||||
toDOM() {
|
||||
const elt = document.createElement('span');
|
||||
elt.className = 'placeholder-widget';
|
||||
elt.textContent = this.name;
|
||||
return elt;
|
||||
}
|
||||
ignoreEvent() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This is a custom MatchDecorator that will not decorate a match if the selection is inside it
|
||||
*/
|
||||
class BetterMatchDecorator extends MatchDecorator {
|
||||
updateDeco(update: ViewUpdate, deco: DecorationSet): DecorationSet {
|
||||
if (!update.startState.selection.eq(update.state.selection)) {
|
||||
return super.createDeco(update.view);
|
||||
} else {
|
||||
return super.updateDeco(update, deco);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const placeholderMatcher = new BetterMatchDecorator({
|
||||
regexp: /\$\{\[\s*([^\]\s]+)\s*]}/g,
|
||||
decoration(match, view, matchStartPos) {
|
||||
const matchEndPos = matchStartPos + match[0].length - 1;
|
||||
|
||||
// Don't decorate if the cursor is inside the match
|
||||
for (const r of view.state.selection.ranges) {
|
||||
if (r.from > matchStartPos && r.to <= matchEndPos) return null;
|
||||
}
|
||||
|
||||
const groupMatch = match[1];
|
||||
if (groupMatch == null) {
|
||||
// Should never happen, but make TS happy
|
||||
console.warn('Group match was empty', match);
|
||||
return null;
|
||||
}
|
||||
|
||||
return Decoration.replace({
|
||||
inclusive: true,
|
||||
widget: new PlaceholderWidget(groupMatch),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const placeholders = ViewPlugin.fromClass(
|
||||
class {
|
||||
placeholders: DecorationSet;
|
||||
constructor(view: EditorView) {
|
||||
this.placeholders = placeholderMatcher.createDeco(view);
|
||||
}
|
||||
update(update: ViewUpdate) {
|
||||
this.placeholders = placeholderMatcher.updateDeco(update, this.placeholders);
|
||||
}
|
||||
},
|
||||
{
|
||||
decorations: (instance) => instance.placeholders,
|
||||
provide: (plugin) =>
|
||||
EditorView.atomicRanges.of((view) => {
|
||||
return view.plugin(plugin)?.placeholders || Decoration.none;
|
||||
}),
|
||||
},
|
||||
);
|
||||
15
src-web/components/core/Heading.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import classnames from 'classnames';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export function Heading({ className, children, ...props }: Props) {
|
||||
return (
|
||||
<h1 className={classnames(className, 'text-2xl font-semibold text-gray-900 mb-3')} {...props}>
|
||||
{children}
|
||||
</h1>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { HTMLAttributes } from 'react';
|
||||
import classnames from 'classnames';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
export function HotKey({ children }: HTMLAttributes<HTMLSpanElement>) {
|
||||
return (
|
||||
75
src-web/components/core/Icon.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
ArchiveIcon,
|
||||
CameraIcon,
|
||||
CheckIcon,
|
||||
ClockIcon,
|
||||
CodeIcon,
|
||||
ColorWheelIcon,
|
||||
Cross2Icon,
|
||||
EyeOpenIcon,
|
||||
GearIcon,
|
||||
HomeIcon,
|
||||
MoonIcon,
|
||||
ListBulletIcon,
|
||||
PaperPlaneIcon,
|
||||
PlusCircledIcon,
|
||||
PlusIcon,
|
||||
QuestionMarkIcon,
|
||||
SunIcon,
|
||||
TrashIcon,
|
||||
TriangleDownIcon,
|
||||
TriangleLeftIcon,
|
||||
TriangleRightIcon,
|
||||
UpdateIcon,
|
||||
RowsIcon,
|
||||
} from '@radix-ui/react-icons';
|
||||
import classnames from 'classnames';
|
||||
|
||||
const icons = {
|
||||
archive: ArchiveIcon,
|
||||
camera: CameraIcon,
|
||||
check: CheckIcon,
|
||||
clock: ClockIcon,
|
||||
code: CodeIcon,
|
||||
colorWheel: ColorWheelIcon,
|
||||
eye: EyeOpenIcon,
|
||||
gear: GearIcon,
|
||||
home: HomeIcon,
|
||||
listBullet: ListBulletIcon,
|
||||
moon: MoonIcon,
|
||||
paperPlane: PaperPlaneIcon,
|
||||
plus: PlusIcon,
|
||||
plusCircle: PlusCircledIcon,
|
||||
question: QuestionMarkIcon,
|
||||
rows: RowsIcon,
|
||||
sun: SunIcon,
|
||||
trash: TrashIcon,
|
||||
triangleDown: TriangleDownIcon,
|
||||
triangleLeft: TriangleLeftIcon,
|
||||
triangleRight: TriangleRightIcon,
|
||||
update: UpdateIcon,
|
||||
x: Cross2Icon,
|
||||
};
|
||||
|
||||
export interface IconProps {
|
||||
icon: keyof typeof icons;
|
||||
className?: string;
|
||||
size?: 'xs' | 'sm' | 'md';
|
||||
spin?: boolean;
|
||||
}
|
||||
|
||||
export function Icon({ icon, spin, size = 'md', className }: IconProps) {
|
||||
const Component = icons[icon] ?? icons.question;
|
||||
return (
|
||||
<Component
|
||||
className={classnames(
|
||||
className,
|
||||
'text-inherit',
|
||||
size === 'md' && 'h-4 w-4',
|
||||
size === 'sm' && 'h-3 w-3',
|
||||
size === 'xs' && 'h-2 w-2',
|
||||
spin && 'animate-spin',
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
35
src-web/components/core/IconButton.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import classnames from 'classnames';
|
||||
import { forwardRef } from 'react';
|
||||
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'] };
|
||||
|
||||
export const IconButton = forwardRef<HTMLButtonElement, Props>(function IconButton(
|
||||
{ icon, spin, className, iconClassName, size = 'md', iconSize, ...props }: Props,
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
className={classnames(
|
||||
className,
|
||||
'text-gray-700 hover:text-gray-1000',
|
||||
'!px-0',
|
||||
size === 'md' && 'w-9',
|
||||
size === 'sm' && 'w-9',
|
||||
)}
|
||||
size={size}
|
||||
{...props}
|
||||
>
|
||||
<Icon
|
||||
size={iconSize}
|
||||
icon={icon}
|
||||
spin={spin}
|
||||
className={classnames(iconClassName, props.disabled && 'opacity-70')}
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
96
src-web/components/core/Input.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import classnames from 'classnames';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { EditorProps } from './Editor';
|
||||
import { Editor } from './Editor';
|
||||
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 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',
|
||||
);
|
||||
|
||||
return (
|
||||
<VStack>
|
||||
<label
|
||||
htmlFor={id}
|
||||
className={classnames(
|
||||
labelClassName,
|
||||
'font-semibold text-sm uppercase text-gray-700',
|
||||
hideLabel && 'sr-only',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
<HStack
|
||||
alignItems="center"
|
||||
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',
|
||||
)}
|
||||
>
|
||||
{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}
|
||||
/>
|
||||
)}
|
||||
{rightSlot}
|
||||
</HStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
34
src-web/components/core/ScrollArea.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { Children, Fragment, HTMLAttributes, ReactNode } from 'react';
|
||||
import classnames from 'classnames';
|
||||
import type { ComponentType, ReactNode } from 'react';
|
||||
import { Children, Fragment } from 'react';
|
||||
|
||||
const spaceClassesX = {
|
||||
0: 'pr-0',
|
||||
@@ -7,6 +8,8 @@ const spaceClassesX = {
|
||||
2: 'pr-2',
|
||||
3: 'pr-3',
|
||||
4: 'pr-4',
|
||||
5: 'pr-5',
|
||||
6: 'pr-6',
|
||||
};
|
||||
|
||||
const spaceClassesY = {
|
||||
@@ -15,16 +18,18 @@ const spaceClassesY = {
|
||||
2: 'pt-2',
|
||||
3: 'pt-3',
|
||||
4: 'pt-4',
|
||||
5: 'pt-5',
|
||||
6: 'pt-6',
|
||||
};
|
||||
|
||||
interface HStackProps extends BaseStackProps {
|
||||
space?: keyof typeof spaceClassesX;
|
||||
children: ReactNode;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function HStack({ className, space, children, ...props }: HStackProps) {
|
||||
return (
|
||||
<BaseStack className={classnames(className, 'w-full flex-row')} {...props}>
|
||||
<BaseStack className={classnames(className, 'flex-row')} {...props}>
|
||||
{space
|
||||
? Children.toArray(children)
|
||||
.filter(Boolean) // Remove null/false/undefined children
|
||||
@@ -33,6 +38,7 @@ export function HStack({ className, space, children, ...props }: HStackProps) {
|
||||
{i > 0 ? (
|
||||
<div
|
||||
className={classnames(spaceClassesX[space], 'pointer-events-none')}
|
||||
data-spacer=""
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
@@ -60,6 +66,7 @@ export function VStack({ className, space, children, ...props }: VStackProps) {
|
||||
{i > 0 ? (
|
||||
<div
|
||||
className={classnames(spaceClassesY[space], 'pointer-events-none')}
|
||||
data-spacer=""
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
@@ -71,23 +78,29 @@ export function VStack({ className, space, children, ...props }: VStackProps) {
|
||||
);
|
||||
}
|
||||
|
||||
interface BaseStackProps extends HTMLAttributes<HTMLElement> {
|
||||
items?: 'start' | 'center';
|
||||
justify?: 'start' | 'end';
|
||||
as?: React.ElementType;
|
||||
interface BaseStackProps {
|
||||
as?: ComponentType | 'ul';
|
||||
alignItems?: 'start' | 'center';
|
||||
justifyContent?: 'start' | 'center' | 'end';
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
function BaseStack({ className, items, justify, as = 'div', ...props }: BaseStackProps) {
|
||||
const Component = as;
|
||||
function BaseStack({ className, alignItems, justifyContent, children, as }: BaseStackProps) {
|
||||
const Component = as ?? 'div';
|
||||
return (
|
||||
<Component
|
||||
className={classnames(className, 'flex flex-grow-0', {
|
||||
'items-center': items === 'center',
|
||||
'items-start': items === 'start',
|
||||
'justify-start': justify === 'start',
|
||||
'justify-end': justify === 'end',
|
||||
})}
|
||||
{...props}
|
||||
/>
|
||||
className={classnames(
|
||||
className,
|
||||
'flex',
|
||||
alignItems === 'center' && 'items-center',
|
||||
alignItems === 'start' && 'items-start',
|
||||
justifyContent === 'start' && 'justify-start',
|
||||
justifyContent === 'center' && 'justify-center',
|
||||
justifyContent === 'end' && 'justify-end',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
23
src-web/components/core/StatusColor.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import classnames from 'classnames';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
statusCode: number;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function StatusColor({ statusCode, children }: Props) {
|
||||
return (
|
||||
<span
|
||||
className={classnames(
|
||||
statusCode >= 100 && statusCode < 200 && 'text-green-600',
|
||||
statusCode >= 200 && statusCode < 300 && 'text-green-600',
|
||||
statusCode >= 300 && statusCode < 400 && 'text-pink-600',
|
||||
statusCode >= 400 && statusCode < 500 && 'text-orange-600',
|
||||
statusCode >= 500 && statusCode < 600 && 'text-red-600',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
5
src-web/components/core/Tabs/Tabs.css
Normal file
@@ -0,0 +1,5 @@
|
||||
.tab-content {
|
||||
&[data-state="inactive"] {
|
||||
@apply hidden;
|
||||
}
|
||||
}
|
||||
88
src-web/components/core/Tabs/Tabs.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import * as T from '@radix-ui/react-tabs';
|
||||
import classnames from 'classnames';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Button } from '../Button';
|
||||
import { ScrollArea } from '../ScrollArea';
|
||||
import { HStack } from '../Stacks';
|
||||
|
||||
import './Tabs.css';
|
||||
|
||||
interface Props {
|
||||
defaultValue?: string;
|
||||
label: string;
|
||||
tabs: { value: string; label: ReactNode }[];
|
||||
tabListClassName?: string;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function Tabs({ defaultValue, label, children, tabs, className, tabListClassName }: Props) {
|
||||
const [value, setValue] = useState(defaultValue);
|
||||
return (
|
||||
<T.Root
|
||||
defaultValue={defaultValue}
|
||||
onValueChange={setValue}
|
||||
className={classnames(className, 'h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1')}
|
||||
>
|
||||
<T.List
|
||||
aria-label={label}
|
||||
className={classnames(
|
||||
tabListClassName,
|
||||
'h-auto flex items-center overflow-x-auto mb-1 pb-1',
|
||||
)}
|
||||
>
|
||||
{/*<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>
|
||||
{/*</ScrollArea>*/}
|
||||
</T.List>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
interface TabContentProps {
|
||||
value: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TabContent({ value, children, className }: TabContentProps) {
|
||||
return (
|
||||
<T.Content
|
||||
forceMount
|
||||
value={value}
|
||||
className={classnames(className, 'tab-content', 'w-full h-full overflow-auto')}
|
||||
>
|
||||
{children}
|
||||
</T.Content>
|
||||
);
|
||||
}
|
||||
28
src-web/components/core/Webview.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
interface Props {
|
||||
body: string;
|
||||
contentType: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export function Webview({ body, url, contentType }: Props) {
|
||||
const contentForIframe: string | undefined = useMemo(() => {
|
||||
if (!contentType.includes('html')) return;
|
||||
if (body.includes('<head>')) {
|
||||
return body.replace(/<head>/gi, `<head><base href="${url}"/>`);
|
||||
}
|
||||
return body;
|
||||
}, [body, contentType]);
|
||||
|
||||
return (
|
||||
<div className="px-2 pb-2">
|
||||
<iframe
|
||||
title="Response preview"
|
||||
srcDoc={contentForIframe}
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
className="h-full w-full rounded-md border border-gray-100/20"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
src-web/components/core/WindowDragRegion.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import classnames from 'classnames';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function WindowDragRegion({ className, ...props }: Props) {
|
||||
return (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className={classnames(className, 'w-full h-12 flex-shrink-0')}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||