Compare commits

..
Author SHA1 Message Date
Gregory Schier bdf89ac288 Fix platform check 2023-03-14 00:15:01 -07:00
Gregory Schier debd3c8185 Some small changes 2023-03-14 00:08:03 -07:00
Gregory Schier f81a3ae8e7 Move stuff around 2023-03-13 23:30:14 -07:00
Gregory Schier 7d4e9894c3 Refactor hooks to be easier to use 2023-03-13 23:25:41 -07:00
Gregory Schier 4bf22d8a60 Fix header editor and scroll in general 2023-03-13 19:37:36 -07:00
Gregory Schier 8be4971a23 Lazy load routes 2023-03-13 13:56:13 -07:00
Gregory Schier 359e916b73 Back to React 2023-03-13 09:50:49 -07:00
Gregory Schier 68058f3e41 Move some stuff around 2023-03-13 09:24:38 -07:00
Gregory Schier 0c6fa3e634 Fix URL bar 2023-03-13 00:13:25 -07:00
Gregory Schier 0fa25c6335 Fix ButtonLink and edit request names 2023-03-13 00:11:23 -07:00
Gregory Schier 5684479f1d Remove old rust cache action 2023-03-12 22:48:43 -07:00
Gregory Schier 2d1603601c Better rust cache 2023-03-12 22:47:43 -07:00
Gregory Schier f5394b2210 Start GraphQL support 2023-03-12 22:43:25 -07:00
Gregory Schier 833db5df06 Fix artifact tag 2023-03-12 21:41:15 -07:00
Gregory Schier 525ac7e980 Remove wasm stuff 2023-03-12 21:25:31 -07:00
Gregory Schier 44a747c80a Use tauri action 2023-03-12 21:13:08 -07:00
Gregory Schier 2056e7f40a Fix traffic lights thingy 2023-03-12 20:47:52 -07:00
Gregory Schier 9b6c1ad364 Cache cargo bin for "install" 2023-03-12 19:10:39 -07:00
Gregory Schier 34987bcacb Refformat 2023-03-12 19:03:27 -07:00
Gregory Schier b62c11222a Fix artifact upload 2023-03-12 19:01:48 -07:00
Gregory Schier b3cee3ace3 Fix dev 2023-03-12 18:39:02 -07:00
Gregory Schier 222c054c95 Split out macos deps 2023-03-12 18:36:25 -07:00
Gregory Schier 46f18a2491 Cache workflow 2023-03-12 18:28:14 -07:00
Gregory Schier f2ca8e2753 Add wasm-pack 2023-03-12 18:19:20 -07:00
Gregory Schier b0d243c378 Install rsw 2023-03-12 18:14:38 -07:00
Gregory Schier 6161fb86c8 Fix artifact names 2023-03-12 18:13:00 -07:00
Gregory Schier b09cc91fe5 Fix build command 2023-03-12 18:11:24 -07:00
Gregory Schier ef1638cbb3 Update secrets context 2023-03-12 18:07:57 -07:00
Gregory Schier 00ef8743f2 Update workflow name 2023-03-12 18:05:45 -07:00
Gregory Schier 68222659e3 Fix workflow 2023-03-12 18:05:13 -07:00
Gregory Schier 69420a4bba Start of auto updates 2023-03-12 18:04:11 -07:00
Gregory Schier 0161bbaeb1 Fix tabbing to tabs 2023-03-11 23:32:39 -08:00
Gregory Schier 948dbfe3cc Fix eslint errors 2023-03-11 23:29:25 -08:00
Gregory Schier 338ba8b189 Got tab content scrolling working 2023-03-11 22:36:13 -08:00
Gregory Schier ca4655b441 Removed some debug stuff 2023-03-10 10:43:15 -08:00
Gregory Schier bf37499428 Refactor editor to update better 2023-03-10 10:39:23 -08:00
Gregory Schier 0b94b57e2a Fix headers persistence and better sending 2023-03-09 13:38:17 -08:00
Gregory Schier fc40aead98 Hook up header editor! 2023-03-09 13:07:13 -08:00
Gregory Schier 7d7f934e6a Fix 2023-03-09 10:58:27 -08:00
Gregory Schier d5fbf4d622 Fix blur de-select speed 2023-03-09 10:57:34 -08:00
Gregory Schier e4f6c919dc Fix Codemirror performance!! 2023-03-09 10:50:55 -08:00
Gregory Schier 4d806ff2b1 Switch to Preact!!! 2023-03-09 00:47:25 -08:00
Gregory Schier bf8f12274f Move some things around 2023-03-08 23:20:15 -08:00
Gregory Schier f4f438d9fe Better scrollbar color 2023-03-08 19:23:24 -08:00
Gregory Schier 2434f373be Zoom, better sizes, color picker, sidebar footer 2023-03-08 19:22:04 -08:00
Gregory Schier 2bb2061f97 Read-only editor 2023-03-08 16:53:13 -08:00
Gregory Schier 2c011a5c2a More theme tweaks 2023-03-08 16:37:20 -08:00
Gregory Schier f66b0ccea1 Debounce autocomplete 2023-03-08 11:25:20 -08:00
Gregory Schier 665dd8447d Minor theme updates again 2023-03-08 09:43:35 -08:00
Gregory Schier 1b61ce31e6 Editor tweaks 2023-03-07 23:05:33 -08:00
Gregory Schier ef4d960698 Remove unneeded space 2023-03-07 22:58:13 -08:00
Gregory Schier b6d557b632 Fix small view 2023-03-07 22:55:51 -08:00
Gregory Schier b700bd356c Minor style tweaks 2023-03-07 22:21:58 -08:00
Gregory Schier 620dd7d3ef Lots more theme stuff 2023-03-07 21:52:21 -08:00
Gregory Schier 6575121902 Start of themes 2023-03-07 11:24:38 -08:00
Gregory Schier 7c1755a0dc More subtle layout tweaks 2023-03-06 08:57:57 -08:00
Gregory Schier 8ad301a666 More layout fiddling and error page 2023-03-04 22:26:00 -08:00
Gregory Schier ae24cd4939 More work on the layout 2023-03-04 21:51:17 -08:00
Gregory Schier 7152e1845e Try new layout and a bunch of editor fixes 2023-03-04 19:06:12 -08:00
Gregory Schier 96c1dd4081 Fix autocomplete inside dialog 2023-03-03 17:03:20 -08:00
Gregory Schier 87c7b3a663 Beginnings of Header Editor 2023-03-03 13:18:57 -08:00
Gregory Schier c1be46a539 Fix tailwind dark selector 2023-03-03 07:54:19 -08:00
Gregory Schier 4655e0018b Fix content type in URL 2023-03-02 23:17:09 -08:00
Gregory Schier da5ba2e3be Add Dialog component 2023-03-02 18:46:10 -08:00
Gregory Schier aaf95f565f More colors 2023-03-02 17:56:53 -08:00
Gregory Schier f32b984e77 Minor style tweaks 2023-03-02 16:16:41 -08:00
Gregory Schier 548aa4c7cd Improved autocompletion! 2023-03-02 11:14:51 -08:00
Gregory Schier 0ccceaac77 Rename, fix autocomplete and singleline, etc... 2023-03-02 10:42:43 -08:00
Gregory Schier 70f534f1d8 Editor placeholder 2023-03-01 14:22:10 -08:00
Gregory Schier 61fe95b300 Some minor bugs 2023-03-01 14:16:02 -08:00
Gregory Schier 915e0e8613 Fix migrations for build and iframe rendering 2023-03-01 10:31:50 -08:00
Gregory Schier aace2580da Tweaks 2023-03-01 10:19:21 -08:00
Gregory Schier 3d36905664 Response streaming 2023-03-01 09:05:00 -08:00
Gregory Schier 0d671423da Autocomplete, and more CM stuff! 2023-02-28 22:54:54 -08:00
Gregory Schier aebfcb9437 Some small tweaks 2023-02-28 17:25:59 -08:00
Gregory Schier be7ef7beb1 Better editor updating 2023-02-28 12:41:03 -08:00
Gregory Schier d77ed0c5cc URL highlighting with inline CM 2023-02-28 11:26:26 -08:00
Gregory Schier e57e7bcec5 Implement request deletion 2023-02-27 15:42:06 -08:00
Gregory Schier a637842ce4 Tauri events for request model updates 2023-02-27 13:28:50 -08:00
Gregory Schier fc54ec49af Split request upsert command 2023-02-27 10:00:57 -08:00
Gregory Schier 5c43d8510a Add toggle for pretty view 2023-02-27 09:08:48 -08:00
Gregory Schier 83f84ded8d Small tweaks 2023-02-26 15:25:55 -08:00
Gregory Schier 5658da34a2 Add variable highlighting widgets 2023-02-26 15:06:14 -08:00
Gregory Schier 38e8ef6535 Dropdown scrolling 2023-02-25 23:33:07 -08:00
Gregory Schier 8c89b06238 Show response body size 2023-02-25 23:08:19 -08:00
Gregory Schier d85c021305 A bunch more small things 2023-02-25 23:04:31 -08:00
Gregory Schier 83bb18df03 Added react-router 2023-02-25 18:04:14 -08:00
Gregory Schier 93105a3e89 Migrations and initial data stuff 2023-02-25 16:39:18 -08:00
Gregory Schier ba3b899115 Minor tweaks 2023-02-24 17:01:48 -08:00
Gregory Schier fcfbc1d1da Dummy requests in sidebar 2023-02-24 16:46:56 -08:00
Gregory Schier 72486b448c Codemirror initial value support 2023-02-24 16:43:47 -08:00
Gregory Schier 7dea1b7870 Send request body 2023-02-24 16:09:19 -08:00
Gregory Schier 4de2c496c9 Vendor basicSetup 2023-02-24 14:51:56 -08:00
Gregory Schier 9e1393a392 Additional methods and tweaks 2023-02-24 14:10:25 -08:00
Gregory Schier 0901690ed6 Focus states 2023-02-24 12:35:13 -08:00
Gregory Schier 95303648cc Hook up theme and clear responses 2023-02-24 12:13:30 -08:00
Gregory Schier 1dbb08c045 SQLite store in proper dir 2023-02-22 20:18:14 -08:00
Gregory Schier 00a7d9a180 Started on grid layout 2023-02-22 19:44:44 -08:00
Gregory Schier 6c549dc086 Save responses in DB 2023-02-22 18:53:44 -08:00
Gregory Schier dc368e326a Better URL bar 2023-02-22 16:15:25 -08:00
Gregory Schier e42188a627 Cleaner URL bar and some improvements 2023-02-22 15:58:04 -08:00
Gregory Schier 7a6a337eff Refactor classname usage 2023-02-21 18:03:57 -08:00
Gregory Schier 3907344884 Some minor tweaks 2023-02-21 17:56:48 -08:00
Gregory Schier 45b7bc2c84 Add system tray 2023-02-20 00:11:15 -08:00
Gregory Schier b95429dbeb Started on general window layout 2023-02-19 23:11:59 -08:00
Gregory Schier 0a0839cc3c Content type highlighting 2023-02-18 21:41:23 -08:00
Gregory Schier d5649b0925 Update editor styles 2023-02-18 21:12:25 -08:00
Gregory Schier bfa3418ee5 Better dir structure 2023-02-18 20:30:39 -08:00
Gregory Schier 51b6147445 Update README 2023-02-18 20:20:47 -08:00
Gregory Schier 81b5678ba1 Add @rsw dir 2023-02-18 20:18:54 -08:00
Gregory Schier de8a36d04b Initial start for WASM 2023-02-18 20:17:01 -08:00
Gregory Schier ec6da90510 Re-add cfg_attr 2023-02-18 19:09:56 -08:00
Gregory Schier 5e55276d79 Fix lint errors 2023-02-18 16:30:29 -08:00
Gregory Schier 92f0ead969 Radix, request methods, and theme stuff 2023-02-18 15:53:03 -08:00
Gregory Schier e69ccb62da Add op_hello 2023-02-17 15:36:54 -08:00
Gregory Schier cf33a7d943 Split to separate files 2023-02-17 13:44:11 -08:00
Gregory Schier 23ed3f3a42 Tailwind, Prettier, ESLint, Typescript runner! 2023-02-17 12:19:13 -08:00
Gregory Schier 5b09b7b613 Oops, fix color 2023-02-16 10:28:01 -08:00
Gregory Schier 507aee40f7 Fixed scrollbar color 2023-02-16 10:25:52 -08:00
Gregory Schier c70ecdc330 Iframe response viewer 2023-02-15 20:36:46 -08:00
Gregory Schier 2418f737e7 Return struct for response 2023-02-15 20:26:33 -08:00
Gregory Schier 2f1d3a5c60 Got Codemirror up and running 2023-02-15 19:53:46 -08:00
Gregory Schier f3fc1fc0c4 Initial setup Vite Tauri 2023-02-15 16:58:56 -08:00
117 changed files with 23908 additions and 0 deletions

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
.prettierrc.cjs
.eslintrc.cjs
env.d.ts
+31
View File
@@ -0,0 +1,31 @@
module.exports = {
extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:import/recommended',
'plugin:jsx-a11y/recommended',
'plugin:@typescript-eslint/recommended',
'eslint-config-prettier',
],
ignorePatterns: ['src-tauri/**/*'],
settings: {
react: {
version: 'detect',
},
'import/resolver': {
node: {
paths: ['src-web'],
extensions: ['.js', '.jsx', '.ts', '.tsx'],
},
},
},
rules: {
"jsx-a11y/no-autofocus": "warn",
"react/react-in-jsx-scope": "off",
"@typescript-eslint/consistent-type-imports": ["error", {
prefer: "type-imports",
disallowTypeAnnotations: true,
fixStyle: "separate-type-imports",
}]
},
};
+49
View File
@@ -0,0 +1,49 @@
name: Generate Artifacts
on:
push:
tags: [ v* ]
jobs:
build-artifacts:
runs-on: ${{ matrix.platform }}
strategy:
fail-fast: false
matrix:
platform: [ ubuntu-latest, macos-latest, windows-latest ]
steps:
- uses: actions/checkout@v3
- uses: dtolnay/rust-toolchain@stable
- name: Cache Rust
uses: actions/cache@v2
with:
path: |
~/.cargo/registry
~/.cargo/git
./src-tauri/target
key: ${{ runner.os }}-cargo-${{ hashFiles('src-tauri/Cargo.lock') }}
- uses: actions/setup-node@v3
with:
node-version: 18
cache: 'npm'
- name: install dependencies (ubuntu only)
if: matrix.platform == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
with:
tagName: v__VERSION__
releaseName: 'App v__VERSION__'
releaseBody: 'See the assets to download this version and install.'
releaseDraft: true
prerelease: false
+26
View File
@@ -0,0 +1,26 @@
# 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
View File
@@ -0,0 +1 @@
18
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
.prettierrc.cjs
+8
View File
@@ -0,0 +1,8 @@
module.exports = {
"trailingComma": "all",
"tabWidth": 2,
"semi": true,
"singleQuote": true,
"printWidth": 100,
"bracketSpacing": true
}
+14
View File
@@ -0,0 +1,14 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Dev Desktop" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/package.json" />
<command value="run" />
<scripts>
<script value="tauri-dev" />
</scripts>
<node-interpreter value="project" />
<envs>
<env name="DATABASE_URL" value="sqlite://$USER_HOME$/Library/Application%20Support/co.schier.yaak/db.sqlite" />
</envs>
<method v="2" />
</configuration>
</component>
+3
View File
@@ -0,0 +1,3 @@
# Tauri REST Client
It's a REST client, yo.
+17
View File
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Yaak App</title>
<!-- <script src="http://localhost:8097"></script>-->
</head>
<body>
<div id="root"></div>
<div id="cm-portal" class="cm-portal" style="pointer-events: auto"></div>
<div id="radix-portal" class="cm-portal"></div>
<script type="module" src="/src-web/main.tsx"></script>
</body>
</html>
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 245 KiB

