A bunch more small things

This commit is contained in:
Gregory Schier
2023-02-25 23:04:31 -08:00
parent 83bb18df03
commit d85c021305
25 changed files with 1749 additions and 918 deletions

1007
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@
"scripts": { "scripts": {
"build": "rsw build && tsc && vite build", "build": "rsw build && tsc && vite build",
"dev": "vite", "dev": "vite",
"lint": "eslint . --ext .ts,.tsx", "lint": "tsc && eslint . --ext .ts,.tsx",
"preview": "vite preview", "preview": "vite preview",
"tauri-dev": "concurrently -n app,rsw \"tauri dev\" \"rsw watch\"", "tauri-dev": "concurrently -n app,rsw \"tauri dev\" \"rsw watch\"",
"tauri-build": "tauri build" "tauri-build": "tauri build"
@@ -20,22 +20,14 @@
"@radix-ui/react-dropdown-menu": "^2.0.2", "@radix-ui/react-dropdown-menu": "^2.0.2",
"@radix-ui/react-icons": "^1.2.0", "@radix-ui/react-icons": "^1.2.0",
"@radix-ui/react-popover": "1.0.3", "@radix-ui/react-popover": "1.0.3",
"@tanstack/react-query": "^4.24.10",
"@tauri-apps/api": "^1.2.0", "@tauri-apps/api": "^1.2.0",
"@typescript-eslint/eslint-plugin": "^5.52.0",
"@typescript-eslint/parser": "^5.52.0",
"classnames": "^2.3.2", "classnames": "^2.3.2",
"codemirror": "^6.0.1", "codemirror": "^6.0.1",
"eslint": "^8.34.0",
"eslint-config-prettier": "^8.6.0",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-react": "^7.32.2",
"framer-motion": "^9.0.4", "framer-motion": "^9.0.4",
"prettier": "^2.8.4",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-helmet-async": "^1.3.0", "react-helmet-async": "^1.3.0",
"react-query": "^3.39.3",
"react-router-dom": "^6.8.1" "react-router-dom": "^6.8.1"
}, },
"devDependencies": { "devDependencies": {
@@ -44,7 +36,15 @@
"@types/react": "^18.0.15", "@types/react": "^18.0.15",
"@types/react-dom": "^18.0.6", "@types/react-dom": "^18.0.6",
"@vitejs/plugin-react": "^3.0.0", "@vitejs/plugin-react": "^3.0.0",
"eslint": "^8.34.0",
"eslint-config-prettier": "^8.6.0",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-react": "^7.32.2",
"@typescript-eslint/eslint-plugin": "^5.52.0",
"@typescript-eslint/parser": "^5.52.0",
"autoprefixer": "^10.4.13", "autoprefixer": "^10.4.13",
"prettier": "^2.8.4",
"concurrently": "^7.6.0", "concurrently": "^7.6.0",
"postcss": "^8.4.21", "postcss": "^8.4.21",
"tailwindcss": "^3.2.7", "tailwindcss": "^3.2.7",

View File

@@ -8,7 +8,7 @@ CREATE TABLE workspaces
description TEXT NOT NULL description TEXT NOT NULL
); );
CREATE TABLE requests CREATE TABLE http_requests
( (
id TEXT NOT NULL PRIMARY KEY, id TEXT NOT NULL PRIMARY KEY,
workspace_id TEXT NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE, workspace_id TEXT NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE,
@@ -22,17 +22,18 @@ CREATE TABLE requests
body TEXT body TEXT
); );
CREATE TABLE responses CREATE TABLE http_responses
( (
id TEXT NOT NULL PRIMARY KEY, id TEXT NOT NULL PRIMARY KEY,
request_id TEXT NOT NULL REFERENCES requests (id) ON DELETE CASCADE, request_id TEXT NOT NULL REFERENCES http_requests (id) ON DELETE CASCADE,
workspace_id TEXT NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE, workspace_id TEXT NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at DATETIME, deleted_at DATETIME,
elapsed INTEGER NOT NULL, elapsed INTEGER NOT NULL,
status INTEGER NOT NULL, status INTEGER NOT NULL,
status_reason TEXT NOT NULL, status_reason TEXT,
url TEXT NOT NULL,
body TEXT NOT NULL, body TEXT NOT NULL,
headers TEXT NOT NULL headers TEXT NOT NULL
); );

View File

