Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
099dff2945 |
No files matched your search
@@ -1,5 +0,0 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.prettierrc.cjs
|
||||
.eslintrc.cjs
|
||||
env.d.ts
|
||||
@@ -1,37 +0,0 @@
|
||||
module.exports = {
|
||||
extends: [
|
||||
"eslint:recommended",
|
||||
"plugin:react/recommended",
|
||||
"plugin:react-hooks/recommended",
|
||||
"plugin:import/recommended",
|
||||
"plugin:jsx-a11y/recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"eslint-config-prettier"
|
||||
],
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.json'],
|
||||
},
|
||||
ignorePatterns: ["src-tauri/**/*"],
|
||||
settings: {
|
||||
react: {
|
||||
version: "detect"
|
||||
},
|
||||
"import/resolver": {
|
||||
node: {
|
||||
paths: ["src-web"],
|
||||
extensions: [".ts", ".tsx"]
|
||||
}
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
"jsx-a11y/no-autofocus": "warn",
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"import/no-unresolved": "off",
|
||||
"@typescript-eslint/consistent-type-imports": ["error", {
|
||||
prefer: "type-imports",
|
||||
disallowTypeAnnotations: true,
|
||||
fixStyle: "separate-type-imports"
|
||||
}],
|
||||
}
|
||||
};
|
||||
@@ -1,49 +0,0 @@
|
||||
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
|
||||
@@ -1,26 +0,0 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.sqlite
|
||||
@@ -1,3 +0,0 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.prettierrc.cjs
|
||||
@@ -1,8 +0,0 @@
|
||||
module.exports = {
|
||||
"trailingComma": "all",
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"bracketSpacing": true
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
<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 />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -1,16 +0,0 @@
|
||||
# Yaak Network Toolkit
|
||||
|
||||
The most fun you'll ever have working with APIs.
|
||||
|
||||
## Common Commands
|
||||
|
||||
```sh
|
||||
# Start dev app
|
||||
npm run tauri-dev
|
||||
|
||||
# Migration commands
|
||||
cd src-tauri
|
||||
cargo sqlx migrate add <name>
|
||||
cargo sqlx migrate run --database-url 'sqlite://db.sqlite?mode=rw'
|
||||
cargo sqlx prepare --database-url 'sqlite://db.sqlite'
|
||||
```
|
||||
@@ -1,28 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Yaak App</title>
|
||||
<script src="http://localhost:8097"></script>
|
||||
<style>
|
||||
body {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background-color: black;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<div id="cm-portal" class="cm-portal"></div>
|
||||
<div id="react-portal"></div>
|
||||
<div id="radix-portal" class="cm-portal"></div>
|
||||
<script type="module" src="/src-web/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 245 KiB |
@@ -1,81 +0,0 @@
|
||||
{
|
||||
"name": "yaak-app",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"tauri-dev": "YAAK_ENV=development tauri dev",
|
||||
"tauri-build": "tauri build",
|
||||
"build": "npm run build:frontend",
|
||||
"dev": "vite dev",
|
||||
"lint": "tsc && eslint . --ext .ts,.tsx",
|
||||
"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-icons": "^1.2.0",
|
||||
"@react-hook/resize-observer": "^1.2.6",
|
||||
"@tailwindcss/container-queries": "^0.1.0",
|
||||
"@tanstack/query-sync-storage-persister": "^4.27.1",
|
||||
"@tanstack/react-query": "^4.28.0",
|
||||
"@tanstack/react-query-devtools": "^4.28.0",
|
||||
"@tanstack/react-query-persist-client": "^4.28.0",
|
||||
"@tauri-apps/api": "^1.2.0",
|
||||
"@vitejs/plugin-react": "^3.1.0",
|
||||
"classnames": "^2.3.2",
|
||||
"cm6-graphql": "^0.0.4-canary-b30a2325.0",
|
||||
"codemirror": "^6.0.1",
|
||||
"focus-trap-react": "^10.1.1",
|
||||
"format-graphql": "^1.4.0",
|
||||
"framer-motion": "^9.0.4",
|
||||
"parse-color": "^1.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dnd": "^16.0.1",
|
||||
"react-dnd-html5-backend": "^16.0.1",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-helmet-async": "^1.3.0",
|
||||
"react-router-dom": "^6.8.1",
|
||||
"react-use": "^17.4.0",
|
||||
"uuid": "^9.0.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": "^18.0.31",
|
||||
"@types/react-dom": "^18.0.11",
|
||||
"@types/uuid": "^9.0.1",
|
||||
"@typescript-eslint/eslint-plugin": "^5.57.0",
|
||||
"@typescript-eslint/parser": "^5.57.0",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"eslint": "^8.34.0",
|
||||
"eslint-config-prettier": "^8.6.0",
|
||||
"eslint-plugin-import": "^2.27.5",
|
||||
"eslint-plugin-jsx-a11y": "^6.7.1",
|
||||
"eslint-plugin-react": "^7.32.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"postcss": "^8.4.21",
|
||||
"postcss-nesting": "^11.2.1",
|
||||
"prettier": "^2.8.4",
|
||||
"tailwindcss": "^3.2.7",
|
||||
"typescript": "^5.0.2",
|
||||
"vite": "^4.0.0",
|
||||
"vite-plugin-svgr": "^2.4.0",
|
||||
"vite-plugin-top-level-await": "^1.2.4",
|
||||
"vitest": "^0.29.2"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: [
|
||||
require('@tailwindcss/nesting')(require('postcss-nesting')),
|
||||
require('tailwindcss'),
|
||||
require('autoprefixer'),
|
||||
]
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
[package]
|
||||
name = "yaak-app"
|
||||
version = "0.0.0"
|
||||
description = "A network protocol testing utility app"
|
||||
authors = ["Gregory Schier"]
|
||||
license = "MIT"
|
||||
repository = "https://github.com/gschier/yaak-app"
|
||||
edition = "2021"
|
||||
|
||||
[profile.release]
|
||||
strip = true # Automatically strip symbols from the binary.
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "1.2", features = [] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc = "0.2.7"
|
||||
cocoa = "0.24.1"
|
||||
|
||||
[dependencies]
|
||||
serde_json = { version = "1.0", features = ["raw_value"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
tauri = { version = "1.2", features = ["config-toml", "devtools", "shell-open", "system-tray", "updater", "window-start-dragging"] }
|
||||
http = "0.2.8"
|
||||
reqwest = { version = "0.11.14", features = ["json"] }
|
||||
tokio = { version = "1.25.0", features = ["sync"] }
|
||||
futures = "0.3.26"
|
||||
deno_core = "0.178.0"
|
||||
deno_ast = { version = "0.25.0", features = ["transpiling"] }
|
||||
sqlx = { version = "0.6.2", features = ["sqlite", "runtime-tokio-rustls", "json", "chrono", "time", "offline"] }
|
||||
uuid = "1.3.0"
|
||||
rand = "0.8.5"
|
||||
chrono = { version = "0.4.23", features = ["serde"] }
|
||||
base64 = "0.21.0"
|
||||
|
||||
[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
|
||||
default = ["custom-protocol"]
|
||||
# this feature is used used for production builds where `devPath` points to the filesystem
|
||||
# DO NOT remove this
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
@@ -1,3 +0,0 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 106 KiB |
@@ -1,65 +0,0 @@
|
||||
CREATE TABLE key_values
|
||||
(
|
||||
model TEXT DEFAULT 'key_value' NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
deleted_at DATETIME,
|
||||
namespace TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
PRIMARY KEY (namespace, key)
|
||||
);
|
||||
|
||||
CREATE TABLE workspaces
|
||||
(
|
||||
id TEXT NOT NULL
|
||||
PRIMARY KEY,
|
||||
model TEXT DEFAULT 'workspace' NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
deleted_at DATETIME,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE http_requests
|
||||
(
|
||||
id TEXT NOT NULL
|
||||
PRIMARY KEY,
|
||||
model TEXT DEFAULT 'http_request' NOT NULL,
|
||||
workspace_id TEXT NOT NULL
|
||||
REFERENCES workspaces
|
||||
ON DELETE CASCADE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
deleted_at DATETIME,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
method TEXT NOT NULL,
|
||||
headers TEXT NOT NULL,
|
||||
body TEXT,
|
||||
body_type TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE http_responses
|
||||
(
|
||||
id TEXT NOT NULL
|
||||
PRIMARY KEY,
|
||||
model TEXT DEFAULT 'http_response' NOT NULL,
|
||||
request_id TEXT NOT NULL
|
||||
REFERENCES http_requests
|
||||
ON DELETE CASCADE,
|
||||
workspace_id TEXT NOT NULL
|
||||
REFERENCES workspaces
|
||||
ON DELETE CASCADE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
deleted_at DATETIME,
|
||||
elapsed INTEGER NOT NULL,
|
||||
status INTEGER NOT NULL,
|
||||
status_reason TEXT,
|
||||
url TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
headers TEXT NOT NULL,
|
||||
error TEXT
|
||||
);
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE main.http_requests ADD COLUMN sort_priority REAL NOT NULL DEFAULT 0;
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE http_requests ADD COLUMN authentication TEXT NOT NULL DEFAULT '{}';
|
||||
ALTER TABLE http_requests ADD COLUMN authentication_type TEXT;
|
||||
@@ -1 +0,0 @@
|
||||
Deno.core.opAsync('op_hello', 'Deno');
|
||||
@@ -1,609 +0,0 @@
|
||||
{
|
||||
"db": "SQLite",
|
||||
"06aaf8f4a17566f1d25da2a60f0baf4b5fc28c3cf0c001a84e25edf9eab3c7e3": {
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "model",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "created_at",
|
||||
"ordinal": 1,
|
||||
"type_info": "Datetime"
|
||||
},
|
||||
{
|
||||
"name": "updated_at",
|
||||
"ordinal": 2,
|
||||
"type_info": "Datetime"
|
||||
},
|
||||
{
|
||||
"name": "namespace",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "key",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "value",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
}
|
||||
},
|
||||
"query": "\n SELECT model, created_at, updated_at, namespace, key, value\n FROM key_values\n WHERE namespace = ? AND key = ?\n "
|
||||
},
|
||||
"07d1a1c7b4f3d9625a766e60fd57bb779b71dae30e5bbce34885a911a5a42428": {
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"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 "
|
||||
},
|
||||
"318ed5a1126fe00719393cf4e6c788ee5a265af88b7253f61a475f78c6774ef6": {
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 9
|
||||
}
|
||||
},
|
||||
"query": "\n INSERT INTO http_responses (\n id,\n request_id,\n workspace_id,\n elapsed,\n url,\n status,\n status_reason,\n body,\n headers\n )\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);\n "
|
||||
},
|
||||
"448a1d1f1866ab42c0f81fcf8eb2930bf21dfdd43ca4831bc1a198cf45ac3732": {
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
}
|
||||
},
|
||||
"query": "\n DELETE FROM http_requests\n WHERE id = ?\n "
|
||||
},
|
||||
"6f0cb5a6d1e8dbc8cdfcc3c7e7944b2c83c22cb795b9d6b98fe067dabec9680b": {
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "model",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "workspace_id",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "created_at",
|
||||
"ordinal": 3,
|
||||
"type_info": "Datetime"
|
||||
},
|
||||
{
|
||||
"name": "updated_at",
|
||||
"ordinal": 4,
|
||||
"type_info": "Datetime"
|
||||
},
|
||||
{
|
||||
"name": "name",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "url",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "method",
|
||||
"ordinal": 7,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "body",
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "body_type",
|
||||
"ordinal": 9,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "authentication!: Json<HashMap<String, JsonValue>>",
|
||||
"ordinal": 10,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "authentication_type",
|
||||
"ordinal": 11,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "sort_priority",
|
||||
"ordinal": 12,
|
||||
"type_info": "Float"
|
||||
},
|
||||
{
|
||||
"name": "headers!: sqlx::types::Json<Vec<HttpRequestHeader>>",
|
||||
"ordinal": 13,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
}
|
||||
},
|
||||
"query": "\n SELECT\n id,\n model,\n workspace_id,\n created_at,\n updated_at,\n name,\n url,\n method,\n body,\n body_type,\n authentication AS \"authentication!: Json<HashMap<String, JsonValue>>\",\n authentication_type,\n sort_priority,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpRequestHeader>>\"\n FROM http_requests\n WHERE workspace_id = ?\n "
|
||||
},
|
||||
"84be2b954870ab181738656ecd4d03fca2ff21012947014c79626abfce8e999b": {
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
}
|
||||
},
|
||||
"query": "\n DELETE FROM workspaces\n WHERE id = ?\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 "
|
||||
},
|
||||
"b19c275180909a39342b13c3cdcf993781636913ae590967f5508c46a56dc961": {
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 11
|
||||
}
|
||||
},
|
||||
"query": "\n INSERT INTO http_requests (\n id,\n workspace_id,\n name,\n url,\n method,\n body,\n body_type,\n authentication,\n authentication_type,\n headers,\n sort_priority\n )\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n ON CONFLICT (id) DO UPDATE SET\n updated_at = CURRENT_TIMESTAMP,\n name = excluded.name,\n method = excluded.method,\n headers = excluded.headers,\n body = excluded.body,\n body_type = excluded.body_type,\n authentication = excluded.authentication,\n authentication_type = excluded.authentication_type,\n url = excluded.url,\n sort_priority = excluded.sort_priority\n "
|
||||
},
|
||||
"caf3f21bf291dfbd36446592066e96c1f83abe96f6ea9211a3e049eb9c58a8c8": {
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "model",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "created_at",
|
||||
"ordinal": 2,
|
||||
"type_info": "Datetime"
|
||||
},
|
||||
{
|
||||
"name": "updated_at",
|
||||
"ordinal": 3,
|
||||
"type_info": "Datetime"
|
||||
},
|
||||
{
|
||||
"name": "name",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "description",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
}
|
||||
},
|
||||
"query": "\n SELECT id, model, created_at, updated_at, name, description\n FROM workspaces WHERE id = ?\n "
|
||||
},
|
||||
"cea4cae52f16ec78aca9a47b17117422d4f165e5a3b308c70fd1a180382475ea": {
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "model",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "created_at",
|
||||
"ordinal": 2,
|
||||
"type_info": "Datetime"
|
||||
},
|
||||
{
|
||||
"name": "updated_at",
|
||||
"ordinal": 3,
|
||||
"type_info": "Datetime"
|
||||
},
|
||||
{
|
||||
"name": "name",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "description",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
}
|
||||
},
|
||||
"query": "\n SELECT id, model, created_at, updated_at, name, description\n FROM workspaces\n "
|
||||
},
|
||||
"ced098adb79c0ee64e223b6e02371ef253920a2c342275de0fa9c181529a4adc": {
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "model",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "workspace_id",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "created_at",
|
||||
"ordinal": 3,
|
||||
"type_info": "Datetime"
|
||||
},
|
||||
{
|
||||
"name": "updated_at",
|
||||
"ordinal": 4,
|
||||
"type_info": "Datetime"
|
||||
},
|
||||
{
|
||||
"name": "name",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "url",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "method",
|
||||
"ordinal": 7,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "body",
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "body_type",
|
||||
"ordinal": 9,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "authentication!: Json<HashMap<String, JsonValue>>",
|
||||
"ordinal": 10,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "authentication_type",
|
||||
"ordinal": 11,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "sort_priority",
|
||||
"ordinal": 12,
|
||||
"type_info": "Float"
|
||||
},
|
||||
{
|
||||
"name": "headers!: sqlx::types::Json<Vec<HttpRequestHeader>>",
|
||||
"ordinal": 13,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
}
|
||||
},
|
||||
"query": "\n SELECT\n id,\n model,\n workspace_id,\n created_at,\n updated_at,\n name,\n url,\n method,\n body,\n body_type,\n authentication AS \"authentication!: Json<HashMap<String, JsonValue>>\",\n authentication_type,\n sort_priority,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpRequestHeader>>\"\n FROM http_requests\n WHERE id = ?\n "
|
||||
},
|
||||
"d5ad6d5f82fe837fa9215bd4619ec18a7c95b3088d4fbf9825f2d1d28069d1ce": {
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "model",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "workspace_id",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "request_id",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "updated_at",
|
||||
"ordinal": 4,
|
||||
"type_info": "Datetime"
|
||||
},
|
||||
{
|
||||
"name": "created_at",
|
||||
"ordinal": 5,
|
||||
"type_info": "Datetime"
|
||||
},
|
||||
{
|
||||
"name": "status",
|
||||
"ordinal": 6,
|
||||
"type_info": "Int64"
|
||||
},
|
||||
{
|
||||
"name": "status_reason",
|
||||
"ordinal": 7,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "body",
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "elapsed",
|
||||
"ordinal": 9,
|
||||
"type_info": "Int64"
|
||||
},
|
||||
{
|
||||
"name": "url",
|
||||
"ordinal": 10,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "error",
|
||||
"ordinal": 11,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "headers!: sqlx::types::Json<Vec<HttpResponseHeader>>",
|
||||
"ordinal": 12,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
}
|
||||
},
|
||||
"query": "\n SELECT id, model, workspace_id, request_id, updated_at,\n created_at, status, status_reason, body, elapsed, url, error,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpResponseHeader>>\"\n FROM http_responses\n WHERE request_id = ?\n ORDER BY created_at ASC\n "
|
||||
},
|
||||
"d80c09497771e3641022e73ec6c6a87e73a551f88a948a5445d754922b82b50b": {
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
}
|
||||
},
|
||||
"query": "\n INSERT INTO key_values (namespace, key, value)\n VALUES (?, ?, ?) ON CONFLICT DO UPDATE SET\n updated_at = CURRENT_TIMESTAMP,\n value = excluded.value\n "
|
||||
},
|
||||
"e3ade0a69348d512e47e964bded9d7d890b92fdc1e01c6c22fa5e91f943639f2": {
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "model",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "workspace_id",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "request_id",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "updated_at",
|
||||
"ordinal": 4,
|
||||
"type_info": "Datetime"
|
||||
},
|
||||
{
|
||||
"name": "created_at",
|
||||
"ordinal": 5,
|
||||
"type_info": "Datetime"
|
||||
},
|
||||
{
|
||||
"name": "status",
|
||||
"ordinal": 6,
|
||||
"type_info": "Int64"
|
||||
},
|
||||
{
|
||||
"name": "status_reason",
|
||||
"ordinal": 7,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "body",
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "elapsed",
|
||||
"ordinal": 9,
|
||||
"type_info": "Int64"
|
||||
},
|
||||
{
|
||||
"name": "url",
|
||||
"ordinal": 10,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "error",
|
||||
"ordinal": 11,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "headers!: sqlx::types::Json<Vec<HttpResponseHeader>>",
|
||||
"ordinal": 12,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
}
|
||||
},
|
||||
"query": "\n SELECT id, model, workspace_id, request_id, updated_at, created_at,\n status, status_reason, body, elapsed, url, error,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpResponseHeader>>\"\n FROM http_responses\n WHERE id = ?\n "
|
||||
},
|
||||
"f116d8cf9aad828135bb8c3a4c8b8e6b857ae13303989e9133a33b2d1cf20e96": {
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
}
|
||||
},
|
||||
"query": "\n INSERT INTO workspaces (id, name, description)\n VALUES (?, ?, ?)\n "
|
||||
}
|
||||
}
|
||||
@@ -1,704 +0,0 @@
|
||||
#![cfg_attr(
|
||||
all(not(debug_assertions), target_os = "windows"),
|
||||
windows_subsystem = "windows"
|
||||
)]
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[macro_use]
|
||||
extern crate objc;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::env::current_dir;
|
||||
use std::fs::create_dir_all;
|
||||
|
||||
use base64::Engine;
|
||||
use http::header::{HeaderName, ACCEPT, USER_AGENT};
|
||||
use http::{HeaderMap, HeaderValue, Method};
|
||||
use reqwest::redirect::Policy;
|
||||
use serde::Serialize;
|
||||
use sqlx::migrate::Migrator;
|
||||
use sqlx::sqlite::SqlitePoolOptions;
|
||||
use sqlx::types::Json;
|
||||
use sqlx::{Pool, Sqlite};
|
||||
use tauri::regex::Regex;
|
||||
use tauri::{AppHandle, Menu, MenuItem, RunEvent, State, Submenu, TitleBarStyle, Window, Wry};
|
||||
use tauri::{CustomMenuItem, Manager, SystemTray, SystemTrayEvent, SystemTrayMenu, WindowEvent};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use window_ext::WindowExt;
|
||||
|
||||
mod models;
|
||||
mod runtime;
|
||||
mod window_ext;
|
||||
|
||||
#[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");
|
||||
println!("Running migrations at {}", p.to_string_lossy());
|
||||
let m = Migrator::new(p).await.expect("Failed to load migrations");
|
||||
m.run(pool).await.expect("Failed to run migrations");
|
||||
println!("Migrations complete");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn send_ephemeral_request(
|
||||
request: models::HttpRequest,
|
||||
app_handle: AppHandle<Wry>,
|
||||
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||
) -> Result<models::HttpResponse, String> {
|
||||
let pool = &*db_instance.lock().await;
|
||||
let response = models::HttpResponse::default();
|
||||
return actually_send_ephemeral_request(request, &response, &app_handle, pool).await;
|
||||
}
|
||||
|
||||
async fn actually_send_ephemeral_request(
|
||||
request: models::HttpRequest,
|
||||
response: &models::HttpResponse,
|
||||
app_handle: &AppHandle<Wry>,
|
||||
pool: &Pool<Sqlite>,
|
||||
) -> Result<models::HttpResponse, String> {
|
||||
let start = std::time::Instant::now();
|
||||
let mut url_string = request.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);
|
||||
}
|
||||
|
||||
println!("Sending request to {}", 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 request.headers.0 {
|
||||
if h.name.is_empty() && h.value.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !h.enabled {
|
||||
continue;
|
||||
}
|
||||
let header_name = match HeaderName::from_bytes(h.name.as_bytes()) {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
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);
|
||||
}
|
||||
|
||||
if let Some(b) = &request.authentication_type {
|
||||
let empty_value = &serde_json::to_value("").unwrap();
|
||||
if b == "basic" {
|
||||
let a = request.authentication.0;
|
||||
let auth = format!(
|
||||
"{}:{}",
|
||||
a.get("username")
|
||||
.unwrap_or(empty_value)
|
||||
.as_str()
|
||||
.unwrap_or(""),
|
||||
a.get("password")
|
||||
.unwrap_or(empty_value)
|
||||
.as_str()
|
||||
.unwrap_or(""),
|
||||
);
|
||||
let encoded = base64::engine::general_purpose::STANDARD_NO_PAD.encode(auth);
|
||||
headers.insert(
|
||||
"Authorization",
|
||||
HeaderValue::from_str(&format!("Basic {}", encoded)).unwrap(),
|
||||
);
|
||||
} else if b == "bearer" {
|
||||
let token = request
|
||||
.authentication
|
||||
.0
|
||||
.get("token")
|
||||
.unwrap_or(empty_value)
|
||||
.as_str()
|
||||
.unwrap_or("");
|
||||
headers.insert(
|
||||
"Authorization",
|
||||
HeaderValue::from_str(&format!("Bearer {}", token)).unwrap(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let m = Method::from_bytes(request.method.to_uppercase().as_bytes())
|
||||
.expect("Failed to create method");
|
||||
let builder = client.request(m, url_string.to_string()).headers(headers);
|
||||
|
||||
let sendable_req_result = match (request.body, request.body_type) {
|
||||
(Some(b), Some(_)) => builder.body(b).build(),
|
||||
_ => builder.build(),
|
||||
};
|
||||
|
||||
let sendable_req = match sendable_req_result {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return response_err(response, e.to_string(), &app_handle, pool).await;
|
||||
}
|
||||
};
|
||||
|
||||
let raw_response = client.execute(sendable_req).await;
|
||||
|
||||
let p = app_handle
|
||||
.path_resolver()
|
||||
.resolve_resource("plugins/plugin.ts")
|
||||
.expect("failed to resolve resource");
|
||||
|
||||
runtime::run_plugin_sync(p.to_str().unwrap()).unwrap();
|
||||
|
||||
match raw_response {
|
||||
Ok(v) => {
|
||||
let mut response = response.clone();
|
||||
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_if_id(response, pool)
|
||||
.await
|
||||
.expect("Failed to update response");
|
||||
if request.id != "" {
|
||||
emit_side_effect(app_handle, "updated_model", &response);
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
Err(e) => response_err(response, e.to_string(), app_handle, pool).await,
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn send_request(
|
||||
window: Window<Wry>,
|
||||
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||
request_id: &str,
|
||||
) -> Result<models::HttpResponse, String> {
|
||||
let pool = &*db_instance.lock().await;
|
||||
|
||||
let req = models::get_request(request_id, pool)
|
||||
.await
|
||||
.expect("Failed to get request");
|
||||
|
||||
let response = models::create_response(&req.id, 0, "", 0, None, "", vec![], pool)
|
||||
.await
|
||||
.expect("Failed to create response");
|
||||
|
||||
let response2 = response.clone();
|
||||
let app_handle2 = window.app_handle().clone();
|
||||
let pool2 = pool.clone();
|
||||
tokio::spawn(async move {
|
||||
actually_send_ephemeral_request(req, &response2, &app_handle2, &pool2)
|
||||
.await
|
||||
.expect("Failed to send request");
|
||||
});
|
||||
|
||||
emit_and_return(&window, "created_model", response)
|
||||
}
|
||||
|
||||
async fn response_err(
|
||||
response: &models::HttpResponse,
|
||||
error: String,
|
||||
app_handle: &AppHandle<Wry>,
|
||||
pool: &Pool<Sqlite>,
|
||||
) -> Result<models::HttpResponse, String> {
|
||||
let mut response = response.clone();
|
||||
response.error = Some(error.clone());
|
||||
response = models::update_response_if_id(response, pool)
|
||||
.await
|
||||
.expect("Failed to update response");
|
||||
emit_side_effect(app_handle, "updated_model", &response);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_key_value(
|
||||
namespace: &str,
|
||||
key: &str,
|
||||
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||
) -> Result<Option<models::KeyValue>, ()> {
|
||||
let pool = &*db_instance.lock().await;
|
||||
let result = models::get_key_value(namespace, key, pool).await;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn set_key_value(
|
||||
namespace: &str,
|
||||
key: &str,
|
||||
value: &str,
|
||||
window: Window<Wry>,
|
||||
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||
) -> Result<models::KeyValue, String> {
|
||||
let pool = &*db_instance.lock().await;
|
||||
let (key_value, created) = models::set_key_value(namespace, key, value, pool).await;
|
||||
|
||||
if created {
|
||||
emit_and_return(&window, "created_model", key_value)
|
||||
} else {
|
||||
emit_and_return(&window, "updated_model", key_value)
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn create_workspace(
|
||||
name: &str,
|
||||
window: Window<Wry>,
|
||||
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||
) -> Result<models::Workspace, String> {
|
||||
let pool = &*db_instance.lock().await;
|
||||
let created_workspace = models::create_workspace(name, "", pool)
|
||||
.await
|
||||
.expect("Failed to create workspace");
|
||||
|
||||
emit_and_return(&window, "created_model", created_workspace)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn create_request(
|
||||
workspace_id: &str,
|
||||
name: &str,
|
||||
sort_priority: f64,
|
||||
window: Window<Wry>,
|
||||
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||
) -> Result<models::HttpRequest, String> {
|
||||
let pool = &*db_instance.lock().await;
|
||||
let headers = Vec::new();
|
||||
let created_request = models::upsert_request(
|
||||
None,
|
||||
workspace_id,
|
||||
name,
|
||||
"GET",
|
||||
None,
|
||||
None,
|
||||
HashMap::new(),
|
||||
None,
|
||||
"",
|
||||
headers,
|
||||
sort_priority,
|
||||
pool,
|
||||
)
|
||||
.await
|
||||
.expect("Failed to create request");
|
||||
|
||||
emit_and_return(&window, "created_model", created_request)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn duplicate_request(
|
||||
id: &str,
|
||||
window: Window<Wry>,
|
||||
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||
) -> Result<models::HttpRequest, String> {
|
||||
let pool = &*db_instance.lock().await;
|
||||
let request = models::duplicate_request(id, pool)
|
||||
.await
|
||||
.expect("Failed to duplicate request");
|
||||
emit_and_return(&window, "updated_model", request)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn update_request(
|
||||
request: models::HttpRequest,
|
||||
window: Window<Wry>,
|
||||
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||
) -> Result<models::HttpRequest, 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,
|
||||
};
|
||||
|
||||
// TODO: Figure out how to make this better
|
||||
let updated_request = models::upsert_request(
|
||||
Some(request.id.as_str()),
|
||||
request.workspace_id.as_str(),
|
||||
request.name.as_str(),
|
||||
request.method.as_str(),
|
||||
body,
|
||||
request.body_type,
|
||||
request.authentication.0,
|
||||
request.authentication_type,
|
||||
request.url.as_str(),
|
||||
request.headers.0,
|
||||
request.sort_priority,
|
||||
pool,
|
||||
)
|
||||
.await
|
||||
.expect("Failed to update request");
|
||||
|
||||
emit_and_return(&window, "updated_model", updated_request)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn delete_request(
|
||||
window: Window<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");
|
||||
emit_and_return(&window, "deleted_model", 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 get_request(
|
||||
id: &str,
|
||||
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||
) -> Result<models::HttpRequest, String> {
|
||||
let pool = &*db_instance.lock().await;
|
||||
models::get_request(id, pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn responses(
|
||||
request_id: &str,
|
||||
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,
|
||||
window: Window<Wry>,
|
||||
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||
) -> Result<models::HttpResponse, String> {
|
||||
let pool = &*db_instance.lock().await;
|
||||
let response = models::delete_response(id, pool)
|
||||
.await
|
||||
.expect("Failed to delete response");
|
||||
emit_and_return(&window, "deleted_model", response)
|
||||
}
|
||||
|
||||
#[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("My Project", "This is the default workspace", pool)
|
||||
.await
|
||||
.expect("Failed to create workspace");
|
||||
Ok(vec![workspace])
|
||||
} else {
|
||||
Ok(workspaces)
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn delete_workspace(
|
||||
window: Window<Wry>,
|
||||
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||
id: &str,
|
||||
) -> Result<models::Workspace, String> {
|
||||
let pool = &*db_instance.lock().await;
|
||||
let workspace = models::delete_workspace(id, pool)
|
||||
.await
|
||||
.expect("Failed to delete workspace");
|
||||
emit_and_return(&window, "deleted_model", workspace)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn greet(name: &str) -> String {
|
||||
format!("Hello, {}! You've been greeted from Rust!", name)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let quit = CustomMenuItem::new("quit".to_string(), "Quit");
|
||||
let tray_menu = SystemTrayMenu::new().add_item(quit);
|
||||
let system_tray = SystemTray::new().with_menu(tray_menu);
|
||||
|
||||
tauri::Builder::default()
|
||||
.system_tray(system_tray)
|
||||
.setup(|app| {
|
||||
let dir = match is_dev() {
|
||||
true => current_dir().unwrap(),
|
||||
false => 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!("Connecting to database at {}", url);
|
||||
tauri::async_runtime::block_on(async move {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.connect(url.as_str())
|
||||
.await
|
||||
.expect("Failed to connect to database");
|
||||
|
||||
// Setup the DB handle
|
||||
let m = Mutex::new(pool);
|
||||
migrate_db(app.handle(), &m)
|
||||
.await
|
||||
.expect("Failed to migrate database");
|
||||
app.manage(m);
|
||||
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
.on_system_tray_event(|app, event| {
|
||||
if let SystemTrayEvent::MenuItemClick { id, .. } = event {
|
||||
match id.as_str() {
|
||||
"quit" => {
|
||||
std::process::exit(0);
|
||||
}
|
||||
"hide" => {
|
||||
let window = app.get_window("main").unwrap();
|
||||
window.hide().unwrap();
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
}
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
greet,
|
||||
workspaces,
|
||||
get_request,
|
||||
requests,
|
||||
send_request,
|
||||
send_ephemeral_request,
|
||||
duplicate_request,
|
||||
create_request,
|
||||
create_workspace,
|
||||
delete_workspace,
|
||||
update_request,
|
||||
delete_request,
|
||||
responses,
|
||||
get_key_value,
|
||||
set_key_value,
|
||||
delete_response,
|
||||
delete_all_responses,
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while running tauri application")
|
||||
.run(|app_handle, event| match event {
|
||||
RunEvent::Ready => {
|
||||
create_window(app_handle);
|
||||
}
|
||||
|
||||
// ExitRequested { api, .. } => {
|
||||
// }
|
||||
_ => {}
|
||||
});
|
||||
}
|
||||
|
||||
fn is_dev() -> bool {
|
||||
let env = option_env!("YAAK_ENV");
|
||||
env.unwrap_or("production") != "production"
|
||||
}
|
||||
|
||||
fn create_window(handle: &AppHandle<Wry>) -> Window<Wry> {
|
||||
let default_menu = Menu::os_default("Yaak".to_string().as_str());
|
||||
let mut test_menu = Menu::new()
|
||||
.add_item(
|
||||
CustomMenuItem::new("send_request".to_string(), "Send Request")
|
||||
.accelerator("CmdOrCtrl+r"),
|
||||
)
|
||||
.add_item(
|
||||
CustomMenuItem::new("zoom_reset".to_string(), "Zoom to Actual Size")
|
||||
.accelerator("CmdOrCtrl+0"),
|
||||
)
|
||||
.add_item(
|
||||
CustomMenuItem::new("zoom_in".to_string(), "Zoom In").accelerator("CmdOrCtrl+Plus"),
|
||||
)
|
||||
.add_item(
|
||||
CustomMenuItem::new("zoom_out".to_string(), "Zoom Out").accelerator("CmdOrCtrl+-"),
|
||||
)
|
||||
.add_item(
|
||||
CustomMenuItem::new("toggle_sidebar".to_string(), "Toggle Sidebar")
|
||||
.accelerator("CmdOrCtrl+b"),
|
||||
)
|
||||
.add_item(
|
||||
CustomMenuItem::new("focus_url".to_string(), "Focus URL").accelerator("CmdOrCtrl+l"),
|
||||
)
|
||||
.add_item(CustomMenuItem::new("new_window".to_string(), "New Window"));
|
||||
if is_dev() {
|
||||
test_menu = test_menu
|
||||
.add_native_item(MenuItem::Separator)
|
||||
.add_item(
|
||||
CustomMenuItem::new("refresh".to_string(), "Refresh")
|
||||
.accelerator("CmdOrCtrl + Shift + r"),
|
||||
)
|
||||
.add_item(
|
||||
CustomMenuItem::new("toggle_devtools".to_string(), "Open Devtools")
|
||||
.accelerator("CmdOrCtrl + Option + i"),
|
||||
);
|
||||
}
|
||||
|
||||
let submenu = Submenu::new("Test Menu", test_menu);
|
||||
|
||||
let window_num = handle.windows().len();
|
||||
let window_id = format!("wnd_{}", window_num);
|
||||
let menu = default_menu.add_submenu(submenu);
|
||||
let win = tauri::WindowBuilder::new(handle, window_id, tauri::WindowUrl::App("".into()))
|
||||
.menu(menu)
|
||||
.fullscreen(false)
|
||||
.resizable(true)
|
||||
.inner_size(1100.0, 600.0)
|
||||
.hidden_title(true)
|
||||
.title(match is_dev() {
|
||||
true => "Yaak Dev",
|
||||
false => "Yaak",
|
||||
})
|
||||
.title_bar_style(TitleBarStyle::Overlay)
|
||||
.build()
|
||||
.expect("failed to build window");
|
||||
|
||||
let win2 = win.clone();
|
||||
let handle2 = handle.clone();
|
||||
win.on_menu_event(move |event| match event.menu_item_id() {
|
||||
"quit" => std::process::exit(0),
|
||||
"close" => win2.close().unwrap(),
|
||||
"zoom_reset" => win2.emit("zoom", 0).unwrap(),
|
||||
"zoom_in" => win2.emit("zoom", 1).unwrap(),
|
||||
"zoom_out" => win2.emit("zoom", -1).unwrap(),
|
||||
"toggle_sidebar" => win2.emit("toggle_sidebar", true).unwrap(),
|
||||
"focus_url" => win2.emit("focus_url", true).unwrap(),
|
||||
"send_request" => win2.emit("send_request", true).unwrap(),
|
||||
"refresh" => win2.eval("location.reload()").unwrap(),
|
||||
"new_window" => _ = create_window(&handle2),
|
||||
"toggle_devtools" => {
|
||||
if win2.is_devtools_open() {
|
||||
win2.close_devtools();
|
||||
} else {
|
||||
win2.open_devtools();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
|
||||
let win3 = win.clone();
|
||||
win.on_window_event(move |e| {
|
||||
let apply_offset = || {
|
||||
#[cfg(target_os = "macos")]
|
||||
win3.position_traffic_lights();
|
||||
};
|
||||
|
||||
match e {
|
||||
WindowEvent::Resized(..) => apply_offset(),
|
||||
WindowEvent::ThemeChanged(..) => apply_offset(),
|
||||
WindowEvent::CloseRequested { .. } => {
|
||||
println!("CLOSE REQUESTED");
|
||||
// api.prevent_close();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
win.position_traffic_lights();
|
||||
|
||||
win
|
||||
}
|
||||
|
||||
/// Emit an event to all windows, with a source window
|
||||
fn emit_and_return<S: Serialize + Clone, E>(
|
||||
current_window: &Window<Wry>,
|
||||
event: &str,
|
||||
payload: S,
|
||||
) -> Result<S, E> {
|
||||
current_window.emit_all(event, &payload).unwrap();
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
/// Emit an event to all windows, used for side-effects where there is no source window to attribute. This
|
||||
fn emit_side_effect<S: Serialize + Clone>(app_handle: &AppHandle<Wry>, event: &str, payload: S) {
|
||||
app_handle.emit_all(event, &payload).unwrap();
|
||||
}
|
||||
@@ -1,527 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use rand::distributions::{Alphanumeric, DistString};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::types::chrono::NaiveDateTime;
|
||||
use sqlx::types::{Json, JsonValue};
|
||||
use sqlx::{Pool, Sqlite};
|
||||
|
||||
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Workspace {
|
||||
pub id: String,
|
||||
pub model: String,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HttpRequestHeader {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HttpRequest {
|
||||
pub id: String,
|
||||
pub model: String,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
pub sort_priority: f64,
|
||||
pub workspace_id: String,
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub method: String,
|
||||
pub body: Option<String>,
|
||||
pub body_type: Option<String>,
|
||||
pub authentication: Json<HashMap<String, JsonValue>>,
|
||||
pub authentication_type: Option<String>,
|
||||
pub headers: Json<Vec<HttpRequestHeader>>,
|
||||
}
|
||||
|
||||
#[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, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HttpResponse {
|
||||
pub id: String,
|
||||
pub model: String,
|
||||
pub workspace_id: String,
|
||||
pub request_id: String,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
pub 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>>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct KeyValue {
|
||||
pub model: String,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
pub namespace: String,
|
||||
pub key: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
pub async fn set_key_value(
|
||||
namespace: &str,
|
||||
key: &str,
|
||||
value: &str,
|
||||
pool: &Pool<Sqlite>,
|
||||
) -> (KeyValue, bool) {
|
||||
let existing = get_key_value(namespace, key, pool).await;
|
||||
sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO key_values (namespace, key, value)
|
||||
VALUES (?, ?, ?) ON CONFLICT DO UPDATE SET
|
||||
updated_at = CURRENT_TIMESTAMP,
|
||||
value = excluded.value
|
||||
"#,
|
||||
namespace,
|
||||
key,
|
||||
value,
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("Failed to insert key value");
|
||||
|
||||
let kv = get_key_value(namespace, key, pool)
|
||||
.await
|
||||
.expect("Failed to get key value");
|
||||
return (kv, existing.is_none());
|
||||
}
|
||||
|
||||
pub async fn get_key_value(namespace: &str, key: &str, pool: &Pool<Sqlite>) -> Option<KeyValue> {
|
||||
sqlx::query_as!(
|
||||
KeyValue,
|
||||
r#"
|
||||
SELECT model, created_at, updated_at, namespace, key, value
|
||||
FROM key_values
|
||||
WHERE namespace = ? AND key = ?
|
||||
"#,
|
||||
namespace,
|
||||
key,
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub async fn find_workspaces(pool: &Pool<Sqlite>) -> Result<Vec<Workspace>, sqlx::Error> {
|
||||
sqlx::query_as!(
|
||||
Workspace,
|
||||
r#"
|
||||
SELECT id, model, created_at, updated_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, model, created_at, updated_at, name, description
|
||||
FROM workspaces WHERE id = ?
|
||||
"#,
|
||||
id,
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_workspace(id: &str, pool: &Pool<Sqlite>) -> Result<Workspace, sqlx::Error> {
|
||||
let workspace = get_workspace(id, pool)
|
||||
.await
|
||||
.expect("Failed to get request to delete");
|
||||
let _ = sqlx::query!(
|
||||
r#"
|
||||
DELETE FROM workspaces
|
||||
WHERE id = ?
|
||||
"#,
|
||||
id,
|
||||
)
|
||||
.execute(pool)
|
||||
.await;
|
||||
Ok(workspace)
|
||||
}
|
||||
|
||||
pub async fn create_workspace(
|
||||
name: &str,
|
||||
description: &str,
|
||||
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 duplicate_request(id: &str, pool: &Pool<Sqlite>) -> Result<HttpRequest, sqlx::Error> {
|
||||
let existing = get_request(id, pool)
|
||||
.await
|
||||
.expect("Failed to get request to duplicate");
|
||||
|
||||
// TODO: Figure out how to make this better
|
||||
let b2;
|
||||
let body = match existing.body {
|
||||
Some(b) => {
|
||||
b2 = b;
|
||||
Some(b2.as_str())
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
upsert_request(
|
||||
None,
|
||||
existing.workspace_id.as_str(),
|
||||
existing.name.as_str(),
|
||||
existing.method.as_str(),
|
||||
body,
|
||||
existing.body_type,
|
||||
existing.authentication.0,
|
||||
existing.authentication_type,
|
||||
existing.url.as_str(),
|
||||
existing.headers.0,
|
||||
existing.sort_priority + 0.001,
|
||||
pool,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn upsert_request(
|
||||
id: Option<&str>,
|
||||
workspace_id: &str,
|
||||
name: &str,
|
||||
method: &str,
|
||||
body: Option<&str>,
|
||||
body_type: Option<String>,
|
||||
authentication: HashMap<String, JsonValue>,
|
||||
authentication_type: Option<String>,
|
||||
url: &str,
|
||||
headers: Vec<HttpRequestHeader>,
|
||||
sort_priority: f64,
|
||||
pool: &Pool<Sqlite>,
|
||||
) -> Result<HttpRequest, sqlx::Error> {
|
||||
let generated_id;
|
||||
let id = match id {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
generated_id = generate_id("rq");
|
||||
generated_id.as_str()
|
||||
}
|
||||
};
|
||||
let headers_json = Json(headers);
|
||||
let auth_json = Json(authentication);
|
||||
sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO http_requests (
|
||||
id,
|
||||
workspace_id,
|
||||
name,
|
||||
url,
|
||||
method,
|
||||
body,
|
||||
body_type,
|
||||
authentication,
|
||||
authentication_type,
|
||||
headers,
|
||||
sort_priority
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
updated_at = CURRENT_TIMESTAMP,
|
||||
name = excluded.name,
|
||||
method = excluded.method,
|
||||
headers = excluded.headers,
|
||||
body = excluded.body,
|
||||
body_type = excluded.body_type,
|
||||
authentication = excluded.authentication,
|
||||
authentication_type = excluded.authentication_type,
|
||||
url = excluded.url,
|
||||
sort_priority = excluded.sort_priority
|
||||
"#,
|
||||
id,
|
||||
workspace_id,
|
||||
name,
|
||||
url,
|
||||
method,
|
||||
body,
|
||||
body_type,
|
||||
auth_json,
|
||||
authentication_type,
|
||||
headers_json,
|
||||
sort_priority,
|
||||
)
|
||||
.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,
|
||||
model,
|
||||
workspace_id,
|
||||
created_at,
|
||||
updated_at,
|
||||
name,
|
||||
url,
|
||||
method,
|
||||
body,
|
||||
body_type,
|
||||
authentication AS "authentication!: Json<HashMap<String, JsonValue>>",
|
||||
authentication_type,
|
||||
sort_priority,
|
||||
headers AS "headers!: sqlx::types::Json<Vec<HttpRequestHeader>>"
|
||||
FROM http_requests
|
||||
WHERE workspace_id = ?
|
||||
"#,
|
||||
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,
|
||||
model,
|
||||
workspace_id,
|
||||
created_at,
|
||||
updated_at,
|
||||
name,
|
||||
url,
|
||||
method,
|
||||
body,
|
||||
body_type,
|
||||
authentication AS "authentication!: Json<HashMap<String, JsonValue>>",
|
||||
authentication_type,
|
||||
sort_priority,
|
||||
headers AS "headers!: sqlx::types::Json<Vec<HttpRequestHeader>>"
|
||||
FROM http_requests
|
||||
WHERE id = ?
|
||||
"#,
|
||||
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_if_id(
|
||||
response: HttpResponse,
|
||||
pool: &Pool<Sqlite>,
|
||||
) -> Result<HttpResponse, sqlx::Error> {
|
||||
if response.id == "" {
|
||||
return Ok(response);
|
||||
}
|
||||
return update_response(response, pool).await;
|
||||
}
|
||||
|
||||
pub async fn update_response(
|
||||
response: HttpResponse,
|
||||
pool: &Pool<Sqlite>,
|
||||
) -> 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, model, workspace_id, request_id, updated_at, created_at,
|
||||
status, status_reason, body, elapsed, url, error,
|
||||
headers AS "headers!: sqlx::types::Json<Vec<HttpResponseHeader>>"
|
||||
FROM http_responses
|
||||
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, model, workspace_id, request_id, updated_at,
|
||||
created_at, status, status_reason, body, elapsed, url, error,
|
||||
headers AS "headers!: sqlx::types::Json<Vec<HttpResponseHeader>>"
|
||||
FROM http_responses
|
||||
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<HttpResponse, sqlx::Error> {
|
||||
let resp = get_response(id, pool)
|
||||
.await
|
||||
.expect("Failed to get response to delete");
|
||||
|
||||
let _ = sqlx::query!(
|
||||
r#"
|
||||
DELETE FROM http_responses
|
||||
WHERE id = ?
|
||||
"#,
|
||||
id,
|
||||
)
|
||||
.execute(pool)
|
||||
.await;
|
||||
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
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,16 +0,0 @@
|
||||
(function (globalThis) {
|
||||
// Deno.core.print(Object.keys(Deno.core).join('\n'));
|
||||
|
||||
function argsToMessage(...args) {
|
||||
return args.map((arg) => JSON.stringify(arg)).join(' ');
|
||||
}
|
||||
|
||||
globalThis.console = {
|
||||
log: (...args) => {
|
||||
Deno.core.print(`[log]: ${argsToMessage(...args)}\n`, false);
|
||||
},
|
||||
error: (...args) => {
|
||||
Deno.core.print(`[err]: ${argsToMessage(...args)}\n`, true);
|
||||
},
|
||||
};
|
||||
})(globalThis);
|
||||
@@ -1,121 +0,0 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use deno_ast::{MediaType, ParseParams, SourceTextInfo};
|
||||
use deno_core::error::AnyError;
|
||||
use deno_core::futures::FutureExt;
|
||||
use deno_core::{op, Extension, JsRuntime, ModuleCode, ModuleSource, ModuleType, RuntimeOptions};
|
||||
use futures::executor;
|
||||
|
||||
pub fn run_plugin_sync(file_path: &str) -> Result<(), AnyError> {
|
||||
executor::block_on(run_plugin(file_path))
|
||||
}
|
||||
|
||||
pub async fn run_plugin(file_path: &str) -> Result<(), AnyError> {
|
||||
let extension = Extension::builder("runtime")
|
||||
.ops(vec![op_hello::decl()])
|
||||
.build();
|
||||
|
||||
// Initialize a runtime instance
|
||||
let mut runtime = JsRuntime::new(RuntimeOptions {
|
||||
module_loader: Some(Rc::new(TsModuleLoader)),
|
||||
extensions: vec![extension],
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
runtime
|
||||
.execute_script("<runtime>", include_str!("runtime.js"))
|
||||
.expect("Failed to execute runtime.js");
|
||||
|
||||
let current_dir = &std::env::current_dir().expect("Unable to get CWD");
|
||||
let main_module =
|
||||
deno_core::resolve_path(file_path, current_dir).expect("Failed to resolve path");
|
||||
let mod_id = runtime
|
||||
.load_main_module(&main_module, None)
|
||||
.await
|
||||
.expect("Failed to load main module");
|
||||
let result = runtime.mod_evaluate(mod_id);
|
||||
runtime
|
||||
.run_event_loop(false)
|
||||
.await
|
||||
.expect("Failed to run event loop");
|
||||
result.await?
|
||||
}
|
||||
|
||||
#[op]
|
||||
async fn op_hello(name: String) -> Result<String, AnyError> {
|
||||
let contents = format!("Hello {} from Rust!", name);
|
||||
println!("{}", contents);
|
||||
Ok(contents)
|
||||
}
|
||||
|
||||
struct TsModuleLoader;
|
||||
|
||||
impl deno_core::ModuleLoader for TsModuleLoader {
|
||||
fn resolve(
|
||||
&self,
|
||||
specifier: &str,
|
||||
referrer: &str,
|
||||
_kind: deno_core::ResolutionKind,
|
||||
) -> Result<deno_core::ModuleSpecifier, AnyError> {
|
||||
deno_core::resolve_import(specifier, referrer).map_err(|e| e.into())
|
||||
}
|
||||
|
||||
fn load(
|
||||
&self,
|
||||
module_specifier: &deno_core::ModuleSpecifier,
|
||||
_maybe_referrer: Option<deno_core::ModuleSpecifier>,
|
||||
_is_dyn_import: bool,
|
||||
) -> std::pin::Pin<Box<deno_core::ModuleSourceFuture>> {
|
||||
let module_specifier = module_specifier.clone();
|
||||
async move {
|
||||
let path = module_specifier
|
||||
.to_file_path()
|
||||
.expect("Failed to convert to file path");
|
||||
|
||||
// Determine what the MediaType is (this is done based on the file
|
||||
// extension) and whether transpiling is required.
|
||||
let media_type = MediaType::from_path(&path);
|
||||
let (module_type, should_transpile) = match media_type {
|
||||
MediaType::JavaScript | MediaType::Mjs | MediaType::Cjs => {
|
||||
(ModuleType::JavaScript, false)
|
||||
}
|
||||
MediaType::Jsx => (ModuleType::JavaScript, true),
|
||||
MediaType::TypeScript
|
||||
| MediaType::Mts
|
||||
| MediaType::Cts
|
||||
| MediaType::Dts
|
||||
| MediaType::Dmts
|
||||
| MediaType::Dcts
|
||||
| MediaType::Tsx => (ModuleType::JavaScript, true),
|
||||
MediaType::Json => (ModuleType::Json, false),
|
||||
_ => panic!("Unknown extension {:?}", path.extension()),
|
||||
};
|
||||
|
||||
// Read the file, transpile if necessary.
|
||||
let code = std::fs::read_to_string(&path)?;
|
||||
let code = if should_transpile {
|
||||
let parsed = deno_ast::parse_module(ParseParams {
|
||||
specifier: module_specifier.to_string(),
|
||||
text_info: SourceTextInfo::from_string(code),
|
||||
media_type,
|
||||
capture_tokens: false,
|
||||
scope_analysis: false,
|
||||
maybe_syntax: None,
|
||||
})?;
|
||||
parsed.transpile(&Default::default())?.text
|
||||
} else {
|
||||
code
|
||||
};
|
||||
|
||||
// Load and return module.
|
||||
let module = ModuleSource {
|
||||
code: ModuleCode::from(code),
|
||||
module_type,
|
||||
module_url_specified: module_specifier.to_string(),
|
||||
module_url_found: module_specifier.to_string(),
|
||||
};
|
||||
Ok(module)
|
||||
}
|
||||
.boxed_local()
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
use tauri::{Runtime, Window};
|
||||
|
||||
const TRAFFIC_LIGHT_OFFSET_X: f64 = 10.0;
|
||||
const TRAFFIC_LIGHT_OFFSET_Y: f64 = 18.0;
|
||||
|
||||
pub trait WindowExt {
|
||||
fn position_traffic_lights(&self);
|
||||
}
|
||||
|
||||
impl<R: Runtime> WindowExt for Window<R> {
|
||||
#[cfg(target_os = "macos")]
|
||||
fn position_traffic_lights(&self) {
|
||||
use cocoa::appkit::{NSView, NSWindow, NSWindowButton};
|
||||
use cocoa::foundation::NSRect;
|
||||
|
||||
let window = self.ns_window().unwrap() as cocoa::base::id;
|
||||
|
||||
let x = TRAFFIC_LIGHT_OFFSET_X;
|
||||
let y = TRAFFIC_LIGHT_OFFSET_Y;
|
||||
|
||||
unsafe {
|
||||
let close = window.standardWindowButton_(NSWindowButton::NSWindowCloseButton);
|
||||
let miniaturize =
|
||||
window.standardWindowButton_(NSWindowButton::NSWindowMiniaturizeButton);
|
||||
let zoom = window.standardWindowButton_(NSWindowButton::NSWindowZoomButton);
|
||||
|
||||
let title_bar_container_view = close.superview().superview();
|
||||
|
||||
let close_rect: NSRect = msg_send![close, frame];
|
||||
let button_height = close_rect.size.height;
|
||||
|
||||
let title_bar_frame_height = button_height + y;
|
||||
let mut title_bar_rect = NSView::frame(title_bar_container_view);
|
||||
title_bar_rect.size.height = title_bar_frame_height;
|
||||
title_bar_rect.origin.y = NSView::frame(window).size.height - title_bar_frame_height;
|
||||
let _: () = msg_send![title_bar_container_view, setFrame: title_bar_rect];
|
||||
|
||||
let window_buttons = vec![close, miniaturize, zoom];
|
||||
let space_between = NSView::frame(miniaturize).origin.x - NSView::frame(close).origin.x;
|
||||
|
||||
for (i, button) in window_buttons.into_iter().enumerate() {
|
||||
let mut rect: NSRect = NSView::frame(button);
|
||||
rect.origin.x = x + (i as f64 * space_between);
|
||||
button.setFrameOrigin(rect.origin);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
{
|
||||
"build": {
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devPath": "http://localhost:1420",
|
||||
"distDir": "../dist",
|
||||
"withGlobalTauri": false
|
||||
},
|
||||
"package": {
|
||||
"productName": "Yaak",
|
||||
"version": "2023.0.0"
|
||||
},
|
||||
"tauri": {
|
||||
"windows": [],
|
||||
"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,5 +0,0 @@
|
||||
<svg width="100%" height="100%" viewBox="0 0 15 15" version="1.1" xmlns="http://www.w3.org/2000/svg"
|
||||
style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<path fill="currentColor"
|
||||
d="M2.5,1C1.672,1 1,1.672 1,2.5L1,12.5C1,13.328 1.672,14 2.5,14L12.5,14C13.328,14 14,13.328 14,12.5L14,2.5C14,1.672 13.328,1 12.5,1L2.5,1ZM12.5,13C12.776,13 13,12.776 13,12.5L13,2.5C13,2.224 12.776,2 12.5,2L6,2L6,13L12.5,13ZM2.5,2L5,2L5,13L2.5,13C2.224,13 2,12.776 2,12.5L2,2.5C2,2.224 2.224,2 2.5,2Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 553 B |
@@ -1,6 +0,0 @@
|
||||
<svg width="100%" height="100%" viewBox="0 0 15 15" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<rect x="0" y="0" width="15" height="15" style="fill:none;"/>
|
||||
<g transform="matrix(1,0,0,1,-16,-8.88178e-16)">
|
||||
<path fill="currentColor" d="M18.5,1C17.672,1 17,1.672 17,2.5L17,12.5C17,13.328 17.672,14 18.5,14L28.5,14C29.328,14 30,13.328 30,12.5L30,2.5C30,1.672 29.328,1 28.5,1L18.5,1ZM28.5,13C28.776,13 29,12.776 29,12.5L29,2.5C29,2.224 28.776,2 28.5,2L22,2L22,13L28.5,13ZM18,11.535L21,12.285L21,13L18.5,13C18.224,13 18,12.776 18,12.5L18,11.535ZM18,10.504L21,11.254L21,9.81L18,9.06L18,10.504ZM18,8.029L21,8.779L21,7.327L18,6.577L18,8.029ZM18,5.546L21,6.296L21,4.833L18,4.083L18,5.546ZM21,3.802L18,3.052L18,2.5C18,2.224 18.224,2 18.5,2L21,2L21,3.802Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1006 B |
@@ -1,57 +0,0 @@
|
||||
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
import { persistQueryClient } from '@tanstack/react-query-persist-client';
|
||||
import { MotionConfig } from 'framer-motion';
|
||||
import { Suspense } from 'react';
|
||||
import { DndProvider } from 'react-dnd';
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||
import { HelmetProvider } from 'react-helmet-async';
|
||||
import { AppRouter } from './AppRouter';
|
||||
import { DialogProvider } from './DialogContext';
|
||||
import { TauriListeners } from './TauriListeners';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
logger: undefined,
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
cacheTime: 1000 * 60 * 60 * 24, // 24 hours
|
||||
networkMode: 'offlineFirst',
|
||||
|
||||
// It's a desktop app, so this isn't necessary
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const localStoragePersister = createSyncStoragePersister({
|
||||
storage: window.localStorage,
|
||||
throttleTime: 1000, // 1 second
|
||||
});
|
||||
|
||||
persistQueryClient({
|
||||
queryClient,
|
||||
persister: localStoragePersister,
|
||||
maxAge: 1000 * 60 * 60 * 24, // 24 hours
|
||||
});
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MotionConfig transition={{ duration: 0.1 }}>
|
||||
<HelmetProvider>
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<DialogProvider>
|
||||
<Suspense>
|
||||
<AppRouter />
|
||||
<TauriListeners />
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</Suspense>
|
||||
</DialogProvider>
|
||||
</DndProvider>
|
||||
</HelmetProvider>
|
||||
</MotionConfig>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import {
|
||||
createBrowserRouter,
|
||||
Navigate,
|
||||
Outlet,
|
||||
RouterProvider,
|
||||
useLocation,
|
||||
} from 'react-router-dom';
|
||||
import { routePaths } from '../hooks/useRoutes';
|
||||
import { setLastLocation } from '../lib/lastLocation';
|
||||
import RouteError from './RouteError';
|
||||
import Workspace from './Workspace';
|
||||
import Workspaces from './Workspaces';
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: '/',
|
||||
errorElement: <RouteError />,
|
||||
element: <RouterRoot />,
|
||||
children: [
|
||||
{
|
||||
path: '/',
|
||||
element: <Navigate to={routePaths.workspaces()} replace={true} />,
|
||||
},
|
||||
{
|
||||
path: routePaths.workspaces(),
|
||||
element: <Workspaces />,
|
||||
},
|
||||
{
|
||||
path: routePaths.workspace({ workspaceId: ':workspaceId' }),
|
||||
element: <Workspace />,
|
||||
},
|
||||
{
|
||||
path: routePaths.request({
|
||||
workspaceId: ':workspaceId',
|
||||
requestId: ':requestId',
|
||||
}),
|
||||
element: <Workspace />,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
export function AppRouter() {
|
||||
return <RouterProvider router={router} />;
|
||||
}
|
||||
|
||||
function RouterRoot() {
|
||||
const { pathname } = useLocation();
|
||||
useEffect(() => {
|
||||
setLastLocation(pathname).catch(console.error);
|
||||
}, [pathname]);
|
||||
return <Outlet />;
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { useUpdateRequest } from '../hooks/useUpdateRequest';
|
||||
import type { HttpRequest } from '../lib/models';
|
||||
import { Input } from './core/Input';
|
||||
import { VStack } from './core/Stacks';
|
||||
|
||||
interface Props {
|
||||
requestId: string;
|
||||
authentication: HttpRequest['authentication'];
|
||||
}
|
||||
|
||||
export function BasicAuth({ requestId, authentication }: Props) {
|
||||
const updateRequest = useUpdateRequest(requestId);
|
||||
|
||||
return (
|
||||
<VStack className="my-2" space={2}>
|
||||
<Input
|
||||
label="Username"
|
||||
name="username"
|
||||
size="sm"
|
||||
defaultValue={`${authentication.username}`}
|
||||
onChange={(username: string) => {
|
||||
updateRequest.mutate((r) => ({
|
||||
...r,
|
||||
authentication: { password: r.authentication.password, username },
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
label="Password"
|
||||
name="password"
|
||||
size="sm"
|
||||
type="password"
|
||||
defaultValue={`${authentication.password}`}
|
||||
onChange={(password: string) => {
|
||||
updateRequest.mutate((r) => ({
|
||||
...r,
|
||||
authentication: { username: r.authentication.username, password },
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { useUpdateRequest } from '../hooks/useUpdateRequest';
|
||||
import type { HttpRequest } from '../lib/models';
|
||||
import { Input } from './core/Input';
|
||||
import { VStack } from './core/Stacks';
|
||||
|
||||
interface Props {
|
||||
requestId: string;
|
||||
authentication: HttpRequest['authentication'];
|
||||
}
|
||||
|
||||
export function BearerAuth({ requestId, authentication }: Props) {
|
||||
const updateRequest = useUpdateRequest(requestId);
|
||||
|
||||
return (
|
||||
<VStack className="my-2" space={2}>
|
||||
<Input
|
||||
label="Token"
|
||||
name="token"
|
||||
size="sm"
|
||||
defaultValue={`${authentication.token}`}
|
||||
onChange={(token: string) => {
|
||||
updateRequest.mutate((r) => ({
|
||||
...r,
|
||||
authentication: { token },
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import React, { createContext, useContext, useMemo, useState } from 'react';
|
||||
import type { DialogProps } from './core/Dialog';
|
||||
import { Dialog } from './core/Dialog';
|
||||
|
||||
type DialogEntry = {
|
||||
id: string;
|
||||
render: ({ hide }: { hide: () => void }) => React.ReactNode;
|
||||
} & Pick<DialogProps, 'title' | 'description' | 'hideX' | 'className' | 'size'>;
|
||||
|
||||
type DialogEntryOptionalId = Omit<DialogEntry, 'id'> & { id?: string };
|
||||
|
||||
interface State {
|
||||
dialogs: DialogEntry[];
|
||||
actions: Actions;
|
||||
}
|
||||
|
||||
interface Actions {
|
||||
show: (d: DialogEntryOptionalId) => void;
|
||||
hide: (id: string) => void;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const DialogContext = createContext<State>({} as any);
|
||||
|
||||
export const DialogProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const [dialogs, setDialogs] = useState<State['dialogs']>([]);
|
||||
const actions = useMemo<Actions>(
|
||||
() => ({
|
||||
show: ({ id: oid, ...props }: DialogEntryOptionalId) => {
|
||||
const id = oid ?? Math.random().toString(36).slice(2);
|
||||
setDialogs((a) => [...a.filter((d) => d.id !== id), { id, ...props }]);
|
||||
},
|
||||
hide: (id: string) => {
|
||||
setDialogs((a) => a.filter((d) => d.id !== id));
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const state: State = {
|
||||
dialogs,
|
||||
actions,
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogContext.Provider value={state}>
|
||||
{children}
|
||||
{dialogs.map(({ id, render, ...props }) => (
|
||||
<Dialog open key={id} onClose={() => actions.hide(id)} {...props}>
|
||||
{render({ hide: () => actions.hide(id) })}
|
||||
</Dialog>
|
||||
))}
|
||||
</DialogContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useDialog = () => useContext(DialogContext).actions;
|
||||
@@ -1,22 +0,0 @@
|
||||
import classnames from 'classnames';
|
||||
import React, { memo } from 'react';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const DropMarker = memo(
|
||||
function DropMarker({ className }: Props) {
|
||||
return (
|
||||
<div
|
||||
className={classnames(
|
||||
className,
|
||||
'relative w-full h-0 overflow-visible pointer-events-none',
|
||||
)}
|
||||
>
|
||||
<div className="absolute z-50 left-2 right-2 -bottom-[0.1rem] h-[0.2rem] bg-blue-500/50 rounded-full" />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
() => true,
|
||||
);
|
||||
@@ -1,13 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function EmptyStateText({ children }: Props) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-highlight h-full text-gray-400 flex items-center justify-center">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import { updateSchema } from 'cm6-graphql';
|
||||
import type { EditorView } from 'codemirror';
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useIntrospectGraphQL } from '../hooks/useIntrospectGraphQL';
|
||||
import type { HttpRequest } from '../lib/models';
|
||||
import { Button } from './core/Button';
|
||||
import type { EditorProps } from './core/Editor';
|
||||
import { Editor, formatGraphQL } from './core/Editor';
|
||||
import { Separator } from './core/Separator';
|
||||
import { useDialog } from './DialogContext';
|
||||
|
||||
type Props = Pick<
|
||||
EditorProps,
|
||||
'heightMode' | 'onChange' | 'defaultValue' | 'className' | 'forceUpdateKey'
|
||||
> & {
|
||||
baseRequest: HttpRequest;
|
||||
};
|
||||
|
||||
interface GraphQLBody {
|
||||
query: string;
|
||||
variables?: Record<string, string | number | boolean | null>;
|
||||
operationName?: string;
|
||||
}
|
||||
|
||||
export function GraphQLEditor({ defaultValue, onChange, baseRequest, ...extraEditorProps }: Props) {
|
||||
const editorViewRef = useRef<EditorView>(null);
|
||||
const introspection = useIntrospectGraphQL(baseRequest);
|
||||
|
||||
const { query, variables } = useMemo<GraphQLBody>(() => {
|
||||
if (defaultValue === undefined) {
|
||||
return { query: '', variables: {} };
|
||||
}
|
||||
try {
|
||||
const p = JSON.parse(defaultValue ?? '{}');
|
||||
const query = p.query ?? '';
|
||||
const variables = p.variables;
|
||||
const operationName = p.operationName;
|
||||
return { query, variables, operationName };
|
||||
} catch (err) {
|
||||
return { query: 'failed to parse' };
|
||||
}
|
||||
}, [defaultValue]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(b: GraphQLBody) => onChange?.(JSON.stringify(b, null, 2)),
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleChangeQuery = useCallback(
|
||||
(query: string) => handleChange({ query, variables }),
|
||||
[handleChange, variables],
|
||||
);
|
||||
|
||||
const handleChangeVariables = useCallback(
|
||||
(variables: string) => {
|
||||
try {
|
||||
handleChange({ query, variables: JSON.parse(variables) });
|
||||
} catch (e) {
|
||||
// Meh, not much we can do here
|
||||
}
|
||||
},
|
||||
[handleChange, query],
|
||||
);
|
||||
|
||||
// Refetch the schema when the URL changes
|
||||
useEffect(() => {
|
||||
if (editorViewRef.current === null) return;
|
||||
updateSchema(editorViewRef.current, introspection.data);
|
||||
}, [introspection.data]);
|
||||
|
||||
const dialog = useDialog();
|
||||
|
||||
return (
|
||||
<div className="pb-2 h-full grid grid-rows-[minmax(0,100%)_auto_auto_minmax(0,auto)]">
|
||||
<Editor
|
||||
contentType="application/graphql"
|
||||
defaultValue={query ?? ''}
|
||||
format={formatGraphQL}
|
||||
heightMode="auto"
|
||||
onChange={handleChangeQuery}
|
||||
placeholder="..."
|
||||
ref={editorViewRef}
|
||||
actions={
|
||||
(introspection.error || introspection.isLoading) && (
|
||||
<Button
|
||||
size="xs"
|
||||
color={introspection.error ? 'danger' : 'gray'}
|
||||
isLoading={introspection.isLoading}
|
||||
onClick={() => {
|
||||
dialog.show({
|
||||
title: 'Introspection Failed',
|
||||
size: 'sm',
|
||||
render: () => (
|
||||
<div className="whitespace-pre-wrap">{introspection.error?.message}</div>
|
||||
),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{introspection.error ? 'Introspection Failed' : 'Introspecting'}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
{...extraEditorProps}
|
||||
/>
|
||||
<Separator variant="primary" />
|
||||
<p className="pt-1 text-gray-500 text-sm">Variables</p>
|
||||
<Editor
|
||||
contentType="application/json"
|
||||
defaultValue={JSON.stringify(variables, null, 2)}
|
||||
heightMode="auto"
|
||||
onChange={handleChangeVariables}
|
||||
placeholder="{}"
|
||||
useTemplating
|
||||
{...extraEditorProps}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { charsets } from '../lib/data/charsets';
|
||||
import { connections } from '../lib/data/connections';
|
||||
import { encodings } from '../lib/data/encodings';
|
||||
import { headerNames } from '../lib/data/headerNames';
|
||||
import { mimeTypes } from '../lib/data/mimetypes';
|
||||
import type { HttpRequest } from '../lib/models';
|
||||
import type { GenericCompletionConfig } from './core/Editor/genericCompletion';
|
||||
import type { PairEditorProps } from './core/PairEditor';
|
||||
import { PairEditor } from './core/PairEditor';
|
||||
|
||||
type Props = {
|
||||
forceUpdateKey: string;
|
||||
headers: HttpRequest['headers'];
|
||||
onChange: (headers: HttpRequest['headers']) => void;
|
||||
};
|
||||
|
||||
export function HeaderEditor({ headers, onChange, forceUpdateKey }: Props) {
|
||||
return (
|
||||
<PairEditor
|
||||
pairs={headers}
|
||||
onChange={onChange}
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
nameValidate={validateHttpHeader}
|
||||
nameAutocomplete={nameAutocomplete}
|
||||
valueAutocomplete={valueAutocomplete}
|
||||
namePlaceholder="Header-Name"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const MIN_MATCH = 3;
|
||||
|
||||
const headerOptionsMap: Record<string, string[]> = {
|
||||
'content-type': mimeTypes,
|
||||
accept: ['*/*', ...mimeTypes],
|
||||
'accept-encoding': encodings,
|
||||
connection: connections,
|
||||
'accept-charset': charsets,
|
||||
};
|
||||
|
||||
const valueAutocomplete = (headerName: string): GenericCompletionConfig | undefined => {
|
||||
const name = headerName.toLowerCase().trim();
|
||||
const options: GenericCompletionConfig['options'] =
|
||||
headerOptionsMap[name]?.map((o) => ({
|
||||
label: o,
|
||||
type: 'constant',
|
||||
boost: 1, // Put above other completions
|
||||
})) ?? [];
|
||||
return { minMatch: MIN_MATCH, options };
|
||||
};
|
||||
|
||||
const nameAutocomplete: PairEditorProps['nameAutocomplete'] = {
|
||||
minMatch: MIN_MATCH,
|
||||
options: headerNames.map((t) => ({
|
||||
label: t,
|
||||
type: 'constant',
|
||||
boost: 1, // Put above other completions
|
||||
})),
|
||||
};
|
||||
|
||||
const validateHttpHeader = (v: string) => {
|
||||
if (v === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return v.match(/^[a-zA-Z0-9-_]+$/) !== null;
|
||||
};
|
||||
@@ -1,46 +0,0 @@
|
||||
import classnames from 'classnames';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Portal } from './Portal';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
portalName: string;
|
||||
open: boolean;
|
||||
onClose?: () => void;
|
||||
zIndex?: keyof typeof zIndexes;
|
||||
}
|
||||
|
||||
const zIndexes: Record<number, string> = {
|
||||
10: 'z-10',
|
||||
20: 'z-20',
|
||||
30: 'z-30',
|
||||
40: 'z-40',
|
||||
50: 'z-50',
|
||||
};
|
||||
|
||||
export function Overlay({ zIndex = 30, open, onClose, portalName, children }: Props) {
|
||||
return (
|
||||
<Portal name={portalName}>
|
||||
{open && (
|
||||
<FocusTrap>
|
||||
<motion.div
|
||||
className={classnames('fixed inset-0', zIndexes[zIndex])}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
onClick={onClose}
|
||||
className="absolute inset-0 bg-gray-600/60 dark:bg-black/50"
|
||||
/>
|
||||
{/* Add region to still be able to drag the window */}
|
||||
<div data-tauri-drag-region className="absolute top-0 left-0 right-0 h-md" />
|
||||
{children}
|
||||
</motion.div>
|
||||
</FocusTrap>
|
||||
)}
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { HttpRequest } from '../lib/models';
|
||||
import { PairEditor } from './core/PairEditor';
|
||||
|
||||
type Props = {
|
||||
forceUpdateKey: string;
|
||||
parameters: { name: string; value: string }[];
|
||||
onChange: (headers: HttpRequest['headers']) => void;
|
||||
};
|
||||
|
||||
export function ParametersEditor({ parameters, forceUpdateKey, onChange }: Props) {
|
||||
return (
|
||||
<PairEditor
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
pairs={parameters}
|
||||
onChange={onChange}
|
||||
namePlaceholder="name"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { usePortal } from '../hooks/usePortal';
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export function Portal({ children, name }: Props) {
|
||||
const portal = usePortal(name);
|
||||
return createPortal(children, portal);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import type { HTMLAttributes, ReactElement } from 'react';
|
||||
import { useConfirm } from '../hooks/useConfirm';
|
||||
import { useDeleteRequest } from '../hooks/useDeleteRequest';
|
||||
import { useDuplicateRequest } from '../hooks/useDuplicateRequest';
|
||||
import { useRequest } from '../hooks/useRequest';
|
||||
import { Dropdown } from './core/Dropdown';
|
||||
import { Icon } from './core/Icon';
|
||||
import { InlineCode } from './core/InlineCode';
|
||||
|
||||
interface Props {
|
||||
requestId: string;
|
||||
children: ReactElement<HTMLAttributes<HTMLButtonElement>>;
|
||||
}
|
||||
|
||||
export function RequestActionsDropdown({ requestId, children }: Props) {
|
||||
const request = useRequest(requestId ?? null);
|
||||
const deleteRequest = useDeleteRequest(requestId ?? null);
|
||||
const duplicateRequest = useDuplicateRequest({ id: requestId, navigateAfter: true });
|
||||
const confirm = useConfirm();
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
label: 'Duplicate',
|
||||
onSelect: duplicateRequest.mutate,
|
||||
leftSlot: <Icon icon="copy" />,
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
onSelect: async () => {
|
||||
const confirmed = await confirm({
|
||||
title: 'Delete Request',
|
||||
variant: 'delete',
|
||||
description: (
|
||||
<>
|
||||
Are you sure you want to delete <InlineCode>{request?.name}</InlineCode>?
|
||||
</>
|
||||
),
|
||||
});
|
||||
if (confirmed) {
|
||||
deleteRequest.mutate();
|
||||
}
|
||||
},
|
||||
leftSlot: <Icon icon="trash" />,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { memo } from 'react';
|
||||
import { Button } from './core/Button';
|
||||
import { RadioDropdown } from './core/RadioDropdown';
|
||||
|
||||
type Props = {
|
||||
method: string;
|
||||
className?: string;
|
||||
onChange: (method: string) => void;
|
||||
};
|
||||
|
||||
const methodItems = ['GET', 'PUT', 'POST', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD'].map((m) => ({
|
||||
value: m,
|
||||
label: m,
|
||||
}));
|
||||
|
||||
export const RequestMethodDropdown = memo(function RequestMethodDropdown({
|
||||
method,
|
||||
onChange,
|
||||
className,
|
||||
}: Props) {
|
||||
return (
|
||||
<RadioDropdown value={method} items={methodItems} onChange={onChange}>
|
||||
<Button size="xs" className={className}>
|
||||
{method.toUpperCase()}
|
||||
</Button>
|
||||
</RadioDropdown>
|
||||
);
|
||||
});
|
||||
@@ -1,241 +0,0 @@
|
||||
import { invoke } from '@tauri-apps/api';
|
||||
import { appWindow } from '@tauri-apps/api/window';
|
||||
import classnames from 'classnames';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { createGlobalState } from 'react-use';
|
||||
import { useActiveRequest } from '../hooks/useActiveRequest';
|
||||
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
|
||||
import { useTauriEvent } from '../hooks/useTauriEvent';
|
||||
import { useUpdateRequest } from '../hooks/useUpdateRequest';
|
||||
import { tryFormatJson } from '../lib/formatters';
|
||||
import type { HttpHeader, HttpRequest } from '../lib/models';
|
||||
import {
|
||||
AUTH_TYPE_BASIC,
|
||||
AUTH_TYPE_BEARER,
|
||||
AUTH_TYPE_NONE,
|
||||
BODY_TYPE_GRAPHQL,
|
||||
BODY_TYPE_JSON,
|
||||
BODY_TYPE_NONE,
|
||||
BODY_TYPE_XML,
|
||||
} from '../lib/models';
|
||||
import { BasicAuth } from './BasicAuth';
|
||||
import { BearerAuth } from './BearerAuth';
|
||||
import { CountBadge } from './core/CountBadge';
|
||||
import { Editor } from './core/Editor';
|
||||
import type { TabItem } from './core/Tabs/Tabs';
|
||||
import { TabContent, Tabs } from './core/Tabs/Tabs';
|
||||
import { EmptyStateText } from './EmptyStateText';
|
||||
import { GraphQLEditor } from './GraphQLEditor';
|
||||
import { HeaderEditor } from './HeaderEditor';
|
||||
import { ParametersEditor } from './ParameterEditor';
|
||||
import { UrlBar } from './UrlBar';
|
||||
|
||||
interface Props {
|
||||
style?: CSSProperties;
|
||||
fullHeight: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const useActiveTab = createGlobalState<string>('body');
|
||||
|
||||
export const RequestPane = memo(function RequestPane({ style, fullHeight, className }: Props) {
|
||||
const activeRequest = useActiveRequest();
|
||||
const activeRequestId = activeRequest?.id ?? null;
|
||||
const updateRequest = useUpdateRequest(activeRequestId);
|
||||
const [activeTab, setActiveTab] = useActiveTab();
|
||||
const [forceUpdateHeaderEditorKey, setForceUpdateHeaderEditorKey] = useState<number>(0);
|
||||
const { updateKey: forceUpdateKey } = useRequestUpdateKey(activeRequest?.id ?? null);
|
||||
|
||||
const tabs: TabItem[] = useMemo(
|
||||
() =>
|
||||
activeRequest === null
|
||||
? []
|
||||
: [
|
||||
{
|
||||
value: 'body',
|
||||
options: {
|
||||
value: activeRequest.bodyType,
|
||||
items: [
|
||||
{ label: 'JSON', value: BODY_TYPE_JSON },
|
||||
{ label: 'XML', value: BODY_TYPE_XML },
|
||||
{ label: 'GraphQL', value: BODY_TYPE_GRAPHQL },
|
||||
{ type: 'separator' },
|
||||
{ label: 'No Body', shortLabel: 'Body', value: BODY_TYPE_NONE },
|
||||
],
|
||||
onChange: async (bodyType) => {
|
||||
const patch: Partial<HttpRequest> = { bodyType };
|
||||
if (bodyType === BODY_TYPE_NONE) {
|
||||
patch.headers = activeRequest?.headers.filter(
|
||||
(h) => h.name.toLowerCase() !== 'content-type',
|
||||
);
|
||||
} else if (bodyType == BODY_TYPE_GRAPHQL || bodyType === BODY_TYPE_JSON) {
|
||||
patch.method = 'POST';
|
||||
patch.headers = [
|
||||
...(activeRequest?.headers.filter(
|
||||
(h) => h.name.toLowerCase() !== 'content-type',
|
||||
) ?? []),
|
||||
{
|
||||
name: 'Content-Type',
|
||||
value: 'application/json',
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Force update header editor so any changed headers are reflected
|
||||
setTimeout(() => setForceUpdateHeaderEditorKey((u) => u + 1), 100);
|
||||
|
||||
await updateRequest.mutate(patch);
|
||||
},
|
||||
},
|
||||
},
|
||||
// { value: 'params', label: 'URL Params' },
|
||||
{
|
||||
value: 'headers',
|
||||
label: (
|
||||
<div className="flex items-center">
|
||||
Headers
|
||||
<CountBadge count={activeRequest.headers.filter((h) => h.name).length} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'auth',
|
||||
label: 'Auth',
|
||||
options: {
|
||||
value: activeRequest.authenticationType,
|
||||
items: [
|
||||
{ label: 'Basic Auth', shortLabel: 'Basic', value: AUTH_TYPE_BASIC },
|
||||
{ label: 'Bearer Token', shortLabel: 'Bearer', value: AUTH_TYPE_BEARER },
|
||||
{ type: 'separator' },
|
||||
{ label: 'No Authentication', shortLabel: 'Auth', value: AUTH_TYPE_NONE },
|
||||
],
|
||||
onChange: async (authenticationType) => {
|
||||
let authentication: HttpRequest['authentication'] = activeRequest?.authentication;
|
||||
if (authenticationType === AUTH_TYPE_BASIC) {
|
||||
authentication = {
|
||||
username: authentication.username ?? '',
|
||||
password: authentication.password ?? '',
|
||||
};
|
||||
} else if (authenticationType === AUTH_TYPE_BEARER) {
|
||||
authentication = {
|
||||
token: authentication.token ?? '',
|
||||
};
|
||||
}
|
||||
await updateRequest.mutate({ authenticationType, authentication });
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
[activeRequest, updateRequest],
|
||||
);
|
||||
|
||||
const handleBodyChange = useCallback(
|
||||
(body: string) => updateRequest.mutate({ body }),
|
||||
[updateRequest],
|
||||
);
|
||||
const handleHeadersChange = useCallback(
|
||||
(headers: HttpHeader[]) => updateRequest.mutate({ headers }),
|
||||
[updateRequest],
|
||||
);
|
||||
|
||||
useTauriEvent(
|
||||
'send_request',
|
||||
async ({ windowLabel }) => {
|
||||
if (windowLabel !== appWindow.label) return;
|
||||
await invoke('send_request', { requestId: activeRequestId });
|
||||
},
|
||||
[activeRequestId],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={style}
|
||||
className={classnames(className, 'h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1')}
|
||||
>
|
||||
{activeRequest && (
|
||||
<>
|
||||
<UrlBar id={activeRequest.id} url={activeRequest.url} method={activeRequest.method} />
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
label="Request"
|
||||
onChangeValue={setActiveTab}
|
||||
tabs={tabs}
|
||||
tabListClassName="mt-2"
|
||||
>
|
||||
<TabContent value="auth">
|
||||
{activeRequest.authenticationType === AUTH_TYPE_BASIC ? (
|
||||
<BasicAuth
|
||||
key={forceUpdateKey}
|
||||
requestId={activeRequest.id}
|
||||
authentication={activeRequest.authentication}
|
||||
/>
|
||||
) : activeRequest.authenticationType === AUTH_TYPE_BEARER ? (
|
||||
<BearerAuth
|
||||
key={forceUpdateKey}
|
||||
requestId={activeRequest.id}
|
||||
authentication={activeRequest.authentication}
|
||||
/>
|
||||
) : (
|
||||
<EmptyStateText>
|
||||
No Authentication {activeRequest.authenticationType}
|
||||
</EmptyStateText>
|
||||
)}
|
||||
</TabContent>
|
||||
<TabContent value="headers">
|
||||
<HeaderEditor
|
||||
forceUpdateKey={`${forceUpdateHeaderEditorKey}::${forceUpdateKey}`}
|
||||
headers={activeRequest.headers}
|
||||
onChange={handleHeadersChange}
|
||||
/>
|
||||
</TabContent>
|
||||
<TabContent value="params">
|
||||
<ParametersEditor
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
parameters={[]}
|
||||
onChange={() => null}
|
||||
/>
|
||||
</TabContent>
|
||||
<TabContent value="body">
|
||||
{activeRequest.bodyType === BODY_TYPE_JSON ? (
|
||||
<Editor
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
useTemplating
|
||||
placeholder="..."
|
||||
className="!bg-gray-50"
|
||||
heightMode={fullHeight ? 'full' : 'auto'}
|
||||
defaultValue={activeRequest.body ?? ''}
|
||||
contentType="application/json"
|
||||
onChange={handleBodyChange}
|
||||
format={(v) => tryFormatJson(v)}
|
||||
/>
|
||||
) : activeRequest.bodyType === BODY_TYPE_XML ? (
|
||||
<Editor
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
useTemplating
|
||||
placeholder="..."
|
||||
className="!bg-gray-50"
|
||||
heightMode={fullHeight ? 'full' : 'auto'}
|
||||
defaultValue={activeRequest.body ?? ''}
|
||||
contentType="text/xml"
|
||||
onChange={handleBodyChange}
|
||||
/>
|
||||
) : activeRequest.bodyType === BODY_TYPE_GRAPHQL ? (
|
||||
<GraphQLEditor
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
baseRequest={activeRequest}
|
||||
className="!bg-gray-50"
|
||||
defaultValue={activeRequest?.body ?? ''}
|
||||
onChange={handleBodyChange}
|
||||
/>
|
||||
) : (
|
||||
<EmptyStateText>No Body</EmptyStateText>
|
||||
)}
|
||||
</TabContent>
|
||||
</Tabs>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,131 +0,0 @@
|
||||
import useResizeObserver from '@react-hook/resize-observer';
|
||||
import classnames from 'classnames';
|
||||
import type { CSSProperties, MouseEvent as ReactMouseEvent } from 'react';
|
||||
import React, { memo, useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useKeyValue } from '../hooks/useKeyValue';
|
||||
import { clamp } from '../lib/clamp';
|
||||
import { RequestPane } from './RequestPane';
|
||||
import { ResizeHandle } from './ResizeHandle';
|
||||
import { ResponsePane } from './ResponsePane';
|
||||
|
||||
interface Props {
|
||||
style: CSSProperties;
|
||||
}
|
||||
|
||||
const rqst = { gridArea: 'rqst' };
|
||||
const resp = { gridArea: 'resp' };
|
||||
const drag = { gridArea: 'drag' };
|
||||
|
||||
const DEFAULT = 0.5;
|
||||
const MIN_WIDTH_PX = 10;
|
||||
const MIN_HEIGHT_PX = 30;
|
||||
const STACK_VERTICAL_WIDTH = 650;
|
||||
|
||||
export const RequestResponse = memo(function RequestResponse({ style }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [vertical, setVertical] = useState<boolean>(false);
|
||||
const widthKv = useKeyValue<number>({ key: 'body_width', defaultValue: DEFAULT });
|
||||
const heightKv = useKeyValue<number>({ key: 'body_height', defaultValue: DEFAULT });
|
||||
const width = widthKv.value ?? DEFAULT;
|
||||
const height = heightKv.value ?? DEFAULT;
|
||||
const [isResizing, setIsResizing] = useState<boolean>(false);
|
||||
const moveState = useRef<{ move: (e: MouseEvent) => void; up: (e: MouseEvent) => void } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
useResizeObserver(containerRef, ({ contentRect }) => {
|
||||
setVertical(contentRect.width < STACK_VERTICAL_WIDTH);
|
||||
});
|
||||
|
||||
const styles = useMemo<CSSProperties>(
|
||||
() => ({
|
||||
...style,
|
||||
gridTemplate: vertical
|
||||
? `
|
||||
' ${rqst.gridArea}' minmax(0,${1 - height}fr)
|
||||
' ${drag.gridArea}' 0
|
||||
' ${resp.gridArea}' minmax(0,${height}fr)
|
||||
/ 1fr
|
||||
`
|
||||
: `
|
||||
' ${rqst.gridArea} ${drag.gridArea} ${resp.gridArea}' minmax(0,1fr)
|
||||
/ ${1 - width}fr 0 ${width}fr
|
||||
`,
|
||||
}),
|
||||
[vertical, width, height, style],
|
||||
);
|
||||
|
||||
const unsub = () => {
|
||||
if (moveState.current !== null) {
|
||||
document.documentElement.removeEventListener('mousemove', moveState.current.move);
|
||||
document.documentElement.removeEventListener('mouseup', moveState.current.up);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = useCallback(
|
||||
() => (vertical ? heightKv.set(DEFAULT) : widthKv.set(DEFAULT)),
|
||||
[heightKv, vertical, widthKv],
|
||||
);
|
||||
|
||||
const handleResizeStart = useCallback(
|
||||
(e: ReactMouseEvent<HTMLDivElement>) => {
|
||||
if (containerRef.current === null) return;
|
||||
unsub();
|
||||
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
|
||||
const mouseStartX = e.clientX;
|
||||
const mouseStartY = e.clientY;
|
||||
const startWidth = containerRect.width * width;
|
||||
const startHeight = containerRect.height * height;
|
||||
|
||||
moveState.current = {
|
||||
move: (e: MouseEvent) => {
|
||||
e.preventDefault(); // Prevent text selection and things
|
||||
if (vertical) {
|
||||
const maxHeightPx = containerRect.height - MIN_HEIGHT_PX;
|
||||
const newHeightPx = clamp(
|
||||
startHeight - (e.clientY - mouseStartY),
|
||||
MIN_HEIGHT_PX,
|
||||
maxHeightPx,
|
||||
);
|
||||
heightKv.set(newHeightPx / containerRect.height);
|
||||
} else {
|
||||
const maxWidthPx = containerRect.width - MIN_WIDTH_PX;
|
||||
const newWidthPx = clamp(
|
||||
startWidth - (e.clientX - mouseStartX),
|
||||
MIN_WIDTH_PX,
|
||||
maxWidthPx,
|
||||
);
|
||||
widthKv.set(newWidthPx / containerRect.width);
|
||||
}
|
||||
},
|
||||
up: (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
unsub();
|
||||
setIsResizing(false);
|
||||
},
|
||||
};
|
||||
document.documentElement.addEventListener('mousemove', moveState.current.move);
|
||||
document.documentElement.addEventListener('mouseup', moveState.current.up);
|
||||
setIsResizing(true);
|
||||
},
|
||||
[width, height, vertical, heightKv, widthKv],
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="grid gap-1.5 w-full h-full p-3" style={styles}>
|
||||
<RequestPane style={rqst} fullHeight={!vertical} />
|
||||
<ResizeHandle
|
||||
style={drag}
|
||||
isResizing={isResizing}
|
||||
className={classnames(vertical ? 'translate-y-0.5' : 'translate-x-0.5')}
|
||||
onResizeStart={handleResizeStart}
|
||||
onReset={handleReset}
|
||||
side={vertical ? 'top' : 'left'}
|
||||
justify="center"
|
||||
/>
|
||||
<ResponsePane style={resp} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,57 +0,0 @@
|
||||
import classnames from 'classnames';
|
||||
import type { CSSProperties, MouseEvent as ReactMouseEvent } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
interface ResizeBarProps {
|
||||
style?: CSSProperties;
|
||||
className?: string;
|
||||
barClassName?: string;
|
||||
isResizing: boolean;
|
||||
onResizeStart: (e: ReactMouseEvent<HTMLDivElement>) => void;
|
||||
onReset?: () => void;
|
||||
side: 'left' | 'right' | 'top';
|
||||
justify: 'center' | 'end' | 'start';
|
||||
}
|
||||
|
||||
export function ResizeHandle({
|
||||
style,
|
||||
justify,
|
||||
className,
|
||||
onResizeStart,
|
||||
onReset,
|
||||
isResizing,
|
||||
side,
|
||||
}: ResizeBarProps) {
|
||||
const vertical = side === 'top';
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
draggable
|
||||
style={style}
|
||||
className={classnames(
|
||||
className,
|
||||
'group z-10 flex cursor-ew-resize',
|
||||
vertical ? 'w-full h-3 cursor-ns-resize' : 'h-full w-3 cursor-ew-resize',
|
||||
justify === 'center' && 'justify-center',
|
||||
justify === 'end' && 'justify-end',
|
||||
justify === 'start' && 'justify-start',
|
||||
side === 'right' && 'right-0',
|
||||
side === 'left' && 'left-0',
|
||||
side === 'top' && 'top-0',
|
||||
)}
|
||||
onDragStart={onResizeStart}
|
||||
onDoubleClick={onReset}
|
||||
>
|
||||
{/* Show global overlay with cursor style to ensure cursor remains the same when moving quickly */}
|
||||
{isResizing && (
|
||||
<div
|
||||
className={classnames(
|
||||
'fixed -left-20 -right-20 -top-20 -bottom-20 cursor-ew-resize',
|
||||
vertical && 'cursor-ns-resize',
|
||||
!vertical && 'cursor-ew-resize',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import classnames from 'classnames';
|
||||
import type { HttpResponse } from '../lib/models';
|
||||
import { HStack } from './core/Stacks';
|
||||
|
||||
interface Props {
|
||||
headers: HttpResponse['headers'];
|
||||
}
|
||||
|
||||
export function ResponseHeaders({ headers }: Props) {
|
||||
return (
|
||||
<dl className="text-xs w-full font-mono">
|
||||
{headers.map((h, i) => {
|
||||
return (
|
||||
<HStack
|
||||
space={3}
|
||||
key={i}
|
||||
className={classnames(i > 0 && 'border-t border-highlightSecondary', 'py-1')}
|
||||
>
|
||||
<dd className="w-1/3 text-violet-600 select-text cursor-text">{h.name}</dd>
|
||||
<dt className="w-2/3 select-text cursor-text break-all">{h.value}</dt>
|
||||
</HStack>
|
||||
);
|
||||
})}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
import classnames from 'classnames';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { memo, useEffect, useMemo, useState } from 'react';
|
||||
import { createGlobalState } from 'react-use';
|
||||
import { useActiveRequestId } from '../hooks/useActiveRequestId';
|
||||
import { useDeleteResponse } from '../hooks/useDeleteResponse';
|
||||
import { useDeleteResponses } from '../hooks/useDeleteResponses';
|
||||
import { useResponses } from '../hooks/useResponses';
|
||||
import { useResponseViewMode } from '../hooks/useResponseViewMode';
|
||||
import { tryFormatJson } from '../lib/formatters';
|
||||
import type { HttpResponse } from '../lib/models';
|
||||
import { pluralize } from '../lib/pluralize';
|
||||
import { Banner } from './core/Banner';
|
||||
import { CountBadge } from './core/CountBadge';
|
||||
import { Dropdown } from './core/Dropdown';
|
||||
import { Editor } from './core/Editor';
|
||||
import { Icon } from './core/Icon';
|
||||
import { IconButton } from './core/IconButton';
|
||||
import { HStack } from './core/Stacks';
|
||||
import { StatusColor } from './core/StatusColor';
|
||||
import { TabContent, Tabs } from './core/Tabs/Tabs';
|
||||
import { Webview } from './core/Webview';
|
||||
import { EmptyStateText } from './EmptyStateText';
|
||||
import { ResponseHeaders } from './ResponseHeaders';
|
||||
|
||||
interface Props {
|
||||
style?: CSSProperties;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const useActiveTab = createGlobalState<string>('body');
|
||||
|
||||
export const ResponsePane = memo(function ResponsePane({ style, className }: Props) {
|
||||
const [pinnedResponseId, setPinnedResponseId] = useState<string | null>(null);
|
||||
const activeRequestId = useActiveRequestId();
|
||||
const responses = useResponses(activeRequestId);
|
||||
const activeResponse: HttpResponse | null = pinnedResponseId
|
||||
? responses.find((r) => r.id === pinnedResponseId) ?? null
|
||||
: responses[responses.length - 1] ?? null;
|
||||
const [viewMode, toggleViewMode] = useResponseViewMode(activeResponse?.requestId);
|
||||
const deleteResponse = useDeleteResponse(activeResponse?.id ?? null);
|
||||
const deleteAllResponses = useDeleteResponses(activeResponse?.requestId);
|
||||
const [activeTab, setActiveTab] = useActiveTab();
|
||||
|
||||
// Unset pinned response when a new one comes in
|
||||
useEffect(() => setPinnedResponseId(null), [responses.length]);
|
||||
|
||||
const contentType = useMemo(
|
||||
() =>
|
||||
activeResponse?.headers.find((h) => h.name.toLowerCase() === 'content-type')?.value ??
|
||||
'text/plain',
|
||||
[activeResponse],
|
||||
);
|
||||
|
||||
const tabs = useMemo(
|
||||
() => [
|
||||
{ label: 'Body', value: 'body' },
|
||||
{
|
||||
label: (
|
||||
<div className="flex items-center">
|
||||
Headers
|
||||
<CountBadge
|
||||
count={activeResponse?.headers.filter((h) => h.name && h.value).length ?? 0}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
value: 'headers',
|
||||
},
|
||||
],
|
||||
[activeResponse?.headers],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={style}
|
||||
className={classnames(
|
||||
className,
|
||||
'bg-gray-50 max-h-full h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1',
|
||||
'dark:bg-gray-100 rounded-md border border-highlight',
|
||||
'shadow shadow-gray-100 dark:shadow-gray-0 relative',
|
||||
)}
|
||||
>
|
||||
<HStack
|
||||
alignItems="center"
|
||||
className={classnames(
|
||||
'italic text-gray-700 text-sm w-full flex-shrink-0',
|
||||
// Remove a bit of space because the tabs have lots too
|
||||
'-mb-1.5',
|
||||
)}
|
||||
>
|
||||
{activeResponse && (
|
||||
<>
|
||||
<div className="whitespace-nowrap p-3 py-2">
|
||||
<StatusColor statusCode={activeResponse.status}>
|
||||
{activeResponse.status}
|
||||
{activeResponse.statusReason && ` ${activeResponse.statusReason}`}
|
||||
</StatusColor>
|
||||
•
|
||||
{activeResponse.elapsed}ms •
|
||||
{Math.round(activeResponse.body.length / 1000)} KB
|
||||
</div>
|
||||
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
label: viewMode === 'pretty' ? 'View Raw' : 'View Prettified',
|
||||
onSelect: toggleViewMode,
|
||||
},
|
||||
{ type: 'separator', label: 'Actions' },
|
||||
{
|
||||
label: 'Clear Response',
|
||||
onSelect: deleteResponse.mutate,
|
||||
disabled: responses.length === 0,
|
||||
},
|
||||
{
|
||||
label: `Clear ${responses.length} ${pluralize('Response', responses.length)}`,
|
||||
onSelect: deleteAllResponses.mutate,
|
||||
hidden: responses.length <= 1,
|
||||
disabled: responses.length === 0,
|
||||
},
|
||||
{ type: 'separator', label: 'History' },
|
||||
...responses.slice(0, 10).map((r) => ({
|
||||
label: r.status + ' - ' + r.elapsed + ' ms',
|
||||
leftSlot: activeResponse?.id === r.id ? <Icon icon="check" /> : <></>,
|
||||
onSelect: () => setPinnedResponseId(r.id),
|
||||
})),
|
||||
]}
|
||||
>
|
||||
<IconButton
|
||||
title="Show response history"
|
||||
icon="triangleDown"
|
||||
className="ml-auto"
|
||||
size="sm"
|
||||
iconSize="md"
|
||||
/>
|
||||
</Dropdown>
|
||||
</>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{activeResponse?.error ? (
|
||||
<Banner className="m-2">{activeResponse.error}</Banner>
|
||||
) : (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChangeValue={setActiveTab}
|
||||
label="Response"
|
||||
className="px-3"
|
||||
tabs={tabs}
|
||||
>
|
||||
<TabContent value="body">
|
||||
{activeResponse === null ? (
|
||||
<EmptyStateText>No Response</EmptyStateText>
|
||||
) : viewMode === 'pretty' && contentType.includes('html') ? (
|
||||
<Webview
|
||||
body={activeResponse.body}
|
||||
contentType={contentType}
|
||||
url={activeResponse.url}
|
||||
/>
|
||||
) : viewMode === 'pretty' && contentType.includes('json') ? (
|
||||
<Editor
|
||||
readOnly
|
||||
forceUpdateKey={`pretty::${activeResponse.updatedAt}`}
|
||||
className="bg-gray-50 dark:!bg-gray-100"
|
||||
defaultValue={tryFormatJson(activeResponse?.body)}
|
||||
contentType={contentType}
|
||||
/>
|
||||
) : activeResponse?.body ? (
|
||||
<Editor
|
||||
readOnly
|
||||
forceUpdateKey={activeResponse.updatedAt}
|
||||
className="bg-gray-50 dark:!bg-gray-100"
|
||||
defaultValue={activeResponse?.body}
|
||||
contentType={contentType}
|
||||
/>
|
||||
) : null}
|
||||
</TabContent>
|
||||
<TabContent value="headers">
|
||||
<ResponseHeaders headers={activeResponse?.headers ?? []} />
|
||||
</TabContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,24 +0,0 @@
|
||||
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);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const message = (error as any).message ?? stringified;
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,295 +0,0 @@
|
||||
import classnames from 'classnames';
|
||||
import type { ForwardedRef, KeyboardEvent } from 'react';
|
||||
import React, { forwardRef, Fragment, memo, useCallback, useMemo, useRef, useState } from 'react';
|
||||
import type { XYCoord } from 'react-dnd';
|
||||
import { useDrag, useDrop } from 'react-dnd';
|
||||
import { useActiveRequest } from '../hooks/useActiveRequest';
|
||||
import { useRequests } from '../hooks/useRequests';
|
||||
import { useUpdateAnyRequest } from '../hooks/useUpdateAnyRequest';
|
||||
import { useUpdateRequest } from '../hooks/useUpdateRequest';
|
||||
import type { HttpRequest } from '../lib/models';
|
||||
import { Button } from './core/Button';
|
||||
import { IconButton } from './core/IconButton';
|
||||
import { HStack, VStack } from './core/Stacks';
|
||||
import { DropMarker } from './DropMarker';
|
||||
import { RequestActionsDropdown } from './RequestActionsDropdown';
|
||||
import { ToggleThemeButton } from './ToggleThemeButton';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
enum ItemTypes {
|
||||
REQUEST = 'request',
|
||||
}
|
||||
|
||||
export const Sidebar = memo(function Sidebar({ className }: Props) {
|
||||
const sidebarRef = useRef<HTMLDivElement>(null);
|
||||
const unorderedRequests = useRequests();
|
||||
const activeRequest = useActiveRequest();
|
||||
const requests = useMemo(
|
||||
() => [...unorderedRequests].sort((a, b) => a.sortPriority - b.sortPriority),
|
||||
[unorderedRequests],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative h-full">
|
||||
<div
|
||||
ref={sidebarRef}
|
||||
className={classnames(className, 'h-full relative grid grid-rows-[minmax(0,1fr)_auto]')}
|
||||
>
|
||||
<VStack
|
||||
as="ul"
|
||||
className="relative py-3 overflow-y-auto overflow-x-visible"
|
||||
draggable={false}
|
||||
>
|
||||
<SidebarItems activeRequestId={activeRequest?.id} requests={requests} />
|
||||
</VStack>
|
||||
<HStack className="mx-1 pb-1" alignItems="center" justifyContent="end">
|
||||
<ToggleThemeButton />
|
||||
</HStack>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function SidebarItems({
|
||||
requests,
|
||||
activeRequestId,
|
||||
}: {
|
||||
requests: HttpRequest[];
|
||||
activeRequestId?: string;
|
||||
}) {
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
const updateRequest = useUpdateAnyRequest();
|
||||
|
||||
const handleMove = useCallback<DraggableSidebarItemProps['onMove']>(
|
||||
(id, side) => {
|
||||
const dragIndex = requests.findIndex((r) => r.id === id);
|
||||
setHoveredIndex(side === 'above' ? dragIndex : dragIndex + 1);
|
||||
},
|
||||
[requests],
|
||||
);
|
||||
|
||||
const handleEnd = useCallback<DraggableSidebarItemProps['onEnd']>(
|
||||
(requestId) => {
|
||||
if (hoveredIndex === null) return;
|
||||
setHoveredIndex(null);
|
||||
|
||||
const index = requests.findIndex((r) => r.id === requestId);
|
||||
const request = requests[index];
|
||||
if (request === undefined) return;
|
||||
|
||||
const newRequests = requests.filter((r) => r.id !== requestId);
|
||||
if (hoveredIndex > index) newRequests.splice(hoveredIndex - 1, 0, request);
|
||||
else newRequests.splice(hoveredIndex, 0, request);
|
||||
|
||||
const beforePriority = newRequests[hoveredIndex - 1]?.sortPriority ?? 0;
|
||||
const afterPriority = newRequests[hoveredIndex + 1]?.sortPriority ?? 0;
|
||||
|
||||
const shouldUpdateAll = afterPriority - beforePriority < 1;
|
||||
if (shouldUpdateAll) {
|
||||
newRequests.forEach(({ id }, i) => {
|
||||
const sortPriority = i * 1000;
|
||||
const update = (r: HttpRequest) => ({ ...r, sortPriority });
|
||||
updateRequest.mutate({ id, update });
|
||||
});
|
||||
} else {
|
||||
const sortPriority = afterPriority - (afterPriority - beforePriority) / 2;
|
||||
const update = (r: HttpRequest) => ({ ...r, sortPriority });
|
||||
updateRequest.mutate({ id: requestId, update });
|
||||
}
|
||||
},
|
||||
[hoveredIndex, requests, updateRequest],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{requests.map((r, i) => (
|
||||
<Fragment key={r.id}>
|
||||
{hoveredIndex === i && <DropMarker />}
|
||||
<DraggableSidebarItem
|
||||
key={r.id}
|
||||
requestId={r.id}
|
||||
requestName={r.name}
|
||||
workspaceId={r.workspaceId}
|
||||
active={r.id === activeRequestId}
|
||||
onMove={handleMove}
|
||||
onEnd={handleEnd}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
{hoveredIndex === requests.length && <DropMarker />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type SidebarItemProps = {
|
||||
className?: string;
|
||||
requestId: string;
|
||||
requestName: string;
|
||||
workspaceId: string;
|
||||
active?: boolean;
|
||||
};
|
||||
|
||||
const _SidebarItem = forwardRef(function SidebarItem(
|
||||
{ className, requestName, requestId, workspaceId, active }: SidebarItemProps,
|
||||
ref: ForwardedRef<HTMLLIElement>,
|
||||
) {
|
||||
const updateRequest = useUpdateRequest(requestId);
|
||||
const [editing, setEditing] = useState<boolean>(false);
|
||||
|
||||
const handleSubmitNameEdit = useCallback(
|
||||
async (el: HTMLInputElement) => {
|
||||
await updateRequest.mutate((r) => ({ ...r, name: el.value }));
|
||||
setEditing(false);
|
||||
},
|
||||
[updateRequest],
|
||||
);
|
||||
|
||||
const handleFocus = useCallback((el: HTMLInputElement | null) => {
|
||||
el?.focus();
|
||||
el?.select();
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLElement>) => {
|
||||
// Hitting enter on active request during keyboard nav will start edit
|
||||
if (active && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
setEditing(true);
|
||||
}
|
||||
},
|
||||
[active],
|
||||
);
|
||||
|
||||
const handleInputKeyDown = useCallback(
|
||||
async (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
switch (e.key) {
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
await handleSubmitNameEdit(e.currentTarget);
|
||||
break;
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
setEditing(false);
|
||||
break;
|
||||
}
|
||||
},
|
||||
[handleSubmitNameEdit],
|
||||
);
|
||||
|
||||
return (
|
||||
<li ref={ref} className={classnames(className, 'block group/item px-2 pb-0.5')}>
|
||||
<div className="relative">
|
||||
<Button
|
||||
color="custom"
|
||||
size="sm"
|
||||
to={`/workspaces/${workspaceId}/requests/${requestId}`}
|
||||
draggable={false} // Item should drag, not the link
|
||||
onDoubleClick={() => setEditing(true)}
|
||||
onClick={active ? () => setEditing(true) : undefined}
|
||||
justify="start"
|
||||
onKeyDown={handleKeyDown}
|
||||
className={classnames(
|
||||
editing && 'focus-within:border-focus',
|
||||
active
|
||||
? 'bg-highlight text-gray-900'
|
||||
: 'text-gray-600 group-hover/item:text-gray-800 active:bg-highlightSecondary',
|
||||
// Move out of the way when trash is shown
|
||||
'group-hover/item:pr-7',
|
||||
)}
|
||||
>
|
||||
{editing ? (
|
||||
<input
|
||||
ref={handleFocus}
|
||||
defaultValue={requestName}
|
||||
className="bg-transparent outline-none w-full"
|
||||
onBlur={(e) => handleSubmitNameEdit(e.currentTarget)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
/>
|
||||
) : (
|
||||
<span className={classnames('truncate', !requestName && 'text-gray-400 italic')}>
|
||||
{requestName || 'New Request'}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<RequestActionsDropdown requestId={requestId}>
|
||||
<IconButton
|
||||
color="custom"
|
||||
size="sm"
|
||||
title="Request Options"
|
||||
icon="dotsH"
|
||||
className={classnames(
|
||||
'absolute right-0 top-0 transition-opacity !opacity-0',
|
||||
'group-hover/item:!opacity-100 focus-visible:!opacity-100',
|
||||
)}
|
||||
/>
|
||||
</RequestActionsDropdown>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
});
|
||||
const SidebarItem = memo(_SidebarItem);
|
||||
|
||||
type DraggableSidebarItemProps = SidebarItemProps & {
|
||||
onMove: (id: string, side: 'above' | 'below') => void;
|
||||
onEnd: (id: string) => void;
|
||||
};
|
||||
|
||||
type DragItem = {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
requestName: string;
|
||||
};
|
||||
|
||||
const DraggableSidebarItem = memo(function DraggableSidebarItem({
|
||||
requestName,
|
||||
requestId,
|
||||
workspaceId,
|
||||
active,
|
||||
onMove,
|
||||
onEnd,
|
||||
}: DraggableSidebarItemProps) {
|
||||
const ref = useRef<HTMLLIElement>(null);
|
||||
|
||||
const [, connectDrop] = useDrop<DragItem, void>(
|
||||
{
|
||||
accept: ItemTypes.REQUEST,
|
||||
hover: (item, monitor) => {
|
||||
if (!ref.current) return;
|
||||
const hoverBoundingRect = ref.current?.getBoundingClientRect();
|
||||
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
|
||||
const clientOffset = monitor.getClientOffset();
|
||||
const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top;
|
||||
onMove(requestId, hoverClientY < hoverMiddleY ? 'above' : 'below');
|
||||
},
|
||||
},
|
||||
[onMove],
|
||||
);
|
||||
|
||||
const [{ isDragging }, connectDrag] = useDrag<DragItem, unknown, { isDragging: boolean }>(
|
||||
() => ({
|
||||
type: ItemTypes.REQUEST,
|
||||
item: () => ({ id: requestId, requestName, workspaceId }),
|
||||
collect: (m) => ({ isDragging: m.isDragging() }),
|
||||
options: { dropEffect: 'move' },
|
||||
end: () => onEnd(requestId),
|
||||
}),
|
||||
[onEnd],
|
||||
);
|
||||
|
||||
connectDrag(ref);
|
||||
connectDrop(ref);
|
||||
|
||||
return (
|
||||
<SidebarItem
|
||||
ref={ref}
|
||||
className={classnames(isDragging && 'opacity-20')}
|
||||
requestName={requestName}
|
||||
requestId={requestId}
|
||||
workspaceId={workspaceId}
|
||||
active={active}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useCreateRequest } from '../hooks/useCreateRequest';
|
||||
import { useSidebarHidden } from '../hooks/useSidebarHidden';
|
||||
import { IconButton } from './core/IconButton';
|
||||
|
||||
export const SidebarActions = memo(function SidebarDisplayToggle() {
|
||||
const { hidden, toggle } = useSidebarHidden();
|
||||
const createRequest = useCreateRequest({ navigateAfter: true });
|
||||
const handleCreateRequest = useCallback(() => {
|
||||
createRequest.mutate({ name: 'New Request' });
|
||||
}, [createRequest]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
onClick={toggle}
|
||||
className="pointer-events-auto"
|
||||
size="sm"
|
||||
title="Show sidebar"
|
||||
icon={hidden ? 'leftPanelHidden' : 'leftPanelVisible'}
|
||||
/>
|
||||
<IconButton
|
||||
onClick={handleCreateRequest}
|
||||
className="pointer-events-auto"
|
||||
size="sm"
|
||||
title="Show sidebar"
|
||||
icon="plusCircle"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -1,121 +0,0 @@
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { appWindow } from '@tauri-apps/api/window';
|
||||
import { keyValueQueryKey } from '../hooks/useKeyValue';
|
||||
import { requestsQueryKey } from '../hooks/useRequests';
|
||||
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
|
||||
import { responsesQueryKey } from '../hooks/useResponses';
|
||||
import { useTauriEvent } from '../hooks/useTauriEvent';
|
||||
import { workspacesQueryKey } from '../hooks/useWorkspaces';
|
||||
import { DEFAULT_FONT_SIZE } from '../lib/constants';
|
||||
import { NAMESPACE_NO_SYNC } from '../lib/keyValueStore';
|
||||
import type { HttpRequest, HttpResponse, Model, Workspace } from '../lib/models';
|
||||
import { modelsEq } from '../lib/models';
|
||||
|
||||
export function TauriListeners() {
|
||||
const queryClient = useQueryClient();
|
||||
const { wasUpdatedExternally } = useRequestUpdateKey(null);
|
||||
|
||||
useTauriEvent<Model>('created_model', ({ payload, windowLabel }) => {
|
||||
if (shouldIgnoreEvent(payload, windowLabel)) return;
|
||||
|
||||
const queryKey =
|
||||
payload.model === 'http_request'
|
||||
? requestsQueryKey(payload)
|
||||
: payload.model === 'http_response'
|
||||
? responsesQueryKey(payload)
|
||||
: payload.model === 'workspace'
|
||||
? workspacesQueryKey(payload)
|
||||
: payload.model === 'key_value'
|
||||
? keyValueQueryKey(payload)
|
||||
: null;
|
||||
|
||||
if (queryKey === null) {
|
||||
if (payload.model) {
|
||||
console.log('Unrecognized created model:', payload);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shouldIgnoreModel(payload)) {
|
||||
queryClient.setQueryData<Model[]>(queryKey, (values) => [...(values ?? []), payload]);
|
||||
}
|
||||
});
|
||||
|
||||
useTauriEvent<Model>('updated_model', ({ payload, windowLabel }) => {
|
||||
if (shouldIgnoreEvent(payload, windowLabel)) return;
|
||||
|
||||
const queryKey =
|
||||
payload.model === 'http_request'
|
||||
? requestsQueryKey(payload)
|
||||
: payload.model === 'http_response'
|
||||
? responsesQueryKey(payload)
|
||||
: payload.model === 'workspace'
|
||||
? workspacesQueryKey(payload)
|
||||
: payload.model === 'key_value'
|
||||
? keyValueQueryKey(payload)
|
||||
: null;
|
||||
|
||||
if (queryKey === null) {
|
||||
if (payload.model) {
|
||||
console.log('Unrecognized updated model:', payload);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.model === 'http_request') {
|
||||
wasUpdatedExternally(payload.id);
|
||||
}
|
||||
|
||||
if (!shouldIgnoreModel(payload)) {
|
||||
queryClient.setQueryData<Model[]>(queryKey, (values) =>
|
||||
values?.map((v) => (modelsEq(v, payload) ? payload : v)),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
useTauriEvent<Model>('deleted_model', ({ payload, windowLabel }) => {
|
||||
if (shouldIgnoreEvent(payload, windowLabel)) return;
|
||||
|
||||
if (shouldIgnoreModel(payload)) return;
|
||||
|
||||
if (payload.model === 'workspace') {
|
||||
queryClient.setQueryData<Workspace[]>(workspacesQueryKey(), removeById(payload));
|
||||
} else if (payload.model === 'http_request') {
|
||||
queryClient.setQueryData<HttpRequest[]>(requestsQueryKey(payload), removeById(payload));
|
||||
} else if (payload.model === 'http_response') {
|
||||
queryClient.setQueryData<HttpResponse[]>(responsesQueryKey(payload), removeById(payload));
|
||||
} else if (payload.model === 'key_value') {
|
||||
queryClient.setQueryData(keyValueQueryKey(payload), undefined);
|
||||
}
|
||||
});
|
||||
useTauriEvent<number>('zoom', ({ payload: zoomDelta, windowLabel }) => {
|
||||
if (windowLabel !== appWindow.label) return;
|
||||
const fontSize = parseFloat(window.getComputedStyle(document.documentElement).fontSize);
|
||||
|
||||
let newFontSize;
|
||||
if (zoomDelta === 0) {
|
||||
newFontSize = DEFAULT_FONT_SIZE;
|
||||
} else if (zoomDelta > 0) {
|
||||
newFontSize = Math.min(fontSize * 1.1, DEFAULT_FONT_SIZE * 5);
|
||||
} else if (zoomDelta < 0) {
|
||||
newFontSize = Math.max(fontSize * 0.9, DEFAULT_FONT_SIZE * 0.4);
|
||||
}
|
||||
|
||||
document.documentElement.style.fontSize = `${newFontSize}px`;
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function removeById<T extends { id: string }>(model: T) {
|
||||
return (entries: T[] | undefined) => entries?.filter((e) => e.id !== model.id);
|
||||
}
|
||||
|
||||
const shouldIgnoreEvent = (payload: Model, windowLabel: string) =>
|
||||
windowLabel === appWindow.label && payload.model !== 'http_response';
|
||||
|
||||
const shouldIgnoreModel = (payload: Model) => {
|
||||
if (payload.model === 'http_response') return false;
|
||||
if (payload.model === 'key_value' && payload.namespace === NAMESPACE_NO_SYNC) return false;
|
||||
return true;
|
||||
};
|
||||
@@ -1,14 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useTheme } from '../hooks/useTheme';
|
||||
import { IconButton } from './core/IconButton';
|
||||
|
||||
export function ToggleThemeButton() {
|
||||
const { appearance, toggleAppearance } = useTheme();
|
||||
return (
|
||||
<IconButton
|
||||
title={appearance === 'dark' ? 'Enable light mode' : 'Enable dark mode'}
|
||||
icon={appearance === 'dark' ? 'moon' : 'sun'}
|
||||
onClick={toggleAppearance}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import classnames from 'classnames';
|
||||
import type { EditorView } from 'codemirror';
|
||||
import type { FormEvent } from 'react';
|
||||
import { memo, useCallback, useRef } from 'react';
|
||||
import { useIsResponseLoading } from '../hooks/useIsResponseLoading';
|
||||
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
|
||||
import { useSendRequest } from '../hooks/useSendRequest';
|
||||
import { useTauriEvent } from '../hooks/useTauriEvent';
|
||||
import { useUpdateRequest } from '../hooks/useUpdateRequest';
|
||||
import type { HttpRequest } from '../lib/models';
|
||||
import { IconButton } from './core/IconButton';
|
||||
import { Input } from './core/Input';
|
||||
import { RequestMethodDropdown } from './RequestMethodDropdown';
|
||||
|
||||
type Props = Pick<HttpRequest, 'id' | 'url' | 'method'> & {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const UrlBar = memo(function UrlBar({ id: requestId, url, method, className }: Props) {
|
||||
const inputRef = useRef<EditorView>(null);
|
||||
const sendRequest = useSendRequest(requestId);
|
||||
const updateRequest = useUpdateRequest(requestId);
|
||||
const handleMethodChange = useCallback(
|
||||
(method: string) => updateRequest.mutate({ method }),
|
||||
[updateRequest],
|
||||
);
|
||||
const handleUrlChange = useCallback(
|
||||
(url: string) => updateRequest.mutate({ url }),
|
||||
[updateRequest],
|
||||
);
|
||||
const loading = useIsResponseLoading(requestId);
|
||||
const { updateKey } = useRequestUpdateKey(requestId);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
sendRequest();
|
||||
},
|
||||
[sendRequest],
|
||||
);
|
||||
|
||||
useTauriEvent('focus_url', () => {
|
||||
inputRef.current?.focus();
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className={classnames('url-bar', className)}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
size="sm"
|
||||
hideLabel
|
||||
useTemplating
|
||||
contentType="url"
|
||||
className="px-0"
|
||||
name="url"
|
||||
label="Enter URL"
|
||||
forceUpdateKey={updateKey}
|
||||
containerClassName="shadow shadow-gray-100 dark:shadow-gray-50"
|
||||
onChange={handleUrlChange}
|
||||
defaultValue={url}
|
||||
placeholder="https://example.com"
|
||||
leftSlot={
|
||||
<RequestMethodDropdown
|
||||
method={method}
|
||||
onChange={handleMethodChange}
|
||||
className="mx-0.5 h-full my-1"
|
||||
/>
|
||||
}
|
||||
rightSlot={
|
||||
<IconButton
|
||||
size="xs"
|
||||
iconSize="sm"
|
||||
title="Send Request"
|
||||
type="submit"
|
||||
className="w-8 mr-0.5"
|
||||
icon={loading ? 'update' : 'paperPlane'}
|
||||
spin={loading}
|
||||
disabled={loading}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
});
|
||||
@@ -1,184 +0,0 @@
|
||||
import classnames from 'classnames';
|
||||
import { motion } from 'framer-motion';
|
||||
import type {
|
||||
CSSProperties,
|
||||
HTMLAttributes,
|
||||
MouseEvent as ReactMouseEvent,
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useWindowSize } from 'react-use';
|
||||
import { useSidebarHidden } from '../hooks/useSidebarHidden';
|
||||
import { useSidebarWidth } from '../hooks/useSidebarWidth';
|
||||
import { useTauriEvent } from '../hooks/useTauriEvent';
|
||||
import { WINDOW_FLOATING_SIDEBAR_WIDTH } from '../lib/constants';
|
||||
import { Button } from './core/Button';
|
||||
import { HStack } from './core/Stacks';
|
||||
import { Overlay } from './Overlay';
|
||||
import { RequestResponse } from './RequestResponse';
|
||||
import { ResizeHandle } from './ResizeHandle';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { SidebarActions } from './SidebarActions';
|
||||
import { WorkspaceHeader } from './WorkspaceHeader';
|
||||
|
||||
const side = { gridArea: 'side' };
|
||||
const head = { gridArea: 'head' };
|
||||
const body = { gridArea: 'body' };
|
||||
const drag = { gridArea: 'drag' };
|
||||
|
||||
export default function Workspace() {
|
||||
const { set: setWidth, value: width, reset: resetWidth } = useSidebarWidth();
|
||||
const { show, hide, hidden, toggle } = useSidebarHidden();
|
||||
|
||||
const windowSize = useWindowSize();
|
||||
const [floating, setFloating] = useState<boolean>(false);
|
||||
const [isResizing, setIsResizing] = useState<boolean>(false);
|
||||
const moveState = useRef<{ move: (e: MouseEvent) => void; up: (e: MouseEvent) => void } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
useTauriEvent('toggle_sidebar', toggle);
|
||||
|
||||
// float/un-float sidebar on window resize
|
||||
useEffect(() => {
|
||||
const shouldHide = windowSize.width <= WINDOW_FLOATING_SIDEBAR_WIDTH;
|
||||
if (shouldHide && !hidden) {
|
||||
setFloating(true);
|
||||
hide();
|
||||
} else if (!shouldHide && hidden) {
|
||||
setFloating(false);
|
||||
show();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [windowSize.width]);
|
||||
|
||||
const unsub = () => {
|
||||
if (moveState.current !== null) {
|
||||
document.documentElement.removeEventListener('mousemove', moveState.current.move);
|
||||
document.documentElement.removeEventListener('mouseup', moveState.current.up);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResizeStart = useCallback(
|
||||
(e: ReactMouseEvent<HTMLDivElement>) => {
|
||||
if (width === undefined) return;
|
||||
|
||||
unsub();
|
||||
const mouseStartX = e.clientX;
|
||||
const startWidth = width;
|
||||
moveState.current = {
|
||||
move: async (e: MouseEvent) => {
|
||||
e.preventDefault(); // Prevent text selection and things
|
||||
setWidth(startWidth + (e.clientX - mouseStartX));
|
||||
},
|
||||
up: (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
unsub();
|
||||
setIsResizing(false);
|
||||
},
|
||||
};
|
||||
document.documentElement.addEventListener('mousemove', moveState.current.move);
|
||||
document.documentElement.addEventListener('mouseup', moveState.current.up);
|
||||
setIsResizing(true);
|
||||
},
|
||||
[setWidth, width],
|
||||
);
|
||||
|
||||
const sideWidth = hidden ? 0 : width;
|
||||
const styles = useMemo<CSSProperties>(
|
||||
() => ({
|
||||
gridTemplate: floating
|
||||
? `
|
||||
' ${head.gridArea}' auto
|
||||
' ${body.gridArea}' minmax(0,1fr)
|
||||
/ 1fr`
|
||||
: `
|
||||
' ${head.gridArea} ${head.gridArea} ${head.gridArea}' auto
|
||||
' ${side.gridArea} ${drag.gridArea} ${body.gridArea}' minmax(0,1fr)
|
||||
/ ${sideWidth}px 0 1fr`,
|
||||
}),
|
||||
[sideWidth, floating],
|
||||
);
|
||||
|
||||
if (windowSize.width <= 100) {
|
||||
return (
|
||||
<div>
|
||||
<Button>Send</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={styles}
|
||||
className={classnames(
|
||||
'grid w-full h-full',
|
||||
// Animate sidebar width changes but only when not resizing
|
||||
// because it's too slow to animate on mouse move
|
||||
!isResizing && 'transition-all',
|
||||
)}
|
||||
>
|
||||
<HeaderSize
|
||||
data-tauri-drag-region
|
||||
className="w-full bg-gray-50 border-b border-b-highlight text-gray-900"
|
||||
style={head}
|
||||
>
|
||||
<WorkspaceHeader className="pointer-events-none" />
|
||||
</HeaderSize>
|
||||
{floating ? (
|
||||
<Overlay open={!hidden} portalName="sidebar" onClose={hide}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className={classnames(
|
||||
'absolute top-0 left-0 bottom-0 bg-gray-100 border-r border-highlight w-[14rem]',
|
||||
'grid grid-rows-[auto_1fr]',
|
||||
)}
|
||||
>
|
||||
<HeaderSize className="border-transparent">
|
||||
<HStack space={0.5}>
|
||||
<SidebarActions />
|
||||
</HStack>
|
||||
</HeaderSize>
|
||||
<Sidebar />
|
||||
</motion.div>
|
||||
</Overlay>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
style={side}
|
||||
className={classnames('overflow-hidden bg-gray-100 border-r border-highlight')}
|
||||
>
|
||||
<Sidebar />
|
||||
</div>
|
||||
<ResizeHandle
|
||||
className="-translate-x-3"
|
||||
justify="end"
|
||||
side="right"
|
||||
isResizing={isResizing}
|
||||
onResizeStart={handleResizeStart}
|
||||
onReset={resetWidth}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<RequestResponse style={body} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface HeaderSizeProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function HeaderSize({ className, ...props }: HeaderSizeProps) {
|
||||
return (
|
||||
<div
|
||||
className={classnames(
|
||||
className,
|
||||
'h-md pt-[1px] flex items-center w-full pr-3 pl-20 border-b',
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import classnames from 'classnames';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
|
||||
import { useConfirm } from '../hooks/useConfirm';
|
||||
import { useCreateWorkspace } from '../hooks/useCreateWorkspace';
|
||||
import { useDeleteWorkspace } from '../hooks/useDeleteWorkspace';
|
||||
import { useRoutes } from '../hooks/useRoutes';
|
||||
import { useWorkspaces } from '../hooks/useWorkspaces';
|
||||
import { Button } from './core/Button';
|
||||
import type { DropdownItem } from './core/Dropdown';
|
||||
import { Dropdown } from './core/Dropdown';
|
||||
import { Icon } from './core/Icon';
|
||||
import { InlineCode } from './core/InlineCode';
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const WorkspaceActionsDropdown = memo(function WorkspaceDropdown({ className }: Props) {
|
||||
const workspaces = useWorkspaces();
|
||||
const activeWorkspace = useActiveWorkspace();
|
||||
const activeWorkspaceId = activeWorkspace?.id ?? null;
|
||||
const createWorkspace = useCreateWorkspace({ navigateAfter: true });
|
||||
const deleteWorkspace = useDeleteWorkspace(activeWorkspaceId);
|
||||
const routes = useRoutes();
|
||||
const confirm = useConfirm();
|
||||
|
||||
const items: DropdownItem[] = useMemo(() => {
|
||||
const workspaceItems = workspaces.map((w) => ({
|
||||
label: w.name,
|
||||
leftSlot: activeWorkspaceId === w.id ? <Icon icon="check" /> : <Icon icon="empty" />,
|
||||
onSelect: () => {
|
||||
if (w.id === activeWorkspaceId) return;
|
||||
routes.navigate('workspace', { workspaceId: w.id });
|
||||
},
|
||||
}));
|
||||
|
||||
return [
|
||||
...workspaceItems,
|
||||
{
|
||||
type: 'separator',
|
||||
label: 'Actions',
|
||||
},
|
||||
{
|
||||
label: 'New Workspace',
|
||||
leftSlot: <Icon icon="plus" />,
|
||||
onSelect: () => createWorkspace.mutate({ name: 'New Workspace' }),
|
||||
},
|
||||
{
|
||||
label: 'Delete Workspace',
|
||||
leftSlot: <Icon icon="trash" />,
|
||||
onSelect: async () => {
|
||||
const confirmed = await confirm({
|
||||
title: 'Delete Workspace',
|
||||
variant: 'delete',
|
||||
description: (
|
||||
<>
|
||||
Are you sure you want to delete <InlineCode>{activeWorkspace?.name}</InlineCode>?
|
||||
</>
|
||||
),
|
||||
});
|
||||
if (confirmed) {
|
||||
deleteWorkspace.mutate();
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}, [
|
||||
workspaces,
|
||||
activeWorkspaceId,
|
||||
routes,
|
||||
createWorkspace,
|
||||
confirm,
|
||||
activeWorkspace?.name,
|
||||
deleteWorkspace,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Dropdown items={items}>
|
||||
<Button
|
||||
size="sm"
|
||||
className={classnames(className, 'text-gray-800 !px-2 truncate')}
|
||||
forDropdown
|
||||
>
|
||||
{activeWorkspace?.name}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
import classnames from 'classnames';
|
||||
import { memo } from 'react';
|
||||
import { useActiveRequest } from '../hooks/useActiveRequest';
|
||||
import { IconButton } from './core/IconButton';
|
||||
import { HStack } from './core/Stacks';
|
||||
import { RequestActionsDropdown } from './RequestActionsDropdown';
|
||||
import { SidebarActions } from './SidebarActions';
|
||||
import { WorkspaceActionsDropdown } from './WorkspaceActionsDropdown';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Props) {
|
||||
const activeRequest = useActiveRequest();
|
||||
return (
|
||||
<HStack
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
className={classnames(className, 'w-full h-full')}
|
||||
>
|
||||
<HStack space={0.5} className="flex-1 pointer-events-none" alignItems="center">
|
||||
<SidebarActions />
|
||||
<WorkspaceActionsDropdown className="pointer-events-auto" />
|
||||
</HStack>
|
||||
<div className="flex-[2] text-center text-gray-800 text-sm truncate pointer-events-none">
|
||||
{activeRequest?.name}
|
||||
</div>
|
||||
<div className="flex-1 flex justify-end -mr-2 pointer-events-none">
|
||||
{activeRequest && (
|
||||
<RequestActionsDropdown requestId={activeRequest?.id}>
|
||||
<IconButton
|
||||
size="sm"
|
||||
title="Request Options"
|
||||
icon="gear"
|
||||
className="pointer-events-auto"
|
||||
/>
|
||||
</RequestActionsDropdown>
|
||||
)}
|
||||
</div>
|
||||
</HStack>
|
||||
);
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useRoutes } from '../hooks/useRoutes';
|
||||
import { useWorkspaces } from '../hooks/useWorkspaces';
|
||||
import { Heading } from './core/Heading';
|
||||
|
||||
export default function Workspaces() {
|
||||
const routes = useRoutes();
|
||||
const workspaces = useWorkspaces();
|
||||
const workspace = workspaces[0];
|
||||
|
||||
if (workspace === undefined) {
|
||||
return <Heading>There are no workspaces</Heading>;
|
||||
}
|
||||
|
||||
return <Navigate to={routes.paths.workspace({ workspaceId: workspace.id })} />;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import classnames from 'classnames';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
export function Banner({ children, className }: Props) {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className={classnames(
|
||||
className,
|
||||
'border border-red-500 bg-red-300/10 text-red-800 px-3 py-2 rounded select-auto cursor-text',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import classnames from 'classnames';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { forwardRef, memo, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
const colorStyles = {
|
||||
custom: 'ring-blue-500/50',
|
||||
default:
|
||||
'text-gray-700 enabled:hocus:bg-gray-700/10 enabled:hocus:text-gray-1000 ring-blue-500/50',
|
||||
gray: 'text-gray-800 bg-highlight enabled:hocus:bg-gray-500/20 enabled:hocus:text-gray-1000 ring-blue-500/50',
|
||||
primary: 'bg-blue-400 text-white enabled:hocus:bg-blue-500 ring-blue-500/50',
|
||||
secondary: 'bg-violet-400 text-white enabled:hocus:bg-violet-500 ring-violet-500/50',
|
||||
warning: 'bg-orange-400 text-white enabled:hocus:bg-orange-500 ring-orange-500/50',
|
||||
danger: 'bg-red-400 text-white enabled:hocus:bg-red-500 ring-red-500/50',
|
||||
};
|
||||
|
||||
export type ButtonProps = HTMLAttributes<HTMLElement> & {
|
||||
to?: string;
|
||||
color?: keyof typeof colorStyles;
|
||||
isLoading?: boolean;
|
||||
size?: 'sm' | 'md' | 'xs';
|
||||
justify?: 'start' | 'center';
|
||||
type?: 'button' | 'submit';
|
||||
forDropdown?: boolean;
|
||||
disabled?: boolean;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const _Button = forwardRef<any, ButtonProps>(function Button(
|
||||
{
|
||||
to,
|
||||
isLoading,
|
||||
className,
|
||||
children,
|
||||
forDropdown,
|
||||
color,
|
||||
justify = 'center',
|
||||
size = 'md',
|
||||
...props
|
||||
}: ButtonProps,
|
||||
ref,
|
||||
) {
|
||||
const classes = useMemo(
|
||||
() =>
|
||||
classnames(
|
||||
className,
|
||||
'opacity-90 hover:opacity-100',
|
||||
'outline-none whitespace-nowrap',
|
||||
'focus-visible-or-class:ring',
|
||||
'rounded-md flex items-center',
|
||||
colorStyles[color || 'default'],
|
||||
justify === 'start' && 'justify-start',
|
||||
justify === 'center' && 'justify-center',
|
||||
size === 'md' && 'h-md px-3',
|
||||
size === 'sm' && 'h-sm px-2.5 text-sm',
|
||||
size === 'xs' && 'h-xs px-2 text-sm',
|
||||
),
|
||||
[color, size, justify, className],
|
||||
);
|
||||
|
||||
if (typeof to === 'string') {
|
||||
return (
|
||||
<Link ref={ref} to={to} className={classes} {...props}>
|
||||
{children}
|
||||
{forDropdown && <Icon icon="chevronDown" className="ml-1 -mr-1" />}
|
||||
</Link>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<button ref={ref} className={classes} {...props}>
|
||||
{isLoading && <Icon icon="update" size={size} className="animate-spin mr-1" />}
|
||||
{children}
|
||||
{forDropdown && <Icon icon="chevronDown" size={size} className="ml-1 -mr-1" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export const Button = memo(_Button);
|
||||
@@ -1,38 +0,0 @@
|
||||
import classnames from 'classnames';
|
||||
import { useCallback } from 'react';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
interface Props {
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Checkbox({ checked, onChange, className, disabled }: Props) {
|
||||
const handleClick = useCallback(() => {
|
||||
onChange(!checked);
|
||||
}, [onChange, checked]);
|
||||
|
||||
return (
|
||||
<button
|
||||
role="checkbox"
|
||||
aria-checked={checked ? 'true' : 'false'}
|
||||
disabled={disabled}
|
||||
onClick={handleClick}
|
||||
className={classnames(
|
||||
className,
|
||||
'flex-shrink-0 w-4 h-4 border border-gray-200 rounded',
|
||||
'focus:border-focus',
|
||||
'disabled:opacity-disabled',
|
||||
checked && 'bg-gray-200/10',
|
||||
// Remove focus style
|
||||
'outline-none',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-center">
|
||||
<Icon size="sm" icon={checked ? 'check' : 'empty'} />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
interface Props {
|
||||
count: number;
|
||||
}
|
||||
|
||||
export function CountBadge({ count }: Props) {
|
||||
if (count === 0) return null;
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
aria-hidden
|
||||
className="opacity-70 border border-highlight text-3xs rounded mb-0.5 px-1 ml-1 h-4 font-mono"
|
||||
>
|
||||
{count}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import classnames from 'classnames';
|
||||
import { motion } from 'framer-motion';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { useKeyPressEvent } from 'react-use';
|
||||
import { Overlay } from '../Overlay';
|
||||
import { Heading } from './Heading';
|
||||
import { IconButton } from './IconButton';
|
||||
|
||||
export interface DialogProps {
|
||||
children: ReactNode;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
className?: string;
|
||||
size?: 'sm' | 'md' | 'full' | 'dynamic';
|
||||
hideX?: boolean;
|
||||
}
|
||||
|
||||
export function Dialog({
|
||||
children,
|
||||
className,
|
||||
size = 'full',
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
description,
|
||||
hideX,
|
||||
}: DialogProps) {
|
||||
const titleId = useMemo(() => Math.random().toString(36).slice(2), []);
|
||||
const descriptionId = useMemo(
|
||||
() => (description ? Math.random().toString(36).slice(2) : undefined),
|
||||
[description],
|
||||
);
|
||||
|
||||
useKeyPressEvent('Escape', (e) => {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
});
|
||||
|
||||
return (
|
||||
<Overlay open={open} onClose={onClose} portalName="dialog">
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={descriptionId}
|
||||
className="pointer-events-auto"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ top: 5, scale: 0.97 }}
|
||||
animate={{ top: 0, scale: 1 }}
|
||||
className={classnames(
|
||||
className,
|
||||
'relative bg-gray-50 pointer-events-auto',
|
||||
'max-h-[80vh] p-5 rounded-lg overflow-auto',
|
||||
'dark:border border-gray-200 shadow-md shadow-black/10',
|
||||
size === 'sm' && 'w-[25rem]',
|
||||
size === 'md' && 'w-[45rem]',
|
||||
size === 'full' && 'w-[80vw]',
|
||||
size === 'dynamic' && 'min-w-[30vw] max-w-[80vw]',
|
||||
)}
|
||||
>
|
||||
{!hideX && (
|
||||
<IconButton
|
||||
onClick={onClose}
|
||||
title="Close dialog"
|
||||
aria-label="Close"
|
||||
icon="x"
|
||||
size="sm"
|
||||
className="ml-auto absolute right-1 top-1"
|
||||
/>
|
||||
)}
|
||||
<Heading className="text-xl font-semibold w-full" id={titleId}>
|
||||
{title}
|
||||
</Heading>
|
||||
{description && <p id={descriptionId}>{description}</p>}
|
||||
<div className="mt-6">{children}</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</Overlay>
|
||||
);
|
||||
}
|
||||
@@ -1,279 +0,0 @@
|
||||
import classnames from 'classnames';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import type { CSSProperties, HTMLAttributes, MouseEvent, ReactElement, ReactNode } from 'react';
|
||||
import { Children, cloneElement, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useKeyPressEvent } from 'react-use';
|
||||
import { Portal } from '../Portal';
|
||||
import { Separator } from './Separator';
|
||||
import { VStack } from './Stacks';
|
||||
|
||||
export type DropdownItemSeparator = {
|
||||
type: 'separator';
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export type DropdownItem =
|
||||
| {
|
||||
type?: 'default';
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
hidden?: boolean;
|
||||
leftSlot?: ReactNode;
|
||||
rightSlot?: ReactNode;
|
||||
onSelect?: () => void;
|
||||
}
|
||||
| DropdownItemSeparator;
|
||||
|
||||
export interface DropdownProps {
|
||||
children: ReactElement<HTMLAttributes<HTMLButtonElement>>;
|
||||
items: DropdownItem[];
|
||||
}
|
||||
|
||||
export function Dropdown({ children, items }: DropdownProps) {
|
||||
const [open, setOpen] = useState<boolean>(false);
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
const child = useMemo(() => {
|
||||
const existingChild = Children.only(children);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const props: any = {
|
||||
...existingChild.props,
|
||||
ref,
|
||||
'aria-haspopup': 'true',
|
||||
onClick:
|
||||
existingChild.props?.onClick ??
|
||||
((e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOpen((o) => !o);
|
||||
}),
|
||||
};
|
||||
return cloneElement(existingChild, props);
|
||||
}, [children]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setOpen(false);
|
||||
ref.current?.focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
ref.current?.setAttribute('aria-expanded', open.toString());
|
||||
}, [open]);
|
||||
|
||||
const triggerRect = useMemo(() => {
|
||||
if (!open) return null;
|
||||
return ref.current?.getBoundingClientRect();
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{child}
|
||||
{open && triggerRect && (
|
||||
<Menu items={items} triggerRect={triggerRect} onClose={handleClose} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface MenuProps {
|
||||
className?: string;
|
||||
items: DropdownProps['items'];
|
||||
triggerRect: DOMRect;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function Menu({ className, items, onClose, triggerRect }: MenuProps) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [menuStyles, setMenuStyles] = useState<CSSProperties>({});
|
||||
|
||||
// Calculate the max height so we can scroll
|
||||
const initMenu = useCallback((el: HTMLDivElement | null) => {
|
||||
if (el === null) return {};
|
||||
const windowBox = document.documentElement.getBoundingClientRect();
|
||||
const menuBox = el.getBoundingClientRect();
|
||||
setMenuStyles({ maxHeight: windowBox.height - menuBox.top - 5 });
|
||||
}, []);
|
||||
|
||||
useKeyPressEvent('Escape', (e) => {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
});
|
||||
|
||||
useKeyPressEvent('ArrowUp', (e) => {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((currIndex) => {
|
||||
let nextIndex = (currIndex ?? 0) - 1;
|
||||
const maxTries = items.length;
|
||||
for (let i = 0; i < maxTries; i++) {
|
||||
if (items[nextIndex]?.type === 'separator') {
|
||||
nextIndex--;
|
||||
} else if (nextIndex < 0) {
|
||||
nextIndex = items.length - 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return nextIndex;
|
||||
});
|
||||
});
|
||||
|
||||
useKeyPressEvent('ArrowDown', (e) => {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((currIndex) => {
|
||||
let nextIndex = (currIndex ?? -1) + 1;
|
||||
const maxTries = items.length;
|
||||
for (let i = 0; i < maxTries; i++) {
|
||||
if (items[nextIndex]?.type === 'separator') {
|
||||
nextIndex++;
|
||||
} else if (nextIndex >= items.length) {
|
||||
nextIndex = 0;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return nextIndex;
|
||||
});
|
||||
});
|
||||
|
||||
const { containerStyles, triangleStyles } = useMemo<{
|
||||
containerStyles: CSSProperties;
|
||||
triangleStyles: CSSProperties;
|
||||
}>(() => {
|
||||
const docWidth = document.documentElement.getBoundingClientRect().width;
|
||||
const spaceRemaining = docWidth - triggerRect.left;
|
||||
const top = triggerRect?.bottom + 5;
|
||||
const onRight = spaceRemaining < 200;
|
||||
const containerStyles = onRight
|
||||
? { top, right: docWidth - triggerRect?.right }
|
||||
: { top, left: triggerRect?.left };
|
||||
const size = { top: '-0.2rem', width: '0.4rem', height: '0.4rem' };
|
||||
const triangleStyles = onRight
|
||||
? { right: triggerRect.width / 2, marginRight: '-0.2rem', ...size }
|
||||
: { left: triggerRect.width / 2, marginLeft: '-0.2rem', ...size };
|
||||
return { containerStyles, triangleStyles };
|
||||
}, [triggerRect]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(i: DropdownItem) => {
|
||||
onClose();
|
||||
setSelectedIndex(null);
|
||||
if (i.type !== 'separator') {
|
||||
i.onSelect?.();
|
||||
}
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(
|
||||
(i: DropdownItem) => {
|
||||
const index = items.findIndex((item) => item === i) ?? null;
|
||||
setSelectedIndex(index);
|
||||
},
|
||||
[items],
|
||||
);
|
||||
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||
|
||||
return (
|
||||
<Portal name="dropdown">
|
||||
<FocusTrap>
|
||||
<div>
|
||||
<div tabIndex={-1} aria-hidden className="fixed inset-0" onClick={onClose} />
|
||||
<motion.div
|
||||
tabIndex={0}
|
||||
initial={{ opacity: 0, y: -5, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
dir="ltr"
|
||||
ref={containerRef}
|
||||
style={containerStyles}
|
||||
className={classnames(className, 'outline-none mt-1 pointer-events-auto fixed z-50')}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
style={triangleStyles}
|
||||
className="bg-gray-50 absolute rotate-45 border-gray-200 border-t border-l"
|
||||
/>
|
||||
{containerStyles && (
|
||||
<VStack
|
||||
space={0.5}
|
||||
ref={initMenu}
|
||||
style={menuStyles}
|
||||
className={classnames(
|
||||
className,
|
||||
'h-auto bg-gray-50 rounded-md shadow-lg dark:shadow-gray-0 py-1.5 border',
|
||||
'border-gray-200 overflow-auto mb-1 mx-0.5',
|
||||
)}
|
||||
>
|
||||
{items.map((item, i) => {
|
||||
if (item.type === 'separator') {
|
||||
return <Separator key={i} className="my-1.5" label={item.label} />;
|
||||
}
|
||||
if (item.hidden) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<MenuItem
|
||||
focused={i === selectedIndex}
|
||||
onFocus={handleFocus}
|
||||
onSelect={handleSelect}
|
||||
key={i + item.label}
|
||||
item={item}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</FocusTrap>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
interface MenuItemProps {
|
||||
className?: string;
|
||||
item: DropdownItem;
|
||||
onSelect: (item: DropdownItem) => void;
|
||||
onFocus: (item: DropdownItem) => void;
|
||||
focused: boolean;
|
||||
}
|
||||
|
||||
function MenuItem({ className, focused, onFocus, item, onSelect, ...props }: MenuItemProps) {
|
||||
const handleClick = useCallback(() => onSelect?.(item), [item, onSelect]);
|
||||
const handleFocus = useCallback(() => onFocus?.(item), [item, onFocus]);
|
||||
|
||||
const initRef = useCallback(
|
||||
(el: HTMLButtonElement | null) => {
|
||||
if (el === null) return;
|
||||
if (focused) {
|
||||
setTimeout(() => el.focus(), 0);
|
||||
}
|
||||
},
|
||||
[focused],
|
||||
);
|
||||
|
||||
if (item.type === 'separator') return <Separator className="my-1.5" />;
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={initRef}
|
||||
tabIndex={-1}
|
||||
onMouseEnter={(e) => e.currentTarget.focus()}
|
||||
onMouseLeave={(e) => e.currentTarget.blur()}
|
||||
onFocus={handleFocus}
|
||||
onClick={handleClick}
|
||||
className={classnames(
|
||||
className,
|
||||
'min-w-[8rem] outline-none px-2 mx-1.5 h-7 flex items-center text-sm text-gray-700 whitespace-nowrap pr-4',
|
||||
'focus:bg-highlight focus:text-gray-900 rounded',
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{item.leftSlot && <div className="w-6">{item.leftSlot}</div>}
|
||||
<div>{item.label}</div>
|
||||
{item.rightSlot && <div className="ml-auto pl-3">{item.rightSlot}</div>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
.cm-wrapper {
|
||||
@apply h-full overflow-hidden;
|
||||
|
||||
.cm-editor {
|
||||
@apply w-full block text-base;
|
||||
|
||||
* {
|
||||
@apply cursor-text;
|
||||
}
|
||||
|
||||
&.cm-focused {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
.cm-content {
|
||||
@apply py-0;
|
||||
}
|
||||
|
||||
.cm-line {
|
||||
@apply text-gray-800 pl-1 pr-1.5;
|
||||
}
|
||||
|
||||
.cm-placeholder {
|
||||
@apply text-placeholder;
|
||||
}
|
||||
|
||||
.cm-scroller {
|
||||
/* Inherit line-height from outside */
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
/* Don't show selection on blurred input */
|
||||
.cm-selectionBackground {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
&.cm-focused .cm-selectionBackground {
|
||||
@apply bg-selection;
|
||||
}
|
||||
|
||||
/* Style gutters */
|
||||
.cm-gutters {
|
||||
@apply border-0 text-gray-500/50 opacity-95;
|
||||
|
||||
.cm-gutterElement {
|
||||
@apply cursor-default;
|
||||
}
|
||||
}
|
||||
|
||||
.placeholder-widget {
|
||||
@apply text-xs text-gray-800 dark:text-gray-900 px-1 rounded cursor-default dark:shadow;
|
||||
|
||||
/* NOTE: Background and border are translucent so we can see text selection through it */
|
||||
@apply bg-gray-300/40 border border-gray-300 border-opacity-40 hover:border-opacity-80;
|
||||
|
||||
/* Bring above on hover */
|
||||
@apply hover:z-10 relative;
|
||||
}
|
||||
}
|
||||
|
||||
&.cm-singleline {
|
||||
.cm-editor {
|
||||
@apply w-full h-auto;
|
||||
}
|
||||
|
||||
.cm-scroller {
|
||||
@apply font-mono text-[0.8rem] overflow-hidden;
|
||||
}
|
||||
|
||||
.cm-line {
|
||||
@apply px-2 overflow-hidden;
|
||||
}
|
||||
}
|
||||
|
||||
&.cm-multiline {
|
||||
&.cm-full-height {
|
||||
@apply relative;
|
||||
|
||||
.cm-editor {
|
||||
@apply inset-0 absolute;
|
||||
position: absolute !important;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-editor {
|
||||
@apply h-full;
|
||||
}
|
||||
|
||||
.cm-scroller {
|
||||
@apply font-mono text-[0.75rem];
|
||||
|
||||
/*
|
||||
* Round corners or they'll stick out of the editor bounds of editor is rounded.
|
||||
* Could potentially be pushed up from the editor like we do with bg color but this
|
||||
* is probably fine.
|
||||
*/
|
||||
@apply rounded-lg;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Obscure text for password fields */
|
||||
.cm-wrapper.cm-obscure-text .cm-line {
|
||||
-webkit-text-security: disc;
|
||||
}
|
||||
|
||||
.cm-editor .cm-gutterElement {
|
||||
@apply flex items-center;
|
||||
transition: color var(--transition-duration);
|
||||
}
|
||||
|
||||
.cm-editor .fold-gutter-icon {
|
||||
@apply pt-[0.25em] pl-[0.4em] px-[0.4em] h-4 cursor-pointer rounded;
|
||||
}
|
||||
|
||||
.cm-editor .fold-gutter-icon::after {
|
||||
@apply block w-1.5 h-1.5 border-transparent -rotate-45
|
||||
border-l border-b border-l-[currentColor] border-b-[currentColor] content-[''];
|
||||
}
|
||||
|
||||
.cm-editor .fold-gutter-icon[data-open] {
|
||||
@apply pt-[0.38em] pl-[0.3em];
|
||||
}
|
||||
|
||||
.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-600;
|
||||
}
|
||||
|
||||
.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 text-gray-700 border border-gray-200 z-50 pointer-events-auto text-sm;
|
||||
|
||||
&.cm-completionInfo-right {
|
||||
@apply ml-1;
|
||||
}
|
||||
|
||||
&.cm-completionInfo-right-narrow {
|
||||
@apply ml-1;
|
||||
}
|
||||
|
||||
* {
|
||||
@apply transition-none;
|
||||
}
|
||||
|
||||
&.cm-tooltip-autocomplete {
|
||||
& > ul {
|
||||
@apply p-1 max-h-[40vh];
|
||||
}
|
||||
|
||||
& > ul > li {
|
||||
@apply cursor-default px-2 rounded-sm text-gray-600 h-7 flex items-center;
|
||||
}
|
||||
|
||||
& > ul > li[aria-selected] {
|
||||
@apply bg-highlight text-gray-900;
|
||||
}
|
||||
|
||||
& > ul > li:hover {
|
||||
@apply text-gray-800;
|
||||
}
|
||||
|
||||
.cm-completionIcon {
|
||||
@apply text-sm flex items-center pb-0.5;
|
||||
}
|
||||
|
||||
|
||||
.cm-completionLabel {
|
||||
}
|
||||
|
||||
.cm-completionDetail {
|
||||
@apply ml-auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Add default icon. Needs low priority so it can be overwritten */
|
||||
.cm-completionIcon::after {
|
||||
content: '𝑥';
|
||||
}
|
||||
@@ -1,297 +0,0 @@
|
||||
import { defaultKeymap } from '@codemirror/commands';
|
||||
import { Compartment, EditorState, Transaction } from '@codemirror/state';
|
||||
import type { ViewUpdate } from '@codemirror/view';
|
||||
import { keymap, placeholder as placeholderExt, tooltips } from '@codemirror/view';
|
||||
import classnames from 'classnames';
|
||||
import { EditorView } from 'codemirror';
|
||||
import type { MutableRefObject, ReactNode } from 'react';
|
||||
import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef } from 'react';
|
||||
import { IconButton } from '../IconButton';
|
||||
import { HStack } from '../Stacks';
|
||||
import './Editor.css';
|
||||
import { baseExtensions, getLanguageExtension, multiLineExtensions } from './extensions';
|
||||
import type { GenericCompletionConfig } from './genericCompletion';
|
||||
import { singleLineExt } from './singleLine';
|
||||
|
||||
// Export some things so all the code-split parts are in this file
|
||||
export { buildClientSchema, getIntrospectionQuery } from 'graphql/utilities';
|
||||
export { graphql } from 'cm6-graphql';
|
||||
export { formatSdl } from 'format-graphql';
|
||||
|
||||
export interface EditorProps {
|
||||
id?: string;
|
||||
readOnly?: boolean;
|
||||
type?: 'text' | 'password';
|
||||
className?: string;
|
||||
heightMode?: 'auto' | 'full';
|
||||
contentType?: string;
|
||||
forceUpdateKey?: string;
|
||||
autoFocus?: boolean;
|
||||
defaultValue?: string;
|
||||
placeholder?: string;
|
||||
tooltipContainer?: HTMLElement;
|
||||
useTemplating?: boolean;
|
||||
onChange?: (value: string) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
singleLine?: boolean;
|
||||
format?: (v: string) => string;
|
||||
autocomplete?: GenericCompletionConfig;
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
|
||||
{
|
||||
readOnly,
|
||||
type = 'text',
|
||||
heightMode,
|
||||
contentType,
|
||||
autoFocus,
|
||||
placeholder,
|
||||
useTemplating,
|
||||
defaultValue,
|
||||
forceUpdateKey,
|
||||
onChange,
|
||||
onFocus,
|
||||
onBlur,
|
||||
className,
|
||||
singleLine,
|
||||
format,
|
||||
autocomplete,
|
||||
actions,
|
||||
}: EditorProps,
|
||||
ref,
|
||||
) {
|
||||
const cm = useRef<{ view: EditorView; languageCompartment: Compartment } | null>(null);
|
||||
useImperativeHandle(ref, () => cm.current?.view);
|
||||
|
||||
// Use ref so we can update the onChange handler without re-initializing the editor
|
||||
const handleChange = useRef<EditorProps['onChange']>(onChange);
|
||||
useEffect(() => {
|
||||
handleChange.current = onChange;
|
||||
}, [onChange]);
|
||||
|
||||
// Use ref so we can update the onChange handler without re-initializing the editor
|
||||
const handleFocus = useRef<EditorProps['onFocus']>(onFocus);
|
||||
useEffect(() => {
|
||||
handleFocus.current = onFocus;
|
||||
}, [onFocus]);
|
||||
|
||||
// Use ref so we can update the onChange handler without re-initializing the editor
|
||||
const handleBlur = useRef<EditorProps['onBlur']>(onBlur);
|
||||
useEffect(() => {
|
||||
handleBlur.current = onBlur;
|
||||
}, [onBlur]);
|
||||
|
||||
// Update placeholder
|
||||
const placeholderCompartment = useRef(new Compartment());
|
||||
useEffect(() => {
|
||||
if (cm.current === null) return;
|
||||
const effect = placeholderCompartment.current.reconfigure(
|
||||
placeholderExt(placeholderElFromText(placeholder ?? '')),
|
||||
);
|
||||
cm.current?.view.dispatch({ effects: effect });
|
||||
}, [placeholder]);
|
||||
|
||||
// Update language extension when contentType changes
|
||||
useEffect(() => {
|
||||
if (cm.current === null) return;
|
||||
const { view, languageCompartment } = cm.current;
|
||||
const ext = getLanguageExtension({ contentType, useTemplating, autocomplete });
|
||||
view.dispatch({ effects: languageCompartment.reconfigure(ext) });
|
||||
}, [contentType, autocomplete, useTemplating]);
|
||||
|
||||
useEffect(() => {
|
||||
if (cm.current === null) return;
|
||||
const { view } = cm.current;
|
||||
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: defaultValue ?? '' } });
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [forceUpdateKey]);
|
||||
|
||||
// Initialize the editor when ref mounts
|
||||
const initEditorRef = useCallback((container: HTMLDivElement | null) => {
|
||||
if (container === null) {
|
||||
cm.current?.view.destroy();
|
||||
cm.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
let view: EditorView;
|
||||
try {
|
||||
const languageCompartment = new Compartment();
|
||||
const langExt = getLanguageExtension({ contentType, useTemplating, autocomplete });
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: `${defaultValue ?? ''}`,
|
||||
extensions: [
|
||||
languageCompartment.of(langExt),
|
||||
placeholderCompartment.current.of(
|
||||
placeholderExt(placeholderElFromText(placeholder ?? '')),
|
||||
),
|
||||
...getExtensions({
|
||||
container,
|
||||
onChange: handleChange,
|
||||
onFocus: handleFocus,
|
||||
onBlur: handleBlur,
|
||||
readOnly,
|
||||
singleLine,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
view = new EditorView({ state, parent: container });
|
||||
cm.current = { view, languageCompartment };
|
||||
syncGutterBg({ parent: container, className });
|
||||
if (autoFocus) view.focus();
|
||||
} catch (e) {
|
||||
console.log('Failed to initialize Codemirror', e);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const cmContainer = (
|
||||
<div
|
||||
ref={initEditorRef}
|
||||
className={classnames(
|
||||
className,
|
||||
'cm-wrapper text-base bg-gray-50',
|
||||
type === 'password' && 'cm-obscure-text',
|
||||
heightMode === 'auto' ? 'cm-auto-height' : 'cm-full-height',
|
||||
singleLine ? 'cm-singleline' : 'cm-multiline',
|
||||
readOnly && 'cm-readonly',
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
if (singleLine) {
|
||||
return cmContainer;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group relative h-full w-full">
|
||||
{cmContainer}
|
||||
{format && (
|
||||
<HStack space={0.5} alignItems="center" className="absolute bottom-2 right-0 ">
|
||||
{actions}
|
||||
<IconButton
|
||||
showConfirm
|
||||
size="sm"
|
||||
title="Reformat contents"
|
||||
icon="magicWand"
|
||||
className="transition-opacity opacity-0 group-hover:opacity-70"
|
||||
onClick={() => {
|
||||
if (cm.current === null) return;
|
||||
const { doc } = cm.current.view.state;
|
||||
const insert = format(doc.toString());
|
||||
// Update editor and blur because the cursor will reset anyway
|
||||
cm.current.view.dispatch({ changes: { from: 0, to: doc.length, insert } });
|
||||
cm.current.view.contentDOM.blur();
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const Editor = memo(_Editor);
|
||||
|
||||
function getExtensions({
|
||||
container,
|
||||
readOnly,
|
||||
singleLine,
|
||||
onChange,
|
||||
onFocus,
|
||||
onBlur,
|
||||
}: Pick<EditorProps, 'singleLine' | 'readOnly'> & {
|
||||
container: HTMLDivElement | null;
|
||||
onChange: MutableRefObject<EditorProps['onChange']>;
|
||||
onFocus: MutableRefObject<EditorProps['onFocus']>;
|
||||
onBlur: MutableRefObject<EditorProps['onBlur']>;
|
||||
}) {
|
||||
// TODO: Ensure tooltips render inside the dialog if we are in one.
|
||||
const parent =
|
||||
container?.closest<HTMLDivElement>('[role="dialog"]') ??
|
||||
document.querySelector<HTMLDivElement>('#cm-portal') ??
|
||||
undefined;
|
||||
|
||||
return [
|
||||
...baseExtensions,
|
||||
tooltips({ parent }),
|
||||
keymap.of(singleLine ? defaultKeymap.filter((k) => k.key !== 'Enter') : defaultKeymap),
|
||||
...(singleLine ? [singleLineExt()] : []),
|
||||
...(!singleLine ? [multiLineExtensions] : []),
|
||||
...(readOnly ? [EditorState.readOnly.of(true)] : []),
|
||||
...(singleLine
|
||||
? [
|
||||
EditorView.domEventHandlers({
|
||||
focus: (e, view) => {
|
||||
// select all text on focus, like a regular input does
|
||||
view.dispatch({ selection: { anchor: 0, head: view.state.doc.length } });
|
||||
},
|
||||
keydown: (e) => {
|
||||
// Submit nearest form on enter if there is one
|
||||
if (e.key === 'Enter') {
|
||||
const el = e.currentTarget as HTMLElement;
|
||||
const form = el.closest('form');
|
||||
form?.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
|
||||
}
|
||||
},
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
|
||||
// Handle onFocus
|
||||
EditorView.domEventHandlers({
|
||||
focus: onFocus.current,
|
||||
blur: onBlur.current,
|
||||
}),
|
||||
|
||||
// Handle onChange
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (onChange && update.docChanged && isViewUpdateFromUserInput(update)) {
|
||||
onChange.current?.(update.state.doc.toString());
|
||||
}
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function isViewUpdateFromUserInput(viewUpdate: ViewUpdate) {
|
||||
// Make sure document has changed, ensuring user events like selections don't count.
|
||||
if (viewUpdate.docChanged) {
|
||||
// Check transactions for any that are direct user input, not changes from Y.js or another extension.
|
||||
for (const transaction of viewUpdate.transactions) {
|
||||
// Not using Transaction.isUserEvent because that only checks for a specific User event type ( "input", "delete", etc.). Checking the annotation directly allows for any type of user event.
|
||||
const userEventType = transaction.annotation(Transaction.userEvent);
|
||||
if (userEventType) return userEventType;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const syncGutterBg = ({
|
||||
parent,
|
||||
className = '',
|
||||
}: {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const placeholderElFromText = (text: string) => {
|
||||
const el = document.createElement('div');
|
||||
el.innerHTML = text.replace('\n', '<br/>');
|
||||
return el;
|
||||
};
|
||||
@@ -1,35 +0,0 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
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 { graphql, graphqlLanguageSupport } from 'cm6-graphql';
|
||||
import type { EditorProps } from './index';
|
||||
import { text } from './text/extension';
|
||||
import { twig } from './twig/extension';
|
||||
import { url } from './url/extension';
|
||||
|
||||
export const myHighlightStyle = HighlightStyle.define([
|
||||
{
|
||||
tag: [t.documentMeta, t.blockComment, t.lineComment, t.docComment, t.comment],
|
||||
color: 'hsl(var(--color-gray-600))',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
{
|
||||
tag: [t.paren],
|
||||
color: 'hsl(var(--color-gray-900))',
|
||||
},
|
||||
{
|
||||
tag: [t.name, t.tagName, t.angleBracket, t.docString, t.number],
|
||||
color: 'hsl(var(--color-blue-600))',
|
||||
},
|
||||
{ tag: [t.variableName], color: 'hsl(var(--color-green-600))' },
|
||||
{ tag: [t.bool], color: 'hsl(var(--color-pink-600))' },
|
||||
{ tag: [t.attributeName, t.propertyName], color: 'hsl(var(--color-violet-600))' },
|
||||
{ tag: [t.attributeValue], color: 'hsl(var(--color-orange-600))' },
|
||||
{ tag: [t.string], color: 'hsl(var(--color-yellow-600))' },
|
||||
{ tag: [t.keyword, t.meta, t.operator], color: 'hsl(var(--color-red-600))' },
|
||||
]);
|
||||
|
||||
// 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': graphqlLanguageSupport(),
|
||||
'application/json': json(),
|
||||
'application/javascript': javascript(),
|
||||
'text/html': html(),
|
||||
'application/xml': xml(),
|
||||
'text/xml': xml(),
|
||||
url: url(),
|
||||
};
|
||||
|
||||
export function getLanguageExtension({
|
||||
contentType,
|
||||
useTemplating = false,
|
||||
autocomplete,
|
||||
}: Pick<EditorProps, 'contentType' | 'useTemplating' | 'autocomplete'>) {
|
||||
if (contentType === 'application/graphql') {
|
||||
return graphql();
|
||||
}
|
||||
const justContentType = contentType?.split(';')[0] ?? contentType ?? '';
|
||||
const base = syntaxExtensions[justContentType] ?? text();
|
||||
if (!useTemplating) {
|
||||
return base ? base : [];
|
||||
}
|
||||
|
||||
return twig(base, autocomplete);
|
||||
}
|
||||
|
||||
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: false,
|
||||
interactionDelay: 200,
|
||||
compareCompletions: (a, b) => {
|
||||
// Don't sort completions at all, only on boost
|
||||
return (a.boost ?? 0) - (b.boost ?? 0);
|
||||
},
|
||||
}),
|
||||
syntaxHighlighting(myHighlightStyle),
|
||||
EditorState.allowMultipleSelections.of(true),
|
||||
];
|
||||
|
||||
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,
|
||||
]),
|
||||
];
|
||||
@@ -1,29 +0,0 @@
|
||||
import type { CompletionContext } from '@codemirror/autocomplete';
|
||||
|
||||
export interface GenericCompletionOption {
|
||||
label: string;
|
||||
type: 'constant' | 'variable';
|
||||
/** When given, should be a number from -99 to 99 that adjusts
|
||||
* how this completion is ranked compared to other completions
|
||||
* that match the input as well as this one. A negative number
|
||||
* moves it down the list, a positive number moves it up. */
|
||||
boost?: number;
|
||||
}
|
||||
|
||||
export interface GenericCompletionConfig {
|
||||
minMatch?: number;
|
||||
options: GenericCompletionOption[];
|
||||
}
|
||||
|
||||
export function genericCompletion({ options, minMatch = 1 }: GenericCompletionConfig) {
|
||||
return function completions(context: CompletionContext) {
|
||||
const toMatch = context.matchBefore(/^.*/);
|
||||
if (toMatch === null) return null;
|
||||
|
||||
const matchedMinimumLength = toMatch.to - toMatch.from >= minMatch;
|
||||
if (!matchedMinimumLength && !context.explicit) return null;
|
||||
|
||||
const optionsWithoutExactMatches = options.filter((o) => o.label !== toMatch.text);
|
||||
return { from: toMatch.from, options: optionsWithoutExactMatches, info: 'hello' };
|
||||
};
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import * as editor from './Editor';
|
||||
|
||||
export type { EditorProps } from './Editor';
|
||||
// TODO: Figure out why code-splitting breaks production build from
|
||||
// showing any content
|
||||
// const editor = await import('./Editor');
|
||||
|
||||
export const Editor = editor.Editor;
|
||||
export const graphql = editor.graphql;
|
||||
export const getIntrospectionQuery = editor.getIntrospectionQuery;
|
||||
export const buildClientSchema = editor.buildClientSchema;
|
||||
export const formatGraphQL = editor.formatSdl;
|
||||
@@ -1,76 +0,0 @@
|
||||
import type { DecorationSet, ViewUpdate } from '@codemirror/view';
|
||||
import { Decoration, EditorView, MatchDecorator, ViewPlugin, WidgetType } from '@codemirror/view';
|
||||
|
||||
class PlaceholderWidget extends WidgetType {
|
||||
constructor(readonly name: string) {
|
||||
super();
|
||||
}
|
||||
eq(other: PlaceholderWidget) {
|
||||
return this.name == other.name;
|
||||
}
|
||||
toDOM() {
|
||||
const elt = document.createElement('span');
|
||||
elt.className = 'placeholder-widget';
|
||||
elt.textContent = this.name;
|
||||
return elt;
|
||||
}
|
||||
ignoreEvent() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This is a custom MatchDecorator that will not decorate a match if the selection is inside it
|
||||
*/
|
||||
class BetterMatchDecorator extends MatchDecorator {
|
||||
updateDeco(update: ViewUpdate, deco: DecorationSet): DecorationSet {
|
||||
if (!update.startState.selection.eq(update.state.selection)) {
|
||||
return super.createDeco(update.view);
|
||||
} else {
|
||||
return super.updateDeco(update, deco);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const placeholderMatcher = new BetterMatchDecorator({
|
||||
regexp: /\$\{\[\s*([^\]\s]+)\s*]}/g,
|
||||
decoration(match, view, matchStartPos) {
|
||||
const matchEndPos = matchStartPos + match[0].length - 1;
|
||||
|
||||
// Don't decorate if the cursor is inside the match
|
||||
for (const r of view.state.selection.ranges) {
|
||||
if (r.from > matchStartPos && r.to <= matchEndPos) return null;
|
||||
}
|
||||
|
||||
const groupMatch = match[1];
|
||||
if (groupMatch == null) {
|
||||
// Should never happen, but make TS happy
|
||||
console.warn('Group match was empty', match);
|
||||
return null;
|
||||
}
|
||||
|
||||
return Decoration.replace({
|
||||
inclusive: true,
|
||||
widget: new PlaceholderWidget(groupMatch),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const placeholders = ViewPlugin.fromClass(
|
||||
class {
|
||||
placeholders: DecorationSet;
|
||||
constructor(view: EditorView) {
|
||||
this.placeholders = placeholderMatcher.createDeco(view);
|
||||
}
|
||||
update(update: ViewUpdate) {
|
||||
this.placeholders = placeholderMatcher.updateDeco(update, this.placeholders);
|
||||
}
|
||||
},
|
||||
{
|
||||
decorations: (instance) => instance.placeholders,
|
||||
provide: (plugin) =>
|
||||
EditorView.atomicRanges.of((view) => {
|
||||
return view.plugin(plugin)?.placeholders || Decoration.none;
|
||||
}),
|
||||
},
|
||||
);
|
||||
@@ -1,29 +0,0 @@
|
||||
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;
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { LanguageSupport, LRLanguage } from '@codemirror/language';
|
||||
import { parser } from './text';
|
||||
|
||||
export const textLanguageName = 'text';
|
||||
|
||||
const textLanguage = LRLanguage.define({
|
||||
name: textLanguageName,
|
||||
parser,
|
||||
languageData: {},
|
||||
});
|
||||
|
||||
export function text() {
|
||||
return new LanguageSupport(textLanguage);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
@top Template { Text }
|
||||
|
||||
@tokens {
|
||||
Text { ![]+ }
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
export const
|
||||
Template = 1,
|
||||
Text = 2
|
||||
@@ -1,16 +0,0 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
import {LRParser} from "@lezer/lr"
|
||||
export const parser = LRParser.deserialize({
|
||||
version: 14,
|
||||
states: "[OQOPOOQOOOOO",
|
||||
stateData: "V~OQPO~O",
|
||||
goto: "QPP",
|
||||
nodeNames: "⚠ Template Text",
|
||||
maxTerm: 3,
|
||||
skippedNodes: [0],
|
||||
repeatNodeCount: 0,
|
||||
tokenData: "p~RRO;'S[;'S;=`j<%lO[~aRQ~O;'S[;'S;=`j<%lO[~mP;=`<%l[",
|
||||
tokenizers: [0],
|
||||
topRules: {"Template":[0,1]},
|
||||
tokenPrec: 0
|
||||
})
|
||||
@@ -1,56 +0,0 @@
|
||||
import type { CompletionContext } from '@codemirror/autocomplete';
|
||||
|
||||
const openTag = '${[ ';
|
||||
const closeTag = ' ]}';
|
||||
|
||||
const variables = [
|
||||
{ name: 'DOMAIN' },
|
||||
{ name: 'BASE_URL' },
|
||||
{ name: 'CONTENT_THINGY' },
|
||||
{ name: 'TOKEN' },
|
||||
{ name: 'PROJECT_ID' },
|
||||
{ name: 'DUMMY' },
|
||||
{ name: 'DUMMY_2' },
|
||||
{ name: 'STRIPE_PUB_KEY' },
|
||||
{ name: 'RAILWAY_TOKEN' },
|
||||
{ name: 'SECRET' },
|
||||
{ name: 'PORT' },
|
||||
];
|
||||
|
||||
const MIN_MATCH_VAR = 2;
|
||||
const MIN_MATCH_NAME = 3;
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import type { LanguageSupport } from '@codemirror/language';
|
||||
import { LRLanguage } from '@codemirror/language';
|
||||
import { parseMixed } from '@lezer/common';
|
||||
import type { GenericCompletionConfig } from '../genericCompletion';
|
||||
import { genericCompletion } from '../genericCompletion';
|
||||
import { placeholders } from '../placeholder';
|
||||
import { textLanguageName } from '../text/extension';
|
||||
import { completions } from './completion';
|
||||
import { parser as twigParser } from './twig';
|
||||
|
||||
export function twig(base: LanguageSupport, autocomplete?: GenericCompletionConfig) {
|
||||
const language = mixLanguage(base);
|
||||
const completion = language.data.of({ autocomplete: completions });
|
||||
const completionBase = base.language.data.of({ autocomplete: completions });
|
||||
const additionalCompletion = autocomplete
|
||||
? [language.data.of({ autocomplete: genericCompletion(autocomplete) })]
|
||||
: [];
|
||||
|
||||
return [
|
||||
language,
|
||||
completion,
|
||||
completionBase,
|
||||
base.support,
|
||||
placeholders,
|
||||
...additionalCompletion,
|
||||
];
|
||||
}
|
||||
|
||||
function mixLanguage(base: LanguageSupport): LRLanguage {
|
||||
const name = 'twig';
|
||||
|
||||
const parser = twigParser.configure({
|
||||
wrap: parseMixed((node) => {
|
||||
// If the base language is text, we can overwrite at the top
|
||||
if (base.language.name !== textLanguageName && !node.type.isTop) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
parser: base.language.parser,
|
||||
overlay: (node) => node.type.name === 'Text',
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
return LRLanguage.define({ name, parser });
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { styleTags, tags as t } from '@lezer/highlight';
|
||||
|
||||
export const highlight = styleTags({
|
||||
Open: t.tagName,
|
||||
Close: t.tagName,
|
||||
Content: t.keyword,
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
@top Template { (Tag | Text)* }
|
||||
|
||||
@local tokens {
|
||||
Close { "]}" }
|
||||
@else Content
|
||||
}
|
||||
|
||||
@skip { } {
|
||||
Open { "${[" }
|
||||
Tag { Open (Content)+ Close }
|
||||
}
|
||||
|
||||
@tokens {
|
||||
Text { ![$] Text? }
|
||||
}
|
||||
|
||||
@external propSource highlight from "./highlight"
|
||||