mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-02-18 08:37:48 +01:00
Compare commits
31 Commits
v2024.11.0
...
v2024.11.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f50f35519 | ||
|
|
4e775b2b49 | ||
|
|
e77a9e5d44 | ||
|
|
a381e44d8c | ||
|
|
4acf0969e8 | ||
|
|
30c4178269 | ||
|
|
dffe6e0a16 | ||
|
|
8090e67b9e | ||
|
|
f1beabcb6f | ||
|
|
647b8e2313 | ||
|
|
f5b4697608 | ||
|
|
f201857d51 | ||
|
|
0d982057a5 | ||
|
|
6fb94384b9 | ||
|
|
d754e7233d | ||
|
|
f974a66086 | ||
|
|
250625fc0e | ||
|
|
16e090b520 | ||
|
|
be9fbbcb6e | ||
|
|
8be3c3d0e1 | ||
|
|
c680e15cb5 | ||
|
|
da6baf72f5 | ||
|
|
2ca30bcb31 | ||
|
|
2e2b3128c5 | ||
|
|
4a81818d05 | ||
|
|
0eb98a3882 | ||
|
|
d28100d682 | ||
|
|
0f4d3bdbb5 | ||
|
|
c7eccddac9 | ||
|
|
4b7712df80 | ||
|
|
e5c6c31e02 |
2541
package-lock.json
generated
2541
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@
|
||||
"src-tauri/yaak_plugin_runtime",
|
||||
"src-tauri/yaak_sync",
|
||||
"src-tauri/yaak_templates",
|
||||
"src-tauri/yaak_sse",
|
||||
"src-web"
|
||||
],
|
||||
"scripts": {
|
||||
@@ -30,7 +31,7 @@
|
||||
"tauri-before-dev": "npm run --workspaces --if-present dev"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
"@tauri-apps/cli": "^2.0.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.5.0",
|
||||
"@typescript-eslint/parser": "^8.5.0",
|
||||
"eslint": "^8",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@yaakapp/api",
|
||||
"version": "0.2.15",
|
||||
"version": "0.2.16",
|
||||
"main": "lib/index.js",
|
||||
"typings": "./lib/index.d.ts",
|
||||
"files": [
|
||||
|
||||
@@ -95,7 +95,12 @@ export type SendHttpRequestResponse = { httpResponse: HttpResponse, };
|
||||
|
||||
export type ShowToastRequest = { message: string, color?: Color, icon?: Icon, };
|
||||
|
||||
export type TemplateFunction = { name: string, aliases?: Array<string>, args: Array<TemplateFunctionArg>, };
|
||||
export type TemplateFunction = { name: string, description?: string,
|
||||
/**
|
||||
* Also support alternative names. This is useful for not breaking existing
|
||||
* tags when changing the `name` property
|
||||
*/
|
||||
aliases?: Array<string>, args: Array<TemplateFunctionArg>, };
|
||||
|
||||
export type TemplateFunctionArg = { "type": "text" } & TemplateFunctionTextArg | { "type": "select" } & TemplateFunctionSelectArg | { "type": "checkbox" } & TemplateFunctionCheckboxArg | { "type": "http_request" } & TemplateFunctionHttpRequestArg | { "type": "file" } & TemplateFunctionFileArg;
|
||||
|
||||
|
||||
@@ -14,10 +14,12 @@ export type HttpRequest = { model: "http_request", id: string, createdAt: string
|
||||
|
||||
export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, };
|
||||
|
||||
export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, elapsed: number, elapsedHeaders: number, error: string | null, headers: Array<HttpResponseHeader>, remoteAddr: string | null, status: number, statusReason: string | null, url: string, version: string | null, };
|
||||
export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, elapsed: number, elapsedHeaders: number, error: string | null, headers: Array<HttpResponseHeader>, remoteAddr: string | null, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, };
|
||||
|
||||
export type HttpResponseHeader = { name: string, value: string, };
|
||||
|
||||
export type HttpResponseState = "initialized" | "connected" | "closed";
|
||||
|
||||
export type HttpUrlParameter = { enabled?: boolean, name: string, value: string, };
|
||||
|
||||
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, name: string, description: string, variables: Array<EnvironmentVariable>, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-ts_proto v2.2.0
|
||||
// protoc-gen-ts_proto v2.2.3
|
||||
// protoc v3.19.1
|
||||
// source: plugins/runtime.proto
|
||||
|
||||
@@ -33,13 +33,14 @@ export const EventStreamEvent: MessageFns<EventStreamEvent> = {
|
||||
while (reader.pos < end) {
|
||||
const tag = reader.uint32();
|
||||
switch (tag >>> 3) {
|
||||
case 1:
|
||||
case 1: {
|
||||
if (tag !== 10) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.event = reader.string();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ((tag & 7) === 4 || tag === 0) {
|
||||
break;
|
||||
|
||||
@@ -1 +1,7 @@
|
||||
edition = "2018"
|
||||
|
||||
# Widths
|
||||
chain_width = 100
|
||||
max_width = 100
|
||||
single_line_if_else_max_width = 100
|
||||
fn_call_width = 100
|
||||
|
||||
618
src-tauri/Cargo.lock
generated
618
src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
[workspace]
|
||||
members = ["yaak_grpc", "yaak_templates", "yaak_plugin_runtime", "yaak_models"]
|
||||
members = ["yaak_grpc", "yaak_templates", "yaak_plugin_runtime", "yaak_models", "yaak_sse"]
|
||||
|
||||
[package]
|
||||
name = "yaak-app"
|
||||
@@ -16,7 +16,7 @@ crate-type = ["staticlib", "cdylib", "lib"]
|
||||
strip = true # Automatically strip symbols from the binary.
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.0.0", features = [] }
|
||||
tauri-build = { version = "2.0.1", features = [] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc = "0.2.7"
|
||||
@@ -30,6 +30,7 @@ yaak_grpc = { path = "yaak_grpc" }
|
||||
yaak_templates = { path = "yaak_templates" }
|
||||
yaak_plugin_runtime = { workspace = true }
|
||||
yaak_models = { workspace = true }
|
||||
yaak_sse = { path = "yaak_sse" }
|
||||
anyhow = "1.0.86"
|
||||
base64 = "0.22.0"
|
||||
chrono = { version = "0.4.31", features = ["serde"] }
|
||||
@@ -46,22 +47,23 @@ serde_json = { version = "1.0.116", features = ["raw_value"] }
|
||||
serde_yaml = "0.9.34"
|
||||
tauri = { workspace = true }
|
||||
tauri-plugin-shell = { workspace = true }
|
||||
tauri-plugin-clipboard-manager = "2.1.0-beta.7"
|
||||
tauri-plugin-dialog = "2.0.0"
|
||||
tauri-plugin-fs = "2.0.0"
|
||||
tauri-plugin-log = { version = "2.0.0", features = ["colored"] }
|
||||
tauri-plugin-os = "2.0.0"
|
||||
tauri-plugin-updater = "2.0.0"
|
||||
tauri-plugin-window-state = "2.0.0"
|
||||
tauri-plugin-clipboard-manager = "2.0.1"
|
||||
tauri-plugin-dialog = "2.0.1"
|
||||
tauri-plugin-fs = "2.0.1"
|
||||
tauri-plugin-log = { version = "2.0.1", features = ["colored"] }
|
||||
tauri-plugin-os = "2.0.1"
|
||||
tauri-plugin-updater = "2.0.2"
|
||||
tauri-plugin-window-state = "2.0.1"
|
||||
tokio = { version = "1.36.0", features = ["sync"] }
|
||||
tokio-stream = "0.1.15"
|
||||
uuid = "1.7.0"
|
||||
thiserror = "1.0.61"
|
||||
mime_guess = "2.0.5"
|
||||
urlencoding = "2.1.3"
|
||||
eventsource-client = { git = "https://github.com/yaakapp/rust-eventsource-client", version = "0.13.0" }
|
||||
|
||||
[workspace.dependencies]
|
||||
yaak_models = { path = "yaak_models" }
|
||||
yaak_plugin_runtime = { path = "yaak_plugin_runtime" }
|
||||
tauri-plugin-shell = "2.0.0"
|
||||
tauri = { version = "2.0.0", features = ["devtools", "protocol-asset"] }
|
||||
tauri-plugin-shell = "2.0.1"
|
||||
tauri = { version = "2.0.4", features = ["devtools", "protocol-asset"] }
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"shell:allow-open",
|
||||
"core:webview:allow-set-webview-zoom",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-internal-toggle-maximize",
|
||||
"core:window:allow-is-fullscreen",
|
||||
"core:window:allow-maximize",
|
||||
"core:window:allow-minimize",
|
||||
|
||||
2
src-tauri/gen/schemas/capabilities.json
generated
2
src-tauri/gen/schemas/capabilities.json
generated
@@ -1 +1 @@
|
||||
{"main":{"identifier":"main","description":"Main permissions","local":true,"windows":["*"],"permissions":["core:event:allow-emit","core:event:allow-listen","core:event:allow-unlisten","os:allow-os-type","clipboard-manager:allow-clear","clipboard-manager:allow-write-text","clipboard-manager:allow-read-text","dialog:allow-open","dialog:allow-save","fs:allow-read-file","fs:allow-read-text-file",{"identifier":"fs:scope","allow":[{"path":"$APPDATA"},{"path":"$APPDATA/**"}]},"shell:allow-open","core:webview:allow-set-webview-zoom","core:window:allow-close","core:window:allow-is-fullscreen","core:window:allow-maximize","core:window:allow-minimize","core:window:allow-set-decorations","core:window:allow-set-title","core:window:allow-show","core:window:allow-start-dragging","core:window:allow-theme","core:window:allow-toggle-maximize","core:window:allow-unmaximize","clipboard-manager:allow-read-text","clipboard-manager:allow-write-text"]}}
|
||||
{"main":{"identifier":"main","description":"Main permissions","local":true,"windows":["*"],"permissions":["core:event:allow-emit","core:event:allow-listen","core:event:allow-unlisten","os:allow-os-type","clipboard-manager:allow-clear","clipboard-manager:allow-write-text","clipboard-manager:allow-read-text","dialog:allow-open","dialog:allow-save","fs:allow-read-file","fs:allow-read-text-file",{"identifier":"fs:scope","allow":[{"path":"$APPDATA"},{"path":"$APPDATA/**"}]},"shell:allow-open","core:webview:allow-set-webview-zoom","core:window:allow-close","core:window:allow-internal-toggle-maximize","core:window:allow-is-fullscreen","core:window:allow-maximize","core:window:allow-minimize","core:window:allow-set-decorations","core:window:allow-set-title","core:window:allow-show","core:window:allow-start-dragging","core:window:allow-theme","core:window:allow-toggle-maximize","core:window:allow-unmaximize","clipboard-manager:allow-read-text","clipboard-manager:allow-write-text"]}}
|
||||
7819
src-tauri/gen/schemas/linux-schema.json
generated
7819
src-tauri/gen/schemas/linux-schema.json
generated
File diff suppressed because it is too large
Load Diff
5
src-tauri/migrations/20241003134208_response-state.sql
Normal file
5
src-tauri/migrations/20241003134208_response-state.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE http_responses
|
||||
ADD COLUMN state TEXT DEFAULT 'closed' NOT NULL;
|
||||
|
||||
ALTER TABLE grpc_connections
|
||||
ADD COLUMN state TEXT DEFAULT 'closed' NOT NULL;
|
||||
1
src-tauri/migrations/20241012181547_proxy-setting.sql
Normal file
1
src-tauri/migrations/20241012181547_proxy-setting.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE settings ADD COLUMN proxy TEXT;
|
||||
@@ -1,7 +1,4 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::fs::{create_dir_all, File};
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
@@ -14,38 +11,48 @@ use base64::prelude::BASE64_STANDARD;
|
||||
use base64::Engine;
|
||||
use http::header::{ACCEPT, USER_AGENT};
|
||||
use http::{HeaderMap, HeaderName, HeaderValue};
|
||||
use log::{error, warn};
|
||||
use log::{debug, error, warn};
|
||||
use mime_guess::Mime;
|
||||
use reqwest::redirect::Policy;
|
||||
use reqwest::Method;
|
||||
use reqwest::{multipart, Url};
|
||||
use reqwest::{multipart, Proxy, Url};
|
||||
use reqwest::{Method, Response};
|
||||
use serde_json::Value;
|
||||
use tauri::{Manager, Runtime, WebviewWindow};
|
||||
use tokio::sync::oneshot;
|
||||
use tokio::fs;
|
||||
use tokio::fs::{create_dir_all, File};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::sync::watch::Receiver;
|
||||
use tokio::sync::{oneshot, Mutex};
|
||||
use yaak_models::models::{
|
||||
Cookie, CookieJar, Environment, HttpRequest, HttpResponse, HttpResponseHeader,
|
||||
HttpResponseState, ProxySetting, ProxySettingAuth,
|
||||
};
|
||||
use yaak_models::queries::{
|
||||
get_http_response, get_or_create_settings, get_workspace, update_response_if_id,
|
||||
upsert_cookie_jar,
|
||||
};
|
||||
use yaak_models::queries::{get_workspace, update_response_if_id, upsert_cookie_jar};
|
||||
use yaak_plugin_runtime::events::{RenderPurpose, WindowContext};
|
||||
|
||||
pub async fn send_http_request<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
request: &HttpRequest,
|
||||
response: &HttpResponse,
|
||||
og_response: &HttpResponse,
|
||||
environment: Option<Environment>,
|
||||
cookie_jar: Option<CookieJar>,
|
||||
cancel_rx: &mut Receiver<bool>,
|
||||
cancelled_rx: &mut Receiver<bool>,
|
||||
) -> Result<HttpResponse, String> {
|
||||
let workspace = get_workspace(window, &request.workspace_id)
|
||||
.await
|
||||
.expect("Failed to get Workspace");
|
||||
let workspace =
|
||||
get_workspace(window, &request.workspace_id).await.expect("Failed to get Workspace");
|
||||
let settings = get_or_create_settings(window).await;
|
||||
let cb = PluginTemplateCallback::new(
|
||||
window.app_handle(),
|
||||
&WindowContext::from_window(window),
|
||||
RenderPurpose::Send,
|
||||
);
|
||||
|
||||
|
||||
let response_id = og_response.id.clone();
|
||||
let response = Arc::new(Mutex::new(og_response.clone()));
|
||||
|
||||
let rendered_request =
|
||||
render_http_request(&request, &workspace, environment.as_ref(), &cb).await;
|
||||
|
||||
@@ -55,6 +62,7 @@ pub async fn send_http_request<R: Runtime>(
|
||||
if !url_string.starts_with("http://") && !url_string.starts_with("https://") {
|
||||
url_string = format!("http://{}", url_string);
|
||||
}
|
||||
debug!("Sending request to {url_string}");
|
||||
|
||||
let mut client_builder = reqwest::Client::builder()
|
||||
.redirect(match workspace.setting_follow_redirects {
|
||||
@@ -69,6 +77,31 @@ pub async fn send_http_request<R: Runtime>(
|
||||
.danger_accept_invalid_certs(!workspace.setting_validate_certificates)
|
||||
.tls_info(true);
|
||||
|
||||
match settings.proxy {
|
||||
Some(ProxySetting::Disabled) => client_builder = client_builder.no_proxy(),
|
||||
Some(ProxySetting::Enabled { http, https, auth }) => {
|
||||
debug!("Using proxy http={http} https={https}");
|
||||
let mut proxy = Proxy::custom(move |url| {
|
||||
let http = if http.is_empty() { None } else { Some(http.to_owned()) };
|
||||
let https = if https.is_empty() { None } else { Some(https.to_owned()) };
|
||||
let proxy_url = match (url.scheme(), http, https) {
|
||||
("http", Some(proxy_url), _) => Some(proxy_url),
|
||||
("https", _, Some(proxy_url)) => Some(proxy_url),
|
||||
_ => None,
|
||||
};
|
||||
proxy_url
|
||||
});
|
||||
|
||||
if let Some(ProxySettingAuth { user, password }) = auth {
|
||||
debug!("Using proxy auth");
|
||||
proxy = proxy.basic_auth(user.as_str(), password.as_str());
|
||||
}
|
||||
|
||||
client_builder = client_builder.proxy(proxy);
|
||||
}
|
||||
None => {} // Nothing to do for this one, as it is the default
|
||||
}
|
||||
|
||||
// Add cookie store if specified
|
||||
let maybe_cookie_manager = match cookie_jar.clone() {
|
||||
Some(cj) => {
|
||||
@@ -114,24 +147,24 @@ pub async fn send_http_request<R: Runtime>(
|
||||
let uri = match http::Uri::from_str(url_string.as_str()) {
|
||||
Ok(u) => u,
|
||||
Err(e) => {
|
||||
return response_err(
|
||||
response,
|
||||
return Ok(response_err(
|
||||
&*response.lock().await,
|
||||
format!("Failed to parse URL \"{}\": {}", url_string, e.to_string()),
|
||||
window,
|
||||
)
|
||||
.await;
|
||||
.await);
|
||||
}
|
||||
};
|
||||
// Yes, we're parsing both URI and URL because they could return different errors
|
||||
let url = match Url::from_str(uri.to_string().as_str()) {
|
||||
Ok(u) => u,
|
||||
Err(e) => {
|
||||
return response_err(
|
||||
response,
|
||||
return Ok(response_err(
|
||||
&*response.lock().await,
|
||||
format!("Failed to parse URL \"{}\": {}", url_string, e.to_string()),
|
||||
window,
|
||||
)
|
||||
.await;
|
||||
.await);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -190,16 +223,8 @@ pub async fn send_http_request<R: Runtime>(
|
||||
let a = rendered_request.authentication;
|
||||
|
||||
if b == "basic" {
|
||||
let username = a
|
||||
.get("username")
|
||||
.unwrap_or(empty_value)
|
||||
.as_str()
|
||||
.unwrap_or_default();
|
||||
let password = a
|
||||
.get("password")
|
||||
.unwrap_or(empty_value)
|
||||
.as_str()
|
||||
.unwrap_or_default();
|
||||
let username = a.get("username").unwrap_or(empty_value).as_str().unwrap_or_default();
|
||||
let password = a.get("password").unwrap_or(empty_value).as_str().unwrap_or_default();
|
||||
|
||||
let auth = format!("{username}:{password}");
|
||||
let encoded = BASE64_STANDARD.encode(auth);
|
||||
@@ -208,11 +233,7 @@ pub async fn send_http_request<R: Runtime>(
|
||||
HeaderValue::from_str(&format!("Basic {}", encoded)).unwrap(),
|
||||
);
|
||||
} else if b == "bearer" {
|
||||
let token = a
|
||||
.get("token")
|
||||
.unwrap_or(empty_value)
|
||||
.as_str()
|
||||
.unwrap_or_default();
|
||||
let token = a.get("token").unwrap_or(empty_value).as_str().unwrap_or_default();
|
||||
headers.insert(
|
||||
"Authorization",
|
||||
HeaderValue::from_str(&format!("Bearer {token}")).unwrap(),
|
||||
@@ -226,10 +247,7 @@ pub async fn send_http_request<R: Runtime>(
|
||||
let query = get_str_h(&request_body, "query");
|
||||
let variables = get_str_h(&request_body, "variables");
|
||||
let body = if variables.trim().is_empty() {
|
||||
format!(
|
||||
r#"{{"query":{}}}"#,
|
||||
serde_json::to_string(query).unwrap_or_default()
|
||||
)
|
||||
format!(r#"{{"query":{}}}"#, serde_json::to_string(query).unwrap_or_default())
|
||||
} else {
|
||||
format!(
|
||||
r#"{{"query":{},"variables":{variables}}}"#,
|
||||
@@ -269,12 +287,12 @@ pub async fn send_http_request<R: Runtime>(
|
||||
.as_str()
|
||||
.unwrap_or_default();
|
||||
|
||||
match fs::read(file_path).map_err(|e| e.to_string()) {
|
||||
match fs::read(file_path).await.map_err(|e| e.to_string()) {
|
||||
Ok(f) => {
|
||||
request_builder = request_builder.body(f);
|
||||
}
|
||||
Err(e) => {
|
||||
return response_err(response, e, window).await;
|
||||
return Ok(response_err(&*response.lock().await, e, window).await);
|
||||
}
|
||||
}
|
||||
} else if body_type == "multipart/form-data" && request_body.contains_key("form") {
|
||||
@@ -297,10 +315,15 @@ pub async fn send_http_request<R: Runtime>(
|
||||
let mut part = if file_path.is_empty() {
|
||||
multipart::Part::text(value.clone())
|
||||
} else {
|
||||
match fs::read(file_path.clone()) {
|
||||
match fs::read(file_path.clone()).await {
|
||||
Ok(f) => multipart::Part::bytes(f),
|
||||
Err(e) => {
|
||||
return response_err(response, e.to_string(), window).await;
|
||||
return Ok(response_err(
|
||||
&*response.lock().await,
|
||||
e.to_string(),
|
||||
window,
|
||||
)
|
||||
.await);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -315,9 +338,8 @@ pub async fn send_http_request<R: Runtime>(
|
||||
Mime::from_str("application/octet-stream").unwrap();
|
||||
let mime =
|
||||
mime_guess::from_path(file_path.clone()).first_or(default_mime);
|
||||
part = part
|
||||
.mime_str(mime.essence_str())
|
||||
.map_err(|e| e.to_string())?;
|
||||
part =
|
||||
part.mime_str(mime.essence_str()).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
// Set file path if not empty
|
||||
@@ -348,118 +370,179 @@ pub async fn send_http_request<R: Runtime>(
|
||||
let sendable_req = match request_builder.build() {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return response_err(response, e.to_string(), window).await;
|
||||
warn!("Failed to build request builder {e:?}");
|
||||
return Ok(response_err(&*response.lock().await, e.to_string(), window).await);
|
||||
}
|
||||
};
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
let (resp_tx, resp_rx) = oneshot::channel::<Result<Response, reqwest::Error>>();
|
||||
let (done_tx, done_rx) = oneshot::channel::<HttpResponse>();
|
||||
|
||||
let (resp_tx, resp_rx) = oneshot::channel();
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let _ = resp_tx.send(client.execute(sendable_req).await);
|
||||
});
|
||||
|
||||
let raw_response = tokio::select! {
|
||||
Ok(r) = resp_rx => {r}
|
||||
_ = cancel_rx.changed() => {
|
||||
return response_err(response, "Request was cancelled".to_string(), window).await;
|
||||
Ok(r) = resp_rx => r,
|
||||
_ = cancelled_rx.changed() => {
|
||||
debug!("Request cancelled");
|
||||
return Ok(response_err(&*response.lock().await, "Request was cancelled".to_string(), window).await);
|
||||
}
|
||||
};
|
||||
|
||||
match raw_response {
|
||||
Ok(v) => {
|
||||
let mut response = response.clone();
|
||||
response.elapsed_headers = start.elapsed().as_millis() as i32;
|
||||
let response_headers = v.headers().clone();
|
||||
response.status = v.status().as_u16() as i32;
|
||||
response.status_reason = v.status().canonical_reason().map(|s| s.to_string());
|
||||
response.headers = response_headers
|
||||
.iter()
|
||||
.map(|(k, v)| HttpResponseHeader {
|
||||
name: k.as_str().to_string(),
|
||||
value: v.to_str().unwrap_or_default().to_string(),
|
||||
})
|
||||
.collect();
|
||||
response.url = v.url().to_string();
|
||||
response.remote_addr = v.remote_addr().map(|a| a.to_string());
|
||||
response.version = match v.version() {
|
||||
reqwest::Version::HTTP_09 => Some("HTTP/0.9".to_string()),
|
||||
reqwest::Version::HTTP_10 => Some("HTTP/1.0".to_string()),
|
||||
reqwest::Version::HTTP_11 => Some("HTTP/1.1".to_string()),
|
||||
reqwest::Version::HTTP_2 => Some("HTTP/2".to_string()),
|
||||
reqwest::Version::HTTP_3 => Some("HTTP/3".to_string()),
|
||||
_ => None,
|
||||
{
|
||||
let window = window.clone();
|
||||
let cancelled_rx = cancelled_rx.clone();
|
||||
let response_id = response_id.clone();
|
||||
let response = response.clone();
|
||||
tokio::spawn(async move {
|
||||
match raw_response {
|
||||
Ok(mut v) => {
|
||||
let content_length = v.content_length();
|
||||
let response_headers = v.headers().clone();
|
||||
let dir = window.app_handle().path().app_data_dir().unwrap();
|
||||
let base_dir = dir.join("responses");
|
||||
create_dir_all(base_dir.clone()).await.expect("Failed to create responses dir");
|
||||
let body_path = if response_id.is_empty() {
|
||||
base_dir.join(response_id.clone())
|
||||
} else {
|
||||
base_dir.join(uuid::Uuid::new_v4().to_string())
|
||||
};
|
||||
|
||||
{
|
||||
let mut r = response.lock().await;
|
||||
r.body_path = Some(body_path.to_str().unwrap().to_string());
|
||||
r.elapsed_headers = start.elapsed().as_millis() as i32;
|
||||
r.status = v.status().as_u16() as i32;
|
||||
r.status_reason = v.status().canonical_reason().map(|s| s.to_string());
|
||||
r.headers = response_headers
|
||||
.iter()
|
||||
.map(|(k, v)| HttpResponseHeader {
|
||||
name: k.as_str().to_string(),
|
||||
value: v.to_str().unwrap_or_default().to_string(),
|
||||
})
|
||||
.collect();
|
||||
r.url = v.url().to_string();
|
||||
r.remote_addr = v.remote_addr().map(|a| a.to_string());
|
||||
r.version = match v.version() {
|
||||
reqwest::Version::HTTP_09 => Some("HTTP/0.9".to_string()),
|
||||
reqwest::Version::HTTP_10 => Some("HTTP/1.0".to_string()),
|
||||
reqwest::Version::HTTP_11 => Some("HTTP/1.1".to_string()),
|
||||
reqwest::Version::HTTP_2 => Some("HTTP/2".to_string()),
|
||||
reqwest::Version::HTTP_3 => Some("HTTP/3".to_string()),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
r.state = HttpResponseState::Connected;
|
||||
update_response_if_id(&window, &r)
|
||||
.await
|
||||
.expect("Failed to update response after connected");
|
||||
}
|
||||
|
||||
// Write body to FS
|
||||
let mut f = File::options()
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.write(true)
|
||||
.open(&body_path)
|
||||
.await
|
||||
.expect("Failed to open file");
|
||||
|
||||
let mut written_bytes: usize = 0;
|
||||
loop {
|
||||
let chunk = v.chunk().await;
|
||||
if *cancelled_rx.borrow() {
|
||||
// Request was canceled
|
||||
return;
|
||||
}
|
||||
match chunk {
|
||||
Ok(Some(bytes)) => {
|
||||
f.write_all(&bytes).await.expect("Failed to write to file");
|
||||
f.flush().await.expect("Failed to flush file");
|
||||
written_bytes += bytes.len();
|
||||
let mut r = response.lock().await;
|
||||
r.elapsed = start.elapsed().as_millis() as i32;
|
||||
r.content_length = Some(written_bytes as i32);
|
||||
update_response_if_id(&window, &r)
|
||||
.await
|
||||
.expect("Failed to update response");
|
||||
}
|
||||
Ok(None) => {
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
response_err(&*response.lock().await, e.to_string(), &window).await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set final content length
|
||||
{
|
||||
let mut r = response.lock().await;
|
||||
r.content_length = match content_length {
|
||||
Some(l) => Some(l as i32),
|
||||
None => Some(written_bytes as i32),
|
||||
};
|
||||
r.state = HttpResponseState::Closed;
|
||||
update_response_if_id(&window, &r)
|
||||
.await
|
||||
.expect("Failed to update response");
|
||||
};
|
||||
|
||||
// Add cookie store if specified
|
||||
if let Some((cookie_store, mut cookie_jar)) = maybe_cookie_manager {
|
||||
// let cookies = response_headers.get_all(SET_COOKIE).iter().map(|h| {
|
||||
// println!("RESPONSE COOKIE: {}", h.to_str().unwrap());
|
||||
// cookie_store::RawCookie::from_str(h.to_str().unwrap())
|
||||
// .expect("Failed to parse cookie")
|
||||
// });
|
||||
// store.store_response_cookies(cookies, &url);
|
||||
|
||||
let json_cookies: Vec<Cookie> = cookie_store
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter_any()
|
||||
.map(|c| {
|
||||
let json_cookie =
|
||||
serde_json::to_value(&c).expect("Failed to serialize cookie");
|
||||
serde_json::from_value(json_cookie)
|
||||
.expect("Failed to deserialize cookie")
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
cookie_jar.cookies = json_cookies;
|
||||
if let Err(e) = upsert_cookie_jar(&window, &cookie_jar).await {
|
||||
error!("Failed to update cookie jar: {}", e);
|
||||
};
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to execute request {e}");
|
||||
response_err(&*response.lock().await, format!("{e} → {e:?}"), &window).await;
|
||||
}
|
||||
};
|
||||
|
||||
let content_length = v.content_length();
|
||||
let body_bytes = v.bytes().await.expect("Failed to get body").to_vec();
|
||||
response.elapsed = start.elapsed().as_millis() as i32;
|
||||
let r = response.lock().await.clone();
|
||||
done_tx.send(r).unwrap();
|
||||
});
|
||||
};
|
||||
|
||||
// Use content length if available, otherwise use body length
|
||||
response.content_length = match content_length {
|
||||
Some(l) => Some(l as i32),
|
||||
None => Some(body_bytes.len() as i32),
|
||||
};
|
||||
|
||||
{
|
||||
// Write body to FS
|
||||
let dir = window.app_handle().path().app_data_dir().unwrap();
|
||||
let base_dir = dir.join("responses");
|
||||
create_dir_all(base_dir.clone()).expect("Failed to create responses dir");
|
||||
let body_path = match response.id.is_empty() {
|
||||
false => base_dir.join(response.id.clone()),
|
||||
true => base_dir.join(uuid::Uuid::new_v4().to_string()),
|
||||
};
|
||||
let mut f = File::options()
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.write(true)
|
||||
.open(&body_path)
|
||||
.expect("Failed to open file");
|
||||
f.write_all(body_bytes.as_slice())
|
||||
.expect("Failed to write to file");
|
||||
response.body_path = Some(
|
||||
body_path
|
||||
.to_str()
|
||||
.expect("Failed to get body path")
|
||||
.to_string(),
|
||||
);
|
||||
Ok(tokio::select! {
|
||||
Ok(r) = done_rx => r,
|
||||
_ = cancelled_rx.changed() => {
|
||||
match get_http_response(window, response_id.as_str()).await {
|
||||
Ok(mut r) => {
|
||||
r.state = HttpResponseState::Closed;
|
||||
update_response_if_id(&window, &r).await.expect("Failed to update response")
|
||||
},
|
||||
_ => {
|
||||
response_err(&*response.lock().await, "Ephemeral request was cancelled".to_string(), &window).await
|
||||
}.clone(),
|
||||
}
|
||||
|
||||
response = update_response_if_id(window, &response)
|
||||
.await
|
||||
.expect("Failed to update response");
|
||||
|
||||
// Add cookie store if specified
|
||||
if let Some((cookie_store, mut cookie_jar)) = maybe_cookie_manager {
|
||||
// let cookies = response_headers.get_all(SET_COOKIE).iter().map(|h| {
|
||||
// println!("RESPONSE COOKIE: {}", h.to_str().unwrap());
|
||||
// cookie_store::RawCookie::from_str(h.to_str().unwrap())
|
||||
// .expect("Failed to parse cookie")
|
||||
// });
|
||||
// store.store_response_cookies(cookies, &url);
|
||||
|
||||
let json_cookies: Vec<Cookie> = cookie_store
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter_any()
|
||||
.map(|c| {
|
||||
let json_cookie =
|
||||
serde_json::to_value(&c).expect("Failed to serialize cookie");
|
||||
serde_json::from_value(json_cookie).expect("Failed to deserialize cookie")
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
cookie_jar.cookies = json_cookies;
|
||||
if let Err(e) = upsert_cookie_jar(window, &cookie_jar).await {
|
||||
error!("Failed to update cookie jar: {}", e);
|
||||
};
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
Err(e) => response_err(response, e.to_string(), window).await,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn ensure_proto(url_str: &str) -> String {
|
||||
|
||||
@@ -3,7 +3,7 @@ extern crate core;
|
||||
extern crate objc;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs::{create_dir_all, read_to_string, File};
|
||||
use std::fs::{create_dir_all, File};
|
||||
use std::path::PathBuf;
|
||||
use std::process::exit;
|
||||
use std::str::FromStr;
|
||||
@@ -13,6 +13,7 @@ use std::{fs, panic};
|
||||
use base64::prelude::BASE64_STANDARD;
|
||||
use base64::Engine;
|
||||
use chrono::Utc;
|
||||
use eventsource_client::{EventParser, SSE};
|
||||
use fern::colors::ColoredLevelConfig;
|
||||
use log::{debug, error, info, warn};
|
||||
use rand::random;
|
||||
@@ -27,6 +28,7 @@ use tauri::{Manager, WindowEvent};
|
||||
use tauri_plugin_clipboard_manager::ClipboardExt;
|
||||
use tauri_plugin_log::{fern, Target, TargetKind};
|
||||
use tauri_plugin_shell::ShellExt;
|
||||
use tokio::fs::read_to_string;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use yaak_grpc::manager::{DynamicMessage, GrpcHandle};
|
||||
@@ -43,18 +45,33 @@ use crate::template_callback::PluginTemplateCallback;
|
||||
use crate::updates::{UpdateMode, YaakUpdater};
|
||||
use crate::window_menu::app_menu;
|
||||
use yaak_models::models::{
|
||||
CookieJar, Environment, EnvironmentVariable, Folder, GrpcConnection, GrpcEvent, GrpcEventType,
|
||||
GrpcRequest, HttpRequest, HttpResponse, KeyValue, ModelType, Plugin, Settings, Workspace,
|
||||
CookieJar, Environment, EnvironmentVariable, Folder, GrpcConnection, GrpcConnectionState,
|
||||
GrpcEvent, GrpcEventType, GrpcRequest, HttpRequest, HttpResponse, HttpResponseState, KeyValue,
|
||||
ModelType, Plugin, Settings, Workspace,
|
||||
};
|
||||
use yaak_models::queries::{
|
||||
cancel_pending_grpc_connections, cancel_pending_responses, create_default_http_response,
|
||||
delete_all_grpc_connections, delete_all_http_responses_for_request, delete_cookie_jar,
|
||||
delete_environment, delete_folder, delete_grpc_connection, delete_grpc_request,
|
||||
delete_http_request, delete_http_response, delete_plugin, delete_workspace,
|
||||
duplicate_grpc_request, duplicate_http_request, generate_id, generate_model_id, get_cookie_jar,
|
||||
get_environment, get_folder, get_grpc_connection, get_grpc_request, get_http_request,
|
||||
get_http_response, get_key_value_raw, get_or_create_settings, get_plugin, get_workspace,
|
||||
list_cookie_jars, list_environments, list_folders, list_grpc_connections, list_grpc_events,
|
||||
list_grpc_requests, list_http_requests, list_http_responses, list_http_responses_for_request,
|
||||
list_plugins, list_workspaces, set_key_value_raw, update_response_if_id, update_settings,
|
||||
upsert_cookie_jar, upsert_environment, upsert_folder, upsert_grpc_connection,
|
||||
upsert_grpc_event, upsert_grpc_request, upsert_http_request, upsert_plugin, upsert_workspace,
|
||||
};
|
||||
use yaak_models::queries::{cancel_pending_grpc_connections, cancel_pending_responses, create_default_http_response, delete_all_grpc_connections, delete_all_http_responses, delete_cookie_jar, delete_environment, delete_folder, delete_grpc_connection, delete_grpc_request, delete_http_request, delete_http_response, delete_plugin, delete_workspace, duplicate_grpc_request, duplicate_http_request, generate_id, generate_model_id, get_cookie_jar, get_environment, get_folder, get_grpc_connection, get_grpc_request, get_http_request, get_http_response, get_key_value_raw, get_or_create_settings, get_plugin, get_workspace, list_cookie_jars, list_environments, list_folders, list_grpc_connections, list_grpc_events, list_grpc_requests, list_http_requests, list_http_responses, list_plugins, list_workspaces, set_key_value_raw, update_response_if_id, update_settings, upsert_cookie_jar, upsert_environment, upsert_folder, upsert_grpc_connection, upsert_grpc_event, upsert_grpc_request, upsert_http_request, upsert_plugin, upsert_workspace};
|
||||
use yaak_plugin_runtime::events::{
|
||||
BootResponse, CallHttpRequestActionRequest, FilterResponse, FindHttpResponsesResponse,
|
||||
GetHttpRequestActionsResponse, GetHttpRequestByIdResponse, GetTemplateFunctionsResponse, Icon,
|
||||
InternalEvent, InternalEventPayload, RenderHttpRequestResponse, RenderPurpose,
|
||||
SendHttpRequestResponse, PromptTextResponse, ShowToastRequest, TemplateRenderResponse,
|
||||
InternalEvent, InternalEventPayload, PromptTextResponse, RenderHttpRequestResponse,
|
||||
RenderPurpose, SendHttpRequestResponse, ShowToastRequest, TemplateRenderResponse,
|
||||
WindowContext,
|
||||
};
|
||||
use yaak_plugin_runtime::plugin_handle::PluginHandle;
|
||||
use yaak_sse::sse::ServerSentEvent;
|
||||
use yaak_templates::{Parser, Tokens};
|
||||
|
||||
mod analytics;
|
||||
@@ -267,6 +284,7 @@ async fn cmd_grpc_go<R: Runtime>(
|
||||
request_id: req.id,
|
||||
status: -1,
|
||||
elapsed: 0,
|
||||
state: GrpcConnectionState::Initialized,
|
||||
url: req.url.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
@@ -322,6 +340,7 @@ async fn cmd_grpc_go<R: Runtime>(
|
||||
&GrpcConnection {
|
||||
elapsed: start.elapsed().as_millis() as i32,
|
||||
error: Some(err.clone()),
|
||||
state: GrpcConnectionState::Closed,
|
||||
..conn.clone()
|
||||
},
|
||||
)
|
||||
@@ -577,6 +596,7 @@ async fn cmd_grpc_go<R: Runtime>(
|
||||
stream.into_inner()
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
warn!("GRPC stream error {e:?}");
|
||||
upsert_grpc_event(
|
||||
&w,
|
||||
&(match e.status {
|
||||
@@ -629,7 +649,7 @@ async fn cmd_grpc_go<R: Runtime>(
|
||||
&w,
|
||||
&GrpcEvent {
|
||||
content: "Connection complete".to_string(),
|
||||
status: Some(Code::Unavailable as i32),
|
||||
status: Some(Code::Ok as i32),
|
||||
metadata: metadata_to_map(trailers),
|
||||
event_type: GrpcEventType::ConnectionEnd,
|
||||
..base_event.clone()
|
||||
@@ -676,6 +696,7 @@ async fn cmd_grpc_go<R: Runtime>(
|
||||
&GrpcConnection{
|
||||
elapsed: start.elapsed().as_millis() as i32,
|
||||
status: closed_status,
|
||||
state: GrpcConnectionState::Closed,
|
||||
..get_grpc_connection(&w, &conn_id).await.unwrap().clone()
|
||||
},
|
||||
).await.unwrap();
|
||||
@@ -695,6 +716,7 @@ async fn cmd_grpc_go<R: Runtime>(
|
||||
&GrpcConnection {
|
||||
elapsed: start.elapsed().as_millis() as i32,
|
||||
status: Code::Cancelled as i32,
|
||||
state: GrpcConnectionState::Closed,
|
||||
..get_grpc_connection(&w, &conn_id).await.unwrap().clone()
|
||||
},
|
||||
)
|
||||
@@ -739,7 +761,9 @@ async fn cmd_send_ephemeral_request(
|
||||
window.listen_any(
|
||||
format!("cancel_http_response_{}", response.id),
|
||||
move |_event| {
|
||||
let _ = cancel_tx.send(true);
|
||||
if let Err(e) = cancel_tx.send(true) {
|
||||
warn!("Failed to send cancel event for ephemeral request {e:?}");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -777,7 +801,7 @@ async fn cmd_filter_response<R: Runtime>(
|
||||
}
|
||||
}
|
||||
|
||||
let body = read_to_string(response.body_path.unwrap()).unwrap();
|
||||
let body = read_to_string(response.body_path.unwrap()).await.unwrap();
|
||||
|
||||
// TODO: Have plugins register their own content type (regex?)
|
||||
plugin_manager
|
||||
@@ -786,14 +810,36 @@ async fn cmd_filter_response<R: Runtime>(
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn cmd_get_sse_events(file_path: &str) -> Result<Vec<ServerSentEvent>, String> {
|
||||
let body = fs::read(file_path).map_err(|e| e.to_string())?;
|
||||
let mut p = EventParser::new();
|
||||
p.process_bytes(body.into()).map_err(|e| e.to_string())?;
|
||||
|
||||
let mut events = Vec::new();
|
||||
while let Some(e) = p.get_event() {
|
||||
if let SSE::Event(e) = e {
|
||||
events.push(ServerSentEvent {
|
||||
event_type: e.event_type,
|
||||
data: e.data,
|
||||
id: e.id,
|
||||
retry: e.retry,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn cmd_import_data<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
file_path: &str,
|
||||
) -> Result<WorkspaceExportResources, String> {
|
||||
let file =
|
||||
read_to_string(file_path).unwrap_or_else(|_| panic!("Unable to read file {}", file_path));
|
||||
let file = read_to_string(file_path)
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("Unable to read file {}", file_path));
|
||||
let file_contents = file.as_str();
|
||||
let (import_result, plugin_name) = plugin_manager
|
||||
.import_data(&window, file_contents)
|
||||
@@ -1077,10 +1123,12 @@ async fn cmd_send_http_request(
|
||||
window.listen_any(
|
||||
format!("cancel_http_response_{}", response.id),
|
||||
move |_event| {
|
||||
let _ = cancel_tx.send(true);
|
||||
if let Err(e) = cancel_tx.send(true) {
|
||||
warn!("Failed to send cancel event for request {e:?}");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
let environment = match environment_id {
|
||||
Some(id) => match get_environment(&window, id).await {
|
||||
Ok(env) => Some(env),
|
||||
@@ -1116,15 +1164,15 @@ async fn response_err<R: Runtime>(
|
||||
response: &HttpResponse,
|
||||
error: String,
|
||||
w: &WebviewWindow<R>,
|
||||
) -> Result<HttpResponse, String> {
|
||||
warn!("Failed to send request: {}", error);
|
||||
) -> HttpResponse {
|
||||
warn!("Failed to send request: {error:?}");
|
||||
let mut response = response.clone();
|
||||
response.elapsed = -1;
|
||||
response.state = HttpResponseState::Closed;
|
||||
response.error = Some(error.clone());
|
||||
response = update_response_if_id(w, &response)
|
||||
.await
|
||||
.expect("Failed to update response");
|
||||
Ok(response)
|
||||
response
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1454,10 +1502,10 @@ async fn cmd_delete_environment(
|
||||
|
||||
#[tauri::command]
|
||||
async fn cmd_list_grpc_connections(
|
||||
request_id: &str,
|
||||
workspace_id: &str,
|
||||
w: WebviewWindow,
|
||||
) -> Result<Vec<GrpcConnection>, String> {
|
||||
list_grpc_connections(&w, request_id)
|
||||
list_grpc_connections(&w, workspace_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -1604,11 +1652,11 @@ async fn cmd_get_workspace(id: &str, w: WebviewWindow) -> Result<Workspace, Stri
|
||||
|
||||
#[tauri::command]
|
||||
async fn cmd_list_http_responses(
|
||||
request_id: &str,
|
||||
workspace_id: &str,
|
||||
limit: Option<i64>,
|
||||
w: WebviewWindow,
|
||||
) -> Result<Vec<HttpResponse>, String> {
|
||||
list_http_responses(&w, request_id, limit)
|
||||
list_http_responses(&w, workspace_id, limit)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -1636,7 +1684,7 @@ async fn cmd_delete_all_grpc_connections(request_id: &str, w: WebviewWindow) ->
|
||||
|
||||
#[tauri::command]
|
||||
async fn cmd_delete_all_http_responses(request_id: &str, w: WebviewWindow) -> Result<(), String> {
|
||||
delete_all_http_responses(&w, request_id)
|
||||
delete_all_http_responses_for_request(&w, request_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -1779,6 +1827,7 @@ pub fn run() {
|
||||
])
|
||||
.level_for("plugin_runtime", log::LevelFilter::Info)
|
||||
.level_for("cookie_store", log::LevelFilter::Info)
|
||||
.level_for("eventsource_client::event_parser", log::LevelFilter::Info)
|
||||
.level_for("h2", log::LevelFilter::Info)
|
||||
.level_for("hyper", log::LevelFilter::Info)
|
||||
.level_for("hyper_util", log::LevelFilter::Info)
|
||||
@@ -1879,6 +1928,7 @@ pub fn run() {
|
||||
cmd_get_folder,
|
||||
cmd_get_grpc_request,
|
||||
cmd_get_http_request,
|
||||
cmd_get_sse_events,
|
||||
cmd_get_key_value,
|
||||
cmd_get_settings,
|
||||
cmd_get_workspace,
|
||||
@@ -2169,13 +2219,16 @@ async fn call_frontend<T: Serialize + Clone, R: Runtime>(
|
||||
let (tx, mut rx) = tokio::sync::watch::channel(PromptTextResponse::default());
|
||||
|
||||
let event_id = window.clone().listen(reply_id, move |ev| {
|
||||
println!("GOT REPLY {ev:?}");
|
||||
let resp: PromptTextResponse = serde_json::from_str(ev.payload()).unwrap();
|
||||
_ = tx.send(resp);
|
||||
if let Err(e) = tx.send(resp) {
|
||||
warn!("Failed to prompt for text {e:?}");
|
||||
}
|
||||
});
|
||||
|
||||
// When reply shows up, unlisten to events and return
|
||||
_ = rx.changed().await;
|
||||
if let Err(e) = rx.changed().await {
|
||||
warn!("Failed to check channel changed {e:?}");
|
||||
}
|
||||
window.unlisten(event_id);
|
||||
|
||||
let foo = rx.borrow();
|
||||
@@ -2215,7 +2268,7 @@ async fn handle_plugin_event<R: Runtime>(
|
||||
Some(InternalEventPayload::PromptTextResponse(resp))
|
||||
}
|
||||
InternalEventPayload::FindHttpResponsesRequest(req) => {
|
||||
let http_responses = list_http_responses(
|
||||
let http_responses = list_http_responses_for_request(
|
||||
app_handle,
|
||||
req.request_id.as_str(),
|
||||
req.limit.map(|l| l as i64),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "exporter-curl",
|
||||
"name": "@yaakapp/exporter-curl",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "filter-jsonpath",
|
||||
"name": "@yaakapp/filter-jsonpath",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "filter-xpath",
|
||||
"name": "@yaakapp/filter-xpath",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
|
||||
@@ -288,7 +288,8 @@ var SUPPORTED_ARGS = [
|
||||
// Request method
|
||||
DATA_FLAGS
|
||||
].flatMap((v) => v);
|
||||
function pluginHookImport(ctx, rawData) {
|
||||
var BOOL_FLAGS = ["G", "get", "digest"];
|
||||
function pluginHookImport(_ctx, rawData) {
|
||||
if (!rawData.match(/^\s*curl /)) {
|
||||
return null;
|
||||
}
|
||||
@@ -359,10 +360,11 @@ function importCommand(parseEntries, workspaceId) {
|
||||
}
|
||||
let value;
|
||||
const nextEntry = parseEntries[i + 1];
|
||||
const hasValue = !BOOL_FLAGS.includes(name);
|
||||
if (isSingleDash && name.length > 1) {
|
||||
value = name.slice(1);
|
||||
name = name.slice(0, 1);
|
||||
} else if (typeof nextEntry === "string" && !nextEntry.startsWith("-")) {
|
||||
} else if (typeof nextEntry === "string" && hasValue && !nextEntry.startsWith("-")) {
|
||||
value = nextEntry;
|
||||
i++;
|
||||
} else {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "importer-curl",
|
||||
"name": "@yaakapp/importer-curl",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
@@ -8,5 +8,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"shell-quote": "^1.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/shell-quote": "^1.7.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "importer-insomnia",
|
||||
"name": "@yaakapp/importer-insomnia",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "importer-openapi",
|
||||
"name": "@yaakapp/importer-openapi",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
@@ -9,5 +9,8 @@
|
||||
"dependencies": {
|
||||
"openapi-to-postmanv2": "^4.23.1",
|
||||
"yaml": "^2.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/openapi-to-postmanv2": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "importer-postman",
|
||||
"name": "@yaakapp/importer-postman",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"main": "./build/index.js",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "importer-yaak",
|
||||
"name": "@yaakapp/importer-yaak",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
|
||||
54
src-tauri/vendored/plugins/template-function-file/build/index.js
generated
Normal file
54
src-tauri/vendored/plugins/template-function-file/build/index.js
generated
Normal file
@@ -0,0 +1,54 @@
|
||||
"use strict";
|
||||
var __create = Object.create;
|
||||
var __defProp = Object.defineProperty;
|
||||
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||
var __getProtoOf = Object.getPrototypeOf;
|
||||
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||
var __export = (target, all) => {
|
||||
for (var name in all)
|
||||
__defProp(target, name, { get: all[name], enumerable: true });
|
||||
};
|
||||
var __copyProps = (to, from, except, desc) => {
|
||||
if (from && typeof from === "object" || typeof from === "function") {
|
||||
for (let key of __getOwnPropNames(from))
|
||||
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||
}
|
||||
return to;
|
||||
};
|
||||
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
||||
// If the importer is in node compatibility mode or this is not an ESM
|
||||
// file that has been converted to a CommonJS file using a Babel-
|
||||
// compatible transform (i.e. "__esModule" has not been set), then set
|
||||
// "default" to the CommonJS "module.exports" for node compatibility.
|
||||
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
||||
mod
|
||||
));
|
||||
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
||||
|
||||
// src/index.ts
|
||||
var src_exports = {};
|
||||
__export(src_exports, {
|
||||
plugin: () => plugin
|
||||
});
|
||||
module.exports = __toCommonJS(src_exports);
|
||||
var import_node_fs = __toESM(require("node:fs"));
|
||||
var plugin = {
|
||||
templateFunctions: [{
|
||||
name: "fs.readFile",
|
||||
args: [{ title: "Select File", type: "file", name: "path", label: "File" }],
|
||||
async onRender(_ctx, args) {
|
||||
if (!args.values.path) return null;
|
||||
try {
|
||||
return import_node_fs.default.promises.readFile(args.values.path, "utf-8");
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}]
|
||||
};
|
||||
// Annotate the CommonJS export names for ESM import in node:
|
||||
0 && (module.exports = {
|
||||
plugin
|
||||
});
|
||||
9
src-tauri/vendored/plugins/template-function-file/package.json
generated
Normal file
9
src-tauri/vendored/plugins/template-function-file/package.json
generated
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "@yaakapp/template-function-file",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"build": "yaakcli build ./src/index.ts",
|
||||
"dev": "yaakcli dev ./src/index.js"
|
||||
}
|
||||
}
|
||||
55
src-tauri/vendored/plugins/template-function-fs/build/index.js
generated
Normal file
55
src-tauri/vendored/plugins/template-function-fs/build/index.js
generated
Normal file
@@ -0,0 +1,55 @@
|
||||
"use strict";
|
||||
var __create = Object.create;
|
||||
var __defProp = Object.defineProperty;
|
||||
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||
var __getProtoOf = Object.getPrototypeOf;
|
||||
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||
var __export = (target, all) => {
|
||||
for (var name in all)
|
||||
__defProp(target, name, { get: all[name], enumerable: true });
|
||||
};
|
||||
var __copyProps = (to, from, except, desc) => {
|
||||
if (from && typeof from === "object" || typeof from === "function") {
|
||||
for (let key of __getOwnPropNames(from))
|
||||
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||
}
|
||||
return to;
|
||||
};
|
||||
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
||||
// If the importer is in node compatibility mode or this is not an ESM
|
||||
// file that has been converted to a CommonJS file using a Babel-
|
||||
// compatible transform (i.e. "__esModule" has not been set), then set
|
||||
// "default" to the CommonJS "module.exports" for node compatibility.
|
||||
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
||||
mod
|
||||
));
|
||||
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
||||
|
||||
// src/index.ts
|
||||
var src_exports = {};
|
||||
__export(src_exports, {
|
||||
plugin: () => plugin
|
||||
});
|
||||
module.exports = __toCommonJS(src_exports);
|
||||
var import_node_fs = __toESM(require("node:fs"));
|
||||
var plugin = {
|
||||
templateFunctions: [{
|
||||
name: "fs.readFile",
|
||||
description: "Read the contents of a file as utf-8",
|
||||
args: [{ title: "Select File", type: "file", name: "path", label: "File" }],
|
||||
async onRender(_ctx, args) {
|
||||
if (!args.values.path) return null;
|
||||
try {
|
||||
return import_node_fs.default.promises.readFile(args.values.path, "utf-8");
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}]
|
||||
};
|
||||
// Annotate the CommonJS export names for ESM import in node:
|
||||
0 && (module.exports = {
|
||||
plugin
|
||||
});
|
||||
9
src-tauri/vendored/plugins/template-function-fs/package.json
generated
Normal file
9
src-tauri/vendored/plugins/template-function-fs/package.json
generated
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "@yaakapp/template-function-fs",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"build": "yaakcli build ./src/index.ts",
|
||||
"dev": "yaakcli dev ./src/index.js"
|
||||
}
|
||||
}
|
||||
49
src-tauri/vendored/plugins/template-function-hash/build/index.js
generated
Normal file
49
src-tauri/vendored/plugins/template-function-hash/build/index.js
generated
Normal file
@@ -0,0 +1,49 @@
|
||||
"use strict";
|
||||
var __defProp = Object.defineProperty;
|
||||
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||
var __export = (target, all) => {
|
||||
for (var name in all)
|
||||
__defProp(target, name, { get: all[name], enumerable: true });
|
||||
};
|
||||
var __copyProps = (to, from, except, desc) => {
|
||||
if (from && typeof from === "object" || typeof from === "function") {
|
||||
for (let key of __getOwnPropNames(from))
|
||||
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||
}
|
||||
return to;
|
||||
};
|
||||
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
||||
|
||||
// src/index.ts
|
||||
var src_exports = {};
|
||||
__export(src_exports, {
|
||||
plugin: () => plugin
|
||||
});
|
||||
module.exports = __toCommonJS(src_exports);
|
||||
var import_node_crypto = require("node:crypto");
|
||||
var algorithms = ["md5", "sha1", "sha256", "sha512"];
|
||||
var plugin = {
|
||||
templateFunctions: algorithms.map((algorithm) => ({
|
||||
name: `hash.${algorithm}`,
|
||||
description: "Hash a value to its hexidecimal representation",
|
||||
args: [
|
||||
{
|
||||
name: "input",
|
||||
label: "Input",
|
||||
placeholder: "input text",
|
||||
type: "text"
|
||||
}
|
||||
],
|
||||
async onRender(_ctx, args) {
|
||||
if (!args.values.input) return "";
|
||||
return (0, import_node_crypto.createHash)(algorithm).update(args.values.input, "utf-8").digest("hex");
|
||||
}
|
||||
}))
|
||||
};
|
||||
// Annotate the CommonJS export names for ESM import in node:
|
||||
0 && (module.exports = {
|
||||
plugin
|
||||
});
|
||||
9
src-tauri/vendored/plugins/template-function-hash/package.json
generated
Executable file
9
src-tauri/vendored/plugins/template-function-hash/package.json
generated
Executable file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "@yaakapp/template-function-hash",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"build": "yaakcli build ./src/index.ts",
|
||||
"dev": "yaakcli dev ./src/index.js"
|
||||
}
|
||||
}
|
||||
50
src-tauri/vendored/plugins/template-function-prompt/build/index.js
generated
Normal file
50
src-tauri/vendored/plugins/template-function-prompt/build/index.js
generated
Normal file
@@ -0,0 +1,50 @@
|
||||
"use strict";
|
||||
var __defProp = Object.defineProperty;
|
||||
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||
var __export = (target, all) => {
|
||||
for (var name in all)
|
||||
__defProp(target, name, { get: all[name], enumerable: true });
|
||||
};
|
||||
var __copyProps = (to, from, except, desc) => {
|
||||
if (from && typeof from === "object" || typeof from === "function") {
|
||||
for (let key of __getOwnPropNames(from))
|
||||
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||
}
|
||||
return to;
|
||||
};
|
||||
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
||||
|
||||
// src/index.ts
|
||||
var src_exports = {};
|
||||
__export(src_exports, {
|
||||
plugin: () => plugin
|
||||
});
|
||||
module.exports = __toCommonJS(src_exports);
|
||||
var plugin = {
|
||||
templateFunctions: [{
|
||||
name: "prompt.text",
|
||||
description: "Prompt the user for input when sending a request",
|
||||
args: [
|
||||
{ type: "text", name: "title", label: "Title" },
|
||||
{ type: "text", name: "defaultValue", label: "Default Value", optional: true },
|
||||
{ type: "text", name: "placeholder", label: "Placeholder", optional: true }
|
||||
],
|
||||
async onRender(ctx, args) {
|
||||
if (args.purpose !== "send") return null;
|
||||
return await ctx.prompt.text({
|
||||
id: `prompt-${args.values.label}`,
|
||||
label: args.values.title ?? "",
|
||||
title: args.values.title ?? "",
|
||||
defaultValue: args.values.defaultValue,
|
||||
placeholder: args.values.placeholder
|
||||
});
|
||||
}
|
||||
}]
|
||||
};
|
||||
// Annotate the CommonJS export names for ESM import in node:
|
||||
0 && (module.exports = {
|
||||
plugin
|
||||
});
|
||||
9
src-tauri/vendored/plugins/template-function-prompt/package.json
generated
Normal file
9
src-tauri/vendored/plugins/template-function-prompt/package.json
generated
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "@yaakapp/template-function-prompt",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"build": "yaakcli build ./src/index.ts",
|
||||
"dev": "yaakcli dev ./src/index.js"
|
||||
}
|
||||
}
|
||||
@@ -27,12 +27,18 @@ var plugin = {
|
||||
templateFunctions: [
|
||||
{
|
||||
name: "request.body",
|
||||
args: [],
|
||||
args: [{
|
||||
name: "requestId",
|
||||
label: "Http Request",
|
||||
type: "http_request"
|
||||
}],
|
||||
async onRender(ctx, args) {
|
||||
const httpRequest = await ctx.httpRequest.getById({ id: args.values.request ?? "n/a" });
|
||||
const httpRequest = await ctx.httpRequest.getById({ id: args.values.requestId ?? "n/a" });
|
||||
if (httpRequest == null) return null;
|
||||
const rendered = await ctx.httpRequest.render({ httpRequest, purpose: args.purpose });
|
||||
return rendered.body.text ?? "";
|
||||
return String(await ctx.templates.render({
|
||||
data: httpRequest.body?.text ?? "",
|
||||
purpose: args.purpose
|
||||
}));
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -52,8 +58,11 @@ var plugin = {
|
||||
async onRender(ctx, args) {
|
||||
const httpRequest = await ctx.httpRequest.getById({ id: args.values.requestId ?? "n/a" });
|
||||
if (httpRequest == null) return null;
|
||||
const rendered = await ctx.httpRequest.render({ httpRequest, purpose: args.purpose });
|
||||
return rendered.headers.find((h) => h.name.toLowerCase() === args.values.header?.toLowerCase())?.value ?? "";
|
||||
const header = httpRequest.headers.find((h) => h.name.toLowerCase() === args.values.header?.toLowerCase());
|
||||
return String(await ctx.templates.render({
|
||||
data: header?.value ?? "",
|
||||
purpose: args.purpose
|
||||
}));
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
{
|
||||
"name": "template-function-request",
|
||||
"name": "@yaakapp/template-function-request",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"build": "yaakcli build ./src/index.ts",
|
||||
"dev": "yaakcli dev ./src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"jsonpath-plus": "^9.0.0",
|
||||
"xpath": "^0.0.34",
|
||||
"@xmldom/xmldom": "^0.8.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jsonpath": "^0.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8818,73 +8818,121 @@ var SafeScript = import_vm.default.Script;
|
||||
// src/index.ts
|
||||
var import_node_fs = require("node:fs");
|
||||
var import_xpath = __toESM(require_xpath());
|
||||
var behaviorArg = {
|
||||
type: "select",
|
||||
name: "behavior",
|
||||
label: "Sending Behavior",
|
||||
defaultValue: "smart",
|
||||
options: [
|
||||
{ label: "When no responses", value: "smart" },
|
||||
{ label: "Always", value: "always" }
|
||||
]
|
||||
};
|
||||
var requestArg = {
|
||||
type: "http_request",
|
||||
name: "request",
|
||||
label: "Request"
|
||||
};
|
||||
var plugin = {
|
||||
templateFunctions: [{
|
||||
name: "response",
|
||||
args: [
|
||||
{
|
||||
type: "http_request",
|
||||
name: "request",
|
||||
label: "Request"
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
name: "path",
|
||||
label: "JSONPath or XPath",
|
||||
placeholder: "$.books[0].id or /books[0]/id"
|
||||
},
|
||||
{
|
||||
type: "select",
|
||||
name: "behavior",
|
||||
label: "Sending Behavior",
|
||||
defaultValue: "smart",
|
||||
options: [
|
||||
{ name: "When no responses", value: "smart" },
|
||||
{ name: "Always", value: "always" }
|
||||
]
|
||||
templateFunctions: [
|
||||
{
|
||||
name: "response.header",
|
||||
description: "Read the value of a response header, by name",
|
||||
args: [
|
||||
requestArg,
|
||||
{
|
||||
type: "text",
|
||||
name: "header",
|
||||
label: "Header Name",
|
||||
placeholder: "Content-Type"
|
||||
},
|
||||
behaviorArg
|
||||
],
|
||||
async onRender(ctx, args) {
|
||||
if (!args.values.request || !args.values.header) return null;
|
||||
const response = await getResponse(ctx, {
|
||||
requestId: args.values.request,
|
||||
purpose: args.purpose,
|
||||
behavior: args.values.behavior ?? null
|
||||
});
|
||||
if (response == null) return null;
|
||||
const header = response.headers.find(
|
||||
(h) => h.name.toLowerCase() === String(args.values.header ?? "").toLowerCase()
|
||||
);
|
||||
return header?.value ?? null;
|
||||
}
|
||||
],
|
||||
async onRender(ctx, args) {
|
||||
if (!args.values.request || !args.values.path) {
|
||||
},
|
||||
{
|
||||
name: "response.body.path",
|
||||
description: "Access a field of the response body using JsonPath or XPath",
|
||||
aliases: ["response"],
|
||||
args: [
|
||||
requestArg,
|
||||
{
|
||||
type: "text",
|
||||
name: "path",
|
||||
label: "JSONPath or XPath",
|
||||
placeholder: "$.books[0].id or /books[0]/id"
|
||||
},
|
||||
behaviorArg
|
||||
],
|
||||
async onRender(ctx, args) {
|
||||
if (!args.values.request || !args.values.path) return null;
|
||||
const response = await getResponse(ctx, {
|
||||
requestId: args.values.request,
|
||||
purpose: args.purpose,
|
||||
behavior: args.values.behavior ?? null
|
||||
});
|
||||
if (response == null) return null;
|
||||
if (response.bodyPath == null) {
|
||||
return null;
|
||||
}
|
||||
let body;
|
||||
try {
|
||||
body = (0, import_node_fs.readFileSync)(response.bodyPath, "utf-8");
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return filterJSONPath(body, args.values.path);
|
||||
} catch (err) {
|
||||
}
|
||||
try {
|
||||
return filterXPath(body, args.values.path);
|
||||
} catch (err) {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const httpRequest = await ctx.httpRequest.getById({ id: args.values.request ?? "n/a" });
|
||||
if (httpRequest == null) {
|
||||
return null;
|
||||
},
|
||||
{
|
||||
name: "response.body.raw",
|
||||
description: "Access the entire response body, as text",
|
||||
aliases: ["response"],
|
||||
args: [
|
||||
requestArg,
|
||||
behaviorArg
|
||||
],
|
||||
async onRender(ctx, args) {
|
||||
if (!args.values.request) return null;
|
||||
const response = await getResponse(ctx, {
|
||||
requestId: args.values.request,
|
||||
purpose: args.purpose,
|
||||
behavior: args.values.behavior ?? null
|
||||
});
|
||||
if (response == null) return null;
|
||||
if (response.bodyPath == null) {
|
||||
return null;
|
||||
}
|
||||
let body;
|
||||
try {
|
||||
body = (0, import_node_fs.readFileSync)(response.bodyPath, "utf-8");
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
const responses = await ctx.httpResponse.find({ requestId: httpRequest.id, limit: 1 });
|
||||
if (args.values.behavior === "never" && responses.length === 0) {
|
||||
return null;
|
||||
}
|
||||
let response = responses[0] ?? null;
|
||||
let behavior = args.values.behavior === "always" && args.purpose === "preview" ? "smart" : args.values.behavior;
|
||||
if (behavior === "smart" && response == null || behavior === "always") {
|
||||
const renderedHttpRequest = await ctx.httpRequest.render({ httpRequest, purpose: args.purpose });
|
||||
response = await ctx.httpRequest.send({ httpRequest: renderedHttpRequest });
|
||||
}
|
||||
if (response == null) {
|
||||
return null;
|
||||
}
|
||||
if (response.bodyPath == null) {
|
||||
return null;
|
||||
}
|
||||
let body;
|
||||
try {
|
||||
body = (0, import_node_fs.readFileSync)(response.bodyPath, "utf-8");
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return filterJSONPath(body, args.values.path);
|
||||
} catch (err) {
|
||||
}
|
||||
try {
|
||||
return filterXPath(body, args.values.path);
|
||||
} catch (err) {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}]
|
||||
]
|
||||
};
|
||||
function filterJSONPath(body, path) {
|
||||
const parsed = JSON.parse(body);
|
||||
@@ -8907,6 +8955,24 @@ function filterXPath(body, path) {
|
||||
return String(items);
|
||||
}
|
||||
}
|
||||
async function getResponse(ctx, { requestId, behavior, purpose }) {
|
||||
if (!requestId) return null;
|
||||
const httpRequest = await ctx.httpRequest.getById({ id: requestId ?? "n/a" });
|
||||
if (httpRequest == null) {
|
||||
return null;
|
||||
}
|
||||
const responses = await ctx.httpResponse.find({ requestId: httpRequest.id, limit: 1 });
|
||||
if (behavior === "never" && responses.length === 0) {
|
||||
return null;
|
||||
}
|
||||
let response = responses[0] ?? null;
|
||||
let finalBehavior = behavior === "always" && purpose === "preview" ? "smart" : behavior;
|
||||
if (finalBehavior === "smart" && response == null || finalBehavior === "always") {
|
||||
const renderedHttpRequest = await ctx.httpRequest.render({ httpRequest, purpose });
|
||||
response = await ctx.httpRequest.send({ httpRequest: renderedHttpRequest });
|
||||
}
|
||||
return response;
|
||||
}
|
||||
// Annotate the CommonJS export names for ESM import in node:
|
||||
0 && (module.exports = {
|
||||
plugin
|
||||
|
||||
@@ -28,7 +28,7 @@ pub struct GrpcConnection {
|
||||
pub uri: Uri,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
#[derive(Default, Debug)]
|
||||
pub struct StreamError {
|
||||
pub message: String,
|
||||
pub status: Option<Status>,
|
||||
@@ -234,7 +234,7 @@ impl GrpcHandle {
|
||||
&pool,
|
||||
input_message,
|
||||
))
|
||||
.unwrap(),
|
||||
.unwrap(),
|
||||
})
|
||||
}
|
||||
def
|
||||
@@ -301,4 +301,4 @@ fn make_pool_key(id: &str, uri: &str, proto_files: &Vec<PathBuf>) -> String {
|
||||
);
|
||||
|
||||
format!("{:x}", md5::compute(pool_key))
|
||||
}
|
||||
}
|
||||
@@ -153,7 +153,7 @@ async fn file_descriptor_set_from_service_name(
|
||||
client,
|
||||
MessageRequest::FileContainingSymbol(service_name.into()),
|
||||
)
|
||||
.await
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp,
|
||||
Err(e) => {
|
||||
@@ -249,4 +249,4 @@ pub fn method_desc_to_path(md: &MethodDescriptor) -> PathAndQuery {
|
||||
.ok_or_else(|| anyhow!("invalid method path"))
|
||||
.expect("invalid method path");
|
||||
PathAndQuery::from_str(&format!("/{}/{}", namespace, method_name)).expect("invalid method path")
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,9 @@ export type EnvironmentVariable = { enabled?: boolean, name: string, value: stri
|
||||
|
||||
export type Folder = { model: "folder", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, name: string, sortPriority: number, };
|
||||
|
||||
export type GrpcConnection = { model: "grpc_connection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, elapsed: number, error: string | null, method: string, service: string, status: number, trailers: { [key in string]?: string }, url: string, };
|
||||
export type GrpcConnection = { model: "grpc_connection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, elapsed: number, error: string | null, method: string, service: string, status: number, state: GrpcConnectionState, trailers: { [key in string]?: string }, url: string, };
|
||||
|
||||
export type GrpcConnectionState = "initialized" | "connected" | "closed";
|
||||
|
||||
export type GrpcEvent = { model: "grpc_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, connectionId: string, content: string, error: string | null, eventType: GrpcEventType, metadata: { [key in string]?: string }, status: number | null, };
|
||||
|
||||
@@ -30,16 +32,22 @@ export type HttpRequest = { model: "http_request", id: string, createdAt: string
|
||||
|
||||
export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, };
|
||||
|
||||
export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, elapsed: number, elapsedHeaders: number, error: string | null, headers: Array<HttpResponseHeader>, remoteAddr: string | null, status: number, statusReason: string | null, url: string, version: string | null, };
|
||||
export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, elapsed: number, elapsedHeaders: number, error: string | null, headers: Array<HttpResponseHeader>, remoteAddr: string | null, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, };
|
||||
|
||||
export type HttpResponseHeader = { name: string, value: string, };
|
||||
|
||||
export type HttpResponseState = "initialized" | "connected" | "closed";
|
||||
|
||||
export type HttpUrlParameter = { enabled?: boolean, name: string, value: string, };
|
||||
|
||||
export type KeyValue = { model: "key_value", createdAt: string, updatedAt: string, key: string, namespace: string, value: string, };
|
||||
|
||||
export type Plugin = { model: "plugin", id: string, createdAt: string, updatedAt: string, checkedAt: string | null, directory: string, enabled: boolean, url: string | null, };
|
||||
|
||||
export type Settings = { model: "settings", id: string, createdAt: string, updatedAt: string, appearance: string, editorFontSize: number, editorSoftWrap: boolean, interfaceFontSize: number, interfaceScale: number, openWorkspaceNewWindow: boolean | null, telemetry: boolean, theme: string, themeDark: string, themeLight: string, updateChannel: string, };
|
||||
export type ProxySetting = { "type": "enabled", http: string, https: string, auth: ProxySettingAuth | null, } | { "type": "disabled" };
|
||||
|
||||
export type ProxySettingAuth = { user: string, password: string, };
|
||||
|
||||
export type Settings = { model: "settings", id: string, createdAt: string, updatedAt: string, appearance: string, editorFontSize: number, editorSoftWrap: boolean, interfaceFontSize: number, interfaceScale: number, openWorkspaceNewWindow: boolean | null, telemetry: boolean, theme: string, themeDark: string, themeLight: string, updateChannel: string, proxy: ProxySetting | null, };
|
||||
|
||||
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, name: string, description: string, variables: Array<EnvironmentVariable>, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, };
|
||||
|
||||
@@ -6,6 +6,26 @@ use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
use ts_rs::TS;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase", tag = "type")]
|
||||
#[ts(export, export_to = "models.ts")]
|
||||
pub enum ProxySetting {
|
||||
Enabled {
|
||||
http: String,
|
||||
https: String,
|
||||
auth: Option<ProxySettingAuth>,
|
||||
},
|
||||
Disabled,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "models.ts")]
|
||||
pub struct ProxySettingAuth {
|
||||
pub user: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "models.ts")]
|
||||
@@ -27,6 +47,7 @@ pub struct Settings {
|
||||
pub theme_dark: String,
|
||||
pub theme_light: String,
|
||||
pub update_channel: String,
|
||||
pub proxy: Option<ProxySetting>,
|
||||
}
|
||||
|
||||
#[derive(Iden)]
|
||||
@@ -44,6 +65,7 @@ pub enum SettingsIden {
|
||||
InterfaceFontSize,
|
||||
InterfaceScale,
|
||||
OpenWorkspaceNewWindow,
|
||||
Proxy,
|
||||
Telemetry,
|
||||
Theme,
|
||||
ThemeDark,
|
||||
@@ -55,22 +77,24 @@ impl<'s> TryFrom<&Row<'s>> for Settings {
|
||||
type Error = rusqlite::Error;
|
||||
|
||||
fn try_from(r: &Row<'s>) -> Result<Self, Self::Error> {
|
||||
let proxy: Option<String> = r.get("proxy")?;
|
||||
Ok(Settings {
|
||||
id: r.get("id")?,
|
||||
model: r.get("model")?,
|
||||
created_at: r.get("created_at")?,
|
||||
updated_at: r.get("updated_at")?,
|
||||
theme: r.get("theme")?,
|
||||
appearance: r.get("appearance")?,
|
||||
editor_font_size: r.get("editor_font_size")?,
|
||||
editor_soft_wrap: r.get("editor_soft_wrap")?,
|
||||
interface_font_size: r.get("interface_font_size")?,
|
||||
interface_scale: r.get("interface_scale")?,
|
||||
open_workspace_new_window: r.get("open_workspace_new_window")?,
|
||||
proxy: proxy.map(|p| -> ProxySetting { serde_json::from_str(p.as_str()).unwrap() }),
|
||||
telemetry: r.get("telemetry")?,
|
||||
theme: r.get("theme")?,
|
||||
theme_dark: r.get("theme_dark")?,
|
||||
theme_light: r.get("theme_light")?,
|
||||
update_channel: r.get("update_channel")?,
|
||||
interface_font_size: r.get("interface_font_size")?,
|
||||
interface_scale: r.get("interface_scale")?,
|
||||
editor_font_size: r.get("editor_font_size")?,
|
||||
editor_soft_wrap: r.get("editor_soft_wrap")?,
|
||||
telemetry: r.get("telemetry")?,
|
||||
open_workspace_new_window: r.get("open_workspace_new_window")?,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -430,6 +454,21 @@ pub struct HttpResponseHeader {
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(export, export_to = "models.ts")]
|
||||
pub enum HttpResponseState {
|
||||
Initialized,
|
||||
Connected,
|
||||
Closed,
|
||||
}
|
||||
|
||||
impl Default for HttpResponseState {
|
||||
fn default() -> Self {
|
||||
Self::Initialized
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "models.ts")]
|
||||
@@ -451,6 +490,7 @@ pub struct HttpResponse {
|
||||
pub remote_addr: Option<String>,
|
||||
pub status: i32,
|
||||
pub status_reason: Option<String>,
|
||||
pub state: HttpResponseState,
|
||||
pub url: String,
|
||||
pub version: Option<String>,
|
||||
}
|
||||
@@ -475,6 +515,7 @@ pub enum HttpResponseIden {
|
||||
RemoteAddr,
|
||||
Status,
|
||||
StatusReason,
|
||||
State,
|
||||
Url,
|
||||
Version,
|
||||
}
|
||||
@@ -484,6 +525,7 @@ impl<'s> TryFrom<&Row<'s>> for HttpResponse {
|
||||
|
||||
fn try_from(r: &Row<'s>) -> Result<Self, Self::Error> {
|
||||
let headers: String = r.get("headers")?;
|
||||
let state: String = r.get("state")?;
|
||||
Ok(HttpResponse {
|
||||
id: r.get("id")?,
|
||||
model: r.get("model")?,
|
||||
@@ -500,6 +542,7 @@ impl<'s> TryFrom<&Row<'s>> for HttpResponse {
|
||||
remote_addr: r.get("remote_addr")?,
|
||||
status: r.get("status")?,
|
||||
status_reason: r.get("status_reason")?,
|
||||
state: serde_json::from_str(format!(r#""{state}""#).as_str()).unwrap(),
|
||||
body_path: r.get("body_path")?,
|
||||
headers: serde_json::from_str(headers.as_str()).unwrap_or_default(),
|
||||
})
|
||||
@@ -598,6 +641,21 @@ impl<'s> TryFrom<&Row<'s>> for GrpcRequest {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(export, export_to = "models.ts")]
|
||||
pub enum GrpcConnectionState {
|
||||
Initialized,
|
||||
Connected,
|
||||
Closed,
|
||||
}
|
||||
|
||||
impl Default for GrpcConnectionState {
|
||||
fn default() -> Self {
|
||||
Self::Initialized
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "models.ts")]
|
||||
@@ -615,6 +673,7 @@ pub struct GrpcConnection {
|
||||
pub method: String,
|
||||
pub service: String,
|
||||
pub status: i32,
|
||||
pub state: GrpcConnectionState,
|
||||
pub trailers: BTreeMap<String, String>,
|
||||
pub url: String,
|
||||
}
|
||||
@@ -634,6 +693,7 @@ pub enum GrpcConnectionIden {
|
||||
Error,
|
||||
Method,
|
||||
Service,
|
||||
State,
|
||||
Status,
|
||||
Trailers,
|
||||
Url,
|
||||
@@ -644,6 +704,7 @@ impl<'s> TryFrom<&Row<'s>> for GrpcConnection {
|
||||
|
||||
fn try_from(r: &Row<'s>) -> Result<Self, Self::Error> {
|
||||
let trailers: String = r.get("trailers")?;
|
||||
let state: String = r.get("state")?;
|
||||
Ok(GrpcConnection {
|
||||
id: r.get("id")?,
|
||||
model: r.get("model")?,
|
||||
@@ -654,6 +715,7 @@ impl<'s> TryFrom<&Row<'s>> for GrpcConnection {
|
||||
service: r.get("service")?,
|
||||
method: r.get("method")?,
|
||||
elapsed: r.get("elapsed")?,
|
||||
state: serde_json::from_str(format!(r#""{state}""#).as_str()).unwrap(),
|
||||
status: r.get("status")?,
|
||||
url: r.get("url")?,
|
||||
error: r.get("error")?,
|
||||
@@ -873,7 +935,7 @@ impl ModelType {
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase", untagged)]
|
||||
#[ts(export, export_to="models.ts")]
|
||||
#[ts(export, export_to = "models.ts")]
|
||||
pub enum AnyModel {
|
||||
CookieJar(CookieJar),
|
||||
Environment(Environment),
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
use std::fs;
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::models::{CookieJar, CookieJarIden, Environment, EnvironmentIden, Folder, FolderIden, GrpcConnection, GrpcConnectionIden, GrpcEvent, GrpcEventIden, GrpcRequest, GrpcRequestIden, HttpRequest, HttpRequestIden, HttpResponse, HttpResponseHeader, HttpResponseIden, KeyValue, KeyValueIden, ModelType, Plugin, PluginIden, Settings, SettingsIden, Workspace, WorkspaceIden};
|
||||
use crate::models::{
|
||||
CookieJar, CookieJarIden, Environment, EnvironmentIden, Folder, FolderIden, GrpcConnection,
|
||||
GrpcConnectionIden, GrpcConnectionState, GrpcEvent, GrpcEventIden, GrpcRequest,
|
||||
GrpcRequestIden, HttpRequest, HttpRequestIden, HttpResponse, HttpResponseHeader,
|
||||
HttpResponseIden, HttpResponseState, KeyValue, KeyValueIden, ModelType, Plugin, PluginIden,
|
||||
Settings, SettingsIden, Workspace, WorkspaceIden,
|
||||
};
|
||||
use crate::plugin::SqliteConnection;
|
||||
use log::{debug, error};
|
||||
use rand::distributions::{Alphanumeric, DistString};
|
||||
use rusqlite::OptionalExtension;
|
||||
use sea_query::ColumnRef::Asterisk;
|
||||
use sea_query::Keyword::CurrentTimestamp;
|
||||
use sea_query::{Cond, Expr, OnConflict, Order, Query, SqliteQueryBuilder};
|
||||
@@ -12,6 +19,9 @@ use sea_query_rusqlite::RusqliteBinder;
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewWindow};
|
||||
|
||||
const MAX_GRPC_CONNECTIONS_PER_REQUEST: usize = 20;
|
||||
const MAX_HTTP_RESPONSES_PER_REQUEST: usize = MAX_GRPC_CONNECTIONS_PER_REQUEST;
|
||||
|
||||
pub async fn set_key_value_string<R: Runtime>(
|
||||
mgr: &WebviewWindow<R>,
|
||||
namespace: &str,
|
||||
@@ -108,9 +118,7 @@ pub async fn set_key_value_raw<R: Runtime>(
|
||||
.returning_all()
|
||||
.build_rusqlite(SqliteQueryBuilder);
|
||||
|
||||
let mut stmt = db
|
||||
.prepare(sql.as_str())
|
||||
.expect("Failed to prepare KeyValue upsert");
|
||||
let mut stmt = db.prepare(sql.as_str()).expect("Failed to prepare KeyValue upsert");
|
||||
let kv = stmt
|
||||
.query_row(&*params.as_params(), |row| row.try_into())
|
||||
.expect("Failed to upsert KeyValue");
|
||||
@@ -134,8 +142,7 @@ pub async fn get_key_value_raw<R: Runtime>(
|
||||
)
|
||||
.build_rusqlite(SqliteQueryBuilder);
|
||||
|
||||
db.query_row(sql.as_str(), &*params.as_params(), |row| row.try_into())
|
||||
.ok()
|
||||
db.query_row(sql.as_str(), &*params.as_params(), |row| row.try_into()).ok()
|
||||
}
|
||||
|
||||
pub async fn list_workspaces<R: Runtime>(mgr: &impl Manager<R>) -> Result<Vec<Workspace>> {
|
||||
@@ -356,11 +363,7 @@ pub async fn upsert_grpc_request<R: Runtime>(
|
||||
request.service.as_ref().map(|s| s.as_str()).into(),
|
||||
request.method.as_ref().map(|s| s.as_str()).into(),
|
||||
request.message.as_str().into(),
|
||||
request
|
||||
.authentication_type
|
||||
.as_ref()
|
||||
.map(|s| s.as_str())
|
||||
.into(),
|
||||
request.authentication_type.as_ref().map(|s| s.as_str()).into(),
|
||||
serde_json::to_string(&request.authentication)?.into(),
|
||||
serde_json::to_string(&request.metadata)?.into(),
|
||||
])
|
||||
@@ -423,6 +426,13 @@ pub async fn upsert_grpc_connection<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
connection: &GrpcConnection,
|
||||
) -> Result<GrpcConnection> {
|
||||
let connections =
|
||||
list_http_responses_for_request(window, connection.request_id.as_str(), None).await?;
|
||||
for c in connections.iter().skip(MAX_GRPC_CONNECTIONS_PER_REQUEST - 1) {
|
||||
debug!("Deleting old grpc connection {}", c.id);
|
||||
delete_grpc_connection(window, c.id.as_str()).await?;
|
||||
}
|
||||
|
||||
let id = match connection.id.as_str() {
|
||||
"" => generate_model_id(ModelType::TypeGrpcConnection),
|
||||
_ => connection.id.to_string(),
|
||||
@@ -440,6 +450,7 @@ pub async fn upsert_grpc_connection<R: Runtime>(
|
||||
GrpcConnectionIden::Service,
|
||||
GrpcConnectionIden::Method,
|
||||
GrpcConnectionIden::Elapsed,
|
||||
GrpcConnectionIden::State,
|
||||
GrpcConnectionIden::Status,
|
||||
GrpcConnectionIden::Error,
|
||||
GrpcConnectionIden::Trailers,
|
||||
@@ -454,6 +465,7 @@ pub async fn upsert_grpc_connection<R: Runtime>(
|
||||
connection.service.as_str().into(),
|
||||
connection.method.as_str().into(),
|
||||
connection.elapsed.into(),
|
||||
serde_json::to_value(&connection.state)?.as_str().into(),
|
||||
connection.status.into(),
|
||||
connection.error.as_ref().map(|s| s.as_str()).into(),
|
||||
serde_json::to_string(&connection.trailers)?.into(),
|
||||
@@ -467,6 +479,7 @@ pub async fn upsert_grpc_connection<R: Runtime>(
|
||||
GrpcConnectionIden::Method,
|
||||
GrpcConnectionIden::Elapsed,
|
||||
GrpcConnectionIden::Status,
|
||||
GrpcConnectionIden::State,
|
||||
GrpcConnectionIden::Error,
|
||||
GrpcConnectionIden::Trailers,
|
||||
GrpcConnectionIden::Url,
|
||||
@@ -497,6 +510,24 @@ pub async fn get_grpc_connection<R: Runtime>(
|
||||
}
|
||||
|
||||
pub async fn list_grpc_connections<R: Runtime>(
|
||||
mgr: &impl Manager<R>,
|
||||
workspace_id: &str,
|
||||
) -> Result<Vec<GrpcConnection>> {
|
||||
let dbm = &*mgr.state::<SqliteConnection>();
|
||||
let db = dbm.0.lock().await.get().unwrap();
|
||||
|
||||
let (sql, params) = Query::select()
|
||||
.from(GrpcConnectionIden::Table)
|
||||
.cond_where(Expr::col(GrpcConnectionIden::WorkspaceId).eq(workspace_id))
|
||||
.column(Asterisk)
|
||||
.order_by(GrpcConnectionIden::CreatedAt, Order::Desc)
|
||||
.build_rusqlite(SqliteQueryBuilder);
|
||||
let mut stmt = db.prepare(sql.as_str())?;
|
||||
let items = stmt.query_map(&*params.as_params(), |row| row.try_into())?;
|
||||
Ok(items.map(|v| v.unwrap()).collect())
|
||||
}
|
||||
|
||||
pub async fn list_grpc_connections_for_request<R: Runtime>(
|
||||
mgr: &impl Manager<R>,
|
||||
request_id: &str,
|
||||
) -> Result<Vec<GrpcConnection>> {
|
||||
@@ -536,7 +567,7 @@ pub async fn delete_all_grpc_connections<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
request_id: &str,
|
||||
) -> Result<()> {
|
||||
for r in list_grpc_connections(window, request_id).await? {
|
||||
for r in list_grpc_connections_for_request(window, request_id).await? {
|
||||
delete_grpc_connection(window, &r.id).await?;
|
||||
}
|
||||
Ok(())
|
||||
@@ -624,7 +655,7 @@ pub async fn list_grpc_events<R: Runtime>(
|
||||
.from(GrpcEventIden::Table)
|
||||
.cond_where(Expr::col(GrpcEventIden::ConnectionId).eq(connection_id))
|
||||
.column(Asterisk)
|
||||
.order_by(GrpcEventIden::CreatedAt, Order::Desc)
|
||||
.order_by(GrpcEventIden::CreatedAt, Order::Asc)
|
||||
.build_rusqlite(SqliteQueryBuilder);
|
||||
let mut stmt = db.prepare(sql.as_str())?;
|
||||
let items = stmt.query_map(&*params.as_params(), |row| row.try_into())?;
|
||||
@@ -717,7 +748,7 @@ pub async fn delete_environment<R: Runtime>(
|
||||
|
||||
const SETTINGS_ID: &str = "default";
|
||||
|
||||
async fn get_settings<R: Runtime>(mgr: &impl Manager<R>) -> Result<Settings> {
|
||||
async fn get_settings<R: Runtime>(mgr: &impl Manager<R>) -> Result<Option<Settings>> {
|
||||
let dbm = &*mgr.state::<SqliteConnection>();
|
||||
let db = dbm.0.lock().await.get().unwrap();
|
||||
|
||||
@@ -727,13 +758,15 @@ async fn get_settings<R: Runtime>(mgr: &impl Manager<R>) -> Result<Settings> {
|
||||
.cond_where(Expr::col(SettingsIden::Id).eq(SETTINGS_ID))
|
||||
.build_rusqlite(SqliteQueryBuilder);
|
||||
let mut stmt = db.prepare(sql.as_str())?;
|
||||
Ok(stmt.query_row(&*params.as_params(), |row| row.try_into())?)
|
||||
Ok(stmt.query_row(&*params.as_params(), |row| row.try_into()).optional()?)
|
||||
}
|
||||
|
||||
pub async fn get_or_create_settings<R: Runtime>(mgr: &impl Manager<R>) -> Settings {
|
||||
if let Ok(settings) = get_settings(mgr).await {
|
||||
return settings;
|
||||
}
|
||||
match get_settings(mgr).await {
|
||||
Ok(Some(settings)) => return settings,
|
||||
Ok(None) => (),
|
||||
Err(e) => panic!("Failed to get settings {e:?}"),
|
||||
};
|
||||
|
||||
let dbm = &*mgr.state::<SqliteConnection>();
|
||||
let db = dbm.0.lock().await.get().unwrap();
|
||||
@@ -745,11 +778,8 @@ pub async fn get_or_create_settings<R: Runtime>(mgr: &impl Manager<R>) -> Settin
|
||||
.returning_all()
|
||||
.build_rusqlite(SqliteQueryBuilder);
|
||||
|
||||
let mut stmt = db
|
||||
.prepare(sql.as_str())
|
||||
.expect("Failed to prepare Settings insert");
|
||||
stmt.query_row(&*params.as_params(), |row| row.try_into())
|
||||
.expect("Failed to insert Settings")
|
||||
let mut stmt = db.prepare(sql.as_str()).expect("Failed to prepare Settings insert");
|
||||
stmt.query_row(&*params.as_params(), |row| row.try_into()).expect("Failed to insert Settings")
|
||||
}
|
||||
|
||||
pub async fn update_settings<R: Runtime>(
|
||||
@@ -765,42 +795,23 @@ pub async fn update_settings<R: Runtime>(
|
||||
.values([
|
||||
(SettingsIden::Id, "default".into()),
|
||||
(SettingsIden::CreatedAt, CurrentTimestamp.into()),
|
||||
(
|
||||
SettingsIden::Appearance,
|
||||
settings.appearance.as_str().into(),
|
||||
),
|
||||
(SettingsIden::Appearance, settings.appearance.as_str().into()),
|
||||
(SettingsIden::ThemeDark, settings.theme_dark.as_str().into()),
|
||||
(SettingsIden::ThemeLight, settings.theme_light.as_str().into()),
|
||||
(SettingsIden::UpdateChannel, settings.update_channel.into()),
|
||||
(SettingsIden::InterfaceFontSize, settings.interface_font_size.into()),
|
||||
(SettingsIden::InterfaceScale, settings.interface_scale.into()),
|
||||
(SettingsIden::EditorFontSize, settings.editor_font_size.into()),
|
||||
(SettingsIden::EditorSoftWrap, settings.editor_soft_wrap.into()),
|
||||
(SettingsIden::Telemetry, settings.telemetry.into()),
|
||||
(SettingsIden::OpenWorkspaceNewWindow, settings.open_workspace_new_window.into()),
|
||||
(
|
||||
SettingsIden::ThemeLight,
|
||||
settings.theme_light.as_str().into(),
|
||||
),
|
||||
(
|
||||
SettingsIden::UpdateChannel,
|
||||
settings.update_channel.as_str().into(),
|
||||
),
|
||||
(
|
||||
SettingsIden::InterfaceFontSize,
|
||||
settings.interface_font_size.into(),
|
||||
),
|
||||
(
|
||||
SettingsIden::InterfaceScale,
|
||||
settings.interface_scale.into(),
|
||||
),
|
||||
(
|
||||
SettingsIden::EditorFontSize,
|
||||
settings.editor_font_size.into(),
|
||||
),
|
||||
(
|
||||
SettingsIden::EditorSoftWrap,
|
||||
settings.editor_soft_wrap.into(),
|
||||
),
|
||||
(
|
||||
SettingsIden::Telemetry,
|
||||
settings.telemetry.into(),
|
||||
),
|
||||
(
|
||||
SettingsIden::OpenWorkspaceNewWindow,
|
||||
settings.open_workspace_new_window.into(),
|
||||
SettingsIden::Proxy,
|
||||
(match settings.proxy {
|
||||
None => None,
|
||||
Some(p) => Some(serde_json::to_string(&p)?),
|
||||
})
|
||||
.into(),
|
||||
),
|
||||
])
|
||||
.returning_all()
|
||||
@@ -872,10 +883,7 @@ pub async fn get_environment<R: Runtime>(mgr: &impl Manager<R>, id: &str) -> Res
|
||||
Ok(stmt.query_row(&*params.as_params(), |row| row.try_into())?)
|
||||
}
|
||||
|
||||
pub async fn get_plugin<R: Runtime>(
|
||||
mgr: &impl Manager<R>,
|
||||
id: &str
|
||||
) -> Result<Plugin> {
|
||||
pub async fn get_plugin<R: Runtime>(mgr: &impl Manager<R>, id: &str) -> Result<Plugin> {
|
||||
let dbm = &*mgr.state::<SqliteConnection>();
|
||||
let db = dbm.0.lock().await.get().unwrap();
|
||||
|
||||
@@ -888,9 +896,7 @@ pub async fn get_plugin<R: Runtime>(
|
||||
Ok(stmt.query_row(&*params.as_params(), |row| row.try_into())?)
|
||||
}
|
||||
|
||||
pub async fn list_plugins<R: Runtime>(
|
||||
mgr: &impl Manager<R>,
|
||||
) -> Result<Vec<Plugin>> {
|
||||
pub async fn list_plugins<R: Runtime>(mgr: &impl Manager<R>) -> Result<Vec<Plugin>> {
|
||||
let dbm = &*mgr.state::<SqliteConnection>();
|
||||
let db = dbm.0.lock().await.get().unwrap();
|
||||
|
||||
@@ -1185,7 +1191,7 @@ pub async fn delete_http_request<R: Runtime>(
|
||||
let req = get_http_request(window, id).await?;
|
||||
|
||||
// DB deletes will cascade but this will delete the files
|
||||
delete_all_http_responses(window, id).await?;
|
||||
delete_all_http_responses_for_request(window, id).await?;
|
||||
|
||||
let dbm = &*window.app_handle().state::<SqliteConnection>();
|
||||
let db = dbm.0.lock().await.get().unwrap();
|
||||
@@ -1208,6 +1214,7 @@ pub async fn create_default_http_response<R: Runtime>(
|
||||
0,
|
||||
0,
|
||||
"",
|
||||
HttpResponseState::Initialized,
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
@@ -1226,6 +1233,7 @@ pub async fn create_http_response<R: Runtime>(
|
||||
elapsed: i64,
|
||||
elapsed_headers: i64,
|
||||
url: &str,
|
||||
state: HttpResponseState,
|
||||
status: i64,
|
||||
status_reason: Option<&str>,
|
||||
content_length: Option<i64>,
|
||||
@@ -1234,6 +1242,12 @@ pub async fn create_http_response<R: Runtime>(
|
||||
version: Option<&str>,
|
||||
remote_addr: Option<&str>,
|
||||
) -> Result<HttpResponse> {
|
||||
let responses = list_http_responses_for_request(window, request_id, None).await?;
|
||||
for response in responses.iter().skip(MAX_HTTP_RESPONSES_PER_REQUEST - 1) {
|
||||
debug!("Deleting old response {}", response.id);
|
||||
delete_http_response(window, response.id.as_str()).await?;
|
||||
}
|
||||
|
||||
let req = get_http_request(window, request_id).await?;
|
||||
let id = generate_model_id(ModelType::TypeHttpResponse);
|
||||
let dbm = &*window.app_handle().state::<SqliteConnection>();
|
||||
@@ -1250,6 +1264,7 @@ pub async fn create_http_response<R: Runtime>(
|
||||
HttpResponseIden::Elapsed,
|
||||
HttpResponseIden::ElapsedHeaders,
|
||||
HttpResponseIden::Url,
|
||||
HttpResponseIden::State,
|
||||
HttpResponseIden::Status,
|
||||
HttpResponseIden::StatusReason,
|
||||
HttpResponseIden::ContentLength,
|
||||
@@ -1267,6 +1282,7 @@ pub async fn create_http_response<R: Runtime>(
|
||||
elapsed.into(),
|
||||
elapsed_headers.into(),
|
||||
url.into(),
|
||||
serde_json::to_value(state)?.as_str().unwrap_or_default().into(),
|
||||
status.into(),
|
||||
status_reason.into(),
|
||||
content_length.into(),
|
||||
@@ -1287,10 +1303,11 @@ pub async fn cancel_pending_grpc_connections(app: &AppHandle) -> Result<()> {
|
||||
let dbm = &*app.app_handle().state::<SqliteConnection>();
|
||||
let db = dbm.0.lock().await.get().unwrap();
|
||||
|
||||
let closed = serde_json::to_value(&GrpcConnectionState::Closed)?;
|
||||
let (sql, params) = Query::update()
|
||||
.table(GrpcConnectionIden::Table)
|
||||
.value(GrpcConnectionIden::Elapsed, -1)
|
||||
.cond_where(Expr::col(GrpcConnectionIden::Elapsed).eq(0))
|
||||
.values([(GrpcConnectionIden::State, closed.as_str().into())])
|
||||
.cond_where(Expr::col(GrpcConnectionIden::State).ne(closed.as_str()))
|
||||
.build_rusqlite(SqliteQueryBuilder);
|
||||
|
||||
db.execute(sql.as_str(), &*params.as_params())?;
|
||||
@@ -1301,13 +1318,14 @@ pub async fn cancel_pending_responses(app: &AppHandle) -> Result<()> {
|
||||
let dbm = &*app.app_handle().state::<SqliteConnection>();
|
||||
let db = dbm.0.lock().await.get().unwrap();
|
||||
|
||||
let closed = serde_json::to_value(&GrpcConnectionState::Closed)?;
|
||||
let (sql, params) = Query::update()
|
||||
.table(HttpResponseIden::Table)
|
||||
.values([
|
||||
(HttpResponseIden::Elapsed, (-1i32).into()),
|
||||
(HttpResponseIden::State, closed.as_str().into()),
|
||||
(HttpResponseIden::StatusReason, "Cancelled".into()),
|
||||
])
|
||||
.cond_where(Expr::col(HttpResponseIden::Elapsed).eq(0))
|
||||
.cond_where(Expr::col(HttpResponseIden::State).ne(closed.as_str()))
|
||||
.build_rusqlite(SqliteQueryBuilder);
|
||||
|
||||
db.execute(sql.as_str(), &*params.as_params())?;
|
||||
@@ -1321,11 +1339,11 @@ pub async fn update_response_if_id<R: Runtime>(
|
||||
if response.id.is_empty() {
|
||||
Ok(response.clone())
|
||||
} else {
|
||||
update_response(window, response).await
|
||||
update_http_response(window, response).await
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_response<R: Runtime>(
|
||||
pub async fn update_http_response<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
response: &HttpResponse,
|
||||
) -> Result<HttpResponse> {
|
||||
@@ -1344,28 +1362,15 @@ pub async fn update_response<R: Runtime>(
|
||||
HttpResponseIden::StatusReason,
|
||||
response.status_reason.as_ref().map(|s| s.as_str()).into(),
|
||||
),
|
||||
(
|
||||
HttpResponseIden::ContentLength,
|
||||
response.content_length.into(),
|
||||
),
|
||||
(
|
||||
HttpResponseIden::BodyPath,
|
||||
response.body_path.as_ref().map(|s| s.as_str()).into(),
|
||||
),
|
||||
(
|
||||
HttpResponseIden::Error,
|
||||
response.error.as_ref().map(|s| s.as_str()).into(),
|
||||
),
|
||||
(HttpResponseIden::ContentLength, response.content_length.into()),
|
||||
(HttpResponseIden::BodyPath, response.body_path.as_ref().map(|s| s.as_str()).into()),
|
||||
(HttpResponseIden::Error, response.error.as_ref().map(|s| s.as_str()).into()),
|
||||
(
|
||||
HttpResponseIden::Headers,
|
||||
serde_json::to_string(&response.headers)
|
||||
.unwrap_or_default()
|
||||
.into(),
|
||||
),
|
||||
(
|
||||
HttpResponseIden::Version,
|
||||
response.version.as_ref().map(|s| s.as_str()).into(),
|
||||
serde_json::to_string(&response.headers).unwrap_or_default().into(),
|
||||
),
|
||||
(HttpResponseIden::Version, response.version.as_ref().map(|s| s.as_str()).into()),
|
||||
(HttpResponseIden::State, serde_json::to_value(&response.state)?.as_str().into()),
|
||||
(
|
||||
HttpResponseIden::RemoteAddr,
|
||||
response.remote_addr.as_ref().map(|s| s.as_str()).into(),
|
||||
@@ -1418,17 +1423,37 @@ pub async fn delete_http_response<R: Runtime>(
|
||||
emit_deleted_model(window, resp)
|
||||
}
|
||||
|
||||
pub async fn delete_all_http_responses<R: Runtime>(
|
||||
pub async fn delete_all_http_responses_for_request<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
request_id: &str,
|
||||
) -> Result<()> {
|
||||
for r in list_http_responses(window, request_id, None).await? {
|
||||
for r in list_http_responses_for_request(window, request_id, None).await? {
|
||||
delete_http_response(window, &r.id).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn list_http_responses<R: Runtime>(
|
||||
mgr: &impl Manager<R>,
|
||||
workspace_id: &str,
|
||||
limit: Option<i64>,
|
||||
) -> Result<Vec<HttpResponse>> {
|
||||
let limit_unwrapped = limit.unwrap_or_else(|| i64::MAX);
|
||||
let dbm = mgr.state::<SqliteConnection>();
|
||||
let db = dbm.0.lock().await.get().unwrap();
|
||||
let (sql, params) = Query::select()
|
||||
.from(HttpResponseIden::Table)
|
||||
.cond_where(Expr::col(HttpResponseIden::WorkspaceId).eq(workspace_id))
|
||||
.column(Asterisk)
|
||||
.order_by(HttpResponseIden::CreatedAt, Order::Desc)
|
||||
.limit(limit_unwrapped as u64)
|
||||
.build_rusqlite(SqliteQueryBuilder);
|
||||
let mut stmt = db.prepare(sql.as_str())?;
|
||||
let items = stmt.query_map(&*params.as_params(), |row| row.try_into())?;
|
||||
Ok(items.map(|v| v.unwrap()).collect())
|
||||
}
|
||||
|
||||
pub async fn list_http_responses_for_request<R: Runtime>(
|
||||
mgr: &impl Manager<R>,
|
||||
request_id: &str,
|
||||
limit: Option<i64>,
|
||||
|
||||
@@ -95,7 +95,12 @@ export type SendHttpRequestResponse = { httpResponse: HttpResponse, };
|
||||
|
||||
export type ShowToastRequest = { message: string, color?: Color, icon?: Icon, };
|
||||
|
||||
export type TemplateFunction = { name: string, aliases?: Array<string>, args: Array<TemplateFunctionArg>, };
|
||||
export type TemplateFunction = { name: string, description?: string,
|
||||
/**
|
||||
* Also support alternative names. This is useful for not breaking existing
|
||||
* tags when changing the `name` property
|
||||
*/
|
||||
aliases?: Array<string>, args: Array<TemplateFunctionArg>, };
|
||||
|
||||
export type TemplateFunctionArg = { "type": "text" } & TemplateFunctionTextArg | { "type": "select" } & TemplateFunctionSelectArg | { "type": "checkbox" } & TemplateFunctionCheckboxArg | { "type": "http_request" } & TemplateFunctionHttpRequestArg | { "type": "file" } & TemplateFunctionFileArg;
|
||||
|
||||
|
||||
@@ -14,10 +14,12 @@ export type HttpRequest = { model: "http_request", id: string, createdAt: string
|
||||
|
||||
export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, };
|
||||
|
||||
export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, elapsed: number, elapsedHeaders: number, error: string | null, headers: Array<HttpResponseHeader>, remoteAddr: string | null, status: number, statusReason: string | null, url: string, version: string | null, };
|
||||
export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, elapsed: number, elapsedHeaders: number, error: string | null, headers: Array<HttpResponseHeader>, remoteAddr: string | null, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, };
|
||||
|
||||
export type HttpResponseHeader = { name: string, value: string, };
|
||||
|
||||
export type HttpResponseState = "initialized" | "connected" | "closed";
|
||||
|
||||
export type HttpUrlParameter = { enabled?: boolean, name: string, value: string, };
|
||||
|
||||
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, name: string, description: string, variables: Array<EnvironmentVariable>, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, };
|
||||
|
||||
@@ -294,6 +294,8 @@ pub struct GetTemplateFunctionsResponse {
|
||||
#[ts(export, export_to = "events.ts")]
|
||||
pub struct TemplateFunction {
|
||||
pub name: String,
|
||||
#[ts(optional)]
|
||||
pub description: Option<String>,
|
||||
|
||||
/// Also support alternative names. This is useful for not breaking existing
|
||||
/// tags when changing the `name` property
|
||||
|
||||
9
src-tauri/yaak_sse/Cargo.toml
Normal file
9
src-tauri/yaak_sse/Cargo.toml
Normal file
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "yaak_sse"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0.204", features = ["derive"] }
|
||||
serde_json = "1.0.122"
|
||||
ts-rs = { version = "10.0.0", features = ["serde-json-impl"] }
|
||||
3
src-tauri/yaak_sse/bindings/sse.ts
Normal file
3
src-tauri/yaak_sse/bindings/sse.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type ServerSentEvent = { eventType: string, data: string, id: string | null, retry: bigint | null, };
|
||||
1
src-tauri/yaak_sse/index.ts
Normal file
1
src-tauri/yaak_sse/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './bindings/sse';
|
||||
6
src-tauri/yaak_sse/package.json
Normal file
6
src-tauri/yaak_sse/package.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "@yaakapp-internal/sse",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"main": "index.ts"
|
||||
}
|
||||
1
src-tauri/yaak_sse/src/lib.rs
Normal file
1
src-tauri/yaak_sse/src/lib.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod sse;
|
||||
12
src-tauri/yaak_sse/src/sse.rs
Normal file
12
src-tauri/yaak_sse/src/sse.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "sse.ts")]
|
||||
pub struct ServerSentEvent {
|
||||
pub event_type: String,
|
||||
pub data: String,
|
||||
pub id: Option<String>,
|
||||
pub retry: Option<u64>,
|
||||
}
|
||||
2
src-web/.gitignore
vendored
Normal file
2
src-web/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
vite.config.d.ts
|
||||
vite.config.js
|
||||
@@ -1,4 +1,4 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { QueryCache, QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
import { MotionConfig } from 'framer-motion';
|
||||
import React, { Suspense } from 'react';
|
||||
@@ -7,18 +7,25 @@ import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||
import { HelmetProvider } from 'react-helmet-async';
|
||||
import { AppRouter } from './AppRouter';
|
||||
|
||||
const ENABLE_REACT_QUERY_DEVTOOLS = false;
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
queryCache: new QueryCache({
|
||||
onError: (err, query) => {
|
||||
console.log('Query client error', { err, query });
|
||||
},
|
||||
}),
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
networkMode: 'always',
|
||||
refetchOnWindowFocus: true,
|
||||
networkMode: 'offlineFirst',
|
||||
refetchOnReconnect: false,
|
||||
refetchOnMount: false, // Don't refetch when a hook mounts
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const ENABLE_REACT_QUERY_DEVTOOLS = false;
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import classNames from 'classnames';
|
||||
import { search } from 'fast-fuzzy';
|
||||
import { fuzzyFilter } from 'fuzzbunny';
|
||||
import type { KeyboardEvent, ReactNode } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useActiveCookieJar } from '../hooks/useActiveCookieJar';
|
||||
@@ -328,15 +328,21 @@ export function CommandPalette({ onClose }: { onClose: () => void }) {
|
||||
|
||||
const { filteredGroups, filteredAllItems } = useMemo(() => {
|
||||
const result = command
|
||||
? search(command, allItems, {
|
||||
threshold: 0.5,
|
||||
keySelector: (v) => ('searchText' in v ? v.searchText : v.label),
|
||||
})
|
||||
? fuzzyFilter(
|
||||
allItems.map((i) => ({
|
||||
...i,
|
||||
filterBy: 'searchText' in i ? i.searchText : i.label,
|
||||
})),
|
||||
command,
|
||||
{ fields: ['filterBy'] },
|
||||
).map((v) => v.item)
|
||||
: allItems;
|
||||
|
||||
const filteredGroups = groups
|
||||
.map((g) => {
|
||||
g.items = result.filter((i) => g.items.includes(i)).slice(0, MAX_PER_GROUP);
|
||||
g.items = result
|
||||
.filter((i) => g.items.find((i2) => i2.key === i.key))
|
||||
.slice(0, MAX_PER_GROUP);
|
||||
return g;
|
||||
})
|
||||
.filter((g) => g.items.length > 0);
|
||||
|
||||
@@ -12,8 +12,8 @@ interface Props {
|
||||
|
||||
export const CookieDialog = function ({ cookieJarId }: Props) {
|
||||
const updateCookieJar = useUpdateCookieJar(cookieJarId ?? null);
|
||||
const cookieJars = useCookieJars().data ?? [];
|
||||
const cookieJar = cookieJars.find((c) => c.id === cookieJarId);
|
||||
const cookieJars = useCookieJars();
|
||||
const cookieJar = cookieJars?.find((c) => c.id === cookieJarId);
|
||||
|
||||
if (cookieJar == null) {
|
||||
return <div>No cookie jar selected</div>;
|
||||
|
||||
@@ -12,7 +12,7 @@ import { InlineCode } from './core/InlineCode';
|
||||
import { useDialog } from './DialogContext';
|
||||
|
||||
export function CookieDropdown() {
|
||||
const cookieJars = useCookieJars().data ?? [];
|
||||
const cookieJars = useCookieJars() ?? [];
|
||||
const [activeCookieJar, setActiveCookieJarId] = useActiveCookieJar();
|
||||
const updateCookieJar = useUpdateCookieJar(activeCookieJar?.id ?? null);
|
||||
const deleteCookieJar = useDeleteCookieJar(activeCookieJar ?? null);
|
||||
|
||||
@@ -2,8 +2,8 @@ import classNames from 'classnames';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { useOsInfo } from '../hooks/useOsInfo';
|
||||
import { DialogProvider, Dialogs } from './DialogContext';
|
||||
import { GlobalHooks } from './GlobalHooks';
|
||||
import { ToastProvider, Toasts } from './ToastContext';
|
||||
import { GlobalHooks } from './GlobalHooks';
|
||||
|
||||
export function DefaultLayout() {
|
||||
const osInfo = useOsInfo();
|
||||
|
||||
@@ -1,187 +1,43 @@
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { emit } from '@tauri-apps/api/event';
|
||||
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
|
||||
import type { AnyModel } from '@yaakapp-internal/models';
|
||||
import type { PromptTextRequest, PromptTextResponse } from '@yaakapp-internal/plugin';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { useEffect } from 'react';
|
||||
import { useEnsureActiveCookieJar, useMigrateActiveCookieJarId } from '../hooks/useActiveCookieJar';
|
||||
import { useEnsureActiveCookieJar } from '../hooks/useActiveCookieJar';
|
||||
import { useActiveWorkspaceChangedToast } from '../hooks/useActiveWorkspaceChangedToast';
|
||||
import { cookieJarsQueryKey } from '../hooks/useCookieJars';
|
||||
import { useCopy } from '../hooks/useCopy';
|
||||
import { environmentsAtom } from '../hooks/useEnvironments';
|
||||
import { foldersQueryKey } from '../hooks/useFolders';
|
||||
import { grpcConnectionsQueryKey } from '../hooks/useGrpcConnections';
|
||||
import { grpcEventsQueryKey } from '../hooks/useGrpcEvents';
|
||||
import { grpcRequestsAtom } from '../hooks/useGrpcRequests';
|
||||
import {useGenerateThemeCss} from "../hooks/useGenerateThemeCss";
|
||||
import { useHotKey } from '../hooks/useHotKey';
|
||||
import { httpRequestsAtom } from '../hooks/useHttpRequests';
|
||||
import { httpResponsesQueryKey } from '../hooks/useHttpResponses';
|
||||
import { keyValueQueryKey } from '../hooks/useKeyValue';
|
||||
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
|
||||
import { useNotificationToast } from '../hooks/useNotificationToast';
|
||||
import { pluginsAtom } from '../hooks/usePlugins';
|
||||
import { usePrompt } from '../hooks/usePrompt';
|
||||
import { useRecentCookieJars } from '../hooks/useRecentCookieJars';
|
||||
import { useRecentEnvironments } from '../hooks/useRecentEnvironments';
|
||||
import { useRecentRequests } from '../hooks/useRecentRequests';
|
||||
import { useRecentWorkspaces } from '../hooks/useRecentWorkspaces';
|
||||
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
|
||||
import { settingsAtom, useSettings } from '../hooks/useSettings';
|
||||
import {useSyncFontSizeSetting} from "../hooks/useSyncFontSizeSetting";
|
||||
import {useSyncModelStores} from "../hooks/useSyncModelStores";
|
||||
import { useSyncWorkspaceChildModels } from '../hooks/useSyncWorkspaceChildModels';
|
||||
import {useSyncZoomSetting} from "../hooks/useSyncZoomSetting";
|
||||
import { useToggleCommandPalette } from '../hooks/useToggleCommandPalette';
|
||||
import { workspacesAtom } from '../hooks/useWorkspaces';
|
||||
import { useZoom } from '../hooks/useZoom';
|
||||
import { extractKeyValue } from '../lib/keyValueStore';
|
||||
import { modelsEq } from '../lib/model_util';
|
||||
import { catppuccinMacchiato } from '../lib/theme/themes/catppuccin';
|
||||
import { githubLight } from '../lib/theme/themes/github';
|
||||
import { hotdogStandDefault } from '../lib/theme/themes/hotdog-stand';
|
||||
import { monokaiProDefault } from '../lib/theme/themes/monokai-pro';
|
||||
import { rosePineDefault } from '../lib/theme/themes/rose-pine';
|
||||
import { yaakDark } from '../lib/theme/themes/yaak';
|
||||
import { getThemeCSS } from '../lib/theme/window';
|
||||
|
||||
export interface ModelPayload {
|
||||
model: AnyModel;
|
||||
windowLabel: string;
|
||||
}
|
||||
|
||||
export function GlobalHooks() {
|
||||
useSyncModelStores();
|
||||
useSyncZoomSetting();
|
||||
useSyncFontSizeSetting();
|
||||
useGenerateThemeCss();
|
||||
|
||||
// Include here so they always update, even if no component references them
|
||||
useRecentWorkspaces();
|
||||
useRecentEnvironments();
|
||||
useRecentCookieJars();
|
||||
useRecentRequests();
|
||||
useSyncWorkspaceChildModels();
|
||||
|
||||
// Other useful things
|
||||
useNotificationToast();
|
||||
useActiveWorkspaceChangedToast();
|
||||
useEnsureActiveCookieJar();
|
||||
|
||||
// TODO: Remove in future version
|
||||
useMigrateActiveCookieJarId();
|
||||
|
||||
const toggleCommandPalette = useToggleCommandPalette();
|
||||
useHotKey('command_palette.toggle', toggleCommandPalette);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const { wasUpdatedExternally } = useRequestUpdateKey(null);
|
||||
|
||||
const setSettings = useSetAtom(settingsAtom);
|
||||
const setWorkspaces = useSetAtom(workspacesAtom);
|
||||
const setPlugins = useSetAtom(pluginsAtom);
|
||||
const setHttpRequests = useSetAtom(httpRequestsAtom);
|
||||
const setGrpcRequests = useSetAtom(grpcRequestsAtom);
|
||||
const setEnvironments = useSetAtom(environmentsAtom);
|
||||
|
||||
useListenToTauriEvent<ModelPayload>('upserted_model', ({ payload }) => {
|
||||
console.log('Upserted model', payload.model);
|
||||
const { model, windowLabel } = payload;
|
||||
const queryKey =
|
||||
model.model === 'http_response'
|
||||
? httpResponsesQueryKey(model)
|
||||
: model.model === 'folder'
|
||||
? foldersQueryKey(model)
|
||||
: model.model === 'grpc_connection'
|
||||
? grpcConnectionsQueryKey(model)
|
||||
: model.model === 'grpc_event'
|
||||
? grpcEventsQueryKey(model)
|
||||
: model.model === 'key_value'
|
||||
? keyValueQueryKey(model)
|
||||
: model.model === 'cookie_jar'
|
||||
? cookieJarsQueryKey(model)
|
||||
: null;
|
||||
|
||||
if (model.model === 'http_request' && windowLabel !== getCurrentWebviewWindow().label) {
|
||||
wasUpdatedExternally(model.id);
|
||||
}
|
||||
|
||||
const pushToFront = (['http_response', 'grpc_connection'] as AnyModel['model'][]).includes(
|
||||
model.model,
|
||||
);
|
||||
|
||||
if (shouldIgnoreModel(model, windowLabel)) return;
|
||||
|
||||
if (model.model === 'workspace') {
|
||||
setWorkspaces(updateModelList(model, pushToFront));
|
||||
} else if (model.model === 'plugin') {
|
||||
setPlugins(updateModelList(model, pushToFront));
|
||||
} else if (model.model === 'http_request') {
|
||||
setHttpRequests(updateModelList(model, pushToFront));
|
||||
} else if (model.model === 'grpc_request') {
|
||||
setGrpcRequests(updateModelList(model, pushToFront));
|
||||
} else if (model.model === 'environment') {
|
||||
setEnvironments(updateModelList(model, pushToFront));
|
||||
} else if (model.model === 'settings') {
|
||||
setSettings(model);
|
||||
} else if (queryKey != null) {
|
||||
// TODO: Convert all models to use Jotai
|
||||
queryClient.setQueryData(queryKey, (current: unknown) => {
|
||||
if (model.model === 'key_value') {
|
||||
// Special-case for KeyValue
|
||||
return extractKeyValue(model);
|
||||
}
|
||||
|
||||
if (Array.isArray(current)) {
|
||||
return updateModelList(model, pushToFront)(current);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
useListenToTauriEvent<ModelPayload>('deleted_model', ({ payload }) => {
|
||||
const { model, windowLabel } = payload;
|
||||
if (shouldIgnoreModel(model, windowLabel)) return;
|
||||
|
||||
console.log('Delete model', payload.model);
|
||||
|
||||
if (model.model === 'workspace') {
|
||||
setWorkspaces(removeById(model));
|
||||
} else if (model.model === 'plugin') {
|
||||
setPlugins(removeById(model));
|
||||
} else if (model.model === 'http_request') {
|
||||
setHttpRequests(removeById(model));
|
||||
} else if (model.model === 'http_response') {
|
||||
queryClient.setQueryData(httpResponsesQueryKey(model), removeById(model));
|
||||
} else if (model.model === 'folder') {
|
||||
queryClient.setQueryData(foldersQueryKey(model), removeById(model));
|
||||
} else if (model.model === 'environment') {
|
||||
setEnvironments(removeById(model));
|
||||
} else if (model.model === 'grpc_request') {
|
||||
setGrpcRequests(removeById(model));
|
||||
} else if (model.model === 'grpc_connection') {
|
||||
queryClient.setQueryData(grpcConnectionsQueryKey(model), removeById(model));
|
||||
} else if (model.model === 'grpc_event') {
|
||||
queryClient.setQueryData(grpcEventsQueryKey(model), removeById(model));
|
||||
} else if (model.model === 'key_value') {
|
||||
queryClient.setQueryData(keyValueQueryKey(model), undefined);
|
||||
} else if (model.model === 'cookie_jar') {
|
||||
queryClient.setQueryData(cookieJarsQueryKey(model), undefined);
|
||||
}
|
||||
});
|
||||
|
||||
const settings = useSettings();
|
||||
useEffect(() => {
|
||||
if (settings == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { interfaceScale, editorFontSize } = settings;
|
||||
getCurrentWebviewWindow().setZoom(interfaceScale).catch(console.error);
|
||||
document.documentElement.style.setProperty('--editor-font-size', `${editorFontSize}px`);
|
||||
}, [settings]);
|
||||
|
||||
// Handle Zoom.
|
||||
// Note, Mac handles it in the app menu, so need to also handle keyboard
|
||||
// shortcuts for Windows/Linux
|
||||
const zoom = useZoom();
|
||||
useHotKey('app.zoom_in', zoom.zoomIn);
|
||||
useListenToTauriEvent('zoom_in', zoom.zoomIn);
|
||||
useHotKey('app.zoom_out', zoom.zoomOut);
|
||||
useListenToTauriEvent('zoom_out', zoom.zoomOut);
|
||||
useHotKey('app.zoom_reset', zoom.zoomReset);
|
||||
useListenToTauriEvent('zoom_reset', zoom.zoomReset);
|
||||
|
||||
const prompt = usePrompt();
|
||||
useListenToTauriEvent<{ replyId: string; args: PromptTextRequest }>(
|
||||
'show_prompt',
|
||||
@@ -192,46 +48,5 @@ export function GlobalHooks() {
|
||||
},
|
||||
);
|
||||
|
||||
const copy = useCopy();
|
||||
useListenToTauriEvent('generate_theme_css', () => {
|
||||
const themesCss = [
|
||||
yaakDark,
|
||||
monokaiProDefault,
|
||||
rosePineDefault,
|
||||
catppuccinMacchiato,
|
||||
githubLight,
|
||||
hotdogStandDefault,
|
||||
]
|
||||
.map(getThemeCSS)
|
||||
.join('\n\n');
|
||||
copy(themesCss);
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function updateModelList<T extends AnyModel>(model: T, pushToFront: boolean) {
|
||||
return (current: T[]): T[] => {
|
||||
const index = current.findIndex((v) => modelsEq(v, model)) ?? -1;
|
||||
if (index >= 0) {
|
||||
return [...current.slice(0, index), model, ...current.slice(index + 1)];
|
||||
} else {
|
||||
return pushToFront ? [model, ...(current ?? [])] : [...(current ?? []), model];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function removeById<T extends { id: string }>(model: T) {
|
||||
return (entries: T[] | undefined) => entries?.filter((e) => e.id !== model.id) ?? [];
|
||||
}
|
||||
|
||||
const shouldIgnoreModel = (payload: AnyModel, windowLabel: string) => {
|
||||
if (windowLabel === getCurrentWebviewWindow().label) {
|
||||
// Never ignore same-window updates
|
||||
return false;
|
||||
}
|
||||
if (payload.model === 'key_value') {
|
||||
return payload.namespace === 'no_sync';
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -22,7 +22,7 @@ const emptyArray: string[] = [];
|
||||
export function GrpcConnectionLayout({ style }: Props) {
|
||||
const activeRequest = useActiveRequest('grpc_request');
|
||||
const updateRequest = useUpdateAnyGrpcRequest();
|
||||
const connections = useGrpcConnections(activeRequest?.id ?? null);
|
||||
const connections = useGrpcConnections().filter((c) => c.requestId === activeRequest?.id);
|
||||
const activeConnection = connections[0] ?? null;
|
||||
const messages = useGrpcEvents(activeConnection?.id ?? null);
|
||||
const protoFilesKv = useGrpcProtoFiles(activeRequest?.id ?? null);
|
||||
|
||||
@@ -188,7 +188,7 @@ function EventRow({
|
||||
className={classNames(
|
||||
'w-full grid grid-cols-[auto_minmax(0,3fr)_auto] gap-2 items-center text-left',
|
||||
'px-1.5 py-1 font-mono cursor-default group focus:outline-none rounded',
|
||||
isActive && '!bg-surface-highlight !text',
|
||||
isActive && '!bg-surface-highlight !text-text',
|
||||
'text-text-subtle hover:text',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import useResizeObserver from '@react-hook/resize-observer';
|
||||
import type { GrpcMetadataEntry, GrpcRequest } from '@yaakapp-internal/models';
|
||||
import classNames from 'classnames';
|
||||
import type { CSSProperties } from 'react';
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
@@ -6,7 +7,6 @@ import { createGlobalState } from 'react-use';
|
||||
import type { ReflectResponseService } from '../hooks/useGrpc';
|
||||
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
|
||||
import { useUpdateAnyGrpcRequest } from '../hooks/useUpdateAnyGrpcRequest';
|
||||
import type { GrpcMetadataEntry, GrpcRequest } from '@yaakapp-internal/models';
|
||||
import { AUTH_TYPE_BASIC, AUTH_TYPE_BEARER, AUTH_TYPE_NONE } from '../lib/model_util';
|
||||
import { BasicAuth } from './BasicAuth';
|
||||
import { BearerAuth } from './BearerAuth';
|
||||
|
||||
@@ -6,9 +6,7 @@ import {
|
||||
handleRefresh,
|
||||
jsonCompletion,
|
||||
jsonSchemaLinter,
|
||||
// eslint-disable-next-line import/named
|
||||
stateExtensions,
|
||||
// eslint-disable-next-line import/named
|
||||
updateSchema,
|
||||
} from 'codemirror-json-schema';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
@@ -1,50 +1,54 @@
|
||||
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
|
||||
import classNames from 'classnames';
|
||||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
import { useIsFullscreen } from '../hooks/useIsFullscreen';
|
||||
import { useOsInfo } from '../hooks/useOsInfo';
|
||||
import React from 'react';
|
||||
import { useSettings } from '../hooks/useSettings';
|
||||
import { useStoplightsVisible } from '../hooks/useStoplightsVisible';
|
||||
import { WINDOW_CONTROLS_WIDTH, WindowControls } from './WindowControls';
|
||||
|
||||
interface HeaderSizeProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children?: ReactNode;
|
||||
size: 'md' | 'lg';
|
||||
ignoreStoplights?: boolean;
|
||||
ignoreControlsSpacing?: boolean;
|
||||
onlyXWindowControl?: boolean;
|
||||
}
|
||||
|
||||
export const HEADER_SIZE_MD = '27px';
|
||||
export const HEADER_SIZE_LG = '38px';
|
||||
|
||||
export function HeaderSize({
|
||||
className,
|
||||
style,
|
||||
size,
|
||||
ignoreStoplights,
|
||||
...props
|
||||
ignoreControlsSpacing,
|
||||
onlyXWindowControl,
|
||||
children,
|
||||
}: HeaderSizeProps) {
|
||||
const settings = useSettings();
|
||||
const platform = useOsInfo();
|
||||
const fullscreen = useIsFullscreen();
|
||||
const stoplightsVisible = platform?.osType === 'macos' && !fullscreen && !ignoreStoplights;
|
||||
const stoplightsVisible = useStoplightsVisible();
|
||||
return (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
onDoubleClick={async () => {
|
||||
// Maximize window on double-click
|
||||
await getCurrentWebviewWindow().toggleMaximize();
|
||||
}}
|
||||
style={{
|
||||
...style,
|
||||
// Add padding for macOS stoplights, but keep it the same width (account for the interface scale)
|
||||
paddingLeft: stoplightsVisible ? 72 / settings.interfaceScale : undefined,
|
||||
...(size === 'md' ? { height: HEADER_SIZE_MD } : {}),
|
||||
...(size === 'lg' ? { height: HEADER_SIZE_LG } : {}),
|
||||
...(stoplightsVisible || ignoreControlsSpacing
|
||||
? { paddingRight: '2px' }
|
||||
: { paddingLeft: '2px', paddingRight: WINDOW_CONTROLS_WIDTH }),
|
||||
}}
|
||||
className={classNames(
|
||||
className,
|
||||
'select-none',
|
||||
'select-none relative',
|
||||
'pt-[1px] w-full border-b border-border-subtle min-w-0',
|
||||
stoplightsVisible ? 'pr-1' : 'pl-1',
|
||||
size === 'md' && 'h-[27px]',
|
||||
size === 'lg' && 'h-[38px]',
|
||||
)}
|
||||
>
|
||||
{/* NOTE: This needs display:grid or else the element shrinks (even though scrollable) */}
|
||||
<div className="h-full w-full overflow-x-auto hide-scrollbars grid" {...props} />
|
||||
<div className="pointer-events-none h-full w-full overflow-x-auto hide-scrollbars grid">
|
||||
{children}
|
||||
</div>
|
||||
<WindowControls onlyX={onlyXWindowControl} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ export function Overlay({
|
||||
onClick={onClose}
|
||||
className={classNames(
|
||||
'absolute inset-0',
|
||||
variant === 'default' && 'bg-surface-backdrop backdrop-blur-sm',
|
||||
variant === 'default' && 'bg-backdrop backdrop-blur-sm',
|
||||
)}
|
||||
/>
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ export function RecentRequestsDropdown({ className }: Pick<ButtonProps, 'classNa
|
||||
hotkeyAction="request_switcher.toggle"
|
||||
className={classNames(
|
||||
className,
|
||||
'text truncate pointer-events-auto',
|
||||
'truncate pointer-events-auto',
|
||||
activeRequest === null && 'text-text-subtlest italic',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -37,7 +37,7 @@ export const RecentResponsesDropdown = function ResponsePane({
|
||||
onSelect: saveResponse.mutate,
|
||||
leftSlot: <Icon icon="save" />,
|
||||
hidden: responses.length === 0,
|
||||
disabled: responses.length === 0,
|
||||
disabled: activeResponse.state !== 'closed' && activeResponse.status >= 100,
|
||||
},
|
||||
{
|
||||
key: 'copy',
|
||||
@@ -45,14 +45,14 @@ export const RecentResponsesDropdown = function ResponsePane({
|
||||
onSelect: copyResponse.mutate,
|
||||
leftSlot: <Icon icon="copy" />,
|
||||
hidden: responses.length === 0,
|
||||
disabled: responses.length === 0,
|
||||
disabled: activeResponse.state !== 'closed' && activeResponse.status >= 100,
|
||||
},
|
||||
{
|
||||
key: 'clear-single',
|
||||
label: 'Delete',
|
||||
leftSlot: <Icon icon="trash" />,
|
||||
onSelect: deleteResponse.mutate,
|
||||
disabled: responses.length === 0,
|
||||
disabled: activeResponse.state !== 'closed',
|
||||
},
|
||||
{
|
||||
key: 'unpin',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { HttpRequest } from '@yaakapp-internal/models';
|
||||
import type { HttpRequest, HttpResponse } from '@yaakapp-internal/models';
|
||||
import classNames from 'classnames';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import React, { memo, useCallback, useMemo } from 'react';
|
||||
import { useLocalStorage } from 'react-use';
|
||||
import { useContentTypeFromHeaders } from '../hooks/useContentTypeFromHeaders';
|
||||
import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse';
|
||||
@@ -23,6 +23,7 @@ import { ResponseHeaders } from './ResponseHeaders';
|
||||
import { ResponseInfo } from './ResponseInfo';
|
||||
import { AudioViewer } from './responseViewers/AudioViewer';
|
||||
import { CsvViewer } from './responseViewers/CsvViewer';
|
||||
import { EventStreamViewer } from './responseViewers/EventStreamViewer';
|
||||
import { HTMLOrTextViewer } from './responseViewers/HTMLOrTextViewer';
|
||||
import { ImageViewer } from './responseViewers/ImageViewer';
|
||||
import { PdfViewer } from './responseViewers/PdfViewer';
|
||||
@@ -88,6 +89,8 @@ export const ResponsePane = memo(function ResponsePane({ style, className, activ
|
||||
[activeResponse?.headers, contentType, setViewMode, viewMode],
|
||||
);
|
||||
|
||||
const isLoading = isResponseLoading(activeResponse);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={style}
|
||||
@@ -103,10 +106,6 @@ export const ResponsePane = memo(function ResponsePane({ style, className, activ
|
||||
<HotKeyList
|
||||
hotkeys={['http_request.send', 'http_request.create', 'sidebar.focus', 'urlBar.focus']}
|
||||
/>
|
||||
) : isResponseLoading(activeResponse) ? (
|
||||
<div className="h-full w-full flex items-center justify-center">
|
||||
<Icon size="lg" className="opacity-disabled" spin icon="refresh" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full w-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1">
|
||||
<HStack
|
||||
@@ -119,27 +118,21 @@ export const ResponsePane = memo(function ResponsePane({ style, className, activ
|
||||
{activeResponse && (
|
||||
<HStack
|
||||
space={2}
|
||||
alignItems="center"
|
||||
className={classNames(
|
||||
'cursor-default select-none',
|
||||
'whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm',
|
||||
)}
|
||||
>
|
||||
{isLoading && <Icon size="sm" icon="refresh" spin />}
|
||||
<StatusTag showReason response={activeResponse} />
|
||||
{activeResponse.elapsed > 0 && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<DurationTag
|
||||
headers={activeResponse.elapsedHeaders}
|
||||
total={activeResponse.elapsed}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{!!activeResponse.contentLength && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<SizeTag contentLength={activeResponse.contentLength} />
|
||||
</>
|
||||
)}
|
||||
<span>•</span>
|
||||
<DurationTag
|
||||
headers={activeResponse.elapsedHeaders}
|
||||
total={activeResponse.elapsed}
|
||||
/>
|
||||
<span>•</span>
|
||||
<SizeTag contentLength={activeResponse.contentLength ?? 0} />
|
||||
|
||||
<div className="ml-auto">
|
||||
<RecentResponsesDropdown
|
||||
@@ -171,15 +164,17 @@ export const ResponsePane = memo(function ResponsePane({ style, className, activ
|
||||
<div className="pb-2 h-full">
|
||||
<EmptyStateText>Empty Body</EmptyStateText>
|
||||
</div>
|
||||
) : contentType?.startsWith('image') ? (
|
||||
<ImageViewer className="pb-2" response={activeResponse} />
|
||||
) : contentType?.startsWith('audio') ? (
|
||||
<AudioViewer response={activeResponse} />
|
||||
) : contentType?.startsWith('video') ? (
|
||||
<VideoViewer response={activeResponse} />
|
||||
) : contentType?.match(/pdf/) ? (
|
||||
<PdfViewer response={activeResponse} />
|
||||
) : contentType?.match(/csv|tab-separated/) ? (
|
||||
) : contentType?.match(/^text\/event-stream$/i) && viewMode === 'pretty' ? (
|
||||
<EventStreamViewer response={activeResponse} />
|
||||
) : contentType?.match(/^image/i) ? (
|
||||
<EnsureCompleteResponse response={activeResponse} render={ImageViewer} />
|
||||
) : contentType?.match(/^audio/i) ? (
|
||||
<EnsureCompleteResponse response={activeResponse} render={AudioViewer} />
|
||||
) : contentType?.match(/^video/i) ? (
|
||||
<EnsureCompleteResponse response={activeResponse} render={VideoViewer} />
|
||||
) : contentType?.match(/pdf/i) ? (
|
||||
<EnsureCompleteResponse response={activeResponse} render={PdfViewer} />
|
||||
) : contentType?.match(/csv|tab-separated/i) ? (
|
||||
<CsvViewer className="pb-2" response={activeResponse} />
|
||||
) : (
|
||||
// ) : viewMode === 'pretty' && contentType?.includes('json') ? (
|
||||
@@ -204,3 +199,26 @@ export const ResponsePane = memo(function ResponsePane({ style, className, activ
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function EnsureCompleteResponse({
|
||||
response,
|
||||
render,
|
||||
}: {
|
||||
response: HttpResponse;
|
||||
render: (v: { bodyPath: string }) => ReactNode;
|
||||
}) {
|
||||
if (response.bodyPath === null) {
|
||||
return <div>Empty response body</div>;
|
||||
}
|
||||
|
||||
// Wait until the response has been fully-downloaded
|
||||
if (response.state !== 'closed') {
|
||||
return (
|
||||
<EmptyStateText>
|
||||
<Icon icon="refresh" spin />
|
||||
</EmptyStateText>
|
||||
);
|
||||
}
|
||||
|
||||
return render({ bodyPath: response.bodyPath });
|
||||
}
|
||||
|
||||
@@ -7,46 +7,63 @@ import { capitalize } from '../../lib/capitalize';
|
||||
import { HStack } from '../core/Stacks';
|
||||
import { TabContent, Tabs } from '../core/Tabs/Tabs';
|
||||
import { HeaderSize } from '../HeaderSize';
|
||||
import { WindowControls } from '../WindowControls';
|
||||
import { SettingsAppearance } from './SettingsAppearance';
|
||||
import { SettingsGeneral } from './SettingsGeneral';
|
||||
import { SettingsPlugins } from './SettingsPlugins';
|
||||
import {SettingsProxy} from "./SettingsProxy";
|
||||
|
||||
interface Props {
|
||||
hide?: () => void;
|
||||
}
|
||||
|
||||
enum Tab {
|
||||
General = 'general',
|
||||
Proxy = 'proxy',
|
||||
Appearance = 'appearance',
|
||||
Plugins = 'plugins',
|
||||
}
|
||||
|
||||
const tabs = [Tab.General, Tab.Appearance, Tab.Plugins];
|
||||
const tabs = [Tab.General, Tab.Appearance, Tab.Proxy, Tab.Plugins];
|
||||
|
||||
export default function Settings() {
|
||||
export default function Settings({ hide }: Props) {
|
||||
const osInfo = useOsInfo();
|
||||
const [tab, setTab] = useState<string>(Tab.General);
|
||||
|
||||
// Close settings window on escape
|
||||
// TODO: Could this be put in a better place? Eg. in Rust key listener when creating the window
|
||||
useKeyPressEvent('Escape', () => getCurrentWebviewWindow().close());
|
||||
useKeyPressEvent('Escape', async () => {
|
||||
if (hide != null) {
|
||||
// It's being shown in a dialog, so close the dialog
|
||||
hide();
|
||||
} else {
|
||||
// It's being shown in a window, so close the window
|
||||
await getCurrentWebviewWindow().close();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={classNames('grid grid-rows-[auto_minmax(0,1fr)] h-full')}>
|
||||
<HeaderSize
|
||||
data-tauri-drag-region
|
||||
ignoreStoplights
|
||||
size="md"
|
||||
className="x-theme-appHeader bg-surface text-text-subtle flex items-center justify-center border-b border-border-subtle text-sm font-semibold"
|
||||
>
|
||||
<HStack
|
||||
space={2}
|
||||
justifyContent="center"
|
||||
className="w-full h-full grid grid-cols-[1fr_auto] pointer-events-none"
|
||||
{hide ? (
|
||||
<span />
|
||||
) : (
|
||||
<HeaderSize
|
||||
data-tauri-drag-region
|
||||
ignoreControlsSpacing
|
||||
onlyXWindowControl
|
||||
size="md"
|
||||
className="x-theme-appHeader bg-surface text-text-subtle flex items-center justify-center border-b border-border-subtle text-sm font-semibold"
|
||||
>
|
||||
<div className={classNames(osInfo?.osType === 'macos' ? 'text-center' : 'pl-2')}>
|
||||
Settings
|
||||
</div>
|
||||
<WindowControls className="ml-auto" onlyX />
|
||||
</HStack>
|
||||
</HeaderSize>
|
||||
<HStack
|
||||
space={2}
|
||||
justifyContent="center"
|
||||
className="w-full h-full grid grid-cols-[1fr_auto] pointer-events-none"
|
||||
>
|
||||
<div className={classNames(osInfo?.osType === 'macos' ? 'text-center' : 'pl-2')}>
|
||||
Settings
|
||||
</div>
|
||||
</HStack>
|
||||
</HeaderSize>
|
||||
)}
|
||||
<Tabs
|
||||
value={tab}
|
||||
addBorders
|
||||
@@ -63,6 +80,9 @@ export default function Settings() {
|
||||
<TabContent value={Tab.Plugins} className="pt-3 overflow-y-auto h-full px-4">
|
||||
<SettingsPlugins />
|
||||
</TabContent>
|
||||
<TabContent value={Tab.Proxy} className="pt-3 overflow-y-auto h-full px-4">
|
||||
<SettingsProxy />
|
||||
</TabContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -27,12 +27,13 @@ export function SettingsGeneral() {
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack space={2} className="mb-4">
|
||||
<VStack space={1.5} className="mb-4">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-1">
|
||||
<Select
|
||||
name="updateChannel"
|
||||
label="Update Channel"
|
||||
labelPosition="left"
|
||||
labelClassName="w-[12rem]"
|
||||
size="sm"
|
||||
value={settings.updateChannel}
|
||||
onChange={(updateChannel) => updateSettings.mutate({ updateChannel })}
|
||||
@@ -54,22 +55,19 @@ export function SettingsGeneral() {
|
||||
name="openWorkspace"
|
||||
label="Open Workspace"
|
||||
labelPosition="left"
|
||||
labelClassName="w-[12rem]"
|
||||
size="sm"
|
||||
value={
|
||||
settings.openWorkspaceNewWindow === true
|
||||
? 'new'
|
||||
: settings.openWorkspaceNewWindow === false
|
||||
? 'current'
|
||||
: 'ask'
|
||||
? 'current'
|
||||
: 'ask'
|
||||
}
|
||||
onChange={(v) => {
|
||||
if (v === 'current') {
|
||||
updateSettings.mutate({ openWorkspaceNewWindow: false });
|
||||
} else if (v === 'new') {
|
||||
updateSettings.mutate({ openWorkspaceNewWindow: true });
|
||||
} else {
|
||||
updateSettings.mutate({ openWorkspaceNewWindow: null });
|
||||
}
|
||||
if (v === 'current') updateSettings.mutate({ openWorkspaceNewWindow: false });
|
||||
else if (v === 'new') updateSettings.mutate({ openWorkspaceNewWindow: true });
|
||||
else updateSettings.mutate({ openWorkspaceNewWindow: null });
|
||||
}}
|
||||
options={[
|
||||
{ label: 'Always Ask', value: 'ask' },
|
||||
@@ -77,7 +75,9 @@ export function SettingsGeneral() {
|
||||
{ label: 'New Window', value: 'new' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
className="mt-3"
|
||||
checked={settings.telemetry}
|
||||
title="Send Usage Statistics"
|
||||
onChange={(telemetry) => updateSettings.mutate({ telemetry })}
|
||||
|
||||
119
src-web/components/Settings/SettingsProxy.tsx
Normal file
119
src-web/components/Settings/SettingsProxy.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import React from 'react';
|
||||
import { useSettings } from '../../hooks/useSettings';
|
||||
import { useUpdateSettings } from '../../hooks/useUpdateSettings';
|
||||
import { Checkbox } from '../core/Checkbox';
|
||||
import { PlainInput } from '../core/PlainInput';
|
||||
import { Select } from '../core/Select';
|
||||
import { Separator } from '../core/Separator';
|
||||
import { HStack, VStack } from '../core/Stacks';
|
||||
|
||||
export function SettingsProxy() {
|
||||
const settings = useSettings();
|
||||
const updateSettings = useUpdateSettings();
|
||||
|
||||
return (
|
||||
<VStack space={1.5} className="mb-4">
|
||||
<Select
|
||||
name="proxy"
|
||||
label="Proxy"
|
||||
hideLabel
|
||||
size="sm"
|
||||
value={settings.proxy?.type ?? 'automatic'}
|
||||
onChange={(v) => {
|
||||
if (v === 'automatic') {
|
||||
updateSettings.mutate({ proxy: undefined });
|
||||
} else if (v === 'enabled') {
|
||||
updateSettings.mutate({
|
||||
proxy: {
|
||||
type: 'enabled',
|
||||
http: '',
|
||||
https: '',
|
||||
auth: { user: '', password: '' },
|
||||
},
|
||||
});
|
||||
} else {
|
||||
updateSettings.mutate({ proxy: { type: 'disabled' } });
|
||||
}
|
||||
}}
|
||||
options={[
|
||||
{ label: 'Automatic Proxy Detection', value: 'automatic' },
|
||||
{ label: 'Custom Proxy Configuration', value: 'enabled' },
|
||||
{ label: 'No Proxy', value: 'disabled' },
|
||||
]}
|
||||
/>
|
||||
{settings.proxy?.type === 'enabled' && (
|
||||
<VStack space={1.5}>
|
||||
<HStack space={1.5} className="mt-3">
|
||||
<PlainInput
|
||||
size="sm"
|
||||
label="HTTP"
|
||||
placeholder="localhost:9090"
|
||||
defaultValue={settings.proxy?.http}
|
||||
onChange={(http) => {
|
||||
const https = settings.proxy?.type === 'enabled' ? settings.proxy.https : '';
|
||||
const auth = settings.proxy?.type === 'enabled' ? settings.proxy.auth : null;
|
||||
updateSettings.mutate({ proxy: { type: 'enabled', http, https, auth } });
|
||||
}}
|
||||
/>
|
||||
<PlainInput
|
||||
size="sm"
|
||||
label="HTTPS"
|
||||
placeholder="localhost:9090"
|
||||
defaultValue={settings.proxy?.https}
|
||||
onChange={(https) => {
|
||||
const http = settings.proxy?.type === 'enabled' ? settings.proxy.http : '';
|
||||
const auth = settings.proxy?.type === 'enabled' ? settings.proxy.auth : null;
|
||||
updateSettings.mutate({ proxy: { type: 'enabled', http, https, auth } });
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
<Separator className="my-6"/>
|
||||
<Checkbox
|
||||
checked={settings.proxy.auth != null}
|
||||
title="Enable authentication"
|
||||
onChange={(enabled) => {
|
||||
const http = settings.proxy?.type === 'enabled' ? settings.proxy.http : '';
|
||||
const https = settings.proxy?.type === 'enabled' ? settings.proxy.https : '';
|
||||
const auth = enabled ? { user: '', password: '' } : null;
|
||||
updateSettings.mutate({ proxy: { type: 'enabled', http, https, auth } });
|
||||
}}
|
||||
/>
|
||||
|
||||
{settings.proxy.auth != null && (
|
||||
<HStack space={1.5}>
|
||||
<PlainInput
|
||||
size="sm"
|
||||
label="User"
|
||||
placeholder="myUser"
|
||||
defaultValue={settings.proxy.auth.user}
|
||||
onChange={(user) => {
|
||||
const https = settings.proxy?.type === 'enabled' ? settings.proxy.https : '';
|
||||
const http = settings.proxy?.type === 'enabled' ? settings.proxy.http : '';
|
||||
const password =
|
||||
settings.proxy?.type === 'enabled' ? (settings.proxy.auth?.password ?? '') : '';
|
||||
const auth = { user, password };
|
||||
updateSettings.mutate({ proxy: { type: 'enabled', http, https, auth } });
|
||||
}}
|
||||
/>
|
||||
<PlainInput
|
||||
size="sm"
|
||||
label="Password"
|
||||
type="password"
|
||||
placeholder="s3cretPassw0rd"
|
||||
defaultValue={settings.proxy.auth.password}
|
||||
onChange={(password) => {
|
||||
const https = settings.proxy?.type === 'enabled' ? settings.proxy.https : '';
|
||||
const http = settings.proxy?.type === 'enabled' ? settings.proxy.http : '';
|
||||
const user =
|
||||
settings.proxy?.type === 'enabled' ? (settings.proxy.auth?.user ?? '') : '';
|
||||
const auth = { user, password };
|
||||
updateSettings.mutate({ proxy: { type: 'enabled', http, https, auth } });
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
)}
|
||||
</VStack>
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import type {
|
||||
AnyModel,
|
||||
Folder,
|
||||
GrpcConnection,
|
||||
GrpcRequest,
|
||||
HttpRequest,
|
||||
HttpResponse,
|
||||
Workspace,
|
||||
} from '@yaakapp-internal/models';
|
||||
import classNames from 'classnames';
|
||||
@@ -22,18 +24,19 @@ import { useDeleteRequest } from '../hooks/useDeleteRequest';
|
||||
import { useDuplicateGrpcRequest } from '../hooks/useDuplicateGrpcRequest';
|
||||
import { useDuplicateHttpRequest } from '../hooks/useDuplicateHttpRequest';
|
||||
import { useFolders } from '../hooks/useFolders';
|
||||
import { useGrpcConnections } from '../hooks/useGrpcConnections';
|
||||
import { useHotKey } from '../hooks/useHotKey';
|
||||
import type { CallableHttpRequestAction } from '../hooks/useHttpRequestActions';
|
||||
import { useHttpRequestActions } from '../hooks/useHttpRequestActions';
|
||||
import { useHttpResponses } from '../hooks/useHttpResponses';
|
||||
import { useKeyValue } from '../hooks/useKeyValue';
|
||||
import { useLatestGrpcConnection } from '../hooks/useLatestGrpcConnection';
|
||||
import { useLatestHttpResponse } from '../hooks/useLatestHttpResponse';
|
||||
import { useMoveToWorkspace } from '../hooks/useMoveToWorkspace';
|
||||
import { usePrompt } from '../hooks/usePrompt';
|
||||
import { useRenameRequest } from '../hooks/useRenameRequest';
|
||||
import { useRequests } from '../hooks/useRequests';
|
||||
import { useScrollIntoView } from '../hooks/useScrollIntoView';
|
||||
import { useSendAnyHttpRequest } from '../hooks/useSendAnyHttpRequest';
|
||||
import { useSendManyRequests } from '../hooks/useSendFolder';
|
||||
import { useSendManyRequests } from '../hooks/useSendManyRequests';
|
||||
import { useSidebarHidden } from '../hooks/useSidebarHidden';
|
||||
import { useUpdateAnyFolder } from '../hooks/useUpdateAnyFolder';
|
||||
import { useUpdateAnyGrpcRequest } from '../hooks/useUpdateAnyGrpcRequest';
|
||||
@@ -73,6 +76,9 @@ export function Sidebar({ className }: Props) {
|
||||
const folders = useFolders();
|
||||
const requests = useRequests();
|
||||
const activeWorkspace = useActiveWorkspace();
|
||||
const httpRequestActions = useHttpRequestActions();
|
||||
const httpResponses = useHttpResponses();
|
||||
const grpcConnections = useGrpcConnections();
|
||||
const duplicateHttpRequest = useDuplicateHttpRequest({
|
||||
id: activeRequest?.id ?? null,
|
||||
navigateAfter: true,
|
||||
@@ -453,6 +459,9 @@ export function Sidebar({ className }: Props) {
|
||||
selectedId={selectedId}
|
||||
selectedTree={selectedTree}
|
||||
isCollapsed={isCollapsed}
|
||||
httpRequestActions={httpRequestActions}
|
||||
httpResponses={httpResponses}
|
||||
grpcConnections={grpcConnections}
|
||||
tree={tree}
|
||||
focused={hasFocus}
|
||||
draggingId={draggingId}
|
||||
@@ -483,6 +492,9 @@ interface SidebarItemsProps {
|
||||
handleDragStart: (id: string) => void;
|
||||
onSelect: (requestId: string) => void;
|
||||
isCollapsed: (id: string) => boolean;
|
||||
httpRequestActions: CallableHttpRequestAction[];
|
||||
httpResponses: HttpResponse[];
|
||||
grpcConnections: GrpcConnection[];
|
||||
}
|
||||
|
||||
function SidebarItems({
|
||||
@@ -500,6 +512,9 @@ function SidebarItems({
|
||||
handleEnd,
|
||||
handleMove,
|
||||
handleDragStart,
|
||||
httpRequestActions,
|
||||
httpResponses,
|
||||
grpcConnections,
|
||||
}: SidebarItemsProps) {
|
||||
return (
|
||||
<VStack
|
||||
@@ -537,6 +552,11 @@ function SidebarItems({
|
||||
/>
|
||||
)
|
||||
}
|
||||
httpRequestActions={httpRequestActions}
|
||||
latestHttpResponse={httpResponses.find((r) => r.requestId === child.item.id) ?? null}
|
||||
latestGrpcConnection={
|
||||
grpcConnections.find((c) => c.requestId === child.item.id) ?? null
|
||||
}
|
||||
onMove={handleMove}
|
||||
onEnd={handleEnd}
|
||||
onSelect={onSelect}
|
||||
@@ -549,20 +569,23 @@ function SidebarItems({
|
||||
!isCollapsed(child.item.id) &&
|
||||
draggingId !== child.item.id && (
|
||||
<SidebarItems
|
||||
treeParentMap={treeParentMap}
|
||||
tree={child}
|
||||
isCollapsed={isCollapsed}
|
||||
draggingId={draggingId}
|
||||
hoveredTree={hoveredTree}
|
||||
hoveredIndex={hoveredIndex}
|
||||
focused={focused}
|
||||
activeId={activeId}
|
||||
draggingId={draggingId}
|
||||
focused={focused}
|
||||
handleDragStart={handleDragStart}
|
||||
handleEnd={handleEnd}
|
||||
handleMove={handleMove}
|
||||
hoveredIndex={hoveredIndex}
|
||||
hoveredTree={hoveredTree}
|
||||
httpRequestActions={httpRequestActions}
|
||||
httpResponses={httpResponses}
|
||||
grpcConnections={grpcConnections}
|
||||
isCollapsed={isCollapsed}
|
||||
onSelect={onSelect}
|
||||
selectedId={selectedId}
|
||||
selectedTree={selectedTree}
|
||||
onSelect={onSelect}
|
||||
handleMove={handleMove}
|
||||
handleEnd={handleEnd}
|
||||
handleDragStart={handleDragStart}
|
||||
tree={child}
|
||||
treeParentMap={treeParentMap}
|
||||
/>
|
||||
)}
|
||||
</SidebarItem>
|
||||
@@ -590,7 +613,9 @@ type SidebarItemProps = {
|
||||
onDragStart: (id: string) => void;
|
||||
children?: ReactNode;
|
||||
child: TreeNode;
|
||||
} & Pick<SidebarItemsProps, 'isCollapsed' | 'onSelect'>;
|
||||
latestHttpResponse: HttpResponse | null;
|
||||
latestGrpcConnection: GrpcConnection | null;
|
||||
} & Pick<SidebarItemsProps, 'isCollapsed' | 'onSelect' | 'httpRequestActions'>;
|
||||
|
||||
type DragItem = {
|
||||
id: string;
|
||||
@@ -612,6 +637,9 @@ function SidebarItem({
|
||||
selected,
|
||||
itemFallbackName,
|
||||
useProminentStyles,
|
||||
latestHttpResponse,
|
||||
latestGrpcConnection,
|
||||
httpRequestActions,
|
||||
children,
|
||||
}: SidebarItemProps) {
|
||||
const ref = useRef<HTMLLIElement>(null);
|
||||
@@ -659,14 +687,9 @@ function SidebarItem({
|
||||
const renameRequest = useRenameRequest(itemId);
|
||||
const duplicateHttpRequest = useDuplicateHttpRequest({ id: itemId, navigateAfter: true });
|
||||
const duplicateGrpcRequest = useDuplicateGrpcRequest({ id: itemId, navigateAfter: true });
|
||||
const httpRequestActions = useHttpRequestActions();
|
||||
const sendRequest = useSendAnyHttpRequest();
|
||||
const moveToWorkspace = useMoveToWorkspace(itemId);
|
||||
const sendManyRequests = useSendManyRequests();
|
||||
const latestHttpResponse = useLatestHttpResponse(itemModel === 'http_request' ? itemId : null);
|
||||
const latestGrpcConnection = useLatestGrpcConnection(
|
||||
itemModel === 'grpc_request' ? itemId : null,
|
||||
);
|
||||
const updateHttpRequest = useUpdateAnyHttpRequest();
|
||||
const workspaces = useWorkspaces();
|
||||
const updateGrpcRequest = useUpdateAnyGrpcRequest();
|
||||
@@ -679,11 +702,11 @@ function SidebarItem({
|
||||
useScrollIntoView(ref.current, isActive);
|
||||
|
||||
const handleSubmitNameEdit = useCallback(
|
||||
(el: HTMLInputElement) => {
|
||||
async (el: HTMLInputElement) => {
|
||||
if (itemModel === 'http_request') {
|
||||
updateHttpRequest.mutate({ id: itemId, update: (r) => ({ ...r, name: el.value }) });
|
||||
await updateHttpRequest.mutateAsync({ id: itemId, update: (r) => ({ ...r, name: el.value }) });
|
||||
} else if (itemModel === 'grpc_request') {
|
||||
updateGrpcRequest.mutate({ id: itemId, update: (r) => ({ ...r, name: el.value }) });
|
||||
await updateGrpcRequest.mutateAsync({ id: itemId, update: (r) => ({ ...r, name: el.value }) });
|
||||
}
|
||||
setEditing(false);
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
|
||||
import classNames from 'classnames';
|
||||
import React, { useState } from 'react';
|
||||
import { useOsInfo } from '../hooks/useOsInfo';
|
||||
import { useStoplightsVisible } from '../hooks/useStoplightsVisible';
|
||||
import { Button } from './core/Button';
|
||||
import { HStack } from './core/Stacks';
|
||||
|
||||
@@ -10,16 +10,21 @@ interface Props {
|
||||
onlyX?: boolean;
|
||||
}
|
||||
|
||||
export const WINDOW_CONTROLS_WIDTH = '10.5rem';
|
||||
|
||||
export function WindowControls({ className, onlyX }: Props) {
|
||||
const [maximized, setMaximized] = useState<boolean>(false);
|
||||
const osInfo = useOsInfo();
|
||||
const shouldShow = osInfo?.osType === 'linux' || osInfo?.osType === 'windows';
|
||||
if (!shouldShow) {
|
||||
const stoplightsVisible = useStoplightsVisible();
|
||||
if (stoplightsVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<HStack className={classNames(className, 'ml-4 h-full')}>
|
||||
<HStack
|
||||
className={classNames(className, 'ml-4 absolute right-0 top-0 bottom-0')}
|
||||
justifyContent="end"
|
||||
style={{ width: WINDOW_CONTROLS_WIDTH }}
|
||||
>
|
||||
{!onlyX && (
|
||||
<>
|
||||
<Button
|
||||
@@ -57,7 +62,7 @@ export function WindowControls({ className, onlyX }: Props) {
|
||||
)}
|
||||
<Button
|
||||
color="custom"
|
||||
className="!h-full px-4 text-text-subtle rounded-none hocus:bg-danger hocus:text"
|
||||
className="!h-full px-4 text-text-subtle rounded-none hocus:bg-danger hocus:text-text"
|
||||
onClick={() => getCurrentWebviewWindow().close()}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
|
||||
@@ -10,7 +10,6 @@ import { ImportCurlButton } from './ImportCurlButton';
|
||||
import { RecentRequestsDropdown } from './RecentRequestsDropdown';
|
||||
import { SettingsDropdown } from './SettingsDropdown';
|
||||
import { SidebarActions } from './SidebarActions';
|
||||
import { WindowControls } from './WindowControls';
|
||||
import { WorkspaceActionsDropdown } from './WorkspaceActionsDropdown';
|
||||
|
||||
interface Props {
|
||||
@@ -20,17 +19,22 @@ interface Props {
|
||||
export const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Props) {
|
||||
const togglePalette = useToggleCommandPalette();
|
||||
return (
|
||||
<HStack space={2} justifyContent="center" className={classNames(className, 'w-full h-full')}>
|
||||
<div
|
||||
className={classNames(
|
||||
className,
|
||||
'grid grid-cols-[auto_minmax(0,1fr)_auto_auto] items-center w-full h-full',
|
||||
)}
|
||||
>
|
||||
<HStack space={0.5} className="flex-1 pointer-events-none">
|
||||
<SidebarActions />
|
||||
<CookieDropdown />
|
||||
<HStack>
|
||||
<HStack className="min-w-0">
|
||||
<WorkspaceActionsDropdown />
|
||||
<Icon icon="chevron_right" className="text-text-subtle" />
|
||||
<EnvironmentActionsDropdown className="w-auto pointer-events-auto" />
|
||||
</HStack>
|
||||
</HStack>
|
||||
<div className="pointer-events-none">
|
||||
<div className="pointer-events-none w-full max-w-[30vw] mx-auto">
|
||||
<RecentRequestsDropdown />
|
||||
</div>
|
||||
<div className="flex-1 flex gap-1 items-center h-full justify-end pointer-events-none pr-0.5">
|
||||
@@ -42,8 +46,7 @@ export const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Prop
|
||||
onClick={togglePalette}
|
||||
/>
|
||||
<SettingsDropdown />
|
||||
<WindowControls />
|
||||
</div>
|
||||
</HStack>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ export function Banner({ children, className, color = 'secondary' }: Props) {
|
||||
className={classNames(
|
||||
className,
|
||||
`x-theme-banner--${color}`,
|
||||
'whitespace-pre-wrap',
|
||||
'border border-dashed border-border-subtle bg-surface',
|
||||
'italic px-3 py-2 rounded select-auto cursor-text',
|
||||
'overflow-x-auto text-text',
|
||||
|
||||
@@ -229,17 +229,8 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
|
||||
defaultSelectedIndex ?? null,
|
||||
[defaultSelectedIndex],
|
||||
);
|
||||
const [menuStyles, setMenuStyles] = useState<CSSProperties>({});
|
||||
const [filter, setFilter] = useState<string>('');
|
||||
|
||||
// Calculate the max height so we can scroll
|
||||
const initMenu = useCallback((el: HTMLDivElement | null) => {
|
||||
if (el === null) return {};
|
||||
const windowBox = document.documentElement.getBoundingClientRect();
|
||||
const menuBox = el.getBoundingClientRect();
|
||||
setMenuStyles({ maxHeight: windowBox.height - menuBox.top - 5 });
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
onClose();
|
||||
setSelectedIndex(null);
|
||||
@@ -252,7 +243,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
|
||||
const isSpecial = e.ctrlKey || e.metaKey || e.altKey;
|
||||
if (isCharacter && !isSpecial) {
|
||||
e.preventDefault();
|
||||
setFilter((f) => (f + e.key).trim());
|
||||
setFilter((f) => f + e.key);
|
||||
setSelectedIndex(0);
|
||||
} else if (e.key === 'Backspace' && !isSpecial) {
|
||||
e.preventDefault();
|
||||
@@ -355,11 +346,12 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
|
||||
[handleClose, handleNext, handlePrev, handleSelect, items, selectedIndex],
|
||||
);
|
||||
|
||||
const { containerStyles, triangleStyles } = useMemo<{
|
||||
containerStyles: CSSProperties;
|
||||
triangleStyles: CSSProperties | null;
|
||||
const styles = useMemo<{
|
||||
container: CSSProperties;
|
||||
menu: CSSProperties;
|
||||
triangle: CSSProperties;
|
||||
}>(() => {
|
||||
if (triggerShape == null) return { containerStyles: {}, triangleStyles: null };
|
||||
if (triggerShape == null) return { container: {}, triangle: {}, menu: {} };
|
||||
|
||||
const menuMarginY = 5;
|
||||
const docRect = document.documentElement.getBoundingClientRect();
|
||||
@@ -371,27 +363,31 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
|
||||
const onRight = horizontalSpaceRemaining < 200;
|
||||
const upsideDown = heightBelow < heightAbove && heightBelow < items.length * 25 + 20 + 200;
|
||||
const triggerWidth = triggerShape.right - triggerShape.left;
|
||||
const containerStyles = {
|
||||
top: !upsideDown ? top + menuMarginY : undefined,
|
||||
bottom: upsideDown
|
||||
? docRect.height - top - (triggerShape.top - triggerShape.bottom) + menuMarginY
|
||||
: undefined,
|
||||
right: onRight ? docRect.width - triggerShape.right : undefined,
|
||||
left: !onRight ? triggerShape.left : undefined,
|
||||
minWidth: fullWidth ? triggerWidth : undefined,
|
||||
maxWidth: '40rem',
|
||||
return {
|
||||
container: {
|
||||
top: !upsideDown ? top + menuMarginY : undefined,
|
||||
bottom: upsideDown
|
||||
? docRect.height - top - (triggerShape.top - triggerShape.bottom) + menuMarginY
|
||||
: undefined,
|
||||
right: onRight ? docRect.width - triggerShape.right : undefined,
|
||||
left: !onRight ? triggerShape.left : undefined,
|
||||
minWidth: fullWidth ? triggerWidth : undefined,
|
||||
maxWidth: '40rem',
|
||||
},
|
||||
triangle: {
|
||||
width: '0.4rem',
|
||||
height: '0.4rem',
|
||||
...(onRight
|
||||
? { right: width / 2, marginRight: '-0.2rem' }
|
||||
: { left: width / 2, marginLeft: '-0.2rem' }),
|
||||
...(upsideDown
|
||||
? { bottom: '-0.2rem', rotate: '225deg' }
|
||||
: { top: '-0.2rem', rotate: '45deg' }),
|
||||
},
|
||||
menu: {
|
||||
maxHeight: `${(upsideDown ? heightAbove : heightBelow) - 15}px`,
|
||||
},
|
||||
};
|
||||
const triangleStyles: CSSProperties = {
|
||||
width: '0.4rem',
|
||||
height: '0.4rem',
|
||||
...(onRight
|
||||
? { right: width / 2, marginRight: '-0.2rem' }
|
||||
: { left: width / 2, marginLeft: '-0.2rem' }),
|
||||
...(upsideDown
|
||||
? { bottom: '-0.2rem', rotate: '225deg' }
|
||||
: { top: '-0.2rem', rotate: '45deg' }),
|
||||
};
|
||||
return { containerStyles, triangleStyles };
|
||||
}, [fullWidth, items.length, triggerShape]);
|
||||
|
||||
const filteredItems = useMemo(
|
||||
@@ -435,61 +431,58 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
dir="ltr"
|
||||
style={containerStyles}
|
||||
style={styles.container}
|
||||
className={classNames(className, 'outline-none my-1 pointer-events-auto fixed z-50')}
|
||||
>
|
||||
{triangleStyles && showTriangle && (
|
||||
{showTriangle && (
|
||||
<span
|
||||
aria-hidden
|
||||
style={triangleStyles}
|
||||
style={styles.triangle}
|
||||
className="bg-surface absolute border-border-subtle border-t border-l"
|
||||
/>
|
||||
)}
|
||||
{containerStyles && (
|
||||
<VStack
|
||||
ref={initMenu}
|
||||
style={menuStyles}
|
||||
className={classNames(
|
||||
className,
|
||||
'h-auto bg-surface rounded-md shadow-lg py-1.5 border',
|
||||
'border-border-subtle overflow-auto mx-0.5',
|
||||
)}
|
||||
>
|
||||
{filter && (
|
||||
<HStack
|
||||
space={2}
|
||||
className="pb-0.5 px-1.5 mb-2 text-sm border border-border-subtle mx-2 rounded font-mono h-xs"
|
||||
>
|
||||
<Icon icon="search" size="xs" className="text-text-subtle" />
|
||||
<div className="text">{filter}</div>
|
||||
</HStack>
|
||||
)}
|
||||
{filteredItems.length === 0 && (
|
||||
<span className="text-text-subtlest text-center px-2 py-1">No matches</span>
|
||||
)}
|
||||
{filteredItems.map((item, i) => {
|
||||
if (item.hidden) {
|
||||
return null;
|
||||
}
|
||||
if (item.type === 'separator') {
|
||||
return (
|
||||
<Separator key={i} className={classNames('my-1.5', item.label && 'ml-2')}>
|
||||
{item.label}
|
||||
</Separator>
|
||||
);
|
||||
}
|
||||
<VStack
|
||||
style={styles.menu}
|
||||
className={classNames(
|
||||
className,
|
||||
'h-auto bg-surface rounded-md shadow-lg py-1.5 border',
|
||||
'border-border-subtle overflow-auto mx-0.5',
|
||||
)}
|
||||
>
|
||||
{filter && (
|
||||
<HStack
|
||||
space={2}
|
||||
className="pb-0.5 px-1.5 mb-2 text-sm border border-border-subtle mx-2 rounded font-mono h-xs"
|
||||
>
|
||||
<Icon icon="search" size="xs" className="text-text-subtle" />
|
||||
<div className="text">{filter}</div>
|
||||
</HStack>
|
||||
)}
|
||||
{filteredItems.length === 0 && (
|
||||
<span className="text-text-subtlest text-center px-2 py-1">No matches</span>
|
||||
)}
|
||||
{filteredItems.map((item, i) => {
|
||||
if (item.hidden) {
|
||||
return null;
|
||||
}
|
||||
if (item.type === 'separator') {
|
||||
return (
|
||||
<MenuItem
|
||||
focused={i === selectedIndex}
|
||||
onFocus={handleFocus}
|
||||
onSelect={handleSelect}
|
||||
key={item.key}
|
||||
item={item}
|
||||
/>
|
||||
<Separator key={i} className={classNames('my-1.5', item.label && 'ml-2')}>
|
||||
{item.label}
|
||||
</Separator>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
)}
|
||||
}
|
||||
return (
|
||||
<MenuItem
|
||||
focused={i === selectedIndex}
|
||||
onFocus={handleFocus}
|
||||
onSelect={handleSelect}
|
||||
key={item.key}
|
||||
item={item}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
</motion.div>
|
||||
</div>
|
||||
</Overlay>
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
|
||||
.template-tag {
|
||||
/* Colors */
|
||||
@apply bg-surface text-text-subtle border-border-subtle;
|
||||
@apply bg-surface text-text-subtle border-border-subtle whitespace-nowrap;
|
||||
@apply hover:border-border-subtle hover:text-text hover:bg-surface-highlight;
|
||||
|
||||
@apply inline border px-1 mx-[0.5px] rounded cursor-default dark:shadow;
|
||||
@@ -285,12 +285,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.cm-completionInfo-right {
|
||||
@apply ml-1 -mt-0.5 font-sans;
|
||||
}
|
||||
|
||||
&.cm-completionInfo-right-narrow {
|
||||
@apply ml-1;
|
||||
&.cm-completionInfo {
|
||||
@apply mx-0.5 -mt-0.5 font-sans;
|
||||
}
|
||||
|
||||
* {
|
||||
|
||||
@@ -8,12 +8,12 @@ import classNames from 'classnames';
|
||||
import { EditorView } from 'codemirror';
|
||||
import type { MutableRefObject, ReactNode } from 'react';
|
||||
import {
|
||||
useEffect,
|
||||
Children,
|
||||
cloneElement,
|
||||
forwardRef,
|
||||
isValidElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
@@ -174,6 +174,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
|
||||
id: 'template-function',
|
||||
size: 'sm',
|
||||
title: 'Configure Function',
|
||||
description: fn.description,
|
||||
render: ({ hide }) => (
|
||||
<TemplateFunctionDialog
|
||||
templateFunction={fn}
|
||||
@@ -342,6 +343,33 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
|
||||
[forceUpdateKey],
|
||||
);
|
||||
|
||||
// For read-only mode, update content when `defaultValue` changes
|
||||
useEffect(() => {
|
||||
if (!readOnly || cm.current?.view == null || defaultValue == null) return;
|
||||
|
||||
// Replace codemirror contents
|
||||
const currentDoc = cm.current.view.state.doc.toString();
|
||||
if (defaultValue.startsWith(currentDoc)) {
|
||||
// If we're just appending, append only the changes. This preserves
|
||||
// things like scroll position.
|
||||
cm.current.view.dispatch({
|
||||
changes: cm.current.view.state.changes({
|
||||
from: currentDoc.length,
|
||||
insert: defaultValue.slice(currentDoc.length),
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
// If we're replacing everything, reset the entire content
|
||||
cm.current.view.dispatch({
|
||||
changes: cm.current.view.state.changes({
|
||||
from: 0,
|
||||
to: currentDoc.length,
|
||||
insert: currentDoc,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}, [defaultValue, readOnly]);
|
||||
|
||||
// Add bg classes to actions, so they appear over the text
|
||||
const decoratedActions = useMemo(() => {
|
||||
const results = [];
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
closeBracketsKeymap,
|
||||
completionKeymap,
|
||||
} from '@codemirror/autocomplete';
|
||||
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
|
||||
import { history, historyKeymap, indentWithTab } from '@codemirror/commands';
|
||||
import { javascript } from '@codemirror/lang-javascript';
|
||||
import { json } from '@codemirror/lang-json';
|
||||
import { xml } from '@codemirror/lang-xml';
|
||||
@@ -134,7 +134,7 @@ export const baseExtensions = [
|
||||
}),
|
||||
syntaxHighlighting(syntaxHighlightStyle),
|
||||
syntaxTheme,
|
||||
EditorState.allowMultipleSelections.of(true),
|
||||
keymap.of([...historyKeymap, ...completionKeymap]),
|
||||
];
|
||||
|
||||
export const multiLineExtensions = [
|
||||
@@ -156,14 +156,5 @@ export const multiLineExtensions = [
|
||||
rectangularSelection(),
|
||||
crosshairCursor(),
|
||||
highlightActiveLineGutter(),
|
||||
keymap.of([
|
||||
indentWithTab,
|
||||
...closeBracketsKeymap,
|
||||
...defaultKeymap,
|
||||
...searchKeymap,
|
||||
...historyKeymap,
|
||||
...foldKeymap,
|
||||
...completionKeymap,
|
||||
...lintKeymap,
|
||||
]),
|
||||
keymap.of([indentWithTab, ...closeBracketsKeymap, ...searchKeymap, ...foldKeymap, ...lintKeymap]),
|
||||
];
|
||||
|
||||
@@ -24,6 +24,7 @@ export type TwigCompletionOption = (
|
||||
) & {
|
||||
name: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
onClick: (rawTag: string, startPos: number) => void;
|
||||
value: string | null;
|
||||
invalid?: boolean;
|
||||
@@ -63,10 +64,11 @@ export function twigCompletion({ options }: TwigCompletionConfig) {
|
||||
|
||||
// If not on the last segment, only complete the namespace
|
||||
if (matchSegments.length < optionSegments.length) {
|
||||
const prefix = optionSegments.slice(0, matchSegments.length).join('.');
|
||||
return [
|
||||
{
|
||||
label: optionSegments.slice(0, matchSegments.length).join('.') + '…',
|
||||
apply: optionSegments.slice(0, matchSegments.length).join('.'),
|
||||
label: prefix + '.*',
|
||||
apply: prefix,
|
||||
type: 'namespace',
|
||||
},
|
||||
];
|
||||
@@ -78,6 +80,8 @@ export function twigCompletion({ options }: TwigCompletionConfig) {
|
||||
{
|
||||
label: o.name,
|
||||
apply: openTag + inner + closeTag,
|
||||
info: o.description,
|
||||
detail: o.type,
|
||||
type: o.type === 'variable' ? 'variable' : 'function',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -52,6 +52,7 @@ export function twig({
|
||||
name: fn.name,
|
||||
aliases: fn.aliases,
|
||||
type: 'function',
|
||||
description: fn.description,
|
||||
args: fn.args.map((a) => ({ name: a.name })),
|
||||
value: null,
|
||||
label: `${fn.name}(${shortArgs})`,
|
||||
|
||||
@@ -6,7 +6,6 @@ export function InlineCode({ className, ...props }: HTMLAttributes<HTMLSpanEleme
|
||||
<code
|
||||
className={classNames(
|
||||
className,
|
||||
'select-text cursor-text',
|
||||
'font-mono text-shrink bg-surface-highlight border border-border-subtle',
|
||||
'px-1.5 py-0.5 rounded text shadow-inner break-words',
|
||||
)}
|
||||
|
||||
@@ -93,7 +93,7 @@ export const PlainInput = forwardRef<HTMLInputElement, PlainInputProps>(function
|
||||
htmlFor={id}
|
||||
className={classNames(
|
||||
labelClassName,
|
||||
'text-text-subtle whitespace-nowrap',
|
||||
'text-text-subtle whitespace-nowrap flex-shrink-0',
|
||||
hideLabel && 'sr-only',
|
||||
)}
|
||||
>
|
||||
@@ -128,6 +128,9 @@ export const PlainInput = forwardRef<HTMLInputElement, PlainInputProps>(function
|
||||
type={type === 'password' && !obscured ? 'text' : type}
|
||||
defaultValue={defaultValue}
|
||||
placeholder={placeholder}
|
||||
autoComplete="off"
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
onPaste={(e) => onPaste?.(e.clipboardData.getData('Text'))}
|
||||
className={inputClassName}
|
||||
|
||||
@@ -1,30 +1,34 @@
|
||||
import classNames from 'classnames';
|
||||
import type { HttpResponse } from '@yaakapp-internal/models';
|
||||
import classNames from 'classnames';
|
||||
|
||||
interface Props {
|
||||
response: Pick<HttpResponse, 'status' | 'statusReason' | 'error'>;
|
||||
response: HttpResponse;
|
||||
className?: string;
|
||||
showReason?: boolean;
|
||||
}
|
||||
|
||||
export function StatusTag({ response, className, showReason }: Props) {
|
||||
const { status } = response;
|
||||
const label = status < 100 ? 'ERR' : status;
|
||||
const { status, state } = response;
|
||||
const label = status < 100 ? 'ERROR' : status;
|
||||
const category = `${status}`[0];
|
||||
const isInitializing = state === 'initialized';
|
||||
|
||||
return (
|
||||
<span
|
||||
className={classNames(
|
||||
className,
|
||||
'font-mono',
|
||||
category === '0' && 'text-danger',
|
||||
category === '1' && 'text-info',
|
||||
category === '2' && 'text-success',
|
||||
category === '3' && 'text-primary',
|
||||
category === '4' && 'text-warning',
|
||||
category === '5' && 'text-danger',
|
||||
!isInitializing && category === '0' && 'text-danger',
|
||||
!isInitializing && category === '1' && 'text-info',
|
||||
!isInitializing && category === '2' && 'text-success',
|
||||
!isInitializing && category === '3' && 'text-primary',
|
||||
!isInitializing && category === '4' && 'text-warning',
|
||||
!isInitializing && category === '5' && 'text-danger',
|
||||
isInitializing && 'text-text-subtle',
|
||||
)}
|
||||
>
|
||||
{label} {showReason && response.statusReason && response.statusReason}
|
||||
{isInitializing ? 'CONNECTING' : label}{' '}
|
||||
{showReason && response.statusReason && response.statusReason}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||
import React from 'react';
|
||||
import type { HttpResponse } from '@yaakapp-internal/models';
|
||||
|
||||
interface Props {
|
||||
response: HttpResponse;
|
||||
bodyPath: string;
|
||||
}
|
||||
|
||||
export function AudioViewer({ response }: Props) {
|
||||
if (response.bodyPath === null) {
|
||||
return <div>Empty response body</div>;
|
||||
}
|
||||
|
||||
const src = convertFileSrc(response.bodyPath);
|
||||
export function AudioViewer({ bodyPath }: Props) {
|
||||
const src = convertFileSrc(bodyPath);
|
||||
|
||||
// eslint-disable-next-line jsx-a11y/media-has-caption
|
||||
return <audio className="w-full" controls src={src}></audio>;
|
||||
|
||||
@@ -3,7 +3,9 @@ import type { HttpResponse } from '@yaakapp-internal/models';
|
||||
import { getContentTypeHeader } from '../../lib/model_util';
|
||||
import { Banner } from '../core/Banner';
|
||||
import { Button } from '../core/Button';
|
||||
import { Icon } from '../core/Icon';
|
||||
import { InlineCode } from '../core/InlineCode';
|
||||
import { EmptyStateText } from '../EmptyStateText';
|
||||
|
||||
interface Props {
|
||||
response: HttpResponse;
|
||||
@@ -12,6 +14,16 @@ interface Props {
|
||||
export function BinaryViewer({ response }: Props) {
|
||||
const saveResponse = useSaveResponse(response);
|
||||
const contentType = getContentTypeHeader(response.headers) ?? 'unknown';
|
||||
|
||||
// Wait until the response has been fully-downloaded
|
||||
if (response.state !== 'closed') {
|
||||
return (
|
||||
<EmptyStateText>
|
||||
<Icon icon="refresh" spin />
|
||||
</EmptyStateText>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Banner color="primary" className="h-full flex flex-col gap-3">
|
||||
<p>
|
||||
|
||||
204
src-web/components/responseViewers/EventStreamViewer.tsx
Normal file
204
src-web/components/responseViewers/EventStreamViewer.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import type { HttpResponse } from '@yaakapp-internal/models';
|
||||
import type { ServerSentEvent } from '@yaakapp-internal/sse';
|
||||
import classNames from 'classnames';
|
||||
import { motion } from 'framer-motion';
|
||||
import React, { Fragment, useMemo, useRef, useState } from 'react';
|
||||
import { useResponseBodyEventSource } from '../../hooks/useResponseBodyEventSource';
|
||||
import { isJSON } from '../../lib/contentType';
|
||||
import { tryFormatJson } from '../../lib/formatters';
|
||||
import { Button } from '../core/Button';
|
||||
import { Editor } from '../core/Editor';
|
||||
import { Icon } from '../core/Icon';
|
||||
import { InlineCode } from '../core/InlineCode';
|
||||
import { Separator } from '../core/Separator';
|
||||
import { SplitLayout } from '../core/SplitLayout';
|
||||
import { HStack, VStack } from '../core/Stacks';
|
||||
|
||||
interface Props {
|
||||
response: HttpResponse;
|
||||
}
|
||||
|
||||
export function EventStreamViewer({ response }: Props) {
|
||||
return (
|
||||
<Fragment
|
||||
key={response.id} // force a refresh when the response changes
|
||||
>
|
||||
<ActualEventStreamViewer response={response} />
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function ActualEventStreamViewer({ response }: Props) {
|
||||
const [showLarge, setShowLarge] = useState<boolean>(false);
|
||||
const [showingLarge, setShowingLarge] = useState<boolean>(false);
|
||||
const [activeEventIndex, setActiveEventIndex] = useState<number | null>(null);
|
||||
const events = useResponseBodyEventSource(response);
|
||||
const activeEvent = useMemo(
|
||||
() => (activeEventIndex == null ? null : events.data?.[activeEventIndex]),
|
||||
[activeEventIndex, events],
|
||||
);
|
||||
|
||||
const language = useMemo<'text' | 'json'>(() => {
|
||||
if (!activeEvent?.data) return 'text';
|
||||
return isJSON(activeEvent?.data) ? 'json' : 'text';
|
||||
}, [activeEvent?.data]);
|
||||
|
||||
return (
|
||||
<SplitLayout
|
||||
layout="vertical"
|
||||
name="grpc_events"
|
||||
defaultRatio={0.4}
|
||||
minHeightPx={20}
|
||||
firstSlot={() => (
|
||||
<EventStreamEventsVirtual
|
||||
events={events.data ?? []}
|
||||
activeEventIndex={activeEventIndex}
|
||||
setActiveEventIndex={setActiveEventIndex}
|
||||
/>
|
||||
)}
|
||||
secondSlot={
|
||||
activeEvent
|
||||
? () => (
|
||||
<div className="grid grid-rows-[auto_minmax(0,1fr)]">
|
||||
<div className="pb-3 px-2">
|
||||
<Separator />
|
||||
</div>
|
||||
<div className="pl-2 overflow-y-auto">
|
||||
<div className="mb-2 select-text cursor-text font-semibold">Message Received</div>
|
||||
{!showLarge && activeEvent.data.length > 1000 * 1000 ? (
|
||||
<VStack space={2} className="italic text-text-subtlest">
|
||||
Message previews larger than 1MB are hidden
|
||||
<div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowingLarge(true);
|
||||
setTimeout(() => {
|
||||
setShowLarge(true);
|
||||
setShowingLarge(false);
|
||||
}, 500);
|
||||
}}
|
||||
isLoading={showingLarge}
|
||||
color="secondary"
|
||||
variant="border"
|
||||
size="xs"
|
||||
>
|
||||
Try Showing
|
||||
</Button>
|
||||
</div>
|
||||
</VStack>
|
||||
) : (
|
||||
<Editor
|
||||
readOnly
|
||||
defaultValue={tryFormatJson(activeEvent.data)}
|
||||
language={language}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: null
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function EventStreamEventsVirtual({
|
||||
events,
|
||||
activeEventIndex,
|
||||
setActiveEventIndex,
|
||||
}: {
|
||||
events: ServerSentEvent[];
|
||||
activeEventIndex: number | null;
|
||||
setActiveEventIndex: (eventId: number | null) => void;
|
||||
}) {
|
||||
// The scrollable element for your list
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// The virtualizer
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: events.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 28, // react-virtual requires a height, so we'll give it one
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={parentRef} className="overflow-y-auto">
|
||||
<div
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualItem) => {
|
||||
const event = events[virtualItem.index]!;
|
||||
return (
|
||||
<div
|
||||
key={virtualItem.key}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: `${virtualItem.size}px`,
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
}}
|
||||
>
|
||||
<EventStreamEvent
|
||||
event={event}
|
||||
isActive={virtualItem.index === activeEventIndex}
|
||||
onClick={() => {
|
||||
if (virtualItem.index === activeEventIndex) setActiveEventIndex(null);
|
||||
else setActiveEventIndex(virtualItem.index);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EventStreamEvent({
|
||||
onClick,
|
||||
isActive,
|
||||
event,
|
||||
className,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
isActive: boolean;
|
||||
event: ServerSentEvent;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<motion.button
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
onClick={onClick}
|
||||
className={classNames(
|
||||
className,
|
||||
'w-full grid grid-cols-[auto_auto_minmax(0,3fr)] gap-2 items-center text-left',
|
||||
'px-1.5 py-1 font-mono cursor-default group focus:outline-none rounded',
|
||||
isActive && '!bg-surface-highlight !text-text',
|
||||
'text-text-subtle hover:text',
|
||||
)}
|
||||
>
|
||||
<Icon className={classNames('text-info')} title="Server Message" icon="arrow_big_down_dash" />
|
||||
<HStack space={1.5} className="text-sm">
|
||||
{event.eventType && (
|
||||
<InlineCode className={classNames('py-0', isActive && 'bg-text-subtlest text-text')}>
|
||||
{event.eventType}
|
||||
</InlineCode>
|
||||
)}
|
||||
{event.id && (
|
||||
<InlineCode className={classNames('py-0', isActive && 'bg-text-subtlest text-text')}>
|
||||
{event.id}
|
||||
</InlineCode>
|
||||
)}
|
||||
</HStack>
|
||||
<div className={classNames('w-full truncate text-xs')}>{event.data.slice(0, 1000)}</div>
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { HttpResponse } from '@yaakapp-internal/models';
|
||||
import { useContentTypeFromHeaders } from '../../hooks/useContentTypeFromHeaders';
|
||||
import { useResponseBodyText } from '../../hooks/useResponseBodyText';
|
||||
import { isJSON, languageFromContentType } from '../../lib/contentType';
|
||||
import { useSaveResponse } from '../../hooks/useSaveResponse';
|
||||
import { languageFromContentType } from '../../lib/contentType';
|
||||
import { BinaryViewer } from './BinaryViewer';
|
||||
import { TextViewer } from './TextViewer';
|
||||
import { WebPageViewer } from './WebPageViewer';
|
||||
@@ -13,25 +14,34 @@ interface Props {
|
||||
}
|
||||
|
||||
export function HTMLOrTextViewer({ response, pretty, textViewerClassName }: Props) {
|
||||
const rawBody = useResponseBodyText(response);
|
||||
let language = languageFromContentType(useContentTypeFromHeaders(response.headers));
|
||||
const rawTextBody = useResponseBodyText(response);
|
||||
const language = languageFromContentType(
|
||||
useContentTypeFromHeaders(response.headers),
|
||||
rawTextBody.data ?? '',
|
||||
);
|
||||
const saveResponse = useSaveResponse(response);
|
||||
|
||||
// A lot of APIs return JSON with `text/html` content type, so interpret as JSON if so
|
||||
if (language === 'html' && isJSON(rawBody.data ?? '')) {
|
||||
language = 'json';
|
||||
}
|
||||
|
||||
if (rawBody.isLoading) {
|
||||
if (rawTextBody.isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (rawBody.data == null) {
|
||||
// Wasn't able to decode as text, so it must be binary
|
||||
if (rawTextBody.data == null) {
|
||||
return <BinaryViewer response={response} />;
|
||||
}
|
||||
|
||||
if (language === 'html' && pretty) {
|
||||
return <WebPageViewer response={response} />;
|
||||
} else {
|
||||
return <TextViewer response={response} pretty={pretty} className={textViewerClassName} />;
|
||||
return (
|
||||
<TextViewer
|
||||
language={language}
|
||||
text={rawTextBody.data}
|
||||
pretty={pretty}
|
||||
className={textViewerClassName}
|
||||
onSaveResponse={saveResponse.mutate}
|
||||
responseId={response.id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,40 +1,11 @@
|
||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||
import classNames from 'classnames';
|
||||
import { useState } from 'react';
|
||||
import type { HttpResponse } from '@yaakapp-internal/models';
|
||||
import React from 'react';
|
||||
|
||||
interface Props {
|
||||
response: HttpResponse;
|
||||
className?: string;
|
||||
bodyPath: string;
|
||||
}
|
||||
|
||||
export function ImageViewer({ response, className }: Props) {
|
||||
const bytes = response.contentLength ?? 0;
|
||||
const [show, setShow] = useState(bytes < 3 * 1000 * 1000);
|
||||
|
||||
if (response.bodyPath === null) {
|
||||
return <div>Empty response body</div>;
|
||||
}
|
||||
|
||||
const src = convertFileSrc(response.bodyPath);
|
||||
if (!show) {
|
||||
return (
|
||||
<>
|
||||
<div className="italic text-text-subtlest">
|
||||
Response body is too large to preview.{' '}
|
||||
<button className="cursor-pointer underline hover:text" onClick={() => setShow(true)}>
|
||||
Show anyway
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt="Response preview"
|
||||
className={classNames(className, 'max-w-full max-h-full')}
|
||||
/>
|
||||
);
|
||||
export function ImageViewer({ bodyPath }: Props) {
|
||||
const src = convertFileSrc(bodyPath);
|
||||
return <img src={src} alt="Response preview" className="max-w-full max-h-full pb-2" />;
|
||||
}
|
||||
|
||||
@@ -2,15 +2,14 @@ import useResizeObserver from '@react-hook/resize-observer';
|
||||
import 'react-pdf/dist/Page/TextLayer.css';
|
||||
import 'react-pdf/dist/Page/AnnotationLayer.css';
|
||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||
import './PdfViewer.css';
|
||||
import type { PDFDocumentProxy } from 'pdfjs-dist';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { Document, Page } from 'react-pdf';
|
||||
import { useDebouncedState } from '../../hooks/useDebouncedState';
|
||||
import type { HttpResponse } from '@yaakapp-internal/models';
|
||||
import './PdfViewer.css';
|
||||
|
||||
interface Props {
|
||||
response: HttpResponse;
|
||||
bodyPath: string;
|
||||
}
|
||||
|
||||
const options = {
|
||||
@@ -18,7 +17,7 @@ const options = {
|
||||
standardFontDataUrl: '/standard_fonts/',
|
||||
};
|
||||
|
||||
export function PdfViewer({ response }: Props) {
|
||||
export function PdfViewer({ bodyPath }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [containerWidth, setContainerWidth] = useDebouncedState<number>(0, 100);
|
||||
const [numPages, setNumPages] = useState<number>();
|
||||
@@ -31,11 +30,7 @@ export function PdfViewer({ response }: Props) {
|
||||
setNumPages(nextNumPages);
|
||||
};
|
||||
|
||||
if (response.bodyPath === null) {
|
||||
return <div>Empty response body</div>;
|
||||
}
|
||||
|
||||
const src = convertFileSrc(response.bodyPath);
|
||||
const src = convertFileSrc(bodyPath);
|
||||
return (
|
||||
<div ref={containerRef} className="w-full h-full overflow-y-auto">
|
||||
<Document
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
import type { HttpResponse } from '@yaakapp-internal/models';
|
||||
import classNames from 'classnames';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { createGlobalState } from 'react-use';
|
||||
import { useContentTypeFromHeaders } from '../../hooks/useContentTypeFromHeaders';
|
||||
import { useCopy } from '../../hooks/useCopy';
|
||||
import { useDebouncedValue } from '../../hooks/useDebouncedValue';
|
||||
import { useFilterResponse } from '../../hooks/useFilterResponse';
|
||||
import { useResponseBodyText } from '../../hooks/useResponseBodyText';
|
||||
import { useSaveResponse } from '../../hooks/useSaveResponse';
|
||||
import { useToggle } from '../../hooks/useToggle';
|
||||
import { isJSON, languageFromContentType } from '../../lib/contentType';
|
||||
import { tryFormatJson, tryFormatXml } from '../../lib/formatters';
|
||||
import { CopyButton } from '../CopyButton';
|
||||
import { Banner } from '../core/Banner';
|
||||
import { Button } from '../core/Button';
|
||||
import type { EditorProps } from '../core/Editor';
|
||||
import { Editor } from '../core/Editor';
|
||||
import { hyperlink } from '../core/Editor/hyperlink/extension';
|
||||
import { IconButton } from '../core/IconButton';
|
||||
@@ -21,46 +18,43 @@ import { InlineCode } from '../core/InlineCode';
|
||||
import { Input } from '../core/Input';
|
||||
import { SizeTag } from '../core/SizeTag';
|
||||
import { HStack } from '../core/Stacks';
|
||||
import { BinaryViewer } from './BinaryViewer';
|
||||
|
||||
const extraExtensions = [hyperlink];
|
||||
const LARGE_RESPONSE_BYTES = 2 * 1000 * 1000;
|
||||
|
||||
interface Props {
|
||||
response: HttpResponse;
|
||||
pretty: boolean;
|
||||
className?: string;
|
||||
text: string;
|
||||
language: EditorProps['language'];
|
||||
responseId: string;
|
||||
onSaveResponse: () => void;
|
||||
}
|
||||
|
||||
const useFilterText = createGlobalState<Record<string, string | null>>({});
|
||||
|
||||
export function TextViewer({ response, pretty, className }: Props) {
|
||||
export function TextViewer({
|
||||
language,
|
||||
text,
|
||||
responseId,
|
||||
pretty,
|
||||
className,
|
||||
onSaveResponse,
|
||||
}: Props) {
|
||||
const [filterTextMap, setFilterTextMap] = useFilterText();
|
||||
const [showLargeResponse, toggleShowLargeResponse] = useToggle();
|
||||
const filterText = filterTextMap[response.id] ?? null;
|
||||
const filterText = filterTextMap[responseId] ?? null;
|
||||
const copy = useCopy();
|
||||
const debouncedFilterText = useDebouncedValue(filterText, 200);
|
||||
const setFilterText = useCallback(
|
||||
(v: string | null) => {
|
||||
setFilterTextMap((m) => ({ ...m, [response.id]: v }));
|
||||
setFilterTextMap((m) => ({ ...m, [responseId]: v }));
|
||||
},
|
||||
[setFilterTextMap, response],
|
||||
[setFilterTextMap, responseId],
|
||||
);
|
||||
|
||||
const rawBody = useResponseBodyText(response);
|
||||
const saveResponse = useSaveResponse(response);
|
||||
let language = languageFromContentType(useContentTypeFromHeaders(response.headers));
|
||||
|
||||
// A lot of APIs return JSON with `text/html` content type, so interpret as JSON if so
|
||||
if (language === 'html' && isJSON(rawBody.data ?? '')) {
|
||||
language = 'json';
|
||||
}
|
||||
|
||||
const isSearching = filterText != null;
|
||||
|
||||
const filteredResponse = useFilterResponse({
|
||||
filter: debouncedFilterText ?? '',
|
||||
responseId: response.id,
|
||||
});
|
||||
const filteredResponse = useFilterResponse({ filter: debouncedFilterText ?? '', responseId });
|
||||
|
||||
const toggleSearch = useCallback(() => {
|
||||
if (isSearching) {
|
||||
@@ -81,7 +75,7 @@ export function TextViewer({ response, pretty, className }: Props) {
|
||||
nodes.push(
|
||||
<div key="input" className="w-full !opacity-100">
|
||||
<Input
|
||||
key={response.id}
|
||||
key={responseId}
|
||||
validate={!filteredResponse.error}
|
||||
hideLabel
|
||||
autoFocus
|
||||
@@ -116,20 +110,12 @@ export function TextViewer({ response, pretty, className }: Props) {
|
||||
filteredResponse.error,
|
||||
isSearching,
|
||||
language,
|
||||
response.id,
|
||||
responseId,
|
||||
setFilterText,
|
||||
toggleSearch,
|
||||
]);
|
||||
|
||||
if (rawBody.isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (rawBody.data == null) {
|
||||
return <BinaryViewer response={response} />;
|
||||
}
|
||||
|
||||
if (!showLargeResponse && (response.contentLength ?? 0) > LARGE_RESPONSE_BYTES) {
|
||||
if (!showLargeResponse && text.length > LARGE_RESPONSE_BYTES) {
|
||||
return (
|
||||
<Banner color="primary" className="h-full flex flex-col gap-3">
|
||||
<p>
|
||||
@@ -143,15 +129,10 @@ export function TextViewer({ response, pretty, className }: Props) {
|
||||
<Button color="primary" size="xs" onClick={toggleShowLargeResponse}>
|
||||
Reveal Response
|
||||
</Button>
|
||||
<Button variant="border" size="xs" onClick={() => saveResponse.mutate()}>
|
||||
<Button variant="border" size="xs" onClick={onSaveResponse}>
|
||||
Save to File
|
||||
</Button>
|
||||
<CopyButton
|
||||
variant="border"
|
||||
size="xs"
|
||||
onClick={() => saveResponse.mutate()}
|
||||
text={rawBody.data}
|
||||
/>
|
||||
<CopyButton variant="border" size="xs" onClick={() => copy(text)} text={text} />
|
||||
</HStack>
|
||||
</Banner>
|
||||
);
|
||||
@@ -159,10 +140,10 @@ export function TextViewer({ response, pretty, className }: Props) {
|
||||
|
||||
const formattedBody =
|
||||
pretty && language === 'json'
|
||||
? tryFormatJson(rawBody.data)
|
||||
? tryFormatJson(text)
|
||||
: pretty && (language === 'xml' || language === 'html')
|
||||
? tryFormatXml(rawBody.data)
|
||||
: rawBody.data;
|
||||
? tryFormatXml(text)
|
||||
: text;
|
||||
|
||||
let body;
|
||||
if (isSearching && filterText?.length > 0) {
|
||||
@@ -179,7 +160,6 @@ export function TextViewer({ response, pretty, className }: Props) {
|
||||
<Editor
|
||||
readOnly
|
||||
className={className}
|
||||
forceUpdateKey={body}
|
||||
defaultValue={body}
|
||||
language={language}
|
||||
actions={actions}
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||
import React from 'react';
|
||||
import type { HttpResponse } from '@yaakapp-internal/models';
|
||||
|
||||
interface Props {
|
||||
response: HttpResponse;
|
||||
bodyPath: string;
|
||||
}
|
||||
|
||||
export function VideoViewer({ response }: Props) {
|
||||
if (response.bodyPath === null) {
|
||||
return <div>Empty response body</div>;
|
||||
}
|
||||
|
||||
const src = convertFileSrc(response.bodyPath);
|
||||
export function VideoViewer({ bodyPath }: Props) {
|
||||
const src = convertFileSrc(bodyPath);
|
||||
|
||||
// eslint-disable-next-line jsx-a11y/media-has-caption
|
||||
return <video className="w-full" controls src={src}></video>;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Listen for settings changes, the re-compute theme
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import type { ModelPayload } from './components/GlobalHooks';
|
||||
import type { ModelPayload } from './hooks/useSyncModelStores';
|
||||
import { getSettings } from './lib/store';
|
||||
|
||||
function setFontSizeOnDocument(fontSize: number) {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useActiveWorkspace } from './useActiveWorkspace';
|
||||
import { useCookieJars } from './useCookieJars';
|
||||
import { useKeyValue } from './useKeyValue';
|
||||
|
||||
export const QUERY_COOKIE_JAR_ID = 'cookie_jar_id';
|
||||
|
||||
@@ -11,9 +9,8 @@ export function useActiveCookieJar() {
|
||||
const cookieJars = useCookieJars();
|
||||
|
||||
const activeCookieJar = useMemo(() => {
|
||||
if (cookieJars.data == null) return undefined;
|
||||
return cookieJars.data.find((cookieJar) => cookieJar.id === activeCookieJarId) ?? null;
|
||||
}, [activeCookieJarId, cookieJars.data]);
|
||||
return cookieJars?.find((cookieJar) => cookieJar.id === activeCookieJarId) ?? null;
|
||||
}, [activeCookieJarId, cookieJars]);
|
||||
|
||||
return [activeCookieJar ?? null, setActiveCookieJarId] as const;
|
||||
}
|
||||
@@ -22,13 +19,12 @@ export function useEnsureActiveCookieJar() {
|
||||
const cookieJars = useCookieJars();
|
||||
const [activeCookieJarId, setActiveCookieJarId] = useActiveCookieJarId();
|
||||
useEffect(() => {
|
||||
if (cookieJars.data == null) return;
|
||||
|
||||
if (cookieJars.data.find((j) => j.id === activeCookieJarId)) {
|
||||
if (cookieJars == null) return; // Hasn't loaded yet
|
||||
if (cookieJars.find((j) => j.id === activeCookieJarId)) {
|
||||
return; // There's an active jar
|
||||
}
|
||||
|
||||
const firstJar = cookieJars.data[0];
|
||||
const firstJar = cookieJars[0];
|
||||
if (firstJar == null) {
|
||||
console.log("Workspace doesn't have any cookie jars to activate");
|
||||
return;
|
||||
@@ -37,35 +33,7 @@ export function useEnsureActiveCookieJar() {
|
||||
// There's no active jar, so set it to the first one
|
||||
console.log('Setting active cookie jar to', firstJar.id);
|
||||
setActiveCookieJarId(firstJar.id);
|
||||
}, [activeCookieJarId, cookieJars, cookieJars.data, setActiveCookieJarId]);
|
||||
}
|
||||
|
||||
export function useMigrateActiveCookieJarId() {
|
||||
const workspace = useActiveWorkspace();
|
||||
const [, setActiveCookieJarId] = useActiveCookieJarId();
|
||||
const {
|
||||
set: setLegacyActiveCookieJarId,
|
||||
value: legacyActiveCookieJarId,
|
||||
isLoading: isLoadingLegacyActiveCookieJarId,
|
||||
} = useKeyValue<string | null>({
|
||||
namespace: 'global',
|
||||
key: ['activeCookieJar', workspace?.id ?? 'n/a'],
|
||||
fallback: null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (legacyActiveCookieJarId == null || isLoadingLegacyActiveCookieJarId) return;
|
||||
|
||||
console.log('Migrating active cookie jar ID to query param', legacyActiveCookieJarId);
|
||||
setActiveCookieJarId(legacyActiveCookieJarId);
|
||||
setLegacyActiveCookieJarId(null).catch(console.error);
|
||||
}, [
|
||||
workspace,
|
||||
isLoadingLegacyActiveCookieJarId,
|
||||
legacyActiveCookieJarId,
|
||||
setActiveCookieJarId,
|
||||
setLegacyActiveCookieJarId,
|
||||
]);
|
||||
}, [activeCookieJarId, cookieJars, setActiveCookieJarId]);
|
||||
}
|
||||
|
||||
function useActiveCookieJarId() {
|
||||
|
||||
@@ -18,11 +18,12 @@ export function useActiveWorkspaceChangedToast() {
|
||||
if (id === null) return;
|
||||
|
||||
toast.show({
|
||||
id: 'workspace-changed',
|
||||
id: `workspace-changed-${activeWorkspace.id}`,
|
||||
timeout: 3000,
|
||||
message: (
|
||||
<>
|
||||
Switched workspace to <InlineCode>{activeWorkspace.name}</InlineCode>
|
||||
Activated workspace{' '}
|
||||
<InlineCode className="whitespace-nowrap">{activeWorkspace.name}</InlineCode>
|
||||
</>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -1,22 +1,8 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { CookieJar } from '@yaakapp-internal/models';
|
||||
import { invokeCmd } from '../lib/tauri';
|
||||
import { useActiveWorkspace } from './useActiveWorkspace';
|
||||
import { atom, useAtomValue } from 'jotai';
|
||||
|
||||
export function cookieJarsQueryKey({ workspaceId }: { workspaceId: string }) {
|
||||
return ['cookie_jars', { workspaceId }];
|
||||
}
|
||||
export const cookieJarsAtom = atom<CookieJar[] | undefined>();
|
||||
|
||||
export function useCookieJars() {
|
||||
const workspace = useActiveWorkspace();
|
||||
return useQuery({
|
||||
enabled: workspace != null,
|
||||
queryKey: cookieJarsQueryKey({ workspaceId: workspace?.id ?? 'n/a' }),
|
||||
queryFn: async () => {
|
||||
if (workspace == null) return [];
|
||||
return (await invokeCmd('cmd_list_cookie_jars', {
|
||||
workspaceId: workspace.id,
|
||||
})) as CookieJar[];
|
||||
},
|
||||
});
|
||||
return useAtomValue(cookieJarsAtom);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,9 @@
|
||||
import type { Environment } from '@yaakapp-internal/models';
|
||||
import { atom, useAtom } from 'jotai/index';
|
||||
import { useEffect } from 'react';
|
||||
import { invokeCmd } from '../lib/tauri';
|
||||
import { useActiveWorkspace } from './useActiveWorkspace';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { atom } from 'jotai/index';
|
||||
|
||||
export const environmentsAtom = atom<Environment[]>([]);
|
||||
|
||||
export function useEnvironments() {
|
||||
const [items, setItems] = useAtom(environmentsAtom);
|
||||
const workspace = useActiveWorkspace();
|
||||
|
||||
// Fetch new requests when workspace changes
|
||||
useEffect(() => {
|
||||
if (workspace == null) return;
|
||||
invokeCmd<Environment[]>('cmd_list_environments', { workspaceId: workspace.id }).then(setItems);
|
||||
}, [setItems, workspace]);
|
||||
|
||||
return items;
|
||||
return useAtomValue(environmentsAtom);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,9 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { Folder } from '@yaakapp-internal/models';
|
||||
import { invokeCmd } from '../lib/tauri';
|
||||
import { useActiveWorkspace } from './useActiveWorkspace';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { atom } from 'jotai/index';
|
||||
|
||||
export function foldersQueryKey({ workspaceId }: { workspaceId: string }) {
|
||||
return ['folders', { workspaceId }];
|
||||
}
|
||||
export const foldersAtom = atom<Folder[]>([]);
|
||||
|
||||
export function useFolders() {
|
||||
const workspace = useActiveWorkspace();
|
||||
return (
|
||||
useQuery({
|
||||
enabled: workspace != null,
|
||||
queryKey: foldersQueryKey({ workspaceId: workspace?.id ?? 'n/a' }),
|
||||
queryFn: async () => {
|
||||
if (workspace == null) return [];
|
||||
return (await invokeCmd('cmd_list_folders', { workspaceId: workspace.id })) as Folder[];
|
||||
},
|
||||
}).data ?? []
|
||||
);
|
||||
return useAtomValue(foldersAtom);
|
||||
}
|
||||
|
||||
26
src-web/hooks/useGenerateThemeCss.ts
Normal file
26
src-web/hooks/useGenerateThemeCss.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { catppuccinMacchiato } from '../lib/theme/themes/catppuccin';
|
||||
import { githubLight } from '../lib/theme/themes/github';
|
||||
import { hotdogStandDefault } from '../lib/theme/themes/hotdog-stand';
|
||||
import { monokaiProDefault } from '../lib/theme/themes/monokai-pro';
|
||||
import { rosePineDefault } from '../lib/theme/themes/rose-pine';
|
||||
import { yaakDark } from '../lib/theme/themes/yaak';
|
||||
import { getThemeCSS } from '../lib/theme/window';
|
||||
import { useCopy } from './useCopy';
|
||||
import { useListenToTauriEvent } from './useListenToTauriEvent';
|
||||
|
||||
export function useGenerateThemeCss() {
|
||||
const copy = useCopy();
|
||||
useListenToTauriEvent('generate_theme_css', () => {
|
||||
const themesCss = [
|
||||
yaakDark,
|
||||
monokaiProDefault,
|
||||
rosePineDefault,
|
||||
catppuccinMacchiato,
|
||||
githubLight,
|
||||
hotdogStandDefault,
|
||||
]
|
||||
.map(getThemeCSS)
|
||||
.join('\n\n');
|
||||
copy(themesCss);
|
||||
});
|
||||
}
|
||||
@@ -1,24 +1,8 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { GrpcConnection } from '@yaakapp-internal/models';
|
||||
import { invokeCmd } from '../lib/tauri';
|
||||
import { atom, useAtomValue } from 'jotai/index';
|
||||
|
||||
export function grpcConnectionsQueryKey({ requestId }: { requestId: string }) {
|
||||
return ['grpc_connections', { requestId }];
|
||||
}
|
||||
export const grpcConnectionsAtom = atom<GrpcConnection[]>([]);
|
||||
|
||||
export function useGrpcConnections(requestId: string | null) {
|
||||
return (
|
||||
useQuery<GrpcConnection[]>({
|
||||
enabled: requestId !== null,
|
||||
initialData: [],
|
||||
queryKey: grpcConnectionsQueryKey({ requestId: requestId ?? 'n/a' }),
|
||||
queryFn: async () => {
|
||||
if (requestId == null) return [];
|
||||
return (await invokeCmd('cmd_list_grpc_connections', {
|
||||
requestId,
|
||||
limit: 200,
|
||||
})) as GrpcConnection[];
|
||||
},
|
||||
}).data ?? []
|
||||
);
|
||||
export function useGrpcConnections() {
|
||||
return useAtomValue(grpcConnectionsAtom);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,8 @@
|
||||
import type { GrpcRequest } from '@yaakapp-internal/models';
|
||||
import { atom, useAtom } from 'jotai';
|
||||
import { useEffect } from 'react';
|
||||
import { invokeCmd } from '../lib/tauri';
|
||||
import { useActiveWorkspace } from './useActiveWorkspace';
|
||||
import { atom, useAtomValue } from 'jotai';
|
||||
|
||||
export const grpcRequestsAtom = atom<GrpcRequest[]>([]);
|
||||
|
||||
export function useGrpcRequests() {
|
||||
const [items, setItems] = useAtom(grpcRequestsAtom);
|
||||
const workspace = useActiveWorkspace();
|
||||
|
||||
// Fetch new requests when workspace changes
|
||||
useEffect(() => {
|
||||
if (workspace == null) return;
|
||||
invokeCmd<GrpcRequest[]>('cmd_list_grpc_requests', { workspaceId: workspace.id }).then(
|
||||
setItems,
|
||||
);
|
||||
}, [setItems, workspace]);
|
||||
|
||||
return items;
|
||||
return useAtomValue(grpcRequestsAtom);
|
||||
}
|
||||
|
||||
@@ -3,10 +3,15 @@ import type { HttpRequest } from '@yaakapp-internal/models';
|
||||
import type {
|
||||
CallHttpRequestActionRequest,
|
||||
GetHttpRequestActionsResponse,
|
||||
HttpRequestAction,
|
||||
} from '@yaakapp-internal/plugin';
|
||||
import { invokeCmd } from '../lib/tauri';
|
||||
import { usePluginsKey } from './usePlugins';
|
||||
|
||||
export type CallableHttpRequestAction = Pick<HttpRequestAction, 'key' | 'label' | 'icon'> & {
|
||||
call: (httpRequest: HttpRequest) => Promise<void>;
|
||||
};
|
||||
|
||||
export function useHttpRequestActions() {
|
||||
const pluginsKey = usePluginsKey();
|
||||
|
||||
@@ -20,7 +25,7 @@ export function useHttpRequestActions() {
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
const actions: CallableHttpRequestAction[] =
|
||||
httpRequestActions.data?.flatMap((r) =>
|
||||
r.actions.map((a) => ({
|
||||
key: a.key,
|
||||
@@ -35,6 +40,7 @@ export function useHttpRequestActions() {
|
||||
await invokeCmd('cmd_call_http_request_action', { req: payload });
|
||||
},
|
||||
})),
|
||||
) ?? []
|
||||
);
|
||||
) ?? [];
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user