Initial "plugin" system with importer (#7)

This commit is contained in:
Gregory Schier
2023-11-02 18:08:43 -07:00
committed by GitHub
parent 98a4834d4f
commit e0b0e3d781
27 changed files with 972 additions and 452 deletions

View File

@@ -5,3 +5,7 @@ sqlx-prepare:
dev:
npm run tauri-dev
build:
./node_modules/.bin/tauri build

Binary file not shown.

View File

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

View File

@@ -1,7 +1,7 @@
import { hello } from './hello.js';
import { greet } from './greet.js';
export function entrypoint() {
hello();
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,100 @@
{
"_type": "export",
"__export_format": 4,
"__export_date": "2023-11-01T23:41:02.844Z",
"__export_source": "insomnia.desktop.app:v8.3.0",
"resources": [
{
"_id": "req_c8ae891b0fe549a4a530a75da59b6e34",
"parentId": "wrk_ea69a78d6a0540f583d2ec80666a1724",
"modified": 1698767088880,
"created": 1698767077168,
"url": "https://schier.co",
"name": "My Request",
"description": "",
"method": "GET",
"body": {},
"parameters": [],
"headers": [{ "name": "User-Agent", "value": "insomnia/8.3.0" }],
"authentication": {},
"metaSortKey": -1698767077168,
"isPrivate": false,
"settingStoreCookies": true,
"settingSendCookies": true,
"settingDisableRenderRequestBody": false,
"settingEncodeUrl": true,
"settingRebuildPath": true,
"settingFollowRedirects": "global",
"_type": "request"
},
{
"_id": "wrk_ea69a78d6a0540f583d2ec80666a1724",
"parentId": null,
"modified": 1698767073768,
"created": 1698767068649,
"name": "Hello World",
"description": "",
"scope": "collection",
"_type": "workspace"
},
{
"_id": "env_90b3abd7ed857fd535396167018da33932100672",
"parentId": "wrk_ea69a78d6a0540f583d2ec80666a1724",
"modified": 1698881852559,
"created": 1698767068650,
"name": "Base Environment",
"data": { "base": true },
"dataPropertyOrder": { "&": ["base"] },
"color": null,
"isPrivate": false,
"metaSortKey": 1698767068650,
"_type": "environment"
},
{
"_id": "jar_90b3abd7ed857fd535396167018da33932100672",
"parentId": "wrk_ea69a78d6a0540f583d2ec80666a1724",
"modified": 1698767090390,
"created": 1698767068651,
"name": "Default Jar",
"cookies": [
{
"key": "_gorilla_csrf",
"value": "MTY5ODc2NzA5MHxJa1Z1U0RCVVMzcDJhbEJFWkd0Q09WVkllbXBMVlhSd1VtaGFkVlpsVVhobVNVNDVTV2hDWmpFd1JtTTlJZ289fPkab2rsnQwWmJi-pCbg5Wz4O_6csc29ZcYOdB0tOLtD",
"expires": "2023-11-07T15:44:50.000Z",
"maxAge": 604800,
"domain": "schier.co",
"path": "/",
"httpOnly": true,
"hostOnly": true,
"creation": "2023-10-31T15:44:50.390Z",
"lastAccessed": "2023-10-31T15:44:50.390Z",
"sameSite": "lax",
"id": "672286917061701"
}
],
"_type": "cookie_jar"
},
{
"_id": "env_d04deba50c2f44b0b9bd01c53efebff4",
"parentId": "env_90b3abd7ed857fd535396167018da33932100672",
"modified": 1698882026143,
"created": 1698881855600,
"name": "Sub Environment",
"data": {
"string": "string",
"bool": true,
"number": 123,
"object": { "foo": "bar" },
"array": [1, 2, 3]
},
"dataPropertyOrder": {
"&": ["string", "bool", "number", "object", "array"],
"&~|object": ["foo"]
},
"color": null,
"isPrivate": false,
"metaSortKey": 1698881855600,
"_type": "environment"
}
]
}

View File

@@ -0,0 +1,23 @@
/**
* Import an Insomnia environment object.
* @param {Object} e - The environment object to import.
*/
export function importEnvironment(e) {
if (e.parentId.startsWith('env_')) {
return null;
}
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: e.parentId,
model: 'environment',
name: e.name,
variables: Object.entries(e.data).map(([name, value]) => ({
enabled: true,
name,
value: `${value}`,
})),
};
}

View File

@@ -0,0 +1,28 @@
/**
* Import an Insomnia request object.
* @param {Object} r - The request object to import.
* @param {number} sortPriority - The sort priority to use for the request.
*/
export function importRequest(r, sortPriority = 0) {
console.log('IMPORTING REQUEST', r._id, r.name, JSON.stringify(r, null, 2));
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: r.parentId,
model: 'http_request',
sortPriority,
name: r.name,
url: r.url,
body: null, // TODO: Import body
bodyType: null,
authentication: {}, // TODO: Import authentication
authenticationType: null,
method: r.method,
headers: (r.headers ?? []).map(({ name, value, disabled }) => ({
enabled: !disabled,
name,
value,
})),
};
}

