Compare commits

...

93 Commits

Author SHA1 Message Date
Gregory Schier
aeda72f13e Fix plugin (again) 2023-11-08 13:33:15 -08:00
Gregory Schier
83aa9041cb Bundle plugin in Git 2023-11-08 13:06:49 -08:00
Gregory Schier
d51913509d Move plugins back 2023-11-08 12:34:14 -08:00
Gregory Schier
5106f28ba5 Fix permissions 2023-11-08 12:02:17 -08:00
Gregory Schier
0c55c6eaab Bump version 2023-11-08 10:12:01 -08:00
Gregory Schier
b0edbd19c8 Tweak theme 2023-11-08 10:11:29 -08:00
Gregory Schier
7630db79b7 Tweak theme 2023-11-08 09:56:13 -08:00
Gregory Schier
55a7b82567 Track screen size, os, and version 2023-11-08 09:49:29 -08:00
Gregory Schier
b5cb46918a Rust analytics and a few tweaks 2023-11-08 09:13:32 -08:00
Gregory Schier
a793ece1a5 Add basic analytics 2023-11-07 09:53:59 -08:00
Gregory Schier
0f6e4b641a Cancel responses on startup 2023-11-06 13:06:15 -08:00
Gregory Schier
5ac5fab0c6 Send all in a folder 2023-11-06 10:53:39 -08:00
Gregory Schier
8030a8a235 Rework workspace header 2023-11-06 10:42:59 -08:00
Gregory Schier
d98426cad3 Fix sidebar focus max recursion 2023-11-06 10:40:02 -08:00
Gregory Schier
06034a8fc4 Save after formatting GraphQL (Closes #9) 2023-11-06 07:20:47 -08:00
Gregory Schier
1ee9f9bb51 Move plugins back to root dir 2023-11-06 07:18:53 -08:00
Gregory Schier
4b99d1405e Persist sidebar collapsed state (Closes #10) 2023-11-06 07:18:42 -08:00
Gregory Schier
8480e52195 Vite to bundle insomnia plugin 2023-11-05 22:13:22 -08:00
Gregory Schier
243e65a992 Better import conversion 2023-11-05 14:46:08 -08:00
Gregory Schier
b82304a233 Basic import of request body and bearer auth 2023-11-05 14:35:25 -08:00
Gregory Schier
f7a4ea9735 Recursive Insomnia import! 2023-11-05 13:33:23 -08:00
Gregory Schier
33d1a84ecd Remove numbers from recent requests 2023-11-04 16:19:21 -07:00
Gregory Schier
f4a071ee05 Fix drop marker indent 2023-11-04 11:16:41 -07:00
Gregory Schier
e26ba0f9d0 Folder actions 2023-11-04 10:48:18 -07:00
Gregory Schier
b4e2a12375 Folder chevron icon 2023-11-03 23:10:44 -07:00
Gregory Schier
5e7aacd31a Fix arrow navigation for nested sidebar 2023-11-03 17:29:33 -07:00
Gregory Schier
00718df49e Folder-based drag-n-drop complete! 2023-11-03 16:29:21 -07:00
Gregory Schier
bb9025ab07 Sidebar ordering 95% done! 2023-11-03 15:02:17 -07:00
Gregory Schier
867f3908ed Nested sidebar ordering almost working 2023-11-03 14:08:46 -07:00
Gregory Schier
30e1ecac39 Add folder model 2023-11-03 07:49:44 -07:00
Gregory Schier
7eb2abe9b2 Even better focus state 2023-11-02 22:23:21 -07:00
Gregory Schier
a5ac8fa035 Remove focus on env sidebar buttons 2023-11-02 22:18:13 -07:00
Gregory Schier
dd705de155 Rearrange menus 2023-11-02 21:41:35 -07:00
Gregory Schier
b15cdec701 Refactor environment edit dialog 2023-11-02 20:38:33 -07:00
Gregory Schier
a99a36b5cc Base environments fully working 2023-11-02 18:43:39 -07:00
Gregory Schier
e0b0e3d781 Initial "plugin" system with importer (#7) 2023-11-02 18:08:43 -07:00
Gregory Schier
98a4834d4f Fix cursor color in single-line mode 2023-10-31 15:10:33 -07:00
Gregory Schier
32b135dbaf Fix sending of ephemeral requests 2023-10-30 08:24:49 -07:00
Gregory Schier
0fc8d12a06 Fix GQL introspection and bearer auth templating 2023-10-30 08:07:34 -07:00
Gregory Schier
3c2bdab101 Fix button styles 2023-10-30 07:27:27 -07:00
Gregory Schier
8b5d7ae3ed Fix editor stale callbacks and recent item deletion 2023-10-30 07:07:14 -07:00
Gregory Schier
51949f4fbf Refactored some core UI 2023-10-30 06:35:52 -07:00
Gregory Schier
6013cd2329 Plugin module loading 2023-10-29 20:50:23 -07:00
Gregory Schier
eba28ade48 Bump version 2023-10-29 17:22:27 -07:00
Gregory Schier
44af1ddc8a Fix sidebar scroll 2023-10-29 17:19:03 -07:00
Gregory Schier
63c0d09df8 A bit more playing with JS runtime 2023-10-29 17:05:48 -07:00
Gregory Schier
f305633d94 Initial "Hello World" for plugins 2023-10-29 16:43:28 -07:00
Gregory Schier
13155f8591 Fix request creation 2023-10-29 12:05:05 -07:00
Gregory Schier
f2ac97aa62 Restore recent environment on workspace change
Fixes #6
2023-10-29 11:32:55 -07:00
Gregory Schier
18eb0027a1 Fix var complete and env dialog actions 2023-10-29 11:18:55 -07:00
Gregory Schier
9e2803fcfb Remove broken key/value enter/backspace logic 2023-10-29 10:45:05 -07:00
Gregory Schier
705e30b6e0 Delete key/value on backspace 2023-10-29 10:26:38 -07:00
Gregory Schier
f1260911ea Move workspace menu, better env mgmt, QoL 2023-10-29 09:45:16 -07:00
Gregory Schier
076ff63dbe Bump version 2023-10-28 23:41:58 -07:00
Gregory Schier
899092b4d2 Better listening for path changes 2023-10-28 23:41:24 -07:00
Gregory Schier
c2c3a28aab Bump version 2023-10-28 22:14:51 -07:00
Gregory Schier
25c0db502e Fixed auto-focus in prompt and env dropdown 2023-10-28 22:14:12 -07:00
Gregory Schier
6dcbe45a53 Clear selected sidebar index on drag-drop end 2023-10-28 21:47:00 -07:00
Gregory Schier
e2b46f25ff Revert debug name 2023-10-28 21:43:09 -07:00
Gregory Schier
981182be46 Fix drag-n-drop things 2023-10-28 21:42:35 -07:00
Gregory Schier
ad164ebd5e Persist window paths 2023-10-28 21:23:46 -07:00
Gregory Schier
cacdad8826 Bump version to 2023.1.0 2023-10-28 19:15:33 -07:00
Gregory Schier
77e5142a7c Update placeholders when env changes 2023-10-28 19:14:51 -07:00
Gregory Schier
613081728d Placeholder error and fix env nav 2023-10-28 19:08:31 -07:00
Gregory Schier
23e77dfec1 Recent requests/workspaces. Closes #1 2023-10-28 18:46:54 -07:00
Gregory Schier
6e273ae2a3 Fix recent requests loading on startup 2023-10-28 18:27:18 -07:00
Gregory Schier
4061094988 Add tauri window save state plugin 2023-10-28 13:14:27 -07:00
Gregory Schier
82b185e27f Fix rustfmt 2023-10-28 12:45:25 -07:00
Gregory Schier
27dc261639 Handle enabled/disabled variables and render multi 2023-10-28 11:36:40 -07:00
Gregory Schier
7e45fecf19 Remove unused Variable type 2023-10-28 11:31:45 -07:00
Gregory Schier
1a5053380b Variables under Environment, and render all props 2023-10-28 11:29:29 -07:00
Gregory Schier
408665c62d Native Codemirror cursor 2023-10-27 13:14:41 -07:00
Gregory Schier
65efee2048 Only wrap URLBar on focus and hotkey to open recent requests 2023-10-27 12:40:43 -07:00
Gregory Schier
3faa66a1fc Resizing window no longer changes sidebar visibility
Fixes #4
2023-10-27 11:21:59 -07:00
Gregory Schier
9dafe4f704 Auto-expand URL bar height 2023-10-27 10:57:07 -07:00
Gregory Schier
356eaf1713 Environment deletion and better actions menu 2023-10-26 16:18:47 -07:00
Gregory Schier
f8584f1537 Stop autocomplete from jumping around 2023-10-26 15:27:48 -07:00
Gregory Schier
6ad6cb34b0 Fix request creation from menu 2023-10-26 10:41:14 -07:00
Gregory Schier
32b27cd780 Send requests with active environment 2023-10-26 10:32:06 -07:00
Gregory Schier
0344a1e8c9 Move create request and fix slow HTML highlighting 2023-10-26 09:42:19 -07:00
Gregory Schier
0515271c12 Better project selector, Fixes #2, and a bunch more 2023-10-26 09:11:44 -07:00
Gregory Schier
5ae8d54ce0 Fixed some routing and introspection requests 2023-10-25 21:53:18 -07:00
Gregory Schier
33c406ce49 Environments in URL and better rendering 2023-10-25 11:13:00 -07:00
Gregory Schier
3b660ddbd0 Move responses dropdown to separate component 2023-10-25 07:59:10 -07:00
Gregory Schier
3132728a27 Fix dialog height 2023-10-25 00:02:51 -07:00
Gregory Schier
7063128342 Better style when no active environment 2023-10-24 23:58:12 -07:00
Gregory Schier
2187775462 Environment dropdown and actions 2023-10-24 09:17:29 -07:00
Gregory Schier
18adcd1004 Started on environment edit dialog 2023-10-23 21:00:36 -07:00
Gregory Schier
b0656d1e38 Hacky implementation of variable autocomplete 2023-10-23 10:31:21 -07:00
Gregory Schier
38e66047e0 Rendered first variable! 2023-10-22 22:30:29 -07:00
Gregory Schier
c24f049dac Updating environments! 2023-10-22 22:06:51 -07:00
Gregory Schier
53d13c8172 Update .gitignore 2023-10-22 20:40:00 -07:00
Gregory Schier
0727c6e437 Prettier and start of env editor 2023-10-22 20:38:57 -07:00
139 changed files with 8865 additions and 9380 deletions

View File

@@ -12,7 +12,7 @@ module.exports = {
parserOptions: {
project: ["./tsconfig.json"]
},
ignorePatterns: ["src-tauri/**/*"],
ignorePatterns: ["src-tauri/**/*", "plugins/**/*"],
settings: {
react: {
version: "detect"

View File

@@ -3,6 +3,8 @@ on:
push:
tags: [ v* ]
permissions: write-all
jobs:
build-artifacts:
strategy:

1
.gitignore vendored
View File

@@ -22,5 +22,6 @@ dist-ssr
*.njsproj
*.sln
*.sw?
.eslintcache
*.sqlite

1
.husky/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
_

4
.husky/pre-commit Executable file
View File

@@ -0,0 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npx lint-staged

2
.nvmrc
View File

@@ -1 +1 @@
18
20

13
Makefile Normal file
View File

@@ -0,0 +1,13 @@
.PHONY: sqlx-prepare, dev, migrate, build
sqlx-prepare:
cd src-tauri && cargo sqlx prepare --database-url 'sqlite://db.sqlite'
dev:
npm run tauri-dev
migrate:
cd src-tauri && cargo sqlx migrate run --database-url 'sqlite://db.sqlite?mode=rw'
build:
./node_modules/.bin/tauri build

View File

@@ -10,7 +10,7 @@ npm run tauri-dev
# Migration commands
cd src-tauri
cargo sqlx migrate add <name>
cargo sqlx migrate add ${MIGRATION_NAME}
cargo sqlx migrate run --database-url 'sqlite://db.sqlite?mode=rw'
cargo sqlx prepare --database-url 'sqlite://db.sqlite'
```

View File

Binary file not shown.

9321
package-lock.json generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -13,11 +13,11 @@
"build:icon": "tauri icon src-tauri/icons/icon.png",
"build:frontend": "vite build",
"test": "vitest",
"coverage": "vitest run --coverage"
"coverage": "vitest run --coverage",
"prepare": "husky install"
},
"dependencies": {
"@codemirror/commands": "^6.2.1",
"@codemirror/lang-html": "^6.4.2",
"@codemirror/lang-javascript": "^6.1.4",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-xml": "^6.0.2",
@@ -34,10 +34,9 @@
"@tanstack/react-query-devtools": "^4.28.0",
"@tanstack/react-query-persist-client": "^4.28.0",
"@tauri-apps/api": "^1.5.1",
"@vitejs/plugin-react": "^3.1.0",
"buffer": "^6.0.3",
"classnames": "^2.3.2",
"cm6-graphql": "^0.0.4-canary-b30a2325.0",
"cm6-graphql": "^0.0.9",
"codemirror": "^6.0.1",
"focus-trap-react": "^10.1.1",
"format-graphql": "^1.4.0",
@@ -65,6 +64,7 @@
"@types/uuid": "^9.0.1",
"@typescript-eslint/eslint-plugin": "^5.57.0",
"@typescript-eslint/parser": "^5.57.0",
"@vitejs/plugin-react": "^3.1.0",
"autoprefixer": "^10.4.13",
"eslint": "^8.34.0",
"eslint-config-prettier": "^8.6.0",
@@ -72,6 +72,8 @@
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-react": "^7.32.2",
"eslint-plugin-react-hooks": "^4.6.0",
"husky": "^8.0.3",
"lint-staged": "^15.0.2",
"postcss": "^8.4.21",
"postcss-nesting": "^11.2.1",
"prettier": "^2.8.4",
@@ -81,5 +83,9 @@
"vite-plugin-svgr": "^2.4.0",
"vite-plugin-top-level-await": "^1.2.4",
"vitest": "^0.29.2"
},
"lint-staged": {
"*.{ts,tsx}": "eslint --cache --fix",
"*.{js,css,md}": "prettier --write"
}
}

1
rustfmt.toml Normal file
View File

@@ -0,0 +1 @@
edition = "2018"

2287
src-tauri/Cargo.lock generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -18,18 +18,33 @@ objc = "0.2.7"
cocoa = "0.25.0"
[dependencies]
serde_json = { version = "1.0", features = ["raw_value"] }
serde = { version = "1.0", features = ["derive"] }
tauri = { version = "1.3", features = ["config-toml", "devtools", "fs-read-file", "os-all", "protocol-asset", "shell-open", "system-tray", "updater", "window-start-dragging"] }
http = "0.2.8"
reqwest = { version = "0.11.14", features = ["json"] }
tokio = { version = "1.25.0", features = ["sync"] }
futures = "0.3.26"
sqlx = { version = "0.6.2", features = ["sqlite", "runtime-tokio-rustls", "json", "chrono", "time", "offline"] }
uuid = "1.3.0"
rand = "0.8.5"
chrono = { version = "0.4.23", features = ["serde"] }
base64 = "0.21.0"
boa_engine = "0.17.3"
boa_runtime = "0.17.3"
chrono = { version = "0.4.23", features = ["serde"] }
futures = "0.3.26"
http = "0.2.8"
rand = "0.8.5"
reqwest = { version = "0.11.14", features = ["json"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = { version = "1.0", features = ["raw_value"] }
sqlx = { version = "0.6.2", features = ["sqlite", "runtime-tokio-rustls", "json", "chrono", "time", "offline"] }
tauri = { version = "1.3", features = [
"cli",
"config-toml",
"devtools",
"fs-read-file",
"os-all",
"protocol-asset",
"shell-open",
"system-tray",
"updater",
"window-start-dragging",
"dialog-open",
] }
tauri-plugin-window-state = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
tokio = { version = "1.25.0", features = ["sync"] }
uuid = "1.3.0"
[features]
# by default Tauri runs in production mode

View File

@@ -0,0 +1,2 @@
ALTER TABLE environments DROP COLUMN data;
ALTER TABLE environments ADD COLUMN variables DEFAULT '[]' NOT NULL;

View File

@@ -0,0 +1 @@
ALTER TABLE workspaces ADD COLUMN variables DEFAULT '[]' NOT NULL;

View File

@@ -0,0 +1,19 @@
CREATE TABLE folders
(
id TEXT NOT NULL
PRIMARY KEY,
model TEXT DEFAULT 'folder' NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
deleted_at DATETIME,
workspace_id TEXT NOT NULL
REFERENCES workspaces
ON DELETE CASCADE,
folder_id TEXT NULL
REFERENCES folders
ON DELETE CASCADE,
name TEXT NOT NULL,
sort_priority REAL DEFAULT 0 NOT NULL
);
ALTER TABLE http_requests ADD COLUMN folder_id TEXT REFERENCES folders(id) ON DELETE CASCADE;

View File

@@ -0,0 +1,4 @@
export function greet() {
// Call Rust-provided fn!
sayHello('Plugin');
}

View File

@@ -0,0 +1,7 @@
import { greet } from './greet.js';
export function hello() {
greet();
console.log('Try JSON parse', JSON.parse(`{ "hello": 123 }`).hello);
console.log('Try RegExp', '123'.match(/[\d]+/));
}

View File

@@ -0,0 +1,156 @@
function O(e, t) {
return (
console.log('IMPORTING Environment', e._id, e.name, JSON.stringify(e, null, 2)),
{
id: e._id,
createdAt: new Date(e.created ?? Date.now()).toISOString().replace('Z', ''),
updatedAt: new Date(e.updated ?? Date.now()).toISOString().replace('Z', ''),
workspaceId: t,
model: 'environment',
name: e.name,
variables: Object.entries(e.data).map(([i, s]) => ({
enabled: !0,
name: i,
value: `${s}`,
})),
}
);
}
function g(e) {
return d(e) && e._type === 'workspace';
}
function y(e) {
return d(e) && e._type === 'request_group';
}
function _(e) {
return d(e) && e._type === 'request';
}
function I(e) {
return d(e) && e._type === 'environment';
}
function d(e) {
return Object.prototype.toString.call(e) === '[object Object]';
}
function h(e) {
return Object.prototype.toString.call(e) === '[object String]';
}
function N(e) {
return Object.entries(e).map(([t, i]) => ({
enabled: !0,
name: t,
value: `${i}`,
}));
}
function c(e) {
return h(e) ? e.replaceAll(/{{\s*(_\.)?([^}]+)\s*}}/g, '${[$2]}') : e;
}
function D(e, t, i = 0) {
var a, u;
console.log('IMPORTING REQUEST', e._id, e.name, JSON.stringify(e, null, 2));
let s = null,
n = null;
((a = e.body) == null ? void 0 : a.mimeType) === 'application/graphql'
? ((s = 'graphql'), (n = c(e.body.text)))
: ((u = e.body) == null ? void 0 : u.mimeType) === 'application/json' &&
((s = 'application/json'), (n = c(e.body.text)));
let p = null,
o = {};
return (
e.authentication.type === 'bearer'
? ((p = 'bearer'),
(o = {
token: c(e.authentication.token),
}))
: e.authentication.type === 'basic' &&
((p = 'basic'),
(o = {
username: c(e.authentication.username),
password: c(e.authentication.password),
})),
{
id: e._id,
createdAt: new Date(e.created ?? Date.now()).toISOString().replace('Z', ''),
updatedAt: new Date(e.updated ?? Date.now()).toISOString().replace('Z', ''),
workspaceId: t,
folderId: e.parentId === t ? null : e.parentId,
model: 'http_request',
sortPriority: i,
name: e.name,
url: c(e.url),
body: n,
bodyType: s,
authentication: o,
authenticationType: p,
method: e.method,
headers: (e.headers ?? []).map(({ name: m, value: r, disabled: f }) => ({
enabled: !f,
name: m,
value: r,
})),
}
);
}
function w(e, t) {
return (
console.log('IMPORTING Workspace', e._id, e.name, JSON.stringify(e, null, 2)),
{
id: e._id,
createdAt: new Date(e.created ?? Date.now()).toISOString().replace('Z', ''),
updatedAt: new Date(e.updated ?? Date.now()).toISOString().replace('Z', ''),
model: 'workspace',
name: e.name,
variables: t,
}
);
}
function b(e, t) {
return (
console.log('IMPORTING FOLDER', e._id, e.name, JSON.stringify(e, null, 2)),
{
id: e._id,
createdAt: new Date(e.created ?? Date.now()).toISOString().replace('Z', ''),
updatedAt: new Date(e.updated ?? Date.now()).toISOString().replace('Z', ''),
folderId: e.parentId === t ? null : e.parentId,
workspaceId: t,
model: 'folder',
name: e.name,
}
);
}
function T(e) {
const t = JSON.parse(e);
if (!d(t)) return;
const { _type: i, __export_format: s } = t;
if (i !== 'export' || s !== 4 || !Array.isArray(t.resources)) return;
const n = {
workspaces: [],
requests: [],
environments: [],
folders: [],
},
p = t.resources.filter(g);
for (const o of p) {
console.log('IMPORTING WORKSPACE', o.name);
const a = t.resources.find((r) => I(r) && r.parentId === o._id);
console.log('FOUND BASE ENV', a.name),
n.workspaces.push(w(o, a ? N(a.data) : [])),
console.log('IMPORTING ENVIRONMENTS', a.name);
const u = t.resources.filter((r) => I(r) && r.parentId === (a == null ? void 0 : a._id));
console.log('FOUND', u.length, 'ENVIRONMENTS'),
n.environments.push(...u.map((r) => O(r, o._id)));
const m = (r) => {
const f = t.resources.filter((l) => l.parentId === r);
let S = 0;
for (const l of f)
y(l) ? (n.folders.push(b(l, o._id)), m(l._id)) : _(l) && n.requests.push(D(l, o._id, S++));
};
m(o._id);
}
return (
(n.requests = n.requests.filter(Boolean)),
(n.environments = n.environments.filter(Boolean)),
(n.workspaces = n.workspaces.filter(Boolean)),
n
);
}
export { T as pluginHookImport };

View File

@@ -0,0 +1,23 @@
export function isWorkspace(obj) {
return isJSObject(obj) && obj._type === 'workspace';
}
export function isRequestGroup(obj) {
return isJSObject(obj) && obj._type === 'request_group';
}
export function isRequest(obj) {
return isJSObject(obj) && obj._type === 'request';
}
export function isEnvironment(obj) {
return isJSObject(obj) && obj._type === 'environment';
}
export function isJSObject(obj) {
return Object.prototype.toString.call(obj) === '[object Object]';
}
export function isJSString(obj) {
return Object.prototype.toString.call(obj) === '[object String]';
}

View File

@@ -0,0 +1,18 @@
import { isJSString } from './types.js';
export function parseVariables(data) {
return Object.entries(data).map(([name, value]) => ({
enabled: true,
name,
value: `${value}`,
}));
}
/**
* Convert Insomnia syntax to Yaak syntax
* @param {string} variable - Text to convert
*/
export function convertSyntax(variable) {
if (!isJSString(variable)) return variable;
return variable.replaceAll(/{{\s*(_\.)?([^}]+)\s*}}/g, '${[$2]}');
}

View File

@@ -0,0 +1,21 @@
/**
* Import an Insomnia environment object.
* @param {Object} e - The environment object to import.
* @param workspaceId - Workspace to import into.
*/
export function importEnvironment(e, workspaceId) {
console.log('IMPORTING Environment', e._id, e.name, JSON.stringify(e, null, 2));
return {
id: e._id,
createdAt: new Date(e.created ?? Date.now()).toISOString().replace('Z', ''),
updatedAt: new Date(e.updated ?? Date.now()).toISOString().replace('Z', ''),
workspaceId,
model: 'environment',
name: e.name,
variables: Object.entries(e.data).map(([name, value]) => ({
enabled: true,
name,
value: `${value}`,
})),
};
}

View File

@@ -0,0 +1,17 @@
/**
* Import an Insomnia folder object.
* @param {Object} f - The environment object to import.
* @param workspaceId - Workspace to import into.
*/
export function importFolder(f, workspaceId) {
console.log('IMPORTING FOLDER', f._id, f.name, JSON.stringify(f, null, 2));
return {
id: f._id,
createdAt: new Date(f.created ?? Date.now()).toISOString().replace('Z', ''),
updatedAt: new Date(f.updated ?? Date.now()).toISOString().replace('Z', ''),
folderId: f.parentId === workspaceId ? null : f.parentId,
workspaceId,
model: 'folder',
name: f.name,
};
}

View File

@@ -0,0 +1,58 @@
import { convertSyntax } from '../helpers/variables.js';
/**
* Import an Insomnia request object.
* @param {Object} r - The request object to import.
* @param workspaceId - The workspace ID to use for the request.
* @param {number} sortPriority - The sort priority to use for the request.
*/
export function importRequest(r, workspaceId, sortPriority = 0) {
console.log('IMPORTING REQUEST', r._id, r.name, JSON.stringify(r, null, 2));
let bodyType = null;
let body = null;
if (r.body?.mimeType === 'application/graphql') {
bodyType = 'graphql';
body = convertSyntax(r.body.text);
} else if (r.body?.mimeType === 'application/json') {
bodyType = 'application/json';
body = convertSyntax(r.body.text);
}
let authenticationType = null;
let authentication = {};
if (r.authentication.type === 'bearer') {
authenticationType = 'bearer';
authentication = {
token: convertSyntax(r.authentication.token),
};
} else if (r.authentication.type === 'basic') {
authenticationType = 'basic';
authentication = {
username: convertSyntax(r.authentication.username),
password: convertSyntax(r.authentication.password),
};
}
return {
id: r._id,
createdAt: new Date(r.created ?? Date.now()).toISOString().replace('Z', ''),
updatedAt: new Date(r.updated ?? Date.now()).toISOString().replace('Z', ''),
workspaceId,
folderId: r.parentId === workspaceId ? null : r.parentId,
model: 'http_request',
sortPriority,
name: r.name,
url: convertSyntax(r.url),
body,
bodyType,
authentication,
authenticationType,
method: r.method,
headers: (r.headers ?? []).map(({ name, value, disabled }) => ({
enabled: !disabled,
name,
value,
})),
};
}

View File

@@ -0,0 +1,15 @@
/**
* Import an Insomnia workspace object.
* @param {Object} w - The workspace object to import.
*/
export function importWorkspace(w, variables) {
console.log('IMPORTING Workspace', w._id, w.name, JSON.stringify(w, null, 2));
return {
id: w._id,
createdAt: new Date(w.created ?? Date.now()).toISOString().replace('Z', ''),
updatedAt: new Date(w.updated ?? Date.now()).toISOString().replace('Z', ''),
model: 'workspace',
name: w.name,
variables,
};
}

View File

@@ -0,0 +1,78 @@
import { importEnvironment } from './importers/environment.js';
import { importRequest } from './importers/request.js';
import { importWorkspace } from './importers/workspace.js';
import {
isEnvironment,
isJSObject,
isRequest,
isRequestGroup,
isWorkspace,
} from './helpers/types.js';
import { parseVariables } from './helpers/variables.js';
import { importFolder } from './importers/folder.js';
export function pluginHookImport(contents) {
const parsed = JSON.parse(contents);
if (!isJSObject(parsed)) {
return;
}
const { _type, __export_format } = parsed;
if (_type !== 'export' || __export_format !== 4 || !Array.isArray(parsed.resources)) {
return;
}
const resources = {
workspaces: [],
requests: [],
environments: [],
folders: [],
};
// Import workspaces
const workspacesToImport = parsed.resources.filter(isWorkspace);
for (const workspaceToImport of workspacesToImport) {
console.log('IMPORTING WORKSPACE', workspaceToImport.name);
const baseEnvironment = parsed.resources.find(
(r) => isEnvironment(r) && r.parentId === workspaceToImport._id,
);
console.log('FOUND BASE ENV', baseEnvironment.name);
resources.workspaces.push(
importWorkspace(
workspaceToImport,
baseEnvironment ? parseVariables(baseEnvironment.data) : [],
),
);
console.log('IMPORTING ENVIRONMENTS', baseEnvironment.name);
const environmentsToImport = parsed.resources.filter(
(r) => isEnvironment(r) && r.parentId === baseEnvironment?._id,
);
console.log('FOUND', environmentsToImport.length, 'ENVIRONMENTS');
resources.environments.push(
...environmentsToImport.map((r) => importEnvironment(r, workspaceToImport._id)),
);
const nextFolder = (parentId) => {
const children = parsed.resources.filter((r) => r.parentId === parentId);
let sortPriority = 0;
for (const child of children) {
if (isRequestGroup(child)) {
resources.folders.push(importFolder(child, workspaceToImport._id));
nextFolder(child._id);
} else if (isRequest(child)) {
resources.requests.push(importRequest(child, workspaceToImport._id, sortPriority++));
}
}
};
// Import folders
nextFolder(workspaceToImport._id);
}
// Filter out any `null` values
resources.requests = resources.requests.filter(Boolean);
resources.environments = resources.environments.filter(Boolean);
resources.workspaces = resources.workspaces.filter(Boolean);
return resources;
}

View File

@@ -0,0 +1,13 @@
import { resolve } from 'path';
import { defineConfig } from 'vite';
export default defineConfig({
build: {
lib: {
entry: resolve(__dirname, 'src/index.js'),
fileName: 'index',
formats: ['es'],
},
outDir: resolve(__dirname, 'out'),
},
});

View File

@@ -1,5 +1,15 @@
{
"db": "SQLite",
"02506ad41cc94cd937422ef1977a97174431f008a9fb4ce39667d587a858b876": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 5
}
},
"query": "\n INSERT INTO folders (\n id,\n workspace_id,\n folder_id,\n name,\n sort_priority\n )\n VALUES (?, ?, ?, ?, ?)\n ON CONFLICT (id) DO UPDATE SET\n updated_at = CURRENT_TIMESTAMP,\n name = excluded.name,\n folder_id = excluded.folder_id,\n sort_priority = excluded.sort_priority\n "
},
"06aaf8f4a17566f1d25da2a60f0baf4b5fc28c3cf0c001a84e25edf9eab3c7e3": {
"describe": {
"columns": [
@@ -58,6 +68,126 @@
},
"query": "\n DELETE FROM http_responses\n WHERE id = ?\n "
},
"1428d25b6aa3d6ec55742a968571fa951da0406d7bb32408883c584eae7dd53c": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "model",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "workspace_id",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 3,
"type_info": "Datetime"
},
{
"name": "updated_at",
"ordinal": 4,
"type_info": "Datetime"
},
{
"name": "folder_id",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "name",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "sort_priority",
"ordinal": 7,
"type_info": "Float"
}
],
"nullable": [
false,
false,
false,
false,
false,
true,
false,
false
],
"parameters": {
"Right": 1
}
},
"query": "\n SELECT\n id,\n model,\n workspace_id,\n created_at,\n updated_at,\n folder_id,\n name,\n sort_priority\n FROM folders\n WHERE id = ?\n "
},
"1517b0f86c841b5f1247bd40c3a9b38ab001d846a410b6e3cd36f9e844d50ddb": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "model",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "workspace_id",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 3,
"type_info": "Datetime"
},
{
"name": "updated_at",
"ordinal": 4,
"type_info": "Datetime"
},
{
"name": "folder_id",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "name",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "sort_priority",
"ordinal": 7,
"type_info": "Float"
}
],
"nullable": [
false,
false,
false,
false,
false,
true,
false,
false
],
"parameters": {
"Right": 1
}
},
"query": "\n SELECT\n id,\n model,\n workspace_id,\n created_at,\n updated_at,\n folder_id,\n name,\n sort_priority\n FROM folders\n WHERE workspace_id = ?\n "
},
"26072725d536c3cfdffd9a681d17c0ee2f246ca98e0459630a2430236d3bbdd2": {
"describe": {
"columns": [
@@ -170,6 +300,60 @@
},
"query": "\n DELETE FROM http_requests\n WHERE id = ?\n "
},
"5588db23df7f30dc75857e05395ebbcf2384e2ac0d7cb87f76d74c6d50781d7b": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "model",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 2,
"type_info": "Datetime"
},
{
"name": "updated_at",
"ordinal": 3,
"type_info": "Datetime"
},
{
"name": "name",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "description",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "variables!: sqlx::types::Json<Vec<EnvironmentVariable>>",
"ordinal": 6,
"type_info": "Text"
}
],
"nullable": [
false,
false,
false,
false,
false,
false,
false
],
"parameters": {
"Right": 0
}
},
"query": "\n SELECT id, model, created_at, updated_at, name, description,\n variables AS \"variables!: sqlx::types::Json<Vec<EnvironmentVariable>>\"\n FROM workspaces\n "
},
"5aa070e61995f8b1724efaa94c5f0cef5a4be6efda5d70354ad449d7d4b5aee4": {
"describe": {
"columns": [
@@ -272,6 +456,16 @@
},
"query": "\n SELECT id, model, workspace_id, request_id, updated_at, created_at, url,\n status, status_reason, content_length, body, body_path, elapsed, error,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpResponseHeader>>\"\n FROM http_responses\n WHERE request_id = ?\n ORDER BY created_at DESC\n "
},
"610223ad10b6e25926d486ba775a74b55625fcc4e6637d8a805d44ec3f3b9532": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 4
}
},
"query": "\n INSERT INTO workspaces (id, name, description, variables)\n VALUES (?, ?, ?, ?)\n ON CONFLICT (id) DO UPDATE SET\n updated_at = CURRENT_TIMESTAMP,\n name = excluded.name,\n description = excluded.description,\n variables = excluded.variables\n "
},
"62475fd9483fb5eda01c937949da2ef66ac7005b4be06b87aa6210d462348aca": {
"describe": {
"columns": [],
@@ -282,7 +476,7 @@
},
"query": "\n UPDATE http_responses SET (\n elapsed,\n url,\n status,\n status_reason,\n content_length,\n body,\n body_path,\n error,\n headers,\n updated_at\n ) = (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) WHERE id = ?;\n "
},
"6f0cb5a6d1e8dbc8cdfcc3c7e7944b2c83c22cb795b9d6b98fe067dabec9680b": {
"689bcc92b914f50c14921faa796c07a256deb84c832fc3d90200b393fb159417": {
"describe": {
"columns": [
{
@@ -316,44 +510,9 @@
"type_info": "Text"
},
{
"name": "url",
"name": "variables!: sqlx::types::Json<Vec<EnvironmentVariable>>",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "method",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "body",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "body_type",
"ordinal": 9,
"type_info": "Text"
},
{
"name": "authentication!: Json<HashMap<String, JsonValue>>",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "authentication_type",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "sort_priority",
"ordinal": 12,
"type_info": "Float"
},
{
"name": "headers!: sqlx::types::Json<Vec<HttpRequestHeader>>",
"ordinal": 13,
"type_info": "Text"
"type_info": "Null"
}
],
"nullable": [
@@ -363,20 +522,13 @@
false,
false,
false,
false,
false,
true,
true,
false,
true,
false,
false
],
"parameters": {
"Right": 1
}
},
"query": "\n SELECT\n id,\n model,\n workspace_id,\n created_at,\n updated_at,\n name,\n url,\n method,\n body,\n body_type,\n authentication AS \"authentication!: Json<HashMap<String, JsonValue>>\",\n authentication_type,\n sort_priority,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpRequestHeader>>\"\n FROM http_requests\n WHERE workspace_id = ?\n "
"query": "\n SELECT\n id,\n model,\n workspace_id,\n created_at,\n updated_at,\n name,\n variables AS \"variables!: sqlx::types::Json<Vec<EnvironmentVariable>>\"\n FROM environments\n WHERE id = ?\n "
},
"84be2b954870ab181738656ecd4d03fca2ff21012947014c79626abfce8e999b": {
"describe": {
@@ -388,6 +540,108 @@
},
"query": "\n DELETE FROM workspaces\n WHERE id = ?\n "
},
"854536c80af3f86bb9a63b8ce059ad724374b545cb23481bb3b2ce07d7414220": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "model",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "workspace_id",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "folder_id",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 4,
"type_info": "Datetime"
},
{
"name": "updated_at",
"ordinal": 5,
"type_info": "Datetime"
},
{
"name": "name",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "url",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "method",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "body",
"ordinal": 9,
"type_info": "Text"
},
{
"name": "body_type",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "authentication!: Json<HashMap<String, JsonValue>>",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "authentication_type",
"ordinal": 12,
"type_info": "Text"
},
{
"name": "sort_priority",
"ordinal": 13,
"type_info": "Float"
},
{
"name": "headers!: sqlx::types::Json<Vec<HttpRequestHeader>>",
"ordinal": 14,
"type_info": "Text"
}
],
"nullable": [
false,
false,
false,
true,
false,
false,
false,
false,
false,
true,
true,
false,
true,
false,
false
],
"parameters": {
"Right": 1
}
},
"query": "\n SELECT\n id,\n model,\n workspace_id,\n folder_id,\n created_at,\n updated_at,\n name,\n url,\n method,\n body,\n body_type,\n authentication AS \"authentication!: Json<HashMap<String, JsonValue>>\",\n authentication_type,\n sort_priority,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpRequestHeader>>\"\n FROM http_requests\n WHERE id = ?\n "
},
"8947a2a90478277c42fe9b06bc1fa98197642a4d281a3dbc101be2c9c1fec36c": {
"describe": {
"columns": [],
@@ -398,7 +652,139 @@
},
"query": "\n INSERT INTO http_responses (\n id,\n request_id,\n workspace_id,\n elapsed,\n url,\n status,\n status_reason,\n content_length,\n body,\n body_path,\n headers\n )\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);\n "
},
"986763e31599881f287ef378002fc35d8e983af10a30a9aa4ade606dacf83260": {
"93aea3881dffb70a82325263740a0bb6477e78f27991ce7456b394e84383acb6": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 1
}
},
"query": "\n DELETE FROM folders\n WHERE id = ?\n "
},
"a558e182f40286fe52bed5f03b2dc367b7229ab6bd9cda0a7ce219a438ccd5fd": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "model",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "workspace_id",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "folder_id",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 4,
"type_info": "Datetime"
},
{
"name": "updated_at",
"ordinal": 5,
"type_info": "Datetime"
},
{
"name": "name",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "url",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "method",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "body",
"ordinal": 9,
"type_info": "Text"
},
{
"name": "body_type",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "authentication!: Json<HashMap<String, JsonValue>>",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "authentication_type",
"ordinal": 12,
"type_info": "Text"
},
{
"name": "sort_priority",
"ordinal": 13,
"type_info": "Float"
},
{
"name": "headers!: sqlx::types::Json<Vec<HttpRequestHeader>>",
"ordinal": 14,
"type_info": "Text"
}
],
"nullable": [
false,
false,
false,
true,
false,
false,
false,
false,
false,
true,
true,
false,
true,
false,
false
],
"parameters": {
"Right": 1
}
},
"query": "\n SELECT\n id,\n model,\n workspace_id,\n folder_id,\n created_at,\n updated_at,\n name,\n url,\n method,\n body,\n body_type,\n authentication AS \"authentication!: Json<HashMap<String, JsonValue>>\",\n authentication_type,\n sort_priority,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpRequestHeader>>\"\n FROM http_requests\n WHERE workspace_id = ?\n "
},
"ac1b4ffbd98b67f0a1a74e3525387d679dd6f44c561d55c7bbea747053e53671": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 0
}
},
"query": "\n UPDATE http_responses\n SET (elapsed, status_reason) = (-1, 'Cancelled')\n WHERE elapsed = 0;\n "
},
"aeb0712785a9964d516dc8939bc54aa8206ad852e608b362d014b67a0f21b0ed": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 1
}
},
"query": "\n DELETE FROM environments\n WHERE id = ?\n "
},
"ba2b34a77723f24f86e4c3c45274dbfec6ca130e16e592f948844c037bdc0593": {
"describe": {
"columns": [
{
@@ -432,9 +818,9 @@
"type_info": "Text"
},
{
"name": "data!: Json<HashMap<String, JsonValue>>",
"name": "variables!: sqlx::types::Json<Vec<EnvironmentVariable>>",
"ordinal": 6,
"type_info": "Text"
"type_info": "Null"
}
],
"nullable": [
@@ -450,27 +836,7 @@
"Right": 1
}
},
"query": "\n SELECT id, workspace_id, model, created_at, updated_at, name,\n data AS \"data!: Json<HashMap<String, JsonValue>>\"\n FROM environments\n WHERE workspace_id = ?\n "
},
"ab7294b681f1202ef06aaa26885147ead2db6ac740023793cda1e1c92665d996": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 4
}
},
"query": "\n INSERT INTO environments (\n id,\n workspace_id,\n name,\n data\n )\n VALUES (?, ?, ?, ?)\n "
},
"b19c275180909a39342b13c3cdcf993781636913ae590967f5508c46a56dc961": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 11
}
},
"query": "\n INSERT INTO http_requests (\n id,\n workspace_id,\n name,\n url,\n method,\n body,\n body_type,\n authentication,\n authentication_type,\n headers,\n sort_priority\n )\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n ON CONFLICT (id) DO UPDATE SET\n updated_at = CURRENT_TIMESTAMP,\n name = excluded.name,\n method = excluded.method,\n headers = excluded.headers,\n body = excluded.body,\n body_type = excluded.body_type,\n authentication = excluded.authentication,\n authentication_type = excluded.authentication_type,\n url = excluded.url,\n sort_priority = excluded.sort_priority\n "
"query": "\n SELECT id, workspace_id, model, created_at, updated_at, name,\n variables AS \"variables!: sqlx::types::Json<Vec<EnvironmentVariable>>\"\n FROM environments\n WHERE workspace_id = ?\n "
},
"c23c61b05a4c9e04ab0c1fc2c579d6f2a82a37aeed8addf9861b4985f2a5422e": {
"describe": {
@@ -574,198 +940,6 @@
},
"query": "\n SELECT id, model, workspace_id, request_id, updated_at, created_at, url,\n status, status_reason, content_length, body, body_path, elapsed, error,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpResponseHeader>>\"\n FROM http_responses\n WHERE id = ?\n "
},
"caf3f21bf291dfbd36446592066e96c1f83abe96f6ea9211a3e049eb9c58a8c8": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "model",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 2,
"type_info": "Datetime"
},
{
"name": "updated_at",
"ordinal": 3,
"type_info": "Datetime"
},
{
"name": "name",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "description",
"ordinal": 5,
"type_info": "Text"
}
],
"nullable": [
false,
false,
false,
false,
false,
false
],
"parameters": {
"Right": 1
}
},
"query": "\n SELECT id, model, created_at, updated_at, name, description\n FROM workspaces WHERE id = ?\n "
},
"cea4cae52f16ec78aca9a47b17117422d4f165e5a3b308c70fd1a180382475ea": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "model",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 2,
"type_info": "Datetime"
},
{
"name": "updated_at",
"ordinal": 3,
"type_info": "Datetime"
},
{
"name": "name",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "description",
"ordinal": 5,
"type_info": "Text"
}
],
"nullable": [
false,
false,
false,
false,
false,
false
],
"parameters": {
"Right": 0
}
},
"query": "\n SELECT id, model, created_at, updated_at, name, description\n FROM workspaces\n "
},
"ced098adb79c0ee64e223b6e02371ef253920a2c342275de0fa9c181529a4adc": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "model",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "workspace_id",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 3,
"type_info": "Datetime"
},
{
"name": "updated_at",
"ordinal": 4,
"type_info": "Datetime"
},
{
"name": "name",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "url",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "method",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "body",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "body_type",
"ordinal": 9,
"type_info": "Text"
},
{
"name": "authentication!: Json<HashMap<String, JsonValue>>",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "authentication_type",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "sort_priority",
"ordinal": 12,
"type_info": "Float"
},
{
"name": "headers!: sqlx::types::Json<Vec<HttpRequestHeader>>",
"ordinal": 13,
"type_info": "Text"
}
],
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false,
true,
true,
false,
true,
false,
false
],
"parameters": {
"Right": 1
}
},
"query": "\n SELECT\n id,\n model,\n workspace_id,\n created_at,\n updated_at,\n name,\n url,\n method,\n body,\n body_type,\n authentication AS \"authentication!: Json<HashMap<String, JsonValue>>\",\n authentication_type,\n sort_priority,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpRequestHeader>>\"\n FROM http_requests\n WHERE id = ?\n "
},
"d80c09497771e3641022e73ec6c6a87e73a551f88a948a5445d754922b82b50b": {
"describe": {
"columns": [],
@@ -776,27 +950,7 @@
},
"query": "\n INSERT INTO key_values (namespace, key, value)\n VALUES (?, ?, ?) ON CONFLICT DO UPDATE SET\n updated_at = CURRENT_TIMESTAMP,\n value = excluded.value\n "
},
"e0f41023d877d94b7609ce910a71bd89c4827a558654b8ae14d85e6ba86990cf": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 2
}
},
"query": "\n UPDATE workspaces SET (name, updated_at) =\n (?, CURRENT_TIMESTAMP) WHERE id = ?;\n "
},
"f116d8cf9aad828135bb8c3a4c8b8e6b857ae13303989e9133a33b2d1cf20e96": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 3
}
},
"query": "\n INSERT INTO workspaces (id, name, description)\n VALUES (?, ?, ?)\n "
},
"fb89f653780b3f3ab0dd0bb2af30c8d3945203819cb9df7bdd331df56a6ae690": {
"dbe457087a7bccbca4c1d673aa8e547df04530a7f860a6ccd4e20126a7cdfa4f": {
"describe": {
"columns": [
{
@@ -809,30 +963,30 @@
"ordinal": 1,
"type_info": "Text"
},
{
"name": "workspace_id",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 3,
"ordinal": 2,
"type_info": "Datetime"
},
{
"name": "updated_at",
"ordinal": 4,
"ordinal": 3,
"type_info": "Datetime"
},
{
"name": "name",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "description",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "data!: Json<HashMap<String, JsonValue>>",
"name": "variables!: sqlx::types::Json<Vec<EnvironmentVariable>>",
"ordinal": 6,
"type_info": "Text"
"type_info": "Null"
}
],
"nullable": [
@@ -848,6 +1002,26 @@
"Right": 1
}
},
"query": "\n SELECT\n id,\n model,\n workspace_id,\n created_at,\n updated_at,\n name,\n data AS \"data!: Json<HashMap<String, JsonValue>>\"\n FROM environments\n WHERE id = ?\n "
"query": "\n SELECT id, model, created_at, updated_at, name, description,\n variables AS \"variables!: sqlx::types::Json<Vec<EnvironmentVariable>>\"\n FROM workspaces WHERE id = ?\n "
},
"dcc2f405f8e29d0599d86bcde509187e9cc5fc647067eaa5c738cb24e2f081e5": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 4
}
},
"query": "\n INSERT INTO environments (\n id,\n workspace_id,\n name,\n variables\n )\n VALUES (?, ?, ?, ?)\n ON CONFLICT (id) DO UPDATE SET\n updated_at = CURRENT_TIMESTAMP,\n name = excluded.name,\n variables = excluded.variables\n "
},
"e5b410442b00ee354bb58eb0e8fb2af06e9dea4bb24763d717c72a840450a759": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 12
}
},
"query": "\n INSERT INTO http_requests (\n id,\n workspace_id,\n folder_id,\n name,\n url,\n method,\n body,\n body_type,\n authentication,\n authentication_type,\n headers,\n sort_priority\n )\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n ON CONFLICT (id) DO UPDATE SET\n updated_at = CURRENT_TIMESTAMP,\n name = excluded.name,\n folder_id = excluded.folder_id,\n method = excluded.method,\n headers = excluded.headers,\n body = excluded.body,\n body_type = excluded.body_type,\n authentication = excluded.authentication,\n authentication_type = excluded.authentication_type,\n url = excluded.url,\n sort_priority = excluded.sort_priority\n "
}
}

118
src-tauri/src/analytics.rs Normal file
View File

@@ -0,0 +1,118 @@
use sqlx::types::JsonValue;
use tauri::{async_runtime, AppHandle, Manager};
use crate::is_dev;
pub enum AnalyticsResource {
App,
// Workspace,
// Environment,
// Folder,
// HttpRequest,
// HttpResponse,
}
pub enum AnalyticsAction {
Launch,
// Create,
// Update,
// Upsert,
// Delete,
// Send,
// Duplicate,
}
fn resource_name(resource: AnalyticsResource) -> &'static str {
match resource {
AnalyticsResource::App => "app",
// AnalyticsResource::Workspace => "workspace",
// AnalyticsResource::Environment => "environment",
// AnalyticsResource::Folder => "folder",
// AnalyticsResource::HttpRequest => "http_request",
// AnalyticsResource::HttpResponse => "http_response",
}
}
fn action_name(action: AnalyticsAction) -> &'static str {
match action {
AnalyticsAction::Launch => "launch",
// AnalyticsAction::Create => "create",
// AnalyticsAction::Update => "update",
// AnalyticsAction::Upsert => "upsert",
// AnalyticsAction::Delete => "delete",
// AnalyticsAction::Send => "send",
// AnalyticsAction::Duplicate => "duplicate",
}
}
pub fn track_event(
app_handle: &AppHandle,
resource: AnalyticsResource,
action: AnalyticsAction,
attributes: Option<JsonValue>,
) {
async_runtime::block_on(async move {
let event = format!("{}.{}", resource_name(resource), action_name(action));
let attributes_json = attributes.unwrap_or("{}".to_string().into()).to_string();
let info = app_handle.package_info();
let params = vec![
("e", event.clone()),
("a", attributes_json.clone()),
("id", "site_zOK0d7jeBy2TLxFCnZ".to_string()),
("v", info.version.clone().to_string()),
("os", get_os().to_string()),
("xy", get_window_size(app_handle)),
];
let url = format!("https://t.yaak.app/t/e");
let req = reqwest::Client::builder()
.build()
.unwrap()
.get(&url)
.query(&params);
if is_dev() {
println!("Ignore dev analytics event: {} {:?}", event, params);
} else {
if let Err(e) = req.send().await {
println!("Error sending analytics event: {}", e);
} else {
println!("Sent analytics event: {}", event);
}
}
});
}
fn get_os() -> &'static str {
if cfg!(target_os = "windows") {
"windows"
} else if cfg!(target_os = "macos") {
"macos"
} else if cfg!(target_os = "linux") {
"linux"
} else {
"unknown"
}
}
fn get_window_size(app_handle: &AppHandle) -> String {
let window = match app_handle.windows().into_values().next() {
Some(w) => w,
None => return "unknown".to_string(),
};
let current_monitor = match window.current_monitor() {
Ok(Some(m)) => m,
_ => return "unknown".to_string(),
};
let scale_factor = current_monitor.scale_factor();
let size = current_monitor.size();
let width: f64 = size.width as f64 / scale_factor;
let height: f64 = size.height as f64 / scale_factor;
format!(
"{}x{}",
(width / 100.0).round() * 100.0,
(height / 100.0).round() * 100.0
)
}

View File

@@ -11,6 +11,7 @@ use std::collections::HashMap;
use std::env::current_dir;
use std::fs::{create_dir_all, File};
use std::io::Write;
use std::process::exit;
use base64::Engine;
use http::header::{HeaderName, ACCEPT, USER_AGENT};
@@ -20,21 +21,25 @@ use reqwest::redirect::Policy;
use serde::Serialize;
use sqlx::migrate::Migrator;
use sqlx::sqlite::SqlitePoolOptions;
use sqlx::types::{Json, JsonValue};
use sqlx::types::Json;
use sqlx::{Pool, Sqlite};
use tauri::regex::Regex;
#[cfg(target_os = "macos")]
use tauri::TitleBarStyle;
use tauri::{AppHandle, Menu, MenuItem, RunEvent, State, Submenu, Window, WindowUrl, Wry};
use tauri::{AppHandle, Menu, RunEvent, State, Submenu, Window, WindowUrl, Wry};
use tauri::{CustomMenuItem, Manager, WindowEvent};
use tauri_plugin_window_state::{StateFlags, WindowExt};
use tokio::sync::Mutex;
use window_ext::WindowExt;
use window_ext::TrafficLightWindowExt;
use crate::models::generate_id;
use crate::analytics::{track_event, AnalyticsAction, AnalyticsResource};
mod analytics;
mod models;
mod plugin;
mod render;
mod window_ext;
mod window_menu;
#[derive(serde::Serialize)]
pub struct CustomResponse {
@@ -66,44 +71,38 @@ async fn migrate_db(
#[tauri::command]
async fn send_ephemeral_request(
request: models::HttpRequest,
mut request: models::HttpRequest,
environment_id: Option<&str>,
app_handle: AppHandle<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<models::HttpResponse, String> {
let pool = &*db_instance.lock().await;
let response = models::HttpResponse::default();
return actually_send_ephemeral_request(request, &response, &app_handle, pool).await;
let response = models::HttpResponse::new();
let environment_id2 = environment_id.unwrap_or("n/a").to_string();
request.id = "".to_string();
return actually_send_request(request, &response, &environment_id2, &app_handle, pool).await;
}
async fn actually_send_ephemeral_request(
async fn actually_send_request(
request: models::HttpRequest,
response: &models::HttpResponse,
environment_id: &str,
app_handle: &AppHandle<Wry>,
pool: &Pool<Sqlite>,
) -> Result<models::HttpResponse, String> {
let start = std::time::Instant::now();
let mut url_string = request.url.to_string();
let environment = models::get_environment(environment_id, pool).await.ok();
let environment_ref = environment.as_ref();
let workspace = models::get_workspace(&request.workspace_id, pool)
.await
.expect("Failed to get Workspace");
let variables: HashMap<&str, &str> = HashMap::new();
// variables.insert("", "");
let re = Regex::new(r"\$\{\[\s*([^]\s]+)\s*]}").expect("Failed to create regex");
url_string = re
.replace(&url_string, |caps: &tauri::regex::Captures| {
let key = caps.get(1).unwrap().as_str();
match variables.get(key) {
Some(v) => v,
None => "",
}
})
.to_string();
let mut url_string = render::render(&request.url, &workspace, environment.as_ref());
if !url_string.starts_with("http://") && !url_string.starts_with("https://") {
url_string = format!("http://{}", url_string);
}
println!("Sending request to {}", url_string);
let client = reqwest::Client::builder()
.redirect(Policy::none())
// .danger_accept_invalid_certs(true)
@@ -118,57 +117,62 @@ async fn actually_send_ephemeral_request(
if h.name.is_empty() && h.value.is_empty() {
continue;
}
if !h.enabled {
continue;
}
let header_name = match HeaderName::from_bytes(h.name.as_bytes()) {
let name = render::render(&h.name, &workspace, environment_ref);
let value = render::render(&h.value, &workspace, environment_ref);
let header_name = match HeaderName::from_bytes(name.as_bytes()) {
Ok(n) => n,
Err(e) => {
eprintln!("Failed to create header name: {}", e);
continue;
}
};
let header_value = match HeaderValue::from_str(h.value.as_str()) {
let header_value = match HeaderValue::from_str(value.as_str()) {
Ok(n) => n,
Err(e) => {
eprintln!("Failed to create header value: {}", e);
continue;
}
};
headers.insert(header_name, header_value);
}
if let Some(b) = &request.authentication_type {
let empty_value = &serde_json::to_value("").unwrap();
let a = request.authentication.0;
if b == "basic" {
let a = request.authentication.0;
let auth = format!(
"{}:{}",
a.get("username")
.unwrap_or(empty_value)
.as_str()
.unwrap_or(""),
a.get("password")
.unwrap_or(empty_value)
.as_str()
.unwrap_or(""),
);
let raw_username = a
.get("username")
.unwrap_or(empty_value)
.as_str()
.unwrap_or("");
let raw_password = a
.get("password")
.unwrap_or(empty_value)
.as_str()
.unwrap_or("");
let username = render::render(raw_username, &workspace, environment_ref);
let password = render::render(raw_password, &workspace, environment_ref);
let auth = format!("{username}:{password}");
let encoded = base64::engine::general_purpose::STANDARD_NO_PAD.encode(auth);
headers.insert(
"Authorization",
HeaderValue::from_str(&format!("Basic {}", encoded)).unwrap(),
);
} else if b == "bearer" {
let token = request
.authentication
.0
.get("token")
.unwrap_or(empty_value)
.as_str()
.unwrap_or("");
let raw_token = a.get("token").unwrap_or(empty_value).as_str().unwrap_or("");
let token = render::render(raw_token, &workspace, environment_ref);
headers.insert(
"Authorization",
HeaderValue::from_str(&format!("Bearer {}", token)).unwrap(),
HeaderValue::from_str(&format!("Bearer {token}")).unwrap(),
);
}
}
@@ -178,7 +182,10 @@ async fn actually_send_ephemeral_request(
let builder = client.request(m, url_string.to_string()).headers(headers);
let sendable_req_result = match (request.body, request.body_type) {
(Some(b), Some(_)) => builder.body(b).build(),
(Some(raw_body), Some(_)) => {
let body = render::render(&raw_body, &workspace, environment_ref);
builder.body(body).build()
}
_ => builder.build(),
};
@@ -251,12 +258,29 @@ async fn actually_send_ephemeral_request(
Err(e) => response_err(response, e.to_string(), app_handle, pool).await,
}
}
#[tauri::command]
async fn import_data(
window: Window<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
file_paths: Vec<&str>,
) -> Result<plugin::ImportedResources, String> {
let pool = &*db_instance.lock().await;
let imported = plugin::run_plugin_import(
&window.app_handle(),
pool,
"insomnia-importer",
file_paths.first().unwrap(),
)
.await;
Ok(imported)
}
#[tauri::command]
async fn send_request(
window: Window<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
request_id: &str,
environment_id: Option<&str>,
) -> Result<models::HttpResponse, String> {
let pool = &*db_instance.lock().await;
@@ -269,10 +293,12 @@ async fn send_request(
.expect("Failed to create response");
let response2 = response.clone();
let environment_id2 = environment_id.unwrap_or("n/a").to_string();
let app_handle2 = window.app_handle().clone();
let pool2 = pool.clone();
tokio::spawn(async move {
actually_send_ephemeral_request(req, &response2, &app_handle2, &pool2)
actually_send_request(req, &response2, &environment_id2, &app_handle2, &pool2)
.await
.expect("Failed to send request");
});
@@ -287,6 +313,7 @@ async fn response_err(
pool: &Pool<Sqlite>,
) -> Result<models::HttpResponse, String> {
let mut response = response.clone();
response.elapsed = -1;
response.error = Some(error.clone());
response = models::update_response_if_id(&response, pool)
.await
@@ -331,36 +358,63 @@ async fn create_workspace(
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<models::Workspace, String> {
let pool = &*db_instance.lock().await;
let created_workspace = models::create_workspace(name, "", pool)
.await
.expect("Failed to create workspace");
let created_workspace = models::upsert_workspace(
pool,
models::Workspace {
name: name.to_string(),
..Default::default()
},
)
.await
.expect("Failed to create Workspace");
emit_and_return(&window, "created_model", created_workspace)
}
#[tauri::command]
async fn create_environment(
workspace_id: &str,
name: &str,
variables: Vec<models::EnvironmentVariable>,
window: Window<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<models::Environment, String> {
let pool = &*db_instance.lock().await;
let created_environment = models::upsert_environment(
pool,
models::Environment {
workspace_id: workspace_id.to_string(),
name: name.to_string(),
variables: Json(variables),
..Default::default()
},
)
.await
.expect("Failed to create environment");
emit_and_return(&window, "created_model", created_environment)
}
#[tauri::command]
async fn create_request(
workspace_id: &str,
name: &str,
sort_priority: f64,
folder_id: Option<&str>,
window: Window<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<models::HttpRequest, String> {
let pool = &*db_instance.lock().await;
let headers = Vec::new();
let created_request = models::upsert_request(
None,
workspace_id,
name,
"GET",
None,
None,
HashMap::new(),
None,
"",
headers,
sort_priority,
pool,
models::HttpRequest {
workspace_id: workspace_id.to_string(),
name: name.to_string(),
method: "GET".to_string(),
folder_id: folder_id.map(|s| s.to_string()),
sort_priority,
..Default::default()
},
)
.await
.expect("Failed to create request");
@@ -389,13 +443,28 @@ async fn update_workspace(
) -> Result<models::Workspace, String> {
let pool = &*db_instance.lock().await;
let updated_workspace = models::update_workspace(workspace, pool)
let updated_workspace = models::upsert_workspace(pool, workspace)
.await
.expect("Failed to update request");
emit_and_return(&window, "updated_model", updated_workspace)
}
#[tauri::command]
async fn update_environment(
environment: models::Environment,
window: Window<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<models::Environment, String> {
let pool = &*db_instance.lock().await;
let updated_environment = models::upsert_environment(pool, environment)
.await
.expect("Failed to update environment");
emit_and_return(&window, "updated_model", updated_environment)
}
#[tauri::command]
async fn update_request(
request: models::HttpRequest,
@@ -403,35 +472,9 @@ async fn update_request(
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<models::HttpRequest, String> {
let pool = &*db_instance.lock().await;
// TODO: Figure out how to make this better
let b2;
let body = match request.body {
Some(b) => {
b2 = b;
Some(b2.as_str())
}
None => None,
};
// TODO: Figure out how to make this better
let updated_request = models::upsert_request(
Some(request.id.as_str()),
request.workspace_id.as_str(),
request.name.as_str(),
request.method.as_str(),
body,
request.body_type,
request.authentication.0,
request.authentication_type,
request.url.as_str(),
request.headers.0,
request.sort_priority,
pool,
)
.await
.expect("Failed to update request");
let updated_request = models::upsert_request(pool, request)
.await
.expect("Failed to update request");
emit_and_return(&window, "updated_model", updated_request)
}
@@ -449,7 +492,83 @@ async fn delete_request(
}
#[tauri::command]
async fn requests(
async fn list_folders(
workspace_id: &str,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<Vec<models::Folder>, String> {
let pool = &*db_instance.lock().await;
models::find_folders(workspace_id, pool)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn create_folder(
workspace_id: &str,
name: &str,
sort_priority: f64,
folder_id: Option<&str>,
window: Window<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<models::Folder, String> {
let pool = &*db_instance.lock().await;
let created_request = models::upsert_folder(
pool,
models::Folder {
workspace_id: workspace_id.to_string(),
name: name.to_string(),
folder_id: folder_id.map(|s| s.to_string()),
sort_priority,
..Default::default()
},
)
.await
.expect("Failed to create folder");
emit_and_return(&window, "created_model", created_request)
}
#[tauri::command]
async fn update_folder(
folder: models::Folder,
window: Window<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<models::Folder, String> {
let pool = &*db_instance.lock().await;
let updated_folder = models::upsert_folder(pool, folder)
.await
.expect("Failed to update request");
emit_and_return(&window, "updated_model", updated_folder)
}
#[tauri::command]
async fn delete_folder(
window: Window<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
folder_id: &str,
) -> Result<models::Folder, String> {
let pool = &*db_instance.lock().await;
let req = models::delete_folder(folder_id, pool)
.await
.expect("Failed to delete folder");
emit_and_return(&window, "deleted_model", req)
}
#[tauri::command]
async fn delete_environment(
window: Window<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
environment_id: &str,
) -> Result<models::Environment, String> {
let pool = &*db_instance.lock().await;
let req = models::delete_environment(environment_id, pool)
.await
.expect("Failed to delete environment");
emit_and_return(&window, "deleted_model", req)
}
#[tauri::command]
async fn list_requests(
workspace_id: &str,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<Vec<models::HttpRequest>, String> {
@@ -460,7 +579,7 @@ async fn requests(
}
#[tauri::command]
async fn environments(
async fn list_environments(
workspace_id: &str,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<Vec<models::Environment>, String> {
@@ -469,17 +588,18 @@ async fn environments(
.await
.expect("Failed to find environments");
println!("");
if environments.is_empty() {
println!("CREATING DEFAULT ENVIRONMENT");
let data: HashMap<String, JsonValue> = HashMap::new();
let environment = models::create_environment(workspace_id, "Default", data, pool)
.await
.expect("Failed to create default environment");
Ok(vec![environment])
} else {
Ok(environments)
}
Ok(environments)
}
#[tauri::command]
async fn get_folder(
id: &str,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<models::Folder, String> {
let pool = &*db_instance.lock().await;
models::get_folder(id, pool)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
@@ -493,6 +613,17 @@ async fn get_request(
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn get_environment(
id: &str,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<models::Environment, String> {
let pool = &*db_instance.lock().await;
models::get_environment(id, pool)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn get_workspace(
id: &str,
@@ -505,7 +636,7 @@ async fn get_workspace(
}
#[tauri::command]
async fn responses(
async fn list_responses(
request_id: &str,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<Vec<models::HttpResponse>, String> {
@@ -540,7 +671,7 @@ async fn delete_all_responses(
}
#[tauri::command]
async fn workspaces(
async fn list_workspaces(
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<Vec<models::Workspace>, String> {
let pool = &*db_instance.lock().await;
@@ -548,10 +679,15 @@ async fn workspaces(
.await
.expect("Failed to find workspaces");
if workspaces.is_empty() {
let workspace =
models::create_workspace("My Project", "This is the default workspace", pool)
.await
.expect("Failed to create workspace");
let workspace = models::upsert_workspace(
pool,
models::Workspace {
name: "My Project".to_string(),
..Default::default()
},
)
.await
.expect("Failed to create Workspace");
Ok(vec![workspace])
} else {
Ok(workspaces)
@@ -568,17 +704,18 @@ async fn new_window(window: Window<Wry>, url: &str) -> Result<(), String> {
async fn delete_workspace(
window: Window<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
id: &str,
workspace_id: &str,
) -> Result<models::Workspace, String> {
let pool = &*db_instance.lock().await;
let workspace = models::delete_workspace(id, pool)
let workspace = models::delete_workspace(workspace_id, pool)
.await
.expect("Failed to delete workspace");
.expect("Failed to delete Workspace");
emit_and_return(&window, "deleted_model", workspace)
}
fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_window_state::Builder::default().build())
.setup(|app| {
let dir = match is_dev() {
true => current_dir().unwrap(),
@@ -590,6 +727,7 @@ fn main() {
let p_string = p.to_string_lossy().replace(' ', "%20");
let url = format!("sqlite://{}?mode=rwc", p_string);
println!("Connecting to database at {}", url);
tauri::async_runtime::block_on(async move {
let pool = SqlitePoolOptions::new()
.connect(url.as_str())
@@ -597,42 +735,94 @@ fn main() {
.expect("Failed to connect to database");
// Setup the DB handle
let m = Mutex::new(pool);
let m = Mutex::new(pool.clone());
migrate_db(app.handle(), &m)
.await
.expect("Failed to migrate database");
app.manage(m);
let _ = models::cancel_pending_responses(&pool).await;
// TODO: Move this somewhere better
match app.get_cli_matches() {
Ok(matches) => {
let cmd = matches.subcommand.unwrap_or_default();
if cmd.name == "import" {
let arg_file = cmd
.matches
.args
.get("file")
.unwrap()
.value
.as_str()
.unwrap();
plugin::run_plugin_import(
&app.handle(),
&pool,
"insomnia-importer",
arg_file,
)
.await;
exit(0);
} else if cmd.name == "hello" {
plugin::run_plugin_hello(&app.handle(), "hello-world");
exit(0);
}
}
Err(e) => {
println!("Nothing found: {}", e);
}
}
Ok(())
})
})
.invoke_handler(tauri::generate_handler![
workspaces,
environments,
requests,
responses,
new_window,
get_request,
send_request,
send_ephemeral_request,
duplicate_request,
create_environment,
create_folder,
create_request,
get_workspace,
create_workspace,
delete_workspace,
update_workspace,
update_request,
delete_request,
get_key_value,
set_key_value,
delete_response,
delete_all_responses,
delete_environment,
delete_folder,
delete_request,
delete_response,
delete_workspace,
duplicate_request,
get_key_value,
get_environment,
get_folder,
get_request,
get_workspace,
import_data,
list_environments,
list_folders,
list_requests,
list_responses,
list_workspaces,
new_window,
send_ephemeral_request,
send_request,
set_key_value,
update_environment,
update_folder,
update_request,
update_workspace,
])
.build(tauri::generate_context!())
.expect("error while running tauri application")
.run(|app_handle, event| match event {
RunEvent::Ready => {
create_window(app_handle, None);
let w = create_window(app_handle, None);
w.restore_state(StateFlags::all())
.expect("Failed to restore window state");
track_event(
app_handle,
AnalyticsResource::App,
AnalyticsAction::Launch,
None,
);
}
// ExitRequested { api, .. } => {
@@ -647,70 +837,31 @@ fn is_dev() -> bool {
}
fn create_window(handle: &AppHandle<Wry>, url: Option<&str>) -> Window<Wry> {
let default_menu = Menu::os_default("Yaak".to_string().as_str());
let mut test_menu = Menu::new()
.add_item(
CustomMenuItem::new("send_request".to_string(), "Send Request")
.accelerator("CmdOrCtrl+r"),
)
.add_item(
CustomMenuItem::new("zoom_reset".to_string(), "Zoom to Actual Size")
.accelerator("CmdOrCtrl+0"),
)
.add_item(
CustomMenuItem::new("zoom_in".to_string(), "Zoom In").accelerator("CmdOrCtrl+Plus"),
)
.add_item(
CustomMenuItem::new("zoom_out".to_string(), "Zoom Out").accelerator("CmdOrCtrl+-"),
)
.add_item(
CustomMenuItem::new("toggle_sidebar".to_string(), "Toggle Sidebar")
.accelerator("CmdOrCtrl+b"),
)
.add_item(
CustomMenuItem::new("focus_url".to_string(), "Focus URL").accelerator("CmdOrCtrl+l"),
)
.add_item(
CustomMenuItem::new("new_request".to_string(), "New Request")
.accelerator("CmdOrCtrl+n"),
)
.add_item(
CustomMenuItem::new("toggle_settings".to_string(), "Toggle Settings")
.accelerator("CmdOrCtrl+,"),
)
.add_item(
CustomMenuItem::new("duplicate_request".to_string(), "Duplicate Request")
.accelerator("CmdOrCtrl+d"),
)
.add_item(
CustomMenuItem::new("focus_sidebar".to_string(), "Focus Sidebar")
.accelerator("CmdOrCtrl+1"),
)
.add_item(CustomMenuItem::new("new_window".to_string(), "New Window"));
let mut app_menu = window_menu::os_default("Yaak".to_string().as_str());
if is_dev() {
test_menu = test_menu
.add_native_item(MenuItem::Separator)
.add_item(
CustomMenuItem::new("refresh".to_string(), "Refresh")
.accelerator("CmdOrCtrl + Shift + r"),
)
.add_item(
CustomMenuItem::new("toggle_devtools".to_string(), "Open Devtools")
.accelerator("CmdOrCtrl + Option + i"),
);
let submenu = Submenu::new(
"Developer",
Menu::new()
.add_item(
CustomMenuItem::new("refresh".to_string(), "Refresh")
.accelerator("CmdOrCtrl + Shift + r"),
)
.add_item(
CustomMenuItem::new("toggle_devtools".to_string(), "Open Devtools")
.accelerator("CmdOrCtrl + Option + i"),
),
);
app_menu = app_menu.add_submenu(submenu);
}
let submenu = Submenu::new("Test Menu", test_menu);
let window_num = handle.windows().len();
let window_id = format!("wnd_{}_{}", window_num, generate_id(None));
let menu = default_menu.add_submenu(submenu);
let window_id = format!("wnd_{}", window_num);
let mut win_builder = tauri::WindowBuilder::new(
handle,
window_id,
WindowUrl::App(url.unwrap_or_default().into()),
)
.menu(menu)
.menu(app_menu)
.fullscreen(false)
.resizable(true)
.inner_size(1100.0, 600.0)
@@ -737,7 +888,7 @@ fn create_window(handle: &AppHandle<Wry>, url: Option<&str>) -> Window<Wry> {
let win2 = win.clone();
let handle2 = handle.clone();
win.on_menu_event(move |event| match event.menu_item_id() {
"quit" => std::process::exit(0),
"quit" => exit(0),
"close" => win2.close().unwrap(),
"zoom_reset" => win2.emit("zoom", 0).unwrap(),
"zoom_in" => win2.emit("zoom", 1).unwrap(),

View File

@@ -7,8 +7,8 @@ use sqlx::types::chrono::NaiveDateTime;
use sqlx::types::{Json, JsonValue};
use sqlx::{Pool, Sqlite};
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct Workspace {
pub id: String,
pub model: String,
@@ -16,10 +16,11 @@ pub struct Workspace {
pub updated_at: NaiveDateTime,
pub name: String,
pub description: String,
pub variables: Json<Vec<EnvironmentVariable>>,
}
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct Environment {
pub id: String,
pub workspace_id: String,
@@ -27,29 +28,48 @@ pub struct Environment {
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
pub name: String,
pub data: Json<HashMap<String, JsonValue>>,
pub variables: Json<Vec<EnvironmentVariable>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HttpRequestHeader {
#[serde(default)]
fn default_enabled() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct EnvironmentVariable {
#[serde(default = "default_enabled")]
pub enabled: bool,
pub name: String,
pub value: String,
}
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct HttpRequestHeader {
#[serde(default = "default_enabled")]
pub enabled: bool,
pub name: String,
pub value: String,
}
fn default_http_request_method() -> String {
"GET".to_string()
}
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct HttpRequest {
pub id: String,
pub workspace_id: String,
pub model: String,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
pub id: String,
pub workspace_id: String,
pub folder_id: Option<String>,
pub model: String,
pub sort_priority: f64,
pub name: String,
pub url: String,
#[serde(default = "default_http_request_method")]
pub method: String,
pub body: Option<String>,
pub body_type: Option<String>,
@@ -58,15 +78,28 @@ pub struct HttpRequest {
pub headers: Json<Vec<HttpRequestHeader>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct Folder {
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
pub id: String,
pub workspace_id: String,
pub folder_id: Option<String>,
pub model: String,
pub name: String,
pub sort_priority: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct HttpResponseHeader {
pub name: String,
pub value: String,
}
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
#[serde(default, rename_all = "camelCase")]
pub struct HttpResponse {
pub id: String,
pub model: String,
@@ -85,8 +118,17 @@ pub struct HttpResponse {
pub headers: Json<Vec<HttpResponseHeader>>,
}
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
impl HttpResponse {
pub(crate) fn new() -> Self {
Self {
model: "http_response".to_string(),
..Default::default()
}
}
}
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct KeyValue {
pub model: String,
pub created_at: NaiveDateTime,
@@ -144,7 +186,8 @@ pub async fn find_workspaces(pool: &Pool<Sqlite>) -> Result<Vec<Workspace>, sqlx
sqlx::query_as!(
Workspace,
r#"
SELECT id, model, created_at, updated_at, name, description
SELECT id, model, created_at, updated_at, name, description,
variables AS "variables!: sqlx::types::Json<Vec<EnvironmentVariable>>"
FROM workspaces
"#,
)
@@ -156,7 +199,8 @@ pub async fn get_workspace(id: &str, pool: &Pool<Sqlite>) -> Result<Workspace, s
sqlx::query_as!(
Workspace,
r#"
SELECT id, model, created_at, updated_at, name, description
SELECT id, model, created_at, updated_at, name, description,
variables AS "variables!: sqlx::types::Json<Vec<EnvironmentVariable>>"
FROM workspaces WHERE id = ?
"#,
id,
@@ -184,27 +228,6 @@ pub async fn delete_workspace(id: &str, pool: &Pool<Sqlite>) -> Result<Workspace
Ok(workspace)
}
pub async fn create_workspace(
name: &str,
description: &str,
pool: &Pool<Sqlite>,
) -> Result<Workspace, sqlx::Error> {
let id = generate_id(Some("wk"));
sqlx::query!(
r#"
INSERT INTO workspaces (id, name, description)
VALUES (?, ?, ?)
"#,
id,
name,
description,
)
.execute(pool)
.await?;
get_workspace(&id, pool).await
}
pub async fn find_environments(
workspace_id: &str,
pool: &Pool<Sqlite>,
@@ -213,7 +236,7 @@ pub async fn find_environments(
Environment,
r#"
SELECT id, workspace_id, model, created_at, updated_at, name,
data AS "data!: Json<HashMap<String, JsonValue>>"
variables AS "variables!: sqlx::types::Json<Vec<EnvironmentVariable>>"
FROM environments
WHERE workspace_id = ?
"#,
@@ -223,29 +246,48 @@ pub async fn find_environments(
.await
}
pub async fn create_environment(
workspace_id: &str,
name: &str,
data: HashMap<String, JsonValue>,
pub async fn delete_environment(id: &str, pool: &Pool<Sqlite>) -> Result<Environment, sqlx::Error> {
let env = get_environment(id, pool).await?;
let _ = sqlx::query!(
r#"
DELETE FROM environments
WHERE id = ?
"#,
id,
)
.execute(pool)
.await;
Ok(env)
}
pub async fn upsert_environment(
pool: &Pool<Sqlite>,
environment: Environment,
) -> Result<Environment, sqlx::Error> {
let id = generate_id(Some("en"));
let data_json = Json(data);
let trimmed_name = name.trim();
let id = match environment.id.as_str() {
"" => generate_id(Some("ev")),
_ => environment.id.to_string(),
};
let trimmed_name = environment.name.trim();
sqlx::query!(
r#"
INSERT INTO environments (
id,
workspace_id,
name,
data
variables
)
VALUES (?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET
updated_at = CURRENT_TIMESTAMP,
name = excluded.name,
variables = excluded.variables
"#,
id,
workspace_id,
environment.workspace_id,
trimmed_name,
data_json,
environment.variables,
)
.execute(pool)
.await?;
@@ -263,7 +305,7 @@ pub async fn get_environment(id: &str, pool: &Pool<Sqlite>) -> Result<Environmen
created_at,
updated_at,
name,
data AS "data!: Json<HashMap<String, JsonValue>>"
variables AS "variables!: sqlx::types::Json<Vec<EnvironmentVariable>>"
FROM environments
WHERE id = ?
"#,
@@ -273,66 +315,127 @@ pub async fn get_environment(id: &str, pool: &Pool<Sqlite>) -> Result<Environmen
.await
}
pub async fn duplicate_request(id: &str, pool: &Pool<Sqlite>) -> Result<HttpRequest, sqlx::Error> {
let existing = get_request(id, pool).await?;
// TODO: Figure out how to make this better
let b2;
let body = match existing.body {
Some(b) => {
b2 = b;
Some(b2.as_str())
}
None => None,
};
upsert_request(
None,
existing.workspace_id.as_str(),
existing.name.as_str(),
existing.method.as_str(),
body,
existing.body_type,
existing.authentication.0,
existing.authentication_type,
existing.url.as_str(),
existing.headers.0,
existing.sort_priority + 0.001,
pool,
pub async fn get_folder(id: &str, pool: &Pool<Sqlite>) -> Result<Folder, sqlx::Error> {
sqlx::query_as!(
Folder,
r#"
SELECT
id,
model,
workspace_id,
created_at,
updated_at,
folder_id,
name,
sort_priority
FROM folders
WHERE id = ?
"#,
id,
)
.fetch_one(pool)
.await
}
pub async fn upsert_request(
id: Option<&str>,
pub async fn find_folders(
workspace_id: &str,
name: &str,
method: &str,
body: Option<&str>,
body_type: Option<String>,
authentication: HashMap<String, JsonValue>,
authentication_type: Option<String>,
url: &str,
headers: Vec<HttpRequestHeader>,
sort_priority: f64,
pool: &Pool<Sqlite>,
) -> Result<HttpRequest, sqlx::Error> {
let generated_id;
let id = match id {
Some(v) => v,
None => {
generated_id = generate_id(Some("rq"));
generated_id.as_str()
}
) -> Result<Vec<Folder>, sqlx::Error> {
sqlx::query_as!(
Folder,
r#"
SELECT
id,
model,
workspace_id,
created_at,
updated_at,
folder_id,
name,
sort_priority
FROM folders
WHERE workspace_id = ?
"#,
workspace_id,
)
.fetch_all(pool)
.await
}
pub async fn delete_folder(id: &str, pool: &Pool<Sqlite>) -> Result<Folder, sqlx::Error> {
let env = get_folder(id, pool).await?;
let _ = sqlx::query!(
r#"
DELETE FROM folders
WHERE id = ?
"#,
id,
)
.execute(pool)
.await;
Ok(env)
}
pub async fn upsert_folder(pool: &Pool<Sqlite>, r: Folder) -> Result<Folder, sqlx::Error> {
let id = match r.id.as_str() {
"" => generate_id(Some("fl")),
_ => r.id.to_string(),
};
let headers_json = Json(headers);
let auth_json = Json(authentication);
let trimmed_name = name.trim();
let trimmed_name = r.name.trim();
sqlx::query!(
r#"
INSERT INTO folders (
id,
workspace_id,
folder_id,
name,
sort_priority
)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET
updated_at = CURRENT_TIMESTAMP,
name = excluded.name,
folder_id = excluded.folder_id,
sort_priority = excluded.sort_priority
"#,
id,
r.workspace_id,
r.folder_id,
trimmed_name,
r.sort_priority,
)
.execute(pool)
.await?;
get_folder(&id, pool).await
}
pub async fn duplicate_request(id: &str, pool: &Pool<Sqlite>) -> Result<HttpRequest, sqlx::Error> {
let mut request = get_request(id, pool).await?.clone();
request.id = "".to_string();
upsert_request(pool, request).await
}
pub async fn upsert_request(
pool: &Pool<Sqlite>,
r: HttpRequest,
) -> Result<HttpRequest, sqlx::Error> {
let id = match r.id.as_str() {
"" => generate_id(Some("rq")),
_ => r.id.to_string(),
};
let headers_json = Json(r.headers);
let auth_json = Json(r.authentication);
let trimmed_name = r.name.trim();
sqlx::query!(
r#"
INSERT INTO http_requests (
id,
workspace_id,
folder_id,
name,
url,
method,
@@ -343,10 +446,11 @@ pub async fn upsert_request(
headers,
sort_priority
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET
updated_at = CURRENT_TIMESTAMP,
name = excluded.name,
folder_id = excluded.folder_id,
method = excluded.method,
headers = excluded.headers,
body = excluded.body,
@@ -357,20 +461,22 @@ pub async fn upsert_request(
sort_priority = excluded.sort_priority
"#,
id,
workspace_id,
r.workspace_id,
r.folder_id,
trimmed_name,
url,
method,
body,
body_type,
r.url,
r.method,
r.body,
r.body_type,
auth_json,
authentication_type,
r.authentication_type,
headers_json,
sort_priority,
r.sort_priority,
)
.execute(pool)
.await?;
get_request(id, pool).await
get_request(&id, pool).await
}
pub async fn find_requests(
@@ -384,6 +490,7 @@ pub async fn find_requests(
id,
model,
workspace_id,
folder_id,
created_at,
updated_at,
name,
@@ -412,6 +519,7 @@ pub async fn get_request(id: &str, pool: &Pool<Sqlite>) -> Result<HttpRequest, s
id,
model,
workspace_id,
folder_id,
created_at,
updated_at,
name,
@@ -434,6 +542,10 @@ pub async fn get_request(id: &str, pool: &Pool<Sqlite>) -> Result<HttpRequest, s
pub async fn delete_request(id: &str, pool: &Pool<Sqlite>) -> Result<HttpRequest, sqlx::Error> {
let req = get_request(id, pool).await?;
// DB deletes will cascade but this will delete the files
delete_all_responses(id, pool).await?;
let _ = sqlx::query!(
r#"
DELETE FROM http_requests
@@ -444,8 +556,6 @@ pub async fn delete_request(id: &str, pool: &Pool<Sqlite>) -> Result<HttpRequest
.execute(pool)
.await;
delete_all_responses(id, pool).await?;
Ok(req)
}
@@ -499,6 +609,19 @@ pub async fn create_response(
get_response(&id, pool).await
}
pub async fn cancel_pending_responses(pool: &Pool<Sqlite>) -> Result<(), sqlx::Error> {
sqlx::query!(
r#"
UPDATE http_responses
SET (elapsed, status_reason) = (-1, 'Cancelled')
WHERE elapsed = 0;
"#,
)
.execute(pool)
.await?;
Ok(())
}
pub async fn update_response_if_id(
response: &HttpResponse,
pool: &Pool<Sqlite>,
@@ -509,23 +632,34 @@ pub async fn update_response_if_id(
return update_response(response, pool).await;
}
pub async fn update_workspace(
workspace: Workspace,
pub async fn upsert_workspace(
pool: &Pool<Sqlite>,
workspace: Workspace,
) -> Result<Workspace, sqlx::Error> {
let id = match workspace.id.as_str() {
"" => generate_id(Some("wk")),
_ => workspace.id.to_string(),
};
let trimmed_name = workspace.name.trim();
sqlx::query!(
r#"
UPDATE workspaces SET (name, updated_at) =
(?, CURRENT_TIMESTAMP) WHERE id = ?;
INSERT INTO workspaces (id, name, description, variables)
VALUES (?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET
updated_at = CURRENT_TIMESTAMP,
name = excluded.name,
description = excluded.description,
variables = excluded.variables
"#,
id,
trimmed_name,
workspace.id,
workspace.description,
workspace.variables,
)
.execute(pool)
.await?;
get_workspace(&workspace.id, pool).await
get_workspace(&id, pool).await
}
pub async fn update_response(

187
src-tauri/src/plugin.rs Normal file
View File

@@ -0,0 +1,187 @@
use std::fs;
use boa_engine::builtins::promise::PromiseState;
use boa_engine::{
js_string,
module::{ModuleLoader, SimpleModuleLoader},
property::Attribute,
Context, JsArgs, JsNativeError, JsValue, Module, NativeFunction, Source,
};
use boa_runtime::Console;
use serde::{Deserialize, Serialize};
use serde_json::json;
use sqlx::{Pool, Sqlite};
use tauri::AppHandle;
use crate::models::{self, Environment, Folder, HttpRequest, Workspace};
pub fn run_plugin_hello(app_handle: &AppHandle, plugin_name: &str) {
run_plugin(app_handle, plugin_name, "hello", &[]);
}
#[derive(Default, Debug, Deserialize, Serialize)]
pub struct ImportedResources {
workspaces: Vec<Workspace>,
environments: Vec<Environment>,
folders: Vec<Folder>,
requests: Vec<HttpRequest>,
}
pub async fn run_plugin_import(
app_handle: &AppHandle,
pool: &Pool<Sqlite>,
plugin_name: &str,
file_path: &str,
) -> ImportedResources {
let file = fs::read_to_string(file_path)
.expect(format!("Unable to read file {}", file_path.to_string()).as_str());
let file_contents = file.as_str();
let result_json = run_plugin(
app_handle,
plugin_name,
"pluginHookImport",
&[js_string!(file_contents).into()],
);
let resources: ImportedResources =
serde_json::from_value(result_json).expect("failed to parse result json");
let mut imported_resources = ImportedResources::default();
println!("Importing resources");
for w in resources.workspaces {
println!("Importing workspace: {:?}", w);
let x = models::upsert_workspace(&pool, w)
.await
.expect("Failed to create workspace");
imported_resources.workspaces.push(x.clone());
println!("Imported workspace: {}", x.name);
}
for e in resources.environments {
println!("Importing environment: {:?}", e);
let x = models::upsert_environment(&pool, e)
.await
.expect("Failed to create environment");
imported_resources.environments.push(x.clone());
println!("Imported environment: {}", x.name);
}
for f in resources.folders {
println!("Importing folder: {:?}", f);
let x = models::upsert_folder(&pool, f)
.await
.expect("Failed to create folder");
imported_resources.folders.push(x.clone());
println!("Imported folder: {}", x.name);
}
for r in resources.requests {
println!("Importing request: {:?}", r);
let x = models::upsert_request(&pool, r)
.await
.expect("Failed to create request");
imported_resources.requests.push(x.clone());
println!("Imported request: {}", x.name);
}
imported_resources
}
fn run_plugin(
app_handle: &AppHandle,
plugin_name: &str,
entrypoint: &str,
js_args: &[JsValue],
) -> serde_json::Value {
let plugin_dir = app_handle
.path_resolver()
.resolve_resource("plugins")
.expect("failed to resolve plugin directory resource")
.join(plugin_name);
let plugin_index_file = plugin_dir.join("out/index.js");
println!("Plugin dir={:?} file={:?}", plugin_dir, plugin_index_file);
// Module loader for the specific plugin
let loader = &SimpleModuleLoader::new(plugin_dir).expect("failed to create module loader");
let dyn_loader: &dyn ModuleLoader = loader;
let context = &mut Context::builder()
.module_loader(dyn_loader)
.build()
.expect("failed to create context");
add_runtime(context);
add_globals(context);
let source = Source::from_filepath(&plugin_index_file).expect("Error opening file");
// Can also pass a `Some(realm)` if you need to execute the module in another realm.
let module = Module::parse(source, None, context).expect("failed to parse module");
// Insert parsed entrypoint into the module loader
// TODO: Is this needed if loaded from file already?
loader.insert(plugin_index_file, module.clone());
let promise_result = module
.load_link_evaluate(context)
.expect("failed to evaluate module");
// Very important to push forward the job queue after queueing promises.
context.run_jobs();
// Checking if the final promise didn't return an error.
match promise_result.state().expect("failed to get promise state") {
PromiseState::Pending => {
panic!("Promise was pending");
}
PromiseState::Fulfilled(v) => {
assert_eq!(v, JsValue::undefined())
}
PromiseState::Rejected(err) => {
panic!("Failed to link: {}", err.display());
}
}
let namespace = module.namespace(context);
let result = namespace
.get(js_string!(entrypoint), context)
.expect("failed to get entrypoint")
.as_callable()
.cloned()
.ok_or_else(|| JsNativeError::typ().with_message("export wasn't a function!"))
.expect("Failed to get entrypoint")
.call(&JsValue::undefined(), js_args, context)
.expect("Failed to call entrypoint");
match result.is_undefined() {
true => json!(null), // to_json doesn't work with undefined (yet)
false => result
.to_json(context)
.expect("failed to convert result to json"),
}
}
fn add_runtime(context: &mut Context<'_>) {
let console = Console::init(context);
context
.register_global_property(js_string!(Console::NAME), console, Attribute::all())
.expect("the console builtin shouldn't exist");
}
fn add_globals(context: &mut Context<'_>) {
context
.register_global_builtin_callable(
"sayHello",
1,
NativeFunction::from_fn_ptr(|_, args, context| {
let value: String = args
.get_or_undefined(0)
.try_js_into(context)
.expect("failed to convert arg");
println!("Hello {}!", value);
Ok(value.into())
}),
)
.expect("failed to register global");
}

32
src-tauri/src/render.rs Normal file
View File

@@ -0,0 +1,32 @@
use crate::models::{Environment, Workspace};
use std::collections::HashMap;
use tauri::regex::Regex;
pub fn render(template: &str, workspace: &Workspace, environment: Option<&Environment>) -> String {
let mut map = HashMap::new();
let workspace_variables = &workspace.variables.0;
for variable in workspace_variables {
if !variable.enabled || variable.value.is_empty() {
continue;
}
map.insert(variable.name.as_str(), variable.value.as_str());
}
if let Some(e) = environment {
let environment_variables = &e.variables.0;
for variable in environment_variables {
if !variable.enabled || variable.value.is_empty() {
continue;
}
map.insert(variable.name.as_str(), variable.value.as_str());
}
}
Regex::new(r"\$\{\[\s*([^]\s]+)\s*]}")
.expect("Failed to create regex")
.replace_all(template, |caps: &tauri::regex::Captures| {
let key = caps.get(1).unwrap().as_str();
map.get(key).unwrap_or(&"")
})
.to_string()
}

View File

@@ -3,11 +3,11 @@ use tauri::{Runtime, Window};
const TRAFFIC_LIGHT_OFFSET_X: f64 = 13.0;
const TRAFFIC_LIGHT_OFFSET_Y: f64 = 18.0;
pub trait WindowExt {
pub trait TrafficLightWindowExt {
fn position_traffic_lights(&self);
}
impl<R: Runtime> WindowExt for Window<R> {
impl<R: Runtime> TrafficLightWindowExt for Window<R> {
#[cfg(not(target_os = "macos"))]
fn position_traffic_lights(&self) {
// No-op

View File

@@ -0,0 +1,119 @@
use tauri::{AboutMetadata, CustomMenuItem, Menu, MenuItem, Submenu};
pub fn os_default(#[allow(unused)] app_name: &str) -> Menu {
let mut menu = Menu::new();
#[cfg(target_os = "macos")]
{
menu = menu.add_submenu(Submenu::new(
app_name,
Menu::new()
.add_native_item(MenuItem::About(
app_name.to_string(),
AboutMetadata::default(),
))
.add_native_item(MenuItem::Separator)
.add_native_item(MenuItem::Services)
.add_native_item(MenuItem::Separator)
.add_native_item(MenuItem::Hide)
.add_native_item(MenuItem::HideOthers)
.add_native_item(MenuItem::ShowAll)
.add_native_item(MenuItem::Separator)
.add_native_item(MenuItem::Quit),
));
}
let mut file_menu = Menu::new();
file_menu = file_menu.add_native_item(MenuItem::CloseWindow);
#[cfg(not(target_os = "macos"))]
{
file_menu = file_menu.add_native_item(MenuItem::Quit);
}
menu = menu.add_submenu(Submenu::new("File", file_menu));
#[cfg(not(target_os = "linux"))]
let mut edit_menu = Menu::new();
#[cfg(target_os = "macos")]
{
edit_menu = edit_menu.add_native_item(MenuItem::Undo);
edit_menu = edit_menu.add_native_item(MenuItem::Redo);
edit_menu = edit_menu.add_native_item(MenuItem::Separator);
}
#[cfg(not(target_os = "linux"))]
{
edit_menu = edit_menu.add_native_item(MenuItem::Cut);
edit_menu = edit_menu.add_native_item(MenuItem::Copy);
edit_menu = edit_menu.add_native_item(MenuItem::Paste);
}
#[cfg(target_os = "macos")]
{
edit_menu = edit_menu.add_native_item(MenuItem::SelectAll);
}
#[cfg(not(target_os = "linux"))]
{
menu = menu.add_submenu(Submenu::new("Edit", edit_menu));
}
let mut view_menu = Menu::new();
#[cfg(target_os = "macos")]
{
view_menu = view_menu
.add_native_item(MenuItem::EnterFullScreen)
.add_native_item(MenuItem::Separator);
}
view_menu = view_menu
.add_item(
CustomMenuItem::new("zoom_reset".to_string(), "Zoom to Actual Size")
.accelerator("CmdOrCtrl+0"),
)
.add_item(
CustomMenuItem::new("zoom_in".to_string(), "Zoom In").accelerator("CmdOrCtrl+Plus"),
)
.add_item(
CustomMenuItem::new("zoom_out".to_string(), "Zoom Out").accelerator("CmdOrCtrl+-"),
)
.add_native_item(MenuItem::Separator)
.add_item(
CustomMenuItem::new("toggle_sidebar".to_string(), "Toggle Sidebar")
.accelerator("CmdOrCtrl+b"),
)
.add_item(
CustomMenuItem::new("focus_sidebar".to_string(), "Focus Sidebar")
.accelerator("CmdOrCtrl+1"),
)
.add_item(
CustomMenuItem::new("toggle_settings".to_string(), "Toggle Settings")
.accelerator("CmdOrCtrl+,"),
)
.add_item(
CustomMenuItem::new("focus_url".to_string(), "Focus URL").accelerator("CmdOrCtrl+l"),
);
menu = menu.add_submenu(Submenu::new("View", view_menu));
let mut window_menu = Menu::new();
window_menu = window_menu.add_native_item(MenuItem::Minimize);
#[cfg(target_os = "macos")]
{
window_menu = window_menu.add_native_item(MenuItem::Zoom);
window_menu = window_menu.add_native_item(MenuItem::Separator);
}
window_menu = window_menu.add_native_item(MenuItem::CloseWindow);
menu = menu.add_submenu(Submenu::new("Window", window_menu));
menu = menu.add_submenu(Submenu::new(
"Workspace",
Menu::new()
.add_item(
CustomMenuItem::new("send_request".to_string(), "Send Request")
.accelerator("CmdOrCtrl+r"),
)
.add_item(
CustomMenuItem::new("new_request".to_string(), "New Request")
.accelerator("CmdOrCtrl+n"),
)
.add_item(
CustomMenuItem::new("duplicate_request".to_string(), "Duplicate Request")
.accelerator("CmdOrCtrl+d"),
),
));
menu
}

View File

@@ -8,10 +8,27 @@
},
"package": {
"productName": "Yaak",
"version": "2023.0.20"
"version": "2023.2.0"
},
"tauri": {
"windows": [],
"cli": {
"description": "Yaak CLI",
"longDescription": "This is the Yaak CLI, yo",
"beforeHelp": "u can use it to build, develop and manage your Yaak application.",
"afterHelp": "Have fun!",
"args": [],
"subcommands": {
"import": {
"args": [{
"name": "file",
"short": "f",
"takesValue": true
}]
},
"hello": {}
}
},
"allowlist": {
"all": false,
"os": {
@@ -36,6 +53,10 @@
},
"window": {
"startDragging": true
},
"dialog": {
"all": false,
"open": true
}
},
"bundle": {
@@ -53,7 +74,8 @@
"identifier": "co.schier.yaak",
"longDescription": "The best cross-platform visual API client",
"resources": [
"migrations/*"
"migrations/*",
"plugins/*"
],
"shortDescription": "The best API client",
"targets": [

View File

@@ -5,7 +5,6 @@ import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { HelmetProvider } from 'react-helmet-async';
import { AppRouter } from './AppRouter';
import { DialogProvider } from './DialogContext';
const queryClient = new QueryClient({
logger: undefined,
@@ -24,12 +23,10 @@ export function App() {
<MotionConfig transition={{ duration: 0.1 }}>
<HelmetProvider>
<DndProvider backend={HTML5Backend}>
<DialogProvider>
<Suspense>
<AppRouter />
{/*<ReactQueryDevtools initialIsOpen={false} />*/}
</Suspense>
</DialogProvider>
</DndProvider>
</HelmetProvider>
</MotionConfig>

View File

@@ -3,9 +3,11 @@ import { routePaths, useAppRoutes } from '../hooks/useAppRoutes';
import { useRecentRequests } from '../hooks/useRecentRequests';
import { useRequests } from '../hooks/useRequests';
import { GlobalHooks } from './GlobalHooks';
import RouteError from './RouteError';
import Workspace from './Workspace';
import Workspaces from './Workspaces';
import { DialogProvider } from './DialogContext';
import { useActiveEnvironmentId } from '../hooks/useActiveEnvironmentId';
import RouteError from './RouteError';
const router = createBrowserRouter([
{
@@ -22,12 +24,16 @@ const router = createBrowserRouter([
element: <Workspaces />,
},
{
path: routePaths.workspace({ workspaceId: ':workspaceId' }),
path: routePaths.workspace({
workspaceId: ':workspaceId',
environmentId: ':environmentId',
}),
element: <WorkspaceOrRedirect />,
},
{
path: routePaths.request({
workspaceId: ':workspaceId',
environmentId: ':environmentId',
requestId: ':requestId',
}),
element: <Workspace />,
@@ -42,6 +48,7 @@ export function AppRouter() {
function WorkspaceOrRedirect() {
const recentRequests = useRecentRequests();
const activeEnvironmentId = useActiveEnvironmentId();
const requests = useRequests();
const request = requests.find((r) => r.id === recentRequests[0]);
const routes = useAppRoutes();
@@ -50,18 +57,25 @@ function WorkspaceOrRedirect() {
return <Workspace />;
}
const { id: requestId, workspaceId } = request;
const environmentId = activeEnvironmentId ?? undefined;
return (
<Navigate
to={routes.paths.request({ workspaceId: request.workspaceId, requestId: request.id })}
to={routes.paths.request({
workspaceId,
environmentId,
requestId,
})}
/>
);
}
function Layout() {
return (
<>
<DialogProvider>
<Outlet />
<GlobalHooks />
</>
</DialogProvider>
);
}

View File

@@ -14,6 +14,7 @@ export function BasicAuth({ requestId, authentication }: Props) {
return (
<VStack className="my-2" space={2}>
<Input
useTemplating
label="Username"
name="username"
size="sm"
@@ -26,6 +27,7 @@ export function BasicAuth({ requestId, authentication }: Props) {
}}
/>
<Input
useTemplating
label="Password"
name="password"
size="sm"

View File

@@ -14,6 +14,9 @@ export function BearerAuth({ requestId, authentication }: Props) {
return (
<VStack className="my-2" space={2}>
<Input
useTemplating
autocompleteVariables
type="password"
label="Token"
name="token"
size="sm"

View File

@@ -54,9 +54,10 @@ export const DialogProvider = ({ children }: { children: React.ReactNode }) => {
function DialogInstance({ id, render, ...props }: DialogEntry) {
const { actions } = useContext(DialogContext);
const children = render({ hide: () => actions.hide(id) });
return (
<Dialog open onClose={() => actions.hide(id)} {...props}>
{render({ hide: () => actions.hide(id) })}
{children}
</Dialog>
);
}

View File

@@ -1,4 +1,4 @@
import classnames from 'classnames';
import classNames from 'classnames';
import React, { memo } from 'react';
interface Props {
@@ -9,7 +9,7 @@ export const DropMarker = memo(
function DropMarker({ className }: Props) {
return (
<div
className={classnames(
className={classNames(
className,
'relative w-full h-0 overflow-visible pointer-events-none',
)}

View File

@@ -0,0 +1,79 @@
import classNames from 'classnames';
import { memo, useCallback, useMemo } from 'react';
import { useActiveEnvironment } from '../hooks/useActiveEnvironment';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { useEnvironments } from '../hooks/useEnvironments';
import type { ButtonProps } from './core/Button';
import { Button } from './core/Button';
import type { DropdownItem } from './core/Dropdown';
import { Dropdown } from './core/Dropdown';
import { Icon } from './core/Icon';
import { useDialog } from './DialogContext';
import { EnvironmentEditDialog } from './EnvironmentEditDialog';
type Props = {
className?: string;
} & Pick<ButtonProps, 'forDropdown' | 'leftSlot'>;
export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdown({
className,
...buttonProps
}: Props) {
const environments = useEnvironments();
const activeEnvironment = useActiveEnvironment();
const dialog = useDialog();
const routes = useAppRoutes();
const showEnvironmentDialog = useCallback(() => {
dialog.show({
title: 'Manage Environments',
render: () => <EnvironmentEditDialog initialEnvironment={activeEnvironment} />,
});
}, [dialog, activeEnvironment]);
const items: DropdownItem[] = useMemo(
() => [
...environments.map(
(e) => ({
key: e.id,
label: e.name,
rightSlot: e.id === activeEnvironment?.id ? <Icon icon="check" /> : undefined,
onSelect: async () => {
if (e.id !== activeEnvironment?.id) {
routes.setEnvironment(e);
} else {
routes.setEnvironment(null);
}
},
}),
[activeEnvironment?.id],
),
...((environments.length > 0
? [{ type: 'separator', label: 'Environments' }]
: []) as DropdownItem[]),
{
key: 'edit',
label: 'Manage Environments',
leftSlot: <Icon icon="gear" />,
onSelect: showEnvironmentDialog,
},
],
[activeEnvironment, environments, routes, showEnvironmentDialog],
);
return (
<Dropdown items={items}>
<Button
size="sm"
className={classNames(
className,
'text-gray-800 !px-2 truncate',
activeEnvironment == null && 'text-opacity-disabled italic',
)}
{...buttonProps}
>
{activeEnvironment?.name ?? 'No Environment'}
</Button>
</Dropdown>
);
});

View File

@@ -0,0 +1,238 @@
import classNames from 'classnames';
import { useCallback, useMemo, useState } from 'react';
import { useWindowSize } from 'react-use';
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
import { useCreateEnvironment } from '../hooks/useCreateEnvironment';
import { useDeleteEnvironment } from '../hooks/useDeleteEnvironment';
import { useEnvironments } from '../hooks/useEnvironments';
import { usePrompt } from '../hooks/usePrompt';
import { useUpdateEnvironment } from '../hooks/useUpdateEnvironment';
import { useUpdateWorkspace } from '../hooks/useUpdateWorkspace';
import type { Environment, Workspace } from '../lib/models';
import { Button } from './core/Button';
import type { DropdownItem } from './core/Dropdown';
import { Dropdown } from './core/Dropdown';
import type {
GenericCompletionConfig,
GenericCompletionOption,
} from './core/Editor/genericCompletion';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { InlineCode } from './core/InlineCode';
import type { PairEditorProps } from './core/PairEditor';
import { PairEditor } from './core/PairEditor';
import { HStack, VStack } from './core/Stacks';
interface Props {
initialEnvironment: Environment | null;
}
export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<string | null>(
initialEnvironment?.id ?? null,
);
const environments = useEnvironments();
const createEnvironment = useCreateEnvironment();
const activeWorkspace = useActiveWorkspace();
const windowSize = useWindowSize();
const showSidebar = windowSize.width > 500;
const selectedEnvironment = useMemo(
() => environments.find((e) => e.id === selectedEnvironmentId) ?? null,
[environments, selectedEnvironmentId],
);
return (
<div
className={classNames(
'h-full grid gap-x-8 grid-rows-[minmax(0,1fr)]',
showSidebar ? 'grid-cols-[auto_minmax(0,1fr)]' : 'grid-cols-[minmax(0,1fr)]',
)}
>
{showSidebar && (
<aside className="grid grid-rows-[minmax(0,1fr)_auto] gap-y-0.5 h-full max-w-[250px] pr-3 border-r border-gray-100 -ml-2">
<div className="min-w-0 h-full w-full overflow-y-scroll">
<SidebarButton
active={selectedEnvironment == null}
onClick={() => setSelectedEnvironmentId(null)}
>
Base Environment
</SidebarButton>
<div className="ml-3 pl-2 border-l border-highlight">
{environments.map((e) => (
<SidebarButton
key={e.id}
active={selectedEnvironment?.id === e.id}
onClick={() => setSelectedEnvironmentId(e.id)}
>
{e.name}
</SidebarButton>
))}
</div>
</div>
<Button
size="sm"
className="w-full text-center"
color="gray"
justify="center"
onClick={() => createEnvironment.mutate()}
>
New Environment
</Button>
</aside>
)}
{activeWorkspace != null ? (
<EnvironmentEditor environment={selectedEnvironment} workspace={activeWorkspace} />
) : (
<div className="flex w-full h-full items-center justify-center text-gray-400 italic">
select an environment
</div>
)}
</div>
);
};
const EnvironmentEditor = function ({
environment,
workspace,
}: {
environment: Environment | null;
workspace: Workspace;
}) {
const environments = useEnvironments();
const updateEnvironment = useUpdateEnvironment(environment?.id ?? 'n/a');
const updateWorkspace = useUpdateWorkspace(workspace.id);
const deleteEnvironment = useDeleteEnvironment(environment);
const variables = environment == null ? workspace.variables : environment.variables;
const handleChange = useCallback<PairEditorProps['onChange']>(
(variables) => {
if (environment != null) {
updateEnvironment.mutate({ variables });
} else {
updateWorkspace.mutate({ variables });
}
},
[updateWorkspace, updateEnvironment, environment],
);
// Gather a list of env names from other environments, to help the user get them aligned
const nameAutocomplete = useMemo<GenericCompletionConfig>(() => {
const otherEnvironments = environments.filter((e) => e.id !== environment?.id);
const allVariableNames =
environment == null
? [
// Nothing to autocomplete if we're in the base environment
]
: [
...workspace.variables.map((v) => v.name),
...otherEnvironments.flatMap((e) => e.variables.map((v) => v.name)),
];
// Filter out empty strings and variables that already exist
const variableNames = allVariableNames.filter(
(name) => name != '' && !variables.find((v) => v.name === name),
);
const uniqueVariableNames = [...new Set(variableNames)];
const options = uniqueVariableNames.map(
(name): GenericCompletionOption => ({
label: name,
type: 'constant',
}),
);
return { options };
}, [environments, variables, workspace, environment]);
const prompt = usePrompt();
const items = useMemo<DropdownItem[] | null>(
() =>
environment == null
? null
: [
{
key: 'rename',
label: 'Rename',
leftSlot: <Icon icon="pencil" size="sm" />,
onSelect: async () => {
const name = await prompt({
title: 'Rename Environment',
description: (
<>
Enter a new name for <InlineCode>{environment.name}</InlineCode>
</>
),
name: 'name',
label: 'Name',
defaultValue: environment.name,
});
updateEnvironment.mutate({ name });
},
},
{
key: 'delete',
variant: 'danger',
label: 'Delete',
leftSlot: <Icon icon="trash" size="sm" />,
onSelect: () => deleteEnvironment.mutate(),
},
],
[deleteEnvironment, updateEnvironment, prompt, environment],
);
const validateName = useCallback((name: string) => {
// Empty just means the variable doesn't have a name yet, and is unusable
if (name === '') return true;
return name.match(/^[a-z_][a-z0-9_]*$/i) != null;
}, []);
return (
<VStack space={2}>
<HStack space={2} className="justify-between">
<h1 className="text-xl">{environment?.name ?? 'Base Environment'}</h1>
{items != null && (
<Dropdown items={items}>
<IconButton icon="gear" title="Environment Actions" size="sm" className="!h-auto w-8" />
</Dropdown>
)}
</HStack>
<PairEditor
nameAutocomplete={nameAutocomplete}
nameAutocompleteVariables={false}
namePlaceholder="VAR_NAME"
valuePlaceholder="variable value"
nameValidate={validateName}
valueAutocompleteVariables={false}
forceUpdateKey={environment?.id ?? workspace?.id ?? 'n/a'}
pairs={variables}
onChange={handleChange}
/>
</VStack>
);
};
function SidebarButton({
children,
className,
active,
onClick,
}: {
className?: string;
children: string;
active: boolean;
onClick: () => void;
}) {
return (
<button
tabIndex={active ? 0 : -1}
onClick={onClick}
className={classNames(
className,
'flex items-center text-sm text-left w-full mb-1 h-xs rounded px-2',
'text-gray-600 hocus:text-gray-800 focus:bg-highlightSecondary outline-none',
active && '!text-gray-900',
)}
>
{children}
</button>
);
}

View File

@@ -1,21 +1,45 @@
import { useQueryClient } from '@tanstack/react-query';
import { appWindow } from '@tauri-apps/api/window';
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { useEffectOnce } from 'react-use';
import { keyValueQueryKey } from '../hooks/useKeyValue';
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
import { useRecentEnvironments } from '../hooks/useRecentEnvironments';
import { useRecentRequests } from '../hooks/useRecentRequests';
import { useRecentWorkspaces } from '../hooks/useRecentWorkspaces';
import { requestsQueryKey } from '../hooks/useRequests';
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
import { responsesQueryKey } from '../hooks/useResponses';
import { useTauriEvent } from '../hooks/useTauriEvent';
import { workspacesQueryKey } from '../hooks/useWorkspaces';
import { trackPage } from '../lib/analytics';
import { DEFAULT_FONT_SIZE } from '../lib/constants';
import { NAMESPACE_NO_SYNC } from '../lib/keyValueStore';
import type { HttpRequest, HttpResponse, Model, Workspace } from '../lib/models';
import { modelsEq } from '../lib/models';
import { setPathname } from '../lib/persistPathname';
export function GlobalHooks() {
// Include here so they always update, even
// if no component references them
useRecentWorkspaces();
useRecentEnvironments();
useRecentRequests();
const queryClient = useQueryClient();
const { wasUpdatedExternally } = useRequestUpdateKey(null);
useTauriEvent<Model>('created_model', ({ payload, windowLabel }) => {
// Listen for location changes and update the pathname
const location = useLocation();
useEffect(() => {
setPathname(location.pathname).catch(console.error);
}, [location.pathname]);
useEffectOnce(() => {
trackPage('/');
});
useListenToTauriEvent<Model>('created_model', ({ payload, windowLabel }) => {
if (shouldIgnoreEvent(payload, windowLabel)) return;
const queryKey =
@@ -40,7 +64,7 @@ export function GlobalHooks() {
}
});
useTauriEvent<Model>('updated_model', ({ payload, windowLabel }) => {
useListenToTauriEvent<Model>('updated_model', ({ payload, windowLabel }) => {
if (shouldIgnoreEvent(payload, windowLabel)) return;
const queryKey =
@@ -70,7 +94,7 @@ export function GlobalHooks() {
}
});
useTauriEvent<Model>('deleted_model', ({ payload, windowLabel }) => {
useListenToTauriEvent<Model>('deleted_model', ({ payload, windowLabel }) => {
if (shouldIgnoreEvent(payload, windowLabel)) return;
if (shouldIgnoreModel(payload)) return;
@@ -85,7 +109,7 @@ export function GlobalHooks() {
queryClient.setQueryData(keyValueQueryKey(payload), undefined);
}
});
useTauriEvent<number>('zoom', ({ payload: zoomDelta, windowLabel }) => {
useListenToTauriEvent<number>('zoom', ({ payload: zoomDelta, windowLabel }) => {
if (windowLabel !== appWindow.label) return;
const fontSize = parseFloat(window.getComputedStyle(document.documentElement).fontSize);

View File

@@ -127,6 +127,7 @@ export function GraphQLEditor({ defaultValue, onChange, baseRequest, ...extraEdi
onChange={handleChangeVariables}
placeholder="{}"
useTemplating
autocompleteVariables
{...extraEditorProps}
/>
</div>

View File

@@ -17,6 +17,8 @@ type Props = {
export function HeaderEditor({ headers, onChange, forceUpdateKey }: Props) {
return (
<PairEditor
valueAutocompleteVariables
nameAutocompleteVariables
pairs={headers}
onChange={onChange}
forceUpdateKey={forceUpdateKey}
@@ -63,5 +65,7 @@ const validateHttpHeader = (v: string) => {
return true;
}
return v.match(/^[a-zA-Z0-9-_]+$/) !== null;
// Template strings are not allowed so we replace them with a valid example string
const withoutTemplateStrings = v.replace(/\$\{\[\s*[^\]\s]+\s*]}/gi, '123');
return withoutTemplateStrings.match(/^[a-zA-Z0-9-_]+$/) !== null;
};

View File

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

View File

@@ -1,25 +1,37 @@
import classNames from 'classnames';
import { useMemo, useRef } from 'react';
import { useKey, useKeyPressEvent } from 'react-use';
import { useActiveEnvironmentId } from '../hooks/useActiveEnvironmentId';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useActiveWorkspaceId } from '../hooks/useActiveWorkspaceId';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { useRecentRequests } from '../hooks/useRecentRequests';
import { useRequests } from '../hooks/useRequests';
import type { ButtonProps } from './core/Button';
import { Button } from './core/Button';
import { CountBadge } from './core/CountBadge';
import type { DropdownItem, DropdownRef } from './core/Dropdown';
import { Dropdown } from './core/Dropdown';
export function RecentRequestsDropdown() {
export function RecentRequestsDropdown({ className }: Pick<ButtonProps, 'className'>) {
const dropdownRef = useRef<DropdownRef>(null);
const activeRequest = useActiveRequest();
const activeWorkspaceId = useActiveWorkspaceId();
const recentRequestIds = useRecentRequests();
const activeEnvironmentId = useActiveEnvironmentId();
const requests = useRequests();
const routes = useAppRoutes();
const allRecentRequestIds = useRecentRequests();
const recentRequestIds = useMemo(() => allRecentRequestIds.slice(1), [allRecentRequestIds]);
// Toggle the menu on Cmd+k
useKey('k', (e) => {
if (e.metaKey) {
e.preventDefault();
dropdownRef.current?.toggle(0);
}
});
// Handle key-up
useKeyPressEvent('Control', undefined, () => {
// Key up
dropdownRef.current?.select?.();
});
@@ -29,8 +41,8 @@ export function RecentRequestsDropdown() {
if (!e.ctrlKey || recentRequestIds.length === 0) return;
if (!dropdownRef.current?.isOpen) {
// Set to 1 because the first item is the active request
dropdownRef.current?.open(e.shiftKey ? -1 : 0);
return;
}
if (e.shiftKey) dropdownRef.current?.prev?.();
@@ -51,10 +63,11 @@ export function RecentRequestsDropdown() {
recentRequestItems.push({
key: request.id,
label: request.name,
leftSlot: <CountBadge className="!ml-0 px-0 w-5" count={recentRequestItems.length + 1} />,
// leftSlot: <CountBadge className="!ml-0 px-0 w-5" count={recentRequestItems.length + 1} />,
onSelect: () => {
routes.navigate('request', {
requestId: request.id,
environmentId: activeEnvironmentId ?? undefined,
workspaceId: activeWorkspaceId,
});
},
@@ -63,18 +76,27 @@ export function RecentRequestsDropdown() {
// No recent requests to show
if (recentRequestItems.length === 0) {
return [];
return [
{
label: 'No recent requests',
disabled: true,
},
] as DropdownItem[];
}
return recentRequestItems.slice(0, 20);
}, [activeWorkspaceId, recentRequestIds, requests, routes]);
}, [activeWorkspaceId, activeEnvironmentId, recentRequestIds, requests, routes]);
return (
<Dropdown ref={dropdownRef} items={items}>
<Button
disabled={activeRequest === null}
data-tauri-drag-region
size="sm"
className="flex-[2] text-center text-gray-800 text-sm truncate pointer-events-none"
className={classNames(
className,
'text-gray-800 text-sm truncate pointer-events-auto',
activeRequest === null && 'text-opacity-disabled italic',
)}
>
{activeRequest?.name ?? 'No Request'}
</Button>

View File

@@ -0,0 +1,64 @@
import { useDeleteResponse } from '../hooks/useDeleteResponse';
import { useDeleteResponses } from '../hooks/useDeleteResponses';
import type { HttpResponse } from '../lib/models';
import { Dropdown } from './core/Dropdown';
import { pluralize } from '../lib/pluralize';
import { HStack } from './core/Stacks';
import { StatusTag } from './core/StatusTag';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
interface Props {
responses: HttpResponse[];
activeResponse: HttpResponse;
onPinnedResponse: (r: HttpResponse) => void;
}
export const RecentResponsesDropdown = function ResponsePane({
activeResponse,
responses,
onPinnedResponse,
}: Props) {
const deleteResponse = useDeleteResponse(activeResponse?.id ?? null);
const deleteAllResponses = useDeleteResponses(activeResponse?.requestId);
return (
<Dropdown
items={[
{
key: 'clear-single',
label: 'Clear Response',
onSelect: deleteResponse.mutate,
disabled: responses.length === 0,
},
{
key: 'clear-all',
label: `Clear ${responses.length} ${pluralize('Response', responses.length)}`,
onSelect: deleteAllResponses.mutate,
hidden: responses.length <= 1,
disabled: responses.length === 0,
},
{ type: 'separator', label: 'History' },
...responses.slice(0, 20).map((r) => ({
key: r.id,
label: (
<HStack space={2} alignItems="center">
<StatusTag className="text-xs" response={r} />
<span>&bull;</span> <span className="font-mono text-xs">{r.elapsed}ms</span>
</HStack>
),
leftSlot: activeResponse?.id === r.id ? <Icon icon="check" /> : <Icon icon="empty" />,
onSelect: () => onPinnedResponse(r),
})),
]}
>
<IconButton
title="Show response history"
icon="triangleDown"
className="ml-auto"
size="sm"
iconSize="md"
/>
</Dropdown>
);
};

View File

@@ -1,34 +1,35 @@
import type { HTMLAttributes, ReactElement } from 'react';
import React, { useRef } from 'react';
import { useRef } from 'react';
import { useDeleteRequest } from '../hooks/useDeleteRequest';
import { useDuplicateRequest } from '../hooks/useDuplicateRequest';
import { useTauriEvent } from '../hooks/useTauriEvent';
import { useTheme } from '../hooks/useTheme';
import type { DropdownRef } from './core/Dropdown';
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
import type { DropdownProps, DropdownRef } from './core/Dropdown';
import { Dropdown } from './core/Dropdown';
import { HotKey } from './core/HotKey';
import { Icon } from './core/Icon';
interface Props {
requestId: string;
children: ReactElement<HTMLAttributes<HTMLButtonElement>>;
requestId: string | null;
children: DropdownProps['children'];
}
export function RequestActionsDropdown({ requestId, children }: Props) {
const deleteRequest = useDeleteRequest(requestId);
const duplicateRequest = useDuplicateRequest({ id: requestId, navigateAfter: true });
const dropdownRef = useRef<DropdownRef>(null);
const { appearance, toggleAppearance } = useTheme();
useTauriEvent('toggle_settings', () => {
useListenToTauriEvent('toggle_settings', () => {
dropdownRef.current?.toggle();
});
// TODO: Put this somewhere better
useTauriEvent('duplicate_request', () => {
useListenToTauriEvent('duplicate_request', () => {
duplicateRequest.mutate();
});
if (requestId == null) {
return null;
}
return (
<Dropdown
ref={dropdownRef}
@@ -47,13 +48,6 @@ export function RequestActionsDropdown({ requestId, children }: Props) {
variant: 'danger',
leftSlot: <Icon icon="trash" />,
},
{ type: 'separator', label: 'Yaak Settings' },
{
key: 'appearance',
label: appearance === 'dark' ? 'Light Theme' : 'Dark Theme',
onSelect: toggleAppearance,
leftSlot: <Icon icon={appearance === 'dark' ? 'sun' : 'moon'} />,
},
]}
>
{children}

View File

@@ -1,12 +1,13 @@
import { invoke } from '@tauri-apps/api';
import { appWindow } from '@tauri-apps/api/window';
import classnames from 'classnames';
import classNames from 'classnames';
import type { CSSProperties } from 'react';
import { memo, useCallback, useMemo, useState } from 'react';
import { createGlobalState } from 'react-use';
import { useActiveEnvironmentId } from '../hooks/useActiveEnvironmentId';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
import { useTauriEvent } from '../hooks/useTauriEvent';
import { useUpdateRequest } from '../hooks/useUpdateRequest';
import { tryFormatJson } from '../lib/formatters';
import type { HttpHeader, HttpRequest } from '../lib/models';
@@ -42,6 +43,7 @@ const useActiveTab = createGlobalState<string>('body');
export const RequestPane = memo(function RequestPane({ style, fullHeight, className }: Props) {
const activeRequest = useActiveRequest();
const activeRequestId = activeRequest?.id ?? null;
const activeEnvironmentId = useActiveEnvironmentId();
const updateRequest = useUpdateRequest(activeRequestId);
const [activeTab, setActiveTab] = useActiveTab();
const [forceUpdateHeaderEditorKey, setForceUpdateHeaderEditorKey] = useState<number>(0);
@@ -86,7 +88,7 @@ export const RequestPane = memo(function RequestPane({ style, fullHeight, classN
// Force update header editor so any changed headers are reflected
setTimeout(() => setForceUpdateHeaderEditorKey((u) => u + 1), 100);
await updateRequest.mutate(patch);
updateRequest.mutate(patch);
},
},
},
@@ -123,7 +125,7 @@ export const RequestPane = memo(function RequestPane({ style, fullHeight, classN
token: authentication.token ?? '',
};
}
await updateRequest.mutate({ authenticationType, authentication });
updateRequest.mutate({ authenticationType, authentication });
},
},
},
@@ -140,19 +142,22 @@ export const RequestPane = memo(function RequestPane({ style, fullHeight, classN
[updateRequest],
);
useTauriEvent(
useListenToTauriEvent(
'send_request',
async ({ windowLabel }) => {
if (windowLabel !== appWindow.label) return;
await invoke('send_request', { requestId: activeRequestId });
await invoke('send_request', {
requestId: activeRequestId,
environmentId: activeEnvironmentId,
});
},
[activeRequestId],
[activeRequestId, activeEnvironmentId],
);
return (
<div
style={style}
className={classnames(className, 'h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1')}
className={classNames(className, 'h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1')}
>
{activeRequest && (
<>
@@ -202,6 +207,7 @@ export const RequestPane = memo(function RequestPane({ style, fullHeight, classN
<Editor
forceUpdateKey={forceUpdateKey}
useTemplating
autocompleteVariables
placeholder="..."
className="!bg-gray-50"
heightMode={fullHeight ? 'full' : 'auto'}
@@ -214,6 +220,7 @@ export const RequestPane = memo(function RequestPane({ style, fullHeight, classN
<Editor
forceUpdateKey={forceUpdateKey}
useTemplating
autocompleteVariables
placeholder="..."
className="!bg-gray-50"
heightMode={fullHeight ? 'full' : 'auto'}

View File

@@ -1,5 +1,5 @@
import useResizeObserver from '@react-hook/resize-observer';
import classnames from 'classnames';
import classNames from 'classnames';
import type { CSSProperties, MouseEvent as ReactMouseEvent } from 'react';
import React, { memo, useCallback, useMemo, useRef, useState } from 'react';
import { useLocalStorage } from 'react-use';
@@ -120,7 +120,7 @@ export const RequestResponse = memo(function RequestResponse({ style }: Props) {
<ResizeHandle
style={drag}
isResizing={isResizing}
className={classnames(vertical ? 'translate-y-0.5' : 'translate-x-0.5')}
className={classNames(vertical ? 'translate-y-0.5' : 'translate-x-0.5')}
onResizeStart={handleResizeStart}
onReset={handleReset}
side={vertical ? 'top' : 'left'}

View File

@@ -1,4 +1,4 @@
import classnames from 'classnames';
import classNames from 'classnames';
import type { CSSProperties, MouseEvent as ReactMouseEvent } from 'react';
import React from 'react';
@@ -28,7 +28,7 @@ export function ResizeHandle({
aria-hidden
draggable
style={style}
className={classnames(
className={classNames(
className,
'group z-10 flex',
vertical ? 'w-full h-3 cursor-row-resize' : 'h-full w-3 cursor-col-resize',
@@ -45,7 +45,7 @@ export function ResizeHandle({
{/* Show global overlay with cursor style to ensure cursor remains the same when moving quickly */}
{isResizing && (
<div
className={classnames(
className={classNames(
'fixed -left-20 -right-20 -top-20 -bottom-20',
vertical && 'cursor-row-resize',
!vertical && 'cursor-col-resize',

View File

@@ -1,4 +1,4 @@
import classnames from 'classnames';
import classNames from 'classnames';
import type { HttpResponse } from '../lib/models';
import { HStack } from './core/Stacks';
@@ -14,7 +14,7 @@ export function ResponseHeaders({ headers }: Props) {
<HStack
space={3}
key={i}
className={classnames(i > 0 ? 'border-t border-highlightSecondary py-1' : 'pb-1')}
className={classNames(i > 0 ? 'border-t border-highlightSecondary py-1' : 'pb-1')}
>
<dd className="w-1/3 text-violet-600 select-text cursor-text">{h.name}</dd>
<dt className="w-2/3 select-text cursor-text break-all">{h.value}</dt>

View File

@@ -1,23 +1,17 @@
import classnames from 'classnames';
import classNames from 'classnames';
import type { CSSProperties } from 'react';
import { memo, useEffect, useMemo, useState } from 'react';
import { useCallback, memo, useEffect, useMemo, useState } from 'react';
import { createGlobalState } from 'react-use';
import { useActiveRequestId } from '../hooks/useActiveRequestId';
import { useDeleteResponse } from '../hooks/useDeleteResponse';
import { useDeleteResponses } from '../hooks/useDeleteResponses';
import { useLatestResponse } from '../hooks/useLatestResponse';
import { useResponseContentType } from '../hooks/useResponseContentType';
import { useResponses } from '../hooks/useResponses';
import { useResponseViewMode } from '../hooks/useResponseViewMode';
import type { HttpResponse } from '../lib/models';
import { isResponseLoading } from '../lib/models';
import { pluralize } from '../lib/pluralize';
import { Banner } from './core/Banner';
import { CountBadge } from './core/CountBadge';
import { Dropdown } from './core/Dropdown';
import { DurationTag } from './core/DurationTag';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { SizeTag } from './core/SizeTag';
import { HStack } from './core/Stacks';
import { StatusTag } from './core/StatusTag';
@@ -29,6 +23,7 @@ import { CsvViewer } from './responseViewers/CsvViewer';
import { ImageViewer } from './responseViewers/ImageViewer';
import { TextViewer } from './responseViewers/TextViewer';
import { WebPageViewer } from './responseViewers/WebPageViewer';
import { RecentResponsesDropdown } from './RecentResponsesDropdown';
interface Props {
style?: CSSProperties;
@@ -46,8 +41,6 @@ export const ResponsePane = memo(function ResponsePane({ style, className }: Pro
? responses.find((r) => r.id === pinnedResponseId) ?? null
: latestResponse ?? null;
const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId);
const deleteResponse = useDeleteResponse(activeResponse?.id ?? null);
const deleteAllResponses = useDeleteResponses(activeResponse?.requestId);
const [activeTab, setActiveTab] = useActiveTab();
// Unset pinned response when a new one comes in
@@ -55,6 +48,10 @@ export const ResponsePane = memo(function ResponsePane({ style, className }: Pro
const contentType = useResponseContentType(activeResponse);
const handlePinnedResponse = useCallback((r: HttpResponse) => {
setPinnedResponseId(r.id);
}, [setPinnedResponseId])
const tabs: TabItem[] = useMemo(
() => [
{
@@ -87,7 +84,7 @@ export const ResponsePane = memo(function ResponsePane({ style, className }: Pro
return (
<div
style={style}
className={classnames(
className={classNames(
className,
'bg-gray-50 max-h-full h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1',
'dark:bg-gray-100 rounded-md border border-highlight',
@@ -99,7 +96,7 @@ export const ResponsePane = memo(function ResponsePane({ style, className }: Pro
<>
<HStack
alignItems="center"
className={classnames(
className={classNames(
'text-gray-700 text-sm w-full flex-shrink-0',
// Remove a bit of space because the tabs have lots too
'-mb-1.5',
@@ -125,44 +122,11 @@ export const ResponsePane = memo(function ResponsePane({ style, className }: Pro
</HStack>
</div>
<Dropdown
items={[
{
key: 'clear-single',
label: 'Clear Response',
onSelect: deleteResponse.mutate,
disabled: responses.length === 0,
},
{
key: 'clear-all',
label: `Clear ${responses.length} ${pluralize('Response', responses.length)}`,
onSelect: deleteAllResponses.mutate,
hidden: responses.length <= 1,
disabled: responses.length === 0,
},
{ type: 'separator', label: 'History' },
...responses.slice(0, 20).map((r) => ({
key: r.id,
label: (
<HStack space={2}>
<StatusTag className="text-xs" response={r} />
<span>&bull;</span> <span>{r.elapsed}ms</span>
</HStack>
),
leftSlot:
activeResponse?.id === r.id ? <Icon icon="check" /> : <Icon icon="empty" />,
onSelect: () => setPinnedResponseId(r.id),
})),
]}
>
<IconButton
title="Show response history"
icon="triangleDown"
className="ml-auto"
size="sm"
iconSize="md"
/>
</Dropdown>
<RecentResponsesDropdown
responses={responses}
activeResponse={activeResponse}
onPinnedResponse={handlePinnedResponse}
/>
</HStack>
)}
</HStack>

View File

@@ -7,13 +7,14 @@ import { VStack } from './core/Stacks';
export default function RouteError() {
const error = useRouteError();
console.log("Error", error);
const stringified = JSON.stringify(error);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const message = (error as any).message ?? stringified;
const routes = useAppRoutes();
return (
<div className="flex items-center justify-center h-full">
<VStack space={5} className="max-w-[30rem] !h-auto">
<VStack space={5} className="max-w-[50rem] !h-auto">
<Heading>Route Error 🔥</Heading>
<FormattedError>{message}</FormattedError>
<VStack space={2}>

View File

@@ -1,21 +1,35 @@
import classnames from 'classnames';
import type { ForwardedRef } from 'react';
import React, { forwardRef, Fragment, memo, useCallback, useMemo, useRef, useState } from 'react';
import classNames from 'classnames';
import type { ForwardedRef, ReactNode } from 'react';
import React, { forwardRef, Fragment, useCallback, useMemo, useRef, useState } from 'react';
import type { XYCoord } from 'react-dnd';
import { useDrag, useDrop } from 'react-dnd';
import { useKey, useKeyPressEvent } from 'react-use';
import { useActiveEnvironmentId } from '../hooks/useActiveEnvironmentId';
import { useActiveRequestId } from '../hooks/useActiveRequestId';
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { useCreateFolder } from '../hooks/useCreateFolder';
import { useCreateRequest } from '../hooks/useCreateRequest';
import { useDeleteAnyRequest } from '../hooks/useDeleteAnyRequest';
import { useDeleteFolder } from '../hooks/useDeleteFolder';
import { useFolders } from '../hooks/useFolders';
import { useKeyValue } from '../hooks/useKeyValue';
import { useLatestResponse } from '../hooks/useLatestResponse';
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
import { usePrompt } from '../hooks/usePrompt';
import { useRequests } from '../hooks/useRequests';
import { useSendAnyRequest } from '../hooks/useSendAnyRequest';
import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { useTauriEvent } from '../hooks/useTauriEvent';
import { useUpdateAnyFolder } from '../hooks/useUpdateAnyFolder';
import { useUpdateAnyRequest } from '../hooks/useUpdateAnyRequest';
import { useUpdateRequest } from '../hooks/useUpdateRequest';
import type { HttpRequest } from '../lib/models';
import { NAMESPACE_NO_SYNC } from '../lib/keyValueStore';
import type { Folder, HttpRequest, Workspace } from '../lib/models';
import { isResponseLoading } from '../lib/models';
import { Dropdown } from './core/Dropdown';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { InlineCode } from './core/InlineCode';
import { VStack } from './core/Stacks';
import { StatusTag } from './core/StatusTag';
import { DropMarker } from './DropMarker';
@@ -28,46 +42,131 @@ enum ItemTypes {
REQUEST = 'request',
}
export const Sidebar = memo(function Sidebar({ className }: Props) {
interface TreeNode {
item: Workspace | Folder | HttpRequest;
children: TreeNode[];
depth: number;
}
export function Sidebar({ className }: Props) {
const { hidden } = useSidebarHidden();
const sidebarRef = useRef<HTMLDivElement>(null);
const createRequest = useCreateRequest();
const sidebarRef = useRef<HTMLLIElement>(null);
const activeRequestId = useActiveRequestId();
const unorderedRequests = useRequests();
const activeEnvironmentId = useActiveEnvironmentId();
const requests = useRequests();
const folders = useFolders();
const deleteAnyRequest = useDeleteAnyRequest();
const activeWorkspace = useActiveWorkspace();
const routes = useAppRoutes();
const requests = useMemo(
() => [...unorderedRequests].sort((a, b) => a.sortPriority - b.sortPriority),
[unorderedRequests],
);
const [hasFocus, setHasFocus] = useState<boolean>(false);
const [selectedIndex, setSelectedIndex] = useState<number>();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [selectedTree, setSelectedTree] = useState<TreeNode | null>(null);
const updateAnyRequest = useUpdateAnyRequest();
const updateAnyFolder = useUpdateAnyFolder();
const [draggingId, setDraggingId] = useState<string | null>(null);
const [hoveredTree, setHoveredTree] = useState<TreeNode | null>(null);
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const collapsed = useKeyValue<Record<string, boolean>>({
key: ['sidebar_collapsed', activeWorkspace?.id ?? 'n/a'],
defaultValue: {},
namespace: NAMESPACE_NO_SYNC,
});
const { tree, treeParentMap, selectableRequests } = useMemo<{
tree: TreeNode | null;
treeParentMap: Record<string, TreeNode>;
selectableRequests: { id: string; index: number; tree: TreeNode }[];
}>(() => {
const treeParentMap: Record<string, TreeNode> = {};
const selectableRequests: { id: string; index: number; tree: TreeNode }[] = [];
if (activeWorkspace == null) {
return { tree: null, treeParentMap, selectableRequests };
}
let selectableRequestIndex = 0;
// Put requests and folders into a tree structure
const next = (node: TreeNode): TreeNode => {
const childItems = [...requests, ...folders].filter((f) =>
node.item.model === 'workspace' ? f.folderId == null : f.folderId === node.item.id,
);
childItems.sort((a, b) => a.sortPriority - b.sortPriority);
const depth = node.depth + 1;
for (const item of childItems) {
treeParentMap[item.id] = node;
node.children.push(next({ item, children: [], depth }));
if (item.model === 'http_request') {
selectableRequests.push({ id: item.id, index: selectableRequestIndex++, tree: node });
}
}
return node;
};
const tree = next({ item: activeWorkspace, children: [], depth: 0 });
return { tree, treeParentMap, selectableRequests };
}, [activeWorkspace, requests, folders]);
// TODO: Move these listeners to a central place
useListenToTauriEvent('new_request', async () => createRequest.mutate({}));
const focusActiveRequest = useCallback(
(forcedIndex?: number) => {
const index = forcedIndex ?? requests.findIndex((r) => r.id === activeRequestId);
if (index < 0) return;
setSelectedIndex(index >= 0 ? index : undefined);
(args: { forced?: { id: string; tree: TreeNode }; noFocusSidebar?: boolean } = {}) => {
const { forced, noFocusSidebar } = args;
const tree = forced?.tree ?? treeParentMap[activeRequestId ?? 'n/a'] ?? null;
const children = tree?.children ?? [];
const id = forced?.id ?? children.find((m) => m.item.id === activeRequestId)?.item.id ?? null;
if (id == null) {
return;
}
setSelectedId(id);
setSelectedTree(tree);
setHasFocus(true);
sidebarRef.current?.focus();
if (!noFocusSidebar) {
sidebarRef.current?.focus();
}
},
[activeRequestId, requests],
[activeRequestId, treeParentMap],
);
const handleSelect = useCallback(
(requestId: string) => {
const index = requests.findIndex((r) => r.id === requestId);
const request = requests[index];
if (!request) return;
routes.navigate('request', { requestId, workspaceId: request.workspaceId });
setSelectedIndex(index);
focusActiveRequest(index);
(id: string) => {
const tree = treeParentMap[id ?? 'n/a'] ?? null;
const children = tree?.children ?? [];
const node = children.find((m) => m.item.id === id) ?? null;
if (node == null || tree == null || node.item.model === 'workspace') {
return;
}
const { item } = node;
if (item.model === 'folder') {
collapsed.set((c) => ({ ...c, [item.id]: !c[item.id] }));
} else {
routes.navigate('request', {
requestId: id,
workspaceId: item.workspaceId,
environmentId: activeEnvironmentId ?? undefined,
});
setSelectedId(id);
setSelectedTree(tree);
focusActiveRequest({ forced: { id, tree } });
}
},
[focusActiveRequest, requests, routes],
[treeParentMap, collapsed, routes, activeEnvironmentId, focusActiveRequest],
);
const handleClearSelected = useCallback(() => {
setSelectedId(null);
setSelectedTree(null);
}, []);
const handleFocus = useCallback(() => {
if (hasFocus) return;
focusActiveRequest();
focusActiveRequest({ noFocusSidebar: true });
}, [focusActiveRequest, hasFocus]);
const handleBlur = useCallback(() => setHasFocus(false), []);
@@ -77,183 +176,330 @@ export const Sidebar = memo(function Sidebar({ className }: Props) {
if (!hasFocus) return;
e.preventDefault();
const selectedRequest = requests[selectedIndex ?? -1];
if (selectedRequest === undefined) return;
deleteAnyRequest.mutate(selectedRequest.id);
const selected = selectableRequests.find((r) => r.id === selectedId);
if (selected == null) return;
deleteAnyRequest.mutate(selected.id);
},
[deleteAnyRequest, hasFocus, requests, selectedIndex],
[deleteAnyRequest, hasFocus, selectableRequests, selectedId],
);
useKeyPressEvent('Backspace', handleDeleteKey);
useKeyPressEvent('Delete', handleDeleteKey);
useTauriEvent(
useListenToTauriEvent(
'focus_sidebar',
() => {
if (hidden || hasFocus) return;
// Select 0 index on focus if none selected
focusActiveRequest(selectedIndex ?? 0);
focusActiveRequest(
selectedTree != null && selectedId != null
? { forced: { id: selectedId, tree: selectedTree } }
: undefined,
);
},
[focusActiveRequest, hidden, activeRequestId],
);
useKeyPressEvent('Enter', (e) => {
if (!hasFocus) return;
const request = requests[selectedIndex ?? -1];
if (!request || request.id === activeRequestId) return;
const selected = selectableRequests.find((r) => r.id === selectedId);
if (!selected || selected.id === activeRequestId || activeWorkspace == null) {
return;
}
e.preventDefault();
routes.navigate('request', { requestId: request.id, workspaceId: request.workspaceId });
routes.navigate('request', {
requestId: selected.id,
workspaceId: activeWorkspace?.id,
environmentId: activeEnvironmentId ?? undefined,
});
});
useKey(
'ArrowUp',
() => {
if (!hasFocus) return;
let newIndex = (selectedIndex ?? requests.length) - 1;
if (newIndex < 0) {
newIndex = requests.length - 1;
const i = selectableRequests.findIndex((r) => r.id === selectedId);
const newSelectable = selectableRequests[i - 1];
if (newSelectable == null) {
return;
}
setSelectedIndex(newIndex);
setSelectedId(newSelectable.id);
setSelectedTree(newSelectable.tree);
},
undefined,
[hasFocus, requests, selectedIndex],
[hasFocus, selectableRequests, selectedId, setSelectedId, setSelectedTree],
);
useKey(
'ArrowDown',
() => {
if (!hasFocus) return;
let newIndex = (selectedIndex ?? -1) + 1;
if (newIndex > requests.length - 1) {
newIndex = 0;
const i = selectableRequests.findIndex((r) => r.id === selectedId);
const newSelectable = selectableRequests[i + 1];
if (newSelectable == null) {
return;
}
setSelectedIndex(newIndex);
setSelectedId(newSelectable.id);
setSelectedTree(newSelectable.tree);
},
undefined,
[hasFocus, requests, selectedIndex],
[hasFocus, selectableRequests, selectedId, setSelectedId, setSelectedTree],
);
return (
<div aria-hidden={hidden} className="relative h-full">
<div
role="menu"
aria-orientation="vertical"
dir="ltr"
ref={sidebarRef}
onFocus={handleFocus}
onBlur={handleBlur}
tabIndex={hidden ? -1 : 0}
className={classnames(className, 'h-full relative grid grid-rows-[minmax(0,1fr)_auto]')}
>
<VStack
as="ul"
className="relative py-3 overflow-y-auto overflow-x-visible"
draggable={false}
>
<SidebarItems
selectedIndex={selectedIndex}
requests={requests}
focused={hasFocus}
onSelect={handleSelect}
/>
</VStack>
</div>
</div>
);
});
interface SidebarItemsProps {
requests: HttpRequest[];
focused: boolean;
selectedIndex?: number;
onSelect: (requestId: string) => void;
}
function SidebarItems({ requests, focused, selectedIndex, onSelect }: SidebarItemsProps) {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const updateRequest = useUpdateAnyRequest();
const handleMove = useCallback<DraggableSidebarItemProps['onMove']>(
(id, side) => {
const dragIndex = requests.findIndex((r) => r.id === id);
setHoveredIndex(side === 'above' ? dragIndex : dragIndex + 1);
const hoveredTree = treeParentMap[id] ?? null;
const dragIndex = hoveredTree?.children.findIndex((n) => n.item.id === id) ?? -99;
const hoveredIndex = dragIndex + (side === 'above' ? 0 : 1);
setHoveredTree(hoveredTree);
setHoveredIndex(hoveredIndex);
},
[requests],
[treeParentMap],
);
const handleDragStart = useCallback<DraggableSidebarItemProps['onDragStart']>((id: string) => {
setDraggingId(id);
}, []);
const handleEnd = useCallback<DraggableSidebarItemProps['onEnd']>(
(requestId) => {
if (hoveredIndex === null) return;
setHoveredIndex(null);
async (itemId) => {
setHoveredTree(null);
handleClearSelected();
const index = requests.findIndex((r) => r.id === requestId);
const request = requests[index];
if (request === undefined) return;
if (hoveredTree == null || hoveredIndex == null) {
return;
}
const newRequests = requests.filter((r) => r.id !== requestId);
if (hoveredIndex > index) newRequests.splice(hoveredIndex - 1, 0, request);
else newRequests.splice(hoveredIndex, 0, request);
const parentTree = treeParentMap[itemId] ?? null;
const index = parentTree?.children.findIndex((n) => n.item.id === itemId) ?? -1;
const child = parentTree?.children[index ?? -1];
if (child == null || parentTree == null) return;
const beforePriority = newRequests[hoveredIndex - 1]?.sortPriority ?? 0;
const afterPriority = newRequests[hoveredIndex + 1]?.sortPriority ?? 0;
const movedToDifferentTree = hoveredTree.item.id !== parentTree.item.id;
const movedUpInSameTree = !movedToDifferentTree && hoveredIndex < index;
const newChildren = hoveredTree.children.filter((c) => c.item.id !== itemId);
if (movedToDifferentTree || movedUpInSameTree) {
// Moving up or into a new tree is simply inserting before the hovered item
newChildren.splice(hoveredIndex, 0, child);
} else {
// Moving down has to account for the fact that the original item will be removed
newChildren.splice(hoveredIndex - 1, 0, child);
}
const prev = newChildren[hoveredIndex - 1]?.item;
const next = newChildren[hoveredIndex + 1]?.item;
const beforePriority = prev == null || prev.model === 'workspace' ? 0 : prev.sortPriority;
const afterPriority = next == null || next.model === 'workspace' ? 0 : next.sortPriority;
const folderId = hoveredTree.item.model === 'folder' ? hoveredTree.item.id : null;
const shouldUpdateAll = afterPriority - beforePriority < 1;
if (shouldUpdateAll) {
newRequests.forEach(({ id }, i) => {
const sortPriority = i * 1000;
const update = (r: HttpRequest) => ({ ...r, sortPriority });
updateRequest.mutate({ id, update });
});
await Promise.all(
newChildren.map((child, i) => {
const sortPriority = i * 1000;
if (child.item.model === 'folder') {
const updateFolder = (f: Folder) => ({ ...f, sortPriority, folderId });
return updateAnyFolder.mutateAsync({ id: child.item.id, update: updateFolder });
} else if (child.item.model === 'http_request') {
const updateRequest = (r: HttpRequest) => ({ ...r, sortPriority, folderId });
return updateAnyRequest.mutateAsync({ id: child.item.id, update: updateRequest });
}
}),
);
} else {
const sortPriority = afterPriority - (afterPriority - beforePriority) / 2;
const update = (r: HttpRequest) => ({ ...r, sortPriority });
updateRequest.mutate({ id: requestId, update });
if (child.item.model === 'folder') {
const updateFolder = (f: Folder) => ({ ...f, sortPriority, folderId });
await updateAnyFolder.mutateAsync({ id: child.item.id, update: updateFolder });
} else if (child.item.model === 'http_request') {
const updateRequest = (r: HttpRequest) => ({ ...r, sortPriority, folderId });
await updateAnyRequest.mutateAsync({ id: child.item.id, update: updateRequest });
}
}
setDraggingId(null);
},
[hoveredIndex, requests, updateRequest],
[
hoveredIndex,
hoveredTree,
handleClearSelected,
treeParentMap,
updateAnyFolder,
updateAnyRequest,
],
);
if (tree == null) {
return null;
}
if (collapsed.value == null) {
return null;
}
return (
<>
{requests.map((r, i) => (
<Fragment key={r.id}>
{hoveredIndex === i && <DropMarker />}
<aside
aria-hidden={hidden}
ref={sidebarRef}
onFocus={handleFocus}
onBlur={handleBlur}
tabIndex={hidden ? -1 : 0}
className={classNames(
className,
'h-full pb-3 overflow-y-scroll overflow-x-visible hide-scrollbars pt-2',
)}
>
<SidebarItems
treeParentMap={treeParentMap}
selectedId={selectedId}
selectedTree={selectedTree}
collapsed={collapsed.value}
tree={tree}
focused={hasFocus}
draggingId={draggingId}
onSelect={handleSelect}
hoveredIndex={hoveredIndex}
hoveredTree={hoveredTree}
handleMove={handleMove}
handleEnd={handleEnd}
handleDragStart={handleDragStart}
/>
</aside>
);
}
interface SidebarItemsProps {
tree: TreeNode;
focused: boolean;
draggingId: string | null;
selectedId: string | null;
selectedTree: TreeNode | null;
treeParentMap: Record<string, TreeNode>;
hoveredTree: TreeNode | null;
hoveredIndex: number | null;
handleMove: (id: string, side: 'above' | 'below') => void;
handleEnd: (id: string) => void;
handleDragStart: (id: string) => void;
onSelect: (requestId: string) => void;
collapsed: Record<string, boolean>;
}
function SidebarItems({
tree,
focused,
selectedId,
selectedTree,
draggingId,
onSelect,
treeParentMap,
collapsed,
hoveredTree,
hoveredIndex,
handleEnd,
handleMove,
handleDragStart,
}: SidebarItemsProps) {
return (
<VStack
as="ul"
role="menu"
aria-orientation="vertical"
dir="ltr"
className={classNames(
tree.depth > 0 && 'border-l border-highlight',
tree.depth === 0 && 'ml-0',
tree.depth >= 1 && 'ml-[1.3em]',
)}
>
{tree.children.map((child, i) => (
<Fragment key={child.item.id}>
{hoveredIndex === i && hoveredTree?.item.id === tree.item.id && <DropMarker />}
<DraggableSidebarItem
key={r.id}
selected={selectedIndex === i}
requestId={r.id}
requestName={r.name}
selected={selectedId === child.item.id}
itemId={child.item.id}
itemName={child.item.name}
itemModel={child.item.model}
onMove={handleMove}
onEnd={handleEnd}
useProminentStyles={focused}
onSelect={onSelect}
/>
onDragStart={handleDragStart}
useProminentStyles={focused}
collapsed={collapsed}
child={child}
>
{child.item.model === 'folder' &&
!collapsed[child.item.id] &&
draggingId !== child.item.id && (
<SidebarItems
treeParentMap={treeParentMap}
tree={child}
collapsed={collapsed}
draggingId={draggingId}
hoveredTree={hoveredTree}
hoveredIndex={hoveredIndex}
focused={focused}
selectedId={selectedId}
selectedTree={selectedTree}
onSelect={onSelect}
handleMove={handleMove}
handleEnd={handleEnd}
handleDragStart={handleDragStart}
/>
)}
</DraggableSidebarItem>
</Fragment>
))}
{hoveredIndex === requests.length && <DropMarker />}
</>
{hoveredIndex === tree.children.length && hoveredTree?.item.id === tree.item.id && (
<DropMarker />
)}
</VStack>
);
}
type SidebarItemProps = {
className?: string;
requestId: string;
requestName: string;
itemId: string;
itemName: string;
itemModel: string;
useProminentStyles?: boolean;
selected?: boolean;
onSelect: (requestId: string) => void;
onSelect: (id: string) => void;
draggable?: boolean;
children?: ReactNode;
collapsed: Record<string, boolean>;
child: TreeNode;
};
const _SidebarItem = forwardRef(function SidebarItem(
{ className, requestName, requestId, useProminentStyles, selected, onSelect }: SidebarItemProps,
const SidebarItem = forwardRef(function SidebarItem(
{
children,
className,
itemName,
itemId,
itemModel,
useProminentStyles,
selected,
onSelect,
collapsed,
child,
}: SidebarItemProps,
ref: ForwardedRef<HTMLLIElement>,
) {
const latestResponse = useLatestResponse(requestId);
const updateRequest = useUpdateRequest(requestId);
const sendAnyRequest = useSendAnyRequest();
const createRequest = useCreateRequest();
const createFolder = useCreateFolder();
const deleteRequest = useDeleteFolder(itemId);
const latestResponse = useLatestResponse(itemId);
const updateRequest = useUpdateRequest(itemId);
const updateAnyFolder = useUpdateAnyFolder();
const prompt = usePrompt();
const [editing, setEditing] = useState<boolean>(false);
const activeRequestId = useActiveRequestId();
const isActive = activeRequestId === requestId;
const isActive = activeRequestId === itemId;
const handleSubmitNameEdit = useCallback(
(el: HTMLInputElement) => {
@@ -285,7 +531,10 @@ const _SidebarItem = forwardRef(function SidebarItem(
[handleSubmitNameEdit],
);
const handleStartEditing = useCallback(() => setEditing(true), [setEditing]);
const handleStartEditing = useCallback(() => {
if (itemModel !== 'http_request') return;
setEditing(true);
}, [setEditing, itemModel]);
const handleBlur = useCallback(
(e: React.FocusEvent<HTMLInputElement>) => {
@@ -295,70 +544,153 @@ const _SidebarItem = forwardRef(function SidebarItem(
);
const handleSelect = useCallback(() => {
onSelect(requestId);
}, [onSelect, requestId]);
onSelect(itemId);
}, [onSelect, itemId]);
return (
<li ref={ref} className={classnames(className, 'block group/item px-2 pb-0.5')}>
<button
// tabIndex={-1} // Will prevent drag-n-drop
onClick={handleSelect}
disabled={editing}
onDoubleClick={handleStartEditing}
data-active={isActive}
data-selected={selected}
className={classnames(
'w-full flex items-center text-sm h-xs px-2 rounded-md transition-colors',
editing && 'ring-1 focus-within:ring-focus',
isActive && 'bg-highlight text-gray-800',
!isActive && 'text-gray-600 group-hover/item:text-gray-800 active:bg-highlightSecondary',
selected && useProminentStyles && '!bg-violet-500/20 text-gray-900',
<li ref={ref}>
<div className={classNames(className, 'block relative group/item px-2 pb-0.5')}>
{itemModel === 'folder' && (
<Dropdown
items={[
{
key: 'sendAll',
label: 'Send All',
leftSlot: <Icon icon="paperPlane" />,
onSelect: () => {
for (const { item } of child.children) {
if (item.model === 'http_request') {
sendAnyRequest.mutate(item.id);
}
}
},
},
{ type: 'separator', label: itemName },
{
key: 'rename',
label: 'Rename',
leftSlot: <Icon icon="pencil" />,
onSelect: async () => {
const name = await prompt({
title: 'Rename Folder',
description: (
<>
Enter a new name for <InlineCode>{itemName}</InlineCode>
</>
),
name: 'name',
label: 'Name',
defaultValue: itemName,
});
updateAnyFolder.mutate({ id: itemId, update: (f) => ({ ...f, name }) });
},
},
{
key: 'deleteFolder',
label: 'Delete',
variant: 'danger',
leftSlot: <Icon icon="trash" />,
onSelect: () => deleteRequest.mutate(),
},
{ type: 'separator' },
{
key: 'createRequest',
label: 'New Request',
leftSlot: <Icon icon="plus" />,
onSelect: () => createRequest.mutate({ folderId: itemId, sortPriority: -1 }),
},
{
key: 'createFolder',
label: 'New Folder',
leftSlot: <Icon icon="plus" />,
onSelect: () => createFolder.mutate({ folderId: itemId, sortPriority: -1 }),
},
]}
>
<IconButton
title="Folder options"
size="xs"
icon="dotsV"
className="ml-auto !bg-transparent absolute right-2 opacity-0 group-hover/item:opacity-70 transition-opacity"
/>
</Dropdown>
)}
>
{editing ? (
<input
ref={handleFocus}
defaultValue={requestName}
className="bg-transparent outline-none w-full"
onBlur={handleBlur}
onKeyDown={handleInputKeyDown}
/>
) : (
<span className={classnames('truncate', !requestName && 'text-gray-400 italic')}>
{requestName || 'New Request'}
</span>
)}
{latestResponse && (
<div className="ml-auto">
{isResponseLoading(latestResponse) ? (
<Icon spin size="sm" icon="update" />
) : (
<StatusTag className="text-2xs dark:opacity-80" response={latestResponse} />
)}
</div>
)}
</button>
<button
// tabIndex={-1} // Will prevent drag-n-drop
onClick={handleSelect}
disabled={editing}
onDoubleClick={handleStartEditing}
data-active={isActive}
data-selected={selected}
className={classNames(
'w-full flex items-center text-sm h-xs px-2 rounded-md transition-colors',
editing && 'ring-1 focus-within:ring-focus',
isActive && 'bg-highlightSecondary text-gray-800',
!isActive &&
'text-gray-600 group-hover/item:text-gray-800 active:bg-highlightSecondary',
selected && useProminentStyles && '!bg-violet-400/20 text-gray-950',
)}
>
{itemModel === 'folder' && (
<Icon
size="sm"
icon="chevronRight"
className={classNames(
'-ml-0.5 mr-2 transition-transform',
!collapsed[itemId] && 'transform rotate-90',
)}
/>
)}
{editing ? (
<input
ref={handleFocus}
defaultValue={itemName}
className="bg-transparent outline-none w-full"
onBlur={handleBlur}
onKeyDown={handleInputKeyDown}
/>
) : (
<span className={classNames('truncate', !itemName && 'text-gray-400 italic')}>
{itemName || 'New Request'}
</span>
)}
{latestResponse && (
<div className="ml-auto">
{isResponseLoading(latestResponse) ? (
<Icon spin size="sm" icon="update" />
) : (
<StatusTag className="text-2xs dark:opacity-80" response={latestResponse} />
)}
</div>
)}
</button>
</div>
{children}
</li>
);
});
const SidebarItem = memo(_SidebarItem);
type DraggableSidebarItemProps = SidebarItemProps & {
onMove: (id: string, side: 'above' | 'below') => void;
onEnd: (id: string) => void;
onDragStart: (id: string) => void;
children?: ReactNode;
child?: TreeNode;
};
type DragItem = {
id: string;
requestName: string;
itemName: string;
};
const DraggableSidebarItem = memo(function DraggableSidebarItem({
requestName,
requestId,
function DraggableSidebarItem({
itemName,
itemId,
itemModel,
child,
onMove,
onEnd,
onDragStart,
...props
}: DraggableSidebarItemProps) {
const ref = useRef<HTMLLIElement>(null);
@@ -372,7 +704,7 @@ const DraggableSidebarItem = memo(function DraggableSidebarItem({
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
const clientOffset = monitor.getClientOffset();
const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top;
onMove(requestId, hoverClientY < hoverMiddleY ? 'above' : 'below');
onMove(itemId, hoverClientY < hoverMiddleY ? 'above' : 'below');
},
},
[onMove],
@@ -381,25 +713,29 @@ const DraggableSidebarItem = memo(function DraggableSidebarItem({
const [{ isDragging }, connectDrag] = useDrag<DragItem, unknown, { isDragging: boolean }>(
() => ({
type: ItemTypes.REQUEST,
item: () => ({ id: requestId, requestName }),
item: () => {
onDragStart(itemId);
return { id: itemId, itemName };
},
collect: (m) => ({ isDragging: m.isDragging() }),
options: { dropEffect: 'move' },
end: () => onEnd(requestId),
end: () => onEnd(itemId),
}),
[onEnd],
);
connectDrag(ref);
connectDrop(ref);
connectDrag(connectDrop(ref));
return (
<SidebarItem
ref={ref}
draggable
className={classnames(isDragging && 'opacity-20')}
requestName={requestName}
requestId={requestId}
className={classNames(isDragging && 'opacity-20')}
itemName={itemName}
itemId={itemId}
itemModel={itemModel}
child={child}
{...props}
/>
);
});
}

View File

@@ -1,37 +1,43 @@
import { memo, useCallback } from 'react';
import { memo } from 'react';
import { useCreateFolder } from '../hooks/useCreateFolder';
import { useCreateRequest } from '../hooks/useCreateRequest';
import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { useTauriEvent } from '../hooks/useTauriEvent';
import { Dropdown } from './core/Dropdown';
import { IconButton } from './core/IconButton';
import { HStack } from './core/Stacks';
export const SidebarActions = memo(function SidebarActions() {
const createRequest = useCreateRequest();
const createFolder = useCreateFolder();
const { hidden, toggle } = useSidebarHidden();
const createRequest = useCreateRequest({ navigateAfter: true });
const handleCreateRequest = useCallback(() => {
createRequest.mutate({});
}, [createRequest]);
useTauriEvent('new_request', () => {
createRequest.mutate({});
});
return (
<>
<IconButton
onClick={toggle}
className="pointer-events-auto"
size="sm"
title="Show sidebar"
icon={hidden ? 'leftPanelHidden' : 'leftPanelVisible'}
/>
<IconButton
onClick={handleCreateRequest}
className="pointer-events-auto"
size="sm"
title="Show sidebar"
icon="plusCircle"
/>
</>
<HStack>
{hidden && (
<IconButton
onClick={toggle}
className="pointer-events-auto"
size="sm"
title="Show sidebar"
icon={hidden ? 'leftPanelHidden' : 'leftPanelVisible'}
/>
)}
<Dropdown
items={[
{
key: 'create-request',
label: 'Create Request',
onSelect: () => createRequest.mutate({}),
},
{
key: 'create-folder',
label: 'Create Folder',
onSelect: () => createFolder.mutate({}),
},
]}
>
<IconButton size="sm" icon="plusCircle" title="Add Resource" />
</Dropdown>
</HStack>
);
});

View File

@@ -1,11 +1,11 @@
import classnames from 'classnames';
import classNames from 'classnames';
import type { EditorView } from 'codemirror';
import type { FormEvent } from 'react';
import { memo, useCallback, useRef } from 'react';
import { memo, useCallback, useRef, useState } from 'react';
import { useIsResponseLoading } from '../hooks/useIsResponseLoading';
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
import { useSendRequest } from '../hooks/useSendRequest';
import { useTauriEvent } from '../hooks/useTauriEvent';
import { useUpdateRequest } from '../hooks/useUpdateRequest';
import type { HttpRequest } from '../lib/models';
import { IconButton } from './core/IconButton';
@@ -20,6 +20,7 @@ export const UrlBar = memo(function UrlBar({ id: requestId, url, method, classNa
const inputRef = useRef<EditorView>(null);
const sendRequest = useSendRequest(requestId);
const updateRequest = useUpdateRequest(requestId);
const [isFocused, setIsFocused] = useState<boolean>(false);
const handleMethodChange = useCallback(
(method: string) => updateRequest.mutate({ method }),
[updateRequest],
@@ -34,27 +35,30 @@ export const UrlBar = memo(function UrlBar({ id: requestId, url, method, classNa
const handleSubmit = useCallback(
async (e: FormEvent) => {
e.preventDefault();
sendRequest();
sendRequest.mutate();
},
[sendRequest],
);
useTauriEvent('focus_url', () => {
useListenToTauriEvent('focus_url', () => {
inputRef.current?.focus();
});
return (
<form onSubmit={handleSubmit} className={classnames('url-bar', className)}>
<form onSubmit={handleSubmit} className={classNames('url-bar', className)}>
<Input
autocompleteVariables
ref={inputRef}
size="sm"
size={isFocused ? 'auto' : 'sm'}
hideLabel
useTemplating
contentType="url"
className="px-0"
className="px-0 py-0.5"
name="url"
label="Enter URL"
forceUpdateKey={updateKey}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
containerClassName="shadow shadow-gray-100 dark:shadow-gray-50"
onChange={handleUrlChange}
defaultValue={url}
@@ -63,7 +67,7 @@ export const UrlBar = memo(function UrlBar({ id: requestId, url, method, classNa
<RequestMethodDropdown
method={method}
onChange={handleMethodChange}
className="mx-0.5 h-full my-1"
className="!h-auto mx-0.5 my-0.5"
/>
}
rightSlot={
@@ -72,7 +76,7 @@ export const UrlBar = memo(function UrlBar({ id: requestId, url, method, classNa
iconSize="sm"
title="Send Request"
type="submit"
className="w-8 mr-0.5"
className="!h-auto w-8 mr-0.5 my-0.5"
icon={loading ? 'update' : 'paperPlane'}
spin={loading}
/>

View File

@@ -1,4 +1,4 @@
import classnames from 'classnames';
import classNames from 'classnames';
import { motion } from 'framer-motion';
import type {
CSSProperties,
@@ -6,12 +6,12 @@ import type {
MouseEvent as ReactMouseEvent,
ReactNode,
} from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useWindowSize } from 'react-use';
import { useOsInfo } from '../hooks/useOsInfo';
import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { useSidebarWidth } from '../hooks/useSidebarWidth';
import { useTauriEvent } from '../hooks/useTauriEvent';
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
import { WINDOW_FLOATING_SIDEBAR_WIDTH } from '../lib/constants';
import { Button } from './core/Button';
import { HStack } from './core/Stacks';
@@ -29,7 +29,7 @@ const drag = { gridArea: 'drag' };
export default function Workspace() {
const { setWidth, width, resetWidth } = useSidebarWidth();
const { show, hide, hidden, toggle } = useSidebarHidden();
const { hide, show, hidden, toggle } = useSidebarHidden();
const windowSize = useWindowSize();
const [floating, setFloating] = useState<boolean>(false);
@@ -38,20 +38,13 @@ export default function Workspace() {
null,
);
useTauriEvent('toggle_sidebar', toggle);
useListenToTauriEvent('toggle_sidebar', toggle);
// float/un-float sidebar on window resize
useEffect(() => {
const shouldHide = windowSize.width <= WINDOW_FLOATING_SIDEBAR_WIDTH;
if (shouldHide && !hidden) {
setFloating(true);
hide();
} else if (!shouldHide && hidden) {
setFloating(false);
show();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
if (shouldHide) setFloating(true);
else if (!shouldHide) setFloating(false);
}, [windowSize.width]);
const unsub = () => {
@@ -71,7 +64,14 @@ export default function Workspace() {
moveState.current = {
move: async (e: MouseEvent) => {
e.preventDefault(); // Prevent text selection and things
setWidth(startWidth + (e.clientX - mouseStartX));
const newWidth = startWidth + (e.clientX - mouseStartX);
if (newWidth < 100) {
hide();
resetWidth();
} else {
show();
setWidth(newWidth);
}
},
up: (e: MouseEvent) => {
e.preventDefault();
@@ -83,7 +83,7 @@ export default function Workspace() {
document.documentElement.addEventListener('mouseup', moveState.current.up);
setIsResizing(true);
},
[setWidth, width],
[setWidth, resetWidth, width, hide, show],
);
const sideWidth = hidden ? 0 : width;
@@ -113,7 +113,7 @@ export default function Workspace() {
return (
<div
style={styles}
className={classnames(
className={classNames(
'grid w-full h-full',
// Animate sidebar width changes but only when not resizing
// because it's too slow to animate on mouse move
@@ -121,11 +121,11 @@ export default function Workspace() {
)}
>
{floating ? (
<Overlay open={!hidden} portalName="sidebar" onClose={hide}>
<Overlay open={!hidden} portalName="sidebar" onClose={hide} zIndex={10}>
<motion.div
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
className={classnames(
className={classNames(
'absolute top-0 left-0 bottom-0 bg-gray-100 border-r border-highlight w-[14rem]',
'grid grid-rows-[auto_1fr]',
)}
@@ -140,7 +140,7 @@ export default function Workspace() {
</Overlay>
) : (
<>
<div style={side} className={classnames('overflow-hidden bg-gray-100')}>
<div style={side} className={classNames('overflow-hidden bg-gray-100')}>
<Sidebar className="border-r border-highlight" />
</div>
<ResizeHandle
@@ -173,7 +173,7 @@ function HeaderSize({ className, ...props }: HeaderSizeProps) {
const platform = useOsInfo();
return (
<div
className={classnames(
className={classNames(
className,
'h-md pt-[1px] flex items-center w-full pr-3 pl-20 border-b',
platform?.osType === 'Darwin' && 'pl-20',

View File

@@ -1,40 +1,110 @@
import { invoke } from '@tauri-apps/api';
import classnames from 'classnames';
import { memo, useMemo } from 'react';
import { open } from '@tauri-apps/api/dialog';
import classNames from 'classnames';
import { memo, useCallback, useMemo } from 'react';
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { useCreateWorkspace } from '../hooks/useCreateWorkspace';
import { useDeleteWorkspace } from '../hooks/useDeleteWorkspace';
import { usePrompt } from '../hooks/usePrompt';
import { getRecentEnvironments } from '../hooks/useRecentEnvironments';
import { useTheme } from '../hooks/useTheme';
import { useUpdateWorkspace } from '../hooks/useUpdateWorkspace';
import { useWorkspaces } from '../hooks/useWorkspaces';
import type { Environment, Folder, HttpRequest, Workspace } from '../lib/models';
import { pluralize } from '../lib/pluralize';
import type { ButtonProps } from './core/Button';
import { Button } from './core/Button';
import type { DropdownItem } from './core/Dropdown';
import { Dropdown } from './core/Dropdown';
import { Icon } from './core/Icon';
import { InlineCode } from './core/InlineCode';
import { HStack } from './core/Stacks';
import { HStack, VStack } from './core/Stacks';
import { useDialog } from './DialogContext';
type Props = {
className?: string;
};
type Props = Pick<ButtonProps, 'className' | 'justify' | 'forDropdown' | 'leftSlot'>;
export const WorkspaceActionsDropdown = memo(function WorkspaceDropdown({ className }: Props) {
export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
className,
...buttonProps
}: Props) {
const workspaces = useWorkspaces();
const activeWorkspace = useActiveWorkspace();
const activeWorkspaceId = activeWorkspace?.id ?? null;
const createWorkspace = useCreateWorkspace({ navigateAfter: true });
const updateWorkspace = useUpdateWorkspace(activeWorkspaceId);
const deleteWorkspace = useDeleteWorkspace(activeWorkspace);
const { appearance, toggleAppearance } = useTheme();
const dialog = useDialog();
const prompt = usePrompt();
const routes = useAppRoutes();
const importData = useCallback(async () => {
const selected = await open({
multiple: true,
filters: [
{
name: 'Export File',
extensions: ['json'],
},
],
});
if (selected == null || selected.length === 0) return;
const imported: {
workspaces: Workspace[];
environments: Environment[];
folders: Folder[];
requests: HttpRequest[];
} = await invoke('import_data', {
filePaths: selected,
});
const importedWorkspace = imported.workspaces[0];
dialog.show({
title: 'Import Complete',
size: 'dynamic',
hideX: true,
render: ({ hide }) => {
const { workspaces, environments, folders, requests } = imported;
return (
<VStack space={3}>
<ul className="list-disc pl-6">
<li>
{workspaces.length} {pluralize('Workspace', workspaces.length)}
</li>
<li>
{environments.length} {pluralize('Environment', environments.length)}
</li>
<li>
{folders.length} {pluralize('Folder', folders.length)}
</li>
<li>
{requests.length} {pluralize('Request', requests.length)}
</li>
</ul>
<div>
<Button className="ml-auto" onClick={hide} color="primary">
Done
</Button>
</div>
</VStack>
);
},
});
if (importedWorkspace != null) {
routes.navigate('workspace', {
workspaceId: importedWorkspace.id,
environmentId: imported.environments[0]?.id,
});
}
}, [routes, dialog]);
const items: DropdownItem[] = useMemo(() => {
const workspaceItems = workspaces.map((w) => ({
const workspaceItems: DropdownItem[] = workspaces.map((w) => ({
key: w.id,
label: w.name,
rightSlot: w.id === activeWorkspaceId ? <Icon icon="check" /> : undefined,
onSelect: async () => {
dialog.show({
id: 'open-workspace',
@@ -47,31 +117,33 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceDropdown({ classN
),
render: ({ hide }) => {
return (
<HStack space={2} justifyContent="end" className="mt-6">
<HStack space={2} justifyContent="end" alignItems="center" className="mt-6">
<Button
className="focus"
color="gray"
onClick={() => {
hide();
routes.navigate('workspace', { workspaceId: w.id });
}}
>
This Window
</Button>
<Button
autoFocus
className="focus"
color="gray"
rightSlot={<Icon icon="openNewWindow" />}
onClick={async () => {
hide();
const environmentId = (await getRecentEnvironments(w.id))[0];
await invoke('new_window', {
url: routes.paths.workspace({ workspaceId: w.id }),
url: routes.paths.workspace({ workspaceId: w.id, environmentId }),
});
}}
>
New Window
</Button>
<Button
autoFocus
className="focus"
color="gray"
onClick={async () => {
hide();
const environmentId = (await getRecentEnvironments(w.id))[0];
routes.navigate('workspace', { workspaceId: w.id, environmentId });
}}
>
This Window
</Button>
</HStack>
);
},
@@ -83,12 +155,12 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceDropdown({ classN
workspaces.length <= 1
? []
: [
...workspaceItems,
{
type: 'separator',
label: activeWorkspace?.name,
},
];
...workspaceItems,
{
type: 'separator',
label: activeWorkspace?.name,
},
];
return [
...activeWorkspaceItems,
@@ -127,31 +199,46 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceDropdown({ classN
const name = await prompt({
name: 'name',
label: 'Name',
defaultValue: '',
description: 'Enter a name for the new workspace',
defaultValue: 'My Workspace',
title: 'Create Workspace',
});
createWorkspace.mutate({ name });
},
},
{
key: 'import',
label: 'Import Data',
onSelect: importData,
leftSlot: <Icon icon="download" />,
},
{
key: 'appearance',
label: 'Toggle Theme',
onSelect: toggleAppearance,
leftSlot: <Icon icon={appearance === 'dark' ? 'sun' : 'moon'} />,
},
];
}, [
workspaces,
activeWorkspace?.name,
activeWorkspaceId,
appearance,
createWorkspace,
deleteWorkspace.mutate,
dialog,
routes,
importData,
prompt,
routes,
toggleAppearance,
updateWorkspace,
createWorkspace,
workspaces,
]);
return (
<Dropdown items={items}>
<Button
size="sm"
className={classnames(className, 'text-gray-800 !px-2 truncate')}
forDropdown
className={classNames(className, 'text-gray-800 !px-2 truncate')}
{...buttonProps}
>
{activeWorkspace?.name}
</Button>

View File

@@ -1,14 +1,15 @@
import classnames from 'classnames';
import { memo } from 'react';
import classNames from 'classnames';
import React, { memo } from 'react';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { HStack } from './core/Stacks';
import { EnvironmentActionsDropdown } from './EnvironmentActionsDropdown';
import { RecentRequestsDropdown } from './RecentRequestsDropdown';
import { RequestActionsDropdown } from './RequestActionsDropdown';
import { SidebarActions } from './SidebarActions';
import { WorkspaceActionsDropdown } from './WorkspaceActionsDropdown';
import { Button } from './core/Button';
import { useDialog } from './DialogContext';
interface Props {
className?: string;
@@ -16,40 +17,40 @@ interface Props {
export const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Props) {
const activeRequest = useActiveRequest();
const dialog = useDialog();
const activeWorkspace = useActiveWorkspace();
return (
<HStack
justifyContent="center"
alignItems="center"
className={classnames(className, 'w-full h-full')}
className={classNames(className, 'w-full h-full')}
>
<HStack space={0.5} className="flex-1 pointer-events-none" alignItems="center">
<SidebarActions />
<WorkspaceActionsDropdown className="pointer-events-auto" />
<Button onClick={() => {
dialog.show({
title: 'Testing',
render: () => <div>These are THE environments</div>
})
}}>
Environments
</Button>
<HStack alignItems="center">
<WorkspaceActionsDropdown
leftSlot={
<div className="w-4 h-4 leading-4 rounded text-[0.8em] bg-[#1B88DE] bg-opacity-80 text-white mr-1">
{activeWorkspace?.name[0]?.toUpperCase()}
</div>
}
/>
<Icon icon="chevronRight" className="text-gray-900 text-opacity-disabled" />
<EnvironmentActionsDropdown className="w-auto pointer-events-auto" />
</HStack>
</HStack>
<div className="pointer-events-none">
<RecentRequestsDropdown />
</div>
<div className="flex-1 flex justify-end -mr-2 pointer-events-none">
{activeRequest && (
<RequestActionsDropdown requestId={activeRequest?.id}>
<IconButton
size="sm"
title="Request Options"
icon="gear"
className="pointer-events-auto"
/>
</RequestActionsDropdown>
)}
<RequestActionsDropdown requestId={activeRequest?.id ?? null}>
<IconButton
size="sm"
title="Request Options"
icon="gear"
className="pointer-events-auto"
/>
</RequestActionsDropdown>
</div>
</HStack>
);

View File

@@ -2,15 +2,24 @@ import { Navigate } from 'react-router-dom';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { useWorkspaces } from '../hooks/useWorkspaces';
import { Heading } from './core/Heading';
import { useRecentWorkspaces } from '../hooks/useRecentWorkspaces';
export default function Workspaces() {
const routes = useAppRoutes();
const recentWorkspaceIds = useRecentWorkspaces();
const workspaces = useWorkspaces();
const workspace = workspaces[0];
if (workspace === undefined) {
const loading = workspaces.length === 0 && recentWorkspaceIds.length === 0;
if (loading) {
return null;
}
const workspaceId = recentWorkspaceIds[0] ?? workspaces[0]?.id ?? null;
if (workspaceId === null) {
return <Heading>There are no workspaces</Heading>;
}
return <Navigate to={routes.paths.workspace({ workspaceId: workspace.id })} />;
// TODO: Somehow get recent environmentId for the workspace in here too
return <Navigate to={routes.paths.workspace({ workspaceId })} />;
}

View File

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

View File

@@ -1,4 +1,4 @@
import classnames from 'classnames';
import classNames from 'classnames';
import type { HTMLAttributes, ReactNode } from 'react';
import { forwardRef, memo, useMemo } from 'react';
import { Icon } from './Icon';
@@ -15,6 +15,7 @@ const colorStyles = {
};
export type ButtonProps = HTMLAttributes<HTMLButtonElement> & {
innerClassName?: string;
color?: keyof typeof colorStyles;
isLoading?: boolean;
size?: 'sm' | 'md' | 'xs';
@@ -23,6 +24,7 @@ export type ButtonProps = HTMLAttributes<HTMLButtonElement> & {
forDropdown?: boolean;
disabled?: boolean;
title?: string;
leftSlot?: ReactNode;
rightSlot?: ReactNode;
};
@@ -31,12 +33,14 @@ const _Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{
isLoading,
className,
innerClassName,
children,
forDropdown,
color,
type = 'button',
justify = 'center',
size = 'md',
leftSlot,
rightSlot,
disabled,
...props
@@ -45,11 +49,12 @@ const _Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
) {
const classes = useMemo(
() =>
classnames(
classNames(
className,
'flex-shrink-0 outline-none whitespace-nowrap',
'focus-visible-or-class:ring',
'rounded-md flex items-center',
'max-w-full min-w-0', // Help with truncation
'whitespace-nowrap outline-none',
'flex-shrink-0 flex items-center',
'focus-visible-or-class:ring rounded-md',
disabled ? 'pointer-events-none opacity-disabled' : 'pointer-events-auto',
colorStyles[color || 'default'],
justify === 'start' && 'justify-start',
@@ -63,8 +68,20 @@ const _Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
return (
<button ref={ref} type={type} className={classes} disabled={disabled} {...props}>
{isLoading && <Icon icon="update" size={size} className="animate-spin mr-1" />}
{children}
{isLoading ? (
<Icon icon="update" size={size} className="animate-spin mr-1" />
) : leftSlot ? (
<div className="mr-1">{leftSlot}</div>
) : null}
<div
className={classNames(
'max-w-[15em] truncate w-full',
justify === 'start' ? 'text-left' : 'text-center',
innerClassName,
)}
>
{children}
</div>
{rightSlot && <div className="ml-1">{rightSlot}</div>}
{forDropdown && <Icon icon="chevronDown" size={size} className="ml-1 -mr-1" />}
</button>

View File

@@ -1,15 +1,16 @@
import classnames from 'classnames';
import classNames from 'classnames';
import { useCallback } from 'react';
import { Icon } from './Icon';
interface Props {
checked: boolean;
title: string;
onChange: (checked: boolean) => void;
disabled?: boolean;
className?: string;
}
export function Checkbox({ checked, onChange, className, disabled }: Props) {
export function Checkbox({ checked, onChange, className, disabled, title }: Props) {
const handleClick = useCallback(() => {
onChange(!checked);
}, [onChange, checked]);
@@ -20,7 +21,8 @@ export function Checkbox({ checked, onChange, className, disabled }: Props) {
aria-checked={checked ? 'true' : 'false'}
disabled={disabled}
onClick={handleClick}
className={classnames(
title={title}
className={classNames(
className,
'flex-shrink-0 w-4 h-4 border border-gray-200 rounded',
'focus:border-focus',

View File

@@ -1,4 +1,4 @@
import classnames from 'classnames';
import classNames from 'classnames';
interface Props {
count: number;
@@ -10,7 +10,7 @@ export function CountBadge({ count, className }: Props) {
return (
<div
aria-hidden
className={classnames(
className={classNames(
className,
'opacity-70 border border-highlight text-3xs rounded mb-0.5 px-1 ml-1 h-4 font-mono',
)}

View File

@@ -1,4 +1,4 @@
import classnames from 'classnames';
import classNames from 'classnames';
import { motion } from 'framer-motion';
import type { ReactNode } from 'react';
import { useMemo } from 'react';
@@ -11,7 +11,7 @@ export interface DialogProps {
children: ReactNode;
open: boolean;
onClose: () => void;
title: ReactNode;
title?: ReactNode;
description?: ReactNode;
className?: string;
size?: 'sm' | 'md' | 'full' | 'dynamic';
@@ -51,23 +51,28 @@ export function Dialog({
<motion.div
initial={{ top: 5, scale: 0.97 }}
animate={{ top: 0, scale: 1 }}
className={classnames(
className={classNames(
className,
'gap-2 grid grid-rows-[auto_minmax(0,1fr)]',
'relative bg-gray-50 pointer-events-auto',
'max-h-[80vh] p-5 rounded-lg overflow-auto',
'p-5 rounded-lg overflow-auto',
'dark:border border-highlight shadow shadow-black/10',
size === 'sm' && 'w-[25rem]',
size === 'md' && 'w-[45rem]',
size === 'full' && 'w-[80vw]',
'max-w-[90vw] max-h-[calc(100vh-8em)]',
size === 'sm' && 'w-[25rem] max-h-[80vh]',
size === 'md' && 'w-[45rem] max-h-[80vh]',
size === 'full' && 'w-[100vw] h-[100vh]',
size === 'dynamic' && 'min-w-[30vw] max-w-[80vw]',
)}
>
<Heading className="text-xl font-semibold w-full" id={titleId}>
{title}
</Heading>
{title ? (
<Heading className="text-xl font-semibold w-full" id={titleId}>
{title}
</Heading>
) : (
<span />
)}
{description && <p id={descriptionId}>{description}</p>}
<div className="mt-4">{children}</div>
<div className="h-full w-full">{children}</div>
{/*Put close at the end so that it's the last thing to be tabbed to*/}
{!hideX && (
<IconButton

View File

@@ -1,7 +1,13 @@
import classnames from 'classnames';
import FocusTrap from 'focus-trap-react';
import classNames from 'classnames';
import { motion } from 'framer-motion';
import type { CSSProperties, HTMLAttributes, MouseEvent, ReactElement, ReactNode } from 'react';
import type {
CSSProperties,
FocusEvent as ReactFocusEvent,
HTMLAttributes,
MouseEvent,
ReactElement,
ReactNode,
} from 'react';
import React, {
Children,
cloneElement,
@@ -13,8 +19,8 @@ import React, {
useRef,
useState,
} from 'react';
import { useKey, useKeyPressEvent } from 'react-use';
import { Portal } from '../Portal';
import { useKey, useKeyPressEvent, useWindowSize } from 'react-use';
import { Overlay } from '../Overlay';
import { Button } from './Button';
import { Separator } from './Separator';
import { VStack } from './Stacks';
@@ -46,7 +52,7 @@ export interface DropdownProps {
export interface DropdownRef {
isOpen: boolean;
open: (activeIndex?: number) => void;
toggle: () => void;
toggle: (activeIndex?: number) => void;
close?: () => void;
next?: () => void;
prev?: () => void;
@@ -65,8 +71,11 @@ export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown
useImperativeHandle(ref, () => ({
...menuRef.current,
isOpen: open,
toggle: () => setOpen(!open),
open: (activeIndex?: number) => {
toggle(activeIndex?: number) {
if (!open) this.open(activeIndex);
else setOpen(false);
},
open(activeIndex?: number) {
if (activeIndex === undefined) {
setDefaultSelectedIndex(undefined);
} else {
@@ -104,10 +113,12 @@ export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown
buttonRef.current?.setAttribute('aria-expanded', open.toString());
}, [open]);
const windowSize = useWindowSize();
const triggerRect = useMemo(() => {
if (!windowSize) return null; // No-op to TS happy with this dep
if (!open) return null;
return buttonRef.current?.getBoundingClientRect();
}, [open]);
}, [open, windowSize]);
return (
<>
@@ -264,61 +275,59 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
if (items.length === 0) return null;
return (
<Portal name="dropdown">
<FocusTrap>
<div>
<div tabIndex={-1} aria-hidden className="fixed inset-0" onClick={onClose} />
<motion.div
tabIndex={0}
onKeyDown={handleMenuKeyDown}
initial={{ opacity: 0, y: -5, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
role="menu"
aria-orientation="vertical"
dir="ltr"
ref={containerRef}
style={containerStyles}
className={classnames(className, 'outline-none mt-1 pointer-events-auto fixed z-50')}
>
<span
aria-hidden
style={triangleStyles}
className="bg-gray-50 absolute rotate-45 border-gray-200 border-t border-l"
/>
{containerStyles && (
<VStack
space={0.5}
ref={initMenu}
style={menuStyles}
className={classnames(
className,
'h-auto bg-gray-50 rounded-md shadow-lg dark:shadow-gray-0 py-1.5 border',
'border-gray-200 overflow-auto mb-1 mx-0.5',
)}
>
{items.map((item, i) => {
if (item.type === 'separator') {
return <Separator key={i} className="my-1.5" label={item.label} />;
}
if (item.hidden) {
return null;
}
return (
<MenuItem
focused={i === selectedIndex}
onFocus={handleFocus}
onSelect={handleSelect}
key={item.key}
item={item}
/>
);
})}
</VStack>
)}
</motion.div>
</div>
</FocusTrap>
</Portal>
<Overlay open variant="transparent" portalName="dropdown" zIndex={50}>
<div>
<div tabIndex={-1} aria-hidden className="fixed inset-0 z-30" onClick={onClose} />
<motion.div
tabIndex={0}
onKeyDown={handleMenuKeyDown}
initial={{ opacity: 0, y: -5, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
role="menu"
aria-orientation="vertical"
dir="ltr"
ref={containerRef}
style={containerStyles}
className={classNames(className, 'outline-none mt-1 pointer-events-auto fixed z-50')}
>
<span
aria-hidden
style={triangleStyles}
className="bg-gray-50 absolute rotate-45 border-gray-200 border-t border-l"
/>
{containerStyles && (
<VStack
space={0.5}
ref={initMenu}
style={menuStyles}
className={classNames(
className,
'h-auto bg-gray-50 rounded-md shadow-lg dark:shadow-gray-0 py-1.5 border',
'border-gray-200 overflow-auto mb-1 mx-0.5',
)}
>
{items.map((item, i) => {
if (item.type === 'separator') {
return <Separator key={i} className="my-1.5" label={item.label} />;
}
if (item.hidden) {
return null;
}
return (
<MenuItem
focused={i === selectedIndex}
onFocus={handleFocus}
onSelect={handleSelect}
key={item.key}
item={item}
/>
);
})}
</VStack>
)}
</motion.div>
</div>
</Overlay>
);
});
@@ -332,7 +341,13 @@ interface MenuItemProps {
function MenuItem({ className, focused, onFocus, item, onSelect, ...props }: MenuItemProps) {
const handleClick = useCallback(() => onSelect?.(item), [item, onSelect]);
const handleFocus = useCallback(() => onFocus?.(item), [item, onFocus]);
const handleFocus = useCallback(
(e: ReactFocusEvent<HTMLButtonElement>) => {
e.stopPropagation(); // Don't trigger focus on any parents
return onFocus?.(item);
},
[item, onFocus],
);
const initRef = useCallback(
(el: HTMLButtonElement | null) => {
@@ -353,27 +368,29 @@ function MenuItem({ className, focused, onFocus, item, onSelect, ...props }: Men
tabIndex={-1}
onMouseEnter={(e) => e.currentTarget.focus()}
onMouseLeave={(e) => e.currentTarget.blur()}
disabled={item.disabled}
onFocus={handleFocus}
onClick={handleClick}
justify="start"
className={classnames(
leftSlot={item.leftSlot && <div className="pr-2 flex justify-start">{item.leftSlot}</div>}
rightSlot={item.rightSlot && <div className="ml-auto pl-3">{item.rightSlot}</div>}
className={classNames(
className,
'min-w-[8rem] outline-none px-2 mx-1.5 flex text-sm text-gray-700 whitespace-nowrap',
'focus:bg-highlight focus:text-gray-900 rounded',
item.variant === 'danger' && 'text-red-600',
)}
innerClassName="!text-left"
{...props}
>
{item.leftSlot && <div className="pr-2 flex justify-start">{item.leftSlot}</div>}
<div
className={classnames(
className={classNames(
// Add padding on right when no right slot, for some visual balance
!item.rightSlot && 'pr-4',
)}
>
{item.label}
</div>
{item.rightSlot && <div className="ml-auto pl-3">{item.rightSlot}</div>}
</Button>
);
}

View File

@@ -1,203 +1,221 @@
.cm-wrapper {
@apply h-full overflow-hidden;
@apply h-full overflow-hidden;
.cm-editor {
@apply w-full block text-base;
* {
@apply cursor-text;
@apply caret-transparent !important;
}
.cm-cursor {
@apply border-gray-800 !important;
}
&.cm-focused {
outline: none !important;
}
.cm-content {
@apply py-0;
}
.cm-line {
@apply text-gray-800 pl-1 pr-1.5;
}
.cm-placeholder {
@apply text-placeholder;
}
.cm-scroller {
/* Inherit line-height from outside */
line-height: inherit;
}
/* Don't show selection on blurred input */
.cm-selectionBackground {
@apply bg-transparent;
}
&.cm-focused .cm-selectionBackground {
@apply bg-selection;
}
/* Style gutters */
.cm-gutters {
@apply border-0 text-gray-500/50;
.cm-gutterElement {
@apply cursor-default;
}
}
.placeholder-widget {
@apply text-xs text-gray-800 dark:text-gray-900 px-1 rounded cursor-default dark:shadow;
/* NOTE: Background and border are translucent so we can see text selection through it */
@apply bg-gray-300/40 border border-gray-300 border-opacity-40;
/* Bring above on hover */
@apply hover:z-10 relative;
-webkit-text-security: none;
&.placeholder-widget-error {
@apply bg-red-300/40 border-red-300/80 border-opacity-40 hover:border-red-300 hover:bg-red-300/50;
}
}
}
&.cm-singleline {
.cm-editor {
@apply w-full h-auto;
}
.cm-scroller {
@apply font-mono text-[0.8rem] overflow-hidden;
}
.cm-line {
@apply px-2 overflow-hidden;
}
}
&.cm-multiline {
&.cm-full-height {
@apply relative;
.cm-editor {
@apply inset-0 absolute;
position: absolute !important;
}
}
.cm-editor {
@apply w-full block text-base;
* {
@apply cursor-text;
}
&.cm-focused {
outline: none !important;
}
.cm-content {
@apply py-0;
}
.cm-line {
@apply text-gray-800 pl-1 pr-1.5;
}
.cm-placeholder {
@apply text-placeholder;
}
.cm-scroller {
/* Inherit line-height from outside */
line-height: inherit;
}
/* Don't show selection on blurred input */
.cm-selectionBackground {
@apply bg-transparent;
}
&.cm-focused .cm-selectionBackground {
@apply bg-selection;
}
/* Style gutters */
.cm-gutters {
@apply border-0 text-gray-500/50;
.cm-gutterElement {
@apply cursor-default;
}
}
.placeholder-widget {
@apply text-xs text-gray-800 dark:text-gray-900 px-1 rounded cursor-default dark:shadow;
/* NOTE: Background and border are translucent so we can see text selection through it */
@apply bg-gray-300/40 border border-gray-300 border-opacity-40 hover:border-opacity-80;
/* Bring above on hover */
@apply hover:z-10 relative;
}
@apply h-full;
}
&.cm-singleline {
.cm-editor {
@apply w-full h-auto;
}
.cm-scroller {
@apply font-mono text-[0.75rem];
.cm-scroller {
@apply font-mono text-[0.8rem] overflow-hidden;
}
.cm-line {
@apply px-2 overflow-hidden;
}
}
&.cm-multiline {
&.cm-full-height {
@apply relative;
.cm-editor {
@apply inset-0 absolute;
position: absolute !important;
}
}
.cm-editor {
@apply h-full;
}
.cm-scroller {
@apply font-mono text-[0.75rem];
/*
/*
* Round corners or they'll stick out of the editor bounds of editor is rounded.
* Could potentially be pushed up from the editor like we do with bg color but this
* is probably fine.
*/
@apply rounded-lg;
}
@apply rounded-lg;
}
}
}
/* Obscure text for password fields */
.cm-wrapper.cm-obscure-text .cm-line {
-webkit-text-security: disc;
-webkit-text-security: disc;
}
.cm-editor .cm-gutterElement {
@apply flex items-center;
transition: color var(--transition-duration);
@apply flex items-center;
transition: color var(--transition-duration);
}
.cm-editor .fold-gutter-icon {
@apply pt-[0.25em] pl-[0.4em] px-[0.4em] h-4 cursor-pointer rounded;
@apply pt-[0.25em] pl-[0.4em] px-[0.4em] h-4 cursor-pointer rounded;
}
.cm-editor .fold-gutter-icon::after {
@apply block w-1.5 h-1.5 border-transparent -rotate-45
@apply block w-1.5 h-1.5 border-transparent -rotate-45
border-l border-b border-l-[currentColor] border-b-[currentColor] content-[''];
}
.cm-editor .fold-gutter-icon[data-open] {
@apply pt-[0.38em] pl-[0.3em];
@apply pt-[0.38em] pl-[0.3em];
}
.cm-editor .fold-gutter-icon[data-open]::after {
@apply rotate-[-135deg];
@apply rotate-[-135deg];
}
.cm-editor .fold-gutter-icon:hover {
@apply text-gray-900 bg-gray-300/50;
@apply text-gray-900 bg-gray-300/50;
}
.cm-editor .cm-foldPlaceholder {
@apply px-2 border border-gray-400/50 bg-gray-300/50 cursor-default;
@apply hover:text-gray-800 hover:border-gray-400;
@apply px-2 border border-gray-400/50 bg-gray-300/50 cursor-default;
@apply hover:text-gray-800 hover:border-gray-400;
}
.cm-editor .cm-activeLineGutter {
@apply bg-transparent;
@apply bg-transparent;
}
.cm-wrapper:not(.cm-readonly) .cm-editor {
&.cm-focused .cm-activeLineGutter {
@apply text-gray-600;
}
&.cm-focused .cm-activeLineGutter {
@apply text-gray-600;
}
.cm-cursor {
@apply border-l-2 border-gray-800;
}
.cm-cursor {
@apply border-l-2 border-gray-800;
}
}
.cm-singleline .cm-editor {
.cm-content {
@apply h-full flex items-center;
.cm-content {
@apply h-full flex items-center;
/* Break characters on line wrapping mode, useful for URL field.
* We can make this dynamic if we need it to be configurable later
*/
&.cm-lineWrapping {
@apply break-all;
}
}
}
/* NOTE: Extra selector required to override default styles */
.cm-tooltip.cm-tooltip {
@apply shadow-lg bg-gray-50 rounded text-gray-700 border border-gray-200 z-50 pointer-events-auto text-[0.75rem];
@apply shadow-lg bg-gray-50 rounded text-gray-700 border border-gray-200 z-50 pointer-events-auto text-[0.75rem];
&.cm-completionInfo-right {
@apply ml-1 -mt-0.5 text-sm;
&.cm-completionInfo-right {
@apply ml-1 -mt-0.5 text-sm;
}
&.cm-completionInfo-right-narrow {
@apply ml-1;
}
* {
@apply transition-none;
}
&.cm-tooltip-autocomplete {
& > ul {
@apply p-1 max-h-[40vh];
}
&.cm-completionInfo-right-narrow {
@apply ml-1;
& > ul > li {
@apply cursor-default px-2 rounded-sm text-gray-600 h-7 flex items-center;
}
* {
@apply transition-none;
& > ul > li[aria-selected] {
@apply bg-highlight text-gray-900;
}
&.cm-tooltip-autocomplete {
& > ul {
@apply p-1 max-h-[40vh];
}
& > ul > li {
@apply cursor-default px-2 rounded-sm text-gray-600 h-7 flex items-center;
}
& > ul > li[aria-selected] {
@apply bg-highlight text-gray-900;
}
.cm-completionIcon {
@apply text-sm flex items-center pb-0.5 flex-shrink-0;
}
.cm-completionLabel {
@apply text-gray-700;
}
.cm-completionDetail {
@apply ml-auto pl-6;
}
.cm-completionIcon {
@apply text-sm flex items-center pb-0.5 mr-2 flex-shrink-0;
}
.cm-completionLabel {
@apply text-gray-700;
}
.cm-completionDetail {
@apply ml-auto pl-6;
}
}
}
/* Add default icon. Needs low priority so it can be overwritten */
.cm-completionIcon::after {
content: '𝑥';
content: '𝑥';
}

View File

@@ -2,10 +2,12 @@ import { defaultKeymap } from '@codemirror/commands';
import { Compartment, EditorState, Transaction } from '@codemirror/state';
import type { ViewUpdate } from '@codemirror/view';
import { keymap, placeholder as placeholderExt, tooltips } from '@codemirror/view';
import classnames from 'classnames';
import classNames from 'classnames';
import { EditorView } from 'codemirror';
import type { MutableRefObject, ReactNode } from 'react';
import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef } from 'react';
import { useActiveEnvironment } from '../../../hooks/useActiveEnvironment';
import { useActiveWorkspace } from '../../../hooks/useActiveWorkspace';
import { IconButton } from '../IconButton';
import { HStack } from '../Stacks';
import './Editor.css';
@@ -27,6 +29,7 @@ export interface EditorProps {
contentType?: string | null;
forceUpdateKey?: string;
autoFocus?: boolean;
autoSelect?: boolean;
defaultValue?: string | null;
placeholder?: string;
tooltipContainer?: HTMLElement;
@@ -34,10 +37,12 @@ export interface EditorProps {
onChange?: (value: string) => void;
onFocus?: () => void;
onBlur?: () => void;
onKeyDown?: (e: KeyboardEvent) => void;
singleLine?: boolean;
wrapLines?: boolean;
format?: (v: string) => string;
autocomplete?: GenericCompletionConfig;
autocompleteVariables?: boolean;
actions?: ReactNode;
}
@@ -48,6 +53,7 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
heightMode,
contentType,
autoFocus,
autoSelect,
placeholder,
useTemplating,
defaultValue,
@@ -55,15 +61,22 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
onChange,
onFocus,
onBlur,
onKeyDown,
className,
singleLine,
format,
autocomplete,
autocompleteVariables,
actions,
wrapLines,
}: EditorProps,
ref,
) {
const e = useActiveEnvironment();
const w = useActiveWorkspace();
const environment = autocompleteVariables ? e : null;
const workspace = autocompleteVariables ? w : null;
const cm = useRef<{ view: EditorView; languageCompartment: Compartment } | null>(null);
useImperativeHandle(ref, () => cm.current?.view);
@@ -85,6 +98,12 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
handleBlur.current = onBlur;
}, [onBlur]);
// Use ref so we can update the onChange handler without re-initializing the editor
const handleKeyDown = useRef<EditorProps['onKeyDown']>(onKeyDown);
useEffect(() => {
handleKeyDown.current = onKeyDown;
}, [onKeyDown]);
// Update placeholder
const placeholderCompartment = useRef(new Compartment());
useEffect(() => {
@@ -108,9 +127,15 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
useEffect(() => {
if (cm.current === null) return;
const { view, languageCompartment } = cm.current;
const ext = getLanguageExtension({ contentType, useTemplating, autocomplete });
const ext = getLanguageExtension({
contentType,
environment,
workspace,
useTemplating,
autocomplete,
});
view.dispatch({ effects: languageCompartment.reconfigure(ext) });
}, [contentType, autocomplete, useTemplating]);
}, [contentType, autocomplete, useTemplating, environment, workspace]);
useEffect(() => {
if (cm.current === null) return;
@@ -131,7 +156,13 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
let view: EditorView;
try {
const languageCompartment = new Compartment();
const langExt = getLanguageExtension({ contentType, useTemplating, autocomplete });
const langExt = getLanguageExtension({
contentType,
useTemplating,
autocomplete,
environment,
workspace,
});
const state = EditorState.create({
doc: `${defaultValue ?? ''}`,
@@ -146,6 +177,7 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
onChange: handleChange,
onFocus: handleFocus,
onBlur: handleBlur,
onKeyDown: handleKeyDown,
}),
],
});
@@ -153,7 +185,12 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
view = new EditorView({ state, parent: container });
cm.current = { view, languageCompartment };
syncGutterBg({ parent: container, className });
if (autoFocus) view.focus();
if (autoFocus) {
view.focus();
}
if (autoSelect) {
view.dispatch({ selection: { anchor: 0, head: view.state.doc.length } });
}
} catch (e) {
console.log('Failed to initialize Codemirror', e);
}
@@ -164,7 +201,7 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
const cmContainer = (
<div
ref={initEditorRef}
className={classnames(
className={classNames(
className,
'cm-wrapper text-base bg-gray-50',
type === 'password' && 'cm-obscure-text',
@@ -194,10 +231,14 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
onClick={() => {
if (cm.current === null) return;
const { doc } = cm.current.view.state;
const insert = format(doc.toString());
const formatted = format(doc.toString());
// Update editor and blur because the cursor will reset anyway
cm.current.view.dispatch({ changes: { from: 0, to: doc.length, insert } });
cm.current.view.dispatch({
changes: { from: 0, to: doc.length, insert: formatted },
});
cm.current.view.contentDOM.blur();
// Fire change event
onChange?.(formatted);
}}
/>
</HStack>
@@ -215,11 +256,13 @@ function getExtensions({
onChange,
onFocus,
onBlur,
onKeyDown,
}: Pick<EditorProps, 'singleLine' | 'readOnly'> & {
container: HTMLDivElement | null;
onChange: MutableRefObject<EditorProps['onChange']>;
onFocus: MutableRefObject<EditorProps['onFocus']>;
onBlur: MutableRefObject<EditorProps['onBlur']>;
onKeyDown: MutableRefObject<EditorProps['onKeyDown']>;
}) {
// TODO: Ensure tooltips render inside the dialog if we are in one.
const parent =
@@ -236,29 +279,13 @@ function getExtensions({
...(readOnly
? [EditorState.readOnly.of(true), EditorView.contentAttributes.of({ tabindex: '-1' })]
: []),
...(singleLine
? [
EditorView.domEventHandlers({
focus: (e, view) => {
// select all text on focus, like a regular input does
view.dispatch({ selection: { anchor: 0, head: view.state.doc.length } });
},
keydown: (e) => {
// Submit nearest form on enter if there is one
if (e.key === 'Enter') {
const el = e.currentTarget as HTMLElement;
const form = el.closest('form');
form?.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
}
},
}),
]
: []),
// Handle onFocus
// NOTE: These *must* be anonymous functions so the references update properly
EditorView.domEventHandlers({
focus: onFocus.current,
blur: onBlur.current,
focus: () => onFocus.current?.(),
blur: () => onBlur.current?.(),
keydown: (e) => onKeyDown.current?.(e),
}),
// Handle onChange

View File

@@ -5,7 +5,6 @@ import {
completionKeymap,
} from '@codemirror/autocomplete';
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
import { html } from '@codemirror/lang-html';
import { javascript } from '@codemirror/lang-javascript';
import { json } from '@codemirror/lang-json';
import { xml } from '@codemirror/lang-xml';
@@ -33,6 +32,7 @@ import {
} from '@codemirror/view';
import { tags as t } from '@lezer/highlight';
import { graphql, graphqlLanguageSupport } from 'cm6-graphql';
import type { Environment, Workspace } from '../../../lib/models';
import type { EditorProps } from './index';
import { text } from './text/extension';
import { twig } from './twig/extension';
@@ -86,7 +86,7 @@ const syntaxExtensions: Record<string, LanguageSupport> = {
'application/graphql': graphqlLanguageSupport(),
'application/json': json(),
'application/javascript': javascript(),
'text/html': html(),
'text/html': xml(), // HTML as xml because HTML is oddly slow
'application/xml': xml(),
'text/xml': xml(),
url: url(),
@@ -95,32 +95,36 @@ const syntaxExtensions: Record<string, LanguageSupport> = {
export function getLanguageExtension({
contentType,
useTemplating = false,
environment,
workspace,
autocomplete,
}: Pick<EditorProps, 'contentType' | 'useTemplating' | 'autocomplete'>) {
if (contentType === 'application/graphql') {
}: { environment: Environment | null; workspace: Workspace | null } & Pick<
EditorProps,
'contentType' | 'useTemplating' | 'autocomplete'
>) {
const justContentType = contentType?.split(';')[0] ?? contentType ?? '';
if (justContentType === 'application/graphql') {
return graphql();
}
const justContentType = contentType?.split(';')[0] ?? contentType ?? '';
const base = syntaxExtensions[justContentType] ?? text();
if (!useTemplating) {
return base ? base : [];
return base;
}
return twig(base, autocomplete);
return twig(base, environment, workspace, autocomplete);
}
export const baseExtensions = [
highlightSpecialChars(),
history(),
drawSelection(),
dropCursor(),
drawSelection(),
bracketMatching(),
// TODO: Figure out how to debounce showing of autocomplete in a good way
// debouncedAutocompletionDisplay({ millis: 1000 }),
// autocompletion({ closeOnBlur: true, interactionDelay: 200, activateOnTyping: false }),
autocompletion({
// closeOnBlur: false,
interactionDelay: 200,
compareCompletions: (a, b) => {
// Don't sort completions at all, only on boost
return (a.boost ?? 0) - (b.boost ?? 0);

View File

@@ -17,13 +17,19 @@ export interface GenericCompletionConfig {
export function genericCompletion({ options, minMatch = 1 }: GenericCompletionConfig) {
return function completions(context: CompletionContext) {
const toMatch = context.matchBefore(/^.*/);
if (toMatch === null) return null;
const toMatch = context.matchBefore(/\w*/);
// Only match if we're at the start of the line
if (toMatch === null || toMatch.from > 0) return null;
const matchedMinimumLength = toMatch.to - toMatch.from >= minMatch;
if (!matchedMinimumLength && !context.explicit) return null;
const optionsWithoutExactMatches = options.filter((o) => o.label !== toMatch.text);
return { from: toMatch.from, options: optionsWithoutExactMatches, info: 'hello' };
return {
validFor: () => true, // Not really sure why this is all it needs
from: toMatch.from,
options: optionsWithoutExactMatches,
};
};
}

View File

@@ -1,76 +0,0 @@
import type { DecorationSet, ViewUpdate } from '@codemirror/view';
import { Decoration, EditorView, MatchDecorator, ViewPlugin, WidgetType } from '@codemirror/view';
class PlaceholderWidget extends WidgetType {
constructor(readonly name: string) {
super();
}
eq(other: PlaceholderWidget) {
return this.name == other.name;
}
toDOM() {
const elt = document.createElement('span');
elt.className = 'placeholder-widget';
elt.textContent = this.name;
return elt;
}
ignoreEvent() {
return false;
}
}
/**
* This is a custom MatchDecorator that will not decorate a match if the selection is inside it
*/
class BetterMatchDecorator extends MatchDecorator {
updateDeco(update: ViewUpdate, deco: DecorationSet): DecorationSet {
if (!update.startState.selection.eq(update.state.selection)) {
return super.createDeco(update.view);
} else {
return super.updateDeco(update, deco);
}
}
}
const placeholderMatcher = new BetterMatchDecorator({
regexp: /\$\{\[\s*([^\]\s]+)\s*]}/g,
decoration(match, view, matchStartPos) {
const matchEndPos = matchStartPos + match[0].length - 1;
// Don't decorate if the cursor is inside the match
for (const r of view.state.selection.ranges) {
if (r.from > matchStartPos && r.to <= matchEndPos) return null;
}
const groupMatch = match[1];
if (groupMatch == null) {
// Should never happen, but make TS happy
console.warn('Group match was empty', match);
return null;
}
return Decoration.replace({
inclusive: true,
widget: new PlaceholderWidget(groupMatch),
});
},
});
export const placeholders = ViewPlugin.fromClass(
class {
placeholders: DecorationSet;
constructor(view: EditorView) {
this.placeholders = placeholderMatcher.createDeco(view);
}
update(update: ViewUpdate) {
this.placeholders = placeholderMatcher.updateDeco(update, this.placeholders);
}
},
{
decorations: (instance) => instance.placeholders,
provide: (plugin) =>
EditorView.atomicRanges.of((view) => {
return view.plugin(plugin)?.placeholders || Decoration.none;
}),
},
);

View File

@@ -6,8 +6,8 @@ export function singleLineExt() {
(tr: Transaction): TransactionSpec | TransactionSpec[] => {
if (!tr.isUserEvent('input')) return tr;
const trs: TransactionSpec[] = [];
tr.changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
const specs: TransactionSpec[] = [];
tr.changes.iterChanges((_, toA, fromB, toB, inserted) => {
let insert = '';
let newlinesRemoved = 0;
for (const line of inserted) {
@@ -21,9 +21,10 @@ export function singleLineExt() {
const selection = EditorSelection.create([cursor], 0);
const changes = [{ from: fromB, to: toA, insert }];
trs.push({ ...tr, selection, changes });
specs.push({ ...tr, selection, changes });
});
return trs;
return specs;
},
);
}

View File

@@ -1,46 +1,54 @@
import type { CompletionContext } from '@codemirror/autocomplete';
import { w } from '@tauri-apps/api/clipboard-79413165';
const openTag = '${[ ';
const closeTag = ' ]}';
const variables: { name: string }[] = [
// TODO: Put variables here
];
interface TwigCompletionOption {
name: string;
}
export interface TwigCompletionConfig {
options: TwigCompletionOption[];
}
const MIN_MATCH_VAR = 2;
const MIN_MATCH_NAME = 3;
const MIN_MATCH_NAME = 1;
export function completions(context: CompletionContext) {
const toStartOfName = context.matchBefore(/\w*/);
const toStartOfVariable = context.matchBefore(/\$\{?\[?\s*\w*/);
const toMatch = toStartOfVariable ?? toStartOfName ?? null;
export function twigCompletion({ options }: TwigCompletionConfig) {
return function completions(context: CompletionContext) {
const toStartOfName = context.matchBefore(/\w*/);
const toStartOfVariable = context.matchBefore(/\$\{?\[?\s*\w*/);
const toMatch = toStartOfVariable ?? toStartOfName ?? null;
if (toMatch === null) return null;
if (toMatch === null) return null;
const matchLen = toMatch.to - toMatch.from;
const matchLen = toMatch.to - toMatch.from;
const failedVarLen = toStartOfVariable !== null && matchLen < MIN_MATCH_VAR;
if (failedVarLen && !context.explicit) {
return null;
}
const failedVarLen = toStartOfVariable !== null && matchLen < MIN_MATCH_VAR;
if (failedVarLen && !context.explicit) {
return null;
}
const failedNameLen = toStartOfVariable === null && matchLen < MIN_MATCH_NAME;
if (failedNameLen && !context.explicit) {
return null;
}
const failedNameLen = toStartOfVariable === null && matchLen < MIN_MATCH_NAME;
if (failedNameLen && !context.explicit) {
return null;
}
// TODO: Figure out how to make autocomplete stay open if opened explicitly. It sucks when you explicitly
// open it, then it closes when you type the next character.
return {
from: toMatch.from,
options: variables
.map((v) => ({
label: toStartOfVariable ? `${openTag}${v.name}${closeTag}` : v.name,
apply: `${openTag}${v.name}${closeTag}`,
type: 'variable',
matchLen,
}))
// Filter out exact matches
.filter((o) => o.label !== toMatch.text),
// TODO: Figure out how to make autocomplete stay open if opened explicitly. It sucks when you explicitly
// open it, then it closes when you type the next character.
return {
validFor: () => true, // Not really sure why this is all it needs
from: toMatch.from,
options: options
.map((v) => ({
label: toStartOfVariable ? `${openTag}${v.name}${closeTag}` : v.name,
apply: `${openTag}${v.name}${closeTag}`,
type: 'variable',
matchLen: matchLen,
}))
// Filter out exact matches
.filter((o) => o.label !== toMatch.text),
};
};
}

View File

@@ -3,12 +3,23 @@ import { LRLanguage } from '@codemirror/language';
import { parseMixed } from '@lezer/common';
import type { GenericCompletionConfig } from '../genericCompletion';
import { genericCompletion } from '../genericCompletion';
import { placeholders } from '../placeholder';
import { placeholders } from './placeholder';
import { textLanguageName } from '../text/extension';
import { completions } from './completion';
import { twigCompletion } from './completion';
import { parser as twigParser } from './twig';
import type { Environment, Workspace } from '../../../../lib/models';
export function twig(
base: LanguageSupport,
environment: Environment | null,
workspace: Workspace | null,
autocomplete?: GenericCompletionConfig,
) {
const variables =
[...(workspace?.variables ?? []), ...(environment?.variables ?? [])].filter((v) => v.enabled) ??
[];
const completions = twigCompletion({ options: variables });
export function twig(base: LanguageSupport, autocomplete?: GenericCompletionConfig) {
const language = mixLanguage(base);
const completion = language.data.of({ autocomplete: completions });
const completionBase = base.language.data.of({ autocomplete: completions });
@@ -21,7 +32,7 @@ export function twig(base: LanguageSupport, autocomplete?: GenericCompletionConf
completion,
completionBase,
base.support,
placeholders,
placeholders(variables),
...additionalCompletion,
];
}

View File

@@ -0,0 +1,89 @@
import type { DecorationSet, ViewUpdate } from '@codemirror/view';
import { Decoration, EditorView, MatchDecorator, ViewPlugin, WidgetType } from '@codemirror/view';
class PlaceholderWidget extends WidgetType {
constructor(
readonly name: string,
readonly isExistingVariable: boolean,
) {
super();
}
eq(other: PlaceholderWidget) {
return this.name == other.name && this.isExistingVariable == other.isExistingVariable;
}
toDOM() {
const elt = document.createElement('span');
elt.className = `placeholder-widget ${
!this.isExistingVariable ? 'placeholder-widget-error' : ''
}`;
elt.title = !this.isExistingVariable ? 'Variable not found in active environment' : '';
elt.textContent = this.name;
return elt;
}
ignoreEvent() {
return false;
}
}
/**
* This is a custom MatchDecorator that will not decorate a match if the selection is inside it
*/
class BetterMatchDecorator extends MatchDecorator {
updateDeco(update: ViewUpdate, deco: DecorationSet): DecorationSet {
if (!update.startState.selection.eq(update.state.selection)) {
return super.createDeco(update.view);
} else {
return super.updateDeco(update, deco);
}
}
}
export const placeholders = function (variables: { name: string }[]) {
const placeholderMatcher = new BetterMatchDecorator({
regexp: /\$\{\[\s*([^\]\s]+)\s*]}/g,
decoration(match, view, matchStartPos) {
const matchEndPos = matchStartPos + match[0].length - 1;
// Don't decorate if the cursor is inside the match
for (const r of view.state.selection.ranges) {
if (r.from > matchStartPos && r.to <= matchEndPos) {
return Decoration.replace({});
}
}
const groupMatch = match[1];
if (groupMatch == null) {
// Should never happen, but make TS happy
console.warn('Group match was empty', match);
return Decoration.replace({});
}
return Decoration.replace({
inclusive: true,
widget: new PlaceholderWidget(
groupMatch,
variables.some((v) => v.name === groupMatch),
),
});
},
});
return ViewPlugin.fromClass(
class {
placeholders: DecorationSet;
constructor(view: EditorView) {
this.placeholders = placeholderMatcher.createDeco(view);
}
update(update: ViewUpdate) {
this.placeholders = placeholderMatcher.updateDeco(update, this.placeholders);
}
},
{
decorations: (instance) => instance.placeholders,
provide: (plugin) =>
EditorView.atomicRanges.of((view) => {
return view.plugin(plugin)?.placeholders || Decoration.none;
}),
},
);
};

View File

@@ -1,10 +1,17 @@
import classNames from 'classnames';
interface Props {
children: string;
}
export function FormattedError({ children }: Props) {
return (
<pre className="text-sm select-auto cursor-text bg-gray-100 p-3 rounded whitespace-normal border border-red-500 border-dashed">
<pre
className={classNames(
'text-sm select-auto cursor-text bg-gray-100 p-3 rounded',
'whitespace-normal border border-red-500 border-dashed',
)}
>
{children}
</pre>
);

View File

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

View File

@@ -1,4 +1,4 @@
import classnames from 'classnames';
import classNames from 'classnames';
interface Props {
modifier: 'Meta' | 'Control' | 'Shift';
@@ -13,7 +13,7 @@ const keys: Record<Props['modifier'], string> = {
export function HotKey({ modifier, keyName }: Props) {
return (
<span className={classnames('text-sm text-gray-600')}>
<span className={classNames('text-sm text-gray-600')}>
{keys[modifier]}
{keyName}
</span>

View File

@@ -4,6 +4,7 @@ import {
CheckboxIcon,
CheckIcon,
ChevronDownIcon,
ChevronRightIcon,
ClockIcon,
CodeIcon,
ColorWheelIcon,
@@ -12,6 +13,7 @@ import {
DividerHorizontalIcon,
DotsHorizontalIcon,
DotsVerticalIcon,
DownloadIcon,
DragHandleDots2Icon,
EyeClosedIcon,
EyeOpenIcon,
@@ -36,7 +38,7 @@ import {
TriangleRightIcon,
UpdateIcon,
} from '@radix-ui/react-icons';
import classnames from 'classnames';
import classNames from 'classnames';
import type { HTMLAttributes } from 'react';
import { memo } from 'react';
import { ReactComponent as LeftPanelHiddenIcon } from '../../assets/icons/LeftPanelHiddenIcon.svg';
@@ -49,12 +51,14 @@ const icons = {
checkbox: CheckboxIcon,
clock: ClockIcon,
chevronDown: ChevronDownIcon,
chevronRight: ChevronRightIcon,
code: CodeIcon,
colorWheel: ColorWheelIcon,
copy: CopyIcon,
dividerH: DividerHorizontalIcon,
dotsH: DotsHorizontalIcon,
dotsV: DotsVerticalIcon,
download: DownloadIcon,
drag: DragHandleDots2Icon,
eye: EyeOpenIcon,
eyeClosed: EyeClosedIcon,
@@ -95,7 +99,7 @@ export const Icon = memo(function Icon({ icon, spin, size = 'md', className }: I
const Component = icons[icon] ?? icons.question;
return (
<Component
className={classnames(
className={classNames(
className,
'text-inherit',
size === 'md' && 'h-4 w-4',

View File

@@ -1,4 +1,4 @@
import classnames from 'classnames';
import classNames from 'classnames';
import type { MouseEvent } from 'react';
import { forwardRef, useCallback } from 'react';
import { useTimedBoolean } from '../../hooks/useTimedBoolean';
@@ -45,7 +45,8 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(function IconButt
disabled={icon === 'empty'}
tabIndex={tabIndex ?? icon === 'empty' ? -1 : undefined}
onClick={handleClick}
className={classnames(
innerClassName="flex items-center justify-center"
className={classNames(
className,
'flex-shrink-0 text-gray-700 hover:text-gray-1000',
'!px-0',
@@ -60,7 +61,7 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(function IconButt
size={iconSize}
icon={confirmed ? 'check' : icon}
spin={spin}
className={classnames(
className={classNames(
iconClassName,
props.disabled && 'opacity-70',
confirmed && 'text-green-600',

View File

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

View File

@@ -1,14 +1,27 @@
import classnames from 'classnames';
import classNames from 'classnames';
import type { EditorView } from 'codemirror';
import type { HTMLAttributes, ReactNode } from 'react';
import { forwardRef, useCallback, useMemo, useState } from 'react';
import { forwardRef, useCallback, useMemo, useRef, useState } from 'react';
import type { EditorProps } from './Editor';
import { Editor } from './Editor';
import { IconButton } from './IconButton';
import { HStack, VStack } from './Stacks';
export type InputProps = Omit<HTMLAttributes<HTMLInputElement>, 'onChange' | 'onFocus'> &
Pick<EditorProps, 'contentType' | 'useTemplating' | 'autocomplete' | 'forceUpdateKey'> & {
export type InputProps = Omit<
HTMLAttributes<HTMLInputElement>,
'onChange' | 'onFocus' | 'onKeyDown'
> &
Pick<
EditorProps,
| 'contentType'
| 'useTemplating'
| 'autocomplete'
| 'forceUpdateKey'
| 'autoFocus'
| 'autoSelect'
| 'autocompleteVariables'
| 'onKeyDown'
> & {
name: string;
type?: 'text' | 'password';
label: string;
@@ -21,34 +34,33 @@ export type InputProps = Omit<HTMLAttributes<HTMLInputElement>, 'onChange' | 'on
defaultValue?: string;
leftSlot?: ReactNode;
rightSlot?: ReactNode;
size?: 'sm' | 'md';
size?: 'sm' | 'md' | 'auto';
className?: string;
placeholder?: string;
autoFocus?: boolean;
validate?: (v: string) => boolean;
require?: boolean;
};
export const Input = forwardRef<EditorView | undefined, InputProps>(function Input(
{
label,
type = 'text',
hideLabel,
className,
containerClassName,
labelClassName,
onChange,
placeholder,
size = 'md',
name,
leftSlot,
rightSlot,
defaultValue,
validate,
require,
onFocus,
onBlur,
forceUpdateKey,
hideLabel,
label,
labelClassName,
leftSlot,
name,
onBlur,
onChange,
onFocus,
placeholder,
require,
rightSlot,
size = 'md',
type = 'text',
validate,
...props
}: InputProps,
ref,
@@ -68,12 +80,9 @@ export const Input = forwardRef<EditorView | undefined, InputProps>(function Inp
}, [onBlur]);
const id = `input-${name}`;
const inputClassName = classnames(
const inputClassName = classNames(
className,
'!bg-transparent min-w-0 h-full w-full focus:outline-none placeholder:text-placeholder',
// Bump things over if the slots are occupied
leftSlot && 'pl-0.5 -ml-2',
rightSlot && 'pr-0.5 -mr-2',
'!bg-transparent min-w-0 h-auto w-full focus:outline-none placeholder:text-placeholder',
);
const isValid = useMemo(() => {
@@ -90,11 +99,26 @@ export const Input = forwardRef<EditorView | undefined, InputProps>(function Inp
[onChange],
);
const wrapperRef = useRef<HTMLDivElement>(null);
// Submit nearest form on Enter key press
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key !== 'Enter') return;
const form = wrapperRef.current?.closest('form');
if (!isValid || form == null) return;
form?.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
},
[isValid],
);
return (
<VStack className="w-full">
<VStack ref={wrapperRef} className="w-full">
<label
htmlFor={id}
className={classnames(
className={classNames(
labelClassName,
'font-semibold text-xs uppercase text-gray-700',
hideLabel && 'sr-only',
@@ -103,32 +127,44 @@ export const Input = forwardRef<EditorView | undefined, InputProps>(function Inp
{label}
</label>
<HStack
alignItems="center"
className={classnames(
alignItems="stretch"
className={classNames(
containerClassName,
'relative w-full rounded-md text-gray-900',
'border',
focused ? 'border-focus' : 'border-highlight',
!isValid && '!border-invalid',
size === 'md' && 'h-md leading-md',
size === 'sm' && 'h-sm leading-sm',
size === 'md' && 'h-md',
size === 'sm' && 'h-sm',
size === 'auto' && 'min-h-sm',
)}
>
{leftSlot}
<Editor
ref={ref}
id={id}
singleLine
type={type === 'password' && !obscured ? 'text' : type}
defaultValue={defaultValue}
forceUpdateKey={forceUpdateKey}
placeholder={placeholder}
onChange={handleChange}
className={inputClassName}
onFocus={handleFocus}
onBlur={handleBlur}
{...props}
/>
<HStack
alignItems="center"
className={classNames(
'w-full min-w-0',
leftSlot && 'pl-0.5 -ml-2',
rightSlot && 'pr-0.5 -mr-2',
)}
>
<Editor
ref={ref}
id={id}
singleLine
wrapLines={size === 'auto'}
onKeyDown={handleKeyDown}
type={type === 'password' && !obscured ? 'text' : type}
defaultValue={defaultValue}
forceUpdateKey={forceUpdateKey}
placeholder={placeholder}
onChange={handleChange}
className={inputClassName}
onFocus={handleFocus}
onBlur={handleBlur}
{...props}
/>
</HStack>
{type === 'password' && (
<IconButton
title={obscured ? `Show ${label}` : `Obscure ${label}`}

View File

@@ -1,5 +1,5 @@
import classnames from 'classnames';
import React, { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import classNames from 'classnames';
import { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { XYCoord } from 'react-dnd';
import { useDrag, useDrop } from 'react-dnd';
import { v4 as uuid } from 'uuid';
@@ -10,6 +10,7 @@ import { Icon } from './Icon';
import { IconButton } from './IconButton';
import type { InputProps } from './Input';
import { Input } from './Input';
import type { EditorView } from 'codemirror';
export type PairEditorProps = {
pairs: Pair[];
@@ -20,11 +21,14 @@ export type PairEditorProps = {
valuePlaceholder?: string;
nameAutocomplete?: GenericCompletionConfig;
valueAutocomplete?: (name: string) => GenericCompletionConfig | undefined;
nameAutocompleteVariables?: boolean;
valueAutocompleteVariables?: boolean;
nameValidate?: InputProps['validate'];
valueValidate?: InputProps['validate'];
};
type Pair = {
export type Pair = {
id?: string;
enabled?: boolean;
name: string;
value: string;
@@ -36,17 +40,20 @@ type PairContainer = {
};
export const PairEditor = memo(function PairEditor({
pairs: originalPairs,
className,
forceUpdateKey,
nameAutocomplete,
valueAutocomplete,
nameAutocompleteVariables,
namePlaceholder,
valuePlaceholder,
nameValidate,
valueValidate,
className,
onChange,
pairs: originalPairs,
valueAutocomplete,
valueAutocompleteVariables,
valuePlaceholder,
valueValidate,
}: PairEditorProps) {
const [forceFocusPairId, setForceFocusPairId] = useState<string | null>(null);
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const [pairs, setPairs] = useState<PairContainer[]>(() => {
// Remove empty headers on initial render
@@ -111,14 +118,21 @@ export const PairEditor = memo(function PairEditor({
);
const handleDelete = useCallback(
(pair: PairContainer) =>
setPairsAndSave((oldPairs) => oldPairs.filter((p) => p.id !== pair.id)),
[setPairsAndSave],
(pair: PairContainer, focusPrevious: boolean) => {
if (focusPrevious) {
const index = pairs.findIndex((p) => p.id === pair.id);
const id = pairs[index - 1]?.id ?? null;
setForceFocusPairId(id);
}
return setPairsAndSave((oldPairs) => oldPairs.filter((p) => p.id !== pair.id));
},
[setPairsAndSave, setForceFocusPairId, pairs],
);
const handleFocus = useCallback(
(pair: PairContainer) =>
setPairs((pairs) => {
setForceFocusPairId(null); // Remove focus override when something focused
const isLast = pair.id === pairs[pairs.length - 1]?.id;
return isLast ? [...pairs, newPairContainer()] : pairs;
}),
@@ -134,12 +148,14 @@ export const PairEditor = memo(function PairEditor({
return (
<div
className={classnames(
className={classNames(
className,
'@container',
'pb-2 grid overflow-auto max-h-full',
// Move over the width of the drag handle
'-ml-3',
// Pad to make room for the drag divider
'pt-0.5',
)}
>
{pairs.map((p, i) => {
@@ -151,16 +167,20 @@ export const PairEditor = memo(function PairEditor({
pairContainer={p}
className="py-1"
isLast={isLast}
nameAutocompleteVariables={nameAutocompleteVariables}
valueAutocompleteVariables={valueAutocompleteVariables}
forceFocusPairId={forceFocusPairId}
forceUpdateKey={forceUpdateKey}
nameAutocomplete={nameAutocomplete}
valueAutocomplete={valueAutocomplete}
namePlaceholder={namePlaceholder}
valuePlaceholder={valuePlaceholder}
namePlaceholder={isLast ? namePlaceholder : ''}
valuePlaceholder={isLast ? valuePlaceholder : ''}
nameValidate={nameValidate}
valueValidate={valueValidate}
showDelete={!isLast}
onChange={handleChange}
onFocus={handleFocus}
onDelete={isLast ? undefined : handleDelete}
onDelete={handleDelete}
onEnd={handleEnd}
onMove={handleMove}
/>
@@ -178,16 +198,21 @@ enum ItemTypes {
type FormRowProps = {
className?: string;
pairContainer: PairContainer;
forceFocusPairId?: string | null;
showDelete?: boolean;
onMove: (id: string, side: 'above' | 'below') => void;
onEnd: (id: string) => void;
onChange: (pair: PairContainer) => void;
onDelete?: (pair: PairContainer) => void;
onDelete?: (pair: PairContainer, focusPrevious: boolean) => void;
onFocus?: (pair: PairContainer) => void;
onSubmit?: (pair: PairContainer) => void;
isLast?: boolean;
} & Pick<
PairEditorProps,
| 'nameAutocomplete'
| 'valueAutocomplete'
| 'nameAutocompleteVariables'
| 'valueAutocompleteVariables'
| 'namePlaceholder'
| 'valuePlaceholder'
| 'nameValidate'
@@ -197,23 +222,34 @@ type FormRowProps = {
const FormRow = memo(function FormRow({
className,
pairContainer,
forceFocusPairId,
forceUpdateKey,
isLast,
nameAutocomplete,
namePlaceholder,
nameAutocompleteVariables,
valueAutocompleteVariables,
nameValidate,
onChange,
onDelete,
onEnd,
onFocus,
onMove,
onEnd,
isLast,
forceUpdateKey,
nameAutocomplete,
pairContainer,
showDelete,
valueAutocomplete,
namePlaceholder,
valuePlaceholder,
nameValidate,
valueValidate,
}: FormRowProps) {
const { id } = pairContainer;
const ref = useRef<HTMLDivElement>(null);
const nameInputRef = useRef<EditorView>(null);
useEffect(() => {
if (forceFocusPairId === pairContainer.id) {
nameInputRef.current?.focus();
}
}, [forceFocusPairId, pairContainer.id]);
const handleChangeEnabled = useMemo(
() => (enabled: boolean) => onChange({ id, pair: { ...pairContainer.pair, enabled } }),
@@ -231,12 +267,15 @@ const FormRow = memo(function FormRow({
);
const handleFocus = useCallback(() => onFocus?.(pairContainer), [onFocus, pairContainer]);
const handleDelete = useCallback(() => onDelete?.(pairContainer), [onDelete, pairContainer]);
const handleDelete = useCallback(
() => onDelete?.(pairContainer, false),
[onDelete, pairContainer],
);
const [, connectDrop] = useDrop<PairContainer>(
{
accept: ItemTypes.ROW,
hover: (item, monitor) => {
hover: (_, monitor) => {
if (!ref.current) return;
const hoverBoundingRect = ref.current?.getBoundingClientRect();
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
@@ -264,7 +303,7 @@ const FormRow = memo(function FormRow({
return (
<div
ref={ref}
className={classnames(
className={classNames(
className,
'group grid grid-cols-[auto_auto_minmax(0,1fr)_auto]',
'grid-rows-1 items-center',
@@ -273,7 +312,7 @@ const FormRow = memo(function FormRow({
>
{!isLast ? (
<div
className={classnames(
className={classNames(
'py-2 h-7 w-3 flex items-center',
'justify-center opacity-0 hover:opacity-100',
)}
@@ -284,26 +323,28 @@ const FormRow = memo(function FormRow({
<span className="w-3" />
)}
<Checkbox
title={pairContainer.pair.enabled ? 'disable entry' : 'Enable item'}
disabled={isLast}
checked={isLast ? false : !!pairContainer.pair.enabled}
className={classnames('mr-2', isLast && '!opacity-disabled')}
className={classNames('mr-2', isLast && '!opacity-disabled')}
onChange={handleChangeEnabled}
/>
<div
className={classnames(
className={classNames(
'grid items-center',
'@xs:gap-2 @xs:!grid-rows-1 @xs:!grid-cols-[minmax(0,1fr)_minmax(0,1fr)]',
'gap-0.5 grid-cols-1 grid-rows-2',
)}
>
<Input
ref={nameInputRef}
hideLabel
useTemplating
size="sm"
require={!isLast && !!pairContainer.pair.enabled && !!pairContainer.pair.value}
validate={nameValidate}
useTemplating
forceUpdateKey={forceUpdateKey}
containerClassName={classnames(isLast && 'border-dashed')}
containerClassName={classNames(isLast && 'border-dashed')}
defaultValue={pairContainer.pair.name}
label="Name"
name="name"
@@ -311,11 +352,13 @@ const FormRow = memo(function FormRow({
onFocus={handleFocus}
placeholder={namePlaceholder ?? 'name'}
autocomplete={nameAutocomplete}
autocompleteVariables={nameAutocompleteVariables}
/>
<Input
hideLabel
useTemplating
size="sm"
containerClassName={classnames(isLast && 'border-dashed')}
containerClassName={classNames(isLast && 'border-dashed')}
validate={valueValidate}
forceUpdateKey={forceUpdateKey}
defaultValue={pairContainer.pair.value}
@@ -324,24 +367,26 @@ const FormRow = memo(function FormRow({
onChange={handleChangeValue}
onFocus={handleFocus}
placeholder={valuePlaceholder ?? 'value'}
useTemplating
autocomplete={valueAutocomplete?.(pairContainer.pair.name)}
autocompleteVariables={valueAutocompleteVariables}
/>
</div>
<IconButton
aria-hidden={!onDelete}
disabled={!onDelete}
aria-hidden={!showDelete}
disabled={!showDelete}
color="custom"
icon={onDelete ? 'trash' : 'empty'}
icon={showDelete ? 'trash' : 'empty'}
size="sm"
title="Delete header"
onClick={handleDelete}
className="ml-0.5 !opacity-0 group-hover:!opacity-100 focus-visible:!opacity-100"
onClick={showDelete ? handleDelete : undefined}
className="ml-0.5 group-hover:!opacity-100 focus-visible:!opacity-100"
/>
</div>
);
});
const newPairContainer = (pair?: Pair): PairContainer => {
return { pair: pair ?? { name: '', value: '', enabled: true }, id: uuid() };
const newPairContainer = (initialPair?: Pair): PairContainer => {
const id = initialPair?.id ?? uuid();
const pair = initialPair ?? { name: '', value: '', enabled: true };
return { id, pair };
};

View File

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

View File

@@ -1,4 +1,4 @@
import classnames from 'classnames';
import classNames from 'classnames';
import type { ComponentType, ForwardedRef, HTMLAttributes, ReactNode } from 'react';
import { forwardRef } from 'react';
@@ -25,7 +25,7 @@ export const HStack = forwardRef(function HStack(
return (
<BaseStack
ref={ref}
className={classnames(className, 'flex-row', space != null && gapClasses[space])}
className={classNames(className, 'flex-row', space != null && gapClasses[space])}
{...props}
>
{children}
@@ -45,7 +45,7 @@ export const VStack = forwardRef(function VStack(
return (
<BaseStack
ref={ref}
className={classnames(className, 'flex-col', space != null && gapClasses[space])}
className={classNames(className, 'flex-col', space != null && gapClasses[space])}
{...props}
>
{children}
@@ -56,8 +56,8 @@ export const VStack = forwardRef(function VStack(
type BaseStackProps = HTMLAttributes<HTMLElement> & {
as?: ComponentType | 'ul' | 'form';
space?: keyof typeof gapClasses;
alignItems?: 'start' | 'center';
justifyContent?: 'start' | 'center' | 'end';
alignItems?: 'start' | 'center' | 'stretch';
justifyContent?: 'start' | 'center' | 'end' | 'between';
};
const BaseStack = forwardRef(function BaseStack(
@@ -69,14 +69,16 @@ const BaseStack = forwardRef(function BaseStack(
return (
<Component
ref={ref}
className={classnames(
className={classNames(
className,
'flex',
alignItems === 'center' && 'items-center',
alignItems === 'start' && 'items-start',
alignItems === 'stretch' && 'items-stretch',
justifyContent === 'start' && 'justify-start',
justifyContent === 'center' && 'justify-center',
justifyContent === 'end' && 'justify-end',
justifyContent === 'between' && 'justify-between',
)}
{...props}
>

View File

@@ -1,4 +1,4 @@
import classnames from 'classnames';
import classNames from 'classnames';
import type { HttpResponse } from '../../lib/models';
interface Props {
@@ -8,11 +8,11 @@ interface Props {
}
export function StatusTag({ response, className, showReason }: Props) {
const { status, error } = response;
const label = error ? 'ERR' : status;
const { status } = response;
const label = status < 100 ? 'ERR' : status;
return (
<span
className={classnames(
className={classNames(
className,
'font-mono',
status >= 0 && status < 100 && 'text-red-600',

View File

@@ -1,4 +1,4 @@
import classnames from 'classnames';
import classNames from 'classnames';
import type { ReactNode } from 'react';
import { memo, useCallback, useEffect, useRef } from 'react';
import { Button } from '../Button';
@@ -67,11 +67,11 @@ export function Tabs({
return (
<div
ref={ref}
className={classnames(className, 'h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1')}
className={classNames(className, 'h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1')}
>
<div
aria-label={label}
className={classnames(
className={classNames(
tabListClassName,
'flex items-center overflow-x-auto overflow-y-visible hide-scrollbars mt-1 mb-2',
// Give space for button focus states within overflow boundary.
@@ -81,7 +81,7 @@ export function Tabs({
<HStack space={2} className="flex-shrink-0">
{tabs.map((t) => {
const isActive = t.value === value;
const btnClassName = classnames(
const btnClassName = classNames(
isActive ? '' : 'text-gray-600 hover:text-gray-800',
'!px-2 ml-[1px]',
);
@@ -102,14 +102,16 @@ export function Tabs({
size="sm"
onClick={isActive ? undefined : () => handleTabChange(t.value)}
className={btnClassName}
rightSlot={
<Icon
icon="triangleDown"
className={classNames('-mr-1.5', isActive ? 'opacity-100' : 'opacity-20')}
/>
}
>
{option && 'shortLabel' in option
? option.shortLabel
: option?.label ?? 'Unknown'}
<Icon
icon="triangleDown"
className={classnames('-mr-1.5', isActive ? 'opacity-100' : 'opacity-20')}
/>
</Button>
</RadioDropdown>
);
@@ -149,7 +151,7 @@ export const TabContent = memo(function TabContent({
<div
tabIndex={-1}
data-tab={value}
className={classnames(className, 'tab-content', 'hidden w-full h-full')}
className={classNames(className, 'tab-content', 'hidden w-full h-full')}
>
{children}
</div>

View File

@@ -1,4 +1,4 @@
import classnames from 'classnames';
import classNames from 'classnames';
import type { ReactNode } from 'react';
interface Props {
@@ -10,7 +10,7 @@ export function WindowDragRegion({ className, ...props }: Props) {
return (
<div
data-tauri-drag-region
className={classnames(className, 'w-full flex-shrink-0')}
className={classNames(className, 'w-full flex-shrink-0')}
{...props}
/>
);

View File

@@ -1,4 +1,4 @@
import classnames from 'classnames';
import classNames from 'classnames';
import Papa from 'papaparse';
import { useMemo } from 'react';
import { useResponseBodyText } from '../../hooks/useResponseBodyText';
@@ -21,10 +21,10 @@ export function CsvViewer({ response, className }: Props) {
return (
<div className="overflow-auto h-full">
<table className={classnames(className, 'text-sm')}>
<table className={classNames(className, 'text-sm')}>
<tbody>
{parsed.data.map((row, i) => (
<tr key={i} className={classnames('border-l border-t', i > 0 && 'border-b')}>
<tr key={i} className={classNames('border-l border-t', i > 0 && 'border-b')}>
{row.map((col, j) => (
<td key={j} className="border-r px-1.5">
{col}

View File

@@ -1,5 +1,5 @@
import { convertFileSrc } from '@tauri-apps/api/tauri';
import classnames from 'classnames';
import classNames from 'classnames';
import type { HttpResponse } from '../../lib/models';
interface Props {
@@ -17,7 +17,7 @@ export function ImageViewer({ response, className }: Props) {
<img
src={src}
alt="Response preview"
className={classnames(className, 'max-w-full max-h-full')}
className={classNames(className, 'max-w-full max-h-full')}
/>
);
}

View File

@@ -1,3 +1,4 @@
import { w } from '@tauri-apps/api/clipboard-79413165';
import { useResponseBodyText } from '../../hooks/useResponseBodyText';
import { useResponseContentType } from '../../hooks/useResponseContentType';
import { tryFormatJson } from '../../lib/formatters';

View File

@@ -3,7 +3,7 @@ import { useCallback, useState } from 'react';
import { Button } from '../components/core/Button';
import type { InputProps } from '../components/core/Input';
import { Input } from '../components/core/Input';
import { HStack, VStack } from '../components/core/Stacks';
import { HStack } from '../components/core/Stacks';
export interface PromptProps {
onHide: () => void;
@@ -25,24 +25,27 @@ export function Prompt({ onHide, label, name, defaultValue, onResult }: PromptPr
);
return (
<form onSubmit={handleSubmit}>
<VStack space={6}>
<Input
hideLabel
label={label}
name={name}
defaultValue={defaultValue}
onChange={setValue}
/>
<HStack space={2} justifyContent="end">
<Button className="focus" color="gray" onClick={onHide}>
Cancel
</Button>
<Button type="submit" className="focus" color="primary">
Save
</Button>
</HStack>
</VStack>
<form
className="grid grid-rows-[auto_auto] grid-cols-[minmax(0,1fr)] gap-6"
onSubmit={handleSubmit}
>
<Input
hideLabel
require
autoSelect
label={label}
name={name}
defaultValue={defaultValue}
onChange={setValue}
/>
<HStack space={2} justifyContent="end">
<Button className="focus" color="gray" onClick={onHide}>
Cancel
</Button>
<Button type="submit" className="focus" color="primary">
Save
</Button>
</HStack>
</form>
);
}

View File

@@ -0,0 +1,15 @@
import { useCallback, useMemo } from 'react';
import type { Environment } from '../lib/models';
import { useActiveEnvironmentId } from './useActiveEnvironmentId';
import { useEnvironments } from './useEnvironments';
export function useActiveEnvironment(): Environment | null {
const id = useActiveEnvironmentId();
const environments = useEnvironments();
const environment = useMemo(
() => environments.find((w) => w.id === id) ?? null,
[environments, id],
);
return environment;
}

View File

@@ -0,0 +1,12 @@
import { useCallback } from 'react';
import { useParams, useSearchParams } from 'react-router-dom';
import type { RouteParamsRequest } from './useAppRoutes';
export function useActiveEnvironmentId(): string | null {
const { environmentId } = useParams<RouteParamsRequest>();
if (environmentId == null || environmentId === '__default__') {
return null;
}
return environmentId;
}

View File

@@ -1,8 +1,12 @@
import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
import { useActiveRequestId } from './useActiveRequestId';
import type { Environment } from '../lib/models';
import { useCallback } from 'react';
export type RouteParamsWorkspace = {
workspaceId: string;
environmentId?: string;
};
export type RouteParamsRequest = RouteParamsWorkspace & {
@@ -13,35 +17,64 @@ export const routePaths = {
workspaces() {
return '/workspaces';
},
workspace({ workspaceId } = { workspaceId: ':workspaceId' } as RouteParamsWorkspace) {
return `/workspaces/${workspaceId}`;
workspace(
{ workspaceId, environmentId } = {
workspaceId: ':workspaceId',
environmentId: ':environmentId',
} as RouteParamsWorkspace,
) {
return `/workspaces/${workspaceId}/environments/${environmentId ?? '__default__'}`;
},
request(
{ workspaceId, requestId } = {
{ workspaceId, environmentId, requestId } = {
workspaceId: ':workspaceId',
environmentId: ':environmentId',
requestId: ':requestId',
} as RouteParamsRequest,
) {
return `${this.workspace({ workspaceId })}/requests/${requestId}`;
return `${this.workspace({ workspaceId, environmentId })}/requests/${requestId}`;
},
};
export function useAppRoutes() {
const navigate = useNavigate();
return useMemo(
() => ({
navigate<T extends keyof typeof routePaths>(
path: T,
...params: Parameters<(typeof routePaths)[T]>
) {
// Not sure how to make TS work here, but it's good from the
// outside caller perspective.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const resolvedPath = routePaths[path](...(params as any));
navigate(resolvedPath);
},
paths: routePaths,
}),
[navigate],
const workspaceId = useActiveWorkspaceId();
const requestId = useActiveRequestId();
const nav = useNavigate();
const navigate = useCallback(<T extends keyof typeof routePaths>(
path: T,
...params: Parameters<(typeof routePaths)[T]>
) => {
// Not sure how to make TS work here, but it's good from the
// outside caller perspective.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const resolvedPath = routePaths[path](...(params as any));
nav(resolvedPath);
}, [nav]);
const setEnvironment = useCallback(
(environment: Environment | null) => {
if (workspaceId == null) {
navigate('workspaces');
} else if (requestId == null) {
navigate('workspace', {
workspaceId: workspaceId,
environmentId: environment == null ? undefined : environment.id,
});
} else {
navigate('request', {
workspaceId,
environmentId: environment == null ? undefined : environment.id,
requestId,
});
}
},
[navigate, workspaceId, requestId],
);
return {
paths: routePaths,
navigate,
setEnvironment,
};
}

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