Compare commits

..

31 Commits

Author SHA1 Message Date
Gregory Schier
6f50f35519 Bump Tauri to fix macOS 13 launch issue 2024-10-15 09:54:21 -07:00
Gregory Schier
4e775b2b49 Undo minimumSystemVersion 2024-10-15 07:49:27 -07:00
Gregory Schier
e77a9e5d44 Rebuild plugins 2024-10-15 07:48:26 -07:00
Gregory Schier
a381e44d8c Prevent stale content flash after editing request name 2024-10-15 07:32:00 -07:00
Gregory Schier
4acf0969e8 Only sync models from active workspace 2024-10-15 07:31:42 -07:00
Gregory Schier
30c4178269 Disable autocomplete/correct/etc in plain input 2024-10-14 21:46:48 -07:00
Gregory Schier
dffe6e0a16 Intelligent readonly editor updates, to preserve scroll 2024-10-14 10:40:09 -07:00
Gregory Schier
8090e67b9e Revert hyper v1 for gRPC 2024-10-12 22:05:17 -07:00
Gregory Schier
f1beabcb6f Try again 2024-10-12 21:33:45 -07:00
Gregory Schier
647b8e2313 Try fix windows build 2024-10-12 21:17:44 -07:00
Gregory Schier
f5b4697608 Npm i 2024-10-12 21:06:19 -07:00
Gregory Schier
f201857d51 Bump Tauri to fix settings window 2024-10-12 20:57:01 -07:00
Gregory Schier
0d982057a5 Add proxy setting for HTTP requests (#127) 2024-10-12 20:55:09 -07:00
Gregory Schier
6fb94384b9 Better fuzzy matching in cmd palette 2024-10-12 07:41:01 -07:00
Gregory Schier
d754e7233d Server sent event response viewer (#126) 2024-10-11 06:52:32 -07:00
Gregory Schier
f974a66086 Fix double-click-maximize and backdrop 2024-10-10 07:11:43 -07:00
Gregory Schier
250625fc0e Always show window controls, and open Linux settings in dialog 2024-10-10 06:22:11 -07:00
Gregory Schier
16e090b520 Fix content type detection 2024-10-09 17:20:09 -07:00
Gregory Schier
be9fbbcb6e Fix content type detection 2024-10-09 17:19:41 -07:00
Gregory Schier
8be3c3d0e1 Disable response copy until response is done 2024-10-09 16:38:12 -07:00
Gregory Schier
c680e15cb5 Max width on request name in header 2024-10-09 16:38:03 -07:00
Gregory Schier
da6baf72f5 Response Streaming (#124) 2024-10-09 16:27:37 -07:00
Gregory Schier
2ca30bcb31 Fix Codemirror undo history 2024-10-09 12:00:52 -07:00
Gregory Schier
2e2b3128c5 Fix cookie jar query 2024-10-09 11:26:19 -07:00
Gregory Schier
4a81818d05 Add descriptions to template functions 2024-10-09 11:25:51 -07:00
Gregory Schier
0eb98a3882 Log query errors 2024-10-09 11:25:05 -07:00
Gregory Schier
d28100d682 Add new plugins 2024-10-09 09:54:15 -07:00
Gregory Schier
0f4d3bdbb5 Allow space in dropdown filter text 2024-10-09 09:54:07 -07:00
Gregory Schier
c7eccddac9 Fix performance related to having 100s of requests (#123) 2024-10-08 14:16:57 -07:00
Gregory Schier
4b7712df80 Better Dropdown size calculation for scrolling when not enough room 2024-10-02 16:17:28 -07:00
Gregory Schier
e5c6c31e02 Fix prompt again 2024-10-02 12:53:58 -07:00
126 changed files with 6952 additions and 7380 deletions

2541
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -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": [

View File

@@ -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;

View File

@@ -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, };

View File

@@ -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;

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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"] }

View File

@@ -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",

View File

@@ -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"]}}

File diff suppressed because it is too large Load Diff

View 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;

View File

@@ -0,0 +1 @@
ALTER TABLE settings ADD COLUMN proxy TEXT;

View File

@@ -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 {

View File

@@ -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),

View File

@@ -1,5 +1,5 @@
{
"name": "exporter-curl",
"name": "@yaakapp/exporter-curl",
"private": true,
"version": "0.0.1",
"scripts": {

View File

@@ -1,5 +1,5 @@
{
"name": "filter-jsonpath",
"name": "@yaakapp/filter-jsonpath",
"private": true,
"version": "0.0.1",
"scripts": {

View File

@@ -1,5 +1,5 @@
{
"name": "filter-xpath",
"name": "@yaakapp/filter-xpath",
"private": true,
"version": "0.0.1",
"scripts": {

View File

@@ -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 {

View File

@@ -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"
}
}

View File

@@ -1,5 +1,5 @@
{
"name": "importer-insomnia",
"name": "@yaakapp/importer-insomnia",
"private": true,
"version": "0.0.1",
"scripts": {

View File

@@ -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"
}
}

View File

@@ -1,5 +1,5 @@
{
"name": "importer-postman",
"name": "@yaakapp/importer-postman",
"private": true,
"version": "0.0.1",
"main": "./build/index.js",

View File

@@ -1,5 +1,5 @@
{
"name": "importer-yaak",
"name": "@yaakapp/importer-yaak",
"private": true,
"version": "0.0.1",
"scripts": {

View 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
});

View 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"
}
}

View 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
});

View 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"
}
}

View 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
});

View 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"
}
}