View File

@@ -0,0 +1,14 @@
/**
* Import an Insomnia workspace object.
* @param {Object} w - The workspace object to import.
*/
export function importWorkspace(w) {
console.log('IMPORTING Workpace', 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,
};
}

View File

@@ -0,0 +1,50 @@
import { importEnvironment } from './importers/environment.js';
import { importRequest } from './importers/request.js';
import { importWorkspace } from './importers/workspace.js';
const TYPES = {
workspace: 'workspace',
request: 'request',
environment: 'environment',
};
export function pluginHookImport(contents) {
const parsed = JSON.parse(contents);
if (!isObject(parsed)) {
return;
}
const { _type, __export_format } = parsed;
if (_type !== 'export' || __export_format !== 4 || !Array.isArray(parsed.resources)) {
return;
}
const resources = {
workspaces: [],
requests: [],
environments: [],
};
for (const v of parsed.resources) {
if (v._type === TYPES.workspace) {
resources.workspaces.push(importWorkspace(v));
} else if (v._type === TYPES.environment) {
resources.environments.push(importEnvironment(v));
} else if (v._type === TYPES.request) {
resources.requests.push(importRequest(v));
} else {
console.log('UNKNOWN TYPE', v._type, JSON.stringify(v, null, 2));
}
}
// 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;
}
function isObject(obj) {
return Object.prototype.toString.call(obj) === '[object Object]';
}

68
src-tauri/Cargo.lock generated
View File

@@ -133,6 +133,17 @@ dependencies = [
"critical-section",
]
[[package]]
name = "atty"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8"
dependencies = [
"hermit-abi 0.1.19",
"libc",
"winapi",
]
[[package]]
name = "autocfg"
version = "1.1.0"
@@ -509,6 +520,30 @@ dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "clap"
version = "3.2.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ea181bf566f71cb9a5d17a59e1871af638180a18fb0035c92ae62b705207123"
dependencies = [
"atty",
"bitflags 1.3.2",
"clap_lex",
"indexmap 1.9.3",
"strsim",
"termcolor",
"textwrap",
]
[[package]]
name = "clap_lex"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2850f2f5a82cbf437dd5af4d49848fbdfc27c157c3d010345776f952765261c5"
dependencies = [
"os_str_bytes",
]
[[package]]
name = "cobs"
version = "0.2.3"
@@ -1577,6 +1612,15 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "hermit-abi"
version = "0.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33"
dependencies = [
"libc",
]
[[package]]
name = "hermit-abi"
version = "0.3.3"
@@ -2392,7 +2436,7 @@ version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43"
dependencies = [
"hermit-abi",
"hermit-abi 0.3.3",
"libc",
]
@@ -2561,6 +2605,12 @@ dependencies = [
"winapi",
]
[[package]]
name = "os_str_bytes"
version = "6.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2355d85b9a3786f481747ced0e0ff2ba35213a1f9bd406ed906554d7af805a1"
[[package]]
name = "overload"
version = "0.1.1"
@@ -4012,6 +4062,7 @@ dependencies = [
"anyhow",
"base64 0.21.5",
"bytes",
"clap",
"cocoa 0.24.1",
"dirs-next",
"embed_plist",
@@ -4238,6 +4289,21 @@ dependencies = [
"utf-8",
]
[[package]]
name = "termcolor"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6093bad37da69aab9d123a8091e4be0aa4a03e4d601ec641c327398315f62b64"
dependencies = [
"winapi-util",
]
[[package]]
name = "textwrap"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "222a222a5bfe1bba4a77b45ec488a741b3cb8872e5e499451fd7d0129c9c7c3d"
[[package]]
name = "thin-slice"
version = "0.1.1"

View File

@@ -29,7 +29,19 @@ 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 = ["config-toml", "devtools", "fs-read-file", "os-all", "protocol-asset", "shell-open", "system-tray", "updater", "window-start-dragging"] }
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"