@@ -1,14 +1,346 @@
{ {
"db": "SQLite", "db": "SQLite",
"74850a49fa21f4cb5f30905b8ede1fa76935c1ff7ad13c105c6de772d10ff742": { "07d1a1c7b4f3d9625a766e60fd57bb779b71dae30e5bbce34885a911a5a42428": {
"describe": { "describe": {
"columns": [], "columns": [],
"nullable": [], "nullable": [],
"parameters": { "parameters": {
"Right": 6 "Right": 1
} }
}, },
"query": "\n INSERT INTO requests (id, workspace_id, name, url, method, body, updated_at, headers)\n VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, '{}')\n ON CONFLICT (id) DO UPDATE SET\n name = excluded.name,\n method = excluded.method,\n body = excluded.body,\n url = excluded.url;\n " "query": "\n DELETE FROM http_responses\n WHERE id = ?\n "
},
"0fa36011553f7ca91113459a5cefd47f990f9b548a95e475ffd6e4b017059488": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 1
}
},
"query": "\n DELETE FROM http_responses\n WHERE request_id = ?\n "
},
"28675cd7ad73860417a667050694675e132b5e92cf6d3195a6eec218834e3a1d": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "workspace_id",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "request_id",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "updated_at",
"ordinal": 3,
"type_info": "Datetime"
},
{
"name": "deleted_at",
"ordinal": 4,
"type_info": "Datetime"
},
{
"name": "created_at",
"ordinal": 5,
"type_info": "Datetime"
},
{
"name": "status",
"ordinal": 6,
"type_info": "Int64"
},
{
"name": "status_reason",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "body",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "elapsed",
"ordinal": 9,
"type_info": "Int64"
},
{
"name": "url",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "headers!: sqlx::types::Json<Vec<HttpResponseHeader>>",
"ordinal": 11,
"type_info": "Text"
}
],
"nullable": [
false,
false,
false,
false,
true,
false,
false,
true,
false,
false,
false,
false
],
"parameters": {
"Right": 1
}
},
"query": "\n SELECT id, workspace_id, request_id, updated_at, deleted_at, created_at, status, status_reason, body, elapsed, url,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpResponseHeader>>\"\n FROM http_responses\n WHERE request_id = ?\n ORDER BY created_at DESC\n "
},
"3d2a542964d946ff9854d053b1adf04985d97a6de27b713188505c1f99c77707": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "workspace_id",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 2,
"type_info": "Datetime"
},
{
"name": "updated_at",
"ordinal": 3,
"type_info": "Datetime"
},
{
"name": "deleted_at",
"ordinal": 4,
"type_info": "Datetime"
},
{
"name": "name",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "url",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "method",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "body",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "headers!: sqlx::types::Json<Vec<HttpRequestHeader>>",
"ordinal": 9,
"type_info": "Text"
}
],
"nullable": [
false,
false,
false,
false,
true,
false,
false,
false,
true,
false
],
"parameters": {
"Right": 1
}
},
"query": "\n SELECT id, workspace_id, created_at, updated_at, deleted_at, name, url, method, body,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpRequestHeader>>\"\n FROM http_requests\n WHERE workspace_id = ?\n "
},
"3d3cc959cd3844950dde2426945bad638fa5f1a46c4681b5fe2bff60780dea62": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 7
}
},
"query": "\n INSERT INTO http_requests (id, workspace_id, name, url, method, body, headers, updated_at)\n VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)\n ON CONFLICT (id) DO UPDATE SET\n updated_at = CURRENT_TIMESTAMP,\n name = excluded.name,\n method = excluded.method,\n body = excluded.body,\n url = excluded.url\n "
},
"55eae4b20a2c313134579b0ea43bad4dc2dd313db6cd1654f783bac12602db8a": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "workspace_id",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "request_id",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "updated_at",
"ordinal": 3,
"type_info": "Datetime"
},
{
"name": "deleted_at",
"ordinal": 4,
"type_info": "Datetime"
},
{
"name": "created_at",
"ordinal": 5,
"type_info": "Datetime"
},
{
"name": "status",
"ordinal": 6,
"type_info": "Int64"
},
{
"name": "status_reason",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "body",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "elapsed",
"ordinal": 9,
"type_info": "Int64"
},
{
"name": "url",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "headers!: sqlx::types::Json<Vec<HttpResponseHeader>>",
"ordinal": 11,
"type_info": "Text"
}
],
"nullable": [
false,
false,
false,
false,
true,
false,
false,
true,
false,
false,
false,
false
],
"parameters": {
"Right": 1
}
},
"query": "\n SELECT id, workspace_id, request_id, updated_at, deleted_at, created_at, status, status_reason, body, elapsed, url,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpResponseHeader>>\"\n FROM http_responses\n WHERE id = ?\n "
},
"7ec60cbc3c9f26e8af86a21ef6b66e564f4fa518925c92308b04f882237a244e": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "workspace_id",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 2,
"type_info": "Datetime"
},
{
"name": "updated_at",
"ordinal": 3,
"type_info": "Datetime"
},
{
"name": "deleted_at",
"ordinal": 4,
"type_info": "Datetime"
},
{
"name": "name",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "url",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "method",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "body",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "headers!: sqlx::types::Json<Vec<HttpRequestHeader>>",
"ordinal": 9,
"type_info": "Text"
}
],
"nullable": [
false,
false,
false,
false,
true,
false,
false,
false,
true,
false
],
"parameters": {
"Right": 1
}
},
"query": "\n SELECT id, workspace_id, created_at, updated_at, deleted_at, name, url, method, body,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpRequestHeader>>\"\n FROM http_requests\n WHERE id = ?\n ORDER BY created_at DESC\n "
}, },
"8069c0bd326f659faca7b45b03e5317d7339a168f4cd7776d9f84304bb7ae7ac": { "8069c0bd326f659faca7b45b03e5317d7339a168f4cd7776d9f84304bb7ae7ac": {
"describe": { "describe": {
@@ -58,149 +390,15 @@
}, },
"query": "\n SELECT id, created_at, updated_at, deleted_at, name, description\n FROM workspaces\n " "query": "\n SELECT id, created_at, updated_at, deleted_at, name, description\n FROM workspaces\n "
}, },
"d461b9471bdc1fd3f85ca9351f686def07634b4906c8429eeef343b11992b445": { "e767522f92c8c49cd2e563e58737a05092daf9b1dc763bacc82a5c14d696d78e": {
"describe": { "describe": {
"columns": [ "columns": [],
{ "nullable": [],
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "workspace_id",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 2,
"type_info": "Datetime"
},
{
"name": "updated_at",
"ordinal": 3,
"type_info": "Datetime"
},
{
"name": "deleted_at",
"ordinal": 4,
"type_info": "Datetime"
},
{
"name": "name",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "url",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "method",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "body",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "headers",
"ordinal": 9,
"type_info": "Text"
}
],
"nullable": [
false,
false,
false,
false,
true,
false,
false,
false,
true,
false
],
"parameters": { "parameters": {
"Right": 1 "Right": 9
} }
}, },
"query": "\n SELECT id, workspace_id, created_at, updated_at, deleted_at, name, url, method, body, headers\n FROM requests\n WHERE id = ?\n " "query": "\n INSERT INTO http_responses (id, request_id, workspace_id, elapsed, url, status, status_reason, body, headers)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);\n "
},
"da08ebedec0942fd5c54ed1e180d7dc399629f83bfa1341c1c09a048123adac1": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "workspace_id",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 2,
"type_info": "Datetime"
},
{
"name": "updated_at",
"ordinal": 3,
"type_info": "Datetime"
},
{
"name": "deleted_at",
"ordinal": 4,
"type_info": "Datetime"
},
{
"name": "name",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "url",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "method",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "body",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "headers",
"ordinal": 9,
"type_info": "Text"
}
],
"nullable": [
false,
false,
false,
false,
true,
false,
false,
false,
true,
false
],
"parameters": {
"Right": 1
}
},
"query": "\n SELECT id, workspace_id, created_at, updated_at, deleted_at, name, url, method, body, headers\n FROM requests\n WHERE workspace_id = ?;\n "
}, },
"f116d8cf9aad828135bb8c3a4c8b8e6b857ae13303989e9133a33b2d1cf20e96": { "f116d8cf9aad828135bb8c3a4c8b8e6b857ae13303989e9133a33b2d1cf20e96": {
"describe": { "describe": {

View File

@@ -21,9 +21,10 @@ use tauri::{AppHandle, State, Wry};
use tauri::{CustomMenuItem, Manager, SystemTray, SystemTrayEvent, SystemTrayMenu, WindowEvent}; use tauri::{CustomMenuItem, Manager, SystemTray, SystemTrayEvent, SystemTrayMenu, WindowEvent};
use tokio::sync::Mutex; use tokio::sync::Mutex;
use crate::models::{create_workspace, find_workspaces, Request, Workspace};
use window_ext::WindowExt; use window_ext::WindowExt;
use crate::models::HttpRequestHeader;
mod models; mod models;
mod runtime; mod runtime;
mod window_ext; mod window_ext;
@@ -54,15 +55,17 @@ async fn load_db(db_instance: State<'_, Mutex<Pool<Sqlite>>>) -> Result<(), Stri
async fn send_request( async fn send_request(
app_handle: AppHandle<Wry>, app_handle: AppHandle<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>, db_instance: State<'_, Mutex<Pool<Sqlite>>>,
url: &str, request_id: &str,
method: &str, ) -> Result<models::HttpResponse, String> {
body: Option<&str>, let pool = &*db_instance.lock().await;
) -> Result<CustomResponse, String> { let req = models::get_request(request_id, pool)
.await
.expect("Failed to get request");
let start = std::time::Instant::now(); let start = std::time::Instant::now();
let mut abs_url = url.to_string(); let mut abs_url = req.url.to_string();
if !abs_url.starts_with("http://") && !abs_url.starts_with("https://") { if !abs_url.starts_with("http://") && !abs_url.starts_with("https://") {
abs_url = format!("http://{}", url); abs_url = format!("http://{}", req.url);
} }
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
@@ -78,24 +81,16 @@ async fn send_request(
HeaderValue::from_static("123-123-123"), HeaderValue::from_static("123-123-123"),
); );
let m = Method::from_bytes(method.to_uppercase().as_bytes()).unwrap(); let m = Method::from_bytes(req.method.to_uppercase().as_bytes()).unwrap();
let builder = client.request(m, abs_url.to_string()).headers(headers); let builder = client.request(m, abs_url.to_string()).headers(headers);
let req = match body { let sendable_req = match req.body {
Some(b) => builder.body(b.to_string()).build(), Some(b) => builder.body(b).build(),
None => builder.build(), None => builder.build(),
}; }
.expect("Failed to build request");
let req = match req { let resp = client.execute(sendable_req).await;
Ok(v) => v,
Err(e) => {
println!("Error: {}", e);
return Err(e.to_string());
}
};
let resp = client.execute(req).await;
let elapsed = start.elapsed().as_millis();
let p = app_handle let p = app_handle
.path_resolver() .path_resolver()
@@ -106,27 +101,33 @@ async fn send_request(
match resp { match resp {
Ok(v) => { Ok(v) => {
let url = v.url().to_string(); let status = v.status().as_u16() as i64;
let status = v.status().as_u16();
let status_reason = v.status().canonical_reason(); let status_reason = v.status().canonical_reason();
let method = method.to_string();
let headers = v let headers = v
.headers() .headers()
.iter() .iter()
.map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap().to_string())) .map(|(k, v)| models::HttpResponseHeader {
.collect::<HashMap<String, String>>(); name: k.as_str().to_string(),
let body = v.text().await.unwrap(); value: v.to_str().unwrap().to_string(),
let elapsed2 = start.elapsed().as_millis(); })
Ok(CustomResponse { .collect();
let url = v.url().clone();
let body = v.text().await.expect("Failed to get body");
let elapsed = start.elapsed().as_millis() as i64;
let response = models::create_response(
&req.id,
elapsed,
url.as_str(),
status, status,
status_reason, status_reason,
body, body.as_str(),
elapsed,
elapsed2,
method,
url,
headers, headers,
}) pool,
)
.await
.expect("Failed to create response");
Ok(response)
} }
Err(e) => { Err(e) => {
println!("Error: {}", e); println!("Error: {}", e);
@@ -140,27 +141,23 @@ async fn upsert_request(
id: Option<&str>, id: Option<&str>,
workspace_id: &str, workspace_id: &str,
name: &str, name: &str,
url: &str,
body: Option<&str>,
headers: Vec<HttpRequestHeader>,
method: &str,
db_instance: State<'_, Mutex<Pool<Sqlite>>>, db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<Request, String> { ) -> Result<models::HttpRequest, String> {
let pool = &*db_instance.lock().await; let pool = &*db_instance.lock().await;
models::upsert_request( models::upsert_request(id, workspace_id, name, method, body, url, headers, pool)
id, .await
workspace_id, .map_err(|e| e.to_string())
name,
"GET",
None,
"https://google.com",
pool,
)
.await
.map_err(|e| e.to_string())
} }
#[tauri::command] #[tauri::command]
async fn requests( async fn requests(
workspace_id: &str, workspace_id: &str,
db_instance: State<'_, Mutex<Pool<Sqlite>>>, db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<Vec<Request>, String> { ) -> Result<Vec<models::HttpRequest>, String> {
let pool = &*db_instance.lock().await; let pool = &*db_instance.lock().await;
models::find_requests(workspace_id, pool) models::find_requests(workspace_id, pool)
.await .await
@@ -168,13 +165,48 @@ async fn requests(
} }
#[tauri::command] #[tauri::command]
async fn workspaces(db_instance: State<'_, Mutex<Pool<Sqlite>>>) -> Result<Vec<Workspace>, String> { async fn responses(
request_id: &str,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<Vec<models::HttpResponse>, String> {
let pool = &*db_instance.lock().await; let pool = &*db_instance.lock().await;
let workspaces = find_workspaces(pool) models::find_responses(request_id, pool)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn delete_response(
id: &str,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<(), String> {
let pool = &*db_instance.lock().await;
models::delete_response(id, pool)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn delete_all_responses(
request_id: &str,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<(), String> {
let pool = &*db_instance.lock().await;
models::delete_all_responses(request_id, pool)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn workspaces(
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<Vec<models::Workspace>, String> {
let pool = &*db_instance.lock().await;
let workspaces = models::find_workspaces(pool)
.await .await
.expect("Failed to find workspaces"); .expect("Failed to find workspaces");
if workspaces.is_empty() { if workspaces.is_empty() {
let workspace = create_workspace("Default", "This is the default workspace", pool) let workspace = models::create_workspace("Default", "This is the default workspace", pool)
.await .await
.expect("Failed to create workspace"); .expect("Failed to create workspace");
Ok(vec![workspace]) Ok(vec![workspace])
@@ -205,7 +237,7 @@ fn main() {
let dir = app.path_resolver().app_data_dir().unwrap(); let dir = app.path_resolver().app_data_dir().unwrap();
create_dir_all(dir.clone()).expect("Problem creating App directory!"); create_dir_all(dir.clone()).expect("Problem creating App directory!");
let p = dir.join("db.sqlite"); let p = dir.join("db.sqlite");
let p_string = p.to_string_lossy().replace(" ", "%20"); let p_string = p.to_string_lossy().replace(' ', "%20");
let url = format!("sqlite://{}?mode=rwc", p_string); let url = format!("sqlite://{}?mode=rwc", p_string);
println!("DB PATH: {}", p_string); println!("DB PATH: {}", p_string);
tauri::async_runtime::block_on(async move { tauri::async_runtime::block_on(async move {
@@ -244,12 +276,15 @@ fn main() {
} }
}) })
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
send_request,
greet, greet,
load_db, load_db,
workspaces, workspaces,
requests, requests,
send_request,
upsert_request, upsert_request,
responses,
delete_response,
delete_all_responses,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

View File

@@ -1,8 +1,7 @@
use std::collections::HashMap;
use rand::distributions::{Alphanumeric, DistString}; use rand::distributions::{Alphanumeric, DistString};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::types::chrono::NaiveDateTime; use sqlx::types::chrono::NaiveDateTime;
use sqlx::types::Json;
use sqlx::{Pool, Sqlite}; use sqlx::{Pool, Sqlite};
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize)] #[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize)]
@@ -16,8 +15,16 @@ pub struct Workspace {
pub description: String, pub description: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HttpRequestHeader {
pub name: String,
pub value: String,
}
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize)] #[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize)]
pub struct Request { #[serde(rename_all = "camelCase")]
pub struct HttpRequest {
pub id: String, pub id: String,
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime, pub updated_at: NaiveDateTime,
@@ -27,26 +34,31 @@ pub struct Request {
pub url: String, pub url: String,
pub method: String, pub method: String,
pub body: Option<String>, pub body: Option<String>,
pub headers: String, pub headers: Json<Vec<HttpRequestHeader>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HttpResponseHeader {
pub name: String,
pub value: String,
} }
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize)] #[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize)]
pub struct Response { #[serde(rename_all = "camelCase")]
pub struct HttpResponse {
pub id: String, pub id: String,
pub workspace_id: String, pub workspace_id: String,
pub request_id: String, pub request_id: String,
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime, pub updated_at: NaiveDateTime,
pub deleted_at: Option<NaiveDateTime>, pub deleted_at: Option<NaiveDateTime>,
pub name: String,
pub status: u16,
pub status_reason: Option<&'static str>,
pub body: String,
pub url: String, pub url: String,
pub method: String, pub elapsed: i64,
pub elapsed: u128, pub status: i64,
pub elapsed2: u128, pub status_reason: Option<String>,
pub headers: HashMap<String, String>, pub body: String,
pub headers: Json<Vec<HttpResponseHeader>>,
} }
pub async fn find_workspaces(pool: &Pool<Sqlite>) -> Result<Vec<Workspace>, sqlx::Error> { pub async fn find_workspaces(pool: &Pool<Sqlite>) -> Result<Vec<Workspace>, sqlx::Error> {
@@ -104,18 +116,28 @@ pub async fn upsert_request(
method: &str, method: &str,
body: Option<&str>, body: Option<&str>,
url: &str, url: &str,
headers: Vec<HttpRequestHeader>,
pool: &Pool<Sqlite>, pool: &Pool<Sqlite>,
) -> Result<Request, sqlx::Error> { ) -> Result<HttpRequest, sqlx::Error> {
let id = generate_id("rq"); let generated_id;
let id = match id {
Some(v) => v,
None => {
generated_id = generate_id("rq");
generated_id.as_str()
}
};
let headers_json = Json(headers);
sqlx::query!( sqlx::query!(
r#" r#"
INSERT INTO requests (id, workspace_id, name, url, method, body, updated_at, headers) INSERT INTO http_requests (id, workspace_id, name, url, method, body, headers, updated_at)
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, '{}') VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT (id) DO UPDATE SET ON CONFLICT (id) DO UPDATE SET
updated_at = CURRENT_TIMESTAMP,
name = excluded.name, name = excluded.name,
method = excluded.method, method = excluded.method,
body = excluded.body, body = excluded.body,
url = excluded.url; url = excluded.url
"#, "#,
id, id,
workspace_id, workspace_id,
@@ -123,23 +145,25 @@ pub async fn upsert_request(
url, url,
method, method,
body, body,
headers_json,
) )
.execute(pool) .execute(pool)
.await .await
.expect("Failed to insert new request"); .expect("Failed to insert new request");
get_request(&id, pool).await get_request(id, pool).await
} }
pub async fn find_requests( pub async fn find_requests(
workspace_id: &str, workspace_id: &str,
pool: &Pool<Sqlite>, pool: &Pool<Sqlite>,
) -> Result<Vec<Request>, sqlx::Error> { ) -> Result<Vec<HttpRequest>, sqlx::Error> {
sqlx::query_as!( sqlx::query_as!(
Request, HttpRequest,
r#" r#"
SELECT id, workspace_id, created_at, updated_at, deleted_at, name, url, method, body, headers SELECT id, workspace_id, created_at, updated_at, deleted_at, name, url, method, body,
FROM requests headers AS "headers!: sqlx::types::Json<Vec<HttpRequestHeader>>"
WHERE workspace_id = ?; FROM http_requests
WHERE workspace_id = ?
"#, "#,
workspace_id, workspace_id,
) )
@@ -147,12 +171,66 @@ pub async fn find_requests(
.await .await
} }
pub async fn get_request(id: &str, pool: &Pool<Sqlite>) -> Result<Request, sqlx::Error> { pub async fn get_request(id: &str, pool: &Pool<Sqlite>) -> Result<HttpRequest, sqlx::Error> {
sqlx::query_as!( sqlx::query_as!(
Request, HttpRequest,
r#" r#"
SELECT id, workspace_id, created_at, updated_at, deleted_at, name, url, method, body, headers SELECT id, workspace_id, created_at, updated_at, deleted_at, name, url, method, body,
FROM requests headers AS "headers!: sqlx::types::Json<Vec<HttpRequestHeader>>"
FROM http_requests
WHERE id = ?
ORDER BY created_at DESC
"#,
id,
)
.fetch_one(pool)
.await
}
pub async fn create_response(
request_id: &str,
elapsed: i64,
url: &str,
status: i64,
status_reason: Option<&str>,
body: &str,
headers: Vec<HttpResponseHeader>,
pool: &Pool<Sqlite>,
) -> Result<HttpResponse, sqlx::Error> {
let req = get_request(request_id, pool)
.await
.expect("Failed to get request");
let id = generate_id("rp");
let headers_json = Json(headers);
sqlx::query!(
r#"
INSERT INTO http_responses (id, request_id, workspace_id, elapsed, url, status, status_reason, body, headers)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);
"#,
id,
request_id,
req.workspace_id,
elapsed,
url,
status,
status_reason,
body,
headers_json,
)
.execute(pool)
.await
.expect("Failed to insert new response");
get_response(&id, pool).await
}
pub async fn get_response(id: &str, pool: &Pool<Sqlite>) -> Result<HttpResponse, sqlx::Error> {
sqlx::query_as!(
HttpResponse,
r#"
SELECT id, workspace_id, request_id, updated_at, deleted_at, created_at, status, status_reason, body, elapsed, url,
headers AS "headers!: sqlx::types::Json<Vec<HttpResponseHeader>>"
FROM http_responses
WHERE id = ? WHERE id = ?
"#, "#,
id, id,
@@ -161,6 +239,56 @@ pub async fn get_request(id: &str, pool: &Pool<Sqlite>) -> Result<Request, sqlx:
.await .await
} }
pub async fn find_responses(
request_id: &str,
pool: &Pool<Sqlite>,
) -> Result<Vec<HttpResponse>, sqlx::Error> {
sqlx::query_as!(
HttpResponse,
r#"
SELECT id, workspace_id, request_id, updated_at, deleted_at, created_at, status, status_reason, body, elapsed, url,
headers AS "headers!: sqlx::types::Json<Vec<HttpResponseHeader>>"
FROM http_responses
WHERE request_id = ?
ORDER BY created_at DESC
"#,
request_id,
)
.fetch_all(pool)
.await
}
pub async fn delete_response(id: &str, pool: &Pool<Sqlite>) -> Result<(), sqlx::Error> {
let _ = sqlx::query!(
r#"
DELETE FROM http_responses
WHERE id = ?
"#,
id,
)
.execute(pool)
.await;
Ok(())
}
pub async fn delete_all_responses(
request_id: &str,
pool: &Pool<Sqlite>,
) -> Result<(), sqlx::Error> {
let _ = sqlx::query!(
r#"
DELETE FROM http_responses
WHERE request_id = ?
"#,
request_id,
)
.execute(pool)
.await;
Ok(())
}
fn generate_id(prefix: &str) -> String { fn generate_id(prefix: &str) -> String {
format!( format!(
"{prefix}_{}", "{prefix}_{}",

View File

@@ -1,26 +1,13 @@
import { useEffect, useState } from 'react'; import { useEffect } from 'react';
import { invoke } from '@tauri-apps/api/tauri';
import Editor from './components/Editor/Editor'; import Editor from './components/Editor/Editor';
import { HStack, VStack } from './components/Stacks'; import { HStack, VStack } from './components/Stacks';
import { Dropdown } from './components/Dropdown';
import { WindowDragRegion } from './components/WindowDragRegion'; import { WindowDragRegion } from './components/WindowDragRegion';
import { IconButton } from './components/IconButton';
import { Sidebar } from './components/Sidebar'; import { Sidebar } from './components/Sidebar';
import { UrlBar } from './components/UrlBar'; import { UrlBar } from './components/UrlBar';
import { Grid } from './components/Grid'; import { Grid } from './components/Grid';
import { motion } from 'framer-motion';
import { useRequests } from './hooks/useWorkspaces';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { useRequests, useRequestUpdate, useSendRequest } from './hooks/useRequest';
interface Response { import { ResponsePane } from './components/ResponsePane';
url: string;
method: string;
body: string;
status: string;
elapsed: number;
elapsed2: number;
headers: Record<string, string>;
}
type Params = { type Params = {
workspaceId: string; workspaceId: string;
@@ -30,57 +17,26 @@ type Params = {
function App() { function App() {
const p = useParams<Params>(); const p = useParams<Params>();
const workspaceId = p.workspaceId ?? ''; const workspaceId = p.workspaceId ?? '';
const requestId = p.requestId;
const { data: requests } = useRequests(workspaceId); const { data: requests } = useRequests(workspaceId);
const request = requests?.find((r) => r.id === requestId); const request = requests?.find((r) => r.id === p.requestId);
const [loading, setLoading] = useState<boolean>(false); const updateRequest = useRequestUpdate(request ?? null);
const [error, setError] = useState<string | null>(null); const sendRequest = useSendRequest(request ?? null);
const [response, setResponse] = useState<Response | null>(null);
const [url, setUrl] = useState<string>(request?.url ?? '');
const [body, setBody] = useState<string>(request?.body ?? '');
const [method, setMethod] = useState<{ label: string; value: string }>({
label: request?.method ?? 'GET',
value: request?.method ?? 'GET',
});
useEffect(() => { useEffect(() => {
const listener = async (e: KeyboardEvent) => { const listener = async (e: KeyboardEvent) => {
if (e.metaKey && (e.key === 'Enter' || e.key === 'r')) { if (e.metaKey && (e.key === 'Enter' || e.key === 'r')) {
await sendRequest(); await sendRequest.mutate();
} }
}; };
document.documentElement.addEventListener('keypress', listener); document.documentElement.addEventListener('keypress', listener);
return () => document.documentElement.removeEventListener('keypress', listener); return () => document.documentElement.removeEventListener('keypress', listener);
}, []); }, []);
async function sendRequest() {
setLoading(true);
setError(null);
try {
const resp = (await invoke('send_request', {
method: method.value,
url,
body: body || undefined,
})) as Response;
if (resp.body.includes('<head>')) {
resp.body = resp.body.replace(/<head>/gi, `<head><base href="${resp.url}"/>`);
}
setResponse(resp);
} catch (err) {
setError(`${err}`);
} finally {
setLoading(false);
}
}
const contentType = response?.headers['content-type']?.split(';')[0] ?? 'text/plain';
return ( return (
<> <div className="grid grid-cols-[auto_1fr] h-full text-gray-900">
<div className="grid grid-cols-[auto_1fr] h-full text-gray-900"> <Sidebar requests={requests ?? []} workspaceId={workspaceId} activeRequestId={request?.id} />
<Sidebar requests={requests ?? []} workspaceId={workspaceId} requestId={requestId} /> {request && (
<Grid cols={2}> <Grid cols={2}>
<VStack className="w-full"> <VStack className="w-full">
<HStack as={WindowDragRegion} items="center" className="pl-3 pr-1.5"> <HStack as={WindowDragRegion} items="center" className="pl-3 pr-1.5">
@@ -88,71 +44,26 @@ function App() {
</HStack> </HStack>
<VStack className="pl-3 px-1.5 py-3" space={3}> <VStack className="pl-3 px-1.5 py-3" space={3}>
<UrlBar <UrlBar
method={method} key={request.id}
url={url} method={request.method}
loading={loading} url={request.url}
onMethodChange={setMethod} loading={sendRequest.isLoading}
onUrlChange={setUrl} onMethodChange={(method) => updateRequest.mutate({ method })}
sendRequest={sendRequest} onUrlChange={(url) => updateRequest.mutate({ url })}
sendRequest={sendRequest.mutate}
/>
<Editor
key={request.id}
defaultValue={request.body}
contentType="application/json"
onChange={(body) => updateRequest.mutate({ body })}
/> />
<Editor initialValue={body} contentType="application/json" onChange={setBody} />
</VStack> </VStack>
</VStack> </VStack>
<VStack className="w-full"> <ResponsePane requestId={request.id} error={sendRequest.error} />
<HStack as={WindowDragRegion} items="center" className="pl-1.5 pr-1">
<Dropdown
items={[
{
label: 'Clear Response',
onSelect: () => setResponse(null),
disabled: !response,
},
{
label: 'Other Thing',
},
]}
>
<IconButton icon="gear" className="ml-auto" size="sm" />
</Dropdown>
</HStack>
{(response || error) && (
<motion.div
animate={{ opacity: 1 }}
initial={{ opacity: 0 }}
className="w-full h-full"
>
<VStack className="pr-3 pl-1.5 py-3" space={3}>
{error && <div className="text-white bg-red-500 px-3 py-1 rounded">{error}</div>}
{response && (
<>
<HStack
items="center"
className="italic text-gray-500 text-sm w-full pointer-events-none h-10 mb-3 flex-shrink-0"
>
{response.status}
&nbsp;&bull;&nbsp;
{response.elapsed}ms &nbsp;&bull;&nbsp;
{response.elapsed2}ms
</HStack>
{contentType.includes('html') ? (
<iframe
title="Response preview"
srcDoc={response.body}
sandbox="allow-scripts allow-same-origin"
className="h-full w-full rounded-lg"
/>
) : response?.body ? (
<Editor value={response?.body} contentType={contentType} />
) : null}
</>
)}
</VStack>
</motion.div>
)}
</VStack>
</Grid> </Grid>
</div> )}
</> </div>
); );
} }

View File

@@ -1,4 +1,10 @@
import { ButtonHTMLAttributes, ComponentPropsWithoutRef, ElementType } from 'react'; import {
ButtonHTMLAttributes,
ComponentPropsWithoutRef,
ElementType,
ForwardedRef,
forwardRef,
} from 'react';
import classnames from 'classnames'; import classnames from 'classnames';
import { Icon } from './Icon'; import { Icon } from './Icon';
@@ -11,19 +17,23 @@ export interface ButtonProps<T extends ElementType>
as?: T; as?: T;
} }
export function Button<T extends ElementType>({ export const Button = forwardRef(function Button<T extends ElementType>(
className, {
as, className,
justify = 'center', as,
children, justify = 'center',
size = 'md', children,
forDropdown, size = 'md',
color, forDropdown,
...props color,
}: ButtonProps<T> & Omit<ComponentPropsWithoutRef<T>, keyof ButtonProps<T>>) { ...props
}: ButtonProps<T> & Omit<ComponentPropsWithoutRef<T>, keyof ButtonProps<T>>,
ref: ForwardedRef<HTMLButtonElement>,
) {
const Component = as || 'button'; const Component = as || 'button';
return ( return (
<Component <Component
ref={ref}
className={classnames( className={classnames(
className, className,
'rounded-md flex items-center', 'rounded-md flex items-center',
@@ -43,4 +53,4 @@ export function Button<T extends ElementType>({
{forDropdown && <Icon icon="triangle-down" className="ml-1 -mr-1" />} {forDropdown && <Icon icon="triangle-down" className="ml-1 -mr-1" />}
</Component> </Component>
); );
} });

View File

@@ -133,39 +133,39 @@ function DropdownMenuItem({
); );
} }
type DropdownMenuCheckboxItemProps = DropdownMenu.DropdownMenuCheckboxItemProps & ItemInnerProps; // type DropdownMenuCheckboxItemProps = DropdownMenu.DropdownMenuCheckboxItemProps & ItemInnerProps;
//
// function DropdownMenuCheckboxItem({
// leftSlot,
// rightSlot,
// children,
// ...props
// }: DropdownMenuCheckboxItemProps) {
// return (
// <DropdownMenu.CheckboxItem asChild {...props}>
// <ItemInner leftSlot={leftSlot} rightSlot={rightSlot}>
// {children}
// </ItemInner>
// </DropdownMenu.CheckboxItem>
// );
// }
function DropdownMenuCheckboxItem({ // type DropdownMenuSubTriggerProps = DropdownMenu.DropdownMenuSubTriggerProps & ItemInnerProps;
leftSlot, //
rightSlot, // function DropdownMenuSubTrigger({
children, // leftSlot,
...props // rightSlot,
}: DropdownMenuCheckboxItemProps) { // children,
return ( // ...props
<DropdownMenu.CheckboxItem asChild {...props}> // }: DropdownMenuSubTriggerProps) {
<ItemInner leftSlot={leftSlot} rightSlot={rightSlot}> // return (
{children} // <DropdownMenu.SubTrigger asChild {...props}>
</ItemInner> // <ItemInner leftSlot={leftSlot} rightSlot={rightSlot}>
</DropdownMenu.CheckboxItem> // {children}
); // </ItemInner>
} // </DropdownMenu.SubTrigger>
// );
type DropdownMenuSubTriggerProps = DropdownMenu.DropdownMenuSubTriggerProps & ItemInnerProps; // }
function DropdownMenuSubTrigger({
leftSlot,
rightSlot,
children,
...props
}: DropdownMenuSubTriggerProps) {
return (
<DropdownMenu.SubTrigger asChild {...props}>
<ItemInner leftSlot={leftSlot} rightSlot={rightSlot}>
{children}
</ItemInner>
</DropdownMenu.SubTrigger>
);
}
type DropdownMenuRadioItemProps = Omit< type DropdownMenuRadioItemProps = Omit<
DropdownMenu.DropdownMenuRadioItemProps & ItemInnerProps, DropdownMenu.DropdownMenuRadioItemProps & ItemInnerProps,
@@ -189,22 +189,22 @@ function DropdownMenuRadioItem({ rightSlot, children, ...props }: DropdownMenuRa
); );
} }
const DropdownMenuSubContent = forwardRef<HTMLDivElement, DropdownMenu.DropdownMenuSubContentProps>( // const DropdownMenuSubContent = forwardRef<HTMLDivElement, DropdownMenu.DropdownMenuSubContentProps>(
function DropdownMenuSubContent( // function DropdownMenuSubContent(
{ className, ...props }: DropdownMenu.DropdownMenuSubContentProps, // { className, ...props }: DropdownMenu.DropdownMenuSubContentProps,
ref, // ref,
) { // ) {
return ( // return (
<DropdownMenu.SubContent // <DropdownMenu.SubContent
ref={ref} // ref={ref}
alignOffset={0} // alignOffset={0}
sideOffset={4} // sideOffset={4}
className={classnames(className, dropdownMenuClasses)} // className={classnames(className, dropdownMenuClasses)}
{...props} // {...props}
/> // />
); // );
}, // },
); // );
function DropdownMenuLabel({ className, children, ...props }: DropdownMenu.DropdownMenuLabelProps) { function DropdownMenuLabel({ className, children, ...props }: DropdownMenu.DropdownMenuLabelProps) {
return ( return (
@@ -216,14 +216,14 @@ function DropdownMenuLabel({ className, children, ...props }: DropdownMenu.Dropd
); );
} }
function DropdownMenuSeparator({ className, ...props }: DropdownMenu.DropdownMenuSeparatorProps) { // function DropdownMenuSeparator({ className, ...props }: DropdownMenu.DropdownMenuSeparatorProps) {
return ( // return (
<DropdownMenu.Separator // <DropdownMenu.Separator
className={classnames(className, 'h-[1px] bg-gray-400 bg-opacity-30 my-1')} // className={classnames(className, 'h-[1px] bg-gray-400 bg-opacity-30 my-1')}
{...props} // {...props}
/> // />
); // );
} // }
function DropdownMenuTrigger({ function DropdownMenuTrigger({
children, children,
@@ -236,7 +236,7 @@ function DropdownMenuTrigger({
className={classnames(className, 'focus:outline-none')} className={classnames(className, 'focus:outline-none')}
{...props} {...props}
> >
<>{children}</> {children}
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
); );
} }

View File

@@ -89,7 +89,12 @@
.cm-editor .cm-activeLineGutter, .cm-editor .cm-activeLineGutter,
.cm-editor .cm-activeLine { .cm-editor .cm-activeLine {
background-color: hsl(var(--color-gray-50)); background-color: transparent;
}
.cm-editor.cm-focused .cm-activeLineGutter,
.cm-editor.cm-focused .cm-activeLine {
background-color: hsl(var(--color-gray-100)/0.3);
} }
.cm-editor * { .cm-editor * {
@@ -101,9 +106,9 @@
} }
.cm-editor .cm-selectionBackground { .cm-editor .cm-selectionBackground {
background-color: hsl(var(--color-gray-100)); background-color: hsl(var(--color-gray-200));
} }
.cm-editor.cm-focused .cm-selectionBackground { .cm-editor.cm-focused .cm-selectionBackground {
background-color: hsl(var(--color-gray-100)); background-color: hsl(var(--color-gray-200));
} }

View File

@@ -1,14 +1,42 @@
import useCodeMirror from '../../hooks/useCodemirror';
import './Editor.css'; import './Editor.css';
import { useEffect, useMemo, useRef } from 'react';
import { EditorView } from 'codemirror';
import { baseExtensions, syntaxExtension } from './extensions';
import { EditorState } from '@codemirror/state';
interface Props { interface Props {
contentType: string; contentType: string;
initialValue?: string; defaultValue?: string | null;
value?: string;
onChange?: (value: string) => void; onChange?: (value: string) => void;
} }
export default function Editor(props: Props) { export default function Editor({ contentType, defaultValue, onChange }: Props) {
const { ref } = useCodeMirror(props); const ref = useRef<HTMLDivElement>(null);
const extensions = useMemo(() => {
const ext = syntaxExtension(contentType);
return [
...baseExtensions,
...(ext ? [ext] : []),
EditorView.updateListener.of((update) => {
if (typeof onChange === 'function') {
onChange(update.state.doc.toString());
}
}),
];
}, [contentType]);
useEffect(() => {
if (ref.current === null) return;
const view = new EditorView({
state: EditorState.create({
doc: defaultValue ?? '',
extensions: extensions,
}),
parent: ref.current,
});
return () => view?.destroy();
}, [ref.current]);
return <div ref={ref} className="cm-wrapper" />; return <div ref={ref} className="cm-wrapper" />;
} }

View File

@@ -1,9 +1,3 @@
import { useEffect, useRef, useState } from 'react';
import { EditorView } from 'codemirror';
import { javascript } from '@codemirror/lang-javascript';
import { json } from '@codemirror/lang-json';
import { html } from '@codemirror/lang-html';
import { tags } from '@lezer/highlight';
import { import {
bracketMatching, bracketMatching,
defaultHighlightStyle, defaultHighlightStyle,
@@ -35,18 +29,23 @@ import {
} from '@codemirror/autocomplete'; } from '@codemirror/autocomplete';
import { lintKeymap } from '@codemirror/lint'; import { lintKeymap } from '@codemirror/lint';
import { EditorState } from '@codemirror/state'; import { EditorState } from '@codemirror/state';
import { json } from '@codemirror/lang-json';
import { javascript } from '@codemirror/lang-javascript';
import { html } from '@codemirror/lang-html';
import { tags } from '@lezer/highlight';
const myHighlightStyle = HighlightStyle.define([ export const myHighlightStyle = HighlightStyle.define([
{ {
tag: [tags.documentMeta, tags.blockComment, tags.lineComment, tags.docComment, tags.comment], tag: [tags.documentMeta, tags.blockComment, tags.lineComment, tags.docComment, tags.comment],
color: '#757b93', color: '#757b93',
}, },
{ tag: tags.name, color: '#4699de' }, { tag: tags.name, color: '#4699de' },
{ tag: tags.variableName, color: '#31c434' }, { tag: tags.variableName, color: '#31c434' },
{ tag: tags.attributeName, color: '#b06fff' }, { tag: tags.bool, color: '#e864f6' },
{ tag: tags.attributeName, color: '#8f68ff' },
{ tag: tags.attributeValue, color: '#ff964b' }, { tag: tags.attributeValue, color: '#ff964b' },
{ tag: [tags.keyword, tags.string], color: '#e8b045' }, { tag: [tags.keyword, tags.string], color: '#e8b045' },
{ tag: tags.comment, color: '#f5d', fontStyle: 'italic' }, { tag: tags.comment, color: '#cec4cc', fontStyle: 'italic' },
]); ]);
const syntaxExtensions: Record<string, LanguageSupport> = { const syntaxExtensions: Record<string, LanguageSupport> = {
@@ -55,7 +54,11 @@ const syntaxExtensions: Record<string, LanguageSupport> = {
'text/html': html(), 'text/html': html(),
}; };
const extensions = [ export function syntaxExtension(contentType: string): LanguageSupport | undefined {
return syntaxExtensions[contentType];
}
export const baseExtensions = [
lineNumbers(), lineNumbers(),
highlightActiveLineGutter(), highlightActiveLineGutter(),
highlightSpecialChars(), highlightSpecialChars(),
@@ -94,64 +97,3 @@ const extensions = [
]), ]),
syntaxHighlighting(myHighlightStyle), syntaxHighlighting(myHighlightStyle),
]; ];
export default function useCodeMirror({
initialValue,
value,
contentType,
onChange,
}: {
initialValue?: string;
value?: string;
contentType: string;
onChange?: (value: string) => void;
}) {
const [cm, setCm] = useState<EditorView | null>(null);
const ref = useRef(null);
useEffect(() => {
if (ref.current === null) return;
const state = EditorState.create({
doc: initialValue,
extensions: getExtensions({ contentType, onChange }),
});
const view = new EditorView({
state,
parent: ref.current,
});
setCm(view);
return () => view?.destroy();
}, [ref.current]);
useEffect(() => {
if (cm === null) return;
const newState = EditorState.create({
doc: value ?? cm.state.doc,
extensions: getExtensions({ contentType, onChange }),
});
cm.setState(newState);
}, [cm, contentType, value, onChange]);
return { ref, cm };
}
function getExtensions({
contentType,
onChange,
}: {
contentType: string;
onChange?: (value: string) => void;
}) {
const ext = syntaxExtensions[contentType];
return ext
? [
...extensions,
...(onChange
? [EditorView.updateListener.of((update) => onChange(update.state.doc.toString()))]
: []),
ext,
]
: extensions;
}

View File

@@ -1,12 +1,16 @@
import { forwardRef } from 'react';
import { Icon, IconProps } from './Icon'; import { Icon, IconProps } from './Icon';
import { Button, ButtonProps } from './Button'; import { Button, ButtonProps } from './Button';
type Props = Omit<IconProps, 'size'> & ButtonProps<typeof Button>; type Props = Omit<IconProps, 'size'> & ButtonProps<typeof Button>;
export function IconButton({ icon, spin, ...props }: Props) { export const IconButton = forwardRef<HTMLButtonElement, Props>(function IconButton(
{ icon, spin, ...props }: Props,
ref,
) {
return ( return (
<Button className="group" {...props}> <Button ref={ref} className="group" {...props}>
<Icon icon={icon} spin={spin} className="text-gray-700 group-hover:text-gray-900" /> <Icon icon={icon} spin={spin} className="text-gray-700 group-hover:text-gray-900" />
</Button> </Button>
); );
} });

View File

@@ -1,4 +1,5 @@
import { Outlet } from 'react-router-dom'; import {Outlet} from 'react-router-dom';
export function Layout() { export function Layout() {
return ( return (

View File

@@ -0,0 +1,89 @@
import { useDeleteAllResponses, useDeleteResponse, useResponses } from '../hooks/useResponses';
import { motion } from 'framer-motion';
import { HStack, VStack } from './Stacks';
import Editor from './Editor/Editor';
import { useMemo } from 'react';
import { WindowDragRegion } from './WindowDragRegion';
import { Dropdown } from './Dropdown';
import { IconButton } from './IconButton';
interface Props {
requestId: string;
error: string | null;
}
export function ResponsePane({ requestId, error }: Props) {
const responses = useResponses(requestId);
const response = responses.data[0];
const deleteResponse = useDeleteResponse(response);
const deleteAllResponses = useDeleteAllResponses(response?.requestId);
const contentType = useMemo(
() =>
response?.headers.find((h) => h.name.toLowerCase() === 'content-type')?.value ?? 'text/plain',
[response],
);
const contentForIframe: string = useMemo(() => {
if (response == null) return '';
if (response.body.includes('<head>')) {
return response.body.replace(/<head>/gi, `<head><base href="${response.url}"/>`);
}
return response.body;
}, [response?.id]);
return (
<VStack className="w-full">
<HStack as={WindowDragRegion} items="center" className="pl-1.5 pr-1">
<Dropdown
items={[
{
label: 'Clear Response',
onSelect: deleteResponse.mutate,
disabled: responses.data.length === 0,
},
{
label: 'Clear All Responses',
onSelect: deleteAllResponses.mutate,
disabled: responses.data.length === 0,
},
]}
>
<IconButton icon="gear" className="ml-auto" size="sm" />
</Dropdown>
</HStack>
<motion.div animate={{ opacity: 1 }} initial={{ opacity: 0 }} className="w-full h-full">
<VStack className="pr-3 pl-1.5 py-3" space={3}>
{error && <div className="text-white bg-red-500 px-3 py-1 rounded">{error}</div>}
{response && (
<>
<HStack
items="center"
className="italic text-gray-500 text-sm w-full pointer-events-none h-10 mb-3 flex-shrink-0"
>
{response.status}
{response.statusReason && ` ${response.statusReason}`}
&nbsp;&bull;&nbsp;
{response.elapsed}ms
</HStack>
{contentType.includes('html') ? (
<iframe
title="Response preview"
srcDoc={contentForIframe}
sandbox="allow-scripts allow-same-origin"
className="h-full w-full rounded-lg"
/>
) : response?.body ? (
<Editor
key={response.body}
defaultValue={response?.body}
contentType={contentType}
/>
) : null}
</>
)}
</VStack>
</motion.div>
</VStack>
);
}

View File

@@ -5,17 +5,18 @@ import { Button } from './Button';
import useTheme from '../hooks/useTheme'; import useTheme from '../hooks/useTheme';
import { HStack, VStack } from './Stacks'; import { HStack, VStack } from './Stacks';
import { WindowDragRegion } from './WindowDragRegion'; import { WindowDragRegion } from './WindowDragRegion';
import { Request } from '../hooks/useWorkspaces'; import { HttpRequest } from '../lib/models';
import { invoke } from '@tauri-apps/api';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useRequestCreate } from '../hooks/useRequest';
interface Props extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> { interface Props extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
workspaceId: string; workspaceId: string;
requests: Request[]; requests: HttpRequest[];
requestId?: string; activeRequestId?: string;
} }
export function Sidebar({ className, requestId, workspaceId, requests, ...props }: Props) { export function Sidebar({ className, activeRequestId, workspaceId, requests, ...props }: Props) {
const createRequest = useRequestCreate(workspaceId);
const { toggleTheme } = useTheme(); const { toggleTheme } = useTheme();
return ( return (
<div <div
@@ -27,31 +28,30 @@ export function Sidebar({ className, requestId, workspaceId, requests, ...props
<IconButton <IconButton
size="sm" size="sm"
icon="camera" icon="camera"
onClick={async () => { onClick={() => createRequest.mutate({ name: 'Test Request' })}
const req = await invoke('upsert_request', {
workspaceId,
id: null,
name: 'Test Request',
});
console.log('UPSERTED', req);
}}
/> />
</HStack> </HStack>
<VStack as="ul" className="py-2" space={1}> <VStack as="ul" className="py-2" space={1}>
{requests.map((r) => ( {requests.map((r) => (
<li key={r.id} className="mx-2"> <SidebarItem key={r.id} request={r} active={r.id === activeRequestId} />
<Button
as={Link}
to={`/workspaces/${workspaceId}/requests/${r.id}`}
className={classnames('w-full', requestId === r.id && 'bg-gray-50')}
size="sm"
justify="start"
>
{r.name}
</Button>
</li>
))} ))}
</VStack> </VStack>
</div> </div>
); );
} }
function SidebarItem({ request, active }: { request: HttpRequest; active: boolean }) {
return (
<li key={request.id} className="mx-2">
<Button
as={Link}
to={`/workspaces/${request.workspaceId}/requests/${request.id}`}
className={classnames('w-full', active && 'bg-gray-50')}
size="sm"
justify="start"
>
{request.name}
</Button>
</li>
);
}

View File

@@ -7,9 +7,9 @@ import { IconButton } from './IconButton';
interface Props { interface Props {
sendRequest: () => void; sendRequest: () => void;
loading: boolean; loading: boolean;
method: { label: string; value: string }; method: string;
url: string; url: string;
onMethodChange: (method: { label: string; value: string }) => void; onMethodChange: (method: string) => void;
onUrlChange: (url: string) => void; onUrlChange: (url: string) => void;
} }
@@ -27,12 +27,12 @@ export function UrlBar({ sendRequest, loading, onMethodChange, method, onUrlChan
label="Enter URL" label="Enter URL"
className="font-mono" className="font-mono"
onChange={(e) => onUrlChange(e.currentTarget.value)} onChange={(e) => onUrlChange(e.currentTarget.value)}
value={url} defaultValue={url}
placeholder="Enter a URL..." placeholder="Enter a URL..."
leftSlot={ leftSlot={
<DropdownMenuRadio <DropdownMenuRadio
onValueChange={onMethodChange} onValueChange={(v) => onMethodChange(v.value)}
value={method.value} value={method.toUpperCase()}
items={[ items={[
{ label: 'GET', value: 'GET' }, { label: 'GET', value: 'GET' },
{ label: 'PUT', value: 'PUT' }, { label: 'PUT', value: 'PUT' },
@@ -43,8 +43,8 @@ export function UrlBar({ sendRequest, loading, onMethodChange, method, onUrlChan
{ label: 'HEAD', value: 'HEAD' }, { label: 'HEAD', value: 'HEAD' },
]} ]}
> >
<Button disabled={loading} size="sm" className="ml-1" justify="start"> <Button type="button" disabled={loading} size="sm" className="ml-1" justify="start">
{method.label} {method.toUpperCase()}
</Button> </Button>
</DropdownMenuRadio> </DropdownMenuRadio>
} }

View File

@@ -0,0 +1,64 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import { convertDates, HttpRequest } from '../lib/models';
import { responsesQueryKey } from './useResponses';
export function useRequests(workspaceId: string) {
return useQuery(['requests'], async () => {
const requests = (await invoke('requests', { workspaceId })) as HttpRequest[];
return requests.map(convertDates);
});
}
export function useRequestUpdate(request: HttpRequest | null) {
const queryClient = useQueryClient();
return useMutation<HttpRequest, unknown, Partial<HttpRequest>>({
mutationFn: async (patch) => {
if (request == null) {
throw new Error("Can't update a null request");
}
// console.error('UPDATE REQUEST', patch);
const req = await invoke('upsert_request', { ...request, ...patch });
return convertDates(req as HttpRequest);
},
onSuccess: (req) => {
queryClient.setQueryData(['requests'], (requests: HttpRequest[] = []) =>
requests.map((r) => (r.id === req.id ? req : r)),
);
},
});
}
export function useRequestCreate(workspaceId: string) {
const queryClient = useQueryClient();
return useMutation<HttpRequest, unknown, Partial<Omit<HttpRequest, 'workspaceId'>>>({
mutationFn: async (patch) => {
const req = await invoke('upsert_request', {
url: '',
method: 'GET',
name: 'New Request',
headers: [],
...patch,
workspaceId,
});
return convertDates(req as HttpRequest);
},
onSuccess: (req) => {
queryClient.setQueryData(['requests'], (requests: HttpRequest[] = []) => [...requests, req]);
},
});
}
export function useSendRequest(request: HttpRequest | null) {
const queryClient = useQueryClient();
return useMutation<void, string>({
mutationFn: async () => {
if (request == null) return;
await invoke('send_request', { requestId: request.id });
},
onSuccess: async () => {
if (request == null) return;
await queryClient.invalidateQueries(responsesQueryKey(request.id));
},
});
}

View File

@@ -0,0 +1,49 @@
import { invoke } from '@tauri-apps/api';
import { convertDates, HttpResponse } from '../lib/models';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
export function responsesQueryKey(requestId: string) {
return ['responses', { requestId }];
}
export function useResponses(requestId: string) {
return useQuery<HttpResponse[]>({
initialData: [],
queryKey: responsesQueryKey(requestId),
queryFn: async () => {
const responses = (await invoke('responses', { requestId })) as HttpResponse[];
return responses.map(convertDates);
},
});
}
export function useDeleteResponse(response?: HttpResponse) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
if (response == null) return;
await invoke('delete_response', { id: response.id });
},
onSuccess: () => {
if (response == null) return;
queryClient.setQueryData(
['responses', { requestId: response.requestId }],
(responses: HttpResponse[] = []) => responses.filter((r) => r.id !== response.id),
);
},
});
}
export function useDeleteAllResponses(requestId?: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
if (requestId == null) return;
await invoke('delete_all_responses', { requestId });
},
onSuccess: () => {
if (requestId == null) return;
queryClient.setQueryData(['responses', { requestId: requestId }], []);
},
});
}

View File

@@ -1,61 +1,10 @@
import { invoke } from '@tauri-apps/api'; import { invoke } from '@tauri-apps/api';
import { useQuery, UseQueryResult } from 'react-query'; import { convertDates, Workspace } from '../lib/models';
import { useQuery } from '@tanstack/react-query';
interface BaseModel { export function useWorkspaces() {
id: string; return useQuery(['workspaces'], async () => {
createdAt: Date;
updatedAt: Date;
deletedAt: Date | null;
}
export interface Request extends BaseModel {
name: string;
url: string;
body: string | null;
method: string;
}
export interface Workspace extends BaseModel {
name: string;
description: string;
}
export function useWorkspaces(): UseQueryResult<Workspace[]> {
return useQuery('workspaces', async () => {
const workspaces = (await invoke('workspaces')) as Workspace[]; const workspaces = (await invoke('workspaces')) as Workspace[];
return workspaces.map(convertDates); return workspaces.map(convertDates);
}); });
} }
export function useRequests(workspaceId: string): UseQueryResult<Request[]> {
return useQuery('requests', async () => {
const requests = (await invoke('requests', { workspaceId })) as Request[];
return requests.map(convertDates);
});
}
export function useWorkspace(): UseQueryResult<{ workspace: Workspace; requests: Request[] }> {
return useQuery('workspace', async () => {
const workspaces = (await invoke('workspaces')) as Workspace[];
const requests = (await invoke('requests', { workspaceId: workspaces[0].id })) as Request[];
return {
workspace: convertDates(workspaces[0]),
requests: requests.map(convertDates),
};
});
}
function convertDates<T extends BaseModel>(m: T): T {
return {
...m,
createdAt: convertDate(m.createdAt),
updatedAt: convertDate(m.updatedAt),
deletedAt: m.deletedAt ? convertDate(m.deletedAt) : null,
};
}
function convertDate(d: string | Date): Date {
const date = new Date(d);
const userTimezoneOffset = date.getTimezoneOffset() * 60000;
return new Date(date.getTime() - userTimezoneOffset);
}

51
src-web/lib/models.ts Normal file
View File

@@ -0,0 +1,51 @@
export interface BaseModel {
id: string;
workspaceId: string;
createdAt: Date;
updatedAt: Date;
deletedAt: Date | null;
}
export interface Workspace extends BaseModel {
name: string;
description: string;
}
export interface HttpHeader {
name: string;
value: string;
}
export interface HttpRequest extends BaseModel {
name: string;
url: string;
body: string | null;
method: string;
headers: HttpHeader[];
}
export interface HttpResponse extends BaseModel {
id: string;
requestId: string;
body: string;
status: string;
elapsed: number;
statusReason: string;
url: string;
headers: HttpHeader[];
}
export function convertDates<T extends BaseModel>(m: T): T {
return {
...m,
createdAt: convertDate(m.createdAt),
updatedAt: convertDate(m.updatedAt),
deletedAt: m.deletedAt ? convertDate(m.deletedAt) : null,
};
}
function convertDate(d: string | Date): Date {
const date = new Date(d);
const userTimezoneOffset = date.getTimezoneOffset() * 60000;
return new Date(date.getTime() - userTimezoneOffset);
}

View File

@@ -6,9 +6,9 @@ import { HelmetProvider } from 'react-helmet-async';
import { MotionConfig } from 'framer-motion'; import { MotionConfig } from 'framer-motion';
import { invoke } from '@tauri-apps/api'; import { invoke } from '@tauri-apps/api';
import { setTheme } from './lib/theme'; import { setTheme } from './lib/theme';
import { QueryClient, QueryClientProvider } from 'react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { createBrowserRouter, RouterProvider } from 'react-router-dom'; import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import { Layout } from './pages/Layout'; import { Layout } from './components/Layout';
import { Workspaces } from './pages/Workspaces'; import { Workspaces } from './pages/Workspaces';
import './main.css'; import './main.css';

View File

@@ -1,14 +1,15 @@
import { Link, useParams } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useWorkspaces } from '../hooks/useWorkspaces'; import { useWorkspaces } from '../hooks/useWorkspaces';
import { Button } from '../components/Button';
export function Workspaces() { export function Workspaces() {
const workspaces = useWorkspaces(); const workspaces = useWorkspaces();
return ( return (
<ul className="p-12"> <ul className="p-12">
{workspaces.data?.map((w) => ( {workspaces.data?.map((w) => (
<Link key={w.id} to={`/workspaces/${w.id}`}> <Button as={Link} key={w.id} to={`/workspaces/${w.id}`}>
{w.name} {w.name}
</Link> </Button>
))} ))}
</ul> </ul>
); );

View File

@@ -8,6 +8,7 @@
"esModuleInterop": false, "esModuleInterop": false,
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"strict": true, "strict": true,
"noUncheckedIndexedAccess": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"module": "ESNext", "module": "ESNext",
"moduleResolution": "Node", "moduleResolution": "Node",

View File

@@ -3,7 +3,8 @@
"composite": true, "composite": true,
"module": "ESNext", "module": "ESNext",
"moduleResolution": "Node", "moduleResolution": "Node",
"allowSyntheticDefaultImports": true "allowSyntheticDefaultImports": true,
"noUncheckedIndexedAccess": true
}, },
"include": ["vite.config.ts"] "include": ["vite.config.ts"]
} }