View 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
});

View 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"
}
}

View File

@@ -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
}));
}
}
]

View File

@@ -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"
}
}

View File

@@ -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

View File

@@ -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))
}
}

View File

@@ -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")
}
}

View File

@@ -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, };

View File

@@ -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),

View File

@@ -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>,

View File

@@ -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;

View File

@@ -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, };

View File

@@ -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

View 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"] }

View 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, };

View File

@@ -0,0 +1 @@
export * from './bindings/sse';

View File

@@ -0,0 +1,6 @@
{
"name": "@yaakapp-internal/sse",
"private": true,
"version": "1.0.0",
"main": "index.ts"
}

View File

@@ -0,0 +1 @@
pub mod sse;

View 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
View File

@@ -0,0 +1,2 @@
vite.config.d.ts
vite.config.js

View File

@@ -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}>

View File

@@ -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);

View File

@@ -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>;

View File

@@ -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);

View File

@@ -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();

View File

@@ -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;
};

View File

@@ -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);

View File

@@ -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',
)}
>

View File

@@ -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';

View File

@@ -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';

View File

@@ -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>
);
}

View File

@@ -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',
)}
/>

View File

@@ -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',
)}
>

View File

@@ -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',

View File

@@ -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>&bull;</span>
<DurationTag
headers={activeResponse.elapsedHeaders}
total={activeResponse.elapsed}
/>
</>
)}
{!!activeResponse.contentLength && (
<>
<span>&bull;</span>
<SizeTag contentLength={activeResponse.contentLength} />
</>
)}
<span>&bull;</span>
<DurationTag
headers={activeResponse.elapsedHeaders}
total={activeResponse.elapsed}
/>
<span>&bull;</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 });
}

View File

@@ -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>
);

View File

@@ -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 })}

View 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>
);
}

View File

@@ -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);
},

View File

@@ -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">

View File

@@ -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>
);
});

View File

@@ -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',

View File

@@ -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>

View File

@@ -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;
}
* {

View File

@@ -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 = [];

View File

@@ -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]),
];

View File

@@ -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',
},
];

View File

@@ -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})`,

View File

@@ -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',
)}

View File

@@ -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}

View File

@@ -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>
);
}

View File

@@ -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>;

View File

@@ -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>

View 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>
);
}

View File

@@ -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}
/>
);
}
}

View File

@@ -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" />;
}

View File

@@ -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

View File

@@ -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}

View File

@@ -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>;

View File

@@ -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) {

View File

@@ -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() {

View File

@@ -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>
</>
),
});

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View 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);
});
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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