View File

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

View File

@@ -1,3 +0,0 @@
export function hello() {
sayHello('Plugin');
}

View File

@@ -160,16 +160,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 workspace_id = ?\n ORDER BY created_at DESC\n "
},
"3ec4710d28a7f38608c96798d971217ac97788bcb639089d0c5750c0d339bc9a": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 3
}
},
"query": "\n UPDATE environments\n SET (name, variables, updated_at) = (?, ?, CURRENT_TIMESTAMP)\n WHERE id = ?;\n "
},
"448a1d1f1866ab42c0f81fcf8eb2930bf21dfdd43ca4831bc1a198cf45ac3732": {
"describe": {
"columns": [],
@@ -180,6 +170,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": [
@@ -282,6 +326,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": [],
@@ -452,16 +506,6 @@
},
"query": "\n DELETE FROM workspaces\n WHERE id = ?\n "
},
"86e32d6a6fadf35436f19b577a659c203a8d143cb3a8d6122951c5bf54a0888d": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 4
}
},
"query": "\n INSERT INTO environments (id, workspace_id, name, variables)\n VALUES (?, ?, ?, ?)\n "
},
"8947a2a90478277c42fe9b06bc1fa98197642a4d281a3dbc101be2c9c1fec36c": {
"describe": {
"columns": [],
@@ -648,102 +692,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": [
@@ -850,24 +798,68 @@
},
"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": {
"dbe457087a7bccbca4c1d673aa8e547df04530a7f860a6ccd4e20126a7cdfa4f": {
"describe": {
"columns": [],
"nullable": [],
"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": "Null"
}
],
"nullable": [
false,
false,
false,
false,
false,
false,
false
],
"parameters": {
"Right": 2
"Right": 1
}
},
"query": "\n UPDATE workspaces SET (name, updated_at) =\n (?, CURRENT_TIMESTAMP) 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 "
},
"f116d8cf9aad828135bb8c3a4c8b8e6b857ae13303989e9133a33b2d1cf20e96": {
"dcc2f405f8e29d0599d86bcde509187e9cc5fc647067eaa5c738cb24e2f081e5": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 3
"Right": 4
}
},
"query": "\n INSERT INTO workspaces (id, name, description)\n VALUES (?, ?, ?)\n "
"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 "
}
}

View File