+13463
View File
File diff suppressed because it is too large. Load diff
+75
View File
@@ -0,0 +1,75 @@
{
"name": "tauri-app",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"tauri-dev": "tauri dev",
"tauri-build": "npm run build:icon && tauri build",
"build": "npm run build:frontend",
"dev": "vite dev",
"lint": "tsc && eslint . --ext .ts,.tsx",
"build:icon": "tauri icon src-tauri/icons/icon.png",
"build:frontend": "vite build",
"test": "vitest",
"coverage": "vitest run --coverage"
},
"dependencies": {
"@codemirror/commands": "^6.2.1",
"@codemirror/lang-html": "^6.4.2",
"@codemirror/lang-javascript": "^6.1.4",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-xml": "^6.0.2",
"@codemirror/language": "^6.6.0",
"@codemirror/search": "^6.2.3",
"@lezer/generator": "^1.2.2",
"@lezer/highlight": "^1.1.3",
"@lezer/lr": "^1.3.3",
"@radix-ui/react-dialog": "^1.0.2",
"@radix-ui/react-dropdown-menu": "^2.0.2",
"@radix-ui/react-icons": "^1.2.0",
"@radix-ui/react-popover": "1.0.3",
"@radix-ui/react-scroll-area": "^1.0.2",
"@radix-ui/react-separator": "^1.0.1",
"@radix-ui/react-tabs": "^1.0.3",
"@tanstack/react-query": "^4.24.10",
"@tauri-apps/api": "^1.2.0",
"@vitejs/plugin-react": "^3.1.0",
"classnames": "^2.3.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.8.1",
"cm6-graphql": "^0.0.4-canary-b30a2325.0",
"codemirror": "^6.0.1",
"format-graphql": "^1.4.0",
"framer-motion": "^9.0.4",
"parse-color": "^1.0.0",
"react-helmet-async": "^1.3.0",
"react-use": "^17.4.0"
},
"devDependencies": {
"@tailwindcss/nesting": "^0.0.0-insiders.565cd3e",
"@tauri-apps/cli": "^1.2.2",
"@types/node": "^18.7.10",
"@types/parse-color": "^1.0.1",
"@types/parse-json": "^4.0.0",
"@types/react-dom": "^18.0.6",
"@typescript-eslint/eslint-plugin": "^5.52.0",
"@typescript-eslint/parser": "^5.52.0",
"autoprefixer": "^10.4.13",
"concurrently": "^7.6.0",
"eslint": "^8.34.0",
"eslint-config-prettier": "^8.6.0",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-react": "^7.32.2",
"postcss": "^8.4.21",
"postcss-nesting": "^11.2.1",
"prettier": "^2.8.4",
"tailwindcss": "^3.2.7",
"typescript": "^4.6.4",
"vite": "^4.0.0",
"vite-plugin-top-level-await": "^1.2.4",
"vitest": "^0.29.2"
}
}
+7
View File
@@ -0,0 +1,7 @@
module.exports = {
plugins: [
require('@tailwindcss/nesting')(require('postcss-nesting')),
require('tailwindcss'),
require('autoprefixer'),
]
}
+4
View File
@@ -0,0 +1,4 @@
# Generated by Cargo
# will have compiled files and executables
/target/
+5199
View File
File diff suppressed because it is too large. Load diff
+40
View File
@@ -0,0 +1,40 @@
[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"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[build-dependencies]
tauri-build = { version = "1.2", features = [] }
[target.'cfg(target_os = "macos")'.dependencies]
objc = { version = "0.2.7" }
cocoa = { version = "0.24.1" }
[dependencies]
serde_json = { version = "1.0", features = ["raw_value"] }
serde = { version = "1.0", features = ["derive"] }
tauri = { version = "1.2", features = ["config-toml", "devtools", "shell-open", "system-tray", "updater", "window-start-dragging"] }
http = { version = "0.2.8" }
reqwest = { version = "0.11.14", features = ["json"] }
tokio = { version = "1.25.0", features = ["sync"] }
futures = { version = "0.3.26" }
deno_core = { version = "0.174.0" }
deno_ast = { version = "0.24.0", features = ["transpiling"] }
sqlx = { version = "0.6.2", features = ["sqlite", "runtime-tokio-rustls", "json", "chrono", "time", "offline"] }
uuid = { version = "1.3.0" }
rand = { version = "0.8.5" }
chrono = { version = "0.4.23", features = ["serde"] }
[features]
# by default Tauri runs in production mode
# when `tauri dev` runs it is executed with `cargo run --no-default-features` if `devPath` is an URL
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"]
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

@@ -0,0 +1,39 @@
CREATE TABLE workspaces
(
id TEXT NOT NULL PRIMARY KEY,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at DATETIME,
name TEXT NOT NULL,
description TEXT NOT NULL
);
CREATE TABLE http_requests
(
id TEXT NOT NULL PRIMARY KEY,
workspace_id TEXT NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at DATETIME,
name TEXT NOT NULL,
url TEXT NOT NULL,
method TEXT NOT NULL,
headers TEXT NOT NULL,
body TEXT
);
CREATE TABLE http_responses
(
id TEXT NOT NULL PRIMARY KEY,
request_id TEXT NOT NULL REFERENCES http_requests (id) ON DELETE CASCADE,
workspace_id TEXT NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at DATETIME,
elapsed INTEGER NOT NULL,
status INTEGER NOT NULL,
status_reason TEXT,
url TEXT NOT NULL,
body TEXT NOT NULL,
headers TEXT NOT NULL
);
@@ -0,0 +1 @@
ALTER TABLE http_responses ADD COLUMN error TEXT NULL;
+1
View File
@@ -0,0 +1 @@
Deno.core.opAsync('op_hello', 'Deno');
+493
View File
@@ -0,0 +1,493 @@
{
"db": "SQLite",
"07d1a1c7b4f3d9625a766e60fd57bb779b71dae30e5bbce34885a911a5a42428": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 1
}
},
"query": "\n DELETE FROM http_responses\n WHERE id = ?\n "
},
"0fa36011553f7ca91113459a5cefd47f990f9b548a95e475ffd6e4b017059488": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 1
}
},
"query": "\n DELETE FROM http_responses\n WHERE request_id = ?\n "
},
"3d2a542964d946ff9854d053b1adf04985d97a6de27b713188505c1f99c77707": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "workspace_id",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 2,
"type_info": "Datetime"
},
{
"name": "updated_at",
"ordinal": 3,
"type_info": "Datetime"
},
{
"name": "deleted_at",
"ordinal": 4,
"type_info": "Datetime"
},
{
"name": "name",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "url",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "method",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "body",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "headers!: sqlx::types::Json<Vec<HttpRequestHeader>>",
"ordinal": 9,
"type_info": "Text"
}
],
"nullable": [
false,
false,
false,
false,
true,
false,
false,
false,
true,
false
],
"parameters": {
"Right": 1
}
},
"query": "\n SELECT id, workspace_id, created_at, updated_at, deleted_at, name, url, method, body,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpRequestHeader>>\"\n FROM http_requests\n WHERE workspace_id = ?\n "
},
"448a1d1f1866ab42c0f81fcf8eb2930bf21dfdd43ca4831bc1a198cf45ac3732": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 1
}
},
"query": "\n DELETE FROM http_requests\n WHERE id = ?\n "
},
"7ec60cbc3c9f26e8af86a21ef6b66e564f4fa518925c92308b04f882237a244e": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "workspace_id",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 2,
"type_info": "Datetime"
},
{
"name": "updated_at",
"ordinal": 3,
"type_info": "Datetime"
},
{
"name": "deleted_at",
"ordinal": 4,
"type_info": "Datetime"
},
{
"name": "name",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "url",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "method",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "body",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "headers!: sqlx::types::Json<Vec<HttpRequestHeader>>",
"ordinal": 9,
"type_info": "Text"
}
],
"nullable": [
false,
false,
false,
false,
true,
false,
false,
false,
true,
false
],
"parameters": {
"Right": 1
}
},
"query": "\n SELECT id, workspace_id, created_at, updated_at, deleted_at, name, url, method, body,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpRequestHeader>>\"\n FROM http_requests\n WHERE id = ?\n ORDER BY created_at DESC\n "
},
"7f623d0e8f1ddad33d356e2d159b776a2bef1a238cb9200d74eb0c5e3983df85": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "workspace_id",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "request_id",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "updated_at",
"ordinal": 3,
"type_info": "Datetime"
},
{
"name": "deleted_at",
"ordinal": 4,
"type_info": "Datetime"
},
{
"name": "created_at",
"ordinal": 5,
"type_info": "Datetime"
},
{
"name": "status",
"ordinal": 6,
"type_info": "Int64"
},
{
"name": "status_reason",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "body",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "elapsed",
"ordinal": 9,
"type_info": "Int64"
},
{
"name": "url",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "error",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "headers!: sqlx::types::Json<Vec<HttpResponseHeader>>",
"ordinal": 12,
"type_info": "Text"
}
],
"nullable": [
false,
false,
false,
false,
true,
false,
false,
true,
false,
false,
false,
true,
false
],
"parameters": {
"Right": 1
}
},
"query": "\n SELECT id, workspace_id, request_id, updated_at, deleted_at,\n created_at, status, status_reason, body, elapsed, url, error,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpResponseHeader>>\"\n FROM http_responses\n WHERE request_id = ?\n ORDER BY created_at ASC\n "
},
"8069c0bd326f659faca7b45b03e5317d7339a168f4cd7776d9f84304bb7ae7ac": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 1,
"type_info": "Datetime"
},
{
"name": "updated_at",
"ordinal": 2,
"type_info": "Datetime"
},
{
"name": "deleted_at",
"ordinal": 3,
"type_info": "Datetime"
},
{
"name": "name",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "description",
"ordinal": 5,
"type_info": "Text"
}
],
"nullable": [
false,
false,
false,
true,
false,
false
],
"parameters": {
"Right": 0
}
},
"query": "\n SELECT id, created_at, updated_at, deleted_at, name, description\n FROM workspaces\n "
},
"a097740ea4ab772ec6f9d8a5144d6871e0b172130d5abe4da61e663155d2bf25": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 7
}
},
"query": "\n INSERT INTO http_requests (id, workspace_id, name, url, method, body, headers, updated_at)\n VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)\n ON CONFLICT (id) DO UPDATE SET\n updated_at = CURRENT_TIMESTAMP,\n name = excluded.name,\n method = excluded.method,\n headers = excluded.headers,\n body = excluded.body,\n url = excluded.url\n "
},
"a83698dcf9a815b881097133edb31a34ba25e7c6c114d463c495342a85371639": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 8
}
},
"query": "\n UPDATE http_responses SET (elapsed, url, status, status_reason, body, error, headers, updated_at) =\n (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) WHERE id = ?;\n "
},
"e767522f92c8c49cd2e563e58737a05092daf9b1dc763bacc82a5c14d696d78e": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 9
}
},
"query": "\n INSERT INTO http_responses (id, request_id, workspace_id, elapsed, url, status, status_reason, body, headers)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);\n "
},
"f116d8cf9aad828135bb8c3a4c8b8e6b857ae13303989e9133a33b2d1cf20e96": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 3
}
},
"query": "\n INSERT INTO workspaces (id, name, description)\n VALUES (?, ?, ?)\n "
},
"fa5011146663ba5675764f33bf55a86b8274aa18737aff427fd1e3fb74ef3535": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 1,
"type_info": "Datetime"
},
{
"name": "updated_at",
"ordinal": 2,
"type_info": "Datetime"
},
{
"name": "deleted_at",
"ordinal": 3,
"type_info": "Datetime"
},
{
"name": "name",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "description",
"ordinal": 5,
"type_info": "Text"
}
],
"nullable": [
false,
false,
false,
true,
false,
false
],
"parameters": {
"Right": 1
}
},
"query": "\n SELECT id, created_at, updated_at, deleted_at, name, description\n FROM workspaces\n WHERE id = ?\n "
},
"fb2c2328678bbdcb64b79ced26f3d7a1b08d315ef6dedfe4d5ae4231c861b079": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "workspace_id",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "request_id",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "updated_at",
"ordinal": 3,
"type_info": "Datetime"
},
{
"name": "deleted_at",
"ordinal": 4,
"type_info": "Datetime"
},
{
"name": "created_at",
"ordinal": 5,
"type_info": "Datetime"
},
{
"name": "status",
"ordinal": 6,
"type_info": "Int64"
},
{
"name": "status_reason",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "body",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "elapsed",
"ordinal": 9,
"type_info": "Int64"
},
{
"name": "url",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "error",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "headers!: sqlx::types::Json<Vec<HttpResponseHeader>>",
"ordinal": 12,
"type_info": "Text"
}
],
"nullable": [
false,
false,
false,
false,
true,
false,
false,
true,
false,
false,
false,
true,
false
],
"parameters": {
"Right": 1
}
},
"query": "\n SELECT id, workspace_id, request_id, updated_at, deleted_at, created_at,\n status, status_reason, body, elapsed, url, error,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpResponseHeader>>\"\n FROM http_responses\n WHERE id = ?\n "
}
}
+433
View File
@@ -0,0 +1,433 @@
#![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::fs::create_dir_all;
use http::header::{ACCEPT, HeaderName, USER_AGENT};
use http::{HeaderMap, HeaderValue, Method};
use reqwest::redirect::Policy;
use sqlx::migrate::Migrator;
use sqlx::sqlite::SqlitePoolOptions;
use sqlx::types::Json;
use sqlx::{Pool, Sqlite};
use tauri::regex::Regex;
use tauri::{AppHandle, Menu, State, Submenu, Wry};
use tauri::{CustomMenuItem, Manager, SystemTray, SystemTrayEvent, SystemTrayMenu, WindowEvent};
use tokio::sync::Mutex;
use window_ext::WindowExt;
mod models;
mod runtime;
mod window_ext;
#[derive(serde::Serialize)]
pub struct CustomResponse {
status: u16,
body: String,
url: String,
method: String,
elapsed: u128,
elapsed2: u128,
headers: HashMap<String, String>,
pub status_reason: Option<&'static str>,
}
async fn migrate_db(
app_handle: AppHandle<Wry>,
db_instance: &Mutex<Pool<Sqlite>>,
) -> Result<(), String> {
let pool = &*db_instance.lock().await;
let p = app_handle
.path_resolver()
.resolve_resource("migrations")
.expect("failed to resolve resource");
let m = Migrator::new(p).await.expect("Failed to load migrations");
m.run(pool).await.expect("Failed to run migrations");
println!("Migrations ran");
Ok(())
}
#[tauri::command]
async fn send_request(
app_handle: AppHandle<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
request_id: &str,
) -> Result<String, String> {
let pool = &*db_instance.lock().await;
let req = models::get_request(request_id, pool)
.await
.expect("Failed to get request");
let mut response = models::create_response(&req.id, 0, "", 0, None, "", vec![], pool)
.await
.expect("Failed to create response");
app_handle.emit_all("updated_response", &response).unwrap();
let start = std::time::Instant::now();
let mut url_string = req.url.to_string();
let mut variables = HashMap::new();
variables.insert("PROJECT_ID", "project_123");
variables.insert("TOKEN", "s3cret");
variables.insert("DOMAIN", "schier.co");
variables.insert("BASE_URL", "https://schier.co");
let re = Regex::new(r"\$\{\[\s*([^]\s]+)\s*]}").expect("Failed to create regex");
url_string = re
.replace(&url_string, |caps: &tauri::regex::Captures| {
let key = caps.get(1).unwrap().as_str();
match variables.get(key) {
Some(v) => v,
None => "",
}
})
.to_string();
if !url_string.starts_with("http://") && !url_string.starts_with("https://") {
url_string = format!("http://{}", url_string);
}
let client = reqwest::Client::builder()
.redirect(Policy::none())
.build()
.expect("Failed to build client");
let mut headers = HeaderMap::new();
headers.insert(USER_AGENT, HeaderValue::from_static("yaak"));
headers.insert(ACCEPT, HeaderValue::from_static("*/*"));
for h in req.headers.0 {
if h.name.is_empty() && h.value.is_empty() {
continue;
}
let header_name = match HeaderName::from_bytes(h.name.as_bytes()) {
Ok(n) => n,
Err(e) => {
eprintln!("Failed to create header name: {}", e);
continue;
}
};
let header_value = match HeaderValue::from_str(h.value.as_str()) {
Ok(n) => n,
Err(e) => {
eprintln!("Failed to create header value: {}", e);
continue;
}
};
headers.insert(header_name, header_value);
}
let m =
Method::from_bytes(req.method.to_uppercase().as_bytes()).expect("Failed to create method");
let builder = client.request(m, url_string.to_string()).headers(headers);
let sendable_req_result = match req.body {
Some(b) => builder.body(b).build(),
None => builder.build(),
};
let sendable_req = match sendable_req_result {
Ok(r) => r,
Err(e) => {
return response_err(response, e.to_string(), app_handle, pool).await;
}
};
let resp = client.execute(sendable_req).await;
let p = app_handle
.path_resolver()
.resolve_resource("plugins/plugin.ts")
.expect("failed to resolve resource");
runtime::run_plugin_sync(p.to_str().unwrap()).unwrap();
match resp {
Ok(v) => {
response.status = v.status().as_u16() as i64;
response.status_reason = v.status().canonical_reason().map(|s| s.to_string());
response.headers = Json(
v.headers()
.iter()
.map(|(k, v)| models::HttpResponseHeader {
name: k.as_str().to_string(),
value: v.to_str().unwrap().to_string(),
})
.collect(),
);
response.url = v.url().to_string();
response.body = v.text().await.expect("Failed to get body");
response.elapsed = start.elapsed().as_millis() as i64;
response = models::update_response(response, pool)
.await
.expect("Failed to update response");
app_handle.emit_all("updated_response", &response).unwrap();
Ok(response.id)
}
Err(e) => response_err(response, e.to_string(), app_handle, pool).await,
}
}
async fn response_err(
mut response: models::HttpResponse,
error: String,
app_handle: AppHandle<Wry>,
pool: &Pool<Sqlite>,
) -> Result<String, String> {
response.error = Some(error.clone());
response = models::update_response(response, pool)
.await
.expect("Failed to update response");
app_handle.emit_all("updated_response", &response).unwrap();
Ok(response.id)
}
#[tauri::command]
async fn create_request(
workspace_id: &str,
name: &str,
app_handle: AppHandle<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<String, String> {
let pool = &*db_instance.lock().await;
let headers = Vec::new();
let created_request =
models::upsert_request(None, workspace_id, name, "GET", None, "", headers, pool)
.await
.expect("Failed to create request");
app_handle
.emit_all("updated_request", &created_request)
.unwrap();
Ok(created_request.id)
}
#[tauri::command]
async fn update_request(
request: models::HttpRequest,
app_handle: AppHandle<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<(), String> {
let pool = &*db_instance.lock().await;
// TODO: Figure out how to make this better
let b2;
let body = match request.body {
Some(b) => {
b2 = b;
Some(b2.as_str())
}
None => None,
};
let updated_request = models::upsert_request(
Some(request.id.as_str()),
request.workspace_id.as_str(),
request.name.as_str(),
request.method.as_str(),
body,
request.url.as_str(),
request.headers.0,
pool,
)
.await
.expect("Failed to update request");
app_handle
.emit_all("updated_request", updated_request)
.unwrap();
Ok(())
}
#[tauri::command]
async fn delete_request(
app_handle: AppHandle<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
request_id: &str,
) -> Result<models::HttpRequest, String> {
let pool = &*db_instance.lock().await;
let req = models::delete_request(request_id, pool)
.await
.expect("Failed to delete request");
app_handle.emit_all("deleted_request", request_id).unwrap();
Ok(req)
}
#[tauri::command]
async fn requests(
workspace_id: &str,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<Vec<models::HttpRequest>, String> {
let pool = &*db_instance.lock().await;
models::find_requests(workspace_id, pool)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn responses(
request_id: &str,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<Vec<models::HttpResponse>, String> {
let pool = &*db_instance.lock().await;
models::find_responses(request_id, pool)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn delete_response(
id: &str,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<(), String> {
let pool = &*db_instance.lock().await;
models::delete_response(id, pool)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn delete_all_responses(
request_id: &str,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<(), String> {
let pool = &*db_instance.lock().await;
models::delete_all_responses(request_id, pool)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn workspaces(
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<Vec<models::Workspace>, String> {
let pool = &*db_instance.lock().await;
let workspaces = models::find_workspaces(pool)
.await
.expect("Failed to find workspaces");
if workspaces.is_empty() {
let workspace = models::create_workspace("Default", "This is the default workspace", pool)
.await
.expect("Failed to create workspace");
Ok(vec![workspace])
} else {
Ok(workspaces)
}
}
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
}
fn main() {
let quit = CustomMenuItem::new("quit".to_string(), "Quit");
let tray_menu = SystemTrayMenu::new().add_item(quit);
let system_tray = SystemTray::new().with_menu(tray_menu);
let default_menu = Menu::os_default("Yaak".to_string().as_str());
let submenu = Submenu::new("Test Menu", Menu::new()
.add_item(CustomMenuItem::new("zoom_reset".to_string(), "Zoom to Actual Size").accelerator("CmdOrCtrl+0"))
.add_item(CustomMenuItem::new("zoom_in".to_string(), "Zoom In").accelerator("CmdOrCtrl+Plus"))
.add_item(CustomMenuItem::new("zoom_out".to_string(), "Zoom Out").accelerator("CmdOrCtrl+-")),
);
let menu = default_menu.add_submenu(submenu);
tauri::Builder::default()
.menu(menu)
.system_tray(system_tray)
.setup(|app| {
let win = app.get_window("main").unwrap();
#[cfg(target_os = "macos")]
win.position_traffic_lights();
Ok(())
})
.setup(|app| {
let dir = app.path_resolver().app_data_dir().unwrap();
create_dir_all(dir.clone()).expect("Problem creating App directory!");
let p = dir.join("db.sqlite");
let p_string = p.to_string_lossy().replace(' ', "%20");
let url = format!("sqlite://{}?mode=rwc", p_string);
println!("DB PATH: {}", p_string);
tauri::async_runtime::block_on(async move {
let pool = SqlitePoolOptions::new()
.connect(url.as_str())
.await
.expect("Failed to connect to database");
let m = Mutex::new(pool);
migrate_db(app.handle(), &m)
.await
.expect("Failed to migrate database");
app.manage(m);
Ok(())
})
})
.on_system_tray_event(|app, event| {
if let SystemTrayEvent::MenuItemClick { id, .. } = event {
match id.as_str() {
"quit" => {
std::process::exit(0);
}
"hide" => {
let window = app.get_window("main").unwrap();
window.hide().unwrap();
}
_ => {}
};
}
})
.on_menu_event(|event| {
match event.menu_item_id() {
"quit" => std::process::exit(0),
"close" => event.window().close().unwrap(),
"zoom_reset" => event.window().emit("zoom", 0).unwrap(),
"zoom_in" => event.window().emit("zoom", 1).unwrap(),
"zoom_out" => event.window().emit("zoom", -1).unwrap(),
_ => {}
};
})
.on_window_event(|e| {
let apply_offset = || {
let win = e.window();
#[cfg(target_os = "macos")]
win.position_traffic_lights();
};
match e.event() {
WindowEvent::Resized(..) => apply_offset(),
WindowEvent::ThemeChanged(..) => apply_offset(),
_ => {}
}
})
.invoke_handler(tauri::generate_handler![
greet,
workspaces,
requests,
send_request,
create_request,
update_request,
delete_request,
responses,
delete_response,
delete_all_responses,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+343
View File
@@ -0,0 +1,343 @@
use rand::distributions::{Alphanumeric, DistString};
use serde::{Deserialize, Serialize};
use sqlx::types::chrono::NaiveDateTime;
use sqlx::types::Json;
use sqlx::{Pool, Sqlite};
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Workspace {
pub id: String,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
pub deleted_at: Option<NaiveDateTime>,
pub name: String,
pub description: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HttpRequestHeader {
pub name: String,
pub value: String,
}
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HttpRequest {
pub id: String,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
pub deleted_at: Option<NaiveDateTime>,
pub workspace_id: String,
pub name: String,
pub url: String,
pub method: String,
pub body: Option<String>,
pub headers: Json<Vec<HttpRequestHeader>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HttpResponseHeader {
pub name: String,
pub value: String,
}
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HttpResponse {
pub id: String,
pub workspace_id: String,
pub request_id: String,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
pub deleted_at: Option<NaiveDateTime>,
pub error: Option<String>,
pub url: String,
pub elapsed: i64,
pub status: i64,
pub status_reason: Option<String>,
pub body: String,
pub headers: Json<Vec<HttpResponseHeader>>,
}
pub async fn find_workspaces(pool: &Pool<Sqlite>) -> Result<Vec<Workspace>, sqlx::Error> {
sqlx::query_as!(
Workspace,
r#"
SELECT id, created_at, updated_at, deleted_at, name, description
FROM workspaces
"#,
)
.fetch_all(pool)
.await
}
pub async fn get_workspace(id: &str, pool: &Pool<Sqlite>) -> Result<Workspace, sqlx::Error> {
sqlx::query_as!(
Workspace,
r#"
SELECT id, created_at, updated_at, deleted_at, name, description
FROM workspaces
WHERE id = ?
"#,
id,
)
.fetch_one(pool)
.await
}
pub async fn create_workspace(
name: &str,
description: &str,
pool: &Pool<Sqlite>,
) -> Result<Workspace, sqlx::Error> {
let id = generate_id("wk");
sqlx::query!(
r#"
INSERT INTO workspaces (id, name, description)
VALUES (?, ?, ?)
"#,
id,
name,
description,
)
.execute(pool)
.await
.expect("Failed to insert new workspace");
get_workspace(&id, pool).await
}
pub async fn upsert_request(
id: Option<&str>,
workspace_id: &str,
name: &str,
method: &str,
body: Option<&str>,
url: &str,
headers: Vec<HttpRequestHeader>,
pool: &Pool<Sqlite>,
) -> Result<HttpRequest, sqlx::Error> {
let generated_id;
let id = match id {
Some(v) => v,
None => {
generated_id = generate_id("rq");
generated_id.as_str()
}
};
let headers_json = Json(headers);
sqlx::query!(
r#"
INSERT INTO http_requests (id, workspace_id, name, url, method, body, headers, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT (id) DO UPDATE SET
updated_at = CURRENT_TIMESTAMP,
name = excluded.name,
method = excluded.method,
headers = excluded.headers,
body = excluded.body,
url = excluded.url
"#,
id,
workspace_id,
name,
url,
method,
body,
headers_json,
)
.execute(pool)
.await
.expect("Failed to insert new request");
get_request(id, pool).await
}
pub async fn find_requests(
workspace_id: &str,
pool: &Pool<Sqlite>,
) -> Result<Vec<HttpRequest>, sqlx::Error> {
sqlx::query_as!(
HttpRequest,
r#"
SELECT id, workspace_id, created_at, updated_at, deleted_at, name, url, method, body,
headers AS "headers!: sqlx::types::Json<Vec<HttpRequestHeader>>"
FROM http_requests
WHERE workspace_id = ?
"#,
workspace_id,
)
.fetch_all(pool)
.await
}
pub async fn get_request(id: &str, pool: &Pool<Sqlite>) -> Result<HttpRequest, sqlx::Error> {
sqlx::query_as!(
HttpRequest,
r#"
SELECT id, workspace_id, created_at, updated_at, deleted_at, name, url, method, body,
headers AS "headers!: sqlx::types::Json<Vec<HttpRequestHeader>>"
FROM http_requests
WHERE id = ?
ORDER BY created_at DESC
"#,
id,
)
.fetch_one(pool)
.await
}
pub async fn delete_request(id: &str, pool: &Pool<Sqlite>) -> Result<HttpRequest, sqlx::Error> {
let req = get_request(id, pool)
.await
.expect("Failed to get request to delete");
let _ = sqlx::query!(
r#"
DELETE FROM http_requests
WHERE id = ?
"#,
id,
)
.execute(pool)
.await;
Ok(req)
}
pub async fn create_response(
request_id: &str,
elapsed: i64,
url: &str,
status: i64,
status_reason: Option<&str>,
body: &str,
headers: Vec<HttpResponseHeader>,
pool: &Pool<Sqlite>,
) -> Result<HttpResponse, sqlx::Error> {
let req = get_request(request_id, pool)
.await
.expect("Failed to get request");
let id = generate_id("rp");
let headers_json = Json(headers);
sqlx::query!(
r#"
INSERT INTO http_responses (id, request_id, workspace_id, elapsed, url, status, status_reason, body, headers)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);
"#,
id,
request_id,
req.workspace_id,
elapsed,
url,
status,
status_reason,
body,
headers_json,
)
.execute(pool)
.await
.expect("Failed to insert new response");
get_response(&id, pool).await
}
pub async fn update_response(
response: HttpResponse,
pool: &Pool<Sqlite>,
) -> Result<HttpResponse, sqlx::Error> {
let headers_json = Json(response.headers);
sqlx::query!(
r#"
UPDATE http_responses SET (elapsed, url, status, status_reason, body, error, headers, updated_at) =
(?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) WHERE id = ?;
"#,
response.elapsed,
response.url,
response.status,
response.status_reason,
response.body,
response.error,
headers_json,
response.id,
)
.execute(pool)
.await
.expect("Failed to update response");
get_response(&response.id, pool).await
}
pub async fn get_response(id: &str, pool: &Pool<Sqlite>) -> Result<HttpResponse, sqlx::Error> {
sqlx::query_as_unchecked!(
HttpResponse,
r#"
SELECT id, workspace_id, request_id, updated_at, deleted_at, created_at,
status, status_reason, body, elapsed, url, error,
headers AS "headers!: sqlx::types::Json<Vec<HttpResponseHeader>>"
FROM http_responses
WHERE id = ?
"#,
id,
)
.fetch_one(pool)
.await
}
pub async fn find_responses(
request_id: &str,
pool: &Pool<Sqlite>,
) -> Result<Vec<HttpResponse>, sqlx::Error> {
sqlx::query_as!(
HttpResponse,
r#"
SELECT id, workspace_id, request_id, updated_at, deleted_at,
created_at, status, status_reason, body, elapsed, url, error,
headers AS "headers!: sqlx::types::Json<Vec<HttpResponseHeader>>"
FROM http_responses
WHERE request_id = ?
ORDER BY created_at ASC
"#,
request_id,
)
.fetch_all(pool)
.await
}
pub async fn delete_response(id: &str, pool: &Pool<Sqlite>) -> Result<(), sqlx::Error> {
let _ = sqlx::query!(
r#"
DELETE FROM http_responses
WHERE id = ?
"#,
id,
)
.execute(pool)
.await;
Ok(())
}
pub async fn delete_all_responses(
request_id: &str,
pool: &Pool<Sqlite>,
) -> Result<(), sqlx::Error> {
let _ = sqlx::query!(
r#"
DELETE FROM http_responses
WHERE request_id = ?
"#,
request_id,
)
.execute(pool)
.await;
Ok(())
}
pub fn generate_id(prefix: &str) -> String {
format!(
"{prefix}_{}",
Alphanumeric.sample_string(&mut rand::thread_rng(), 10)
)
}
+16
View File
@@ -0,0 +1,16 @@
(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);
+117
View File
@@ -0,0 +1,117 @@
use deno_ast::{MediaType, ParseParams, SourceTextInfo};
use deno_core::error::AnyError;
use deno_core::{op, Extension, JsRuntime, ModuleSource, ModuleType, RuntimeOptions};
use std::rc::Rc;
use deno_core::futures::FutureExt;
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 main_module = deno_core::resolve_path(file_path).expect("Failed to resolve path");
let mod_id = runtime
.load_main_module(&main_module, None)
.await
.expect("Failed to load main module");
let result = runtime.mod_evaluate(mod_id);
runtime
.run_event_loop(false)
.await
.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().unwrap();
// Determine what the MediaType is (this is done based on the file
// extension) and whether transpiling is required.
let media_type = MediaType::from(&path);
let (module_type, should_transpile) = match MediaType::from(&path) {
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: code.into_bytes().into_boxed_slice(),
module_type,
module_url_specified: module_specifier.to_string(),
module_url_found: module_specifier.to_string(),
};
Ok(module)
}
.boxed_local()
}
}
+48
View File
@@ -0,0 +1,48 @@
use tauri::{Runtime, Window};
const TRAFFIC_LIGHT_OFFSET_X: f64 = 15.0;
const TRAFFIC_LIGHT_OFFSET_Y: f64 = 22.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);
}
}
}
}
+86
View File
@@ -0,0 +1,86 @@
{
"build": {
"beforeBuildCommand": "npm run build",
"beforeDevCommand": "npm run dev",
"devPath": "http://localhost:1420",
"distDir": "../dist",
"withGlobalTauri": false
},
"package": {
"productName": "Yaak",
"version": "0.0.2"
},
"tauri": {
"windows": [
{
"fullscreen": false,
"height": 800,
"hiddenTitle": true,
"resizable": true,
"title": "Yaak",
"titleBarStyle": "Overlay",
"width": 1400
}
],
"allowlist": {
"all": false,
"fs": {
"scope": [
"$RESOURCE/*"
]
},
"shell": {
"all": false,
"open": true
},
"window": {
"startDragging": true
}
},
"bundle": {
"active": true,
"category": "DeveloperTool",
"copyright": "",
"externalBin": [],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"identifier": "co.schier.yaak",
"longDescription": "",
"resources": [
"plugins/*",
"migrations/*"
],
"shortDescription": "",
"targets": "all",
"deb": {
"depends": []
},
"macOS": {
"exceptionDomain": "",
"frameworks": []
},
"windows": {
"digestAlgorithm": "sha256",
"timestampUrl": ""
}
},
"security": {},
"systemTray": {
"iconAsTemplate": true,
"iconPath": "icons/icon.png"
},
"updater": {
"active": true,
"dialog": true,
"endpoints": [
"https://update.yaak.app/check/{{target}}/{{current_version}}"
],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEMxRDJFREQ1MjExQjdGN0IKUldSN2Z4c2gxZTNTd1FHNCtmYnFXMHVVQzhuNkJOM1cwOFBodmdLall3ckhKenpKUytHSTR1MlkK"
}
}
}
+89
View File
@@ -0,0 +1,89 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { listen } from '@tauri-apps/api/event';
import { MotionConfig } from 'framer-motion';
import { HelmetProvider } from 'react-helmet-async';
import { AppRouter } from './AppRouter';
import { requestsQueryKey } from '../hooks/useRequests';
import { responsesQueryKey } from '../hooks/useResponses';
import { DEFAULT_FONT_SIZE } from '../lib/constants';
import type { HttpRequest, HttpResponse } from '../lib/models';
import { convertDates } from '../lib/models';
const queryClient = new QueryClient();
await listen('updated_request', ({ payload: request }: { payload: HttpRequest }) => {
queryClient.setQueryData(
requestsQueryKey(request.workspaceId),
(requests: HttpRequest[] = []) => {
const newRequests = [];
let found = false;
for (const r of requests) {
if (r.id === request.id) {
found = true;
newRequests.push(convertDates(request));
} else {
newRequests.push(r);
}
}
if (!found) {
newRequests.push(convertDates(request));
}
return newRequests;
},
);
});
await listen('deleted_request', ({ payload: request }: { payload: HttpRequest }) => {
queryClient.setQueryData(requestsQueryKey(request.workspaceId), (requests: HttpRequest[] = []) =>
requests.filter((r) => r.id !== request.id),
);
});
await listen('updated_response', ({ payload: response }: { payload: HttpResponse }) => {
queryClient.setQueryData(
responsesQueryKey(response.requestId),
(responses: HttpResponse[] = []) => {
const newResponses = [];
let found = false;
for (const r of responses) {
if (r.id === response.id) {
found = true;
newResponses.push(convertDates(response));
} else {
newResponses.push(r);
}
}
if (!found) {
newResponses.push(convertDates(response));
}
return newResponses;
},
);
});
await listen('zoom', ({ payload: zoomDelta }: { payload: number }) => {
const fontSize = parseFloat(window.getComputedStyle(document.documentElement).fontSize);
let newFontSize;
if (zoomDelta === 0) {
newFontSize = DEFAULT_FONT_SIZE;
} else if (zoomDelta > 0) {
newFontSize = Math.min(fontSize * 1.1, DEFAULT_FONT_SIZE * 5);
} else if (zoomDelta < 0) {
newFontSize = Math.max(fontSize * 0.9, DEFAULT_FONT_SIZE * 0.4);
}
document.documentElement.style.fontSize = `${newFontSize}px`;
});
export function App() {
return (
<QueryClientProvider client={queryClient}>
<MotionConfig transition={{ duration: 0.1 }}>
<HelmetProvider>
<AppRouter />
</HelmetProvider>
</MotionConfig>
</QueryClientProvider>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { lazy, Suspense } from 'react';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
const Workspaces = lazy(() => import('./Workspaces'));
const Workspace = lazy(() => import('./Workspace'));
const RouteError = lazy(() => import('./RouteError'));
const router = createBrowserRouter([
{
path: '/',
errorElement: <RouteError />,
children: [
{
path: '/',
element: <Workspaces />,
},
{
path: '/workspaces/:workspaceId',
element: <Workspace />,
},
{
path: '/workspaces/:workspaceId/requests/:requestId',
element: <Workspace />,
},
],
},
]);
export function AppRouter() {
return (
<Suspense>
<RouterProvider router={router} />
</Suspense>
);
}
+117
View File
@@ -0,0 +1,117 @@
import classnames from 'classnames';
import { useEffect, useState } from 'react';
import { useUpdateRequest } from '../hooks/useUpdateRequest';
import type { HttpHeader, HttpRequest } from '../lib/models';
import { IconButton } from './core/IconButton';
import { Input } from './core/Input';
import { VStack } from './core/Stacks';
interface Props {
request: HttpRequest;
className?: string;
}
type PairWithId = { header: Partial<HttpHeader>; id: string };
export function HeaderEditor({ request, className }: Props) {
const updateRequest = useUpdateRequest(request);
const saveHeaders = (pairs: PairWithId[]) => {
const headers = pairs.map((p) => ({ name: '', value: '', ...p.header }));
updateRequest.mutate({ headers });
};
const newPair = () => {
return { header: { name: '', value: '' }, id: Math.random().toString() };
};
const [pairs, setPairs] = useState<PairWithId[]>(
request.headers.map((h) => ({ header: h, id: Math.random().toString() })),
);
const setPairsAndSave = (fn: (pairs: PairWithId[]) => PairWithId[]) => {
setPairs((oldPairs) => {
const newPairs = fn(oldPairs);
saveHeaders(newPairs);
return newPairs;
});
};
const handleChangeHeader = (pair: PairWithId) => {
setPairsAndSave((pairs) =>
pairs.map((p) =>
pair.id !== p.id ? p : { id: p.id, header: { ...p.header, ...pair.header } },
),
);
};
useEffect(() => {
const lastPair = pairs[pairs.length - 1];
if (lastPair === undefined) {
setPairs([newPair()]);
return;
}
if (lastPair.header.name !== '' || lastPair.header.value !== '') {
setPairsAndSave((pairs) => [...pairs, newPair()]);
}
}, [pairs]);
const handleDelete = (pair: PairWithId) => {
setPairsAndSave((oldPairs) => oldPairs.filter((p) => p.id !== pair.id));
};
return (
<div className={classnames(className, 'pb-6 grid')}>
<VStack space={2}>
{pairs.map((p, i) => (
<FormRow
key={p.id}
pair={p}
onChange={handleChangeHeader}
onDelete={i < pairs.length - 1 ? handleDelete : undefined}
/>
))}
</VStack>
</div>
);
}
function FormRow({
pair,
onChange,
onDelete,
}: {
pair: PairWithId;
onChange: (pair: PairWithId) => void;
onDelete?: (pair: PairWithId) => void;
}) {
return (
<div className="group grid grid-cols-[1fr_1fr_2.5rem] grid-rows-1 gap-2 items-center">
<Input
hideLabel
useEditor={{ useTemplating: true }}
name="name"
label="Name"
placeholder="name"
defaultValue={pair.header.name}
onChange={(name) => onChange({ id: pair.id, header: { name } })}
/>
<Input
hideLabel
name="value"
label="Value"
useEditor={{ useTemplating: true }}
placeholder="value"
defaultValue={pair.header.value}
onChange={(value) => onChange({ id: pair.id, header: { value } })}
/>
{onDelete && (
<IconButton
icon="trash"
onClick={() => onDelete(pair)}
className="invisible group-hover:visible"
/>
)}
</div>
);
}
+64
View File
@@ -0,0 +1,64 @@
import classnames from 'classnames';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useSendRequest } from '../hooks/useSendRequest';
import { useUpdateRequest } from '../hooks/useUpdateRequest';
import { Editor } from './core/Editor';
import { HeaderEditor } from './HeaderEditor';
import { TabContent, Tabs } from './core/Tabs/Tabs';
import { UrlBar } from './UrlBar';
interface Props {
fullHeight: boolean;
className?: string;
}
export function RequestPane({ fullHeight, className }: Props) {
const activeRequest = useActiveRequest();
const updateRequest = useUpdateRequest(activeRequest);
const sendRequest = useSendRequest(activeRequest);
if (activeRequest === null) return null;
return (
<div className={classnames(className, 'py-2 grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1')}>
<div className="pl-2">
<UrlBar
key={activeRequest.id}
method={activeRequest.method}
url={activeRequest.url}
loading={sendRequest.isLoading}
onMethodChange={(method) => updateRequest.mutate({ method })}
onUrlChange={(url) => updateRequest.mutate({ url })}
sendRequest={sendRequest.mutate}
/>
</div>
<Tabs
tabs={[
{ value: 'body', label: 'JSON' },
{ value: 'params', label: 'Params' },
{ value: 'headers', label: 'Headers' },
{ value: 'auth', label: 'Auth' },
]}
className="mt-2"
tabListClassName="px-2"
defaultValue="body"
label="Request body"
>
<TabContent value="headers" className="pl-2">
<HeaderEditor key={activeRequest.id} request={activeRequest} />
</TabContent>
<TabContent value="body">
<Editor
key={activeRequest.id}
className="!bg-gray-50"
heightMode={fullHeight ? 'full' : 'auto'}
useTemplating
defaultValue={activeRequest.body ?? ''}
contentType="application/graphql+json"
onChange={(body) => updateRequest.mutate({ body })}
/>
</TabContent>
</Tabs>
</div>
);
}
+131
View File
@@ -0,0 +1,131 @@
import classnames from 'classnames';
import { memo, useEffect, useMemo, useState } from 'react';
import { useDeleteResponses } from '../hooks/useDeleteResponses';
import { useDeleteResponse } from '../hooks/useResponseDelete';
import { useResponses } from '../hooks/useResponses';
import { tryFormatJson } from '../lib/formatters';
import type { HttpResponse } from '../lib/models';
import { Dropdown } from './core/Dropdown';
import { Editor } from './core/Editor';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { HStack } from './core/Stacks';
import { StatusColor } from './core/StatusColor';
import { Webview } from './core/Webview';
interface Props {
className?: string;
}
export const ResponsePane = memo(function ResponsePane({ className }: Props) {
const [activeResponseId, setActiveResponseId] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<'pretty' | 'raw'>('pretty');
const responses = useResponses();
const activeResponse: HttpResponse | null = activeResponseId
? responses.find((r) => r.id === activeResponseId) ?? null
: responses[responses.length - 1] ?? null;
const deleteResponse = useDeleteResponse(activeResponse);
const deleteAllResponses = useDeleteResponses(activeResponse?.requestId);
useEffect(() => {
setActiveResponseId(null);
}, [responses.length]);
const contentType = useMemo(
() =>
activeResponse?.headers.find((h) => h.name.toLowerCase() === 'content-type')?.value ??
'text/plain',
[activeResponse],
);
if (activeResponse === null) {
return null;
}
return (
<div className="p-2">
<div
className={classnames(
className,
'max-h-full h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1 ',
'dark:bg-gray-100 rounded-md overflow-hidden border border-gray-200',
'shadow shadow-gray-100 dark:shadow-gray-0',
)}
>
{/*<HStack as={WindowDragRegion} items="center" className="pl-1.5 pr-1">*/}
{/*</HStack>*/}
<HStack
alignItems="center"
className="italic text-gray-700 text-sm w-full mb-1 flex-shrink-0 pl-2"
>
{activeResponse && activeResponse.status > 0 && (
<div className="whitespace-nowrap">
<StatusColor statusCode={activeResponse.status}>
{activeResponse.status}
{activeResponse.statusReason && ` ${activeResponse.statusReason}`}
</StatusColor>
&nbsp;&bull;&nbsp;
{activeResponse.elapsed}ms &nbsp;&bull;&nbsp;
{Math.round(activeResponse.body.length / 1000)} KB
</div>
)}
<HStack alignItems="center" className="ml-auto h-8">
<IconButton
icon={viewMode === 'pretty' ? 'eye' : 'code'}
size="sm"
className="ml-1"
onClick={() => setViewMode((m) => (m === 'pretty' ? 'raw' : 'pretty'))}
/>
<Dropdown
items={[
{
label: 'Clear Response',
onSelect: deleteResponse.mutate,
disabled: responses.length === 0,
},
{
label: 'Clear All Responses',
onSelect: deleteAllResponses.mutate,
disabled: responses.length === 0,
},
'-----',
...responses.slice(0, 10).map((r) => ({
label: r.status + ' - ' + r.elapsed + ' ms',
leftSlot: activeResponse?.id === r.id ? <Icon icon="check" /> : <></>,
onSelect: () => setActiveResponseId(r.id),
})),
]}
>
<IconButton icon="clock" className="ml-auto" size="sm" />
</Dropdown>
</HStack>
</HStack>
{activeResponse?.error ? (
<div className="p-1">
<div className="text-white bg-red-500 px-3 py-2 rounded">{activeResponse.error}</div>
</div>
) : viewMode === 'pretty' && contentType.includes('html') ? (
<Webview body={activeResponse.body} contentType={contentType} url={activeResponse.url} />
) : viewMode === 'pretty' && contentType.includes('json') ? (
<Editor
readOnly
key={`${contentType}:${activeResponse.updatedAt}:pretty`}
className="bg-gray-50 dark:!bg-gray-100"
defaultValue={tryFormatJson(activeResponse?.body)}
contentType={contentType}
/>
) : activeResponse?.body ? (
<Editor
readOnly
key={`${contentType}:${activeResponse.updatedAt}`}
className="bg-gray-50 dark:!bg-gray-100"
defaultValue={activeResponse?.body}
contentType={contentType}
/>
) : null}
</div>
</div>
);
});
+23
View File
@@ -0,0 +1,23 @@
import { useRouteError } from 'react-router-dom';
import { Button } from './core/Button';
import { Heading } from './core/Heading';
import { VStack } from './core/Stacks';
export default function RouteError() {
const error = useRouteError();
const stringified = JSON.stringify(error);
const message = (error as any).message ?? stringified;
return (
<div className="flex items-center justify-center h-full">
<VStack space={5} className="max-w-[30rem] !h-auto">
<Heading>Route Error 🔥</Heading>
<pre className="text-sm select-auto cursor-text bg-gray-100 p-3 rounded whitespace-normal">
{message}
</pre>
<Button to="/" color="primary">
Go Home
</Button>
</VStack>
</div>
);
}
+118
View File
@@ -0,0 +1,118 @@
import classnames from 'classnames';
import { useState } from 'react';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useCreateRequest } from '../hooks/useCreateRequest';
import { useDeleteRequest } from '../hooks/useDeleteRequest';
import { useRequests } from '../hooks/useRequests';
import { useTheme } from '../hooks/useTheme';
import { useUpdateRequest } from '../hooks/useUpdateRequest';
import type { HttpRequest } from '../lib/models';
import { Button } from './core/Button';
import { IconButton } from './core/IconButton';
import { HStack, VStack } from './core/Stacks';
import { WindowDragRegion } from './core/WindowDragRegion';
interface Props {
className?: string;
}
export function Sidebar({ className }: Props) {
const requests = useRequests();
const activeRequest = useActiveRequest();
const deleteRequest = useDeleteRequest(activeRequest);
const createRequest = useCreateRequest({ navigateAfter: true });
const { appearance, toggleAppearance } = useTheme();
return (
<div
className={classnames(
className,
'w-[15rem] bg-gray-100 h-full border-r border-gray-200 relative grid grid-rows-[auto,1fr]',
)}
>
<HStack as={WindowDragRegion} alignItems="center" justifyContent="end">
<IconButton
className="mx-1"
icon="plusCircle"
onClick={async () => {
await createRequest.mutate({ name: 'Test Request' });
}}
/>
</HStack>
<VStack as="ul" className="py-3 px-2 overflow-auto h-full" space={1}>
{requests.map((r) => (
<SidebarItem key={r.id} request={r} active={r.id === activeRequest?.id} />
))}
{/*<Colors />*/}
<HStack
className="absolute bottom-1 left-1 right-0 mx-1"
alignItems="center"
justifyContent="end"
>
<IconButton icon="trash" onClick={() => deleteRequest.mutate()} />
<IconButton icon={appearance === 'dark' ? 'moon' : 'sun'} onClick={toggleAppearance} />
</HStack>
</VStack>
</div>
);
}
function SidebarItem({ request, active }: { request: HttpRequest; active: boolean }) {
const updateRequest = useUpdateRequest(request);
const [editing, setEditing] = useState<boolean>(false);
const handleSubmitNameEdit = async (el: HTMLInputElement) => {
await updateRequest.mutate({ name: el.value });
setEditing(false);
};
const handleFocus = (el: HTMLInputElement | null) => {
el?.focus();
el?.select();
};
return (
<li>
<Button
color="custom"
size="sm"
className={classnames(
editing && 'focus-within:border-blue-400/40',
active
? 'bg-gray-200/70 text-gray-900'
: 'text-gray-600 hover:text-gray-800 active:bg-gray-200/30',
)}
onKeyDown={(e) => {
// Hitting enter on active request during keyboard nav will start edit
if (active && e.key === 'Enter') {
e.preventDefault();
setEditing(true);
}
}}
to={`/workspaces/${request.workspaceId}/requests/${request.id}`}
onDoubleClick={() => setEditing(true)}
justify="start"
>
{editing ? (
<input
ref={handleFocus}
defaultValue={request.name}
className="bg-transparent outline-none w-full"
onBlur={(e) => handleSubmitNameEdit(e.currentTarget)}
onKeyDown={async (e) => {
switch (e.key) {
case 'Enter':
await handleSubmitNameEdit(e.currentTarget);
break;
case 'Escape':
setEditing(false);
break;
}
}}
/>
) : (
<span className="truncate">{request.name || request.url}</span>
)}
</Button>
</li>
);
}
+67
View File
@@ -0,0 +1,67 @@
import { Button } from './core/Button';
import { DropdownMenuRadio } from './core/Dropdown';
import { IconButton } from './core/IconButton';
import { Input } from './core/Input';
interface Props {
sendRequest: () => void;
loading: boolean;
method: string;
url: string;
onMethodChange: (method: string) => void;
onUrlChange: (url: string) => void;
}
export function UrlBar({ sendRequest, loading, onMethodChange, method, onUrlChange, url }: Props) {
return (
<form
onSubmit={async (e) => {
e.preventDefault();
sendRequest();
}}
className="w-full flex items-center"
>
<Input
hideLabel
useEditor={{ useTemplating: true, contentType: 'url' }}
className="px-0"
name="url"
label="Enter URL"
containerClassName="shadow shadow-gray-100 dark:shadow-gray-0"
onChange={onUrlChange}
defaultValue={url}
placeholder="Enter a URL..."
leftSlot={
<DropdownMenuRadio
onValueChange={(v) => onMethodChange(v.value)}
value={method.toUpperCase()}
items={[
{ label: 'GET', value: 'GET' },
{ label: 'PUT', value: 'PUT' },
{ label: 'POST', value: 'POST' },
{ label: 'PATCH', value: 'PATCH' },
{ label: 'DELETE', value: 'DELETE' },
{ label: 'OPTIONS', value: 'OPTIONS' },
{ label: 'HEAD', value: 'HEAD' },
]}
>
<Button type="button" disabled={loading} size="sm" className="mx-0.5" justify="start">
{method.toUpperCase()}
</Button>
</DropdownMenuRadio>
}
rightSlot={
<IconButton
type="submit"
className="mr-0.5"
size="sm"
icon={loading ? 'update' : 'paperPlane'}
spin={loading}
disabled={loading}
title="Send Request"
/>
}
/>
</form>
);
}
+40
View File
@@ -0,0 +1,40 @@
import classnames from 'classnames';
import { useWindowSize } from 'react-use';
import { RequestPane } from './RequestPane';
import { ResponsePane } from './ResponsePane';
import { Sidebar } from './Sidebar';
import { HStack } from './core/Stacks';
import { WindowDragRegion } from './core/WindowDragRegion';
import { useActiveRequest } from '../hooks/useActiveRequest';
export default function Workspace() {
const activeRequest = useActiveRequest();
const { width } = useWindowSize();
const isH = width > 900;
return (
<div className="grid grid-cols-[auto_1fr] grid-rows-1 h-full text-gray-900">
<Sidebar />
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full">
<HStack
as={WindowDragRegion}
className="px-3 bg-gray-50 text-gray-900 border-b border-b-gray-200 pt-[1px]"
alignItems="center"
>
{activeRequest?.name}
</HStack>
<div
className={classnames(
'grid',
isH
? 'grid-cols-[1fr_1fr] grid-rows-1'
: 'grid-cols-1 grid-rows-[minmax(0,auto)_minmax(0,100%)]',
)}
>
<RequestPane fullHeight={isH} className={classnames(!isH && 'pr-2 pb-0')} />
<ResponsePane />
</div>
</div>
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { Button } from './core/Button';
import { Heading } from './core/Heading';
import { VStack } from './core/Stacks';
import { useWorkspaces } from '../hooks/useWorkspaces';
export default function Workspaces() {
const workspaces = useWorkspaces();
return (
<VStack as="ul" className="p-12">
<Heading>Workspaces</Heading>
{workspaces.map((w) => (
<Button key={w.id} color="gray" to={`/workspaces/${w.id}`}>
{w.name}
</Button>
))}
</VStack>
);
}
+84
View File
@@ -0,0 +1,84 @@
import classnames from 'classnames';
import type { HTMLAttributes } from 'react';
import { forwardRef } from 'react';
import { Link } from 'react-router-dom';
import { Icon } from './Icon';
const colorStyles = {
custom: '',
default: 'text-gray-700 enabled:hover:bg-gray-700/10 enabled:hover:text-gray-1000',
gray: 'text-gray-800 bg-gray-100 enabled:hover:bg-gray-500/20 enabled:hover:text-gray-1000',
primary: 'bg-blue-400 text-white hover:bg-blue-500',
secondary: 'bg-violet-400 text-white hover:bg-violet-500',
warning: 'bg-orange-400 text-white hover:bg-orange-500',
danger: 'bg-red-400 text-white hover:bg-red-500',
};
export type ButtonProps = HTMLAttributes<HTMLElement> & {
to?: string;
color?: keyof typeof colorStyles;
size?: 'sm' | 'md';
justify?: 'start' | 'center';
type?: 'button' | 'submit';
forDropdown?: boolean;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const Button = forwardRef<any, ButtonProps>(function Button(
{
to,
className,
children,
forDropdown,
color,
justify = 'center',
size = 'md',
...props
}: ButtonProps,
ref,
) {
if (typeof to === 'string') {
return (
<Link
ref={ref}
to={to}
className={classnames(
className,
'outline-none',
'border border-transparent focus-visible:border-blue-300',
'rounded-md flex items-center',
colorStyles[color || 'default'],
justify === 'start' && 'justify-start',
justify === 'center' && 'justify-center',
size === 'md' && 'h-9 px-3',
size === 'sm' && 'h-7 px-2.5 text-sm',
)}
{...props}
>
{children}
{forDropdown && <Icon icon="triangleDown" className="ml-1 -mr-1" />}
</Link>
);
} else {
return (
<button
ref={ref}
className={classnames(
className,
'outline-none',
'border border-transparent focus-visible:border-blue-300',
'rounded-md flex items-center',
colorStyles[color || 'default'],
justify === 'start' && 'justify-start',
justify === 'center' && 'justify-center',
size === 'md' && 'h-9 px-3',
size === 'sm' && 'h-7 px-2.5 text-sm',
)}
{...props}
>
{children}
{forDropdown && <Icon icon="triangleDown" className="ml-1 -mr-1" />}
</button>
);
}
});
+58
View File
@@ -0,0 +1,58 @@
import * as D from '@radix-ui/react-dialog';
import classnames from 'classnames';
import { motion } from 'framer-motion';
import type { ReactNode } from 'react';
import { IconButton } from './IconButton';
import { HStack, VStack } from './Stacks';
interface Props {
children: ReactNode;
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description?: string;
className?: string;
wide?: boolean;
}
export function Dialog({
children,
className,
wide,
open,
onOpenChange,
title,
description,
}: Props) {
return (
<D.Root open={open} onOpenChange={onOpenChange}>
<D.Portal container={document.querySelector<HTMLElement>('#radix-portal')}>
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
<D.Overlay className="fixed inset-0 bg-gray-600/60 dark:bg-black/50" />
<D.Content>
<div
className={classnames(
className,
'absolute top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%] bg-gray-100',
'w-[20rem] max-h-[80vh] p-5 rounded-lg overflow-auto',
'dark:border border-gray-200 shadow-md shadow-black/10',
wide && 'w-[80vw] max-w-[50rem]',
)}
>
<D.Close asChild className="ml-auto absolute right-1 top-1">
<IconButton aria-label="Close" icon="x" size="sm" />
</D.Close>
<VStack space={3}>
<HStack alignItems="center" className="pb-3">
<D.Title className="text-xl font-semibold">{title}</D.Title>
</HStack>
{description && <D.Description>{description}</D.Description>}
<div>{children}</div>
</VStack>
</div>
</D.Content>
</motion.div>
</D.Portal>
</D.Root>
);
}
+23
View File
@@ -0,0 +1,23 @@
import * as Separator from '@radix-ui/react-separator';
import classnames from 'classnames';
interface Props {
orientation?: 'horizontal' | 'vertical';
decorative?: boolean;
className?: string;
}
export function Divider({ className, orientation = 'horizontal', decorative }: Props) {
return (
<Separator.Root
className={classnames(
className,
'bg-gray-50',
orientation === 'horizontal' && 'w-full h-[1px]',
orientation === 'vertical' && 'h-full w-[1px]',
)}
orientation={orientation}
decorative={decorative}
/>
);
}
+306
View File
@@ -0,0 +1,306 @@
import * as D from '@radix-ui/react-dropdown-menu';
import { CheckIcon } from '@radix-ui/react-icons';
import classnames from 'classnames';
import { motion } from 'framer-motion';
import type { ReactNode, ForwardedRef } from 'react';
import { forwardRef, useImperativeHandle, useLayoutEffect, useState } from 'react';
interface DropdownMenuRadioProps {
children: ReactNode;
onValueChange: ((v: { label: string; value: string }) => void) | null;
value: string;
label?: string;
items: {
label: string;
value: string;
}[];
}
export function DropdownMenuRadio({
children,
items,
onValueChange,
label,
value,
}: DropdownMenuRadioProps) {
const handleChange = (value: string) => {
const item = items.find((item) => item.value === value);
if (item && onValueChange) {
onValueChange(item);
}
};
return (
<D.Root>
<DropdownMenuTrigger>{children}</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent>
{label && <DropdownMenuLabel>{label}</DropdownMenuLabel>}
<D.DropdownMenuRadioGroup onValueChange={handleChange} value={value}>
{items.map((item) => (
<DropdownMenuRadioItem key={item.value} value={item.value}>
{item.label}
</DropdownMenuRadioItem>
))}
</D.DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenuPortal>
</D.Root>
);
}
export interface DropdownProps {
children: ReactNode;
items: (
| {
label: string;
onSelect?: () => void;
disabled?: boolean;
leftSlot?: ReactNode;
}
| '-----'
)[];
}
export function Dropdown({ children, items }: DropdownProps) {
return (
<D.Root>
<DropdownMenuTrigger>{children}</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent>
{items.map((item, i) => {
if (item === '-----') {
return <DropdownMenuSeparator key={i} />;
} else {
return (
<DropdownMenuItem
key={i}
onSelect={() => item.onSelect?.()}
disabled={item.disabled}
leftSlot={item.leftSlot}
>
{item.label}
</DropdownMenuItem>
);
}
})}
</DropdownMenuContent>
</DropdownMenuPortal>
</D.Root>
);
}
interface DropdownMenuPortalProps {
children: ReactNode;
}
function DropdownMenuPortal({ children }: DropdownMenuPortalProps) {
const container = document.querySelector<Element>('#radix-portal');
if (container === null) return null;
return (
<D.Portal>
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
{children}
</motion.div>
</D.Portal>
);
}
const DropdownMenuContent = forwardRef<HTMLDivElement, D.DropdownMenuContentProps>(
function DropdownMenuContent(
{ className, children, ...props }: D.DropdownMenuContentProps,
ref: ForwardedRef<HTMLDivElement>,
) {
const [styles, setStyles] = useState<{ maxHeight: number }>();
const [divRef, setDivRef] = useState<HTMLDivElement | null>(null);
useImperativeHandle<HTMLDivElement | null, HTMLDivElement | null>(ref, () => divRef);
const initDivRef = (ref: HTMLDivElement | null) => {
setDivRef(ref);
};
// Calculate the max height so we can scroll
useLayoutEffect(() => {
if (divRef === null) return;
// Needs to be in a setTimeout because the ref is not positioned yet
// TODO: Make this better?
setTimeout(() => {
const windowBox = document.documentElement.getBoundingClientRect();
const menuBox = divRef.getBoundingClientRect();
const styles = { maxHeight: windowBox.height - menuBox.top - 5 - 45 };
setStyles(styles);
});
}, [divRef]);
return (
<D.Content
ref={initDivRef}
align="start"
className={classnames(
className,
'bg-gray-50 rounded-md shadow-lg p-1.5 border border-gray-200',
'overflow-auto m-1',
)}
style={styles}
{...props}
>
{children}
</D.Content>
);
},
);
type DropdownMenuItemProps = D.DropdownMenuItemProps & ItemInnerProps;
function DropdownMenuItem({
leftSlot,
rightSlot,
className,
children,
disabled,
...props
}: DropdownMenuItemProps) {
return (
<D.Item
asChild
disabled={disabled}
className={classnames(className, disabled && 'opacity-30')}
{...props}
>
<ItemInner leftSlot={leftSlot} rightSlot={rightSlot}>
{children}
</ItemInner>
</D.Item>
);
}
// type DropdownMenuCheckboxItemProps = DropdownMenu.DropdownMenuCheckboxItemProps & ItemInnerProps;
//
// function DropdownMenuCheckboxItem({
// leftSlot,
// rightSlot,
// children,
// ...props
// }: DropdownMenuCheckboxItemProps) {
// return (
// <DropdownMenu.CheckboxItem asChild {...props}>
// <ItemInner leftSlot={leftSlot} rightSlot={rightSlot}>
// {children}
// </ItemInner>
// </DropdownMenu.CheckboxItem>
// );
// }
// type DropdownMenuSubTriggerProps = DropdownMenu.DropdownMenuSubTriggerProps & ItemInnerProps;
//
// function DropdownMenuSubTrigger({
// leftSlot,
// rightSlot,
// children,
// ...props
// }: DropdownMenuSubTriggerProps) {
// return (
// <DropdownMenu.SubTrigger asChild {...props}>
// <ItemInner leftSlot={leftSlot} rightSlot={rightSlot}>
// {children}
// </ItemInner>
// </DropdownMenu.SubTrigger>
// );
// }
type DropdownMenuRadioItemProps = Omit<D.DropdownMenuRadioItemProps & ItemInnerProps, 'leftSlot'>;
function DropdownMenuRadioItem({ rightSlot, children, ...props }: DropdownMenuRadioItemProps) {
return (
<D.RadioItem asChild {...props}>
<ItemInner
leftSlot={
<D.ItemIndicator>
<CheckIcon />
</D.ItemIndicator>
}
rightSlot={rightSlot}
>
{children}
</ItemInner>
</D.RadioItem>
);
}
// const DropdownMenuSubContent = forwardRef<HTMLDivElement, DropdownMenu.DropdownMenuSubContentProps>(
// function DropdownMenuSubContent(
// { className, ...props }: DropdownMenu.DropdownMenuSubContentProps,
// ref,
// ) {
// return (
// <DropdownMenu.SubContent
// ref={ref}
// alignOffset={0}
// sideOffset={4}
// className={classnames(className, dropdownMenuClasses)}
// {...props}
// />
// );
// },
// );
function DropdownMenuLabel({ className, children, ...props }: D.DropdownMenuLabelProps) {
return (
<D.Label asChild {...props}>
<ItemInner noHover className={classnames(className, 'opacity-50 uppercase text-sm')}>
{children}
</ItemInner>
</D.Label>
);
}
function DropdownMenuSeparator({ className, ...props }: D.DropdownMenuSeparatorProps) {
return (
<D.Separator
className={classnames(className, 'h-[1px] bg-gray-400 bg-opacity-30 my-1')}
{...props}
/>
);
}
type DropdownMenuTriggerProps = D.DropdownMenuTriggerProps & {
children: ReactNode;
className?: string;
};
function DropdownMenuTrigger({ children, className, ...props }: DropdownMenuTriggerProps) {
return (
<D.Trigger asChild className={classnames(className)} {...props}>
{children}
</D.Trigger>
);
}
interface ItemInnerProps {
leftSlot?: ReactNode;
rightSlot?: ReactNode;
children: ReactNode;
noHover?: boolean;
className?: string;
}
const ItemInner = forwardRef<HTMLDivElement, ItemInnerProps>(function ItemInner(
{ leftSlot, rightSlot, children, className, noHover, ...props }: ItemInnerProps,
ref,
) {
return (
<div
ref={ref}
className={classnames(
className,
'outline-none px-2 py-1.5 flex items-center text-sm text-gray-700 whitespace-nowrap pr-4',
!noHover && 'focus:bg-gray-50 focus:text-gray-900 rounded',
)}
{...props}
>
{leftSlot && <div className="w-6">{leftSlot}</div>}
<div>{children}</div>
{rightSlot && <div className="ml-auto pl-3">{rightSlot}</div>}
</div>
);
});
+166
View File
@@ -0,0 +1,166 @@
.cm-wrapper {
@apply h-full overflow-hidden;
.cm-editor {
@apply w-full block text-base;
* {
@apply cursor-text;
}
&.cm-focused {
outline: none !important;
}
.cm-line {
@apply text-gray-900 pl-1 pr-1.5;
}
.cm-placeholder {
@apply text-placeholder;
}
/* Don't show selection on blurred input */
.cm-selectionBackground {
@apply bg-transparent;
}
&.cm-focused .cm-selectionBackground {
@apply bg-gray-400;
}
/* Style gutters */
.cm-gutters {
@apply border-0 text-gray-500/60;
.cm-gutterElement {
@apply cursor-default;
}
}
.placeholder-widget {
@apply text-[0.9em] text-gray-800 dark:text-gray-900 px-1 rounded cursor-default dark:shadow;
/* NOTE: Background and border are translucent so we can see text selection through it */
@apply bg-gray-300/40 border border-gray-300 border-opacity-40 hover:border-opacity-80;
/* Bring above on hover */
@apply hover:z-10 relative;
}
}
&.cm-singleline {
.cm-editor {
@apply h-full w-full;
}
.cm-scroller {
@apply font-mono flex text-[0.8rem];
align-items: center !important;
overflow: hidden !important;
}
.cm-line {
@apply px-0;
}
}
&.cm-multiline {
&.cm-full-height {
@apply relative;
.cm-editor {
@apply inset-0 absolute;
position: absolute !important;
}
}
.cm-editor {
@apply h-full;
}
.cm-scroller {
@apply font-mono text-[0.75rem];
}
}
}
.cm-editor .cm-gutterElement {
transition: color var(--transition-duration);
}
.cm-editor .fold-gutter-icon {
@apply pt-[0.3em] pl-[0.4em] px-[0.4em] h-4 cursor-pointer rounded;
}
.cm-editor .fold-gutter-icon::after {
@apply block w-1.5 h-1.5 border-transparent -rotate-45
border-l border-b border-l-[currentColor] border-b-[currentColor] content-[''];
}
.cm-editor .fold-gutter-icon[data-open] {
@apply pt-[0.4em] pl-[0.3em];
}
.cm-editor .fold-gutter-icon[data-open]::after {
@apply rotate-[-135deg];
}
.cm-editor .fold-gutter-icon:hover {
@apply text-gray-900 bg-gray-300/50;
}
.cm-editor .cm-foldPlaceholder {
@apply px-2 border border-gray-400/50 bg-gray-300/50 cursor-default;
@apply hover:text-gray-800 hover:border-gray-400;
}
.cm-editor .cm-activeLineGutter {
@apply bg-transparent;
}
.cm-wrapper:not(.cm-readonly) .cm-editor {
&.cm-focused .cm-activeLineGutter {
@apply text-gray-800;
}
.cm-cursor {
@apply border-l-2 border-gray-800;
}
}
.cm-singleline .cm-editor {
.cm-content {
@apply h-full flex items-center;
}
}
/* NOTE: Extra selector required to override default styles */
.cm-tooltip.cm-tooltip {
@apply shadow-lg bg-gray-50 rounded overflow-hidden text-gray-900 border border-gray-200 z-50 pointer-events-auto;
* {
@apply transition-none;
}
&.cm-tooltip-autocomplete {
& > ul {
@apply p-1 max-h-[40vh];
}
& > ul > li {
@apply cursor-default px-2 rounded-sm text-gray-600 h-7 flex items-center;
}
& > ul > li[aria-selected] {
@apply bg-gray-100 text-gray-900;
}
& > ul > li:hover {
@apply text-gray-800;
}
.cm-completionIcon {
@apply text-sm flex items-center pb-0.5;
}
}
}
+188
View File
@@ -0,0 +1,188 @@
import { defaultKeymap } from '@codemirror/commands';
import { Compartment, EditorState } from '@codemirror/state';
import { keymap, placeholder as placeholderExt, tooltips } from '@codemirror/view';
import classnames from 'classnames';
import { EditorView } from 'codemirror';
import { formatSdl } from 'format-graphql';
import { useEffect, useRef } from 'react';
import { useDebounce, useUnmount } from 'react-use';
import { debounce } from '../../../lib/debounce';
import { IconButton } from '../IconButton';
import './Editor.css';
import { baseExtensions, getLanguageExtension, multiLineExtensions } from './extensions';
import { singleLineExt } from './singleLine';
export interface _EditorProps {
id?: string;
readOnly?: boolean;
className?: string;
heightMode?: 'auto' | 'full';
contentType?: string;
autoFocus?: boolean;
defaultValue?: string;
placeholder?: string;
tooltipContainer?: HTMLElement;
useTemplating?: boolean;
onChange?: (value: string) => void;
singleLine?: boolean;
}
export function _Editor({
readOnly,
heightMode,
contentType,
autoFocus,
placeholder,
useTemplating,
defaultValue,
onChange,
className,
singleLine,
}: _EditorProps) {
const cm = useRef<{ view: EditorView; langHolder: Compartment } | null>(null);
// Unmount the editor
useUnmount(() => {
cm.current?.view.destroy();
cm.current = null;
});
// Update language extension when contentType changes
useEffect(() => {
if (cm.current === null) return;
const { view, langHolder } = cm.current;
const ext = getLanguageExtension({ contentType, useTemplating });
view.dispatch({ effects: langHolder.reconfigure(ext) });
}, [contentType]);
// Initialize the editor
const initDivRef = (el: HTMLDivElement | null) => {
if (el === null || cm.current !== null) return;
try {
const langHolder = new Compartment();
const langExt = getLanguageExtension({ contentType, useTemplating });
const state = EditorState.create({
doc: `${defaultValue ?? ''}`,
extensions: [
langHolder.of(langExt),
...getExtensions({
container: el,
readOnly,
placeholder,
singleLine,
onChange,
contentType,
useTemplating,
}),
],
});
const view = new EditorView({ state, parent: el });
cm.current = { view, langHolder };
syncGutterBg({ parent: el, className });
if (autoFocus) view.focus();
} catch (e) {
console.log('Failed to initialize Codemirror', e);
}
};
return (
<div
ref={initDivRef}
className={classnames(
className,
'cm-wrapper text-base bg-gray-50',
heightMode === 'auto' ? 'cm-auto-height' : 'cm-full-height',
singleLine ? 'cm-singleline' : 'cm-multiline',
readOnly && 'cm-readonly',
)}
>
{contentType?.includes('graphql') && (
<IconButton
icon="eye"
className="absolute right-3 bottom-3 z-10"
onClick={() => {
const doc = cm.current?.view.state.doc ?? '';
const insert = formatSdl(doc.toString());
cm.current?.view.dispatch({ changes: { from: 0, to: doc.length, insert } });
}}
/>
)}
</div>
);
}
function getExtensions({
container,
readOnly,
singleLine,
placeholder,
onChange,
contentType,
useTemplating,
}: Pick<
_EditorProps,
'singleLine' | 'onChange' | 'contentType' | 'useTemplating' | 'placeholder' | 'readOnly'
> & { container: HTMLDivElement | null }) {
const ext = getLanguageExtension({ contentType, useTemplating });
// TODO: Ensure tooltips render inside the dialog if we are in one.
const parent =
container?.closest<HTMLDivElement>('[role="dialog"]') ??
document.querySelector<HTMLDivElement>('#cm-portal') ??
undefined;
return [
...baseExtensions,
tooltips({ parent }),
keymap.of(singleLine ? defaultKeymap.filter((k) => k.key !== 'Enter') : defaultKeymap),
...(singleLine ? [singleLineExt()] : []),
...(!singleLine ? [multiLineExtensions] : []),
...(ext ? [ext] : []),
...(readOnly ? [EditorState.readOnly.of(true)] : []),
...(placeholder ? [placeholderExt(placeholder)] : []),
...(singleLine
? [
EditorView.domEventHandlers({
focus: (e, view) => {
// select all text on focus, like a regular input does
view.dispatch({ selection: { anchor: 0, head: view.state.doc.length } });
},
keydown: (e) => {
// Submit nearest form on enter if there is one
if (e.key === 'Enter') {
const el = e.currentTarget as HTMLElement;
const form = el.closest('form');
form?.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
}
},
}),
]
: []),
// Handle onChange
EditorView.updateListener.of((update) => {
if (typeof onChange === 'function' && update.docChanged) {
onChange(update.state.doc.toString());
}
}),
];
}
const syncGutterBg = ({
parent,
className = '',
}: {
parent: HTMLDivElement;
className?: string;
}) => {
const gutterEl = parent.querySelector<HTMLDivElement>('.cm-gutters');
const classList = className?.split(/\s+/) ?? [];
const bgClasses = classList
.filter((c) => c.match(/(^|:)?bg-.+/)) // Find bg-* classes
.map((c) => c.replace(/^bg-/, '!bg-')) // !important
.map((c) => c.replace(/^dark:bg-/, 'dark:!bg-')); // !important
if (gutterEl) {
gutterEl?.classList.add(...bgClasses);
}
};
@@ -0,0 +1,35 @@
import { closeCompletion, startCompletion } from '@codemirror/autocomplete';
import { EditorView } from 'codemirror';
import { debounce } from '../../../lib/debounce';
/*
* Debounce autocomplete until user stops typing for `millis` milliseconds.
*/
export function debouncedAutocompletionDisplay({ millis }: { millis: number }) {
// TODO: Figure out how to show completion without setting context.explicit = true
const debouncedStartCompletion = debounce(function (view: EditorView) {
startCompletion(view);
}, millis);
return EditorView.updateListener.of(({ view, docChanged }) => {
// const completions = currentCompletions(view.state);
// const status = completionStatus(view.state);
if (!view.hasFocus) {
debouncedStartCompletion.cancel();
closeCompletion(view);
return;
}
if (view.state.doc.length === 0) {
debouncedStartCompletion.cancel();
closeCompletion(view);
return;
}
// If the document hasn't changed, we don't need to do anything
if (docChanged) {
debouncedStartCompletion(view);
}
});
}
@@ -0,0 +1,149 @@
import {
autocompletion,
closeBrackets,
closeBracketsKeymap,
completionKeymap,
} from '@codemirror/autocomplete';
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
import { html } from '@codemirror/lang-html';
import { javascript } from '@codemirror/lang-javascript';
import { json } from '@codemirror/lang-json';
import { xml } from '@codemirror/lang-xml';
import type { LanguageSupport } from '@codemirror/language';
import {
bracketMatching,
foldGutter,
foldKeymap,
HighlightStyle,
indentOnInput,
syntaxHighlighting,
} from '@codemirror/language';
import { lintKeymap } from '@codemirror/lint';
import { highlightSelectionMatches, searchKeymap } from '@codemirror/search';
import { EditorState } from '@codemirror/state';
import {
crosshairCursor,
drawSelection,
dropCursor,
highlightActiveLineGutter,
highlightSpecialChars,
keymap,
lineNumbers,
rectangularSelection,
} from '@codemirror/view';
import { tags as t } from '@lezer/highlight';
import { graphqlLanguageSupport } from 'cm6-graphql';
import { debouncedAutocompletionDisplay } from './autocomplete';
import { twig } from './twig/extension';
import { url } from './url/extension';
export const myHighlightStyle = HighlightStyle.define([
{
tag: [t.documentMeta, t.blockComment, t.lineComment, t.docComment, t.comment],
color: '#757b93',
fontStyle: 'italic',
},
{
tag: [t.name, t.tagName, t.angleBracket, t.docString, t.number],
color: 'hsl(var(--color-blue-600))',
},
{ tag: [t.variableName], color: 'hsl(var(--color-green-600))' },
{ tag: [t.bool], color: 'hsl(var(--color-pink-600))' },
{ tag: [t.attributeName], color: 'hsl(var(--color-violet-600))' },
{ tag: [t.attributeValue], color: 'hsl(var(--color-orange-600))' },
{ tag: [t.string], color: 'hsl(var(--color-yellow-600))' },
{ tag: [t.keyword, t.meta, t.operator], color: 'hsl(var(--color-red-600))' },
]);
// export const defaultHighlightStyle = HighlightStyle.define([
// { tag: t.meta, color: '#404740' },
// { tag: t.link, textDecoration: 'underline' },
// { tag: t.heading, textDecoration: 'underline', fontWeight: 'bold' },
// { tag: t.emphasis, fontStyle: 'italic' },
// { tag: t.strong, fontWeight: 'bold' },
// { tag: t.strikethrough, textDecoration: 'line-through' },
// { tag: t.keyword, color: '#708' },
// { tag: [t.atom, t.bool, t.url, t.contentSeparator, t.labelName], color: '#219' },
// { tag: [t.literal, t.inserted], color: '#164' },
// { tag: [t.string, t.deleted], color: '#a11' },
// { tag: [t.regexp, t.escape, t.special(t.string)], color: '#e40' },
// { tag: t.definition(t.variableName), color: '#00f' },
// { tag: t.local(t.variableName), color: '#30a' },
// { tag: [t.typeName, t.namespace], color: '#085' },
// { tag: t.className, color: '#167' },
// { tag: [t.special(t.variableName), t.macroName], color: '#256' },
// { tag: t.definition(t.propertyName), color: '#00c' },
// { tag: t.comment, color: '#940' },
// { tag: t.invalid, color: '#f00' },
// ]);
const syntaxExtensions: Record<string, LanguageSupport> = {
'application/graphql+json': graphqlLanguageSupport(),
'application/json': json(),
'application/javascript': javascript(),
'text/html': html(),
'application/xml': xml(),
'text/xml': xml(),
url: url(),
};
export function getLanguageExtension({
contentType,
useTemplating,
}: {
contentType?: string;
useTemplating?: boolean;
}) {
const justContentType = contentType?.split(';')[0] ?? contentType ?? '';
const base = syntaxExtensions[justContentType] ?? json();
if (!useTemplating) {
return [base];
}
return twig(base);
}
export const baseExtensions = [
highlightSpecialChars(),
history(),
drawSelection(),
dropCursor(),
bracketMatching(),
// TODO: Figure out how to debounce showing of autocomplete in a good way
// debouncedAutocompletionDisplay({ millis: 1000 }),
// autocompletion({ closeOnBlur: true, interactionDelay: 200, activateOnTyping: false }),
autocompletion({ closeOnBlur: true, interactionDelay: 300 }),
syntaxHighlighting(myHighlightStyle),
EditorState.allowMultipleSelections.of(true),
];
export const multiLineExtensions = [
lineNumbers(),
foldGutter({
markerDOM: (open) => {
const el = document.createElement('div');
el.classList.add('fold-gutter-icon');
el.tabIndex = -1;
if (open) {
el.setAttribute('data-open', '');
}
return el;
},
}),
EditorState.allowMultipleSelections.of(true),
indentOnInput(),
closeBrackets(),
rectangularSelection(),
crosshairCursor(),
highlightActiveLineGutter(),
highlightSelectionMatches({ minSelectionLength: 2 }),
keymap.of([
...closeBracketsKeymap,
...defaultKeymap,
...searchKeymap,
...historyKeymap,
...foldKeymap,
...completionKeymap,
...lintKeymap,
]),
];
+6
View File
@@ -0,0 +1,6 @@
import { memo } from 'react';
import { _Editor } from './Editor';
import type { _EditorProps } from './Editor';
export type EditorProps = _EditorProps;
export const Editor = memo(_Editor);
@@ -0,0 +1,29 @@
import type { Transaction, TransactionSpec } from '@codemirror/state';
import { EditorSelection, EditorState } from '@codemirror/state';
export function singleLineExt() {
return EditorState.transactionFilter.of(
(tr: Transaction): TransactionSpec | TransactionSpec[] => {
if (!tr.isUserEvent('input')) return tr;
const trs: TransactionSpec[] = [];
tr.changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
let insert = '';
let newlinesRemoved = 0;
for (const line of inserted) {
const newLine = line.replace('\n', '');
newlinesRemoved += line.length - newLine.length;
insert += newLine;
}
// Update cursor position based on how many newlines were removed
const cursor = EditorSelection.cursor(toB - newlinesRemoved);
const selection = EditorSelection.create([cursor], 0);
const changes = [{ from: fromB, to: toA, insert }];
trs.push({ ...tr, selection, changes });
});
return trs;
},
);
}
@@ -0,0 +1,55 @@
import type { CompletionContext } from '@codemirror/autocomplete';
const openTag = '${[ ';
const closeTag = ' ]}';
const variables = [
{ name: 'DOMAIN' },
{ name: 'BASE_URL' },
{ name: 'TOKEN' },
{ name: 'PROJECT_ID' },
{ name: 'DUMMY' },
{ name: 'DUMMY_2' },
{ name: 'STRIPE_PUB_KEY' },
{ name: 'RAILWAY_TOKEN' },
{ name: 'SECRET' },
{ name: 'PORT' },
];
const MIN_MATCH_VAR = 2;
const MIN_MATCH_NAME = 4;
export function completions(context: CompletionContext) {
const toStartOfName = context.matchBefore(/\w*/);
const toStartOfVariable = context.matchBefore(/\$\{?\[?\s*\w*/);
const toMatch = toStartOfVariable ?? toStartOfName ?? null;
if (toMatch === null) return null;
const matchLen = toMatch.to - toMatch.from;
const failedVarLen = toStartOfVariable !== null && matchLen < MIN_MATCH_VAR;
if (failedVarLen && !context.explicit) {
return null;
}
const failedNameLen = toStartOfVariable === null && matchLen < MIN_MATCH_NAME;
if (failedNameLen && !context.explicit) {
return null;
}
// TODO: Figure out how to make autocomplete stay open if opened explicitly. It sucks when you explicitly
// open it, then it closes when you type the next character.
return {
from: toMatch.from,
options: variables
.map((v) => ({
label: toStartOfVariable ? `${openTag}${v.name}${closeTag}` : v.name,
apply: `${openTag}${v.name}${closeTag}`,
type: 'variable',
matchLen,
}))
// Filter out exact matches
.filter((o) => o.label !== toMatch.text),
};
}
@@ -0,0 +1,41 @@
import { LanguageSupport, LRLanguage } from '@codemirror/language';
import { parseMixed } from '@lezer/common';
import { completions } from './completion';
import { placeholders } from '../widgets';
import { parser as twigParser } from './twig';
export function twig(base?: LanguageSupport) {
const language = mixedOrPlainLanguage(base);
const completion = language.data.of({
autocomplete: completions,
});
const languageSupport = new LanguageSupport(language, [completion]);
if (base) {
const completion2 = base.language.data.of({ autocomplete: completions });
const languageSupport2 = new LanguageSupport(base.language, [completion2]);
return [languageSupport, languageSupport2, placeholders, base.support];
} else {
return [languageSupport, placeholders];
}
}
function mixedOrPlainLanguage(base?: LanguageSupport): LRLanguage {
const name = 'twig';
if (base == null) {
return LRLanguage.define({ name, parser: twigParser });
}
const parser = twigParser.configure({
wrap: parseMixed((node) => {
if (!node.type.isTop) return null;
return {
parser: base.language.parser,
overlay: (node) => node.type.name === 'Text',
};
}),
});
return LRLanguage.define({ name, parser });
}
@@ -0,0 +1,7 @@
import { styleTags, tags as t } from '@lezer/highlight';
export const highlight = styleTags({
'if endif': t.controlKeyword,
'${[ ]}': t.meta,
DirectiveContent: t.variableName,
});
@@ -0,0 +1,19 @@
@top Template { (directive | Text)* }
directive {
Insert
}
@skip {space} {
Insert { "${[" DirectiveContent "]}" }
}
@tokens {
Text { ![$] Text? }
space { @whitespace+ }
DirectiveContent { ![\]}] DirectiveContent? }
@precedence { space DirectiveContent }
"${[" "]}"
}
@external propSource highlight from "./highlight"
@@ -0,0 +1,6 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
export const
Template = 1,
Insert = 2,
DirectiveContent = 4,
Text = 6
@@ -0,0 +1,18 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
import {LRParser} from "@lezer/lr"
import {highlight} from "./highlight"
export const parser = LRParser.deserialize({
version: 14,
states: "zQVOPOOO_QQO'#C^OOOO'#Cc'#CcQVOPOOOdQQO,58xOOOO-E6a-E6aOOOO1G.d1G.d",
stateData: "l~OYOS~ORPOUQO~OSSO~OTUO~OYS~",
goto: "cWPPXPPPP]TQORQRORTR",
nodeNames: "⚠ Template Insert ${[ DirectiveContent ]} Text",
maxTerm: 10,
propSources: [highlight],
skippedNodes: [0],
repeatNodeCount: 1,
tokenData: ")gRRmOX!|X^$y^p!|pq$yqt!|tu&}u#P!|#P#Q(k#Q#q!|#q#r$[#r#y!|#y#z$y#z$f!|$f$g$y$g#BY!|#BY#BZ$y#BZ$IS!|$IS$I_$y$I_$I|!|$I|$JO$y$JO$JT!|$JT$JU$y$JU$KV!|$KV$KW$y$KW&FU!|&FU&FV$y&FV;'S!|;'S;=`$s<%lO!|R#TXUPSQOt!|tu#pu#P!|#P#Q$[#Q#q!|#q#r$[#r;'S!|;'S;=`$s<%lO!|Q#uTSQO#P#p#Q#q#p#r;'S#p;'S;=`$U<%lO#pQ$XP;=`<%l#pP$aSUPOt$[u;'S$[;'S;=`$m<%lO$[P$pP;=`<%l$[R$vP;=`<%l!|R%SmUPYQSQOX!|X^$y^p!|pq$yqt!|tu#pu#P!|#P#Q$[#Q#q!|#q#r$[#r#y!|#y#z$y#z$f!|$f$g$y$g#BY!|#BY#BZ$y#BZ$IS!|$IS$I_$y$I_$I|!|$I|$JO$y$JO$JT!|$JT$JU$y$JU$KV!|$KV$KW$y$KW&FU!|&FU&FV$y&FV;'S!|;'S;=`$s<%lO!|R'SVSQO#P#p#Q#o#p#o#p'i#p#q#p#r;'S#p;'S;=`$U<%lO#pR'nVSQO!}#p!}#O(T#O#P#p#Q#q#p#r;'S#p;'S;=`$U<%lO#pR([TRPSQO#P#p#Q#q#p#r;'S#p;'S;=`$U<%lO#pR(pUUPOt$[u#q$[#q#r)S#r;'S$[;'S;=`$m<%lO$[R)ZSTQUPOt$[u;'S$[;'S;=`$m<%lO$[",
tokenizers: [0, 1],
topRules: {"Template":[0,1]},
tokenPrec: 25
})
@@ -0,0 +1,19 @@
import type { CompletionContext } from '@codemirror/autocomplete';
const options = [
{ label: 'http://', type: 'constant' },
{ label: 'https://', type: 'constant' },
];
const MIN_MATCH = 1;
export function completions(context: CompletionContext) {
const toMatch = context.matchBefore(/^[\w:/]*/);
if (toMatch === null) return null;
const matchedMinimumLength = toMatch.to - toMatch.from >= MIN_MATCH;
if (!matchedMinimumLength && !context.explicit) return null;
const optionsWithoutExactMatches = options.filter((o) => o.label !== toMatch.text);
return { from: toMatch.from, options: optionsWithoutExactMatches };
}
@@ -0,0 +1,14 @@
import { LanguageSupport, LRLanguage } from '@codemirror/language';
import { completions } from './completion';
import { parser } from './url';
const urlLanguage = LRLanguage.define({
parser,
languageData: {},
});
const completion = urlLanguage.data.of({ autocomplete: completions });
export function url() {
return new LanguageSupport(urlLanguage, [completion]);
}
@@ -0,0 +1,9 @@
import { styleTags, tags as t } from '@lezer/highlight';
export const highlight = styleTags({
Protocol: t.comment,
// Port: t.attributeName,
// Host: t.variableName,
// Path: t.bool,
// Query: t.string,
});
@@ -0,0 +1,18 @@
@top url { Protocol? Host Port? Path? Query? }
Query {
"?" queryPair ("&" queryPair)*
}
@tokens {
Protocol { $[a-zA-Z]+ "://" }
Path { ("/" $[a-zA-Z0-9\-_.]*)+ }
queryPair { ($[a-zA-Z0-9]+ ("=" $[a-zA-Z0-9]*)?) }
Port { ":" $[0-9]+ }
Host { $[a-zA-Z0-9-_.]+ }
// Protocol/host overlaps, so give proto explicit precedence
@precedence { Protocol, Host }
}
@external propSource highlight from "./highlight"
@@ -0,0 +1,8 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
export const
url = 1,
Protocol = 2,
Host = 3,
Port = 4,
Path = 5,
Query = 6
+18
View File
@@ -0,0 +1,18 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
import {LRParser} from "@lezer/lr"
import {highlight} from "./highlight"
export const parser = LRParser.deserialize({
version: 14,
states: "!jOQOPOOQYOPOOOTOPOOOeOQO'#CbQOOOOOQ`OPOOQ]OPOOOjOPO,58|OrOQO'#CcOwOPO1G.hOOOO,58},58}OOOO-E6a-E6a",
stateData: "!S~OQQORPO~OSUOTTOXRO~OYVO~OZWOWUa~OYYO~OZWOWUi~OQR~",
goto: "dWPPPPPPX^VSPTUQXVRZX",
nodeNames: "⚠ url Protocol Host Port Path Query",
maxTerm: 11,
propSources: [highlight],
skippedNodes: [0],
repeatNodeCount: 1,
tokenData: "%[~RYvwq}!Ov!O!Pv!P!Q!_!Q![!y![!]#u!a!b$T!c!}$Y#R#Sv#T#o$Y~vOZ~P{URP}!Ov!O!Pv!Q![v!c!}v#R#Sv#T#ov~!dVT~}!O!_!O!P!_!P!Q!_!Q![!_!c!}!_#R#S!_#T#o!_R#QVYQRP}!Ov!O!Pv!Q![!y!_!`#g!c!}!y#R#Sv#T#o!yQ#lRYQ!Q![#g!c!}#g#T#o#g~#xP!Q![#{~$QPS~!Q![#{~$YOX~R$aWYQRP}!Ov!O!Pv!Q![!y![!]$y!_!`#g!c!}$Y#R#Sv#T#o$YP$|P!P!Q%PP%SP!P!Q%VP%[OQP",
tokenizers: [0, 1],
topRules: {"url":[0,1]},
tokenPrec: 47
})
+76
View File
@@ -0,0 +1,76 @@
import type { DecorationSet, ViewUpdate } from '@codemirror/view';
import { Decoration, EditorView, MatchDecorator, ViewPlugin, WidgetType } from '@codemirror/view';
class PlaceholderWidget extends WidgetType {
constructor(readonly name: string) {
super();
}
eq(other: PlaceholderWidget) {
return this.name == other.name;
}
toDOM() {
const elt = document.createElement('span');
elt.className = 'placeholder-widget';
elt.textContent = this.name;
return elt;
}
ignoreEvent() {
return false;
}
}
/**
* This is a custom MatchDecorator that will not decorate a match if the selection is inside it
*/
class BetterMatchDecorator extends MatchDecorator {
updateDeco(update: ViewUpdate, deco: DecorationSet): DecorationSet {
if (!update.startState.selection.eq(update.state.selection)) {
return super.createDeco(update.view);
} else {
return super.updateDeco(update, deco);
}
}
}
const placeholderMatcher = new BetterMatchDecorator({
regexp: /\$\{\[\s*([^\]\s]+)\s*]}/g,
decoration(match, view, matchStartPos) {
const matchEndPos = matchStartPos + match[0].length - 1;
// Don't decorate if the cursor is inside the match
for (const r of view.state.selection.ranges) {
if (r.from > matchStartPos && r.to <= matchEndPos) return null;
}
const groupMatch = match[1];
if (groupMatch == null) {
// Should never happen, but make TS happy
console.warn('Group match was empty', match);
return null;
}
return Decoration.replace({
inclusive: true,
widget: new PlaceholderWidget(groupMatch),
});
},
});
export const placeholders = ViewPlugin.fromClass(
class {
placeholders: DecorationSet;
constructor(view: EditorView) {
this.placeholders = placeholderMatcher.createDeco(view);
}
update(update: ViewUpdate) {
this.placeholders = placeholderMatcher.updateDeco(update, this.placeholders);
}
},
{
decorations: (instance) => instance.placeholders,
provide: (plugin) =>
EditorView.atomicRanges.of((view) => {
return view.plugin(plugin)?.placeholders || Decoration.none;
}),
},
);
+15
View File
@@ -0,0 +1,15 @@
import classnames from 'classnames';
import type { ReactNode } from 'react';
type Props = {
className?: string;
children?: ReactNode;
};
export function Heading({ className, children, ...props }: Props) {
return (
<h1 className={classnames(className, 'text-2xl font-semibold text-gray-900 mb-3')} {...props}>
{children}
</h1>
);
}
+15
View File
@@ -0,0 +1,15 @@
import classnames from 'classnames';
import type { HTMLAttributes } from 'react';
export function HotKey({ children }: HTMLAttributes<HTMLSpanElement>) {
return (
<span
className={classnames(
'bg-gray-400 bg-opacity-20 px-1.5 py-0.5 rounded text-sm',
'font-mono text-gray-500 tracking-widest',
)}
>
{children}
</span>
);
}
+75
View File
@@ -0,0 +1,75 @@
import {
ArchiveIcon,
CameraIcon,
CheckIcon,
ClockIcon,
CodeIcon,
ColorWheelIcon,
Cross2Icon,
EyeOpenIcon,
GearIcon,
HomeIcon,
MoonIcon,
ListBulletIcon,
PaperPlaneIcon,
PlusCircledIcon,
PlusIcon,
QuestionMarkIcon,
SunIcon,
TrashIcon,
TriangleDownIcon,
TriangleLeftIcon,
TriangleRightIcon,
UpdateIcon,
RowsIcon,
} from '@radix-ui/react-icons';
import classnames from 'classnames';
const icons = {
archive: ArchiveIcon,
camera: CameraIcon,
check: CheckIcon,
clock: ClockIcon,
code: CodeIcon,
colorWheel: ColorWheelIcon,
eye: EyeOpenIcon,
gear: GearIcon,
home: HomeIcon,
listBullet: ListBulletIcon,
moon: MoonIcon,
paperPlane: PaperPlaneIcon,
plus: PlusIcon,
plusCircle: PlusCircledIcon,
question: QuestionMarkIcon,
rows: RowsIcon,
sun: SunIcon,
trash: TrashIcon,
triangleDown: TriangleDownIcon,
triangleLeft: TriangleLeftIcon,
triangleRight: TriangleRightIcon,
update: UpdateIcon,
x: Cross2Icon,
};
export interface IconProps {
icon: keyof typeof icons;
className?: string;
size?: 'xs' | 'sm' | 'md';
spin?: boolean;
}
export function Icon({ icon, spin, size = 'md', className }: IconProps) {
const Component = icons[icon] ?? icons.question;
return (
<Component
className={classnames(
className,
'text-inherit',
size === 'md' && 'h-4 w-4',
size === 'sm' && 'h-3 w-3',
size === 'xs' && 'h-2 w-2',
spin && 'animate-spin',
)}
/>
);
}
+35
View File
@@ -0,0 +1,35 @@
import classnames from 'classnames';
import { forwardRef } from 'react';
import type { ButtonProps } from './Button';
import { Button } from './Button';
import type { IconProps } from './Icon';
import { Icon } from './Icon';
type Props = IconProps & ButtonProps & { iconClassName?: string; iconSize?: IconProps['size'] };
export const IconButton = forwardRef<HTMLButtonElement, Props>(function IconButton(
{ icon, spin, className, iconClassName, size = 'md', iconSize, ...props }: Props,
ref,
) {
return (
<Button
ref={ref}
className={classnames(
className,
'text-gray-700 hover:text-gray-1000',
'!px-0',
size === 'md' && 'w-9',
size === 'sm' && 'w-9',
)}
size={size}
{...props}
>
<Icon
size={iconSize}
icon={icon}
spin={spin}
className={classnames(iconClassName, props.disabled && 'opacity-70')}
/>
</Button>
);
});
+96
View File
@@ -0,0 +1,96 @@
import classnames from 'classnames';
import type { ReactNode } from 'react';
import type { EditorProps } from './Editor';
import { Editor } from './Editor';
import { HStack, VStack } from './Stacks';
interface Props {
name: string;
label: string;
hideLabel?: boolean;
labelClassName?: string;
containerClassName?: string;
onChange?: (value: string) => void;
useEditor?: Pick<EditorProps, 'contentType' | 'useTemplating'>;
defaultValue?: string;
leftSlot?: ReactNode;
rightSlot?: ReactNode;
size?: 'sm' | 'md';
className?: string;
placeholder?: string;
autoFocus?: boolean;
}
export function Input({
label,
hideLabel,
className,
containerClassName,
labelClassName,
onChange,
placeholder,
size = 'md',
useEditor,
name,
leftSlot,
rightSlot,
defaultValue,
...props
}: Props) {
const id = `input-${name}`;
const inputClassName = classnames(
className,
'!bg-transparent pl-3 pr-2 min-w-0 h-full w-full focus:outline-none placeholder:text-placeholder',
!!leftSlot && '!pl-0.5',
!!rightSlot && '!pr-0.5',
);
return (
<VStack>
<label
htmlFor={id}
className={classnames(
labelClassName,
'font-semibold text-sm uppercase text-gray-700',
hideLabel && 'sr-only',
)}
>
{label}
</label>
<HStack
alignItems="center"
className={classnames(
containerClassName,
'relative w-full rounded-md text-gray-900',
'border border-gray-200 focus-within:border-blue-400/40',
size === 'md' && 'h-9',
size === 'sm' && 'h-7',
)}
>
{leftSlot}
{useEditor ? (
<Editor
id={id}
singleLine
defaultValue={defaultValue}
placeholder={placeholder}
onChange={onChange}
className={inputClassName}
{...props}
{...useEditor}
/>
) : (
<input
id={id}
onChange={(e) => onChange?.(e.currentTarget.value)}
placeholder={placeholder}
defaultValue={defaultValue}
className={inputClassName}
{...props}
/>
)}
{rightSlot}
</HStack>
</VStack>
);
}
+34
View File
@@ -0,0 +1,34 @@
import * as S from '@radix-ui/react-scroll-area';
import classnames from 'classnames';
import type { ReactNode } from 'react';
interface Props {
children: ReactNode;
className?: string;
}
export function ScrollArea({ children, className }: Props) {
return (
<S.Root className={classnames(className, 'group/scroll')} type="always">
<S.Viewport>{children}</S.Viewport>
<ScrollBar orientation="vertical" />
<ScrollBar orientation="horizontal" />
<S.Corner />
</S.Root>
);
}
function ScrollBar({ orientation }: { orientation: 'vertical' | 'horizontal' }) {
return (
<S.Scrollbar
orientation={orientation}
className={classnames(
'flex bg-transparent rounded-full',
orientation === 'vertical' && 'w-1.5',
orientation === 'horizontal' && 'h-1.5 flex-col',
)}
>
<S.Thumb className="flex-1 bg-gray-100 group-hover/scroll:bg-gray-200 rounded-full" />
</S.Scrollbar>
);
}
+106
View File
@@ -0,0 +1,106 @@
import classnames from 'classnames';
import type { ComponentType, ReactNode } from 'react';
import { Children, Fragment } from 'react';
const spaceClassesX = {
0: 'pr-0',
1: 'pr-1',
2: 'pr-2',
3: 'pr-3',
4: 'pr-4',
5: 'pr-5',
6: 'pr-6',
};
const spaceClassesY = {
0: 'pt-0',
1: 'pt-1',
2: 'pt-2',
3: 'pt-3',
4: 'pt-4',
5: 'pt-5',
6: 'pt-6',
};
interface HStackProps extends BaseStackProps {
space?: keyof typeof spaceClassesX;
children?: ReactNode;
}
export function HStack({ className, space, children, ...props }: HStackProps) {
return (
<BaseStack className={classnames(className, 'flex-row')} {...props}>
{space
? Children.toArray(children)
.filter(Boolean) // Remove null/false/undefined children
.map((c, i) => (
<Fragment key={i}>
{i > 0 ? (
<div
className={classnames(spaceClassesX[space], 'pointer-events-none')}
data-spacer=""
aria-hidden
/>
) : null}
{c}
</Fragment>
))
: children}
</BaseStack>
);
}
export interface VStackProps extends BaseStackProps {
space?: keyof typeof spaceClassesY;
children: ReactNode;
}
export function VStack({ className, space, children, ...props }: VStackProps) {
return (
<BaseStack className={classnames(className, 'w-full h-full flex-col')} {...props}>
{space
? Children.toArray(children)
.filter(Boolean) // Remove null/false/undefined children
.map((c, i) => (
<Fragment key={i}>
{i > 0 ? (
<div
className={classnames(spaceClassesY[space], 'pointer-events-none')}
data-spacer=""
aria-hidden
/>
) : null}
{c}
</Fragment>
))
: children}
</BaseStack>
);
}
interface BaseStackProps {
as?: ComponentType | 'ul';
alignItems?: 'start' | 'center';
justifyContent?: 'start' | 'center' | 'end';
className?: string;
children?: ReactNode;
}
function BaseStack({ className, alignItems, justifyContent, children, as }: BaseStackProps) {
const Component = as ?? 'div';
return (
<Component
className={classnames(
className,
'flex',
alignItems === 'center' && 'items-center',
alignItems === 'start' && 'items-start',
justifyContent === 'start' && 'justify-start',
justifyContent === 'center' && 'justify-center',
justifyContent === 'end' && 'justify-end',
)}
>
{children}
</Component>
);
}
+23
View File
@@ -0,0 +1,23 @@
import classnames from 'classnames';
import type { ReactNode } from 'react';
interface Props {
statusCode: number;
children: ReactNode;
}
export function StatusColor({ statusCode, children }: Props) {
return (
<span
className={classnames(
statusCode >= 100 && statusCode < 200 && 'text-green-600',
statusCode >= 200 && statusCode < 300 && 'text-green-600',
statusCode >= 300 && statusCode < 400 && 'text-pink-600',
statusCode >= 400 && statusCode < 500 && 'text-orange-600',
statusCode >= 500 && statusCode < 600 && 'text-red-600',
)}
>
{children}
</span>
);
}
+5
View File
@@ -0,0 +1,5 @@
.tab-content {
&[data-state="inactive"] {
@apply hidden;
}
}
+88
View File
@@ -0,0 +1,88 @@
import * as T from '@radix-ui/react-tabs';
import classnames from 'classnames';
import type { ReactNode } from 'react';
import { useState } from 'react';
import { Button } from '../Button';
import { ScrollArea } from '../ScrollArea';
import { HStack } from '../Stacks';
import './Tabs.css';
interface Props {
defaultValue?: string;
label: string;
tabs: { value: string; label: ReactNode }[];
tabListClassName?: string;
className?: string;
children: ReactNode;
}
export function Tabs({ defaultValue, label, children, tabs, className, tabListClassName }: Props) {
const [value, setValue] = useState(defaultValue);
return (
<T.Root
defaultValue={defaultValue}
onValueChange={setValue}
className={classnames(className, 'h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1')}
>
<T.List
aria-label={label}
className={classnames(
tabListClassName,
'h-auto flex items-center overflow-x-auto mb-1 pb-1',
)}
>
{/*<ScrollArea className="w-full pb-2">*/}
<HStack space={1}>
{tabs.map((t) => (
<TabTrigger key={t.value} value={t.value} active={t.value === value}>
{t.label}
</TabTrigger>
))}
</HStack>
{/*</ScrollArea>*/}
</T.List>
{children}
</T.Root>
);
}
interface TabTriggerProps {
value: string;
children: ReactNode;
active?: boolean;
}
export function TabTrigger({ value, children, active }: TabTriggerProps) {
return (
<T.Trigger value={value} asChild>
<Button
color="custom"
size="sm"
className={classnames(
active ? 'bg-gray-100 text-gray-900' : 'text-gray-600 hover:text-gray-900',
)}
>
{children}
</Button>
</T.Trigger>
);
}
interface TabContentProps {
value: string;
children: ReactNode;
className?: string;
}
export function TabContent({ value, children, className }: TabContentProps) {
return (
<T.Content
forceMount
value={value}
className={classnames(className, 'tab-content', 'w-full h-full overflow-auto')}
>
{children}
</T.Content>
);
}
+28
View File
@@ -0,0 +1,28 @@
import { useMemo } from 'react';
interface Props {
body: string;
contentType: string;
url: string;
}
export function Webview({ body, url, contentType }: Props) {
const contentForIframe: string | undefined = useMemo(() => {
if (!contentType.includes('html')) return;
if (body.includes('<head>')) {
return body.replace(/<head>/gi, `<head><base href="${url}"/>`);
}
return body;
}, [body, contentType]);
return (
<div className="px-2 pb-2">
<iframe
title="Response preview"
srcDoc={contentForIframe}
sandbox="allow-scripts allow-same-origin"
className="h-full w-full rounded-md border border-gray-100/20"
/>
</div>
);
}
@@ -0,0 +1,17 @@
import classnames from 'classnames';
import type { ReactNode } from 'react';
interface Props {
className?: string;
children?: ReactNode;
}
export function WindowDragRegion({ className, ...props }: Props) {
return (
<div
data-tauri-drag-region
className={classnames(className, 'w-full h-12 flex-shrink-0')}
{...props}
/>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import type { HttpRequest } from '../lib/models';
import { useRequests } from './useRequests';
export function useActiveRequest(): HttpRequest | null {
const params = useParams<{ requestId?: string }>();
const requests = useRequests();
const [activeRequest, setActiveRequest] = useState<HttpRequest | null>(null);
useEffect(() => {
if (requests.length === 0) {
setActiveRequest(null);
} else {
setActiveRequest(requests.find((r) => r.id === params.requestId) ?? null);
}
}, [requests, params.requestId]);
return activeRequest;
}
+20
View File
@@ -0,0 +1,20 @@
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import type { Workspace } from '../lib/models';
import { useWorkspaces } from './useWorkspaces';
export function useActiveWorkspace(): Workspace | null {
const params = useParams<{ workspaceId?: string }>();
const workspaces = useWorkspaces();
const [activeWorkspace, setActiveWorkspace] = useState<Workspace | null>(null);
useEffect(() => {
if (workspaces.length === 0) {
setActiveWorkspace(null);
} else {
setActiveWorkspace(workspaces.find((w) => w.id === params.workspaceId) ?? null);
}
}, [workspaces, params.workspaceId]);
return activeWorkspace;
}
+23
View File
@@ -0,0 +1,23 @@
import { useMutation } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import { useNavigate } from 'react-router-dom';
import type { HttpRequest } from '../lib/models';
import { useActiveWorkspace } from './useActiveWorkspace';
export function useCreateRequest({ navigateAfter }: { navigateAfter: boolean }) {
const workspace = useActiveWorkspace();
const navigate = useNavigate();
return useMutation<string, unknown, Pick<HttpRequest, 'name'>>({
mutationFn: async (patch) => {
if (workspace === null) {
throw new Error("Cannot create request when there's no active workspace");
}
return invoke('create_request', { ...patch, workspaceId: workspace?.id });
},
onSuccess: async (requestId) => {
if (navigateAfter) {
navigate(`/workspaces/${workspace?.id}/requests/${requestId}`);
}
},
});
}
+18
View File
@@ -0,0 +1,18 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import type { HttpRequest } from '../lib/models';
import { requestsQueryKey } from './useRequests';
export function useDeleteRequest(request: HttpRequest | null) {
const queryClient = useQueryClient();
return useMutation<void, string>({
mutationFn: async () => {
if (request == null) return;
await invoke('delete_request', { requestId: request.id });
},
onSuccess: async () => {
if (request == null) return;
await queryClient.invalidateQueries(requestsQueryKey(request.workspaceId));
},
});
}
+16
View File
@@ -0,0 +1,16 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
export function useDeleteResponses(requestId?: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
if (requestId == null) return;
await invoke('delete_all_responses', { requestId });
},
onSuccess: () => {
if (requestId == null) return;
queryClient.setQueryData(['responses', { requestId: requestId }], []);
},
});
}
+24
View File
@@ -0,0 +1,24 @@
import { useQuery } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import type { HttpRequest } from '../lib/models';
import { convertDates } from '../lib/models';
import { useActiveWorkspace } from './useActiveWorkspace';
export function requestsQueryKey(workspaceId: string) {
return ['requests', { workspaceId }];
}
export function useRequests() {
const workspace = useActiveWorkspace();
return (
useQuery({
enabled: workspace != null,
queryKey: requestsQueryKey(workspace?.id ?? 'n/a'),
queryFn: async () => {
if (workspace == null) return [];
const requests = (await invoke('requests', { workspaceId: workspace.id })) as HttpRequest[];
return requests.map(convertDates);
},
}).data ?? []
);
}
+20
View File
@@ -0,0 +1,20 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import type { HttpResponse } from '../lib/models';
export function useDeleteResponse(response: HttpResponse | null) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
if (response === null) return;
await invoke('delete_response', { id: response.id });
},
onSuccess: () => {
if (response === null) return;
queryClient.setQueryData(
['responses', { requestId: response.requestId }],
(responses: HttpResponse[] = []) => responses.filter((r) => r.id !== response.id),
);
},
});
}
+26
View File
@@ -0,0 +1,26 @@
import { useQuery } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import type { HttpResponse } from '../lib/models';
import { convertDates } from '../lib/models';
import { useActiveRequest } from './useActiveRequest';
export function responsesQueryKey(requestId: string) {
return ['responses', { requestId }];
}
export function useResponses() {
const activeRequest = useActiveRequest();
return (
useQuery<HttpResponse[]>({
enabled: activeRequest != null,
initialData: [],
queryKey: responsesQueryKey(activeRequest?.id ?? 'n/a'),
queryFn: async () => {
const responses = (await invoke('responses', {
requestId: activeRequest?.id,
})) as HttpResponse[];
return responses.map(convertDates);
},
}).data ?? []
);
}
+18
View File
@@ -0,0 +1,18 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import type { HttpRequest } from '../lib/models';
import { responsesQueryKey } from './useResponses';
export function useSendRequest(request: HttpRequest | null) {
const queryClient = useQueryClient();
return useMutation<void, string>({
mutationFn: async () => {
if (request == null) return;
await invoke('send_request', { requestId: request.id });
},
onSuccess: async () => {
if (request == null) return;
await queryClient.invalidateQueries(responsesQueryKey(request.id));
},
});
}
+38
View File
@@ -0,0 +1,38 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';
import type { Appearance } from '../lib/theme/window';
import {
getAppearance,
setAppearance,
subscribeToPreferredAppearanceChange,
toggleAppearance,
} from '../lib/theme/window';
const appearanceQueryKey = ['theme', 'appearance'];
export function useTheme() {
const queryClient = useQueryClient();
const appearance = useQuery({
queryKey: appearanceQueryKey,
queryFn: getAppearance,
initialData: getAppearance(),
}).data;
const themeChange = (appearance: Appearance) => {
setAppearance(appearance);
};
const handleToggleAppearance = async () => {
const newAppearance = toggleAppearance();
await queryClient.setQueryData(appearanceQueryKey, newAppearance);
};
useEffect(() => {
return subscribeToPreferredAppearanceChange(themeChange);
}, []);
return {
appearance,
toggleAppearance: handleToggleAppearance,
};
}
Loaded 100 of 117 files, more files were not shown because too many files have changed in this diff. Show more