@@ -21,6 +21,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;
#[cfg(target_os = "macos")]
use tauri::TitleBarStyle;
use tauri::{AppHandle, Menu, MenuItem, RunEvent, State, Submenu, Window, WindowUrl, Wry};
@@ -30,9 +31,9 @@ use tokio::sync::Mutex;
use window_ext::TrafficLightWindowExt;
mod models;
mod plugin;
mod render;
mod window_ext;
mod plugin;
#[derive(serde::Serialize)]
pub struct CustomResponse {
@@ -72,8 +73,14 @@ async fn send_ephemeral_request(
let pool = &*db_instance.lock().await;
let response = models::HttpResponse::default();
let environment_id2 = environment_id.unwrap_or("n/a").to_string();
return actually_send_ephemeral_request(request, &response, &environment_id2, &app_handle, pool)
.await;
return actually_send_ephemeral_request(
request,
&response,
&environment_id2,
&app_handle,
pool,
)
.await;
}
async fn actually_send_ephemeral_request(
@@ -248,6 +255,25 @@ 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>,
workspace_id: Option<&str>,
) -> Result<plugin::ImportedResources, String> {
let pool = &*db_instance.lock().await;
let workspace_id2 = workspace_id.unwrap_or_default();
let imported = plugin::run_plugin_import(
&window.app_handle(),
pool,
"insomnia-importer",
file_paths.first().unwrap(),
workspace_id2,
)
.await;
Ok(imported)
}
#[tauri::command]
async fn send_request(
@@ -331,9 +357,15 @@ 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)
}
@@ -347,9 +379,17 @@ async fn create_environment(
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<models::Environment, String> {
let pool = &*db_instance.lock().await;
let created_environment = models::create_environment(workspace_id, name, variables, pool)
.await
.expect("Failed to create environment");
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)
}
@@ -363,20 +403,15 @@ async fn create_request(
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(),
sort_priority,
..Default::default()
},
)
.await
.expect("Failed to create request");
@@ -405,7 +440,7 @@ 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");
@@ -420,14 +455,9 @@ async fn update_environment(
) -> Result<models::Environment, String> {
let pool = &*db_instance.lock().await;
let updated_environment = models::update_environment(
environment.id.as_str(),
environment.name.as_str(),
environment.variables.0,
pool,
)
.await
.expect("Failed to update request");
let updated_environment = models::upsert_environment(pool, environment)
.await
.expect("Failed to update environment");
emit_and_return(&window, "updated_model", updated_environment)
}
@@ -439,35 +469,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)
}
@@ -598,10 +602,15 @@ async fn list_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)
@@ -641,6 +650,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())
@@ -648,12 +658,44 @@ 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);
// 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,
"wk_WN8Nrm2Awm",
)
.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(())
})
})
@@ -671,6 +713,7 @@ fn main() {
get_environment,
get_request,
get_workspace,
import_data,
list_environments,
list_requests,
list_responses,
@@ -690,7 +733,6 @@ fn main() {
let w = create_window(app_handle, None);
w.restore_state(StateFlags::all())
.expect("Failed to restore window state");
plugin::test_plugins(&app_handle);
}
// ExitRequested { api, .. } => {

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,
@@ -30,35 +31,44 @@ pub struct Environment {
pub variables: Json<Vec<EnvironmentVariable>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
fn default_enabled() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct EnvironmentVariable {
#[serde(default)]
#[serde(default = "default_enabled")]
pub enabled: bool,
pub name: String,
pub value: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct HttpRequestHeader {
#[serde(default)]
#[serde(default = "default_enabled")]
pub enabled: bool,
pub name: String,
pub value: String,
}
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
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 created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
pub id: String,
pub workspace_id: String,
pub model: String,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
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>,
@@ -67,15 +77,15 @@ pub struct HttpRequest {
pub headers: Json<Vec<HttpRequestHeader>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[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,
@@ -94,8 +104,8 @@ pub struct HttpResponse {
pub headers: Json<Vec<HttpResponseHeader>>,
}
#[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 KeyValue {
pub model: String,
pub created_at: NaiveDateTime,
@@ -153,7 +163,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
"#,
)
@@ -165,7 +176,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,
@@ -193,27 +205,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>,
@@ -232,30 +223,6 @@ pub async fn find_environments(
.await
}
pub async fn create_environment(
workspace_id: &str,
name: &str,
variables: Vec<EnvironmentVariable>,
pool: &Pool<Sqlite>,
) -> Result<Environment, sqlx::Error> {
let id = generate_id(Some("en"));
let trimmed_name = name.trim();
let variables_json = Json(variables);
sqlx::query!(
r#"
INSERT INTO environments (id, workspace_id, name, variables)
VALUES (?, ?, ?, ?)
"#,
id,
workspace_id,
trimmed_name,
variables_json,
)
.execute(pool)
.await?;
get_environment(&id, pool).await
}
pub async fn delete_environment(id: &str, pool: &Pool<Sqlite>) -> Result<Environment, sqlx::Error> {
let env = get_environment(id, pool).await?;
let _ = sqlx::query!(
@@ -271,26 +238,37 @@ pub async fn delete_environment(id: &str, pool: &Pool<Sqlite>) -> Result<Environ
Ok(env)
}
pub async fn update_environment(
id: &str,
name: &str,
variables: Vec<EnvironmentVariable>,
pub async fn upsert_environment(
pool: &Pool<Sqlite>,
environment: Environment,
) -> Result<Environment, sqlx::Error> {
let variables_json = Json(variables);
let id = match environment.id.as_str() {
"" => generate_id(Some("ev")),
_ => environment.id.to_string(),
};
let trimmed_name = environment.name.trim();
sqlx::query!(
r#"
UPDATE environments
SET (name, variables, updated_at) = (?, ?, CURRENT_TIMESTAMP)
WHERE id = ?;
INSERT INTO environments (
id,
workspace_id,
name,
variables
)
VALUES (?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET
updated_at = CURRENT_TIMESTAMP,
name = excluded.name,
variables = excluded.variables
"#,
name,
variables_json,
id,
environment.workspace_id,
trimmed_name,
environment.variables,
)
.execute(pool)
.await?;
get_environment(id, pool).await
get_environment(&id, pool).await
}
pub async fn get_environment(id: &str, pool: &Pool<Sqlite>) -> Result<Environment, sqlx::Error> {
@@ -315,60 +293,23 @@ pub async fn get_environment(id: &str, pool: &Pool<Sqlite>) -> Result<Environmen
}
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,
)
.await
let mut request = get_request(id, pool).await?.clone();
request.id = "".to_string();
upsert_request(pool, request).await
}
pub async fn upsert_request(
id: Option<&str>,
workspace_id: &str,
name: &str,
method: &str,
body: Option<&str>,
body_type: Option<String>,
authentication: HashMap<String, JsonValue>,
authentication_type: Option<String>,
url: &str,
headers: Vec<HttpRequestHeader>,
sort_priority: f64,
pool: &Pool<Sqlite>,
r: HttpRequest,
) -> 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()
}
let id = match r.id.as_str() {
"" => generate_id(Some("rq")),
_ => r.id.to_string(),
};
let headers_json = Json(headers);
let auth_json = Json(authentication);
let trimmed_name = name.trim();
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 (
@@ -398,20 +339,21 @@ pub async fn upsert_request(
sort_priority = excluded.sort_priority
"#,
id,
workspace_id,
r.workspace_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(
@@ -552,18 +494,29 @@ 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?;

View File

@@ -1,3 +1,5 @@
use std::fs;
use boa_engine::{
js_string,
module::{ModuleLoader, SimpleModuleLoader},
@@ -5,17 +7,96 @@ use boa_engine::{
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;
pub fn test_plugins(app_handle: &AppHandle) {
use crate::models::{self, Environment, 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 {
requests: Vec<HttpRequest>,
environments: Vec<Environment>,
workspaces: Vec<Workspace>,
}
pub async fn run_plugin_import(
app_handle: &AppHandle,
pool: &Pool<Sqlite>,
plugin_name: &str,
file_path: &str,
workspace_id: &str,
) -> ImportedResources {
let file = fs::read_to_string(file_path).expect("Unable to read file");
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: {}", workspace_id.is_empty());
if workspace_id.is_empty() {
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 mut e in resources.environments {
if !workspace_id.is_empty() {
e.workspace_id = workspace_id.to_string();
}
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 mut r in resources.requests {
if !workspace_id.is_empty() {
r.workspace_id = workspace_id.to_string();
}
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/hello-world")
.expect("failed to resolve plugin directory resource");
let plugin_entry_file = app_handle
.path_resolver()
.resolve_resource("plugins/hello-world/index.js")
.expect("failed to resolve plugin entry point resource");
.resolve_resource("../plugins")
.expect("failed to resolve plugin directory resource")
.join(plugin_name);
let plugin_index_file = plugin_dir.join("index.js");
println!("Plugin dir: {:?}", plugin_dir);
// Module loader for the specific plugin
let loader = &SimpleModuleLoader::new(plugin_dir).expect("failed to create module loader");
@@ -29,14 +110,14 @@ pub fn test_plugins(app_handle: &AppHandle) {
add_runtime(context);
add_globals(context);
let source = Source::from_filepath(&plugin_entry_file).expect("Error opening file");
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_entry_file, module.clone());
loader.insert(plugin_index_file, module.clone());
let _promise_result = module
.load_link_evaluate(context)
@@ -58,18 +139,22 @@ pub fn test_plugins(app_handle: &AppHandle) {
let namespace = module.namespace(context);
let entrypoint_fn = namespace
.get(js_string!("entrypoint"), 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");
// Actually call the entrypoint function
let _result = entrypoint_fn
.call(&JsValue::undefined(), &[], context)
.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<'_>) {

View File

@@ -12,6 +12,23 @@
},
"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": {
@@ -54,7 +75,7 @@
"longDescription": "The best cross-platform visual API client",
"resources": [
"migrations/*",
"plugins/*"
"../plugins/*"
],
"shortDescription": "The best API client",
"targets": [

View File

@@ -10,7 +10,6 @@ import { useDialog } from './DialogContext';
import { EnvironmentEditDialog } from './EnvironmentEditDialog';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { useCreateEnvironment } from '../hooks/useCreateEnvironment';
import { usePrompt } from '../hooks/usePrompt';
type Props = {
className?: string;
@@ -23,15 +22,14 @@ export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdo
const activeEnvironment = useActiveEnvironment();
const createEnvironment = useCreateEnvironment();
const dialog = useDialog();
const prompt = usePrompt();
const routes = useAppRoutes();
const showEnvironmentDialog = useCallback(() => {
dialog.show({
title: 'Manage Environments',
render: () => <EnvironmentEditDialog />,
render: () => <EnvironmentEditDialog initialEnvironment={activeEnvironment} />,
});
}, [dialog]);
}, [dialog, activeEnvironment]);
const items: DropdownItem[] = useMemo(
() =>

View File

@@ -1,13 +1,11 @@
import { useCreateEnvironment } from '../hooks/useCreateEnvironment';
import { useEnvironments } from '../hooks/useEnvironments';
import type { Environment } from '../lib/models';
import type { Environment, Workspace } from '../lib/models';
import { Button } from './core/Button';
import classNames from 'classnames';
import { useActiveEnvironment } from '../hooks/useActiveEnvironment';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { PairEditor } from './core/PairEditor';
import type { PairEditorProps } from './core/PairEditor';
import { useCallback, useMemo } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useUpdateEnvironment } from '../hooks/useUpdateEnvironment';
import { HStack, VStack } from './core/Stacks';
import { IconButton } from './core/IconButton';
@@ -19,12 +17,20 @@ import { usePrompt } from '../hooks/usePrompt';
import { InlineCode } from './core/InlineCode';
import { useWindowSize } from 'react-use';
import type { GenericCompletionConfig } from './core/Editor/genericCompletion';
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
import { useUpdateWorkspace } from '../hooks/useUpdateWorkspace';
export const EnvironmentEditDialog = function () {
const routes = useAppRoutes();
interface Props {
initialEnvironment: Environment | null;
}
export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
const [selectedEnvironment, setSelectedEnvironment] = useState<Environment | null>(
initialEnvironment,
);
const environments = useEnvironments();
const createEnvironment = useCreateEnvironment();
const activeEnvironment = useActiveEnvironment();
const activeWorkspace = useActiveWorkspace();
const windowSize = useWindowSize();
const showSidebar = windowSize.width > 500;
@@ -39,19 +45,34 @@ export const EnvironmentEditDialog = function () {
{showSidebar && (
<aside className="grid grid-rows-[minmax(0,1fr)_auto] gap-y-0.5 h-full max-w-[250px] pr-4 border-r border-gray-100">
<div className="min-w-0 h-full w-full overflow-y-scroll">
<Button
size="xs"
color="custom"
justify="start"
className={classNames(
'w-full',
'text-gray-600 hocus:text-gray-800',
selectedEnvironment == null && 'bg-highlightSecondary !text-gray-900',
)}
onClick={() => {
setSelectedEnvironment(null);
}}
>
Base Environment
</Button>
{environments.map((e) => (
<Button
key={e.id}
justify="start"
size="xs"
color="custom"
className={classNames(
'w-full',
'text-gray-600 hocus:text-gray-800',
activeEnvironment?.id === e.id && 'bg-highlightSecondary !text-gray-900',
selectedEnvironment?.id === e.id && 'bg-highlightSecondary !text-gray-900',
)}
justify="start"
key={e.id}
onClick={() => {
routes.setEnvironment(e);
setSelectedEnvironment(e);
}}
>
{e.name}
@@ -68,8 +89,8 @@ export const EnvironmentEditDialog = function () {
</Button>
</aside>
)}
{activeEnvironment != null ? (
<EnvironmentEditor environment={activeEnvironment} />
{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
@@ -79,57 +100,72 @@ export const EnvironmentEditDialog = function () {
);
};
const EnvironmentEditor = function ({ environment }: { environment: Environment }) {
const EnvironmentEditor = function ({
environment,
workspace,
}: {
environment: Environment | null;
workspace: Workspace;
}) {
const environments = useEnvironments();
const updateEnvironment = useUpdateEnvironment(environment.id);
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) => {
updateEnvironment.mutate({ variables });
if (environment != null) {
updateEnvironment.mutate({ variables });
} else {
updateWorkspace.mutate({ variables });
}
},
[updateEnvironment],
[updateWorkspace, updateEnvironment, environment],
);
const nameAutocomplete = useMemo<GenericCompletionConfig>(() => {
const allVariableNames = environments.flatMap((e) => e.variables.map((v) => v.name));
// Filter out empty strings and variables that already exist in the active environment
const variableNames = allVariableNames.filter(
(name) => name != '' && !environment.variables.find((v) => v.name === name),
(name) => name != '' && !variables.find((v) => v.name === name),
);
return { options: variableNames.map((name) => ({ label: name, type: 'constant' })) };
}, [environments, environment.variables]);
}, [environments, variables]);
const prompt = usePrompt();
const items = useMemo<DropdownItem[]>(
() => [
{
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, environment.name, prompt],
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) => {
@@ -141,10 +177,12 @@ const EnvironmentEditor = function ({ environment }: { environment: Environment
return (
<VStack space={2}>
<HStack space={2} className="justify-between">
<h1 className="text-xl">{environment.name}</h1>
<Dropdown items={items}>
<IconButton icon="gear" title="Environment Actions" size="sm" className="!h-auto w-8" />
</Dropdown>
<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}
@@ -153,8 +191,8 @@ const EnvironmentEditor = function ({ environment }: { environment: Environment
valuePlaceholder="variable value"
nameValidate={validateName}
valueAutocompleteVariables={false}
forceUpdateKey={environment.id}
pairs={environment.variables}
forceUpdateKey={environment?.id ?? workspace?.id ?? 'n/a'}
pairs={variables}
onChange={handleChange}
/>
</VStack>

View File

@@ -85,9 +85,10 @@ export function RecentRequestsDropdown() {
return (
<Dropdown ref={dropdownRef} items={items}>
<Button
data-tauri-drag-region
size="sm"
className={classNames(
'flex-[2] text-center text-gray-800 text-sm truncate pointer-events-none',
'flex-[2] text-center text-gray-800 text-sm truncate pointer-events-auto',
activeRequest === null && 'text-opacity-disabled italic',
)}
>

View File

@@ -1,23 +1,30 @@
import type { HTMLAttributes, ReactElement } from 'react';
import React, { useRef } from 'react';
import { invoke } from '@tauri-apps/api';
import { useCallback, useRef } from 'react';
import { open } from '@tauri-apps/api/dialog';
import { useDeleteRequest } from '../hooks/useDeleteRequest';
import { useDuplicateRequest } from '../hooks/useDuplicateRequest';
import { useTheme } from '../hooks/useTheme';
import type { DropdownRef } from './core/Dropdown';
import type { DropdownItem, DropdownProps, DropdownRef } from './core/Dropdown';
import { Dropdown } from './core/Dropdown';
import { HotKey } from './core/HotKey';
import { Icon } from './core/Icon';
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
import { useAppRoutes } from '../hooks/useAppRoutes';
import type { Environment, HttpRequest, Workspace } from '../lib/models';
import { useDialog } from './DialogContext';
import { pluralize } from '../lib/pluralize';
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 routes = useAppRoutes();
const dialog = useDialog();
const { appearance, toggleAppearance } = useTheme();
useListenToTauriEvent('toggle_settings', () => {
@@ -29,25 +36,88 @@ export function RequestActionsDropdown({ requestId, children }: Props) {
duplicateRequest.mutate();
});
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[];
requests: HttpRequest[];
} = await invoke('import_data', {
filePaths: selected,
workspaceId: null,
});
const importedWorkspace = imported.workspaces[0];
dialog.show({
title: 'Import Complete',
description: 'Imported the following:',
size: 'dynamic',
render: () => {
const { workspaces, environments, requests } = imported;
return (
<div>
<ul className="list-disc pl-6">
<li>
{workspaces.length} {pluralize('Workspace', workspaces.length)}
</li>
<li>
{environments.length} {pluralize('Environment', environments.length)}
</li>
<li>
{requests.length} {pluralize('Request', requests.length)}
</li>
</ul>
</div>
);
},
});
if (importedWorkspace != null) {
routes.navigate('workspace', {
workspaceId: importedWorkspace.id,
environmentId: imported.environments[0]?.id,
});
}
}, [routes, dialog]);
return (
<Dropdown
ref={dropdownRef}
items={[
...(requestId != null
? ([
{
key: 'duplicate',
label: 'Duplicate',
onSelect: duplicateRequest.mutate,
leftSlot: <Icon icon="copy" />,
rightSlot: <HotKey modifier="Meta" keyName="D" />,
},
{
key: 'delete',
label: 'Delete',
onSelect: deleteRequest.mutate,
variant: 'danger',
leftSlot: <Icon icon="trash" />,
},
{ type: 'separator', label: 'Yaak Settings' },
] as DropdownItem[])
: []),
{
key: 'duplicate',
label: 'Duplicate',
onSelect: duplicateRequest.mutate,
leftSlot: <Icon icon="copy" />,
rightSlot: <HotKey modifier="Meta" keyName="D" />,
key: 'import',
label: 'Import',
onSelect: importData,
leftSlot: <Icon icon="download" />,
},
{
key: 'delete',
label: 'Delete',
onSelect: deleteRequest.mutate,
variant: 'danger',
leftSlot: <Icon icon="trash" />,
},
{ type: 'separator', label: 'Yaak Settings' },
{
key: 'appearance',
label: appearance === 'dark' ? 'Light Theme' : 'Dark Theme',

View File

@@ -31,16 +31,14 @@ export const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Prop
<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

@@ -34,6 +34,7 @@ import {
TriangleDownIcon,
TriangleLeftIcon,
TriangleRightIcon,
DownloadIcon,
UpdateIcon,
} from '@radix-ui/react-icons';
import classNames from 'classnames';
@@ -55,6 +56,7 @@ const icons = {
dividerH: DividerHorizontalIcon,
dotsH: DotsHorizontalIcon,
dotsV: DotsVerticalIcon,
download: DownloadIcon,
drag: DragHandleDots2Icon,
eye: EyeOpenIcon,
eyeClosed: EyeClosedIcon,

View File

@@ -20,6 +20,7 @@ export interface Workspace extends BaseModel {
readonly model: 'workspace';
name: string;
description: string;
variables: EnvironmentVariable[];
}
export interface EnvironmentVariable {

View File

@@ -40,9 +40,10 @@ export const appThemeVariants: AppThemeColorVariant[] = [
];
export type AppThemeLayer = 'root' | 'sidebar' | 'titlebar' | 'content' | 'above';
export type AppThemeColors = Record<AppThemeColor, string>;
export interface AppThemeLayerStyle {
colors: Record<AppThemeColor, string>;
colors: AppThemeColors;
blackPoint?: number;
whitePoint?: number;
}

View File

@@ -1,24 +1,43 @@
import type { AppTheme } from './theme';
import type { AppTheme, AppThemeColors } from './theme';
import { generateCSS, toTailwindVariable } from './theme';
export type Appearance = 'dark' | 'light';
enum Theme {
yaak = 'yaak',
catppuccin = 'catppuccin',
}
const themes: Record<Theme, AppThemeColors> = {
yaak: {
gray: '#6b5b98',
red: '#ff417b',
orange: '#fd9014',
yellow: '#e8d13f',
green: '#3fd265',
blue: '#219dff',
pink: '#ff6dff',
violet: '#b176ff',
},
catppuccin: {
gray: 'hsl(240, 23%, 47%)',
red: 'hsl(343, 91%, 74%)',
orange: 'hsl(23, 92%, 74%)',
yellow: 'hsl(41, 86%, 72%)',
green: 'hsl(115, 54%, 65%)',
blue: 'hsl(217, 92%, 65%)',
pink: 'hsl(316, 72%, 75%)',
violet: 'hsl(267, 84%, 70%)',
},
};
const darkTheme: AppTheme = {
name: 'Default Dark',
appearance: 'dark',
layers: {
root: {
blackPoint: 0.2,
colors: {
gray: '#6b5b98',
red: '#ff417b',
orange: '#fd9014',
yellow: '#e8d13f',
green: '#3fd265',
blue: '#219dff',
pink: '#ff6dff',
violet: '#b176ff',
},
colors: themes.catppuccin,
},
},
};