Compare commits

..

1 Commits

Author SHA1 Message Date
Gregory Schier fa3e6e6508 feat: add ctx.prompt.form() API for multi-field form dialogs
Add PromptFormRequest and PromptFormResponse types to enable plugins to
display forms with multiple input fields. Implement the form() method in
the prompt context and wire up frontend event handling to show and collect
form responses from users.
2026-01-09 19:35:47 -08:00
139 changed files with 1872 additions and 3938 deletions
@@ -37,11 +37,3 @@ The skill generates markdown-formatted release notes following this structure:
**IMPORTANT**: Always add a blank lines around the markdown code fence and output the markdown code block last **IMPORTANT**: Always add a blank lines around the markdown code fence and output the markdown code block last
**IMPORTANT**: PRs by `@gschier` should not mention the @username **IMPORTANT**: PRs by `@gschier` should not mention the @username
## After Generating Release Notes
After outputting the release notes, ask the user if they would like to create a draft GitHub release with these notes. If they confirm, create the release using:
```bash
gh release create <tag> --draft --prerelease --title "<tag>" --notes '<release notes>'
```
+35 -59
View File
@@ -1,7 +1,7 @@
name: Generate Artifacts name: Generate Artifacts
on: on:
push: push:
tags: [v*] tags: [ v* ]
jobs: jobs:
build-artifacts: build-artifacts:
@@ -13,37 +13,37 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
include: include:
- platform: "macos-latest" # for Arm-based Macs (M1 and above). - platform: 'macos-latest' # for Arm-based Macs (M1 and above).
args: "--target aarch64-apple-darwin" args: '--target aarch64-apple-darwin'
yaak_arch: "arm64" yaak_arch: 'arm64'
os: "macos" os: 'macos'
targets: "aarch64-apple-darwin" targets: 'aarch64-apple-darwin'
- platform: "macos-latest" # for Intel-based Macs. - platform: 'macos-latest' # for Intel-based Macs.
args: "--target x86_64-apple-darwin" args: '--target x86_64-apple-darwin'
yaak_arch: "x64" yaak_arch: 'x64'
os: "macos" os: 'macos'
targets: "x86_64-apple-darwin" targets: 'x86_64-apple-darwin'
- platform: "ubuntu-22.04" - platform: 'ubuntu-22.04'
args: "" args: ''
yaak_arch: "x64" yaak_arch: 'x64'
os: "ubuntu" os: 'ubuntu'
targets: "" targets: ''
- platform: "ubuntu-22.04-arm" - platform: 'ubuntu-22.04-arm'
args: "" args: ''
yaak_arch: "arm64" yaak_arch: 'arm64'
os: "ubuntu" os: 'ubuntu'
targets: "" targets: ''
- platform: "windows-latest" - platform: 'windows-latest'
args: "" args: ''
yaak_arch: "x64" yaak_arch: 'x64'
os: "windows" os: 'windows'
targets: "" targets: ''
# Windows ARM64 # Windows ARM64
- platform: "windows-latest" - platform: 'windows-latest'
args: "--target aarch64-pc-windows-msvc" args: '--target aarch64-pc-windows-msvc'
yaak_arch: "arm64" yaak_arch: 'arm64'
os: "windows" os: 'windows'
targets: "aarch64-pc-windows-msvc" targets: 'aarch64-pc-windows-msvc'
runs-on: ${{ matrix.platform }} runs-on: ${{ matrix.platform }}
timeout-minutes: 40 timeout-minutes: 40
steps: steps:
@@ -88,7 +88,6 @@ jobs:
& $exe --version & $exe --version
- run: npm ci - run: npm ci
- run: npm run bootstrap
- run: npm run lint - run: npm run lint
- name: Run JS Tests - name: Run JS Tests
run: npm test run: npm test
@@ -100,29 +99,6 @@ jobs:
env: env:
YAAK_VERSION: ${{ github.ref_name }} YAAK_VERSION: ${{ github.ref_name }}
- name: Sign vendored binaries (macOS only)
if: matrix.os == 'macos'
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
# Create keychain
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
# Import certificate
echo "$APPLE_CERTIFICATE" | base64 --decode > certificate.p12
security import certificate.p12 -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security list-keychain -d user -s $KEYCHAIN_PATH
# Sign vendored binaries with hardened runtime and their specific entitlements
codesign --force --options runtime --entitlements crates-tauri/yaak-app/macos/entitlements.yaakprotoc.plist --sign "$APPLE_SIGNING_IDENTITY" crates-tauri/yaak-app/vendored/protoc/yaakprotoc || true
codesign --force --options runtime --entitlements crates-tauri/yaak-app/macos/entitlements.yaaknode.plist --sign "$APPLE_SIGNING_IDENTITY" crates-tauri/yaak-app/vendored/node/yaaknode || true
- uses: tauri-apps/tauri-action@v0 - uses: tauri-apps/tauri-action@v0
env: env:
YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }} YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}
@@ -145,9 +121,9 @@ jobs:
AZURE_CLIENT_SECRET: ${{ matrix.os == 'windows' && secrets.AZURE_CLIENT_SECRET }} AZURE_CLIENT_SECRET: ${{ matrix.os == 'windows' && secrets.AZURE_CLIENT_SECRET }}
AZURE_TENANT_ID: ${{ matrix.os == 'windows' && secrets.AZURE_TENANT_ID }} AZURE_TENANT_ID: ${{ matrix.os == 'windows' && secrets.AZURE_TENANT_ID }}
with: with:
tagName: "v__VERSION__" tagName: 'v__VERSION__'
releaseName: "Release __VERSION__" releaseName: 'Release __VERSION__'
releaseBody: "[Changelog __VERSION__](https://yaak.app/blog/__VERSION__)" releaseBody: '[Changelog __VERSION__](https://yaak.app/blog/__VERSION__)'
releaseDraft: true releaseDraft: true
prerelease: true prerelease: true
args: "${{ matrix.args }} --config ./crates-tauri/yaak-app/tauri.release.conf.json" args: '${{ matrix.args }} --config ./crates-tauri/yaak-app/tauri.release.conf.json'
Generated
-4
View File
@@ -8075,7 +8075,6 @@ name = "yaak-common"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"serde_json", "serde_json",
"tokio",
] ]
[[package]] [[package]]
@@ -8122,10 +8121,8 @@ dependencies = [
"serde_json", "serde_json",
"serde_yaml", "serde_yaml",
"thiserror 2.0.17", "thiserror 2.0.17",
"tokio",
"ts-rs", "ts-rs",
"url", "url",
"yaak-common",
"yaak-models", "yaak-models",
"yaak-sync", "yaak-sync",
] ]
@@ -8152,7 +8149,6 @@ dependencies = [
"tonic", "tonic",
"tonic-reflection", "tonic-reflection",
"uuid", "uuid",
"yaak-common",
"yaak-tls", "yaak-tls",
] ]
+2 -2
View File
@@ -1,6 +1,6 @@
<p align="center"> <p align="center">
<a href="https://github.com/JamesIves/github-sponsors-readme-action"> <a href="https://github.com/JamesIves/github-sponsors-readme-action">
<img width="200px" src="https://github.com/mountain-loop/yaak/raw/main/crates-tauri/yaak-app/icons/icon.png"> <img width="200px" src="https://github.com/mountain-loop/yaak/raw/main/src-tauri/icons/icon.png">
</a> </a>
</p> </p>
@@ -64,7 +64,7 @@ visit [`DEVELOPMENT.md`](DEVELOPMENT.md) for tips on setting up your environment
## Useful Resources ## Useful Resources
- [Feedback and Bug Reports](https://feedback.yaak.app) - [Feedback and Bug Reports](https://feedback.yaak.app)
- [Documentation](https://yaak.app/docs) - [Documentation](https://feedback.yaak.app/help)
- [Yaak vs Postman](https://yaak.app/alternatives/postman) - [Yaak vs Postman](https://yaak.app/alternatives/postman)
- [Yaak vs Bruno](https://yaak.app/alternatives/bruno) - [Yaak vs Bruno](https://yaak.app/alternatives/bruno)
- [Yaak vs Insomnia](https://yaak.app/alternatives/insomnia) - [Yaak vs Insomnia](https://yaak.app/alternatives/insomnia)
+56 -17
View File
@@ -15,7 +15,7 @@ use yaak_models::util::UpdateSource;
use yaak_plugins::events::{PluginContext, RenderPurpose}; use yaak_plugins::events::{PluginContext, RenderPurpose};
use yaak_plugins::manager::PluginManager; use yaak_plugins::manager::PluginManager;
use yaak_plugins::template_callback::PluginTemplateCallback; use yaak_plugins::template_callback::PluginTemplateCallback;
use yaak_templates::{RenderOptions, parse_and_render, render_json_value_raw}; use yaak_templates::{parse_and_render, render_json_value_raw, RenderOptions};
#[derive(Parser)] #[derive(Parser)]
#[command(name = "yaakcli")] #[command(name = "yaakcli")]
@@ -149,7 +149,14 @@ async fn render_http_request(
// Apply path placeholders (e.g., /users/:id -> /users/123) // Apply path placeholders (e.g., /users/:id -> /users/123)
let (url, url_parameters) = apply_path_placeholders(&url, &url_parameters); let (url, url_parameters) = apply_path_placeholders(&url, &url_parameters);
Ok(HttpRequest { url, url_parameters, headers, body, authentication, ..r.to_owned() }) Ok(HttpRequest {
url,
url_parameters,
headers,
body,
authentication,
..r.to_owned()
})
} }
#[tokio::main] #[tokio::main]
@@ -162,10 +169,16 @@ async fn main() {
} }
// Use the same app_id for both data directory and keyring // Use the same app_id for both data directory and keyring
let app_id = if cfg!(debug_assertions) { "app.yaak.desktop.dev" } else { "app.yaak.desktop" }; let app_id = if cfg!(debug_assertions) {
"app.yaak.desktop.dev"
} else {
"app.yaak.desktop"
};
let data_dir = cli.data_dir.unwrap_or_else(|| { let data_dir = cli.data_dir.unwrap_or_else(|| {
dirs::data_dir().expect("Could not determine data directory").join(app_id) dirs::data_dir()
.expect("Could not determine data directory")
.join(app_id)
}); });
let db_path = data_dir.join("db.sqlite"); let db_path = data_dir.join("db.sqlite");
@@ -178,7 +191,9 @@ async fn main() {
// Initialize encryption manager for secure() template function // Initialize encryption manager for secure() template function
// Use the same app_id as the Tauri app for keyring access // Use the same app_id as the Tauri app for keyring access
let encryption_manager = Arc::new(EncryptionManager::new(query_manager.clone(), app_id)); let encryption_manager = Arc::new(
EncryptionManager::new(query_manager.clone(), app_id),
);
// Initialize plugin manager for template functions // Initialize plugin manager for template functions
let vendored_plugin_dir = data_dir.join("vendored-plugins"); let vendored_plugin_dir = data_dir.join("vendored-plugins");
@@ -188,8 +203,9 @@ async fn main() {
let node_bin_path = PathBuf::from("node"); let node_bin_path = PathBuf::from("node");
// Find the plugin runtime - check YAAK_PLUGIN_RUNTIME env var, then fallback to development path // Find the plugin runtime - check YAAK_PLUGIN_RUNTIME env var, then fallback to development path
let plugin_runtime_main = let plugin_runtime_main = std::env::var("YAAK_PLUGIN_RUNTIME")
std::env::var("YAAK_PLUGIN_RUNTIME").map(PathBuf::from).unwrap_or_else(|_| { .map(PathBuf::from)
.unwrap_or_else(|_| {
// Development fallback: look relative to crate root // Development fallback: look relative to crate root
PathBuf::from(env!("CARGO_MANIFEST_DIR")) PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../crates-tauri/yaak-app/vendored/plugin-runtime/index.cjs") .join("../../crates-tauri/yaak-app/vendored/plugin-runtime/index.cjs")
@@ -210,10 +226,14 @@ async fn main() {
// Initialize plugins from database // Initialize plugins from database
let plugins = db.list_plugins().unwrap_or_default(); let plugins = db.list_plugins().unwrap_or_default();
if !plugins.is_empty() { if !plugins.is_empty() {
let errors = let errors = plugin_manager
plugin_manager.initialize_all_plugins(plugins, &PluginContext::new_empty()).await; .initialize_all_plugins(plugins, &PluginContext::new_empty())
.await;
for (plugin_dir, error_msg) in errors { for (plugin_dir, error_msg) in errors {
eprintln!("Warning: Failed to initialize plugin '{}': {}", plugin_dir, error_msg); eprintln!(
"Warning: Failed to initialize plugin '{}': {}",
plugin_dir, error_msg
);
} }
} }
@@ -229,7 +249,9 @@ async fn main() {
} }
} }
Commands::Requests { workspace_id } => { Commands::Requests { workspace_id } => {
let requests = db.list_http_requests(&workspace_id).expect("Failed to list requests"); let requests = db
.list_http_requests(&workspace_id)
.expect("Failed to list requests");
if requests.is_empty() { if requests.is_empty() {
println!("No requests found in workspace {}", workspace_id); println!("No requests found in workspace {}", workspace_id);
} else { } else {
@@ -239,7 +261,9 @@ async fn main() {
} }
} }
Commands::Send { request_id } => { Commands::Send { request_id } => {
let request = db.get_http_request(&request_id).expect("Failed to get request"); let request = db
.get_http_request(&request_id)
.expect("Failed to get request");
// Resolve environment chain for variable substitution // Resolve environment chain for variable substitution
let environment_chain = db let environment_chain = db
@@ -294,13 +318,18 @@ async fn main() {
})) }))
} else { } else {
// Drain events silently // Drain events silently
tokio::spawn(async move { while event_rx.recv().await.is_some() {} }); tokio::spawn(async move {
while event_rx.recv().await.is_some() {}
});
None None
}; };
// Send the request // Send the request
let sender = ReqwestSender::new().expect("Failed to create HTTP client"); let sender = ReqwestSender::new().expect("Failed to create HTTP client");
let response = sender.send(sendable, event_tx).await.expect("Failed to send request"); let response = sender
.send(sendable, event_tx)
.await
.expect("Failed to send request");
// Wait for event handler to finish // Wait for event handler to finish
if let Some(handle) = verbose_handle { if let Some(handle) = verbose_handle {
@@ -354,13 +383,18 @@ async fn main() {
} }
})) }))
} else { } else {
tokio::spawn(async move { while event_rx.recv().await.is_some() {} }); tokio::spawn(async move {
while event_rx.recv().await.is_some() {}
});
None None
}; };
// Send the request // Send the request
let sender = ReqwestSender::new().expect("Failed to create HTTP client"); let sender = ReqwestSender::new().expect("Failed to create HTTP client");
let response = sender.send(sendable, event_tx).await.expect("Failed to send request"); let response = sender
.send(sendable, event_tx)
.await
.expect("Failed to send request");
if let Some(handle) = verbose_handle { if let Some(handle) = verbose_handle {
let _ = handle.await; let _ = handle.await;
@@ -387,7 +421,12 @@ async fn main() {
let (body, _stats) = response.text().await.expect("Failed to read response body"); let (body, _stats) = response.text().await.expect("Failed to read response body");
println!("{}", body); println!("{}", body);
} }
Commands::Create { workspace_id, name, method, url } => { Commands::Create {
workspace_id,
name,
method,
url,
} => {
let request = HttpRequest { let request = HttpRequest {
workspace_id, workspace_id,
name, name,
@@ -2,6 +2,14 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<!-- Enable for NodeJS execution -->
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<!-- Allow loading 1Password's dylib (signed with different Team ID) -->
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<!-- Re-enable for sandboxing. Currently disabled because auto-updater doesn't work with sandboxing.--> <!-- Re-enable for sandboxing. Currently disabled because auto-updater doesn't work with sandboxing.-->
<!-- <key>com.apple.security.app-sandbox</key> <true/>--> <!-- <key>com.apple.security.app-sandbox</key> <true/>-->
<!-- <key>com.apple.security.files.user-selected.read-write</key> <true/>--> <!-- <key>com.apple.security.files.user-selected.read-write</key> <true/>-->
@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Enable for NodeJS/V8 JIT compiler -->
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<!-- Allow loading plugins signed with different Team IDs (e.g., 1Password) -->
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
</dict>
</plist>
+2 -23
View File
@@ -1,11 +1,9 @@
use crate::PluginContextExt;
use crate::error::Result; use crate::error::Result;
use crate::PluginContextExt;
use std::sync::Arc; use std::sync::Arc;
use tauri::{AppHandle, Manager, Runtime, State, WebviewWindow, command}; use tauri::{AppHandle, Manager, Runtime, State, WebviewWindow, command};
use tauri_plugin_dialog::{DialogExt, MessageDialogKind}; use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
use yaak_crypto::manager::EncryptionManager; use yaak_crypto::manager::EncryptionManager;
use yaak_models::models::HttpRequestHeader;
use yaak_models::queries::workspaces::default_headers;
use yaak_plugins::events::GetThemesResponse; use yaak_plugins::events::GetThemesResponse;
use yaak_plugins::manager::PluginManager; use yaak_plugins::manager::PluginManager;
use yaak_plugins::native_template_functions::{ use yaak_plugins::native_template_functions::{
@@ -56,12 +54,7 @@ pub(crate) async fn cmd_secure_template<R: Runtime>(
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone()); let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone()); let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
let plugin_context = window.plugin_context(); let plugin_context = window.plugin_context();
Ok(encrypt_secure_template_function( Ok(encrypt_secure_template_function(plugin_manager, encryption_manager, &plugin_context, template)?)
plugin_manager,
encryption_manager,
&plugin_context,
template,
)?)
} }
#[command] #[command]
@@ -99,17 +92,3 @@ pub(crate) async fn cmd_set_workspace_key<R: Runtime>(
window.crypto().set_human_key(workspace_id, key)?; window.crypto().set_human_key(workspace_id, key)?;
Ok(()) Ok(())
} }
#[command]
pub(crate) async fn cmd_disable_encryption<R: Runtime>(
window: WebviewWindow<R>,
workspace_id: &str,
) -> Result<()> {
window.crypto().disable_encryption(workspace_id)?;
Ok(())
}
#[command]
pub(crate) fn cmd_default_headers() -> Vec<HttpRequestHeader> {
default_headers()
}
+18 -36
View File
@@ -6,47 +6,33 @@ use crate::error::Result;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use tauri::command; use tauri::command;
use yaak_git::{ use yaak_git::{
BranchDeleteResult, CloneResult, GitCommit, GitRemote, GitStatusSummary, PullResult, GitCommit, GitRemote, GitStatusSummary, PullResult, PushResult,
PushResult, git_add, git_add_credential, git_add_remote, git_checkout_branch, git_clone, git_add, git_add_credential, git_add_remote, git_checkout_branch, git_commit,
git_commit, git_create_branch, git_delete_branch, git_delete_remote_branch, git_fetch_all, git_create_branch, git_delete_branch, git_fetch_all, git_init, git_log,
git_init, git_log, git_merge_branch, git_pull, git_push, git_remotes, git_rename_branch, git_merge_branch, git_pull, git_push, git_remotes, git_rm_remote, git_status,
git_rm_remote, git_status, git_unstage, git_unstage,
}; };
// NOTE: All of these commands are async to prevent blocking work from locking up the UI // NOTE: All of these commands are async to prevent blocking work from locking up the UI
#[command] #[command]
pub async fn cmd_git_checkout(dir: &Path, branch: &str, force: bool) -> Result<String> { pub async fn cmd_git_checkout(dir: &Path, branch: &str, force: bool) -> Result<String> {
Ok(git_checkout_branch(dir, branch, force).await?) Ok(git_checkout_branch(dir, branch, force)?)
} }
#[command] #[command]
pub async fn cmd_git_branch(dir: &Path, branch: &str, base: Option<&str>) -> Result<()> { pub async fn cmd_git_branch(dir: &Path, branch: &str) -> Result<()> {
Ok(git_create_branch(dir, branch, base).await?) Ok(git_create_branch(dir, branch)?)
} }
#[command] #[command]
pub async fn cmd_git_delete_branch( pub async fn cmd_git_delete_branch(dir: &Path, branch: &str) -> Result<()> {
dir: &Path, Ok(git_delete_branch(dir, branch)?)
branch: &str,
force: Option<bool>,
) -> Result<BranchDeleteResult> {
Ok(git_delete_branch(dir, branch, force.unwrap_or(false)).await?)
} }
#[command] #[command]
pub async fn cmd_git_delete_remote_branch(dir: &Path, branch: &str) -> Result<()> { pub async fn cmd_git_merge_branch(dir: &Path, branch: &str, force: bool) -> Result<()> {
Ok(git_delete_remote_branch(dir, branch).await?) Ok(git_merge_branch(dir, branch, force)?)
}
#[command]
pub async fn cmd_git_merge_branch(dir: &Path, branch: &str) -> Result<()> {
Ok(git_merge_branch(dir, branch).await?)
}
#[command]
pub async fn cmd_git_rename_branch(dir: &Path, old_name: &str, new_name: &str) -> Result<()> {
Ok(git_rename_branch(dir, old_name, new_name).await?)
} }
#[command] #[command]
@@ -64,29 +50,24 @@ pub async fn cmd_git_initialize(dir: &Path) -> Result<()> {
Ok(git_init(dir)?) Ok(git_init(dir)?)
} }
#[command]
pub async fn cmd_git_clone(url: &str, dir: &Path) -> Result<CloneResult> {
Ok(git_clone(url, dir).await?)
}
#[command] #[command]
pub async fn cmd_git_commit(dir: &Path, message: &str) -> Result<()> { pub async fn cmd_git_commit(dir: &Path, message: &str) -> Result<()> {
Ok(git_commit(dir, message).await?) Ok(git_commit(dir, message)?)
} }
#[command] #[command]
pub async fn cmd_git_fetch_all(dir: &Path) -> Result<()> { pub async fn cmd_git_fetch_all(dir: &Path) -> Result<()> {
Ok(git_fetch_all(dir).await?) Ok(git_fetch_all(dir)?)
} }
#[command] #[command]
pub async fn cmd_git_push(dir: &Path) -> Result<PushResult> { pub async fn cmd_git_push(dir: &Path) -> Result<PushResult> {
Ok(git_push(dir).await?) Ok(git_push(dir)?)
} }
#[command] #[command]
pub async fn cmd_git_pull(dir: &Path) -> Result<PullResult> { pub async fn cmd_git_pull(dir: &Path) -> Result<PullResult> {
Ok(git_pull(dir).await?) Ok(git_pull(dir)?)
} }
#[command] #[command]
@@ -107,11 +88,12 @@ pub async fn cmd_git_unstage(dir: &Path, rela_paths: Vec<PathBuf>) -> Result<()>
#[command] #[command]
pub async fn cmd_git_add_credential( pub async fn cmd_git_add_credential(
dir: &Path,
remote_url: &str, remote_url: &str,
username: &str, username: &str,
password: &str, password: &str,
) -> Result<()> { ) -> Result<()> {
Ok(git_add_credential(remote_url, username, password).await?) Ok(git_add_credential(dir, remote_url, username, password).await?)
} }
#[command] #[command]
+2 -2
View File
@@ -1,12 +1,12 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use crate::PluginContextExt;
use crate::error::Result; use crate::error::Result;
use crate::models_ext::QueryManagerExt; use crate::PluginContextExt;
use KeyAndValueRef::{Ascii, Binary}; use KeyAndValueRef::{Ascii, Binary};
use tauri::{Manager, Runtime, WebviewWindow}; use tauri::{Manager, Runtime, WebviewWindow};
use yaak_grpc::{KeyAndValueRef, MetadataMap}; use yaak_grpc::{KeyAndValueRef, MetadataMap};
use yaak_models::models::GrpcRequest; use yaak_models::models::GrpcRequest;
use crate::models_ext::QueryManagerExt;
use yaak_plugins::events::{CallHttpAuthenticationRequest, HttpHeader}; use yaak_plugins::events::{CallHttpAuthenticationRequest, HttpHeader};
use yaak_plugins::manager::PluginManager; use yaak_plugins::manager::PluginManager;
+1 -1
View File
@@ -1,8 +1,8 @@
use crate::models_ext::QueryManagerExt;
use chrono::{NaiveDateTime, Utc}; use chrono::{NaiveDateTime, Utc};
use log::debug; use log::debug;
use std::sync::OnceLock; use std::sync::OnceLock;
use tauri::{AppHandle, Runtime}; use tauri::{AppHandle, Runtime};
use crate::models_ext::QueryManagerExt;
use yaak_models::util::UpdateSource; use yaak_models::util::UpdateSource;
const NAMESPACE: &str = "analytics"; const NAMESPACE: &str = "analytics";
+11 -43
View File
@@ -1,13 +1,9 @@
use crate::PluginContextExt;
use crate::error::Error::GenericError; use crate::error::Error::GenericError;
use crate::error::Result; use crate::error::Result;
use crate::models_ext::BlobManagerExt;
use crate::models_ext::QueryManagerExt;
use crate::render::render_http_request; use crate::render::render_http_request;
use log::{debug, warn}; use log::{debug, warn};
use std::pin::Pin; use std::pin::Pin;
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicI32, Ordering};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use tauri::{AppHandle, Manager, Runtime, WebviewWindow}; use tauri::{AppHandle, Manager, Runtime, WebviewWindow};
use tokio::fs::{File, create_dir_all}; use tokio::fs::{File, create_dir_all};
@@ -19,19 +15,22 @@ use yaak_http::client::{
HttpConnectionOptions, HttpConnectionProxySetting, HttpConnectionProxySettingAuth, HttpConnectionOptions, HttpConnectionProxySetting, HttpConnectionProxySettingAuth,
}; };
use yaak_http::cookies::CookieStore; use yaak_http::cookies::CookieStore;
use yaak_http::manager::{CachedClient, HttpConnectionManager}; use yaak_http::manager::HttpConnectionManager;
use yaak_http::sender::ReqwestSender; use yaak_http::sender::ReqwestSender;
use yaak_http::tee_reader::TeeReader; use yaak_http::tee_reader::TeeReader;
use yaak_http::transaction::HttpTransaction; use yaak_http::transaction::HttpTransaction;
use yaak_http::types::{ use yaak_http::types::{
SendableBody, SendableHttpRequest, SendableHttpRequestOptions, append_query_params, SendableBody, SendableHttpRequest, SendableHttpRequestOptions, append_query_params,
}; };
use crate::models_ext::BlobManagerExt;
use yaak_models::blob_manager::BodyChunk; use yaak_models::blob_manager::BodyChunk;
use yaak_models::models::{ use yaak_models::models::{
CookieJar, Environment, HttpRequest, HttpResponse, HttpResponseEvent, HttpResponseHeader, CookieJar, Environment, HttpRequest, HttpResponse, HttpResponseEvent, HttpResponseHeader,
HttpResponseState, ProxySetting, ProxySettingAuth, HttpResponseState, ProxySetting, ProxySettingAuth,
}; };
use crate::models_ext::QueryManagerExt;
use yaak_models::util::UpdateSource; use yaak_models::util::UpdateSource;
use crate::PluginContextExt;
use yaak_plugins::events::{ use yaak_plugins::events::{
CallHttpAuthenticationRequest, HttpHeader, PluginContext, RenderPurpose, CallHttpAuthenticationRequest, HttpHeader, PluginContext, RenderPurpose,
}; };
@@ -174,12 +173,7 @@ async fn send_http_request_inner<R: Runtime>(
let environment_id = environment.map(|e| e.id); let environment_id = environment.map(|e| e.id);
let workspace = window.db().get_workspace(workspace_id)?; let workspace = window.db().get_workspace(workspace_id)?;
let (resolved, auth_context_id) = resolve_http_request(window, unrendered_request)?; let (resolved, auth_context_id) = resolve_http_request(window, unrendered_request)?;
let cb = PluginTemplateCallback::new( let cb = PluginTemplateCallback::new(plugin_manager.clone(), encryption_manager.clone(), &plugin_context, RenderPurpose::Send);
plugin_manager.clone(),
encryption_manager.clone(),
&plugin_context,
RenderPurpose::Send,
);
let env_chain = let env_chain =
window.db().resolve_environments(&workspace.id, folder_id, environment_id.as_deref())?; window.db().resolve_environments(&workspace.id, folder_id, environment_id.as_deref())?;
let request = render_http_request(&resolved, env_chain, &cb, &RenderOptions::throw()).await?; let request = render_http_request(&resolved, env_chain, &cb, &RenderOptions::throw()).await?;
@@ -234,13 +228,12 @@ async fn send_http_request_inner<R: Runtime>(
None => None, None => None,
}; };
let cached_client = connection_manager let client = connection_manager
.get_client(&HttpConnectionOptions { .get_client(&HttpConnectionOptions {
id: plugin_context.id.clone(), id: plugin_context.id.clone(),
validate_certificates: workspace.setting_validate_certificates, validate_certificates: workspace.setting_validate_certificates,
proxy: proxy_setting, proxy: proxy_setting,
client_certificate, client_certificate,
dns_overrides: workspace.setting_dns_overrides.clone(),
}) })
.await?; .await?;
@@ -257,7 +250,7 @@ async fn send_http_request_inner<R: Runtime>(
let cookie_store = maybe_cookie_store.as_ref().map(|(cs, _)| cs.clone()); let cookie_store = maybe_cookie_store.as_ref().map(|(cs, _)| cs.clone());
let result = execute_transaction( let result = execute_transaction(
cached_client, client,
sendable_request, sendable_request,
response_ctx, response_ctx,
cancelled_rx.clone(), cancelled_rx.clone(),
@@ -317,7 +310,7 @@ pub fn resolve_http_request<R: Runtime>(
} }
async fn execute_transaction<R: Runtime>( async fn execute_transaction<R: Runtime>(
cached_client: CachedClient, client: reqwest::Client,
mut sendable_request: SendableHttpRequest, mut sendable_request: SendableHttpRequest,
response_ctx: &mut ResponseContext<R>, response_ctx: &mut ResponseContext<R>,
mut cancelled_rx: Receiver<bool>, mut cancelled_rx: Receiver<bool>,
@@ -328,10 +321,7 @@ async fn execute_transaction<R: Runtime>(
let workspace_id = response_ctx.response().workspace_id.clone(); let workspace_id = response_ctx.response().workspace_id.clone();
let is_persisted = response_ctx.is_persisted(); let is_persisted = response_ctx.is_persisted();
// Keep a reference to the resolver for DNS timing events let sender = ReqwestSender::with_client(client);
let resolver = cached_client.resolver.clone();
let sender = ReqwestSender::with_client(cached_client.client);
let transaction = match cookie_store { let transaction = match cookie_store {
Some(cs) => HttpTransaction::with_cookie_store(sender, cs), Some(cs) => HttpTransaction::with_cookie_store(sender, cs),
None => HttpTransaction::new(sender), None => HttpTransaction::new(sender),
@@ -356,39 +346,21 @@ async fn execute_transaction<R: Runtime>(
let (event_tx, mut event_rx) = let (event_tx, mut event_rx) =
tokio::sync::mpsc::channel::<yaak_http::sender::HttpResponseEvent>(100); tokio::sync::mpsc::channel::<yaak_http::sender::HttpResponseEvent>(100);
// Set the event sender on the DNS resolver so it can emit DNS timing events
resolver.set_event_sender(Some(event_tx.clone())).await;
// Shared state to capture DNS timing from the event processing task
let dns_elapsed = Arc::new(AtomicI32::new(0));
// Write events to DB in a task (only for persisted responses) // Write events to DB in a task (only for persisted responses)
if is_persisted { if is_persisted {
let response_id = response_id.clone(); let response_id = response_id.clone();
let app_handle = app_handle.clone(); let app_handle = app_handle.clone();
let update_source = response_ctx.update_source.clone(); let update_source = response_ctx.update_source.clone();
let workspace_id = workspace_id.clone(); let workspace_id = workspace_id.clone();
let dns_elapsed = dns_elapsed.clone();
tokio::spawn(async move { tokio::spawn(async move {
while let Some(event) = event_rx.recv().await { while let Some(event) = event_rx.recv().await {
// Capture DNS timing when we see a DNS event
if let yaak_http::sender::HttpResponseEvent::DnsResolved { duration, .. } = &event {
dns_elapsed.store(*duration as i32, Ordering::SeqCst);
}
let db_event = HttpResponseEvent::new(&response_id, &workspace_id, event.into()); let db_event = HttpResponseEvent::new(&response_id, &workspace_id, event.into());
let _ = app_handle.db().upsert_http_response_event(&db_event, &update_source); let _ = app_handle.db().upsert_http_response_event(&db_event, &update_source);
} }
}); });
} else { } else {
// For ephemeral responses, just drain the events but still capture DNS timing // For ephemeral responses, just drain the events
let dns_elapsed = dns_elapsed.clone(); tokio::spawn(async move { while event_rx.recv().await.is_some() {} });
tokio::spawn(async move {
while let Some(event) = event_rx.recv().await {
if let yaak_http::sender::HttpResponseEvent::DnsResolved { duration, .. } = &event {
dns_elapsed.store(*duration as i32, Ordering::SeqCst);
}
}
});
}; };
// Capture request body as it's sent (only for persisted responses) // Capture request body as it's sent (only for persisted responses)
@@ -556,14 +528,10 @@ async fn execute_transaction<R: Runtime>(
// Final update with closed state and accurate byte count // Final update with closed state and accurate byte count
response_ctx.update(|r| { response_ctx.update(|r| {
r.elapsed = start.elapsed().as_millis() as i32; r.elapsed = start.elapsed().as_millis() as i32;
r.elapsed_dns = dns_elapsed.load(Ordering::SeqCst);
r.content_length = Some(written_bytes as i32); r.content_length = Some(written_bytes as i32);
r.state = HttpResponseState::Closed; r.state = HttpResponseState::Closed;
})?; })?;
// Clear the event sender from the resolver since this request is done
resolver.set_event_sender(None).await;
Ok((response_ctx.response().clone(), maybe_blob_write_handle)) Ok((response_ctx.response().clone(), maybe_blob_write_handle))
} }
+2 -2
View File
@@ -1,17 +1,17 @@
use crate::PluginContextExt;
use crate::error::Result; use crate::error::Result;
use crate::models_ext::QueryManagerExt; use crate::models_ext::QueryManagerExt;
use crate::PluginContextExt;
use log::info; use log::info;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::fs::read_to_string; use std::fs::read_to_string;
use tauri::{Manager, Runtime, WebviewWindow}; use tauri::{Manager, Runtime, WebviewWindow};
use yaak_tauri_utils::window::WorkspaceWindowTrait;
use yaak_core::WorkspaceContext; use yaak_core::WorkspaceContext;
use yaak_models::models::{ use yaak_models::models::{
Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace, Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace,
}; };
use yaak_models::util::{BatchUpsertResult, UpdateSource, maybe_gen_id, maybe_gen_id_opt}; use yaak_models::util::{BatchUpsertResult, UpdateSource, maybe_gen_id, maybe_gen_id_opt};
use yaak_plugins::manager::PluginManager; use yaak_plugins::manager::PluginManager;
use yaak_tauri_utils::window::WorkspaceWindowTrait;
pub(crate) async fn import_data<R: Runtime>( pub(crate) async fn import_data<R: Runtime>(
window: &WebviewWindow<R>, window: &WebviewWindow<R>,
+32 -144
View File
@@ -7,7 +7,7 @@ use crate::http_request::{resolve_http_request, send_http_request};
use crate::import::import_data; use crate::import::import_data;
use crate::models_ext::{BlobManagerExt, QueryManagerExt}; use crate::models_ext::{BlobManagerExt, QueryManagerExt};
use crate::notifications::YaakNotifier; use crate::notifications::YaakNotifier;
use crate::render::{render_grpc_request, render_json_value, render_template}; use crate::render::{render_grpc_request, render_template};
use crate::updates::{UpdateMode, UpdateTrigger, YaakUpdater}; use crate::updates::{UpdateMode, UpdateTrigger, YaakUpdater};
use crate::uri_scheme::handle_deep_link; use crate::uri_scheme::handle_deep_link;
use error::Result as YaakResult; use error::Result as YaakResult;
@@ -101,7 +101,6 @@ struct AppMetaData {
app_data_dir: String, app_data_dir: String,
app_log_dir: String, app_log_dir: String,
vendored_plugin_dir: String, vendored_plugin_dir: String,
default_project_dir: String,
feature_updater: bool, feature_updater: bool,
feature_license: bool, feature_license: bool,
} }
@@ -112,7 +111,6 @@ async fn cmd_metadata(app_handle: AppHandle) -> YaakResult<AppMetaData> {
let app_log_dir = app_handle.path().app_log_dir()?; let app_log_dir = app_handle.path().app_log_dir()?;
let vendored_plugin_dir = let vendored_plugin_dir =
app_handle.path().resolve("vendored/plugins", BaseDirectory::Resource)?; app_handle.path().resolve("vendored/plugins", BaseDirectory::Resource)?;
let default_project_dir = app_handle.path().home_dir()?.join("YaakProjects");
Ok(AppMetaData { Ok(AppMetaData {
is_dev: is_dev(), is_dev: is_dev(),
version: app_handle.package_info().version.to_string(), version: app_handle.package_info().version.to_string(),
@@ -120,7 +118,6 @@ async fn cmd_metadata(app_handle: AppHandle) -> YaakResult<AppMetaData> {
app_data_dir: app_data_dir.to_string_lossy().to_string(), app_data_dir: app_data_dir.to_string_lossy().to_string(),
app_log_dir: app_log_dir.to_string_lossy().to_string(), app_log_dir: app_log_dir.to_string_lossy().to_string(),
vendored_plugin_dir: vendored_plugin_dir.to_string_lossy().to_string(), vendored_plugin_dir: vendored_plugin_dir.to_string_lossy().to_string(),
default_project_dir: default_project_dir.to_string_lossy().to_string(),
feature_license: cfg!(feature = "license"), feature_license: cfg!(feature = "license"),
feature_updater: cfg!(feature = "updater"), feature_updater: cfg!(feature = "updater"),
}) })
@@ -192,6 +189,7 @@ async fn cmd_grpc_reflect<R: Runtime>(
request_id: &str, request_id: &str,
environment_id: Option<&str>, environment_id: Option<&str>,
proto_files: Vec<String>, proto_files: Vec<String>,
skip_cache: Option<bool>,
window: WebviewWindow<R>, window: WebviewWindow<R>,
app_handle: AppHandle<R>, app_handle: AppHandle<R>,
grpc_handle: State<'_, Mutex<GrpcHandle>>, grpc_handle: State<'_, Mutex<GrpcHandle>>,
@@ -226,21 +224,18 @@ async fn cmd_grpc_reflect<R: Runtime>(
let settings = window.db().get_settings(); let settings = window.db().get_settings();
let client_certificate = let client_certificate =
find_client_certificate(req.url.as_str(), &settings.client_certificates); find_client_certificate(req.url.as_str(), &settings.client_certificates);
let proto_files: Vec<PathBuf> =
proto_files.iter().map(|p| PathBuf::from_str(p).unwrap()).collect();
// Always invalidate cached pool when this command is called, to force re-reflection Ok(grpc_handle
let mut handle = grpc_handle.lock().await; .lock()
handle.invalidate_pool(&req.id, &uri, &proto_files); .await
Ok(handle
.services( .services(
&req.id, &req.id,
&uri, &uri,
&proto_files, &proto_files.iter().map(|p| PathBuf::from_str(p).unwrap()).collect(),
&metadata, &metadata,
workspace.setting_validate_certificates, workspace.setting_validate_certificates,
client_certificate, client_certificate,
skip_cache.unwrap_or(false),
) )
.await .await
.map_err(|e| GenericError(e.to_string()))?) .map_err(|e| GenericError(e.to_string()))?)
@@ -365,8 +360,10 @@ async fn cmd_grpc_go<R: Runtime>(
let cb = { let cb = {
let cancelled_rx = cancelled_rx.clone(); let cancelled_rx = cancelled_rx.clone();
let app_handle = app_handle.clone();
let environment_chain = environment_chain.clone(); let environment_chain = environment_chain.clone();
let window = window.clone(); let window = window.clone();
let base_msg = base_msg.clone();
let plugin_manager = plugin_manager.clone(); let plugin_manager = plugin_manager.clone();
let encryption_manager = encryption_manager.clone(); let encryption_manager = encryption_manager.clone();
@@ -388,12 +385,14 @@ async fn cmd_grpc_go<R: Runtime>(
match serde_json::from_str::<IncomingMsg>(ev.payload()) { match serde_json::from_str::<IncomingMsg>(ev.payload()) {
Ok(IncomingMsg::Message(msg)) => { Ok(IncomingMsg::Message(msg)) => {
let window = window.clone(); let window = window.clone();
let app_handle = app_handle.clone();
let base_msg = base_msg.clone();
let environment_chain = environment_chain.clone(); let environment_chain = environment_chain.clone();
let plugin_manager = plugin_manager.clone(); let plugin_manager = plugin_manager.clone();
let encryption_manager = encryption_manager.clone(); let encryption_manager = encryption_manager.clone();
let msg = block_in_place(|| { let msg = block_in_place(|| {
tauri::async_runtime::block_on(async { tauri::async_runtime::block_on(async {
let result = render_template( render_template(
msg.as_str(), msg.as_str(),
environment_chain, environment_chain,
&PluginTemplateCallback::new( &PluginTemplateCallback::new(
@@ -407,11 +406,24 @@ async fn cmd_grpc_go<R: Runtime>(
), ),
&RenderOptions { error_behavior: RenderErrorBehavior::Throw }, &RenderOptions { error_behavior: RenderErrorBehavior::Throw },
) )
.await; .await
result.expect("Failed to render template") .expect("Failed to render template")
}) })
}); });
in_msg_tx.try_send(msg.clone()).unwrap(); in_msg_tx.try_send(msg.clone()).unwrap();
tauri::async_runtime::spawn(async move {
app_handle
.db()
.upsert_grpc_event(
&GrpcEvent {
content: msg,
event_type: GrpcEventType::ClientMessage,
..base_msg.clone()
},
&UpdateSource::from_window_label(window.label()),
)
.unwrap();
});
} }
Ok(IncomingMsg::Commit) => { Ok(IncomingMsg::Commit) => {
maybe_in_msg_tx.take(); maybe_in_msg_tx.take();
@@ -458,48 +470,12 @@ async fn cmd_grpc_go<R: Runtime>(
)?; )?;
async move { async move {
// Create callback for streaming methods that handles both success and error
let on_message = {
let app_handle = app_handle.clone();
let base_event = base_event.clone();
let window_label = window.label().to_string();
move |result: std::result::Result<String, String>| match result {
Ok(msg) => {
let _ = app_handle.db().upsert_grpc_event(
&GrpcEvent {
content: msg,
event_type: GrpcEventType::ClientMessage,
..base_event.clone()
},
&UpdateSource::from_window_label(&window_label),
);
}
Err(error) => {
let _ = app_handle.db().upsert_grpc_event(
&GrpcEvent {
content: format!("Failed to send message: {}", error),
event_type: GrpcEventType::Error,
..base_event.clone()
},
&UpdateSource::from_window_label(&window_label),
);
}
}
};
let (maybe_stream, maybe_msg) = let (maybe_stream, maybe_msg) =
match (method_desc.is_client_streaming(), method_desc.is_server_streaming()) { match (method_desc.is_client_streaming(), method_desc.is_server_streaming()) {
(true, true) => ( (true, true) => (
Some( Some(
connection connection
.streaming( .streaming(&service, &method, in_msg_stream, &metadata, client_cert)
&service,
&method,
in_msg_stream,
&metadata,
client_cert,
on_message.clone(),
)
.await, .await,
), ),
None, None,
@@ -514,7 +490,6 @@ async fn cmd_grpc_go<R: Runtime>(
in_msg_stream, in_msg_stream,
&metadata, &metadata,
client_cert, client_cert,
on_message.clone(),
) )
.await, .await,
), ),
@@ -1060,54 +1035,14 @@ async fn cmd_get_http_authentication_summaries<R: Runtime>(
#[tauri::command] #[tauri::command]
async fn cmd_get_http_authentication_config<R: Runtime>( async fn cmd_get_http_authentication_config<R: Runtime>(
window: WebviewWindow<R>, window: WebviewWindow<R>,
app_handle: AppHandle<R>,
plugin_manager: State<'_, PluginManager>, plugin_manager: State<'_, PluginManager>,
encryption_manager: State<'_, EncryptionManager>,
auth_name: &str, auth_name: &str,
values: HashMap<String, JsonPrimitive>, values: HashMap<String, JsonPrimitive>,
model: AnyModel, model: AnyModel,
environment_id: Option<&str>, _environment_id: Option<&str>,
) -> YaakResult<GetHttpAuthenticationConfigResponse> { ) -> YaakResult<GetHttpAuthenticationConfigResponse> {
// Extract workspace_id and folder_id from the model to resolve the environment chain
let (workspace_id, folder_id) = match &model {
AnyModel::HttpRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
AnyModel::GrpcRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
AnyModel::WebsocketRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
AnyModel::Folder(f) => (f.workspace_id.clone(), f.folder_id.clone()),
AnyModel::Workspace(w) => (w.id.clone(), None),
_ => return Err(GenericError("Unsupported model type for authentication config".into())),
};
// Resolve environment chain and render the values for token lookup
let environment_chain = app_handle.db().resolve_environments(
&workspace_id,
folder_id.as_deref(),
environment_id,
)?;
let plugin_manager_arc = Arc::new((*plugin_manager).clone());
let encryption_manager_arc = Arc::new((*encryption_manager).clone());
let cb = PluginTemplateCallback::new(
plugin_manager_arc,
encryption_manager_arc,
&window.plugin_context(),
RenderPurpose::Preview,
);
// Convert HashMap<String, JsonPrimitive> to serde_json::Value for rendering
let values_json: serde_json::Value = serde_json::to_value(&values)?;
let rendered_json =
render_json_value(values_json, environment_chain, &cb, &RenderOptions::throw()).await?;
// Convert back to HashMap<String, JsonPrimitive>
let rendered_values: HashMap<String, JsonPrimitive> = serde_json::from_value(rendered_json)?;
Ok(plugin_manager Ok(plugin_manager
.get_http_authentication_config( .get_http_authentication_config(&window.plugin_context(), auth_name, values, model.id())
&window.plugin_context(),
auth_name,
rendered_values,
model.id(),
)
.await?) .await?)
} }
@@ -1154,54 +1089,19 @@ async fn cmd_call_grpc_request_action<R: Runtime>(
#[tauri::command] #[tauri::command]
async fn cmd_call_http_authentication_action<R: Runtime>( async fn cmd_call_http_authentication_action<R: Runtime>(
window: WebviewWindow<R>, window: WebviewWindow<R>,
app_handle: AppHandle<R>,
plugin_manager: State<'_, PluginManager>, plugin_manager: State<'_, PluginManager>,
encryption_manager: State<'_, EncryptionManager>,
auth_name: &str, auth_name: &str,
action_index: i32, action_index: i32,
values: HashMap<String, JsonPrimitive>, values: HashMap<String, JsonPrimitive>,
model: AnyModel, model: AnyModel,
environment_id: Option<&str>, _environment_id: Option<&str>,
) -> YaakResult<()> { ) -> YaakResult<()> {
// Extract workspace_id and folder_id from the model to resolve the environment chain
let (workspace_id, folder_id) = match &model {
AnyModel::HttpRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
AnyModel::GrpcRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
AnyModel::WebsocketRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
AnyModel::Folder(f) => (f.workspace_id.clone(), f.folder_id.clone()),
AnyModel::Workspace(w) => (w.id.clone(), None),
_ => return Err(GenericError("Unsupported model type for authentication action".into())),
};
// Resolve environment chain and render the values
let environment_chain = app_handle.db().resolve_environments(
&workspace_id,
folder_id.as_deref(),
environment_id,
)?;
let plugin_manager_arc = Arc::new((*plugin_manager).clone());
let encryption_manager_arc = Arc::new((*encryption_manager).clone());
let cb = PluginTemplateCallback::new(
plugin_manager_arc,
encryption_manager_arc,
&window.plugin_context(),
RenderPurpose::Send,
);
// Convert HashMap<String, JsonPrimitive> to serde_json::Value for rendering
let values_json: serde_json::Value = serde_json::to_value(&values)?;
let rendered_json =
render_json_value(values_json, environment_chain, &cb, &RenderOptions::throw()).await?;
// Convert back to HashMap<String, JsonPrimitive>
let rendered_values: HashMap<String, JsonPrimitive> = serde_json::from_value(rendered_json)?;
Ok(plugin_manager Ok(plugin_manager
.call_http_authentication_action( .call_http_authentication_action(
&window.plugin_context(), &window.plugin_context(),
auth_name, auth_name,
action_index, action_index,
rendered_values, values,
&model.id(), &model.id(),
) )
.await?) .await?)
@@ -1721,8 +1621,6 @@ pub fn run() {
// //
// Migrated commands // Migrated commands
crate::commands::cmd_decrypt_template, crate::commands::cmd_decrypt_template,
crate::commands::cmd_default_headers,
crate::commands::cmd_disable_encryption,
crate::commands::cmd_enable_encryption, crate::commands::cmd_enable_encryption,
crate::commands::cmd_get_themes, crate::commands::cmd_get_themes,
crate::commands::cmd_reveal_workspace_key, crate::commands::cmd_reveal_workspace_key,
@@ -1751,13 +1649,10 @@ pub fn run() {
git_ext::cmd_git_checkout, git_ext::cmd_git_checkout,
git_ext::cmd_git_branch, git_ext::cmd_git_branch,
git_ext::cmd_git_delete_branch, git_ext::cmd_git_delete_branch,
git_ext::cmd_git_delete_remote_branch,
git_ext::cmd_git_merge_branch, git_ext::cmd_git_merge_branch,
git_ext::cmd_git_rename_branch,
git_ext::cmd_git_status, git_ext::cmd_git_status,
git_ext::cmd_git_log, git_ext::cmd_git_log,
git_ext::cmd_git_initialize, git_ext::cmd_git_initialize,
git_ext::cmd_git_clone,
git_ext::cmd_git_commit, git_ext::cmd_git_commit,
git_ext::cmd_git_fetch_all, git_ext::cmd_git_fetch_all,
git_ext::cmd_git_push, git_ext::cmd_git_push,
@@ -1769,13 +1664,6 @@ pub fn run() {
git_ext::cmd_git_add_remote, git_ext::cmd_git_add_remote,
git_ext::cmd_git_rm_remote, git_ext::cmd_git_rm_remote,
// //
// Plugin commands
plugins_ext::cmd_plugins_search,
plugins_ext::cmd_plugins_install,
plugins_ext::cmd_plugins_uninstall,
plugins_ext::cmd_plugins_updates,
plugins_ext::cmd_plugins_update_all,
//
// WebSocket commands // WebSocket commands
ws_ext::cmd_ws_upsert_request, ws_ext::cmd_ws_upsert_request,
ws_ext::cmd_ws_duplicate_request, ws_ext::cmd_ws_duplicate_request,
+2 -2
View File
@@ -1,6 +1,5 @@
use crate::error::Result; use crate::error::Result;
use crate::history::get_or_upsert_launch_info; use crate::history::get_or_upsert_launch_info;
use crate::models_ext::QueryManagerExt;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use log::{debug, info}; use log::{debug, info};
use reqwest::Method; use reqwest::Method;
@@ -9,8 +8,9 @@ use std::time::Instant;
use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewWindow}; use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewWindow};
use ts_rs::TS; use ts_rs::TS;
use yaak_common::platform::get_os_str; use yaak_common::platform::get_os_str;
use yaak_models::util::UpdateSource;
use yaak_tauri_utils::api_client::yaak_api_client; use yaak_tauri_utils::api_client::yaak_api_client;
use crate::models_ext::QueryManagerExt;
use yaak_models::util::UpdateSource;
// Check for updates every hour // Check for updates every hour
const MAX_UPDATE_CHECK_SECONDS: u64 = 60 * 60; const MAX_UPDATE_CHECK_SECONDS: u64 = 60 * 60;
+6 -25
View File
@@ -1,7 +1,5 @@
use crate::error::Result; use crate::error::Result;
use crate::http_request::send_http_request_with_context; use crate::http_request::send_http_request_with_context;
use crate::models_ext::BlobManagerExt;
use crate::models_ext::QueryManagerExt;
use crate::render::{render_grpc_request, render_http_request, render_json_value}; use crate::render::{render_grpc_request, render_http_request, render_json_value};
use crate::window::{CreateWindowConfig, create_window}; use crate::window::{CreateWindowConfig, create_window};
use crate::{ use crate::{
@@ -16,8 +14,11 @@ use tauri::{AppHandle, Emitter, Manager, Runtime};
use tauri_plugin_clipboard_manager::ClipboardExt; use tauri_plugin_clipboard_manager::ClipboardExt;
use tauri_plugin_opener::OpenerExt; use tauri_plugin_opener::OpenerExt;
use yaak_crypto::manager::EncryptionManager; use yaak_crypto::manager::EncryptionManager;
use yaak_tauri_utils::window::WorkspaceWindowTrait;
use crate::models_ext::BlobManagerExt;
use yaak_models::models::{AnyModel, HttpResponse, Plugin}; use yaak_models::models::{AnyModel, HttpResponse, Plugin};
use yaak_models::queries::any_request::AnyRequest; use yaak_models::queries::any_request::AnyRequest;
use crate::models_ext::QueryManagerExt;
use yaak_models::util::UpdateSource; use yaak_models::util::UpdateSource;
use yaak_plugins::error::Error::PluginErr; use yaak_plugins::error::Error::PluginErr;
use yaak_plugins::events::{ use yaak_plugins::events::{
@@ -31,7 +32,6 @@ use yaak_plugins::events::{
use yaak_plugins::manager::PluginManager; use yaak_plugins::manager::PluginManager;
use yaak_plugins::plugin_handle::PluginHandle; use yaak_plugins::plugin_handle::PluginHandle;
use yaak_plugins::template_callback::PluginTemplateCallback; use yaak_plugins::template_callback::PluginTemplateCallback;
use yaak_tauri_utils::window::WorkspaceWindowTrait;
use yaak_templates::{RenderErrorBehavior, RenderOptions}; use yaak_templates::{RenderErrorBehavior, RenderOptions};
pub(crate) async fn handle_plugin_event<R: Runtime>( pub(crate) async fn handle_plugin_event<R: Runtime>(
@@ -57,10 +57,6 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
let window = get_window_from_plugin_context(app_handle, &plugin_context)?; let window = get_window_from_plugin_context(app_handle, &plugin_context)?;
Ok(call_frontend(&window, event).await) Ok(call_frontend(&window, event).await)
} }
InternalEventPayload::PromptFormRequest(_) => {
let window = get_window_from_plugin_context(app_handle, &plugin_context)?;
Ok(call_frontend(&window, event).await)
}
InternalEventPayload::FindHttpResponsesRequest(req) => { InternalEventPayload::FindHttpResponsesRequest(req) => {
let http_responses = app_handle let http_responses = app_handle
.db() .db()
@@ -170,12 +166,7 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
)?; )?;
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone()); let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone()); let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
let cb = PluginTemplateCallback::new( let cb = PluginTemplateCallback::new(plugin_manager, encryption_manager, &plugin_context, req.purpose);
plugin_manager,
encryption_manager,
&plugin_context,
req.purpose,
);
let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw }; let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw };
let grpc_request = let grpc_request =
render_grpc_request(&req.grpc_request, environment_chain, &cb, &opt).await?; render_grpc_request(&req.grpc_request, environment_chain, &cb, &opt).await?;
@@ -196,12 +187,7 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
)?; )?;
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone()); let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone()); let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
let cb = PluginTemplateCallback::new( let cb = PluginTemplateCallback::new(plugin_manager, encryption_manager, &plugin_context, req.purpose);
plugin_manager,
encryption_manager,
&plugin_context,
req.purpose,
);
let opt = &RenderOptions { error_behavior: RenderErrorBehavior::Throw }; let opt = &RenderOptions { error_behavior: RenderErrorBehavior::Throw };
let http_request = let http_request =
render_http_request(&req.http_request, environment_chain, &cb, &opt).await?; render_http_request(&req.http_request, environment_chain, &cb, &opt).await?;
@@ -232,12 +218,7 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
)?; )?;
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone()); let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone()); let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
let cb = PluginTemplateCallback::new( let cb = PluginTemplateCallback::new(plugin_manager, encryption_manager, &plugin_context, req.purpose);
plugin_manager,
encryption_manager,
&plugin_context,
req.purpose,
);
let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw }; let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw };
let data = render_json_value(req.data, environment_chain, &cb, &opt).await?; let data = render_json_value(req.data, environment_chain, &cb, &opt).await?;
Ok(Some(InternalEventPayload::TemplateRenderResponse(TemplateRenderResponse { data }))) Ok(Some(InternalEventPayload::TemplateRenderResponse(TemplateRenderResponse { data })))
+13 -6
View File
@@ -17,7 +17,7 @@ use tauri::path::BaseDirectory;
use tauri::plugin::{Builder, TauriPlugin}; use tauri::plugin::{Builder, TauriPlugin};
use tauri::{ use tauri::{
AppHandle, Emitter, Manager, RunEvent, Runtime, State, WebviewWindow, WindowEvent, command, AppHandle, Emitter, Manager, RunEvent, Runtime, State, WebviewWindow, WindowEvent, command,
is_dev, generate_handler, is_dev,
}; };
use tokio::sync::Mutex; use tokio::sync::Mutex;
use ts_rs::TS; use ts_rs::TS;
@@ -132,7 +132,7 @@ impl PluginUpdater {
// ============================================================================ // ============================================================================
#[command] #[command]
pub async fn cmd_plugins_search<R: Runtime>( pub(crate) async fn cmd_plugins_search<R: Runtime>(
app_handle: AppHandle<R>, app_handle: AppHandle<R>,
query: &str, query: &str,
) -> Result<PluginSearchResponse> { ) -> Result<PluginSearchResponse> {
@@ -141,7 +141,7 @@ pub async fn cmd_plugins_search<R: Runtime>(
} }
#[command] #[command]
pub async fn cmd_plugins_install<R: Runtime>( pub(crate) async fn cmd_plugins_install<R: Runtime>(
window: WebviewWindow<R>, window: WebviewWindow<R>,
name: &str, name: &str,
version: Option<String>, version: Option<String>,
@@ -163,7 +163,7 @@ pub async fn cmd_plugins_install<R: Runtime>(
} }
#[command] #[command]
pub async fn cmd_plugins_uninstall<R: Runtime>( pub(crate) async fn cmd_plugins_uninstall<R: Runtime>(
plugin_id: &str, plugin_id: &str,
window: WebviewWindow<R>, window: WebviewWindow<R>,
) -> Result<Plugin> { ) -> Result<Plugin> {
@@ -174,7 +174,7 @@ pub async fn cmd_plugins_uninstall<R: Runtime>(
} }
#[command] #[command]
pub async fn cmd_plugins_updates<R: Runtime>( pub(crate) async fn cmd_plugins_updates<R: Runtime>(
app_handle: AppHandle<R>, app_handle: AppHandle<R>,
) -> Result<PluginUpdatesResponse> { ) -> Result<PluginUpdatesResponse> {
let http_client = yaak_api_client(&app_handle)?; let http_client = yaak_api_client(&app_handle)?;
@@ -183,7 +183,7 @@ pub async fn cmd_plugins_updates<R: Runtime>(
} }
#[command] #[command]
pub async fn cmd_plugins_update_all<R: Runtime>( pub(crate) async fn cmd_plugins_update_all<R: Runtime>(
window: WebviewWindow<R>, window: WebviewWindow<R>,
) -> Result<Vec<PluginNameVersion>> { ) -> Result<Vec<PluginNameVersion>> {
let http_client = yaak_api_client(window.app_handle())?; let http_client = yaak_api_client(window.app_handle())?;
@@ -233,6 +233,13 @@ pub async fn cmd_plugins_update_all<R: Runtime>(
pub fn init<R: Runtime>() -> TauriPlugin<R> { pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("yaak-plugins") Builder::new("yaak-plugins")
.invoke_handler(generate_handler![
cmd_plugins_search,
cmd_plugins_install,
cmd_plugins_uninstall,
cmd_plugins_updates,
cmd_plugins_update_all
])
.setup(|app_handle, _| { .setup(|app_handle, _| {
// Resolve paths for plugin manager // Resolve paths for plugin manager
let vendored_plugin_dir = app_handle let vendored_plugin_dir = app_handle
+1 -1
View File
@@ -3,7 +3,6 @@ use std::path::PathBuf;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use crate::error::Result; use crate::error::Result;
use crate::models_ext::QueryManagerExt;
use log::{debug, error, info, warn}; use log::{debug, error, info, warn};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tauri::{Emitter, Listener, Manager, Runtime, WebviewWindow}; use tauri::{Emitter, Listener, Manager, Runtime, WebviewWindow};
@@ -12,6 +11,7 @@ use tauri_plugin_updater::{Update, UpdaterExt};
use tokio::task::block_in_place; use tokio::task::block_in_place;
use tokio::time::sleep; use tokio::time::sleep;
use ts_rs::TS; use ts_rs::TS;
use crate::models_ext::QueryManagerExt;
use yaak_models::util::generate_id; use yaak_models::util::generate_id;
use yaak_plugins::manager::PluginManager; use yaak_plugins::manager::PluginManager;
+3 -4
View File
@@ -1,18 +1,18 @@
use crate::PluginContextExt;
use crate::error::Result; use crate::error::Result;
use crate::import::import_data; use crate::import::import_data;
use crate::models_ext::QueryManagerExt; use crate::models_ext::QueryManagerExt;
use crate::PluginContextExt;
use log::{info, warn}; use log::{info, warn};
use std::collections::HashMap; use std::collections::HashMap;
use std::fs; use std::fs;
use std::sync::Arc; use std::sync::Arc;
use tauri::{AppHandle, Emitter, Manager, Runtime, Url}; use tauri::{AppHandle, Emitter, Manager, Runtime, Url};
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind}; use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
use yaak_tauri_utils::api_client::yaak_api_client;
use yaak_models::util::generate_id; use yaak_models::util::generate_id;
use yaak_plugins::events::{Color, ShowToastRequest}; use yaak_plugins::events::{Color, ShowToastRequest};
use yaak_plugins::install::download_and_install; use yaak_plugins::install::download_and_install;
use yaak_plugins::manager::PluginManager; use yaak_plugins::manager::PluginManager;
use yaak_tauri_utils::api_client::yaak_api_client;
pub(crate) async fn handle_deep_link<R: Runtime>( pub(crate) async fn handle_deep_link<R: Runtime>(
app_handle: &AppHandle<R>, app_handle: &AppHandle<R>,
@@ -55,8 +55,7 @@ pub(crate) async fn handle_deep_link<R: Runtime>(
&plugin_context, &plugin_context,
name, name,
version, version,
) ).await?;
.await?;
app_handle.emit( app_handle.emit(
"show_toast", "show_toast",
ShowToastRequest { ShowToastRequest {
+1 -1
View File
@@ -1,5 +1,4 @@
use crate::error::Result; use crate::error::Result;
use crate::models_ext::QueryManagerExt;
use crate::window_menu::app_menu; use crate::window_menu::app_menu;
use log::{info, warn}; use log::{info, warn};
use rand::random; use rand::random;
@@ -9,6 +8,7 @@ use tauri::{
}; };
use tauri_plugin_opener::OpenerExt; use tauri_plugin_opener::OpenerExt;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use crate::models_ext::QueryManagerExt;
const DEFAULT_WINDOW_WIDTH: f64 = 1100.0; const DEFAULT_WINDOW_WIDTH: f64 = 1100.0;
const DEFAULT_WINDOW_HEIGHT: f64 = 600.0; const DEFAULT_WINDOW_HEIGHT: f64 = 600.0;
+12 -13
View File
@@ -1,9 +1,9 @@
//! WebSocket Tauri command wrappers //! WebSocket Tauri command wrappers
//! These wrap the core yaak-ws functionality for Tauri IPC. //! These wrap the core yaak-ws functionality for Tauri IPC.
use crate::PluginContextExt;
use crate::error::Result; use crate::error::Result;
use crate::models_ext::QueryManagerExt; use crate::models_ext::QueryManagerExt;
use crate::PluginContextExt;
use http::HeaderMap; use http::HeaderMap;
use log::{debug, info, warn}; use log::{debug, info, warn};
use std::str::FromStr; use std::str::FromStr;
@@ -56,10 +56,9 @@ pub async fn cmd_ws_delete_request<R: Runtime>(
app_handle: AppHandle<R>, app_handle: AppHandle<R>,
window: WebviewWindow<R>, window: WebviewWindow<R>,
) -> Result<WebsocketRequest> { ) -> Result<WebsocketRequest> {
Ok(app_handle.db().delete_websocket_request_by_id( Ok(app_handle
request_id, .db()
&UpdateSource::from_window_label(window.label()), .delete_websocket_request_by_id(request_id, &UpdateSource::from_window_label(window.label()))?)
)?)
} }
#[command] #[command]
@@ -68,10 +67,12 @@ pub async fn cmd_ws_delete_connection<R: Runtime>(
app_handle: AppHandle<R>, app_handle: AppHandle<R>,
window: WebviewWindow<R>, window: WebviewWindow<R>,
) -> Result<WebsocketConnection> { ) -> Result<WebsocketConnection> {
Ok(app_handle.db().delete_websocket_connection_by_id( Ok(app_handle
connection_id, .db()
&UpdateSource::from_window_label(window.label()), .delete_websocket_connection_by_id(
)?) connection_id,
&UpdateSource::from_window_label(window.label()),
)?)
} }
#[command] #[command]
@@ -295,10 +296,8 @@ pub async fn cmd_ws_connect<R: Runtime>(
) )
.await?; .await?;
for header in plugin_result.set_headers.unwrap_or_default() { for header in plugin_result.set_headers.unwrap_or_default() {
match ( match (http::HeaderName::from_str(&header.name), HeaderValue::from_str(&header.value))
http::HeaderName::from_str(&header.name), {
HeaderValue::from_str(&header.value),
) {
(Ok(name), Ok(value)) => { (Ok(name), Ok(value)) => {
headers.insert(name, value); headers.insert(name, value);
} }
+2 -2
View File
@@ -44,8 +44,8 @@
"vendored/protoc/include", "vendored/protoc/include",
"vendored/plugins", "vendored/plugins",
"vendored/plugin-runtime", "vendored/plugin-runtime",
"vendored/node/yaaknode*", "vendored/node/yaaknode",
"vendored/protoc/yaakprotoc*" "vendored/protoc/yaakprotoc"
] ]
} }
} }
+1 -1
View File
@@ -8,10 +8,10 @@ use std::time::Duration;
use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewWindow, is_dev}; use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewWindow, is_dev};
use ts_rs::TS; use ts_rs::TS;
use yaak_common::platform::get_os_str; use yaak_common::platform::get_os_str;
use yaak_tauri_utils::api_client::yaak_api_client;
use yaak_models::db_context::DbContext; use yaak_models::db_context::DbContext;
use yaak_models::query_manager::QueryManager; use yaak_models::query_manager::QueryManager;
use yaak_models::util::UpdateSource; use yaak_models::util::UpdateSource;
use yaak_tauri_utils::api_client::yaak_api_client;
/// Extension trait for accessing the QueryManager from Tauri Manager types. /// Extension trait for accessing the QueryManager from Tauri Manager types.
/// This is needed temporarily until all crates are refactored to not use Tauri. /// This is needed temporarily until all crates are refactored to not use Tauri.
-1
View File
@@ -6,4 +6,3 @@ publish = false
[dependencies] [dependencies]
serde_json = { workspace = true } serde_json = { workspace = true }
tokio = { workspace = true, features = ["process"] }
-16
View File
@@ -1,16 +0,0 @@
use std::ffi::OsStr;
#[cfg(target_os = "windows")]
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
/// Creates a new `tokio::process::Command` that won't spawn a console window on Windows.
pub fn new_xplatform_command<S: AsRef<OsStr>>(program: S) -> tokio::process::Command {
#[allow(unused_mut)]
let mut cmd = tokio::process::Command::new(program);
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(CREATE_NO_WINDOW);
}
cmd
}
-1
View File
@@ -1,3 +1,2 @@
pub mod command;
pub mod platform; pub mod platform;
pub mod serde; pub mod serde;
-4
View File
@@ -11,7 +11,3 @@ export function revealWorkspaceKey(workspaceId: string) {
export function setWorkspaceKey(args: { workspaceId: string; key: string }) { export function setWorkspaceKey(args: { workspaceId: string; key: string }) {
return invoke<void>('cmd_set_workspace_key', args); return invoke<void>('cmd_set_workspace_key', args);
} }
export function disableEncryption(workspaceId: string) {
return invoke<void>('cmd_disable_encryption', { workspaceId });
}
-29
View File
@@ -115,35 +115,6 @@ impl EncryptionManager {
self.set_workspace_key(workspace_id, &wkey) self.set_workspace_key(workspace_id, &wkey)
} }
pub fn disable_encryption(&self, workspace_id: &str) -> Result<()> {
info!("Disabling encryption for {workspace_id}");
self.query_manager.with_tx::<(), Error>(|tx| {
let workspace = tx.get_workspace(workspace_id)?;
let workspace_meta = tx.get_or_create_workspace_meta(workspace_id)?;
// Clear encryption challenge on workspace
tx.upsert_workspace(
&Workspace { encryption_key_challenge: None, ..workspace },
&UpdateSource::Background,
)?;
// Clear encryption key on workspace meta
tx.upsert_workspace_meta(
&WorkspaceMeta { encryption_key: None, ..workspace_meta },
&UpdateSource::Background,
)?;
Ok(())
})?;
// Remove from cache
let mut cache = self.cached_workspace_keys.lock().unwrap();
cache.remove(workspace_id);
Ok(())
}
fn get_workspace_key(&self, workspace_id: &str) -> Result<WorkspaceKey> { fn get_workspace_key(&self, workspace_id: &str) -> Result<WorkspaceKey> {
{ {
let cache = self.cached_workspace_keys.lock().unwrap(); let cache = self.cached_workspace_keys.lock().unwrap();
-2
View File
@@ -12,9 +12,7 @@ serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true } serde_json = { workspace = true }
serde_yaml = "0.9.34" serde_yaml = "0.9.34"
thiserror = { workspace = true } thiserror = { workspace = true }
tokio = { workspace = true, features = ["io-util"] }
ts-rs = { workspace = true, features = ["chrono-impl", "serde-json-impl"] } ts-rs = { workspace = true, features = ["chrono-impl", "serde-json-impl"] }
url = "2" url = "2"
yaak-common = { workspace = true }
yaak-models = { workspace = true } yaak-models = { workspace = true }
yaak-sync = { workspace = true } yaak-sync = { workspace = true }
-4
View File
@@ -1,10 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { SyncModel } from "./gen_models"; import type { SyncModel } from "./gen_models";
export type BranchDeleteResult = { "type": "success", message: string, } | { "type": "not_fully_merged" };
export type CloneResult = { "type": "success" } | { "type": "cancelled" } | { "type": "needs_credentials", url: string, error: string | null, };
export type GitAuthor = { name: string | null, email: string | null, }; export type GitAuthor = { name: string | null, email: string | null, };
export type GitCommit = { author: GitAuthor, when: string, message: string | null, }; export type GitCommit = { author: GitAuthor, when: string, message: string | null, };
+1 -3
View File
@@ -1,7 +1,5 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type DnsOverride = { hostname: string, ipv4: Array<string>, ipv6: Array<string>, enabled?: boolean, };
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null, variables: Array<EnvironmentVariable>, color: string | null, sortPriority: number, }; export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null, variables: Array<EnvironmentVariable>, color: string | null, sortPriority: number, };
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, }; export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };
@@ -20,4 +18,4 @@ export type SyncModel = { "type": "workspace" } & Workspace | { "type": "environ
export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, }; export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, };
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, settingDnsOverrides: Array<DnsOverride>, }; export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, };
+6 -39
View File
@@ -3,7 +3,7 @@ import { invoke } from '@tauri-apps/api/core';
import { createFastMutation } from '@yaakapp/app/hooks/useFastMutation'; import { createFastMutation } from '@yaakapp/app/hooks/useFastMutation';
import { queryClient } from '@yaakapp/app/lib/queryClient'; import { queryClient } from '@yaakapp/app/lib/queryClient';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { BranchDeleteResult, CloneResult, GitCommit, GitRemote, GitStatusSummary, PullResult, PushResult } from './bindings/gen_git'; import { GitCommit, GitRemote, GitStatusSummary, PullResult, PushResult } from './bindings/gen_git';
export * from './bindings/gen_git'; export * from './bindings/gen_git';
@@ -59,6 +59,7 @@ export const gitMutations = (dir: string, callbacks: GitCallbacks) => {
if (creds == null) throw new Error('Canceled'); if (creds == null) throw new Error('Canceled');
await invoke('cmd_git_add_credential', { await invoke('cmd_git_add_credential', {
dir,
remoteUrl: result.url, remoteUrl: result.url,
username: creds.username, username: creds.username,
password: creds.password, password: creds.password,
@@ -89,31 +90,21 @@ export const gitMutations = (dir: string, callbacks: GitCallbacks) => {
mutationFn: (args) => invoke('cmd_git_rm_remote', { dir, ...args }), mutationFn: (args) => invoke('cmd_git_rm_remote', { dir, ...args }),
onSuccess, onSuccess,
}), }),
createBranch: createFastMutation<void, string, { branch: string; base?: string }>({ branch: createFastMutation<void, string, { branch: string }>({
mutationKey: ['git', 'branch', dir], mutationKey: ['git', 'branch', dir],
mutationFn: (args) => invoke('cmd_git_branch', { dir, ...args }), mutationFn: (args) => invoke('cmd_git_branch', { dir, ...args }),
onSuccess, onSuccess,
}), }),
mergeBranch: createFastMutation<void, string, { branch: string }>({ mergeBranch: createFastMutation<void, string, { branch: string; force: boolean }>({
mutationKey: ['git', 'merge', dir], mutationKey: ['git', 'merge', dir],
mutationFn: (args) => invoke('cmd_git_merge_branch', { dir, ...args }), mutationFn: (args) => invoke('cmd_git_merge_branch', { dir, ...args }),
onSuccess, onSuccess,
}), }),
deleteBranch: createFastMutation<BranchDeleteResult, string, { branch: string, force?: boolean }>({ deleteBranch: createFastMutation<void, string, { branch: string }>({
mutationKey: ['git', 'delete-branch', dir], mutationKey: ['git', 'delete-branch', dir],
mutationFn: (args) => invoke('cmd_git_delete_branch', { dir, ...args }), mutationFn: (args) => invoke('cmd_git_delete_branch', { dir, ...args }),
onSuccess, onSuccess,
}), }),
deleteRemoteBranch: createFastMutation<void, string, { branch: string }>({
mutationKey: ['git', 'delete-remote-branch', dir],
mutationFn: (args) => invoke('cmd_git_delete_remote_branch', { dir, ...args }),
onSuccess,
}),
renameBranch: createFastMutation<void, string, { oldName: string, newName: string }>({
mutationKey: ['git', 'rename-branch', dir],
mutationFn: (args) => invoke('cmd_git_rename_branch', { dir, ...args }),
onSuccess,
}),
checkout: createFastMutation<string, string, { branch: string; force: boolean }>({ checkout: createFastMutation<string, string, { branch: string; force: boolean }>({
mutationKey: ['git', 'checkout', dir], mutationKey: ['git', 'checkout', dir],
mutationFn: (args) => invoke('cmd_git_checkout', { dir, ...args }), mutationFn: (args) => invoke('cmd_git_checkout', { dir, ...args }),
@@ -153,6 +144,7 @@ export const gitMutations = (dir: string, callbacks: GitCallbacks) => {
if (creds == null) throw new Error('Canceled'); if (creds == null) throw new Error('Canceled');
await invoke('cmd_git_add_credential', { await invoke('cmd_git_add_credential', {
dir,
remoteUrl: result.url, remoteUrl: result.url,
username: creds.username, username: creds.username,
password: creds.password, password: creds.password,
@@ -174,28 +166,3 @@ export const gitMutations = (dir: string, callbacks: GitCallbacks) => {
async function getRemotes(dir: string) { async function getRemotes(dir: string) {
return invoke<GitRemote[]>('cmd_git_remotes', { dir }); return invoke<GitRemote[]>('cmd_git_remotes', { dir });
} }
/**
* Clone a git repository, prompting for credentials if needed.
*/
export async function gitClone(
url: string,
dir: string,
promptCredentials: (args: { url: string; error: string | null }) => Promise<GitCredentials | null>,
): Promise<CloneResult> {
const result = await invoke<CloneResult>('cmd_git_clone', { url, dir });
if (result.type !== 'needs_credentials') return result;
// Prompt for credentials
const creds = await promptCredentials({ url: result.url, error: result.error });
if (creds == null) return {type: 'cancelled'};
// Store credentials and retry
await invoke('cmd_git_add_credential', {
remoteUrl: result.url,
username: creds.username,
password: creds.password,
});
return invoke<CloneResult>('cmd_git_clone', { url, dir });
}
+23 -15
View File
@@ -1,30 +1,38 @@
use crate::error::Error::GitNotFound;
use crate::error::Result; use crate::error::Result;
use std::path::Path; use std::path::Path;
use std::process::Stdio; use std::process::{Command, Stdio};
use tokio::process::Command;
use yaak_common::command::new_xplatform_command;
/// Create a git command that runs in the specified directory use crate::error::Error::GitNotFound;
pub(crate) async fn new_binary_command(dir: &Path) -> Result<Command> { #[cfg(target_os = "windows")]
let mut cmd = new_binary_command_global().await?; use std::os::windows::process::CommandExt;
cmd.arg("-C").arg(dir);
Ok(cmd)
}
/// Create a git command without a specific directory (for global operations) #[cfg(target_os = "windows")]
pub(crate) async fn new_binary_command_global() -> Result<Command> { const CREATE_NO_WINDOW: u32 = 0x0800_0000;
pub(crate) fn new_binary_command(dir: &Path) -> Result<Command> {
// 1. Probe that `git` exists and is runnable // 1. Probe that `git` exists and is runnable
let mut probe = new_xplatform_command("git"); let mut probe = Command::new("git");
probe.arg("--version").stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()); probe.arg("--version").stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null());
let status = probe.status().await.map_err(|_| GitNotFound)?; #[cfg(target_os = "windows")]
{
probe.creation_flags(CREATE_NO_WINDOW);
}
let status = probe.status().map_err(|_| GitNotFound)?;
if !status.success() { if !status.success() {
return Err(GitNotFound); return Err(GitNotFound);
} }
// 2. Build the reusable git command // 2. Build the reusable git command
let cmd = new_xplatform_command("git"); let mut cmd = Command::new("git");
cmd.arg("-C").arg(dir);
#[cfg(target_os = "windows")]
{
cmd.creation_flags(CREATE_NO_WINDOW);
}
Ok(cmd) Ok(cmd)
} }
+64 -118
View File
@@ -1,153 +1,99 @@
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use crate::binary::new_binary_command;
use crate::error::Error::GenericError; use crate::error::Error::GenericError;
use crate::error::Result; use crate::error::Result;
use crate::merge::do_merge;
use crate::repository::open_repo;
use crate::util::{bytes_to_string, get_branch_by_name, get_current_branch};
use git2::BranchType;
use git2::build::CheckoutBuilder;
use log::info;
use std::path::Path; use std::path::Path;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] pub fn git_checkout_branch(dir: &Path, branch_name: &str, force: bool) -> Result<String> {
#[serde(rename_all = "snake_case", tag = "type")] if branch_name.starts_with("origin/") {
#[ts(export, export_to = "gen_git.ts")] return git_checkout_remote_branch(dir, branch_name, force);
pub enum BranchDeleteResult { }
Success { message: String },
NotFullyMerged,
}
pub async fn git_checkout_branch(dir: &Path, branch_name: &str, force: bool) -> Result<String> { let repo = open_repo(dir)?;
let branch_name = branch_name.trim_start_matches("origin/"); let branch = get_branch_by_name(&repo, branch_name)?;
let branch_ref = branch.into_reference();
let branch_tree = branch_ref.peel_to_tree()?;
let mut args = vec!["checkout"]; let mut options = CheckoutBuilder::default();
if force { if force {
args.push("--force"); options.force();
} }
args.push(branch_name);
let out = new_binary_command(dir) repo.checkout_tree(branch_tree.as_object(), Some(&mut options))?;
.await? repo.set_head(branch_ref.name().unwrap())?;
.args(&args)
.output()
.await
.map_err(|e| GenericError(format!("failed to run git checkout: {e}")))?;
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
let combined = format!("{}{}", stdout, stderr);
if !out.status.success() {
return Err(GenericError(format!("Failed to checkout: {}", combined.trim())));
}
Ok(branch_name.to_string()) Ok(branch_name.to_string())
} }
pub async fn git_create_branch(dir: &Path, name: &str, base: Option<&str>) -> Result<()> { pub(crate) fn git_checkout_remote_branch(
let mut cmd = new_binary_command(dir).await?; dir: &Path,
cmd.arg("branch").arg(name); branch_name: &str,
if let Some(base_branch) = base { force: bool,
cmd.arg(base_branch); ) -> Result<String> {
} let branch_name = branch_name.trim_start_matches("origin/");
let repo = open_repo(dir)?;
let out = let refname = format!("refs/remotes/origin/{}", branch_name);
cmd.output().await.map_err(|e| GenericError(format!("failed to run git branch: {e}")))?; let remote_ref = repo.find_reference(&refname)?;
let commit = remote_ref.peel_to_commit()?;
let stdout = String::from_utf8_lossy(&out.stdout); let mut new_branch = repo.branch(branch_name, &commit, false)?;
let stderr = String::from_utf8_lossy(&out.stderr); let upstream_name = format!("origin/{}", branch_name);
let combined = format!("{}{}", stdout, stderr); new_branch.set_upstream(Some(&upstream_name))?;
if !out.status.success() { git_checkout_branch(dir, branch_name, force)
return Err(GenericError(format!("Failed to create branch: {}", combined.trim())));
}
Ok(())
} }
pub async fn git_delete_branch(dir: &Path, name: &str, force: bool) -> Result<BranchDeleteResult> { pub fn git_create_branch(dir: &Path, name: &str) -> Result<()> {
let mut cmd = new_binary_command(dir).await?; let repo = open_repo(dir)?;
let head = match repo.head() {
let out = Ok(h) => h,
if force { cmd.args(["branch", "-D", name]) } else { cmd.args(["branch", "-d", name]) } Err(e) if e.code() == git2::ErrorCode::UnbornBranch => {
.output() let msg = "Cannot create branch when there are no commits";
.await return Err(GenericError(msg.into()));
.map_err(|e| GenericError(format!("failed to run git branch -d: {e}")))?;
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
let combined = format!("{}{}", stdout, stderr);
if !out.status.success() && stderr.to_lowercase().contains("not fully merged") {
return Ok(BranchDeleteResult::NotFullyMerged);
}
if !out.status.success() {
return Err(GenericError(format!("Failed to delete branch: {}", combined.trim())));
}
Ok(BranchDeleteResult::Success { message: combined })
}
pub async fn git_merge_branch(dir: &Path, name: &str) -> Result<()> {
let out = new_binary_command(dir)
.await?
.args(["merge", name])
.output()
.await
.map_err(|e| GenericError(format!("failed to run git merge: {e}")))?;
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
let combined = format!("{}{}", stdout, stderr);
if !out.status.success() {
// Check for merge conflicts
if combined.to_lowercase().contains("conflict") {
return Err(GenericError(
"Merge conflicts detected. Please resolve them manually.".to_string(),
));
} }
return Err(GenericError(format!("Failed to merge: {}", combined.trim()))); Err(e) => return Err(e.into()),
} };
let head = head.peel_to_commit()?;
repo.branch(name, &head, false)?;
Ok(()) Ok(())
} }
pub async fn git_delete_remote_branch(dir: &Path, name: &str) -> Result<()> { pub fn git_delete_branch(dir: &Path, name: &str) -> Result<()> {
// Remote branch names come in as "origin/branch-name", extract the branch name let repo = open_repo(dir)?;
let branch_name = name.trim_start_matches("origin/"); let mut branch = get_branch_by_name(&repo, name)?;
let out = new_binary_command(dir) if branch.is_head() {
.await? info!("Deleting head branch");
.args(["push", "origin", "--delete", branch_name]) let branches = repo.branches(Some(BranchType::Local))?;
.output() let other_branch = branches.into_iter().filter_map(|b| b.ok()).find(|b| !b.0.is_head());
.await let other_branch = match other_branch {
.map_err(|e| GenericError(format!("failed to run git push --delete: {e}")))?; None => return Err(GenericError("Cannot delete only branch".into())),
Some(b) => bytes_to_string(b.0.name_bytes()?)?,
};
let stdout = String::from_utf8_lossy(&out.stdout); git_checkout_branch(dir, &other_branch, true)?;
let stderr = String::from_utf8_lossy(&out.stderr);
let combined = format!("{}{}", stdout, stderr);
if !out.status.success() {
return Err(GenericError(format!("Failed to delete remote branch: {}", combined.trim())));
} }
branch.delete()?;
Ok(()) Ok(())
} }
pub async fn git_rename_branch(dir: &Path, old_name: &str, new_name: &str) -> Result<()> { pub fn git_merge_branch(dir: &Path, name: &str, _force: bool) -> Result<()> {
let out = new_binary_command(dir) let repo = open_repo(dir)?;
.await? let local_branch = get_current_branch(&repo)?.unwrap();
.args(["branch", "-m", old_name, new_name])
.output()
.await
.map_err(|e| GenericError(format!("failed to run git branch -m: {e}")))?;
let stdout = String::from_utf8_lossy(&out.stdout); let commit_to_merge = get_branch_by_name(&repo, name)?.into_reference();
let stderr = String::from_utf8_lossy(&out.stderr); let commit_to_merge = repo.reference_to_annotated_commit(&commit_to_merge)?;
let combined = format!("{}{}", stdout, stderr);
if !out.status.success() { do_merge(&repo, &local_branch, &commit_to_merge)?;
return Err(GenericError(format!("Failed to rename branch: {}", combined.trim())));
}
Ok(()) Ok(())
} }
-53
View File
@@ -1,53 +0,0 @@
use crate::binary::new_binary_command;
use crate::error::Error::GenericError;
use crate::error::Result;
use log::info;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
use ts_rs::TS;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case", tag = "type")]
#[ts(export, export_to = "gen_git.ts")]
pub enum CloneResult {
Success,
Cancelled,
NeedsCredentials { url: String, error: Option<String> },
}
pub async fn git_clone(url: &str, dir: &Path) -> Result<CloneResult> {
let parent = dir.parent().ok_or_else(|| GenericError("Invalid clone directory".to_string()))?;
fs::create_dir_all(parent)
.map_err(|e| GenericError(format!("Failed to create directory: {e}")))?;
let mut cmd = new_binary_command(parent).await?;
cmd.args(["clone", url]).arg(dir).env("GIT_TERMINAL_PROMPT", "0");
let out =
cmd.output().await.map_err(|e| GenericError(format!("failed to run git clone: {e}")))?;
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
let combined = format!("{}{}", stdout, stderr);
let combined_lower = combined.to_lowercase();
info!("Cloned status={}: {combined}", out.status);
if !out.status.success() {
// Check for credentials error
if combined_lower.contains("could not read") {
return Ok(CloneResult::NeedsCredentials { url: url.to_string(), error: None });
}
if combined_lower.contains("unable to access")
|| combined_lower.contains("authentication failed")
{
return Ok(CloneResult::NeedsCredentials {
url: url.to_string(),
error: Some(combined.to_string()),
});
}
return Err(GenericError(format!("Failed to clone: {}", combined.trim())));
}
Ok(CloneResult::Success)
}
+2 -3
View File
@@ -3,9 +3,8 @@ use crate::error::Error::GenericError;
use log::info; use log::info;
use std::path::Path; use std::path::Path;
pub async fn git_commit(dir: &Path, message: &str) -> crate::error::Result<()> { pub fn git_commit(dir: &Path, message: &str) -> crate::error::Result<()> {
let out = let out = new_binary_command(dir)?.args(["commit", "--message", message]).output()?;
new_binary_command(dir).await?.args(["commit", "--message", message]).output().await?;
let stdout = String::from_utf8_lossy(&out.stdout); let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr); let stderr = String::from_utf8_lossy(&out.stderr);
+17 -14
View File
@@ -1,19 +1,24 @@
use crate::binary::new_binary_command_global; use crate::binary::new_binary_command;
use crate::error::Error::GenericError; use crate::error::Error::GenericError;
use crate::error::Result; use crate::error::Result;
use std::io::Write;
use std::path::Path;
use std::process::Stdio; use std::process::Stdio;
use tokio::io::AsyncWriteExt;
use url::Url; use url::Url;
pub async fn git_add_credential(remote_url: &str, username: &str, password: &str) -> Result<()> { pub async fn git_add_credential(
dir: &Path,
remote_url: &str,
username: &str,
password: &str,
) -> Result<()> {
let url = Url::parse(remote_url) let url = Url::parse(remote_url)
.map_err(|e| GenericError(format!("Failed to parse remote url {remote_url}: {e:?}")))?; .map_err(|e| GenericError(format!("Failed to parse remote url {remote_url}: {e:?}")))?;
let protocol = url.scheme(); let protocol = url.scheme();
let host = url.host_str().unwrap(); let host = url.host_str().unwrap();
let path = Some(url.path()); let path = Some(url.path());
let mut child = new_binary_command_global() let mut child = new_binary_command(dir)?
.await?
.args(["credential", "approve"]) .args(["credential", "approve"])
.stdin(Stdio::piped()) .stdin(Stdio::piped())
.stdout(Stdio::null()) .stdout(Stdio::null())
@@ -21,21 +26,19 @@ pub async fn git_add_credential(remote_url: &str, username: &str, password: &str
{ {
let stdin = child.stdin.as_mut().unwrap(); let stdin = child.stdin.as_mut().unwrap();
stdin.write_all(format!("protocol={}\n", protocol).as_bytes()).await?; writeln!(stdin, "protocol={}", protocol)?;
stdin.write_all(format!("host={}\n", host).as_bytes()).await?; writeln!(stdin, "host={}", host)?;
if let Some(path) = path { if let Some(path) = path {
if !path.is_empty() { if !path.is_empty() {
stdin writeln!(stdin, "path={}", path.trim_start_matches('/'))?;
.write_all(format!("path={}\n", path.trim_start_matches('/')).as_bytes())
.await?;
} }
} }
stdin.write_all(format!("username={}\n", username).as_bytes()).await?; writeln!(stdin, "username={}", username)?;
stdin.write_all(format!("password={}\n", password).as_bytes()).await?; writeln!(stdin, "password={}", password)?;
stdin.write_all(b"\n").await?; // blank line terminator writeln!(stdin)?; // blank line terminator
} }
let status = child.wait().await?; let status = child.wait()?;
if !status.success() { if !status.success() {
return Err(GenericError("Failed to approve git credential".to_string())); return Err(GenericError("Failed to approve git credential".to_string()));
} }
+2 -4
View File
@@ -3,12 +3,10 @@ use crate::error::Error::GenericError;
use crate::error::Result; use crate::error::Result;
use std::path::Path; use std::path::Path;
pub async fn git_fetch_all(dir: &Path) -> Result<()> { pub fn git_fetch_all(dir: &Path) -> Result<()> {
let out = new_binary_command(dir) let out = new_binary_command(dir)?
.await?
.args(["fetch", "--all", "--prune", "--tags"]) .args(["fetch", "--all", "--prune", "--tags"])
.output() .output()
.await
.map_err(|e| GenericError(format!("failed to run git pull: {e}")))?; .map_err(|e| GenericError(format!("failed to run git pull: {e}")))?;
let stdout = String::from_utf8_lossy(&out.stdout); let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr); let stderr = String::from_utf8_lossy(&out.stderr);
+2 -7
View File
@@ -1,14 +1,13 @@
mod add; mod add;
mod binary; mod binary;
mod branch; mod branch;
mod clone;
mod commit; mod commit;
mod credential; mod credential;
pub mod error; pub mod error;
mod fetch; mod fetch;
mod init; mod init;
mod log; mod log;
mod merge;
mod pull; mod pull;
mod push; mod push;
mod remotes; mod remotes;
@@ -19,11 +18,7 @@ mod util;
// Re-export all git functions for external use // Re-export all git functions for external use
pub use add::git_add; pub use add::git_add;
pub use branch::{ pub use branch::{git_checkout_branch, git_create_branch, git_delete_branch, git_merge_branch};
BranchDeleteResult, git_checkout_branch, git_create_branch, git_delete_branch,
git_delete_remote_branch, git_merge_branch, git_rename_branch,
};
pub use clone::{CloneResult, git_clone};
pub use commit::git_commit; pub use commit::git_commit;
pub use credential::git_add_credential; pub use credential::git_add_credential;
pub use fetch::git_fetch_all; pub use fetch::git_fetch_all;
+135
View File
@@ -0,0 +1,135 @@
use crate::error::Error::MergeConflicts;
use crate::util::bytes_to_string;
use git2::{AnnotatedCommit, Branch, IndexEntry, Reference, Repository};
use log::{debug, info};
pub(crate) fn do_merge(
repo: &Repository,
local_branch: &Branch,
commit_to_merge: &AnnotatedCommit,
) -> crate::error::Result<()> {
debug!("Merging remote branches");
let analysis = repo.merge_analysis(&[&commit_to_merge])?;
if analysis.0.is_fast_forward() {
let refname = bytes_to_string(local_branch.get().name_bytes())?;
match repo.find_reference(&refname) {
Ok(mut r) => {
merge_fast_forward(repo, &mut r, &commit_to_merge)?;
}
Err(_) => {
// The branch doesn't exist, so set the reference to the commit directly. Usually
// this is because you are pulling into an empty repository.
repo.reference(
&refname,
commit_to_merge.id(),
true,
&format!("Setting {} to {}", refname, commit_to_merge.id()),
)?;
repo.set_head(&refname)?;
repo.checkout_head(Some(
git2::build::CheckoutBuilder::default()
.allow_conflicts(true)
.conflict_style_merge(true)
.force(),
))?;
}
};
} else if analysis.0.is_normal() {
let head_commit = repo.reference_to_annotated_commit(&repo.head()?)?;
merge_normal(repo, &head_commit, commit_to_merge)?;
} else {
debug!("Skipping merge. Nothing to do")
}
Ok(())
}
pub(crate) fn merge_fast_forward(
repo: &Repository,
local_reference: &mut Reference,
remote_commit: &AnnotatedCommit,
) -> crate::error::Result<()> {
info!("Performing fast forward");
let name = match local_reference.name() {
Some(s) => s.to_string(),
None => String::from_utf8_lossy(local_reference.name_bytes()).to_string(),
};
let msg = format!("Fast-Forward: Setting {} to id: {}", name, remote_commit.id());
local_reference.set_target(remote_commit.id(), &msg)?;
repo.set_head(&name)?;
repo.checkout_head(Some(
git2::build::CheckoutBuilder::default()
// For some reason, the force is required to make the working directory actually get
// updated I suspect we should be adding some logic to handle dirty working directory
// states, but this is just an example so maybe not.
.force(),
))?;
Ok(())
}
pub(crate) fn merge_normal(
repo: &Repository,
local: &AnnotatedCommit,
remote: &AnnotatedCommit,
) -> crate::error::Result<()> {
info!("Performing normal merge");
let local_tree = repo.find_commit(local.id())?.tree()?;
let remote_tree = repo.find_commit(remote.id())?.tree()?;
let ancestor = repo.find_commit(repo.merge_base(local.id(), remote.id())?)?.tree()?;
let mut idx = repo.merge_trees(&ancestor, &local_tree, &remote_tree, None)?;
if idx.has_conflicts() {
let conflicts = idx.conflicts()?;
for conflict in conflicts {
if let Ok(conflict) = conflict {
print_conflict(&conflict);
}
}
return Err(MergeConflicts);
}
let result_tree = repo.find_tree(idx.write_tree_to(repo)?)?;
// now create the merge commit
let msg = format!("Merge: {} into {}", remote.id(), local.id());
let sig = repo.signature()?;
let local_commit = repo.find_commit(local.id())?;
let remote_commit = repo.find_commit(remote.id())?;
// Do our merge commit and set current branch head to that commit.
let _merge_commit = repo.commit(
Some("HEAD"),
&sig,
&sig,
&msg,
&result_tree,
&[&local_commit, &remote_commit],
)?;
// Set working tree to match head.
repo.checkout_head(None)?;
Ok(())
}
fn print_conflict(conflict: &git2::IndexConflict) {
let ancestor = conflict.ancestor.as_ref().map(path_from_index_entry);
let ours = conflict.our.as_ref().map(path_from_index_entry);
let theirs = conflict.their.as_ref().map(path_from_index_entry);
println!("Conflict detected:");
if let Some(path) = ancestor {
println!(" Common ancestor: {:?}", path);
}
if let Some(path) = ours {
println!(" Ours: {:?}", path);
}
if let Some(path) = theirs {
println!(" Theirs: {:?}", path);
}
}
fn path_from_index_entry(entry: &IndexEntry) -> String {
String::from_utf8_lossy(entry.path.as_slice()).into_owned()
}
+7 -15
View File
@@ -17,25 +17,17 @@ pub enum PullResult {
NeedsCredentials { url: String, error: Option<String> }, NeedsCredentials { url: String, error: Option<String> },
} }
pub async fn git_pull(dir: &Path) -> Result<PullResult> { pub fn git_pull(dir: &Path) -> Result<PullResult> {
// Extract all git2 data before any await points (git2 types are not Send) let repo = open_repo(dir)?;
let (branch_name, remote_name, remote_url) = { let branch_name = get_current_branch_name(&repo)?;
let repo = open_repo(dir)?; let remote = get_default_remote_in_repo(&repo)?;
let branch_name = get_current_branch_name(&repo)?; let remote_name = remote.name().ok_or(GenericError("Failed to get remote name".to_string()))?;
let remote = get_default_remote_in_repo(&repo)?; let remote_url = remote.url().ok_or(GenericError("Failed to get remote url".to_string()))?;
let remote_name =
remote.name().ok_or(GenericError("Failed to get remote name".to_string()))?.to_string();
let remote_url =
remote.url().ok_or(GenericError("Failed to get remote url".to_string()))?.to_string();
(branch_name, remote_name, remote_url)
};
let out = new_binary_command(dir) let out = new_binary_command(dir)?
.await?
.args(["pull", &remote_name, &branch_name]) .args(["pull", &remote_name, &branch_name])
.env("GIT_TERMINAL_PROMPT", "0") .env("GIT_TERMINAL_PROMPT", "0")
.output() .output()
.await
.map_err(|e| GenericError(format!("failed to run git pull: {e}")))?; .map_err(|e| GenericError(format!("failed to run git pull: {e}")))?;
let stdout = String::from_utf8_lossy(&out.stdout); let stdout = String::from_utf8_lossy(&out.stdout);
+7 -15
View File
@@ -17,25 +17,17 @@ pub enum PushResult {
NeedsCredentials { url: String, error: Option<String> }, NeedsCredentials { url: String, error: Option<String> },
} }
pub async fn git_push(dir: &Path) -> Result<PushResult> { pub fn git_push(dir: &Path) -> Result<PushResult> {
// Extract all git2 data before any await points (git2 types are not Send) let repo = open_repo(dir)?;
let (branch_name, remote_name, remote_url) = { let branch_name = get_current_branch_name(&repo)?;
let repo = open_repo(dir)?; let remote = get_default_remote_for_push_in_repo(&repo)?;
let branch_name = get_current_branch_name(&repo)?; let remote_name = remote.name().ok_or(GenericError("Failed to get remote name".to_string()))?;
let remote = get_default_remote_for_push_in_repo(&repo)?; let remote_url = remote.url().ok_or(GenericError("Failed to get remote url".to_string()))?;
let remote_name =
remote.name().ok_or(GenericError("Failed to get remote name".to_string()))?.to_string();
let remote_url =
remote.url().ok_or(GenericError("Failed to get remote url".to_string()))?.to_string();
(branch_name, remote_name, remote_url)
};
let out = new_binary_command(dir) let out = new_binary_command(dir)?
.await?
.args(["push", &remote_name, &branch_name]) .args(["push", &remote_name, &branch_name])
.env("GIT_TERMINAL_PROMPT", "0") .env("GIT_TERMINAL_PROMPT", "0")
.output() .output()
.await
.map_err(|e| GenericError(format!("failed to run git push: {e}")))?; .map_err(|e| GenericError(format!("failed to run git push: {e}")))?;
let stdout = String::from_utf8_lossy(&out.stdout); let stdout = String::from_utf8_lossy(&out.stdout);
+4
View File
@@ -47,6 +47,10 @@ pub(crate) fn remote_branch_names(repo: &Repository) -> Result<Vec<String>> {
Ok(branches) Ok(branches)
} }
pub(crate) fn get_branch_by_name<'s>(repo: &'s Repository, name: &str) -> Result<Branch<'s>> {
Ok(repo.find_branch(name, BranchType::Local)?)
}
pub(crate) fn bytes_to_string(bytes: &[u8]) -> Result<String> { pub(crate) fn bytes_to_string(bytes: &[u8]) -> Result<String> {
Ok(String::from_utf8(bytes.to_vec())?) Ok(String::from_utf8(bytes.to_vec())?)
} }
-1
View File
@@ -22,6 +22,5 @@ tokio-stream = "0.1.14"
tonic = { version = "0.12.3", default-features = false, features = ["transport"] } tonic = { version = "0.12.3", default-features = false, features = ["transport"] }
tonic-reflection = "0.12.3" tonic-reflection = "0.12.3"
uuid = { version = "1.7.0", features = ["v4"] } uuid = { version = "1.7.0", features = ["v4"] }
yaak-common = { workspace = true }
yaak-tls = { workspace = true } yaak-tls = { workspace = true }
thiserror = "2.0.17" thiserror = "2.0.17"
+48 -71
View File
@@ -115,18 +115,14 @@ impl GrpcConnection {
Ok(client.unary(req, path, codec).await?) Ok(client.unary(req, path, codec).await?)
} }
pub async fn streaming<F>( pub async fn streaming(
&self, &self,
service: &str, service: &str,
method: &str, method: &str,
stream: ReceiverStream<String>, stream: ReceiverStream<String>,
metadata: &BTreeMap<String, String>, metadata: &BTreeMap<String, String>,
client_cert: Option<ClientCertificateConfig>, client_cert: Option<ClientCertificateConfig>,
on_message: F, ) -> Result<Response<Streaming<DynamicMessage>>> {
) -> Result<Response<Streaming<DynamicMessage>>>
where
F: Fn(std::result::Result<String, String>) + Send + Sync + Clone + 'static,
{
let method = &self.method(&service, &method).await?; let method = &self.method(&service, &method).await?;
let mapped_stream = { let mapped_stream = {
let input_message = method.input(); let input_message = method.input();
@@ -135,39 +131,31 @@ impl GrpcConnection {
let md = metadata.clone(); let md = metadata.clone();
let use_reflection = self.use_reflection.clone(); let use_reflection = self.use_reflection.clone();
let client_cert = client_cert.clone(); let client_cert = client_cert.clone();
stream stream.filter_map(move |json| {
.then(move |json| { let pool = pool.clone();
let pool = pool.clone(); let uri = uri.clone();
let uri = uri.clone(); let input_message = input_message.clone();
let input_message = input_message.clone(); let md = md.clone();
let md = md.clone(); let use_reflection = use_reflection.clone();
let use_reflection = use_reflection.clone(); let client_cert = client_cert.clone();
let client_cert = client_cert.clone(); tokio::runtime::Handle::current().block_on(async move {
let on_message = on_message.clone(); if use_reflection {
let json_clone = json.clone(); if let Err(e) =
async move { reflect_types_for_message(pool, &uri, &json, &md, client_cert).await
if use_reflection { {
if let Err(e) = warn!("Failed to resolve Any types: {e}");
reflect_types_for_message(pool, &uri, &json, &md, client_cert).await
{
warn!("Failed to resolve Any types: {e}");
}
} }
let mut de = Deserializer::from_str(&json); }
match DynamicMessage::deserialize(input_message, &mut de) { let mut de = Deserializer::from_str(&json);
Ok(m) => { match DynamicMessage::deserialize(input_message, &mut de) {
on_message(Ok(json_clone)); Ok(m) => Some(m),
Some(m) Err(e) => {
} warn!("Failed to deserialize message: {e}");
Err(e) => { None
warn!("Failed to deserialize message: {e}");
on_message(Err(e.to_string()));
None
}
} }
} }
}) })
.filter_map(|x| x) })
}; };
let mut client = tonic::client::Grpc::with_origin(self.conn.clone(), self.uri.clone()); let mut client = tonic::client::Grpc::with_origin(self.conn.clone(), self.uri.clone());
@@ -181,18 +169,14 @@ impl GrpcConnection {
Ok(client.streaming(req, path, codec).await?) Ok(client.streaming(req, path, codec).await?)
} }
pub async fn client_streaming<F>( pub async fn client_streaming(
&self, &self,
service: &str, service: &str,
method: &str, method: &str,
stream: ReceiverStream<String>, stream: ReceiverStream<String>,
metadata: &BTreeMap<String, String>, metadata: &BTreeMap<String, String>,
client_cert: Option<ClientCertificateConfig>, client_cert: Option<ClientCertificateConfig>,
on_message: F, ) -> Result<Response<DynamicMessage>> {
) -> Result<Response<DynamicMessage>>
where
F: Fn(std::result::Result<String, String>) + Send + Sync + Clone + 'static,
{
let method = &self.method(&service, &method).await?; let method = &self.method(&service, &method).await?;
let mapped_stream = { let mapped_stream = {
let input_message = method.input(); let input_message = method.input();
@@ -201,39 +185,31 @@ impl GrpcConnection {
let md = metadata.clone(); let md = metadata.clone();
let use_reflection = self.use_reflection.clone(); let use_reflection = self.use_reflection.clone();
let client_cert = client_cert.clone(); let client_cert = client_cert.clone();
stream stream.filter_map(move |json| {
.then(move |json| { let pool = pool.clone();
let pool = pool.clone(); let uri = uri.clone();
let uri = uri.clone(); let input_message = input_message.clone();
let input_message = input_message.clone(); let md = md.clone();
let md = md.clone(); let use_reflection = use_reflection.clone();
let use_reflection = use_reflection.clone(); let client_cert = client_cert.clone();
let client_cert = client_cert.clone(); tokio::runtime::Handle::current().block_on(async move {
let on_message = on_message.clone(); if use_reflection {
let json_clone = json.clone(); if let Err(e) =
async move { reflect_types_for_message(pool, &uri, &json, &md, client_cert).await
if use_reflection { {
if let Err(e) = warn!("Failed to resolve Any types: {e}");
reflect_types_for_message(pool, &uri, &json, &md, client_cert).await
{
warn!("Failed to resolve Any types: {e}");
}
} }
let mut de = Deserializer::from_str(&json); }
match DynamicMessage::deserialize(input_message, &mut de) { let mut de = Deserializer::from_str(&json);
Ok(m) => { match DynamicMessage::deserialize(input_message, &mut de) {
on_message(Ok(json_clone)); Ok(m) => Some(m),
Some(m) Err(e) => {
} warn!("Failed to deserialize message: {e}");
Err(e) => { None
warn!("Failed to deserialize message: {e}");
on_message(Err(e.to_string()));
None
}
} }
} }
}) })
.filter_map(|x| x) })
}; };
let mut client = tonic::client::Grpc::with_origin(self.conn.clone(), self.uri.clone()); let mut client = tonic::client::Grpc::with_origin(self.conn.clone(), self.uri.clone());
@@ -340,9 +316,10 @@ impl GrpcHandle {
metadata: &BTreeMap<String, String>, metadata: &BTreeMap<String, String>,
validate_certificates: bool, validate_certificates: bool,
client_cert: Option<ClientCertificateConfig>, client_cert: Option<ClientCertificateConfig>,
skip_cache: bool,
) -> Result<Vec<ServiceDefinition>> { ) -> Result<Vec<ServiceDefinition>> {
// Ensure we have a pool; reflect only if missing // Ensure we have a pool; reflect only if missing
if self.get_pool(id, uri, proto_files).is_none() { if skip_cache || self.get_pool(id, uri, proto_files).is_none() {
info!("Reflecting gRPC services for {} at {}", id, uri); info!("Reflecting gRPC services for {} at {}", id, uri);
self.reflect(id, uri, proto_files, metadata, validate_certificates, client_cert) self.reflect(id, uri, proto_files, metadata, validate_certificates, client_cert)
.await?; .await?;
+6 -6
View File
@@ -16,12 +16,12 @@ use std::path::{Path, PathBuf};
use std::str::FromStr; use std::str::FromStr;
use std::sync::Arc; use std::sync::Arc;
use tokio::fs; use tokio::fs;
use tokio::process::Command;
use tokio::sync::RwLock; use tokio::sync::RwLock;
use tonic::codegen::http::uri::PathAndQuery; use tonic::codegen::http::uri::PathAndQuery;
use tonic::transport::Uri; use tonic::transport::Uri;
use tonic_reflection::pb::v1::server_reflection_request::MessageRequest; use tonic_reflection::pb::v1::server_reflection_request::MessageRequest;
use tonic_reflection::pb::v1::server_reflection_response::MessageResponse; use tonic_reflection::pb::v1::server_reflection_response::MessageResponse;
use yaak_common::command::new_xplatform_command;
use yaak_tls::ClientCertificateConfig; use yaak_tls::ClientCertificateConfig;
pub async fn fill_pool_from_files( pub async fn fill_pool_from_files(
@@ -91,11 +91,11 @@ pub async fn fill_pool_from_files(
info!("Invoking protoc with {}", args.join(" ")); info!("Invoking protoc with {}", args.join(" "));
let mut cmd = new_xplatform_command(&config.protoc_bin_path); let out = Command::new(&config.protoc_bin_path)
cmd.args(&args); .args(&args)
.output()
let out = .await
cmd.output().await.map_err(|e| GenericError(format!("Failed to run protoc: {}", e)))?; .map_err(|e| GenericError(format!("Failed to run protoc: {}", e)))?;
if !out.status.success() { if !out.status.success() {
return Err(GenericError(format!( return Err(GenericError(format!(
+5 -15
View File
@@ -2,8 +2,6 @@ use crate::dns::LocalhostResolver;
use crate::error::Result; use crate::error::Result;
use log::{debug, info, warn}; use log::{debug, info, warn};
use reqwest::{Client, Proxy, redirect}; use reqwest::{Client, Proxy, redirect};
use std::sync::Arc;
use yaak_models::models::DnsOverride;
use yaak_tls::{ClientCertificateConfig, get_tls_config}; use yaak_tls::{ClientCertificateConfig, get_tls_config};
#[derive(Clone)] #[derive(Clone)]
@@ -30,14 +28,10 @@ pub struct HttpConnectionOptions {
pub validate_certificates: bool, pub validate_certificates: bool,
pub proxy: HttpConnectionProxySetting, pub proxy: HttpConnectionProxySetting,
pub client_certificate: Option<ClientCertificateConfig>, pub client_certificate: Option<ClientCertificateConfig>,
pub dns_overrides: Vec<DnsOverride>,
} }
impl HttpConnectionOptions { impl HttpConnectionOptions {
/// Build a reqwest Client and return it along with the DNS resolver. pub(crate) fn build_client(&self) -> Result<Client> {
/// The resolver is returned separately so it can be configured per-request
/// to emit DNS timing events to the appropriate channel.
pub(crate) fn build_client(&self) -> Result<(Client, Arc<LocalhostResolver>)> {
let mut client = Client::builder() let mut client = Client::builder()
.connection_verbose(true) .connection_verbose(true)
.redirect(redirect::Policy::none()) .redirect(redirect::Policy::none())
@@ -46,19 +40,15 @@ impl HttpConnectionOptions {
.no_brotli() .no_brotli()
.no_deflate() .no_deflate()
.referer(false) .referer(false)
.tls_info(true) .tls_info(true);
// Disable connection pooling to ensure DNS resolution happens on each request
// This is needed so we can emit DNS timing events for each request
.pool_max_idle_per_host(0);
// Configure TLS with optional client certificate // Configure TLS with optional client certificate
let config = let config =
get_tls_config(self.validate_certificates, true, self.client_certificate.clone())?; get_tls_config(self.validate_certificates, true, self.client_certificate.clone())?;
client = client.use_preconfigured_tls(config); client = client.use_preconfigured_tls(config);
// Configure DNS resolver - keep a reference to configure per-request // Configure DNS resolver
let resolver = LocalhostResolver::new(self.dns_overrides.clone()); client = client.dns_resolver(LocalhostResolver::new());
client = client.dns_resolver(resolver.clone());
// Configure proxy // Configure proxy
match self.proxy.clone() { match self.proxy.clone() {
@@ -79,7 +69,7 @@ impl HttpConnectionOptions {
self.client_certificate.is_some() self.client_certificate.is_some()
); );
Ok((client.build()?, resolver)) Ok(client.build()?)
} }
} }
+11 -143
View File
@@ -1,185 +1,53 @@
use crate::sender::HttpResponseEvent;
use hyper_util::client::legacy::connect::dns::{ use hyper_util::client::legacy::connect::dns::{
GaiResolver as HyperGaiResolver, Name as HyperName, GaiResolver as HyperGaiResolver, Name as HyperName,
}; };
use log::info;
use reqwest::dns::{Addrs, Name, Resolve, Resolving}; use reqwest::dns::{Addrs, Name, Resolve, Resolving};
use std::collections::HashMap;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use std::str::FromStr; use std::str::FromStr;
use std::sync::Arc; use std::sync::Arc;
use std::time::Instant;
use tokio::sync::{RwLock, mpsc};
use tower_service::Service; use tower_service::Service;
use yaak_models::models::DnsOverride;
/// Stores resolved addresses for a hostname override
#[derive(Clone)]
pub struct ResolvedOverride {
pub ipv4: Vec<Ipv4Addr>,
pub ipv6: Vec<Ipv6Addr>,
}
#[derive(Clone)] #[derive(Clone)]
pub struct LocalhostResolver { pub struct LocalhostResolver {
fallback: HyperGaiResolver, fallback: HyperGaiResolver,
event_tx: Arc<RwLock<Option<mpsc::Sender<HttpResponseEvent>>>>,
overrides: Arc<HashMap<String, ResolvedOverride>>,
} }
impl LocalhostResolver { impl LocalhostResolver {
pub fn new(dns_overrides: Vec<DnsOverride>) -> Arc<Self> { pub fn new() -> Arc<Self> {
let resolver = HyperGaiResolver::new(); let resolver = HyperGaiResolver::new();
Arc::new(Self { fallback: resolver })
// Pre-parse DNS overrides into a lookup map
let mut overrides = HashMap::new();
for o in dns_overrides {
if !o.enabled {
continue;
}
let hostname = o.hostname.to_lowercase();
let ipv4: Vec<Ipv4Addr> =
o.ipv4.iter().filter_map(|s| s.parse::<Ipv4Addr>().ok()).collect();
let ipv6: Vec<Ipv6Addr> =
o.ipv6.iter().filter_map(|s| s.parse::<Ipv6Addr>().ok()).collect();
// Only add if at least one address is valid
if !ipv4.is_empty() || !ipv6.is_empty() {
overrides.insert(hostname, ResolvedOverride { ipv4, ipv6 });
}
}
Arc::new(Self {
fallback: resolver,
event_tx: Arc::new(RwLock::new(None)),
overrides: Arc::new(overrides),
})
}
/// Set the event sender for the current request.
/// This should be called before each request to direct DNS events
/// to the appropriate channel.
pub async fn set_event_sender(&self, tx: Option<mpsc::Sender<HttpResponseEvent>>) {
let mut guard = self.event_tx.write().await;
*guard = tx;
} }
} }
impl Resolve for LocalhostResolver { impl Resolve for LocalhostResolver {
fn resolve(&self, name: Name) -> Resolving { fn resolve(&self, name: Name) -> Resolving {
let host = name.as_str().to_lowercase(); let host = name.as_str().to_lowercase();
let event_tx = self.event_tx.clone();
let overrides = self.overrides.clone();
info!("DNS resolve called for: {}", host);
// Check for DNS override first
if let Some(resolved) = overrides.get(&host) {
log::debug!("DNS override found for: {}", host);
let hostname = host.clone();
let mut addrs: Vec<SocketAddr> = Vec::new();
// Add IPv4 addresses
for ip in &resolved.ipv4 {
addrs.push(SocketAddr::new(IpAddr::V4(*ip), 0));
}
// Add IPv6 addresses
for ip in &resolved.ipv6 {
addrs.push(SocketAddr::new(IpAddr::V6(*ip), 0));
}
let addresses: Vec<String> = addrs.iter().map(|a| a.ip().to_string()).collect();
return Box::pin(async move {
// Emit DNS event for override
let guard = event_tx.read().await;
if let Some(tx) = guard.as_ref() {
let _ = tx
.send(HttpResponseEvent::DnsResolved {
hostname,
addresses,
duration: 0,
overridden: true,
})
.await;
}
Ok::<Addrs, Box<dyn std::error::Error + Send + Sync>>(Box::new(addrs.into_iter()))
});
}
// Check for .localhost suffix
let is_localhost = host.ends_with(".localhost"); let is_localhost = host.ends_with(".localhost");
if is_localhost { if is_localhost {
let hostname = host.clone();
// Port 0 is fine; reqwest replaces it with the URL's explicit // Port 0 is fine; reqwest replaces it with the URL's explicit
// port or the scheme's default (80/443, etc.). // port or the schemes default (80/443, etc.).
// (See docs note below.)
let addrs: Vec<SocketAddr> = vec![ let addrs: Vec<SocketAddr> = vec![
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0), SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0),
SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 0), SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 0),
]; ];
let addresses: Vec<String> = addrs.iter().map(|a| a.ip().to_string()).collect();
return Box::pin(async move { return Box::pin(async move {
// Emit DNS event for localhost resolution
let guard = event_tx.read().await;
if let Some(tx) = guard.as_ref() {
let _ = tx
.send(HttpResponseEvent::DnsResolved {
hostname,
addresses,
duration: 0,
overridden: false,
})
.await;
}
Ok::<Addrs, Box<dyn std::error::Error + Send + Sync>>(Box::new(addrs.into_iter())) Ok::<Addrs, Box<dyn std::error::Error + Send + Sync>>(Box::new(addrs.into_iter()))
}); });
} }
// Fall back to system DNS
let mut fallback = self.fallback.clone(); let mut fallback = self.fallback.clone();
let name_str = name.as_str().to_string(); let name_str = name.as_str().to_string();
let hostname = host.clone();
Box::pin(async move { Box::pin(async move {
let start = Instant::now(); match HyperName::from_str(&name_str) {
Ok(n) => fallback
let result = match HyperName::from_str(&name_str) { .call(n)
Ok(n) => fallback.call(n).await, .await
Err(e) => return Err(Box::new(e) as Box<dyn std::error::Error + Send + Sync>), .map(|addrs| Box::new(addrs) as Addrs)
}; .map_err(|err| Box::new(err) as Box<dyn std::error::Error + Send + Sync>),
Err(e) => Err(Box::new(e) as Box<dyn std::error::Error + Send + Sync>),
let duration = start.elapsed().as_millis() as u64;
match result {
Ok(addrs) => {
// Collect addresses for event emission
let addr_vec: Vec<SocketAddr> = addrs.collect();
let addresses: Vec<String> =
addr_vec.iter().map(|a| a.ip().to_string()).collect();
// Emit DNS event
let guard = event_tx.read().await;
if let Some(tx) = guard.as_ref() {
let _ = tx
.send(HttpResponseEvent::DnsResolved {
hostname,
addresses,
duration,
overridden: false,
})
.await;
}
Ok(Box::new(addr_vec.into_iter()) as Addrs)
}
Err(err) => Err(Box::new(err) as Box<dyn std::error::Error + Send + Sync>),
} }
}) })
} }
+7 -20
View File
@@ -1,5 +1,4 @@
use crate::client::HttpConnectionOptions; use crate::client::HttpConnectionOptions;
use crate::dns::LocalhostResolver;
use crate::error::Result; use crate::error::Result;
use log::info; use log::info;
use reqwest::Client; use reqwest::Client;
@@ -8,15 +7,8 @@ use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use tokio::sync::RwLock; use tokio::sync::RwLock;
/// A cached HTTP client along with its DNS resolver.
/// The resolver is needed to set the event sender per-request.
pub struct CachedClient {
pub client: Client,
pub resolver: Arc<LocalhostResolver>,
}
pub struct HttpConnectionManager { pub struct HttpConnectionManager {
connections: Arc<RwLock<BTreeMap<String, (CachedClient, Instant)>>>, connections: Arc<RwLock<BTreeMap<String, (Client, Instant)>>>,
ttl: Duration, ttl: Duration,
} }
@@ -28,26 +20,21 @@ impl HttpConnectionManager {
} }
} }
pub async fn get_client(&self, opt: &HttpConnectionOptions) -> Result<CachedClient> { pub async fn get_client(&self, opt: &HttpConnectionOptions) -> Result<Client> {
let mut connections = self.connections.write().await; let mut connections = self.connections.write().await;
let id = opt.id.clone(); let id = opt.id.clone();
// Clean old connections // Clean old connections
connections.retain(|_, (_, last_used)| last_used.elapsed() <= self.ttl); connections.retain(|_, (_, last_used)| last_used.elapsed() <= self.ttl);
if let Some((cached, last_used)) = connections.get_mut(&id) { if let Some((c, last_used)) = connections.get_mut(&id) {
info!("Re-using HTTP client {id}"); info!("Re-using HTTP client {id}");
*last_used = Instant::now(); *last_used = Instant::now();
return Ok(CachedClient { return Ok(c.clone());
client: cached.client.clone(),
resolver: cached.resolver.clone(),
});
} }
let (client, resolver) = opt.build_client()?; let c = opt.build_client()?;
let cached = CachedClient { client: client.clone(), resolver: resolver.clone() }; connections.insert(id.into(), (c.clone(), Instant::now()));
connections.insert(id.into(), (cached, Instant::now())); Ok(c)
Ok(CachedClient { client, resolver })
} }
} }
-25
View File
@@ -45,12 +45,6 @@ pub enum HttpResponseEvent {
ChunkReceived { ChunkReceived {
bytes: usize, bytes: usize,
}, },
DnsResolved {
hostname: String,
addresses: Vec<String>,
duration: u64,
overridden: bool,
},
} }
impl Display for HttpResponseEvent { impl Display for HttpResponseEvent {
@@ -73,19 +67,6 @@ impl Display for HttpResponseEvent {
HttpResponseEvent::HeaderDown(name, value) => write!(f, "< {}: {}", name, value), HttpResponseEvent::HeaderDown(name, value) => write!(f, "< {}: {}", name, value),
HttpResponseEvent::ChunkSent { bytes } => write!(f, "> [{} bytes sent]", bytes), HttpResponseEvent::ChunkSent { bytes } => write!(f, "> [{} bytes sent]", bytes),
HttpResponseEvent::ChunkReceived { bytes } => write!(f, "< [{} bytes received]", bytes), HttpResponseEvent::ChunkReceived { bytes } => write!(f, "< [{} bytes received]", bytes),
HttpResponseEvent::DnsResolved { hostname, addresses, duration, overridden } => {
if *overridden {
write!(f, "* DNS override {} -> {}", hostname, addresses.join(", "))
} else {
write!(
f,
"* DNS resolved {} to {} ({}ms)",
hostname,
addresses.join(", "),
duration
)
}
}
} }
} }
} }
@@ -112,9 +93,6 @@ impl From<HttpResponseEvent> for yaak_models::models::HttpResponseEventData {
HttpResponseEvent::HeaderDown(name, value) => D::HeaderDown { name, value }, HttpResponseEvent::HeaderDown(name, value) => D::HeaderDown { name, value },
HttpResponseEvent::ChunkSent { bytes } => D::ChunkSent { bytes }, HttpResponseEvent::ChunkSent { bytes } => D::ChunkSent { bytes },
HttpResponseEvent::ChunkReceived { bytes } => D::ChunkReceived { bytes }, HttpResponseEvent::ChunkReceived { bytes } => D::ChunkReceived { bytes },
HttpResponseEvent::DnsResolved { hostname, addresses, duration, overridden } => {
D::DnsResolved { hostname, addresses, duration, overridden }
}
} }
} }
} }
@@ -376,9 +354,6 @@ impl HttpSender for ReqwestSender {
// Add headers // Add headers
for header in request.headers { for header in request.headers {
if header.0.is_empty() {
continue;
}
req_builder = req_builder.header(&header.0, &header.1); req_builder = req_builder.header(&header.0, &header.1);
} }
+4 -10
View File
@@ -342,8 +342,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_transaction_single_redirect() { async fn test_transaction_single_redirect() {
let redirect_headers = let redirect_headers = vec![("Location".to_string(), "https://example.com/new".to_string())];
vec![("Location".to_string(), "https://example.com/new".to_string())];
let responses = vec![ let responses = vec![
MockResponse { status: 302, headers: redirect_headers, body: vec![] }, MockResponse { status: 302, headers: redirect_headers, body: vec![] },
@@ -374,8 +373,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_transaction_max_redirects_exceeded() { async fn test_transaction_max_redirects_exceeded() {
let redirect_headers = let redirect_headers = vec![("Location".to_string(), "https://example.com/loop".to_string())];
vec![("Location".to_string(), "https://example.com/loop".to_string())];
// Create more redirects than allowed // Create more redirects than allowed
let responses: Vec<MockResponse> = (0..12) let responses: Vec<MockResponse> = (0..12)
@@ -527,8 +525,7 @@ mod tests {
_request: SendableHttpRequest, _request: SendableHttpRequest,
_event_tx: mpsc::Sender<HttpResponseEvent>, _event_tx: mpsc::Sender<HttpResponseEvent>,
) -> Result<HttpResponse> { ) -> Result<HttpResponse> {
let headers = let headers = vec![("set-cookie".to_string(), "session=xyz789; Path=/".to_string())];
vec![("set-cookie".to_string(), "session=xyz789; Path=/".to_string())];
let body_stream: Pin<Box<dyn AsyncRead + Send>> = let body_stream: Pin<Box<dyn AsyncRead + Send>> =
Box::pin(std::io::Cursor::new(vec![])); Box::pin(std::io::Cursor::new(vec![]));
@@ -587,10 +584,7 @@ mod tests {
let headers = vec![ let headers = vec![
("set-cookie".to_string(), "session=abc123; Path=/".to_string()), ("set-cookie".to_string(), "session=abc123; Path=/".to_string()),
("set-cookie".to_string(), "user_id=42; Path=/".to_string()), ("set-cookie".to_string(), "user_id=42; Path=/".to_string()),
( ("set-cookie".to_string(), "preferences=dark; Path=/; Max-Age=86400".to_string()),
"set-cookie".to_string(),
"preferences=dark; Path=/; Max-Age=86400".to_string(),
),
]; ];
let body_stream: Pin<Box<dyn AsyncRead + Send>> = let body_stream: Pin<Box<dyn AsyncRead + Send>> =
+3 -5
View File
@@ -12,8 +12,6 @@ export type CookieExpires = { "AtUtc": string } | "SessionEnd";
export type CookieJar = { model: "cookie_jar", id: string, createdAt: string, updatedAt: string, workspaceId: string, cookies: Array<Cookie>, name: string, }; export type CookieJar = { model: "cookie_jar", id: string, createdAt: string, updatedAt: string, workspaceId: string, cookies: Array<Cookie>, name: string, };
export type DnsOverride = { hostname: string, ipv4: Array<string>, ipv6: Array<string>, enabled?: boolean, };
export type EditorKeymap = "default" | "vim" | "vscode" | "emacs"; export type EditorKeymap = "default" | "vim" | "vscode" | "emacs";
export type EncryptedKey = { encryptedKey: string, }; export type EncryptedKey = { encryptedKey: string, };
@@ -40,7 +38,7 @@ export type HttpRequest = { model: "http_request", id: string, createdAt: string
export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, }; export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };
export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, elapsedDns: number, error: string | null, headers: Array<HttpResponseHeader>, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array<HttpResponseHeader>, status: number, statusReason: string | null, state: HttpResponseState, 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, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, error: string | null, headers: Array<HttpResponseHeader>, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array<HttpResponseHeader>, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, };
export type HttpResponseEvent = { model: "http_response_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, responseId: string, event: HttpResponseEventData, }; export type HttpResponseEvent = { model: "http_response_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, responseId: string, event: HttpResponseEventData, };
@@ -49,7 +47,7 @@ export type HttpResponseEvent = { model: "http_response_event", id: string, crea
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support. * This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
* The `From` impl is in yaak-http to avoid circular dependencies. * The `From` impl is in yaak-http to avoid circular dependencies.
*/ */
export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, path: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array<string>, duration: bigint, overridden: boolean, }; export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, path: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, };
export type HttpResponseHeader = { name: string, value: string, }; export type HttpResponseHeader = { name: string, value: string, };
@@ -93,6 +91,6 @@ export type WebsocketMessageType = "text" | "binary";
export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, }; export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, };
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, settingDnsOverrides: Array<DnsOverride>, }; export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, };
export type WorkspaceMeta = { model: "workspace_meta", id: string, workspaceId: string, createdAt: string, updatedAt: string, encryptionKey: EncryptedKey | null, settingSyncDir: string | null, }; export type WorkspaceMeta = { model: "workspace_meta", id: string, workspaceId: string, createdAt: string, updatedAt: string, encryptionKey: EncryptedKey | null, settingSyncDir: string | null, };
-28
View File
@@ -206,34 +206,6 @@ export function replaceModelsInStore<
}); });
} }
export function mergeModelsInStore<
M extends AnyModel['model'],
T extends Extract<AnyModel, { model: M }>,
>(model: M, models: T[], filter?: (model: T) => boolean) {
mustStore().set(modelStoreDataAtom, (prev: ModelStoreData) => {
const existingModels = { ...prev[model] } as Record<string, T>;
// Merge in new models first
for (const m of models) {
existingModels[m.id] = m;
}
// Then filter out unwanted models
if (filter) {
for (const [id, m] of Object.entries(existingModels)) {
if (!filter(m)) {
delete existingModels[id];
}
}
}
return {
...prev,
[model]: existingModels,
};
});
}
function shouldIgnoreModel({ model, updateSource }: ModelPayload) { function shouldIgnoreModel({ model, updateSource }: ModelPayload) {
// Never ignore updates from non-user sources // Never ignore updates from non-user sources
if (updateSource.type !== 'window') { if (updateSource.type !== 'window') {
@@ -1,2 +0,0 @@
-- Add DNS resolution timing to http_responses
ALTER TABLE http_responses ADD COLUMN elapsed_dns INTEGER DEFAULT 0 NOT NULL;
@@ -1,2 +0,0 @@
-- Add DNS overrides setting to workspaces
ALTER TABLE workspaces ADD COLUMN setting_dns_overrides TEXT DEFAULT '[]' NOT NULL;
@@ -1,12 +0,0 @@
-- Filter out headers that match the hardcoded defaults (User-Agent: yaak, Accept: */*),
-- keeping any other custom headers the user may have added.
UPDATE workspaces
SET headers = (
SELECT json_group_array(json(value))
FROM json_each(headers)
WHERE NOT (
(LOWER(json_extract(value, '$.name')) = 'user-agent' AND json_extract(value, '$.value') = 'yaak')
OR (LOWER(json_extract(value, '$.name')) = 'accept' AND json_extract(value, '$.value') = '*/*')
)
)
WHERE json_array_length(headers) > 0;
-30
View File
@@ -73,20 +73,6 @@ pub struct ClientCertificate {
pub enabled: bool, pub enabled: bool,
} }
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
pub struct DnsOverride {
pub hostname: String,
#[serde(default)]
pub ipv4: Vec<String>,
#[serde(default)]
pub ipv6: Vec<String>,
#[serde(default = "default_true")]
#[ts(optional, as = "Option<bool>")]
pub enabled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)] #[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
#[ts(export, export_to = "gen_models.ts")] #[ts(export, export_to = "gen_models.ts")]
@@ -317,8 +303,6 @@ pub struct Workspace {
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub setting_follow_redirects: bool, pub setting_follow_redirects: bool,
pub setting_request_timeout: i32, pub setting_request_timeout: i32,
#[serde(default)]
pub setting_dns_overrides: Vec<DnsOverride>,
} }
impl UpsertModelInfo for Workspace { impl UpsertModelInfo for Workspace {
@@ -359,7 +343,6 @@ impl UpsertModelInfo for Workspace {
(SettingFollowRedirects, self.setting_follow_redirects.into()), (SettingFollowRedirects, self.setting_follow_redirects.into()),
(SettingRequestTimeout, self.setting_request_timeout.into()), (SettingRequestTimeout, self.setting_request_timeout.into()),
(SettingValidateCertificates, self.setting_validate_certificates.into()), (SettingValidateCertificates, self.setting_validate_certificates.into()),
(SettingDnsOverrides, serde_json::to_string(&self.setting_dns_overrides)?.into()),
]) ])
} }
@@ -376,7 +359,6 @@ impl UpsertModelInfo for Workspace {
WorkspaceIden::SettingFollowRedirects, WorkspaceIden::SettingFollowRedirects,
WorkspaceIden::SettingRequestTimeout, WorkspaceIden::SettingRequestTimeout,
WorkspaceIden::SettingValidateCertificates, WorkspaceIden::SettingValidateCertificates,
WorkspaceIden::SettingDnsOverrides,
] ]
} }
@@ -386,7 +368,6 @@ impl UpsertModelInfo for Workspace {
{ {
let headers: String = row.get("headers")?; let headers: String = row.get("headers")?;
let authentication: String = row.get("authentication")?; let authentication: String = row.get("authentication")?;
let setting_dns_overrides: String = row.get("setting_dns_overrides")?;
Ok(Self { Ok(Self {
id: row.get("id")?, id: row.get("id")?,
model: row.get("model")?, model: row.get("model")?,
@@ -401,7 +382,6 @@ impl UpsertModelInfo for Workspace {
setting_follow_redirects: row.get("setting_follow_redirects")?, setting_follow_redirects: row.get("setting_follow_redirects")?,
setting_request_timeout: row.get("setting_request_timeout")?, setting_request_timeout: row.get("setting_request_timeout")?,
setting_validate_certificates: row.get("setting_validate_certificates")?, setting_validate_certificates: row.get("setting_validate_certificates")?,
setting_dns_overrides: serde_json::from_str(&setting_dns_overrides).unwrap_or_default(),
}) })
} }
} }
@@ -1353,7 +1333,6 @@ pub struct HttpResponse {
pub content_length_compressed: Option<i32>, pub content_length_compressed: Option<i32>,
pub elapsed: i32, pub elapsed: i32,
pub elapsed_headers: i32, pub elapsed_headers: i32,
pub elapsed_dns: i32,
pub error: Option<String>, pub error: Option<String>,
pub headers: Vec<HttpResponseHeader>, pub headers: Vec<HttpResponseHeader>,
pub remote_addr: Option<String>, pub remote_addr: Option<String>,
@@ -1402,7 +1381,6 @@ impl UpsertModelInfo for HttpResponse {
(ContentLengthCompressed, self.content_length_compressed.into()), (ContentLengthCompressed, self.content_length_compressed.into()),
(Elapsed, self.elapsed.into()), (Elapsed, self.elapsed.into()),
(ElapsedHeaders, self.elapsed_headers.into()), (ElapsedHeaders, self.elapsed_headers.into()),
(ElapsedDns, self.elapsed_dns.into()),
(Error, self.error.into()), (Error, self.error.into()),
(Headers, serde_json::to_string(&self.headers)?.into()), (Headers, serde_json::to_string(&self.headers)?.into()),
(RemoteAddr, self.remote_addr.into()), (RemoteAddr, self.remote_addr.into()),
@@ -1424,7 +1402,6 @@ impl UpsertModelInfo for HttpResponse {
HttpResponseIden::ContentLengthCompressed, HttpResponseIden::ContentLengthCompressed,
HttpResponseIden::Elapsed, HttpResponseIden::Elapsed,
HttpResponseIden::ElapsedHeaders, HttpResponseIden::ElapsedHeaders,
HttpResponseIden::ElapsedDns,
HttpResponseIden::Error, HttpResponseIden::Error,
HttpResponseIden::Headers, HttpResponseIden::Headers,
HttpResponseIden::RemoteAddr, HttpResponseIden::RemoteAddr,
@@ -1458,7 +1435,6 @@ impl UpsertModelInfo for HttpResponse {
version: r.get("version")?, version: r.get("version")?,
elapsed: r.get("elapsed")?, elapsed: r.get("elapsed")?,
elapsed_headers: r.get("elapsed_headers")?, elapsed_headers: r.get("elapsed_headers")?,
elapsed_dns: r.get("elapsed_dns").unwrap_or_default(),
remote_addr: r.get("remote_addr")?, remote_addr: r.get("remote_addr")?,
status: r.get("status")?, status: r.get("status")?,
status_reason: r.get("status_reason")?, status_reason: r.get("status_reason")?,
@@ -1515,12 +1491,6 @@ pub enum HttpResponseEventData {
ChunkReceived { ChunkReceived {
bytes: usize, bytes: usize,
}, },
DnsResolved {
hostname: String,
addresses: Vec<String>,
duration: u64,
overridden: bool,
},
} }
impl Default for HttpResponseEventData { impl Default for HttpResponseEventData {
@@ -1,4 +1,3 @@
use super::dedupe_headers;
use crate::db_context::DbContext; use crate::db_context::DbContext;
use crate::error::Result; use crate::error::Result;
use crate::models::{GrpcRequest, GrpcRequestIden, HttpRequestHeader}; use crate::models::{GrpcRequest, GrpcRequestIden, HttpRequestHeader};
@@ -88,6 +87,6 @@ impl<'a> DbContext<'a> {
metadata.append(&mut grpc_request.metadata.clone()); metadata.append(&mut grpc_request.metadata.clone());
Ok(dedupe_headers(metadata)) Ok(metadata)
} }
} }
@@ -1,4 +1,3 @@
use super::dedupe_headers;
use crate::db_context::DbContext; use crate::db_context::DbContext;
use crate::error::Result; use crate::error::Result;
use crate::models::{Folder, FolderIden, HttpRequest, HttpRequestHeader, HttpRequestIden}; use crate::models::{Folder, FolderIden, HttpRequest, HttpRequestHeader, HttpRequestIden};
@@ -88,7 +87,7 @@ impl<'a> DbContext<'a> {
headers.append(&mut http_request.headers.clone()); headers.append(&mut http_request.headers.clone());
Ok(dedupe_headers(headers)) Ok(headers)
} }
pub fn list_http_requests_for_folder_recursive( pub fn list_http_requests_for_folder_recursive(
+1 -21
View File
@@ -19,26 +19,6 @@ mod websocket_connections;
mod websocket_events; mod websocket_events;
mod websocket_requests; mod websocket_requests;
mod workspace_metas; mod workspace_metas;
pub mod workspaces; mod workspaces;
const MAX_HISTORY_ITEMS: usize = 20; const MAX_HISTORY_ITEMS: usize = 20;
use crate::models::HttpRequestHeader;
use std::collections::HashMap;
/// Deduplicate headers by name (case-insensitive), keeping the latest (most specific) value.
/// Preserves the order of first occurrence for each header name.
pub(crate) fn dedupe_headers(headers: Vec<HttpRequestHeader>) -> Vec<HttpRequestHeader> {
let mut index_by_name: HashMap<String, usize> = HashMap::new();
let mut deduped: Vec<HttpRequestHeader> = Vec::new();
for header in headers {
let key = header.name.to_lowercase();
if let Some(&idx) = index_by_name.get(&key) {
deduped[idx] = header;
} else {
index_by_name.insert(key, deduped.len());
deduped.push(header);
}
}
deduped
}
@@ -1,4 +1,3 @@
use super::dedupe_headers;
use crate::db_context::DbContext; use crate::db_context::DbContext;
use crate::error::Result; use crate::error::Result;
use crate::models::{HttpRequestHeader, WebsocketRequest, WebsocketRequestIden}; use crate::models::{HttpRequestHeader, WebsocketRequest, WebsocketRequestIden};
@@ -96,6 +95,6 @@ impl<'a> DbContext<'a> {
headers.append(&mut websocket_request.headers.clone()); headers.append(&mut websocket_request.headers.clone());
Ok(dedupe_headers(headers)) Ok(headers)
} }
} }
+1 -23
View File
@@ -80,28 +80,6 @@ impl<'a> DbContext<'a> {
} }
pub fn resolve_headers_for_workspace(&self, workspace: &Workspace) -> Vec<HttpRequestHeader> { pub fn resolve_headers_for_workspace(&self, workspace: &Workspace) -> Vec<HttpRequestHeader> {
let mut headers = default_headers(); workspace.headers.clone()
headers.extend(workspace.headers.clone());
headers
} }
} }
/// Global default headers that are always sent with requests unless overridden.
/// These are prepended to the inheritance chain so workspace/folder/request headers
/// can override or disable them.
pub fn default_headers() -> Vec<HttpRequestHeader> {
vec![
HttpRequestHeader {
enabled: true,
name: "User-Agent".to_string(),
value: "yaak".to_string(),
id: None,
},
HttpRequestHeader {
enabled: true,
name: "Accept".to_string(),
value: "*/*".to_string(),
id: None,
},
]
}
+3 -5
View File
@@ -12,8 +12,6 @@ export type CookieExpires = { "AtUtc": string } | "SessionEnd";
export type CookieJar = { model: "cookie_jar", id: string, createdAt: string, updatedAt: string, workspaceId: string, cookies: Array<Cookie>, name: string, }; export type CookieJar = { model: "cookie_jar", id: string, createdAt: string, updatedAt: string, workspaceId: string, cookies: Array<Cookie>, name: string, };
export type DnsOverride = { hostname: string, ipv4: Array<string>, ipv6: Array<string>, enabled?: boolean, };
export type EditorKeymap = "default" | "vim" | "vscode" | "emacs"; export type EditorKeymap = "default" | "vim" | "vscode" | "emacs";
export type EncryptedKey = { encryptedKey: string, }; export type EncryptedKey = { encryptedKey: string, };
@@ -40,7 +38,7 @@ export type HttpRequest = { model: "http_request", id: string, createdAt: string
export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, }; export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };
export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, elapsedDns: number, error: string | null, headers: Array<HttpResponseHeader>, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array<HttpResponseHeader>, status: number, statusReason: string | null, state: HttpResponseState, 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, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, error: string | null, headers: Array<HttpResponseHeader>, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array<HttpResponseHeader>, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, };
export type HttpResponseEvent = { model: "http_response_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, responseId: string, event: HttpResponseEventData, }; export type HttpResponseEvent = { model: "http_response_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, responseId: string, event: HttpResponseEventData, };
@@ -49,7 +47,7 @@ export type HttpResponseEvent = { model: "http_response_event", id: string, crea
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support. * This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
* The `From` impl is in yaak-http to avoid circular dependencies. * The `From` impl is in yaak-http to avoid circular dependencies.
*/ */
export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, path: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array<string>, duration: bigint, overridden: boolean, }; export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, path: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, };
export type HttpResponseHeader = { name: string, value: string, }; export type HttpResponseHeader = { name: string, value: string, };
@@ -79,6 +77,6 @@ export type WebsocketEventType = "binary" | "close" | "frame" | "open" | "ping"
export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, }; export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, };
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, settingDnsOverrides: Array<DnsOverride>, }; export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, };
export type WorkspaceMeta = { model: "workspace_meta", id: string, workspaceId: string, createdAt: string, updatedAt: string, encryptionKey: EncryptedKey | null, settingSyncDir: string | null, }; export type WorkspaceMeta = { model: "workspace_meta", id: string, workspaceId: string, createdAt: string, updatedAt: string, encryptionKey: EncryptedKey | null, settingSyncDir: string | null, };
+4 -1
View File
@@ -80,7 +80,10 @@ pub async fn check_plugin_updates(
} }
/// Search for plugins in the registry. /// Search for plugins in the registry.
pub async fn search_plugins(http_client: &Client, query: &str) -> Result<PluginSearchResponse> { pub async fn search_plugins(
http_client: &Client,
query: &str,
) -> Result<PluginSearchResponse> {
let mut url = build_url("/search"); let mut url = build_url("/search");
{ {
let mut query_pairs = url.query_pairs_mut(); let mut query_pairs = url.query_pairs_mut();
+3 -15
View File
@@ -378,8 +378,7 @@ impl PluginManager {
plugins: Vec<PluginHandle>, plugins: Vec<PluginHandle>,
timeout_duration: Duration, timeout_duration: Duration,
) -> Result<Vec<InternalEvent>> { ) -> Result<Vec<InternalEvent>> {
let event_type = payload.type_name(); let label = format!("wait[{}.{}]", plugins.len(), payload.type_name());
let label = format!("wait[{}.{}]", plugins.len(), event_type);
let (rx_id, mut rx) = self.subscribe(label.as_str()).await; let (rx_id, mut rx) = self.subscribe(label.as_str()).await;
// 1. Build the events with IDs and everything // 1. Build the events with IDs and everything
@@ -413,21 +412,10 @@ impl PluginManager {
// Timeout to prevent hanging forever if plugin doesn't respond // Timeout to prevent hanging forever if plugin doesn't respond
if timeout(timeout_duration, collect_events).await.is_err() { if timeout(timeout_duration, collect_events).await.is_err() {
let responded_ids: Vec<&String> =
found_events.iter().filter_map(|e| e.reply_id.as_ref()).collect();
let non_responding: Vec<&str> = events_to_send
.iter()
.filter(|e| !responded_ids.contains(&&e.id))
.map(|e| e.plugin_name.as_str())
.collect();
warn!( warn!(
"Timeout ({:?}) waiting for {} responses. Got {}/{} responses. \ "Timeout waiting for plugin responses. Got {}/{} responses",
Non-responding plugins: [{}]",
timeout_duration,
event_type,
found_events.len(), found_events.len(),
events_to_send.len(), events_to_send.len()
non_responding.join(", ")
); );
} }
@@ -196,11 +196,7 @@ pub fn decrypt_secure_template_function(
} }
} }
new_tokens.push(Token::Raw { new_tokens.push(Token::Raw {
text: template_function_secure_run( text: template_function_secure_run(encryption_manager, args_map, plugin_context)?,
encryption_manager,
args_map,
plugin_context,
)?,
}); });
} }
t => { t => {
@@ -220,8 +216,7 @@ pub fn encrypt_secure_template_function(
plugin_context: &PluginContext, plugin_context: &PluginContext,
template: &str, template: &str,
) -> Result<String> { ) -> Result<String> {
let decrypted = let decrypted = decrypt_secure_template_function(&encryption_manager, plugin_context, template)?;
decrypt_secure_template_function(&encryption_manager, plugin_context, template)?;
let tokens = Tokens { let tokens = Tokens {
tokens: vec![Token::Tag { tokens: vec![Token::Tag {
val: Val::Fn { val: Val::Fn {
@@ -236,12 +231,7 @@ pub fn encrypt_secure_template_function(
Ok(transform_args( Ok(transform_args(
tokens, tokens,
&PluginTemplateCallback::new( &PluginTemplateCallback::new(plugin_manager, encryption_manager, plugin_context, RenderPurpose::Preview),
plugin_manager,
encryption_manager,
plugin_context,
RenderPurpose::Preview,
),
)? )?
.to_string()) .to_string())
} }
+5 -6
View File
@@ -4,8 +4,8 @@ use std::net::SocketAddr;
use std::path::Path; use std::path::Path;
use std::process::Stdio; use std::process::Stdio;
use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
use tokio::sync::watch::Receiver; use tokio::sync::watch::Receiver;
use yaak_common::command::new_xplatform_command;
/// Start the Node.js plugin runtime process. /// Start the Node.js plugin runtime process.
/// ///
@@ -30,14 +30,13 @@ pub async fn start_nodejs_plugin_runtime(
plugin_runtime_main_str plugin_runtime_main_str
); );
let mut cmd = new_xplatform_command(node_bin_path); let mut child = Command::new(node_bin_path)
cmd.env("HOST", addr.ip().to_string()) .env("HOST", addr.ip().to_string())
.env("PORT", addr.port().to_string()) .env("PORT", addr.port().to_string())
.arg(&plugin_runtime_main_str) .arg(&plugin_runtime_main_str)
.stdout(Stdio::piped()) .stdout(Stdio::piped())
.stderr(Stdio::piped()); .stderr(Stdio::piped())
.spawn()?;
let mut child = cmd.spawn()?;
info!("Spawned plugin runtime"); info!("Spawned plugin runtime");
+2 -7
View File
@@ -46,11 +46,7 @@ impl TemplateCallback for PluginTemplateCallback {
let fn_name = if fn_name == "Response" { "response" } else { fn_name }; let fn_name = if fn_name == "Response" { "response" } else { fn_name };
if fn_name == "secure" { if fn_name == "secure" {
return template_function_secure_run( return template_function_secure_run(&self.encryption_manager, args, &self.plugin_context);
&self.encryption_manager,
args,
&self.plugin_context,
);
} else if fn_name == "keychain" || fn_name == "keyring" { } else if fn_name == "keychain" || fn_name == "keyring" {
return template_function_keychain_run(args); return template_function_keychain_run(args);
} }
@@ -60,8 +56,7 @@ impl TemplateCallback for PluginTemplateCallback {
primitive_args.insert(key, JsonPrimitive::from(value)); primitive_args.insert(key, JsonPrimitive::from(value));
} }
let resp = self let resp = self.plugin_manager
.plugin_manager
.call_template_function( .call_template_function(
&self.plugin_context, &self.plugin_context,
fn_name, fn_name,
+1 -3
View File
@@ -1,7 +1,5 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type DnsOverride = { hostname: string, ipv4: Array<string>, ipv6: Array<string>, enabled?: boolean, };
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null, variables: Array<EnvironmentVariable>, color: string | null, sortPriority: number, }; export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null, variables: Array<EnvironmentVariable>, color: string | null, sortPriority: number, };
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, }; export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };
@@ -22,4 +20,4 @@ export type SyncState = { model: "sync_state", id: string, workspaceId: string,
export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, }; export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, };
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, settingDnsOverrides: Array<DnsOverride>, }; export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, };
+5 -1
View File
@@ -296,7 +296,11 @@ pub fn compute_sync_ops(
.collect() .collect()
} }
fn workspace_models(db: &DbContext, version: &str, workspace_id: &str) -> Result<Vec<SyncModel>> { fn workspace_models(
db: &DbContext,
version: &str,
workspace_id: &str,
) -> Result<Vec<SyncModel>> {
// We want to include private environments here so that we can take them into account during // We want to include private environments here so that we can take them into account during
// the sync process. Otherwise, they would be treated as deleted. // the sync process. Otherwise, they would be treated as deleted.
let include_private_environments = true; let include_private_environments = true;
+1 -1
View File
@@ -2,7 +2,6 @@ use crate::connect::ws_connect;
use crate::error::Result; use crate::error::Result;
use futures_util::stream::SplitSink; use futures_util::stream::SplitSink;
use futures_util::{SinkExt, StreamExt}; use futures_util::{SinkExt, StreamExt};
use http::HeaderMap;
use log::{debug, info, warn}; use log::{debug, info, warn};
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
@@ -11,6 +10,7 @@ use tokio::net::TcpStream;
use tokio::sync::{Mutex, mpsc}; use tokio::sync::{Mutex, mpsc};
use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::tungstenite::handshake::client::Response; use tokio_tungstenite::tungstenite::handshake::client::Response;
use http::HeaderMap;
use tokio_tungstenite::tungstenite::http::HeaderValue; use tokio_tungstenite::tungstenite::http::HeaderValue;
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream}; use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
use yaak_tls::ClientCertificateConfig; use yaak_tls::ClientCertificateConfig;
+4 -4
View File
@@ -7811,9 +7811,9 @@
} }
}, },
"node_modules/hono": { "node_modules/hono": {
"version": "4.11.4", "version": "4.11.3",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz", "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.3.tgz",
"integrity": "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==", "integrity": "sha512-PmQi306+M/ct/m5s66Hrg+adPnkD5jiO6IjA7WhWw0gSBSo1EcRegwuI1deZ+wd5pzCGynCcn2DprnE4/yEV4w==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=16.9.0" "node": ">=16.9.0"
@@ -15743,7 +15743,7 @@
"@hono/mcp": "^0.2.3", "@hono/mcp": "^0.2.3",
"@hono/node-server": "^1.19.7", "@hono/node-server": "^1.19.7",
"@modelcontextprotocol/sdk": "^1.25.2", "@modelcontextprotocol/sdk": "^1.25.2",
"hono": "^4.11.4", "hono": "^4.11.3",
"zod": "^3.25.76" "zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
+1 -1
View File
@@ -17,7 +17,7 @@ npx @yaakapp/cli generate
``` ```
For more details on creating plugins, check out For more details on creating plugins, check out
the [Quick Start Guide](https://yaak.app/docs/plugin-development/plugins-quick-start) the [Quick Start Guide](https://feedback.yaak.app/help/articles/6911763-plugins-quick-start)
## Installation ## Installation
+3 -5
View File
@@ -12,8 +12,6 @@ export type CookieExpires = { "AtUtc": string } | "SessionEnd";
export type CookieJar = { model: "cookie_jar", id: string, createdAt: string, updatedAt: string, workspaceId: string, cookies: Array<Cookie>, name: string, }; export type CookieJar = { model: "cookie_jar", id: string, createdAt: string, updatedAt: string, workspaceId: string, cookies: Array<Cookie>, name: string, };
export type DnsOverride = { hostname: string, ipv4: Array<string>, ipv6: Array<string>, enabled?: boolean, };
export type EditorKeymap = "default" | "vim" | "vscode" | "emacs"; export type EditorKeymap = "default" | "vim" | "vscode" | "emacs";
export type EncryptedKey = { encryptedKey: string, }; export type EncryptedKey = { encryptedKey: string, };
@@ -40,7 +38,7 @@ export type HttpRequest = { model: "http_request", id: string, createdAt: string
export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, }; export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };
export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, elapsedDns: number, error: string | null, headers: Array<HttpResponseHeader>, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array<HttpResponseHeader>, status: number, statusReason: string | null, state: HttpResponseState, 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, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, error: string | null, headers: Array<HttpResponseHeader>, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array<HttpResponseHeader>, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, };
export type HttpResponseEvent = { model: "http_response_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, responseId: string, event: HttpResponseEventData, }; export type HttpResponseEvent = { model: "http_response_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, responseId: string, event: HttpResponseEventData, };
@@ -49,7 +47,7 @@ export type HttpResponseEvent = { model: "http_response_event", id: string, crea
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support. * This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
* The `From` impl is in yaak-http to avoid circular dependencies. * The `From` impl is in yaak-http to avoid circular dependencies.
*/ */
export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, path: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array<string>, duration: bigint, overridden: boolean, }; export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, path: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, };
export type HttpResponseHeader = { name: string, value: string, }; export type HttpResponseHeader = { name: string, value: string, };
@@ -79,6 +77,6 @@ export type WebsocketEventType = "binary" | "close" | "frame" | "open" | "ping"
export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, }; export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, };
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, settingDnsOverrides: Array<DnsOverride>, }; export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, };
export type WorkspaceMeta = { model: "workspace_meta", id: string, workspaceId: string, createdAt: string, updatedAt: string, encryptionKey: EncryptedKey | null, settingSyncDir: string | null, }; export type WorkspaceMeta = { model: "workspace_meta", id: string, workspaceId: string, createdAt: string, updatedAt: string, encryptionKey: EncryptedKey | null, settingSyncDir: string | null, };
@@ -25,7 +25,7 @@ import type {
TemplateRenderRequest, TemplateRenderRequest,
WorkspaceInfo, WorkspaceInfo,
} from '../bindings/gen_events.ts'; } from '../bindings/gen_events.ts';
import type { Folder, HttpRequest } from '../bindings/gen_models.ts'; import type { HttpRequest } from '../bindings/gen_models.ts';
import type { JsonValue } from '../bindings/serde_json/JsonValue'; import type { JsonValue } from '../bindings/serde_json/JsonValue';
export type WorkspaceHandle = Pick<WorkspaceInfo, 'id' | 'name'>; export type WorkspaceHandle = Pick<WorkspaceInfo, 'id' | 'name'>;
@@ -82,15 +82,6 @@ export interface Context {
}; };
folder: { folder: {
list(args?: ListFoldersRequest): Promise<ListFoldersResponse['folders']>; list(args?: ListFoldersRequest): Promise<ListFoldersResponse['folders']>;
getById(args: { id: string }): Promise<Folder | null>;
create(
args: Omit<Partial<Folder>, 'id' | 'model' | 'createdAt' | 'updatedAt'> &
Pick<Folder, 'workspaceId' | 'name'>,
): Promise<Folder>;
update(
args: Omit<Partial<Folder>, 'model' | 'createdAt' | 'updatedAt'> & Pick<Folder, 'id'>,
): Promise<Folder>;
delete(args: { id: string }): Promise<Folder>;
}; };
httpResponse: { httpResponse: {
find(args: FindHttpResponsesRequest): Promise<FindHttpResponsesResponse['httpResponses']>; find(args: FindHttpResponsesRequest): Promise<FindHttpResponsesResponse['httpResponses']>;
@@ -11,7 +11,6 @@ import type {
DeleteKeyValueResponse, DeleteKeyValueResponse,
DeleteModelResponse, DeleteModelResponse,
FindHttpResponsesResponse, FindHttpResponsesResponse,
Folder,
GetCookieValueRequest, GetCookieValueRequest,
GetCookieValueResponse, GetCookieValueResponse,
GetHttpRequestByIdResponse, GetHttpRequestByIdResponse,
@@ -783,44 +782,6 @@ export class PluginInstance {
const { folders } = await this.#sendForReply<ListFoldersResponse>(context, payload); const { folders } = await this.#sendForReply<ListFoldersResponse>(context, payload);
return folders; return folders;
}, },
getById: async (args: { id: string }) => {
const payload = { type: 'list_folders_request' } as const;
const { folders } = await this.#sendForReply<ListFoldersResponse>(context, payload);
return folders.find((f) => f.id === args.id) ?? null;
},
create: async (args) => {
const payload = {
type: 'upsert_model_request',
model: {
name: '',
...args,
id: '',
model: 'folder',
},
} as InternalEventPayload;
const response = await this.#sendForReply<UpsertModelResponse>(context, payload);
return response.model as Folder;
},
update: async (args) => {
const payload = {
type: 'upsert_model_request',
model: {
model: 'folder',
...args,
},
} as InternalEventPayload;
const response = await this.#sendForReply<UpsertModelResponse>(context, payload);
return response.model as Folder;
},
delete: async (args: { id: string }) => {
const payload = {
type: 'delete_model_request',
model: 'folder',
id: args.id,
} as InternalEventPayload;
const response = await this.#sendForReply<DeleteModelResponse>(context, payload);
return response.model as Folder;
},
}, },
cookies: { cookies: {
getValue: async (args: GetCookieValueRequest) => { getValue: async (args: GetCookieValueRequest) => {
+1 -1
View File
@@ -18,7 +18,7 @@
"@hono/mcp": "^0.2.3", "@hono/mcp": "^0.2.3",
"@hono/node-server": "^1.19.7", "@hono/node-server": "^1.19.7",
"@modelcontextprotocol/sdk": "^1.25.2", "@modelcontextprotocol/sdk": "^1.25.2",
"hono": "^4.11.4", "hono": "^4.11.3",
"zod": "^3.25.76" "zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
+4 -119
View File
@@ -2,12 +2,6 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import * as z from 'zod'; import * as z from 'zod';
import type { McpServerContext } from '../types.js'; import type { McpServerContext } from '../types.js';
import { getWorkspaceContext } from './helpers.js'; import { getWorkspaceContext } from './helpers.js';
import {
authenticationSchema,
authenticationTypeSchema,
headersSchema,
workspaceIdSchema,
} from './schemas.js';
export function registerFolderTools(server: McpServer, ctx: McpServerContext) { export function registerFolderTools(server: McpServer, ctx: McpServerContext) {
server.registerTool( server.registerTool(
@@ -16,7 +10,10 @@ export function registerFolderTools(server: McpServer, ctx: McpServerContext) {
title: 'List Folders', title: 'List Folders',
description: 'List all folders in a workspace', description: 'List all folders in a workspace',
inputSchema: { inputSchema: {
workspaceId: workspaceIdSchema, workspaceId: z
.string()
.optional()
.describe('Workspace ID (required if multiple workspaces are open)'),
}, },
}, },
async ({ workspaceId }) => { async ({ workspaceId }) => {
@@ -33,116 +30,4 @@ export function registerFolderTools(server: McpServer, ctx: McpServerContext) {
}; };
}, },
); );
server.registerTool(
'get_folder',
{
title: 'Get Folder',
description: 'Get details of a specific folder by ID',
inputSchema: {
id: z.string().describe('The folder ID'),
workspaceId: workspaceIdSchema,
},
},
async ({ id, workspaceId }) => {
const workspaceCtx = await getWorkspaceContext(ctx, workspaceId);
const folder = await workspaceCtx.yaak.folder.getById({ id });
return {
content: [
{
type: 'text' as const,
text: JSON.stringify(folder, null, 2),
},
],
};
},
);
server.registerTool(
'create_folder',
{
title: 'Create Folder',
description: 'Create a new folder in a workspace',
inputSchema: {
workspaceId: workspaceIdSchema,
name: z.string().describe('Folder name'),
folderId: z.string().optional().describe('Parent folder ID (for nested folders)'),
description: z.string().optional().describe('Folder description'),
sortPriority: z.number().optional().describe('Sort priority for ordering'),
headers: headersSchema.describe('Default headers to apply to requests in this folder'),
authenticationType: authenticationTypeSchema,
authentication: authenticationSchema,
},
},
async ({ workspaceId: ogWorkspaceId, ...args }) => {
const workspaceCtx = await getWorkspaceContext(ctx, ogWorkspaceId);
const workspaceId = await workspaceCtx.yaak.window.workspaceId();
if (!workspaceId) {
throw new Error('No workspace is open');
}
const folder = await workspaceCtx.yaak.folder.create({
workspaceId: workspaceId,
...args,
});
return {
content: [{ type: 'text' as const, text: JSON.stringify(folder, null, 2) }],
};
},
);
server.registerTool(
'update_folder',
{
title: 'Update Folder',
description: 'Update an existing folder',
inputSchema: {
id: z.string().describe('Folder ID to update'),
workspaceId: workspaceIdSchema,
name: z.string().optional().describe('Folder name'),
folderId: z.string().optional().describe('Parent folder ID (for nested folders)'),
description: z.string().optional().describe('Folder description'),
sortPriority: z.number().optional().describe('Sort priority for ordering'),
headers: headersSchema.describe('Default headers to apply to requests in this folder'),
authenticationType: authenticationTypeSchema,
authentication: authenticationSchema,
},
},
async ({ id, workspaceId, ...updates }) => {
const workspaceCtx = await getWorkspaceContext(ctx, workspaceId);
// Fetch existing folder to merge with updates
const existing = await workspaceCtx.yaak.folder.getById({ id });
if (!existing) {
throw new Error(`Folder with ID ${id} not found`);
}
// Merge existing fields with updates
const folder = await workspaceCtx.yaak.folder.update({
...existing,
...updates,
id,
});
return {
content: [{ type: 'text' as const, text: JSON.stringify(folder, null, 2) }],
};
},
);
server.registerTool(
'delete_folder',
{
title: 'Delete Folder',
description: 'Delete a folder by ID',
inputSchema: {
id: z.string().describe('Folder ID to delete'),
},
},
async ({ id }) => {
const folder = await ctx.yaak.folder.delete({ id });
return {
content: [{ type: 'text' as const, text: `Deleted: ${folder.name} (${folder.id})` }],
};
},
);
} }
@@ -2,15 +2,6 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import * as z from 'zod'; import * as z from 'zod';
import type { McpServerContext } from '../types.js'; import type { McpServerContext } from '../types.js';
import { getWorkspaceContext } from './helpers.js'; import { getWorkspaceContext } from './helpers.js';
import {
authenticationSchema,
authenticationTypeSchema,
bodySchema,
bodyTypeSchema,
headersSchema,
urlParametersSchema,
workspaceIdSchema,
} from './schemas.js';
export function registerHttpRequestTools(server: McpServer, ctx: McpServerContext) { export function registerHttpRequestTools(server: McpServer, ctx: McpServerContext) {
server.registerTool( server.registerTool(
@@ -19,7 +10,10 @@ export function registerHttpRequestTools(server: McpServer, ctx: McpServerContex
title: 'List HTTP Requests', title: 'List HTTP Requests',
description: 'List all HTTP requests in a workspace', description: 'List all HTTP requests in a workspace',
inputSchema: { inputSchema: {
workspaceId: workspaceIdSchema, workspaceId: z
.string()
.optional()
.describe('Workspace ID (required if multiple workspaces are open)'),
}, },
}, },
async ({ workspaceId }) => { async ({ workspaceId }) => {
@@ -44,7 +38,10 @@ export function registerHttpRequestTools(server: McpServer, ctx: McpServerContex
description: 'Get details of a specific HTTP request by ID', description: 'Get details of a specific HTTP request by ID',
inputSchema: { inputSchema: {
id: z.string().describe('The HTTP request ID'), id: z.string().describe('The HTTP request ID'),
workspaceId: workspaceIdSchema, workspaceId: z
.string()
.optional()
.describe('Workspace ID (required if multiple workspaces are open)'),
}, },
}, },
async ({ id, workspaceId }) => { async ({ id, workspaceId }) => {
@@ -70,7 +67,10 @@ export function registerHttpRequestTools(server: McpServer, ctx: McpServerContex
inputSchema: { inputSchema: {
id: z.string().describe('The HTTP request ID to send'), id: z.string().describe('The HTTP request ID to send'),
environmentId: z.string().optional().describe('Optional environment ID to use'), environmentId: z.string().optional().describe('Optional environment ID to use'),
workspaceId: workspaceIdSchema, workspaceId: z
.string()
.optional()
.describe('Workspace ID (required if multiple workspaces are open)'),
}, },
}, },
async ({ id, workspaceId }) => { async ({ id, workspaceId }) => {
@@ -99,7 +99,10 @@ export function registerHttpRequestTools(server: McpServer, ctx: McpServerContex
title: 'Create HTTP Request', title: 'Create HTTP Request',
description: 'Create a new HTTP request', description: 'Create a new HTTP request',
inputSchema: { inputSchema: {
workspaceId: workspaceIdSchema, workspaceId: z
.string()
.optional()
.describe('Workspace ID (required if multiple workspaces are open)'),
name: z name: z
.string() .string()
.optional() .optional()
@@ -108,12 +111,62 @@ export function registerHttpRequestTools(server: McpServer, ctx: McpServerContex
method: z.string().optional().describe('HTTP method (defaults to GET)'), method: z.string().optional().describe('HTTP method (defaults to GET)'),
folderId: z.string().optional().describe('Parent folder ID'), folderId: z.string().optional().describe('Parent folder ID'),
description: z.string().optional().describe('Request description'), description: z.string().optional().describe('Request description'),
headers: headersSchema.describe('Request headers'), headers: z
urlParameters: urlParametersSchema, .array(
bodyType: bodyTypeSchema, z.object({
body: bodySchema, name: z.string(),
authenticationType: authenticationTypeSchema, value: z.string(),
authentication: authenticationSchema, enabled: z.boolean().default(true),
}),
)
.optional()
.describe('Request headers'),
urlParameters: z
.array(
z.object({
name: z.string(),
value: z.string(),
enabled: z.boolean().default(true),
}),
)
.optional()
.describe('URL query parameters'),
bodyType: z
.string()
.optional()
.describe(
'Body type. Supported values: "binary", "graphql", "application/x-www-form-urlencoded", "multipart/form-data", or any text-based type (e.g., "application/json", "text/plain")',
),
body: z
.record(z.string(), z.any())
.optional()
.describe(
'Body content object. Structure varies by bodyType:\n' +
'- "binary": { filePath: "/path/to/file" }\n' +
'- "graphql": { query: "{ users { name } }", variables: "{\\"id\\": \\"123\\"}" }\n' +
'- "application/x-www-form-urlencoded": { form: [{ name: "key", value: "val", enabled: true }] }\n' +
'- "multipart/form-data": { form: [{ name: "field", value: "text", file: "/path/to/file", enabled: true }] }\n' +
'- text-based (application/json, etc.): { text: "raw body content" }',
),
authenticationType: z
.string()
.optional()
.describe(
'Authentication type. Common values: "basic", "bearer", "oauth2", "apikey", "jwt", "awsv4", "oauth1", "ntlm", "none". Use null to inherit from parent folder/workspace.',
),
authentication: z
.record(z.string(), z.any())
.optional()
.describe(
'Authentication configuration object. Structure varies by authenticationType:\n' +
'- "basic": { username: "user", password: "pass" }\n' +
'- "bearer": { token: "abc123", prefix: "Bearer" }\n' +
'- "oauth2": { clientId: "...", clientSecret: "...", grantType: "authorization_code", authorizationUrl: "...", accessTokenUrl: "...", scope: "...", ... }\n' +
'- "apikey": { location: "header" | "query", key: "X-API-Key", value: "..." }\n' +
'- "jwt": { algorithm: "HS256", secret: "...", payload: "{ ... }" }\n' +
'- "awsv4": { accessKeyId: "...", secretAccessKey: "...", service: "sts", region: "us-east-1", sessionToken: "..." }\n' +
'- "none": {}',
),
}, },
}, },
async ({ workspaceId: ogWorkspaceId, ...args }) => { async ({ workspaceId: ogWorkspaceId, ...args }) => {
@@ -141,18 +194,68 @@ export function registerHttpRequestTools(server: McpServer, ctx: McpServerContex
description: 'Update an existing HTTP request', description: 'Update an existing HTTP request',
inputSchema: { inputSchema: {
id: z.string().describe('HTTP request ID to update'), id: z.string().describe('HTTP request ID to update'),
workspaceId: workspaceIdSchema, workspaceId: z.string().describe('Workspace ID'),
name: z.string().optional().describe('Request name'), name: z.string().optional().describe('Request name'),
url: z.string().optional().describe('Request URL'), url: z.string().optional().describe('Request URL'),
method: z.string().optional().describe('HTTP method'), method: z.string().optional().describe('HTTP method'),
folderId: z.string().optional().describe('Parent folder ID'), folderId: z.string().optional().describe('Parent folder ID'),
description: z.string().optional().describe('Request description'), description: z.string().optional().describe('Request description'),
headers: headersSchema.describe('Request headers'), headers: z
urlParameters: urlParametersSchema, .array(
bodyType: bodyTypeSchema, z.object({
body: bodySchema, name: z.string(),
authenticationType: authenticationTypeSchema, value: z.string(),
authentication: authenticationSchema, enabled: z.boolean().default(true),
}),
)
.optional()
.describe('Request headers'),
urlParameters: z
.array(
z.object({
name: z.string(),
value: z.string(),
enabled: z.boolean().default(true),
}),
)
.optional()
.describe('URL query parameters'),
bodyType: z
.string()
.optional()
.describe(
'Body type. Supported values: "binary", "graphql", "application/x-www-form-urlencoded", "multipart/form-data", or any text-based type (e.g., "application/json", "text/plain")',
),
body: z
.record(z.string(), z.any())
.optional()
.describe(
'Body content object. Structure varies by bodyType:\n' +
'- "binary": { filePath: "/path/to/file" }\n' +
'- "graphql": { query: "{ users { name } }", variables: "{\\"id\\": \\"123\\"}" }\n' +
'- "application/x-www-form-urlencoded": { form: [{ name: "key", value: "val", enabled: true }] }\n' +
'- "multipart/form-data": { form: [{ name: "field", value: "text", file: "/path/to/file", enabled: true }] }\n' +
'- text-based (application/json, etc.): { text: "raw body content" }',
),
authenticationType: z
.string()
.optional()
.describe(
'Authentication type. Common values: "basic", "bearer", "oauth2", "apikey", "jwt", "awsv4", "oauth1", "ntlm", "none". Use null to inherit from parent folder/workspace.',
),
authentication: z
.record(z.string(), z.any())
.optional()
.describe(
'Authentication configuration object. Structure varies by authenticationType:\n' +
'- "basic": { username: "user", password: "pass" }\n' +
'- "bearer": { token: "abc123", prefix: "Bearer" }\n' +
'- "oauth2": { clientId: "...", clientSecret: "...", grantType: "authorization_code", authorizationUrl: "...", accessTokenUrl: "...", scope: "...", ... }\n' +
'- "apikey": { location: "header" | "query", key: "X-API-Key", value: "..." }\n' +
'- "jwt": { algorithm: "HS256", secret: "...", payload: "{ ... }" }\n' +
'- "awsv4": { accessKeyId: "...", secretAccessKey: "...", service: "sts", region: "us-east-1", sessionToken: "..." }\n' +
'- "none": {}',
),
}, },
}, },
async ({ id, workspaceId, ...updates }) => { async ({ id, workspaceId, ...updates }) => {
@@ -1,67 +0,0 @@
import * as z from 'zod';
export const workspaceIdSchema = z
.string()
.optional()
.describe('Workspace ID (required if multiple workspaces are open)');
export const headersSchema = z
.array(
z.object({
name: z.string(),
value: z.string(),
enabled: z.boolean().default(true),
}),
)
.optional();
export const urlParametersSchema = z
.array(
z.object({
name: z.string(),
value: z.string(),
enabled: z.boolean().default(true),
}),
)
.optional()
.describe('URL query parameters');
export const bodyTypeSchema = z
.string()
.optional()
.describe(
'Body type. Supported values: "binary", "graphql", "application/x-www-form-urlencoded", "multipart/form-data", or any text-based type (e.g., "application/json", "text/plain")',
);
export const bodySchema = z
.record(z.string(), z.any())
.optional()
.describe(
'Body content object. Structure varies by bodyType:\n' +
'- "binary": { filePath: "/path/to/file" }\n' +
'- "graphql": { query: "{ users { name } }", variables: "{\\"id\\": \\"123\\"}" }\n' +
'- "application/x-www-form-urlencoded": { form: [{ name: "key", value: "val", enabled: true }] }\n' +
'- "multipart/form-data": { form: [{ name: "field", value: "text", file: "/path/to/file", enabled: true }] }\n' +
'- text-based (application/json, etc.): { text: "raw body content" }',
);
export const authenticationTypeSchema = z
.string()
.optional()
.describe(
'Authentication type. Common values: "basic", "bearer", "oauth2", "apikey", "jwt", "awsv4", "oauth1", "ntlm", "none". Use null to inherit from parent.',
);
export const authenticationSchema = z
.record(z.string(), z.any())
.optional()
.describe(
'Authentication configuration object. Structure varies by authenticationType:\n' +
'- "basic": { username: "user", password: "pass" }\n' +
'- "bearer": { token: "abc123", prefix: "Bearer" }\n' +
'- "oauth2": { clientId: "...", clientSecret: "...", grantType: "authorization_code", authorizationUrl: "...", accessTokenUrl: "...", scope: "...", ... }\n' +
'- "apikey": { location: "header" | "query", key: "X-API-Key", value: "..." }\n' +
'- "jwt": { algorithm: "HS256", secret: "...", payload: "{ ... }" }\n' +
'- "awsv4": { accessKeyId: "...", secretAccessKey: "...", service: "sts", region: "us-east-1", sessionToken: "..." }\n' +
'- "none": {}',
);
@@ -1,161 +0,0 @@
import { open } from '@tauri-apps/plugin-dialog';
import { gitClone } from '@yaakapp-internal/git';
import { useState } from 'react';
import { openWorkspaceFromSyncDir } from '../commands/openWorkspaceFromSyncDir';
import { appInfo } from '../lib/appInfo';
import { showErrorToast } from '../lib/toast';
import { Banner } from './core/Banner';
import { Button } from './core/Button';
import { Checkbox } from './core/Checkbox';
import { IconButton } from './core/IconButton';
import { PlainInput } from './core/PlainInput';
import { VStack } from './core/Stacks';
import { promptCredentials } from './git/credentials';
interface Props {
hide: () => void;
}
// Detect path separator from an existing path (defaults to /)
function getPathSeparator(path: string): string {
return path.includes('\\') ? '\\' : '/';
}
export function CloneGitRepositoryDialog({ hide }: Props) {
const [url, setUrl] = useState<string>('');
const [baseDirectory, setBaseDirectory] = useState<string>(appInfo.defaultProjectDir);
const [directoryOverride, setDirectoryOverride] = useState<string | null>(null);
const [hasSubdirectory, setHasSubdirectory] = useState(false);
const [subdirectory, setSubdirectory] = useState<string>('');
const [isCloning, setIsCloning] = useState(false);
const [error, setError] = useState<string | null>(null);
const repoName = extractRepoName(url);
const sep = getPathSeparator(baseDirectory);
const computedDirectory = repoName ? `${baseDirectory}${sep}${repoName}` : baseDirectory;
const directory = directoryOverride ?? computedDirectory;
const workspaceDirectory =
hasSubdirectory && subdirectory ? `${directory}${sep}${subdirectory}` : directory;
const handleSelectDirectory = async () => {
const dir = await open({
title: 'Select Directory',
directory: true,
multiple: false,
});
if (dir != null) {
setBaseDirectory(dir);
setDirectoryOverride(null);
}
};
const handleClone = async (e: React.FormEvent) => {
e.preventDefault();
if (!url || !directory) return;
setIsCloning(true);
setError(null);
try {
const result = await gitClone(url, directory, promptCredentials);
if (result.type === 'needs_credentials') {
setError(
result.error ?? 'Authentication failed. Please check your credentials and try again.',
);
return;
}
// Open the workspace from the cloned directory (or subdirectory)
await openWorkspaceFromSyncDir.mutateAsync(workspaceDirectory);
hide();
} catch (err) {
setError(String(err));
showErrorToast({
id: 'git-clone-error',
title: 'Clone Failed',
message: String(err),
});
} finally {
setIsCloning(false);
}
};
return (
<VStack as="form" space={3} alignItems="start" className="pb-3" onSubmit={handleClone}>
{error && (
<Banner color="danger" className="w-full">
{error}
</Banner>
)}
<PlainInput
required
label="Repository URL"
placeholder="https://github.com/user/repo.git"
defaultValue={url}
onChange={setUrl}
/>
<PlainInput
label="Directory"
placeholder={appInfo.defaultProjectDir}
defaultValue={directory}
onChange={setDirectoryOverride}
rightSlot={
<IconButton
size="xs"
className="mr-0.5 !h-auto my-0.5"
icon="folder"
title="Browse"
onClick={handleSelectDirectory}
/>
}
/>
<Checkbox
checked={hasSubdirectory}
onChange={setHasSubdirectory}
title="Workspace is in a subdirectory"
help="Enable if the Yaak workspace files are not at the root of the repository"
/>
{hasSubdirectory && (
<PlainInput
label="Subdirectory"
placeholder="path/to/workspace"
defaultValue={subdirectory}
onChange={setSubdirectory}
/>
)}
<Button
type="submit"
color="primary"
className="w-full mt-3"
disabled={!url || !directory || isCloning}
isLoading={isCloning}
>
{isCloning ? 'Cloning...' : 'Clone Repository'}
</Button>
</VStack>
);
}
function extractRepoName(url: string): string {
// Handle various Git URL formats:
// https://github.com/user/repo.git
// git@github.com:user/repo.git
// https://github.com/user/repo
const match = url.match(/\/([^/]+?)(\.git)?$/);
if (match?.[1]) {
return match[1];
}
// Fallback for SSH-style URLs
const sshMatch = url.match(/:([^/]+?)(\.git)?$/);
if (sshMatch?.[1]) {
return sshMatch[1];
}
return '';
}
-181
View File
@@ -1,181 +0,0 @@
import type { DnsOverride, Workspace } from '@yaakapp-internal/models';
import { patchModel } from '@yaakapp-internal/models';
import { useCallback, useId, useMemo } from 'react';
import { Button } from './core/Button';
import { Checkbox } from './core/Checkbox';
import { IconButton } from './core/IconButton';
import { PlainInput } from './core/PlainInput';
import { HStack, VStack } from './core/Stacks';
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from './core/Table';
interface Props {
workspace: Workspace;
}
interface DnsOverrideWithId extends DnsOverride {
_id: string;
}
export function DnsOverridesEditor({ workspace }: Props) {
const reactId = useId();
// Ensure each override has an internal ID for React keys
const overridesWithIds = useMemo<DnsOverrideWithId[]>(() => {
return workspace.settingDnsOverrides.map((override, index) => ({
...override,
_id: `${reactId}-${index}`,
}));
}, [workspace.settingDnsOverrides, reactId]);
const handleChange = useCallback(
(overrides: DnsOverride[]) => {
patchModel(workspace, { settingDnsOverrides: overrides });
},
[workspace],
);
const handleAdd = useCallback(() => {
const newOverride: DnsOverride = {
hostname: '',
ipv4: [''],
ipv6: [],
enabled: true,
};
handleChange([...workspace.settingDnsOverrides, newOverride]);
}, [workspace.settingDnsOverrides, handleChange]);
const handleUpdate = useCallback(
(index: number, update: Partial<DnsOverride>) => {
const updated = workspace.settingDnsOverrides.map((o, i) =>
i === index ? { ...o, ...update } : o,
);
handleChange(updated);
},
[workspace.settingDnsOverrides, handleChange],
);
const handleDelete = useCallback(
(index: number) => {
const updated = workspace.settingDnsOverrides.filter((_, i) => i !== index);
handleChange(updated);
},
[workspace.settingDnsOverrides, handleChange],
);
return (
<VStack space={3} className="pb-3">
<div className="text-text-subtle text-sm">
Override DNS resolution for specific hostnames. This works like{' '}
<code className="text-text-subtlest bg-surface-highlight px-1 rounded">/etc/hosts</code>{' '}
but only for requests made from this workspace.
</div>
{overridesWithIds.length > 0 && (
<Table>
<TableHead>
<TableRow>
<TableHeaderCell className="w-8" />
<TableHeaderCell>Hostname</TableHeaderCell>
<TableHeaderCell>IPv4 Address</TableHeaderCell>
<TableHeaderCell>IPv6 Address</TableHeaderCell>
<TableHeaderCell className="w-10" />
</TableRow>
</TableHead>
<TableBody>
{overridesWithIds.map((override, index) => (
<DnsOverrideRow
key={override._id}
override={override}
onUpdate={(update) => handleUpdate(index, update)}
onDelete={() => handleDelete(index)}
/>
))}
</TableBody>
</Table>
)}
<HStack>
<Button size="xs" color="secondary" variant="border" onClick={handleAdd}>
Add DNS Override
</Button>
</HStack>
</VStack>
);
}
interface DnsOverrideRowProps {
override: DnsOverride;
onUpdate: (update: Partial<DnsOverride>) => void;
onDelete: () => void;
}
function DnsOverrideRow({ override, onUpdate, onDelete }: DnsOverrideRowProps) {
const ipv4Value = override.ipv4.join(', ');
const ipv6Value = override.ipv6.join(', ');
return (
<TableRow>
<TableCell>
<Checkbox
hideLabel
title={override.enabled ? 'Disable override' : 'Enable override'}
checked={override.enabled ?? true}
onChange={(enabled) => onUpdate({ enabled })}
/>
</TableCell>
<TableCell>
<PlainInput
size="sm"
hideLabel
label="Hostname"
placeholder="api.example.com"
defaultValue={override.hostname}
onChange={(hostname) => onUpdate({ hostname })}
/>
</TableCell>
<TableCell>
<PlainInput
size="sm"
hideLabel
label="IPv4 addresses"
placeholder="127.0.0.1"
defaultValue={ipv4Value}
onChange={(value) =>
onUpdate({
ipv4: value
.split(',')
.map((s) => s.trim())
.filter(Boolean),
})
}
/>
</TableCell>
<TableCell>
<PlainInput
size="sm"
hideLabel
label="IPv6 addresses"
placeholder="::1"
defaultValue={ipv6Value}
onChange={(value) =>
onUpdate({
ipv6: value
.split(',')
.map((s) => s.trim())
.filter(Boolean),
})
}
/>
</TableCell>
<TableCell>
<IconButton
size="xs"
iconSize="sm"
icon="trash"
title="Delete override"
onClick={onDelete}
/>
</TableCell>
</TableRow>
);
}
+5 -3
View File
@@ -1,6 +1,6 @@
import { createWorkspaceModel, foldersAtom, patchModel } from '@yaakapp-internal/models'; import { createWorkspaceModel, foldersAtom, patchModel } from '@yaakapp-internal/models';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import { useMemo } from 'react'; import { useMemo, useState } from 'react';
import { useAuthTab } from '../hooks/useAuthTab'; import { useAuthTab } from '../hooks/useAuthTab';
import { useEnvironmentsBreakdown } from '../hooks/useEnvironmentsBreakdown'; import { useEnvironmentsBreakdown } from '../hooks/useEnvironmentsBreakdown';
import { useHeadersTab } from '../hooks/useHeadersTab'; import { useHeadersTab } from '../hooks/useHeadersTab';
@@ -37,6 +37,7 @@ export type FolderSettingsTab =
export function FolderSettingsDialog({ folderId, tab }: Props) { export function FolderSettingsDialog({ folderId, tab }: Props) {
const folders = useAtomValue(foldersAtom); const folders = useAtomValue(foldersAtom);
const folder = folders.find((f) => f.id === folderId) ?? null; const folder = folders.find((f) => f.id === folderId) ?? null;
const [activeTab, setActiveTab] = useState<string>(tab ?? TAB_GENERAL);
const authTab = useAuthTab(TAB_AUTH, folder); const authTab = useAuthTab(TAB_AUTH, folder);
const headersTab = useHeadersTab(TAB_HEADERS, folder); const headersTab = useHeadersTab(TAB_HEADERS, folder);
const inheritedHeaders = useInheritedHeaders(folder); const inheritedHeaders = useInheritedHeaders(folder);
@@ -68,7 +69,8 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
return ( return (
<Tabs <Tabs
defaultValue={tab ?? TAB_GENERAL} value={activeTab}
onChangeValue={setActiveTab}
label="Folder Settings" label="Folder Settings"
className="pt-2 pb-2 pl-3 pr-1" className="pt-2 pb-2 pl-3 pr-1"
layout="horizontal" layout="horizontal"
@@ -111,7 +113,7 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
<VStack alignItems="center" space={1.5}> <VStack alignItems="center" space={1.5}>
<p> <p>
Override{' '} Override{' '}
<Link href="https://yaak.app/docs/using-yaak/environments-and-variables"> <Link href="https://feedback.yaak.app/help/articles/3284139-environments-and-variables">
Variables Variables
</Link>{' '} </Link>{' '}
for requests within this folder. for requests within this folder.
+7 -7
View File
@@ -10,7 +10,7 @@ import {
stateExtensions, stateExtensions,
updateSchema, updateSchema,
} from 'codemirror-json-schema'; } from 'codemirror-json-schema';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef } from 'react';
import type { ReflectResponseService } from '../hooks/useGrpc'; import type { ReflectResponseService } from '../hooks/useGrpc';
import { showAlert } from '../lib/alert'; import { showAlert } from '../lib/alert';
import { showDialog } from '../lib/dialog'; import { showDialog } from '../lib/dialog';
@@ -39,15 +39,15 @@ export function GrpcEditor({
protoFiles, protoFiles,
...extraEditorProps ...extraEditorProps
}: Props) { }: Props) {
const [editorView, setEditorView] = useState<EditorView | null>(null); const editorViewRef = useRef<EditorView>(null);
const handleInitEditorViewRef = useCallback((h: EditorView | null) => { const handleInitEditorViewRef = useCallback((h: EditorView | null) => {
setEditorView(h); editorViewRef.current = h;
}, []); }, []);
// Find the schema for the selected service and method and update the editor // Find the schema for the selected service and method and update the editor
useEffect(() => { useEffect(() => {
if ( if (
editorView == null || editorViewRef.current == null ||
services === null || services === null ||
request.service === null || request.service === null ||
request.method === null request.method === null
@@ -91,7 +91,7 @@ export function GrpcEditor({
} }
try { try {
updateSchema(editorView, JSON.parse(schema)); updateSchema(editorViewRef.current, JSON.parse(schema));
} catch (err) { } catch (err) {
showAlert({ showAlert({
id: 'grpc-parse-schema-error', id: 'grpc-parse-schema-error',
@@ -107,7 +107,7 @@ export function GrpcEditor({
), ),
}); });
} }
}, [editorView, services, request.method, request.service]); }, [services, request.method, request.service]);
const extraExtensions = useMemo( const extraExtensions = useMemo(
() => [ () => [
@@ -118,7 +118,7 @@ export function GrpcEditor({
jsonLanguage.data.of({ jsonLanguage.data.of({
autocomplete: jsonCompletion(), autocomplete: jsonCompletion(),
}), }),
stateExtensions({}), stateExtensions(/** Init with empty schema **/),
], ],
[], [],
); );
+17 -2
View File
@@ -7,6 +7,7 @@ import { useContainerSize } from '../hooks/useContainerQuery';
import type { ReflectResponseService } from '../hooks/useGrpc'; import type { ReflectResponseService } from '../hooks/useGrpc';
import { useHeadersTab } from '../hooks/useHeadersTab'; import { useHeadersTab } from '../hooks/useHeadersTab';
import { useInheritedHeaders } from '../hooks/useInheritedHeaders'; import { useInheritedHeaders } from '../hooks/useInheritedHeaders';
import { useKeyValue } from '../hooks/useKeyValue';
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey'; import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
import { resolvedModelName } from '../lib/resolvedModelName'; import { resolvedModelName } from '../lib/resolvedModelName';
import { Button } from './core/Button'; import { Button } from './core/Button';
@@ -68,6 +69,11 @@ export function GrpcRequestPane({
const authTab = useAuthTab(TAB_AUTH, activeRequest); const authTab = useAuthTab(TAB_AUTH, activeRequest);
const metadataTab = useHeadersTab(TAB_METADATA, activeRequest, 'Metadata'); const metadataTab = useHeadersTab(TAB_METADATA, activeRequest, 'Metadata');
const inheritedHeaders = useInheritedHeaders(activeRequest); const inheritedHeaders = useInheritedHeaders(activeRequest);
const { value: activeTabs, set: setActiveTabs } = useKeyValue<Record<string, string>>({
namespace: 'no_sync',
key: 'grpcRequestActiveTabs',
fallback: {},
});
const forceUpdateKey = useRequestUpdateKey(activeRequest.id ?? null); const forceUpdateKey = useRequestUpdateKey(activeRequest.id ?? null);
const urlContainerEl = useRef<HTMLDivElement>(null); const urlContainerEl = useRef<HTMLDivElement>(null);
@@ -139,6 +145,14 @@ export function GrpcRequestPane({
[activeRequest.description, authTab, metadataTab], [activeRequest.description, authTab, metadataTab],
); );
const activeTab = activeTabs?.[activeRequest.id];
const setActiveTab = useCallback(
async (tab: string) => {
await setActiveTabs((r) => ({ ...r, [activeRequest.id]: tab }));
},
[activeRequest.id, setActiveTabs],
);
const handleMetadataChange = useCallback( const handleMetadataChange = useCallback(
(metadata: HttpRequestHeader[]) => patchModel(activeRequest, { metadata }), (metadata: HttpRequestHeader[]) => patchModel(activeRequest, { metadata }),
[activeRequest], [activeRequest],
@@ -251,11 +265,12 @@ export function GrpcRequestPane({
</HStack> </HStack>
</div> </div>
<Tabs <Tabs
value={activeTab}
label="Request" label="Request"
onChangeValue={setActiveTab}
tabs={tabs} tabs={tabs}
tabListClassName="mt-1 !mb-1.5" tabListClassName="mt-1 !mb-1.5"
storageKey="grpc_request_tabs" storageKey="grpc_request_tabs_order"
activeTabKey={activeRequest.id}
> >
<TabContent value="message"> <TabContent value="message">
<GrpcEditor <GrpcEditor
+215 -174
View File
@@ -1,7 +1,9 @@
import type { GrpcEvent, GrpcRequest } from '@yaakapp-internal/models'; import type { GrpcEvent, GrpcRequest } from '@yaakapp-internal/models';
import classNames from 'classnames';
import { format } from 'date-fns';
import { useAtomValue, useSetAtom } from 'jotai'; import { useAtomValue, useSetAtom } from 'jotai';
import type { CSSProperties } from 'react'; import type { CSSProperties } from 'react';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { import {
activeGrpcConnectionAtom, activeGrpcConnectionAtom,
activeGrpcConnections, activeGrpcConnections,
@@ -9,14 +11,18 @@ import {
useGrpcEvents, useGrpcEvents,
} from '../hooks/usePinnedGrpcConnection'; } from '../hooks/usePinnedGrpcConnection';
import { useStateWithDeps } from '../hooks/useStateWithDeps'; import { useStateWithDeps } from '../hooks/useStateWithDeps';
import { copyToClipboard } from '../lib/copy';
import { AutoScroller } from './core/AutoScroller';
import { Banner } from './core/Banner';
import { Button } from './core/Button'; import { Button } from './core/Button';
import { Editor } from './core/Editor/LazyEditor'; import { Editor } from './core/Editor/LazyEditor';
import { EventDetailHeader, EventViewer } from './core/EventViewer';
import { EventViewerRow } from './core/EventViewerRow';
import { HotkeyList } from './core/HotkeyList'; import { HotkeyList } from './core/HotkeyList';
import { Icon, type IconProps } from './core/Icon'; import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { KeyValueRow, KeyValueRows } from './core/KeyValueRow'; import { KeyValueRow, KeyValueRows } from './core/KeyValueRow';
import { LoadingIcon } from './core/LoadingIcon'; import { LoadingIcon } from './core/LoadingIcon';
import { Separator } from './core/Separator';
import { SplitLayout } from './core/SplitLayout';
import { HStack, VStack } from './core/Stacks'; import { HStack, VStack } from './core/Stacks';
import { EmptyStateText } from './EmptyStateText'; import { EmptyStateText } from './EmptyStateText';
import { ErrorBoundary } from './ErrorBoundary'; import { ErrorBoundary } from './ErrorBoundary';
@@ -36,7 +42,7 @@ interface Props {
} }
export function GrpcResponsePane({ style, methodType, activeRequest }: Props) { export function GrpcResponsePane({ style, methodType, activeRequest }: Props) {
const [activeEventIndex, setActiveEventIndex] = useState<number | null>(null); const [activeEventId, setActiveEventId] = useState<string | null>(null);
const [showLarge, setShowLarge] = useStateWithDeps<boolean>(false, [activeRequest.id]); const [showLarge, setShowLarge] = useStateWithDeps<boolean>(false, [activeRequest.id]);
const [showingLarge, setShowingLarge] = useState<boolean>(false); const [showingLarge, setShowingLarge] = useState<boolean>(false);
const connections = useAtomValue(activeGrpcConnections); const connections = useAtomValue(activeGrpcConnections);
@@ -45,8 +51,8 @@ export function GrpcResponsePane({ style, methodType, activeRequest }: Props) {
const setPinnedGrpcConnectionId = useSetAtom(pinnedGrpcConnectionIdAtom); const setPinnedGrpcConnectionId = useSetAtom(pinnedGrpcConnectionIdAtom);
const activeEvent = useMemo( const activeEvent = useMemo(
() => (activeEventIndex != null ? events[activeEventIndex] : null), () => events.find((m) => m.id === activeEventId) ?? null,
[activeEventIndex, events], [activeEventId, events],
); );
// Set the active message to the first message received if unary // Set the active message to the first message received if unary
@@ -55,188 +61,223 @@ export function GrpcResponsePane({ style, methodType, activeRequest }: Props) {
if (events.length === 0 || activeEvent != null || methodType !== 'unary') { if (events.length === 0 || activeEvent != null || methodType !== 'unary') {
return; return;
} }
const firstServerMessageIndex = events.findIndex((m) => m.eventType === 'server_message'); setActiveEventId(events.find((m) => m.eventType === 'server_message')?.id ?? null);
if (firstServerMessageIndex !== -1) {
setActiveEventIndex(firstServerMessageIndex);
}
}, [events.length]); }, [events.length]);
if (activeConnection == null) {
return (
<HotkeyList hotkeys={['request.send', 'model.create', 'sidebar.focus', 'url_bar.focus']} />
);
}
const header = (
<HStack className="pl-3 mb-1 font-mono text-sm text-text-subtle overflow-x-auto hide-scrollbars">
<HStack space={2}>
<span className="whitespace-nowrap">{events.length} Messages</span>
{activeConnection.state !== 'closed' && (
<LoadingIcon size="sm" className="text-text-subtlest" />
)}
</HStack>
<div className="ml-auto">
<RecentGrpcConnectionsDropdown
connections={connections}
activeConnection={activeConnection}
onPinnedConnectionId={setPinnedGrpcConnectionId}
/>
</div>
</HStack>
);
return ( return (
<div style={style} className="h-full"> <SplitLayout
<ErrorBoundary name="GRPC Events"> layout="vertical"
<EventViewer style={style}
events={events} name="grpc_events"
getEventKey={(event) => event.id} defaultRatio={0.4}
error={activeConnection.error} minHeightPx={20}
header={header} firstSlot={() =>
splitLayoutName="grpc_events" activeConnection == null ? (
defaultRatio={0.4} <HotkeyList
renderRow={({ event, isActive, onClick }) => ( hotkeys={['request.send', 'model.create', 'sidebar.focus', 'url_bar.focus']}
<GrpcEventRow event={event} isActive={isActive} onClick={onClick} /> />
)} ) : (
renderDetail={({ event }) => ( <div className="w-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1 items-center">
<GrpcEventDetail <HStack className="pl-3 mb-1 font-mono text-sm text-text-subtle overflow-x-auto hide-scrollbars">
event={event} <HStack space={2}>
showLarge={showLarge} <span className="whitespace-nowrap">{events.length} Messages</span>
showingLarge={showingLarge} {activeConnection.state !== 'closed' && (
setShowLarge={setShowLarge} <LoadingIcon size="sm" className="text-text-subtlest" />
setShowingLarge={setShowingLarge} )}
/> </HStack>
)} <div className="ml-auto">
/> <RecentGrpcConnectionsDropdown
</ErrorBoundary> connections={connections}
</div> activeConnection={activeConnection}
); onPinnedConnectionId={setPinnedGrpcConnectionId}
} />
</div>
function GrpcEventRow({ </HStack>
event, <ErrorBoundary name="GRPC Events">
isActive, <AutoScroller
onClick, data={events}
}: { header={
event: GrpcEvent; activeConnection.error && (
isActive: boolean; <Banner color="danger" className="m-3">
onClick: () => void; {activeConnection.error}
}) { </Banner>
const { eventType, status, content, error } = event; )
const display = getEventDisplay(eventType, status); }
render={(event) => (
return ( <EventRow
<EventViewerRow key={event.id}
isActive={isActive} event={event}
onClick={onClick} isActive={event.id === activeEventId}
icon={<Icon color={display.color} title={display.title} icon={display.icon} />} onClick={() => {
content={ if (event.id === activeEventId) setActiveEventId(null);
<span className="text-xs"> else setActiveEventId(event.id);
{content.slice(0, 1000)} }}
{error && <span className="text-warning"> ({error})</span>} />
</span> )}
/>
</ErrorBoundary>
</div>
)
}
secondSlot={
activeEvent != null && activeConnection != null
? () => (
<div className="grid grid-rows-[auto_minmax(0,1fr)]">
<div className="pb-3 px-2">
<Separator />
</div>
<div className="h-full pl-2 overflow-y-auto grid grid-rows-[auto_minmax(0,1fr)] ">
{activeEvent.eventType === 'client_message' ||
activeEvent.eventType === 'server_message' ? (
<>
<div className="mb-2 select-text cursor-text grid grid-cols-[minmax(0,1fr)_auto] items-center">
<div className="font-semibold">
Message {activeEvent.eventType === 'client_message' ? 'Sent' : 'Received'}
</div>
<IconButton
title="Copy message"
icon="copy"
size="xs"
onClick={() => copyToClipboard(activeEvent.content)}
/>
</div>
{!showLarge && activeEvent.content.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
language="json"
defaultValue={activeEvent.content ?? ''}
wrapLines={false}
readOnly={true}
stateKey={null}
/>
)}
</>
) : (
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
<div>
<div className="select-text cursor-text font-semibold">
{activeEvent.content}
</div>
{activeEvent.error && (
<div className="select-text cursor-text text-sm font-mono py-1 text-warning">
{activeEvent.error}
</div>
)}
</div>
<div className="py-2 h-full">
{Object.keys(activeEvent.metadata).length === 0 ? (
<EmptyStateText>
No{' '}
{activeEvent.eventType === 'connection_end' ? 'trailers' : 'metadata'}
</EmptyStateText>
) : (
<KeyValueRows>
{Object.entries(activeEvent.metadata).map(([key, value]) => (
<KeyValueRow key={key} label={key}>
{value}
</KeyValueRow>
))}
</KeyValueRows>
)}
</div>
</div>
)}
</div>
</div>
)
: null
} }
timestamp={event.createdAt}
/> />
); );
} }
function GrpcEventDetail({ function EventRow({
onClick,
isActive,
event, event,
showLarge,
showingLarge,
setShowLarge,
setShowingLarge,
}: { }: {
onClick?: () => void;
isActive?: boolean;
event: GrpcEvent; event: GrpcEvent;
showLarge: boolean;
showingLarge: boolean;
setShowLarge: (v: boolean) => void;
setShowingLarge: (v: boolean) => void;
}) { }) {
if (event.eventType === 'client_message' || event.eventType === 'server_message') { const { eventType, status, createdAt, content, error } = event;
const title = `Message ${event.eventType === 'client_message' ? 'Sent' : 'Received'}`; const ref = useRef<HTMLDivElement>(null);
return (
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
<EventDetailHeader title={title} timestamp={event.createdAt} copyText={event.content} />
{!showLarge && event.content.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
language="json"
defaultValue={event.content ?? ''}
wrapLines={false}
readOnly={true}
stateKey={null}
/>
)}
</div>
);
}
// Error or connection_end - show metadata/trailers
return ( return (
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]"> <div className="px-1" ref={ref}>
<EventDetailHeader title={event.content} timestamp={event.createdAt} /> <button
{event.error && ( type="button"
<div className="select-text cursor-text text-sm font-mono py-1 text-warning"> onClick={onClick}
{event.error} className={classNames(
</div> 'w-full grid grid-cols-[auto_minmax(0,3fr)_auto] gap-2 items-center text-left',
)} 'px-1.5 h-xs font-mono cursor-default group focus:outline-none focus:text-text rounded',
<div className="py-2 h-full"> isActive && '!bg-surface-active !text-text',
{Object.keys(event.metadata).length === 0 ? ( 'text-text-subtle hover:text',
<EmptyStateText>
No {event.eventType === 'connection_end' ? 'trailers' : 'metadata'}
</EmptyStateText>
) : (
<KeyValueRows>
{Object.entries(event.metadata).map(([key, value]) => (
<KeyValueRow key={key} label={key}>
{value}
</KeyValueRow>
))}
</KeyValueRows>
)} )}
</div> >
<Icon
color={
eventType === 'server_message'
? 'info'
: eventType === 'client_message'
? 'primary'
: eventType === 'error' || (status != null && status > 0)
? 'danger'
: eventType === 'connection_end'
? 'success'
: undefined
}
title={
eventType === 'server_message'
? 'Server message'
: eventType === 'client_message'
? 'Client message'
: eventType === 'error' || (status != null && status > 0)
? 'Error'
: eventType === 'connection_end'
? 'Connection response'
: undefined
}
icon={
eventType === 'server_message'
? 'arrow_big_down_dash'
: eventType === 'client_message'
? 'arrow_big_up_dash'
: eventType === 'error' || (status != null && status > 0)
? 'alert_triangle'
: eventType === 'connection_end'
? 'check'
: 'info'
}
/>
<div className={classNames('w-full truncate text-xs')}>
{content.slice(0, 1000)}
{error && <span className="text-warning"> ({error})</span>}
</div>
<div className={classNames('opacity-50 text-xs')}>
{format(`${createdAt}Z`, 'HH:mm:ss.SSS')}
</div>
</button>
</div> </div>
); );
} }
function getEventDisplay(
eventType: GrpcEvent['eventType'],
status: GrpcEvent['status'],
): { icon: IconProps['icon']; color: IconProps['color']; title: string } {
if (eventType === 'server_message') {
return { icon: 'arrow_big_down_dash', color: 'info', title: 'Server message' };
}
if (eventType === 'client_message') {
return { icon: 'arrow_big_up_dash', color: 'primary', title: 'Client message' };
}
if (eventType === 'error' || (status != null && status > 0)) {
return { icon: 'alert_triangle', color: 'danger', title: 'Error' };
}
if (eventType === 'connection_end') {
return { icon: 'check', color: 'success', title: 'Connection response' };
}
return { icon: 'info', color: undefined, title: 'Event' };
}
+6 -21
View File
@@ -19,7 +19,6 @@ type Props = {
forceUpdateKey: string; forceUpdateKey: string;
headers: HttpRequestHeader[]; headers: HttpRequestHeader[];
inheritedHeaders?: HttpRequestHeader[]; inheritedHeaders?: HttpRequestHeader[];
inheritedHeadersLabel?: string;
stateKey: string; stateKey: string;
onChange: (headers: HttpRequestHeader[]) => void; onChange: (headers: HttpRequestHeader[]) => void;
label?: string; label?: string;
@@ -29,36 +28,20 @@ export function HeadersEditor({
stateKey, stateKey,
headers, headers,
inheritedHeaders, inheritedHeaders,
inheritedHeadersLabel = 'Inherited',
onChange, onChange,
forceUpdateKey, forceUpdateKey,
}: Props) { }: Props) {
// Get header names defined at current level (case-insensitive)
const currentHeaderNames = new Set(
headers.filter((h) => h.name).map((h) => h.name.toLowerCase()),
);
// Filter inherited headers: must be enabled, have content, and not be overridden by current level
const validInheritedHeaders = const validInheritedHeaders =
inheritedHeaders?.filter( inheritedHeaders?.filter((pair) => pair.enabled && (pair.name || pair.value)) ?? [];
(pair) =>
pair.enabled && (pair.name || pair.value) && !currentHeaderNames.has(pair.name.toLowerCase()),
) ?? [];
const hasInheritedHeaders = validInheritedHeaders.length > 0;
return ( return (
<div <div className="@container w-full h-full grid grid-rows-[auto_minmax(0,1fr)] gap-y-1.5">
className={ {validInheritedHeaders.length > 0 ? (
hasInheritedHeaders
? '@container w-full h-full grid grid-rows-[auto_minmax(0,1fr)] gap-y-1.5'
: '@container w-full h-full'
}
>
{hasInheritedHeaders && (
<DetailsBanner <DetailsBanner
color="secondary" color="secondary"
className="text-sm" className="text-sm"
summary={ summary={
<HStack> <HStack>
{inheritedHeadersLabel} <CountBadge count={validInheritedHeaders.length} /> Inherited <CountBadge count={validInheritedHeaders.length} />
</HStack> </HStack>
} }
> >
@@ -80,6 +63,8 @@ export function HeadersEditor({
))} ))}
</div> </div>
</DetailsBanner> </DetailsBanner>
) : (
<span />
)} )}
<PairOrBulkEditor <PairOrBulkEditor
forceUpdateKey={forceUpdateKey} forceUpdateKey={forceUpdateKey}
@@ -62,7 +62,7 @@ export function HttpAuthenticationEditor({ model }: Props) {
<p> <p>
Apply auth to all requests in <strong>{resolvedModelName(model)}</strong> Apply auth to all requests in <strong>{resolvedModelName(model)}</strong>
</p> </p>
<Link href="https://yaak.app/docs/using-yaak/request-inheritance"> <Link href="https://feedback.yaak.app/help/articles/2112119-request-inheritance">
Documentation Documentation
</Link> </Link>
</EmptyStateText> </EmptyStateText>
+35 -21
View File
@@ -4,7 +4,7 @@ import type { GenericCompletionOption } from '@yaakapp-internal/plugins';
import classNames from 'classnames'; import classNames from 'classnames';
import { atom, useAtomValue } from 'jotai'; import { atom, useAtomValue } from 'jotai';
import type { CSSProperties } from 'react'; import type { CSSProperties } from 'react';
import { lazy, Suspense, useCallback, useMemo, useRef, useState } from 'react'; import { lazy, Suspense, useCallback, useMemo, useState } from 'react';
import { activeRequestIdAtom } from '../hooks/useActiveRequestId'; import { activeRequestIdAtom } from '../hooks/useActiveRequestId';
import { allRequestsAtom } from '../hooks/useAllRequests'; import { allRequestsAtom } from '../hooks/useAllRequests';
import { useAuthTab } from '../hooks/useAuthTab'; import { useAuthTab } from '../hooks/useAuthTab';
@@ -12,6 +12,7 @@ import { useCancelHttpResponse } from '../hooks/useCancelHttpResponse';
import { useHeadersTab } from '../hooks/useHeadersTab'; import { useHeadersTab } from '../hooks/useHeadersTab';
import { useImportCurl } from '../hooks/useImportCurl'; import { useImportCurl } from '../hooks/useImportCurl';
import { useInheritedHeaders } from '../hooks/useInheritedHeaders'; import { useInheritedHeaders } from '../hooks/useInheritedHeaders';
import { useKeyValue } from '../hooks/useKeyValue';
import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse'; import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse';
import { useRequestEditor, useRequestEditorEvent } from '../hooks/useRequestEditor'; import { useRequestEditor, useRequestEditorEvent } from '../hooks/useRequestEditor';
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey'; import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
@@ -41,8 +42,8 @@ import { Editor } from './core/Editor/LazyEditor';
import { InlineCode } from './core/InlineCode'; import { InlineCode } from './core/InlineCode';
import type { Pair } from './core/PairEditor'; import type { Pair } from './core/PairEditor';
import { PlainInput } from './core/PlainInput'; import { PlainInput } from './core/PlainInput';
import type { TabItem, TabsRef } from './core/Tabs/Tabs'; import type { TabItem } from './core/Tabs/Tabs';
import { setActiveTab, TabContent, Tabs } from './core/Tabs/Tabs'; import { TabContent, Tabs } from './core/Tabs/Tabs';
import { EmptyStateText } from './EmptyStateText'; import { EmptyStateText } from './EmptyStateText';
import { FormMultipartEditor } from './FormMultipartEditor'; import { FormMultipartEditor } from './FormMultipartEditor';
import { FormUrlencodedEditor } from './FormUrlencodedEditor'; import { FormUrlencodedEditor } from './FormUrlencodedEditor';
@@ -69,7 +70,6 @@ const TAB_PARAMS = 'params';
const TAB_HEADERS = 'headers'; const TAB_HEADERS = 'headers';
const TAB_AUTH = 'auth'; const TAB_AUTH = 'auth';
const TAB_DESCRIPTION = 'description'; const TAB_DESCRIPTION = 'description';
const TABS_STORAGE_KEY = 'http_request_tabs';
const nonActiveRequestUrlsAtom = atom((get) => { const nonActiveRequestUrlsAtom = atom((get) => {
const activeRequestId = get(activeRequestIdAtom); const activeRequestId = get(activeRequestIdAtom);
@@ -83,20 +83,19 @@ const memoNotActiveRequestUrlsAtom = deepEqualAtom(nonActiveRequestUrlsAtom);
export function HttpRequestPane({ style, fullHeight, className, activeRequest }: Props) { export function HttpRequestPane({ style, fullHeight, className, activeRequest }: Props) {
const activeRequestId = activeRequest.id; const activeRequestId = activeRequest.id;
const tabsRef = useRef<TabsRef>(null); const { value: activeTabs, set: setActiveTabs } = useKeyValue<Record<string, string>>({
namespace: 'no_sync',
key: 'httpRequestActiveTabs',
fallback: {},
});
const [forceUpdateHeaderEditorKey, setForceUpdateHeaderEditorKey] = useState<number>(0); const [forceUpdateHeaderEditorKey, setForceUpdateHeaderEditorKey] = useState<number>(0);
const forceUpdateKey = useRequestUpdateKey(activeRequest.id ?? null); const forceUpdateKey = useRequestUpdateKey(activeRequest.id ?? null);
const [{ urlKey }, { forceUrlRefresh, forceParamsRefresh }] = useRequestEditor(); const [{ urlKey }, { focusParamsTab, forceUrlRefresh, forceParamsRefresh }] = useRequestEditor();
const contentType = getContentTypeFromHeaders(activeRequest.headers); const contentType = getContentTypeFromHeaders(activeRequest.headers);
const authTab = useAuthTab(TAB_AUTH, activeRequest); const authTab = useAuthTab(TAB_AUTH, activeRequest);
const headersTab = useHeadersTab(TAB_HEADERS, activeRequest); const headersTab = useHeadersTab(TAB_HEADERS, activeRequest);
const inheritedHeaders = useInheritedHeaders(activeRequest); const inheritedHeaders = useInheritedHeaders(activeRequest);
// Listen for event to focus the params tab (e.g., when clicking a :param in the URL)
useRequestEditorEvent('request_pane.focus_tab', () => {
tabsRef.current?.setActiveTab(TAB_PARAMS);
}, []);
const handleContentTypeChange = useCallback( const handleContentTypeChange = useCallback(
async (contentType: string | null, patch: Partial<Omit<HttpRequest, 'headers'>> = {}) => { async (contentType: string | null, patch: Partial<Omit<HttpRequest, 'headers'>> = {}) => {
if (activeRequest == null) { if (activeRequest == null) {
@@ -261,6 +260,18 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
[activeRequest], [activeRequest],
); );
const activeTab = activeTabs?.[activeRequestId];
const setActiveTab = useCallback(
async (tab: string) => {
await setActiveTabs((r) => ({ ...r, [activeRequest.id]: tab }));
},
[activeRequest.id, setActiveTabs],
);
useRequestEditorEvent('request_pane.focus_tab', async () => {
await setActiveTab(TAB_PARAMS);
});
const autocompleteUrls = useAtomValue(memoNotActiveRequestUrlsAtom); const autocompleteUrls = useAtomValue(memoNotActiveRequestUrlsAtom);
const autocomplete: GenericCompletionConfig = useMemo( const autocomplete: GenericCompletionConfig = useMemo(
@@ -287,11 +298,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
e.preventDefault(); // Prevent input onChange e.preventDefault(); // Prevent input onChange
await patchModel(activeRequest, patch); await patchModel(activeRequest, patch);
await setActiveTab({ focusParamsTab();
storageKey: TABS_STORAGE_KEY,
activeTabKey: activeRequestId,
value: TAB_PARAMS,
});
// Wait for request to update, then refresh the UI // Wait for request to update, then refresh the UI
// TODO: Somehow make this deterministic // TODO: Somehow make this deterministic
@@ -302,7 +309,14 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
} }
} }
}, },
[activeRequest, activeRequestId, forceParamsRefresh, forceUrlRefresh, importCurl], [
activeRequest,
activeRequestId,
focusParamsTab,
forceParamsRefresh,
forceUrlRefresh,
importCurl,
],
); );
const handleSend = useCallback( const handleSend = useCallback(
() => sendRequest(activeRequest.id ?? null), () => sendRequest(activeRequest.id ?? null),
@@ -340,12 +354,12 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
isLoading={activeResponse != null && activeResponse.state !== 'closed'} isLoading={activeResponse != null && activeResponse.state !== 'closed'}
/> />
<Tabs <Tabs
ref={tabsRef} value={activeTab}
label="Request" label="Request"
onChangeValue={setActiveTab}
tabs={tabs} tabs={tabs}
tabListClassName="mt-1 -mb-1.5" tabListClassName="mt-1 mb-1.5"
storageKey={TABS_STORAGE_KEY} storageKey="http_request_tabs_order"
activeTabKey={activeRequestId}
> >
<TabContent value={TAB_AUTH}> <TabContent value={TAB_AUTH}>
<HttpAuthenticationEditor model={activeRequest} /> <HttpAuthenticationEditor model={activeRequest} />
+39 -32
View File
@@ -1,15 +1,15 @@
import type { HttpResponse } from '@yaakapp-internal/models'; import type { HttpResponse } from '@yaakapp-internal/models';
import classNames from 'classnames'; import classNames from 'classnames';
import type { ComponentType, CSSProperties } from 'react'; import type { ComponentType, CSSProperties } from 'react';
import { lazy, Suspense, useMemo } from 'react'; import { lazy, Suspense, useCallback, useMemo } from 'react';
import { useLocalStorage } from 'react-use';
import { useCancelHttpResponse } from '../hooks/useCancelHttpResponse'; import { useCancelHttpResponse } from '../hooks/useCancelHttpResponse';
import { useHttpResponseEvents } from '../hooks/useHttpResponseEvents'; import { useHttpResponseEvents } from '../hooks/useHttpResponseEvents';
import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse'; import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse';
import { useResponseBodyBytes, useResponseBodyText } from '../hooks/useResponseBodyText'; import { useResponseBodyBytes, useResponseBodyText } from '../hooks/useResponseBodyText';
import { useResponseViewMode } from '../hooks/useResponseViewMode'; import { useResponseViewMode } from '../hooks/useResponseViewMode';
import { useTimelineViewMode } from '../hooks/useTimelineViewMode';
import { getMimeTypeFromContentType } from '../lib/contentType'; import { getMimeTypeFromContentType } from '../lib/contentType';
import { getContentTypeFromHeaders, getCookieCounts } from '../lib/model_util'; import { getContentTypeFromHeaders } from '../lib/model_util';
import { ConfirmLargeResponse } from './ConfirmLargeResponse'; import { ConfirmLargeResponse } from './ConfirmLargeResponse';
import { ConfirmLargeResponseRequest } from './ConfirmLargeResponseRequest'; import { ConfirmLargeResponseRequest } from './ConfirmLargeResponseRequest';
import { Banner } from './core/Banner'; import { Banner } from './core/Banner';
@@ -55,18 +55,32 @@ const TAB_HEADERS = 'headers';
const TAB_COOKIES = 'cookies'; const TAB_COOKIES = 'cookies';
const TAB_TIMELINE = 'timeline'; const TAB_TIMELINE = 'timeline';
export type TimelineViewMode = 'timeline' | 'text';
export function HttpResponsePane({ style, className, activeRequestId }: Props) { export function HttpResponsePane({ style, className, activeRequestId }: Props) {
const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId); const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId);
const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId); const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId);
const [timelineViewMode, setTimelineViewMode] = useTimelineViewMode(); const [activeTabs, setActiveTabs] = useLocalStorage<Record<string, string>>(
'responsePaneActiveTabs',
{},
);
const contentType = getContentTypeFromHeaders(activeResponse?.headers ?? null); const contentType = getContentTypeFromHeaders(activeResponse?.headers ?? null);
const mimeType = contentType == null ? null : getMimeTypeFromContentType(contentType).essence; const mimeType = contentType == null ? null : getMimeTypeFromContentType(contentType).essence;
const responseEvents = useHttpResponseEvents(activeResponse); const responseEvents = useHttpResponseEvents(activeResponse);
const cookieCounts = useMemo(() => getCookieCounts(responseEvents.data), [responseEvents.data]); const cookieCount = useMemo(() => {
if (!responseEvents.data) return 0;
let count = 0;
for (const event of responseEvents.data) {
const e = event.event;
if (
(e.type === 'header_up' && e.name.toLowerCase() === 'cookie') ||
(e.type === 'header_down' && e.name.toLowerCase() === 'set-cookie')
) {
count++;
}
}
return count;
}, [responseEvents.data]);
const tabs = useMemo<TabItem[]>( const tabs = useMemo<TabItem[]>(
() => [ () => [
@@ -78,9 +92,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
onChange: setViewMode, onChange: setViewMode,
items: [ items: [
{ label: 'Response', value: 'pretty' }, { label: 'Response', value: 'pretty' },
...(mimeType?.startsWith('image') ...(mimeType?.startsWith('image') ? [] : [{ label: 'Raw', value: 'raw' }]),
? []
: [{ label: 'Response (Raw)', shortLabel: 'Raw', value: 'raw' }]),
], ],
}, },
}, },
@@ -95,47 +107,40 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
label: 'Headers', label: 'Headers',
rightSlot: ( rightSlot: (
<CountBadge <CountBadge
count={activeResponse?.requestHeaders.length ?? 0}
count2={activeResponse?.headers.length ?? 0} count2={activeResponse?.headers.length ?? 0}
showZero count={activeResponse?.requestHeaders.length ?? 0}
/> />
), ),
}, },
{ {
value: TAB_COOKIES, value: TAB_COOKIES,
label: 'Cookies', label: 'Cookies',
rightSlot: rightSlot: cookieCount > 0 ? <CountBadge count={cookieCount} /> : null,
cookieCounts.sent > 0 || cookieCounts.received > 0 ? (
<CountBadge count={cookieCounts.sent} count2={cookieCounts.received} showZero />
) : null,
}, },
{ {
value: TAB_TIMELINE, value: TAB_TIMELINE,
label: 'Timeline',
rightSlot: <CountBadge count={responseEvents.data?.length ?? 0} />, rightSlot: <CountBadge count={responseEvents.data?.length ?? 0} />,
options: {
value: timelineViewMode,
onChange: (v) => setTimelineViewMode((v as TimelineViewMode) ?? 'timeline'),
items: [
{ label: 'Timeline', value: 'timeline' },
{ label: 'Timeline (Text)', shortLabel: 'Timeline', value: 'text' },
],
},
}, },
], ],
[ [
activeResponse?.headers, activeResponse?.headers,
activeResponse?.requestContentLength, activeResponse?.requestContentLength,
activeResponse?.requestHeaders.length, activeResponse?.requestHeaders.length,
cookieCounts.sent, cookieCount,
cookieCounts.received,
mimeType, mimeType,
responseEvents.data?.length, responseEvents.data?.length,
setViewMode, setViewMode,
viewMode, viewMode,
timelineViewMode,
setTimelineViewMode,
], ],
); );
const activeTab = activeTabs?.[activeRequestId];
const setActiveTab = useCallback(
(tab: string) => {
setActiveTabs((r) => ({ ...r, [activeRequestId]: tab }));
},
[activeRequestId, setActiveTabs],
);
const cancel = useCancelHttpResponse(activeResponse?.id ?? null); const cancel = useCancelHttpResponse(activeResponse?.id ?? null);
@@ -199,12 +204,14 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
)} )}
{/* Show tabs if we have any data (headers, body, etc.) even if there's an error */} {/* Show tabs if we have any data (headers, body, etc.) even if there's an error */}
<Tabs <Tabs
key={activeRequestId} // Freshen tabs on request change
value={activeTab}
onChangeValue={setActiveTab}
tabs={tabs} tabs={tabs}
label="Response" label="Response"
className="ml-3 mr-3 mb-3 min-h-0 flex-1" className="ml-3 mr-3 mb-3 min-h-0 flex-1"
tabListClassName="mt-0.5 -mb-1.5" tabListClassName="mt-0.5"
storageKey="http_response_tabs" storageKey="http_response_tabs_order"
activeTabKey={activeRequestId}
> >
<TabContent value={TAB_BODY}> <TabContent value={TAB_BODY}>
<ErrorBoundary name="Http Response Viewer"> <ErrorBoundary name="Http Response Viewer">
@@ -264,7 +271,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
<ResponseCookies response={activeResponse} /> <ResponseCookies response={activeResponse} />
</TabContent> </TabContent>
<TabContent value={TAB_TIMELINE}> <TabContent value={TAB_TIMELINE}>
<HttpResponseTimeline response={activeResponse} viewMode={timelineViewMode} /> <HttpResponseTimeline response={activeResponse} />
</TabContent> </TabContent>
</Tabs> </Tabs>
</div> </div>
+161 -210
View File
@@ -3,179 +3,186 @@ import type {
HttpResponseEvent, HttpResponseEvent,
HttpResponseEventData, HttpResponseEventData,
} from '@yaakapp-internal/models'; } from '@yaakapp-internal/models';
import classNames from 'classnames';
import { format } from 'date-fns';
import { type ReactNode, useMemo, useState } from 'react'; import { type ReactNode, useMemo, useState } from 'react';
import { useHttpResponseEvents } from '../hooks/useHttpResponseEvents'; import { useHttpResponseEvents } from '../hooks/useHttpResponseEvents';
import { Editor } from './core/Editor/LazyEditor'; import { AutoScroller } from './core/AutoScroller';
import { type EventDetailAction, EventDetailHeader, EventViewer } from './core/EventViewer'; import { Banner } from './core/Banner';
import { EventViewerRow } from './core/EventViewerRow';
import { HttpMethodTagRaw } from './core/HttpMethodTag'; import { HttpMethodTagRaw } from './core/HttpMethodTag';
import { HttpStatusTagRaw } from './core/HttpStatusTag'; import { HttpStatusTagRaw } from './core/HttpStatusTag';
import { Icon, type IconProps } from './core/Icon'; import { Icon, type IconProps } from './core/Icon';
import { KeyValueRow, KeyValueRows } from './core/KeyValueRow'; import { KeyValueRow, KeyValueRows } from './core/KeyValueRow';
import type { TimelineViewMode } from './HttpResponsePane'; import { Separator } from './core/Separator';
import { SplitLayout } from './core/SplitLayout';
interface Props { interface Props {
response: HttpResponse; response: HttpResponse;
viewMode: TimelineViewMode;
} }
export function HttpResponseTimeline({ response, viewMode }: Props) { export function HttpResponseTimeline({ response }: Props) {
return <Inner key={response.id} response={response} viewMode={viewMode} />; return <Inner key={response.id} response={response} />;
} }
function Inner({ response, viewMode }: Props) { function Inner({ response }: Props) {
const [showRaw, setShowRaw] = useState(false); const [activeEventIndex, setActiveEventIndex] = useState<number | null>(null);
const { data: events, error, isLoading } = useHttpResponseEvents(response); const { data: events, error, isLoading } = useHttpResponseEvents(response);
// Generate plain text representation of all events (with prefixes for timeline view) const activeEvent = useMemo(
const plainText = useMemo(() => { () => (activeEventIndex == null ? null : events?.[activeEventIndex]),
if (!events || events.length === 0) return ''; [activeEventIndex, events],
return events.map((event) => formatEventText(event.event, true)).join('\n'); );
}, [events]);
// Plain text view - show all events as text in an editor if (isLoading) {
if (viewMode === 'text') { return <div className="p-3 text-text-subtlest italic">Loading events...</div>;
if (isLoading) { }
return <div className="p-4 text-text-subtlest">Loading events...</div>;
} else if (error) { if (error) {
return <div className="p-4 text-danger">{String(error)}</div>; return (
} else if (!events || events.length === 0) { <Banner color="danger" className="m-3">
return <div className="p-4 text-text-subtlest">No events recorded</div>; {String(error)}
} else { </Banner>
return ( );
<Editor language="timeline" defaultValue={plainText} readOnly stateKey={null} hideGutter /> }
);
} if (!events || events.length === 0) {
return <div className="p-3 text-text-subtlest italic">No events recorded</div>;
} }
return ( return (
<EventViewer <SplitLayout
events={events ?? []} layout="vertical"
getEventKey={(event) => event.id} name="http_response_events"
error={error ? String(error) : null}
isLoading={isLoading}
loadingMessage="Loading events..."
emptyMessage="No events recorded"
splitLayoutName="http_response_events"
defaultRatio={0.25} defaultRatio={0.25}
renderRow={({ event, isActive, onClick }) => { minHeightPx={10}
const display = getEventDisplay(event.event); firstSlot={() => (
return ( <AutoScroller
<EventViewerRow data={events}
isActive={isActive} render={(event, i) => (
onClick={onClick} <EventRow
icon={<Icon color={display.color} icon={display.icon} size="sm" />} key={event.id}
content={display.summary} event={event}
timestamp={event.createdAt} isActive={i === activeEventIndex}
/> onClick={() => {
); if (i === activeEventIndex) setActiveEventIndex(null);
}} else setActiveEventIndex(i);
renderDetail={({ event, onClose }) => ( }}
<EventDetails event={event} showRaw={showRaw} setShowRaw={setShowRaw} onClose={onClose} /> />
)}
/>
)} )}
secondSlot={
activeEvent
? () => (
<div className="grid grid-rows-[auto_minmax(0,1fr)]">
<div className="pb-3 px-2">
<Separator />
</div>
<div className="mx-2 overflow-y-auto">
<EventDetails event={activeEvent} />
</div>
</div>
)
: null
}
/> />
); );
} }
function EventRow({
onClick,
isActive,
event,
}: {
onClick: () => void;
isActive: boolean;
event: HttpResponseEvent;
}) {
const display = getEventDisplay(event.event);
const { icon, color, summary } = display;
return (
<div className="px-1">
<button
type="button"
onClick={onClick}
className={classNames(
'w-full grid grid-cols-[auto_minmax(0,1fr)_auto] gap-2 items-center text-left',
'px-1.5 h-xs font-mono text-editor cursor-default group focus:outline-none focus:text-text rounded',
isActive && '!bg-surface-active !text-text',
'text-text-subtle hover:text',
)}
>
<Icon color={color} icon={icon} size="sm" />
<div className="w-full truncate">{summary}</div>
<div className="opacity-50">{format(`${event.createdAt}Z`, 'HH:mm:ss.SSS')}</div>
</button>
</div>
);
}
function formatBytes(bytes: number): string { function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`; if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
} }
function EventDetails({ function EventDetails({ event }: { event: HttpResponseEvent }) {
event,
showRaw,
setShowRaw,
onClose,
}: {
event: HttpResponseEvent;
showRaw: boolean;
setShowRaw: (v: boolean) => void;
onClose: () => void;
}) {
const { label } = getEventDisplay(event.event); const { label } = getEventDisplay(event.event);
const timestamp = format(new Date(`${event.createdAt}Z`), 'HH:mm:ss.SSS');
const e = event.event; const e = event.event;
const actions: EventDetailAction[] = [ // Headers - show name and value with Editor for JSON
{ if (e.type === 'header_up' || e.type === 'header_down') {
key: 'toggle-raw', return (
label: showRaw ? 'Formatted' : 'Text', <div className="flex flex-col gap-2 h-full">
onClick: () => setShowRaw(!showRaw), <DetailHeader
}, title={e.type === 'header_down' ? 'Header Received' : 'Header Sent'}
]; timestamp={timestamp}
/>
// Determine the title based on event type
const title = (() => {
switch (e.type) {
case 'header_up':
return 'Header Sent';
case 'header_down':
return 'Header Received';
case 'send_url':
return 'Request';
case 'receive_url':
return 'Response';
case 'redirect':
return 'Redirect';
case 'setting':
return 'Apply Setting';
case 'chunk_sent':
return 'Data Sent';
case 'chunk_received':
return 'Data Received';
case 'dns_resolved':
return e.overridden ? 'DNS Override' : 'DNS Resolution';
default:
return label;
}
})();
// Render content based on view mode and event type
const renderContent = () => {
// Raw view - show plaintext representation (without prefix)
if (showRaw) {
const rawText = formatEventText(event.event, false);
return <Editor language="text" defaultValue={rawText} readOnly stateKey={null} hideGutter />;
}
// Headers - show name and value
if (e.type === 'header_up' || e.type === 'header_down') {
return (
<KeyValueRows> <KeyValueRows>
<KeyValueRow label="Header">{e.name}</KeyValueRow> <KeyValueRow label="Header">{e.name}</KeyValueRow>
<KeyValueRow label="Value">{e.value}</KeyValueRow> <KeyValueRow label="Value">{e.value}</KeyValueRow>
</KeyValueRows> </KeyValueRows>
); </div>
} );
}
// Request URL - show method and path separately // Request URL - show method and path separately
if (e.type === 'send_url') { if (e.type === 'send_url') {
return ( return (
<div className="flex flex-col gap-2">
<DetailHeader title="Request" timestamp={timestamp} />
<KeyValueRows> <KeyValueRows>
<KeyValueRow label="Method"> <KeyValueRow label="Method">
<HttpMethodTagRaw forceColor method={e.method} /> <HttpMethodTagRaw forceColor method={e.method} />
</KeyValueRow> </KeyValueRow>
<KeyValueRow label="Path">{e.path}</KeyValueRow> <KeyValueRow label="Path">{e.path}</KeyValueRow>
</KeyValueRows> </KeyValueRows>
); </div>
} );
}
// Response status - show version and status separately // Response status - show version and status separately
if (e.type === 'receive_url') { if (e.type === 'receive_url') {
return ( return (
<div className="flex flex-col gap-2">
<DetailHeader title="Response" timestamp={timestamp} />
<KeyValueRows> <KeyValueRows>
<KeyValueRow label="HTTP Version">{e.version}</KeyValueRow> <KeyValueRow label="HTTP Version">{e.version}</KeyValueRow>
<KeyValueRow label="Status"> <KeyValueRow label="Status">
<HttpStatusTagRaw status={e.status} /> <HttpStatusTagRaw status={e.status} />
</KeyValueRow> </KeyValueRow>
</KeyValueRows> </KeyValueRows>
); </div>
} );
}
// Redirect - show status, URL, and behavior // Redirect - show status, URL, and behavior
if (e.type === 'redirect') { if (e.type === 'redirect') {
return ( return (
<div className="flex flex-col gap-2">
<DetailHeader title="Redirect" timestamp={timestamp} />
<KeyValueRows> <KeyValueRows>
<KeyValueRow label="Status"> <KeyValueRow label="Status">
<HttpStatusTagRaw status={e.status} /> <HttpStatusTagRaw status={e.status} />
@@ -185,98 +192,51 @@ function EventDetails({
{e.behavior === 'drop_body' ? 'Drop body, change to GET' : 'Preserve method and body'} {e.behavior === 'drop_body' ? 'Drop body, change to GET' : 'Preserve method and body'}
</KeyValueRow> </KeyValueRow>
</KeyValueRows> </KeyValueRows>
); </div>
} );
}
// Settings - show as key/value // Settings - show as key/value
if (e.type === 'setting') { if (e.type === 'setting') {
return ( return (
<div className="flex flex-col gap-2">
<DetailHeader title="Apply Setting" timestamp={timestamp} />
<KeyValueRows> <KeyValueRows>
<KeyValueRow label="Setting">{e.name}</KeyValueRow> <KeyValueRow label="Setting">{e.name}</KeyValueRow>
<KeyValueRow label="Value">{e.value}</KeyValueRow> <KeyValueRow label="Value">{e.value}</KeyValueRow>
</KeyValueRows> </KeyValueRows>
); </div>
} );
}
// Chunks - show formatted bytes // Chunks - show formatted bytes
if (e.type === 'chunk_sent' || e.type === 'chunk_received') { if (e.type === 'chunk_sent' || e.type === 'chunk_received') {
return <div className="font-mono text-editor">{formatBytes(e.bytes)}</div>; const direction = e.type === 'chunk_sent' ? 'Sent' : 'Received';
} return (
<div className="flex flex-col gap-2">
<DetailHeader title={`Data ${direction}`} timestamp={timestamp} />
<div className="font-mono text-editor">{formatBytes(e.bytes)}</div>
</div>
);
}
// DNS Resolution - show hostname, addresses, and timing // Default - use summary
if (e.type === 'dns_resolved') { const { summary } = getEventDisplay(event.event);
return (
<KeyValueRows>
<KeyValueRow label="Hostname">{e.hostname}</KeyValueRow>
<KeyValueRow label="Addresses">{e.addresses.join(', ')}</KeyValueRow>
<KeyValueRow label="Duration">
{e.overridden ? (
<span className="text-text-subtlest">--</span>
) : (
`${String(e.duration)}ms`
)}
</KeyValueRow>
{e.overridden ? <KeyValueRow label="Source">Workspace Override</KeyValueRow> : null}
</KeyValueRows>
);
}
// Default - use summary
const { summary } = getEventDisplay(event.event);
return <div className="font-mono text-editor">{summary}</div>;
};
return ( return (
<div className="flex flex-col gap-2 h-full"> <div className="flex flex-col gap-1">
<EventDetailHeader <DetailHeader title={label} timestamp={timestamp} />
title={title} <div className="font-mono text-editor">{summary}</div>
timestamp={event.createdAt}
actions={actions}
onClose={onClose}
/>
{renderContent()}
</div> </div>
); );
} }
type EventTextParts = { prefix: '>' | '<' | '*'; text: string }; function DetailHeader({ title, timestamp }: { title: string; timestamp: string }) {
return (
/** Get the prefix and text for an event */ <div className="flex items-center justify-between gap-2">
function getEventTextParts(event: HttpResponseEventData): EventTextParts { <h3 className="font-semibold select-auto cursor-auto">{title}</h3>
switch (event.type) { <span className="text-text-subtlest font-mono text-editor">{timestamp}</span>
case 'send_url': </div>
return { prefix: '>', text: `${event.method} ${event.path}` }; );
case 'receive_url':
return { prefix: '<', text: `${event.version} ${event.status}` };
case 'header_up':
return { prefix: '>', text: `${event.name}: ${event.value}` };
case 'header_down':
return { prefix: '<', text: `${event.name}: ${event.value}` };
case 'redirect': {
const behavior = event.behavior === 'drop_body' ? 'drop body' : 'preserve';
return { prefix: '*', text: `Redirect ${event.status} -> ${event.url} (${behavior})` };
}
case 'setting':
return { prefix: '*', text: `Setting ${event.name}=${event.value}` };
case 'info':
return { prefix: '*', text: event.message };
case 'chunk_sent':
return { prefix: '*', text: `[${formatBytes(event.bytes)} sent]` };
case 'chunk_received':
return { prefix: '*', text: `[${formatBytes(event.bytes)} received]` };
case 'dns_resolved':
if (event.overridden) {
return { prefix: '*', text: `DNS override ${event.hostname} -> ${event.addresses.join(', ')}` };
}
return { prefix: '*', text: `DNS resolved ${event.hostname} to ${event.addresses.join(', ')} (${event.duration}ms)` };
default:
return { prefix: '*', text: '[unknown event]' };
}
}
/** Format event as plaintext, optionally with curl-style prefix (> outgoing, < incoming, * info) */
function formatEventText(event: HttpResponseEventData, includePrefix: boolean): string {
const { prefix, text } = getEventTextParts(event);
return includePrefix ? `${prefix} ${text}` : text;
} }
type EventDisplay = { type EventDisplay = {
@@ -305,7 +265,7 @@ function getEventDisplay(event: HttpResponseEventData): EventDisplay {
case 'redirect': case 'redirect':
return { return {
icon: 'arrow_big_right_dash', icon: 'arrow_big_right_dash',
color: 'success', color: 'warning',
label: 'Redirect', label: 'Redirect',
summary: `Redirecting ${event.status} ${event.url}${event.behavior === 'drop_body' ? ' (drop body)' : ''}`, summary: `Redirecting ${event.status} ${event.url}${event.behavior === 'drop_body' ? ' (drop body)' : ''}`,
}; };
@@ -352,15 +312,6 @@ function getEventDisplay(event: HttpResponseEventData): EventDisplay {
label: 'Chunk', label: 'Chunk',
summary: `${formatBytes(event.bytes)} chunk received`, summary: `${formatBytes(event.bytes)} chunk received`,
}; };
case 'dns_resolved':
return {
icon: 'globe',
color: event.overridden ? 'success' : 'secondary',
label: event.overridden ? 'DNS Override' : 'DNS',
summary: event.overridden
? `${event.hostname}${event.addresses.join(', ')} (overridden)`
: `${event.hostname}${event.addresses.join(', ')} (${event.duration}ms)`,
};
default: default:
return { return {
icon: 'info', icon: 'info',
+1 -1
View File
@@ -71,7 +71,7 @@ export const RequestMethodDropdown = memo(function RequestMethodDropdown({
onChange={handleChange} onChange={handleChange}
> >
<Button size="xs" className={classNames(className, 'text-text-subtle hover:text')}> <Button size="xs" className={classNames(className, 'text-text-subtle hover:text')}>
<HttpMethodTag request={request} noAlias /> <HttpMethodTag request={request} />
</Button> </Button>
</RadioDropdown> </RadioDropdown>
); );
+5 -2
View File
@@ -5,6 +5,7 @@ import { useLicense } from '@yaakapp-internal/license';
import { pluginsAtom, settingsAtom } from '@yaakapp-internal/models'; import { pluginsAtom, settingsAtom } from '@yaakapp-internal/models';
import classNames from 'classnames'; import classNames from 'classnames';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import { useState } from 'react';
import { useKeyPressEvent } from 'react-use'; import { useKeyPressEvent } from 'react-use';
import { appInfo } from '../../lib/appInfo'; import { appInfo } from '../../lib/appInfo';
import { capitalize } from '../../lib/capitalize'; import { capitalize } from '../../lib/capitalize';
@@ -50,6 +51,7 @@ export default function Settings({ hide }: Props) {
const { tab: tabFromQuery } = useSearch({ from: '/workspaces/$workspaceId/settings' }); const { tab: tabFromQuery } = useSearch({ from: '/workspaces/$workspaceId/settings' });
// Parse tab and subtab (e.g., "plugins:installed") // Parse tab and subtab (e.g., "plugins:installed")
const [mainTab, subtab] = tabFromQuery?.split(':') ?? []; const [mainTab, subtab] = tabFromQuery?.split(':') ?? [];
const [tab, setTab] = useState<string | undefined>(mainTab || tabFromQuery);
const settings = useAtomValue(settingsAtom); const settings = useAtomValue(settingsAtom);
const plugins = useAtomValue(pluginsAtom); const plugins = useAtomValue(pluginsAtom);
const licenseCheck = useLicense(); const licenseCheck = useLicense();
@@ -89,10 +91,11 @@ export default function Settings({ hide }: Props) {
)} )}
<Tabs <Tabs
layout="horizontal" layout="horizontal"
defaultValue={mainTab || tabFromQuery} value={tab}
addBorders addBorders
tabListClassName="min-w-[10rem] bg-surface x-theme-sidebar border-r border-border pl-3" tabListClassName="min-w-[10rem] bg-surface x-theme-sidebar border-r border-border pl-3"
label="Settings" label="Settings"
onChangeValue={setTab}
tabs={tabs.map( tabs={tabs.map(
(value): TabItem => ({ (value): TabItem => ({
value, value,
@@ -142,7 +145,7 @@ export default function Settings({ hide }: Props) {
<SettingsHotkeys /> <SettingsHotkeys />
</TabContent> </TabContent>
<TabContent value={TAB_PLUGINS} className="h-full grid grid-rows-1 px-6 !py-4"> <TabContent value={TAB_PLUGINS} className="h-full grid grid-rows-1 px-6 !py-4">
<SettingsPlugins defaultSubtab={mainTab === TAB_PLUGINS ? subtab : undefined} /> <SettingsPlugins defaultSubtab={tab === TAB_PLUGINS ? subtab : undefined} />
</TabContent> </TabContent>
<TabContent value={TAB_PROXY} className="overflow-y-auto h-full px-6 !py-4"> <TabContent value={TAB_PROXY} className="overflow-y-auto h-full px-6 !py-4">
<SettingsProxy /> <SettingsProxy />
@@ -54,11 +54,13 @@ export function SettingsPlugins({ defaultSubtab }: SettingsPluginsProps) {
const installedPlugins = plugins.filter((p) => !isPluginBundled(p, appInfo.vendoredPluginDir)); const installedPlugins = plugins.filter((p) => !isPluginBundled(p, appInfo.vendoredPluginDir));
const createPlugin = useInstallPlugin(); const createPlugin = useInstallPlugin();
const refreshPlugins = useRefreshPlugins(); const refreshPlugins = useRefreshPlugins();
const [tab, setTab] = useState<string | undefined>(defaultSubtab);
return ( return (
<div className="h-full"> <div className="h-full">
<Tabs <Tabs
defaultValue={defaultSubtab} value={tab}
label="Plugins" label="Plugins"
onChangeValue={setTab}
addBorders addBorders
tabs={[ tabs={[
{ label: 'Discover', value: 'search' }, { label: 'Discover', value: 'search' },
@@ -115,7 +117,7 @@ export function SettingsPlugins({ defaultSubtab }: SettingsPluginsProps) {
icon="help" icon="help"
title="View documentation" title="View documentation"
onClick={() => onClick={() =>
openUrl('https://yaak.app/docs/plugin-development/plugins-quick-start') openUrl('https://feedback.yaak.app/help/articles/6911763-quick-start')
} }
/> />
</HStack> </HStack>
@@ -75,7 +75,7 @@ export function SettingsTheme() {
<Heading>Theme</Heading> <Heading>Theme</Heading>
<p className="text-text-subtle"> <p className="text-text-subtle">
Make Yaak your own by selecting a theme, or{' '} Make Yaak your own by selecting a theme, or{' '}
<Link href="https://yaak.app/docs/plugin-development/plugins-quick-start"> <Link href="https://feedback.yaak.app/help/articles/6911763-plugins-quick-start">
Create Your Own Create Your Own
</Link> </Link>
</p> </p>
+27 -20
View File
@@ -5,7 +5,7 @@ import { closeWebsocket, connectWebsocket, sendWebsocket } from '@yaakapp-intern
import classNames from 'classnames'; import classNames from 'classnames';
import { atom, useAtomValue } from 'jotai'; import { atom, useAtomValue } from 'jotai';
import type { CSSProperties } from 'react'; import type { CSSProperties } from 'react';
import { useCallback, useMemo, useRef } from 'react'; import { useCallback, useMemo } from 'react';
import { getActiveCookieJar } from '../hooks/useActiveCookieJar'; import { getActiveCookieJar } from '../hooks/useActiveCookieJar';
import { getActiveEnvironment } from '../hooks/useActiveEnvironment'; import { getActiveEnvironment } from '../hooks/useActiveEnvironment';
import { activeRequestIdAtom } from '../hooks/useActiveRequestId'; import { activeRequestIdAtom } from '../hooks/useActiveRequestId';
@@ -14,6 +14,7 @@ import { useAuthTab } from '../hooks/useAuthTab';
import { useCancelHttpResponse } from '../hooks/useCancelHttpResponse'; import { useCancelHttpResponse } from '../hooks/useCancelHttpResponse';
import { useHeadersTab } from '../hooks/useHeadersTab'; import { useHeadersTab } from '../hooks/useHeadersTab';
import { useInheritedHeaders } from '../hooks/useInheritedHeaders'; import { useInheritedHeaders } from '../hooks/useInheritedHeaders';
import { useKeyValue } from '../hooks/useKeyValue';
import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse'; import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse';
import { activeWebsocketConnectionAtom } from '../hooks/usePinnedWebsocketConnection'; import { activeWebsocketConnectionAtom } from '../hooks/usePinnedWebsocketConnection';
import { useRequestEditor, useRequestEditorEvent } from '../hooks/useRequestEditor'; import { useRequestEditor, useRequestEditorEvent } from '../hooks/useRequestEditor';
@@ -29,8 +30,8 @@ import { Editor } from './core/Editor/LazyEditor';
import { IconButton } from './core/IconButton'; import { IconButton } from './core/IconButton';
import type { Pair } from './core/PairEditor'; import type { Pair } from './core/PairEditor';
import { PlainInput } from './core/PlainInput'; import { PlainInput } from './core/PlainInput';
import type { TabItem, TabsRef } from './core/Tabs/Tabs'; import type { TabItem } from './core/Tabs/Tabs';
import { setActiveTab, TabContent, Tabs } from './core/Tabs/Tabs'; import { TabContent, Tabs } from './core/Tabs/Tabs';
import { HeadersEditor } from './HeadersEditor'; import { HeadersEditor } from './HeadersEditor';
import { HttpAuthenticationEditor } from './HttpAuthenticationEditor'; import { HttpAuthenticationEditor } from './HttpAuthenticationEditor';
import { MarkdownEditor } from './MarkdownEditor'; import { MarkdownEditor } from './MarkdownEditor';
@@ -49,7 +50,6 @@ const TAB_PARAMS = 'params';
const TAB_HEADERS = 'headers'; const TAB_HEADERS = 'headers';
const TAB_AUTH = 'auth'; const TAB_AUTH = 'auth';
const TAB_DESCRIPTION = 'description'; const TAB_DESCRIPTION = 'description';
const TABS_STORAGE_KEY = 'websocket_request_tabs';
const nonActiveRequestUrlsAtom = atom((get) => { const nonActiveRequestUrlsAtom = atom((get) => {
const activeRequestId = get(activeRequestIdAtom); const activeRequestId = get(activeRequestIdAtom);
@@ -63,18 +63,17 @@ const memoNotActiveRequestUrlsAtom = deepEqualAtom(nonActiveRequestUrlsAtom);
export function WebsocketRequestPane({ style, fullHeight, className, activeRequest }: Props) { export function WebsocketRequestPane({ style, fullHeight, className, activeRequest }: Props) {
const activeRequestId = activeRequest.id; const activeRequestId = activeRequest.id;
const tabsRef = useRef<TabsRef>(null); const { value: activeTabs, set: setActiveTabs } = useKeyValue<Record<string, string>>({
namespace: 'no_sync',
key: 'websocketRequestActiveTabs',
fallback: {},
});
const forceUpdateKey = useRequestUpdateKey(activeRequest.id); const forceUpdateKey = useRequestUpdateKey(activeRequest.id);
const [{ urlKey }, { forceUrlRefresh, forceParamsRefresh }] = useRequestEditor(); const [{ urlKey }, { focusParamsTab, forceUrlRefresh, forceParamsRefresh }] = useRequestEditor();
const authTab = useAuthTab(TAB_AUTH, activeRequest); const authTab = useAuthTab(TAB_AUTH, activeRequest);
const headersTab = useHeadersTab(TAB_HEADERS, activeRequest); const headersTab = useHeadersTab(TAB_HEADERS, activeRequest);
const inheritedHeaders = useInheritedHeaders(activeRequest); const inheritedHeaders = useInheritedHeaders(activeRequest);
// Listen for event to focus the params tab (e.g., when clicking a :param in the URL)
useRequestEditorEvent('request_pane.focus_tab', () => {
tabsRef.current?.setActiveTab(TAB_PARAMS);
}, []);
const { urlParameterPairs, urlParametersKey } = useMemo(() => { const { urlParameterPairs, urlParametersKey } = useMemo(() => {
const placeholderNames = Array.from(activeRequest.url.matchAll(/\/(:[^/]+)/g)).map( const placeholderNames = Array.from(activeRequest.url.matchAll(/\/(:[^/]+)/g)).map(
(m) => m[1] ?? '', (m) => m[1] ?? '',
@@ -116,6 +115,18 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null); const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null);
const connection = useAtomValue(activeWebsocketConnectionAtom); const connection = useAtomValue(activeWebsocketConnectionAtom);
const activeTab = activeTabs?.[activeRequestId];
const setActiveTab = useCallback(
async (tab: string) => {
await setActiveTabs((r) => ({ ...r, [activeRequest.id]: tab }));
},
[activeRequest.id, setActiveTabs],
);
useRequestEditorEvent('request_pane.focus_tab', async () => {
await setActiveTab(TAB_PARAMS);
});
const autocompleteUrls = useAtomValue(memoNotActiveRequestUrlsAtom); const autocompleteUrls = useAtomValue(memoNotActiveRequestUrlsAtom);
const autocomplete: GenericCompletionConfig = useMemo( const autocomplete: GenericCompletionConfig = useMemo(
@@ -165,11 +176,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
e.preventDefault(); // Prevent input onChange e.preventDefault(); // Prevent input onChange
await patchModel(activeRequest, patch); await patchModel(activeRequest, patch);
await setActiveTab({ focusParamsTab();
storageKey: TABS_STORAGE_KEY,
activeTabKey: activeRequestId,
value: TAB_PARAMS,
});
// Wait for request to update, then refresh the UI // Wait for request to update, then refresh the UI
// TODO: Somehow make this deterministic // TODO: Somehow make this deterministic
@@ -179,7 +186,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
}, 100); }, 100);
} }
}, },
[activeRequest, activeRequestId, forceParamsRefresh, forceUrlRefresh], [activeRequest, focusParamsTab, forceParamsRefresh, forceUrlRefresh],
); );
const messageLanguage = languageFromContentType(null, activeRequest.message); const messageLanguage = languageFromContentType(null, activeRequest.message);
@@ -222,12 +229,12 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
/> />
</div> </div>
<Tabs <Tabs
ref={tabsRef} value={activeTab}
label="Request" label="Request"
onChangeValue={setActiveTab}
tabs={tabs} tabs={tabs}
tabListClassName="mt-1 !mb-1.5" tabListClassName="mt-1 !mb-1.5"
storageKey={TABS_STORAGE_KEY} storageKey="websocket_request_tabs_order"
activeTabKey={activeRequestId}
> >
<TabContent value={TAB_AUTH}> <TabContent value={TAB_AUTH}>
<HttpAuthenticationEditor model={activeRequest} /> <HttpAuthenticationEditor model={activeRequest} />
+209 -175
View File
@@ -1,7 +1,9 @@
import type { WebsocketEvent, WebsocketRequest } from '@yaakapp-internal/models'; import type { WebsocketEvent, WebsocketRequest } from '@yaakapp-internal/models';
import classNames from 'classnames';
import { format } from 'date-fns';
import { hexy } from 'hexy'; import { hexy } from 'hexy';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import { useMemo, useState } from 'react'; import { useMemo, useRef, useState } from 'react';
import { useFormatText } from '../hooks/useFormatText'; import { useFormatText } from '../hooks/useFormatText';
import { import {
activeWebsocketConnectionAtom, activeWebsocketConnectionAtom,
@@ -11,13 +13,17 @@ import {
} from '../hooks/usePinnedWebsocketConnection'; } from '../hooks/usePinnedWebsocketConnection';
import { useStateWithDeps } from '../hooks/useStateWithDeps'; import { useStateWithDeps } from '../hooks/useStateWithDeps';
import { languageFromContentType } from '../lib/contentType'; import { languageFromContentType } from '../lib/contentType';
import { copyToClipboard } from '../lib/copy';
import { AutoScroller } from './core/AutoScroller';
import { Banner } from './core/Banner';
import { Button } from './core/Button'; import { Button } from './core/Button';
import { Editor } from './core/Editor/LazyEditor'; import { Editor } from './core/Editor/LazyEditor';
import { EventDetailHeader, EventViewer, type EventDetailAction } from './core/EventViewer';
import { EventViewerRow } from './core/EventViewerRow';
import { HotkeyList } from './core/HotkeyList'; import { HotkeyList } from './core/HotkeyList';
import { Icon } from './core/Icon'; import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { LoadingIcon } from './core/LoadingIcon'; import { LoadingIcon } from './core/LoadingIcon';
import { Separator } from './core/Separator';
import { SplitLayout } from './core/SplitLayout';
import { HStack, VStack } from './core/Stacks'; import { HStack, VStack } from './core/Stacks';
import { WebsocketStatusTag } from './core/WebsocketStatusTag'; import { WebsocketStatusTag } from './core/WebsocketStatusTag';
import { EmptyStateText } from './EmptyStateText'; import { EmptyStateText } from './EmptyStateText';
@@ -29,199 +35,227 @@ interface Props {
} }
export function WebsocketResponsePane({ activeRequest }: Props) { export function WebsocketResponsePane({ activeRequest }: Props) {
const [activeEventId, setActiveEventId] = useState<string | null>(null);
const [showLarge, setShowLarge] = useStateWithDeps<boolean>(false, [activeRequest.id]); const [showLarge, setShowLarge] = useStateWithDeps<boolean>(false, [activeRequest.id]);
const [showingLarge, setShowingLarge] = useState<boolean>(false); const [showingLarge, setShowingLarge] = useState<boolean>(false);
const [hexDumps, setHexDumps] = useState<Record<number, boolean>>({}); const [hexDumps, setHexDumps] = useState<Record<string, boolean>>({});
const activeConnection = useAtomValue(activeWebsocketConnectionAtom); const activeConnection = useAtomValue(activeWebsocketConnectionAtom);
const connections = useAtomValue(activeWebsocketConnectionsAtom); const connections = useAtomValue(activeWebsocketConnectionsAtom);
const events = useWebsocketEvents(activeConnection?.id ?? null); const events = useWebsocketEvents(activeConnection?.id ?? null);
if (activeConnection == null) { const activeEvent = useMemo(
return ( () => events.find((m) => m.id === activeEventId) ?? null,
<HotkeyList hotkeys={['request.send', 'model.create', 'sidebar.focus', 'url_bar.focus']} /> [activeEventId, events],
);
}
const header = (
<HStack className="pl-3 mb-1 font-mono text-sm text-text-subtle">
<HStack space={2}>
{activeConnection.state !== 'closed' && (
<LoadingIcon size="sm" className="text-text-subtlest" />
)}
<WebsocketStatusTag connection={activeConnection} />
<span>&bull;</span>
<span>{events.length} Messages</span>
</HStack>
<HStack space={0.5} className="ml-auto">
<RecentWebsocketConnectionsDropdown
connections={connections}
activeConnection={activeConnection}
onPinnedConnectionId={setPinnedWebsocketConnectionId}
/>
</HStack>
</HStack>
); );
return ( const hexDump = hexDumps[activeEventId ?? 'n/a'] ?? activeEvent?.messageType === 'binary';
<ErrorBoundary name="Websocket Events">
<EventViewer
events={events}
getEventKey={(event) => event.id}
error={activeConnection.error}
header={header}
splitLayoutName="websocket_events"
defaultRatio={0.4}
renderRow={({ event, isActive, onClick }) => (
<WebsocketEventRow event={event} isActive={isActive} onClick={onClick} />
)}
renderDetail={({ event, index }) => (
<WebsocketEventDetail
event={event}
hexDump={hexDumps[index] ?? event.messageType === 'binary'}
setHexDump={(v) => setHexDumps({ ...hexDumps, [index]: v })}
showLarge={showLarge}
showingLarge={showingLarge}
setShowLarge={setShowLarge}
setShowingLarge={setShowingLarge}
/>
)}
/>
</ErrorBoundary>
);
}
function WebsocketEventRow({
event,
isActive,
onClick,
}: {
event: WebsocketEvent;
isActive: boolean;
onClick: () => void;
}) {
const { message: messageBytes, isServer, messageType } = event;
const message = messageBytes
? new TextDecoder('utf-8').decode(Uint8Array.from(messageBytes))
: '';
const iconColor =
messageType === 'close' || messageType === 'open' ? 'secondary' : isServer ? 'info' : 'primary';
const icon =
messageType === 'close' || messageType === 'open'
? 'info'
: isServer
? 'arrow_big_down_dash'
: 'arrow_big_up_dash';
const content =
messageType === 'close' ? (
'Disconnected from server'
) : messageType === 'open' ? (
'Connected to server'
) : message === '' ? (
<em className="italic text-text-subtlest">No content</em>
) : (
<span className="text-xs">{message.slice(0, 1000)}</span>
);
return (
<EventViewerRow
isActive={isActive}
onClick={onClick}
icon={<Icon color={iconColor} icon={icon} />}
content={content}
timestamp={event.createdAt}
/>
);
}
function WebsocketEventDetail({
event,
hexDump,
setHexDump,
showLarge,
showingLarge,
setShowLarge,
setShowingLarge,
}: {
event: WebsocketEvent;
hexDump: boolean;
setHexDump: (v: boolean) => void;
showLarge: boolean;
showingLarge: boolean;
setShowLarge: (v: boolean) => void;
setShowingLarge: (v: boolean) => void;
}) {
const message = useMemo(() => { const message = useMemo(() => {
if (hexDump) { if (hexDump) {
return event.message ? hexy(event.message) : ''; return activeEvent?.message ? hexy(activeEvent?.message) : '';
} }
return event.message ? new TextDecoder('utf-8').decode(Uint8Array.from(event.message)) : ''; return activeEvent?.message
}, [event.message, hexDump]); ? new TextDecoder('utf-8').decode(Uint8Array.from(activeEvent.message))
: '';
}, [activeEvent?.message, hexDump]);
const language = languageFromContentType(null, message); const language = languageFromContentType(null, message);
const formattedMessage = useFormatText({ language, text: message, pretty: true }); const formattedMessage = useFormatText({ language, text: message, pretty: true });
const title = return (
event.messageType === 'close' <SplitLayout
? 'Connection Closed' layout="vertical"
: event.messageType === 'open' name="grpc_events"
? 'Connection Open' defaultRatio={0.4}
: `Message ${event.isServer ? 'Received' : 'Sent'}`; minHeightPx={20}
firstSlot={() =>
activeConnection == null ? (
<HotkeyList
hotkeys={['request.send', 'model.create', 'sidebar.focus', 'url_bar.focus']}
/>
) : (
<div className="w-full grid grid-rows-[auto_minmax(0,1fr)] items-center">
<HStack className="pl-3 mb-1 font-mono text-sm text-text-subtle">
<HStack space={2}>
{activeConnection.state !== 'closed' && (
<LoadingIcon size="sm" className="text-text-subtlest" />
)}
<WebsocketStatusTag connection={activeConnection} />
<span>&bull;</span>
<span>{events.length} Messages</span>
</HStack>
<HStack space={0.5} className="ml-auto">
<RecentWebsocketConnectionsDropdown
connections={connections}
activeConnection={activeConnection}
onPinnedConnectionId={setPinnedWebsocketConnectionId}
/>
</HStack>
</HStack>
<ErrorBoundary name="Websocket Events">
<AutoScroller
data={events}
header={
activeConnection.error && (
<Banner color="danger" className="m-3">
{activeConnection.error}
</Banner>
)
}
render={(event) => (
<EventRow
key={event.id}
event={event}
isActive={event.id === activeEventId}
onClick={() => {
if (event.id === activeEventId) setActiveEventId(null);
else setActiveEventId(event.id);
}}
/>
)}
/>
</ErrorBoundary>
</div>
)
}
secondSlot={
activeEvent != null && activeConnection != null
? () => (
<div className="grid grid-rows-[auto_minmax(0,1fr)]">
<div className="pb-3 px-2">
<Separator />
</div>
<div className="mx-2 overflow-y-auto grid grid-rows-[auto_minmax(0,1fr)]">
<div className="h-xs mb-2 grid grid-cols-[minmax(0,1fr)_auto] items-center">
<div className="font-semibold">
{activeEvent.messageType === 'close'
? 'Connection Closed'
: activeEvent.messageType === 'open'
? 'Connection open'
: `Message ${activeEvent.isServer ? 'Received' : 'Sent'}`}
</div>
{message !== '' && (
<HStack space={1}>
<Button
variant="border"
size="xs"
onClick={() => {
if (activeEventId == null) return;
setHexDumps({ ...hexDumps, [activeEventId]: !hexDump });
}}
>
{hexDump ? 'Show Message' : 'Show Hexdump'}
</Button>
<IconButton
title="Copy message"
icon="copy"
size="xs"
onClick={() => copyToClipboard(formattedMessage ?? '')}
/>
</HStack>
)}
</div>
{!showLarge && activeEvent.message.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>
) : activeEvent.message.length === 0 ? (
<EmptyStateText>No Content</EmptyStateText>
) : (
<Editor
language={language}
defaultValue={formattedMessage ?? ''}
wrapLines={false}
readOnly={true}
stateKey={null}
/>
)}
</div>
</div>
)
: null
}
/>
);
}
const actions: EventDetailAction[] = function EventRow({
message !== '' onClick,
? [ isActive,
{ event,
key: 'toggle-hexdump', }: {
label: hexDump ? 'Show Message' : 'Show Hexdump', onClick?: () => void;
onClick: () => setHexDump(!hexDump), isActive?: boolean;
}, event: WebsocketEvent;
] }) {
: []; const { createdAt, message: messageBytes, isServer, messageType } = event;
const ref = useRef<HTMLDivElement>(null);
const message = messageBytes
? new TextDecoder('utf-8').decode(Uint8Array.from(messageBytes))
: '';
return ( return (
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]"> <div className="px-1" ref={ref}>
<EventDetailHeader <button
title={title} type="button"
timestamp={event.createdAt} onClick={onClick}
actions={actions} className={classNames(
copyText={formattedMessage || undefined} 'w-full grid grid-cols-[auto_minmax(0,3fr)_auto] gap-2 items-center text-left',
'px-1.5 h-xs font-mono cursor-default group focus:outline-none focus:text-text rounded',
isActive && '!bg-surface-active !text-text',
'text-text-subtle hover:text',
)}
>
<Icon
color={
messageType === 'close' || messageType === 'open'
? 'secondary'
: isServer
? 'info'
: 'primary'
}
icon={
messageType === 'close' || messageType === 'open'
? 'info'
: isServer
? 'arrow_big_down_dash'
: 'arrow_big_up_dash'
}
/> />
{!showLarge && event.message.length > 1000 * 1000 ? ( <div className={classNames('w-full truncate text-xs')}>
<VStack space={2} className="italic text-text-subtlest"> {messageType === 'close' ? (
Message previews larger than 1MB are hidden 'Disconnected from server'
<div> ) : messageType === 'open' ? (
<Button 'Connected to server'
onClick={() => { ) : message === '' ? (
setShowingLarge(true); <em className="italic text-text-subtlest">No content</em>
setTimeout(() => { ) : (
setShowLarge(true); message.slice(0, 1000)
setShowingLarge(false); )}
}, 500); {/*{error && <span className="text-warning"> ({error})</span>}*/}
}} </div>
isLoading={showingLarge} <div className={classNames('opacity-50 text-xs')}>
color="secondary" {format(`${createdAt}Z`, 'HH:mm:ss.SSS')}
variant="border" </div>
size="xs" </button>
>
Try Showing
</Button>
</div>
</VStack>
) : event.message.length === 0 ? (
<EmptyStateText>No Content</EmptyStateText>
) : (
<Editor
language={language}
defaultValue={formattedMessage ?? ''}
wrapLines={false}
readOnly={true}
stateKey={null}
/>
)}
</div> </div>
); );
} }
+22 -47
View File
@@ -18,7 +18,6 @@ import { useWorkspaceActions } from '../hooks/useWorkspaceActions';
import { showDialog } from '../lib/dialog'; import { showDialog } from '../lib/dialog';
import { jotaiStore } from '../lib/jotai'; import { jotaiStore } from '../lib/jotai';
import { revealInFinderText } from '../lib/reveal'; import { revealInFinderText } from '../lib/reveal';
import { CloneGitRepositoryDialog } from './CloneGitRepositoryDialog';
import type { ButtonProps } from './core/Button'; import type { ButtonProps } from './core/Button';
import { Button } from './core/Button'; import { Button } from './core/Button';
import type { DropdownItem } from './core/Dropdown'; import type { DropdownItem } from './core/Dropdown';
@@ -40,19 +39,9 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
const { mutate: deleteSendHistory } = useDeleteSendHistory(); const { mutate: deleteSendHistory } = useDeleteSendHistory();
const workspaceActions = useWorkspaceActions(); const workspaceActions = useWorkspaceActions();
const openCloneGitRepositoryDialog = useCallback(() => { const { workspaceItems, itemsAfter } = useMemo<{
showDialog({
id: 'clone-git-repository',
size: 'md',
title: 'Clone Git Repository',
render: ({ hide }) => <CloneGitRepositoryDialog hide={hide} />,
});
}, []);
const { workspaceItems, itemsAfter, itemsBefore } = useMemo<{
workspaceItems: RadioDropdownItem[]; workspaceItems: RadioDropdownItem[];
itemsAfter: DropdownItem[]; itemsAfter: DropdownItem[];
itemsBefore: DropdownItem[];
}>(() => { }>(() => {
const workspaceItems: RadioDropdownItem[] = workspaces.map((w) => ({ const workspaceItems: RadioDropdownItem[] = workspaces.map((w) => ({
key: w.id, key: w.id,
@@ -61,38 +50,6 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
leftSlot: w.id === workspace?.id ? <Icon icon="check" /> : <Icon icon="empty" />, leftSlot: w.id === workspace?.id ? <Icon icon="check" /> : <Icon icon="empty" />,
})); }));
const itemsBefore: DropdownItem[] = [
{
label: 'New Workspace',
leftSlot: <Icon icon="plus" />,
submenu: [
{
label: 'Create Empty',
leftSlot: <Icon icon="plus_circle" />,
onSelect: createWorkspace,
},
{
label: 'Open Folder',
leftSlot: <Icon icon="folder_open" />,
onSelect: async () => {
const dir = await open({
title: 'Select Workspace Directory',
directory: true,
multiple: false,
});
if (dir == null) return;
openWorkspaceFromSyncDir.mutate(dir);
},
},
{
label: 'Clone Git Repository',
leftSlot: <Icon icon="hard_drive_download" />,
onSelect: openCloneGitRepositoryDialog,
},
],
},
];
const itemsAfter: DropdownItem[] = [ const itemsAfter: DropdownItem[] = [
...workspaceActions.map((a) => ({ ...workspaceActions.map((a) => ({
label: a.label, label: a.label,
@@ -123,15 +80,34 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
leftSlot: <Icon icon="history" />, leftSlot: <Icon icon="history" />,
onSelect: deleteSendHistory, onSelect: deleteSendHistory,
}, },
{ type: 'separator' },
{
label: 'New Workspace',
leftSlot: <Icon icon="plus" />,
onSelect: createWorkspace,
},
{
label: 'Open Existing Workspace',
leftSlot: <Icon icon="folder_open" />,
onSelect: async () => {
const dir = await open({
title: 'Select Workspace Directory',
directory: true,
multiple: false,
});
if (dir == null) return;
openWorkspaceFromSyncDir.mutate(dir);
},
},
]; ];
return { workspaceItems, itemsAfter, itemsBefore }; return { workspaceItems, itemsAfter };
}, [ }, [
workspaces, workspaces,
workspaceMeta, workspaceMeta,
deleteSendHistory, deleteSendHistory,
createWorkspace, createWorkspace,
openCloneGitRepositoryDialog,
workspace?.id, workspace?.id,
workspace, workspace,
workspaceActions.map, workspaceActions.map,
@@ -168,7 +144,6 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
<RadioDropdown <RadioDropdown
items={workspaceItems} items={workspaceItems}
itemsAfter={itemsAfter} itemsAfter={itemsAfter}
itemsBefore={itemsBefore}
onChange={handleSwitchWorkspace} onChange={handleSwitchWorkspace}
value={workspace?.id ?? null} value={workspace?.id ?? null}
> >
@@ -1,9 +1,4 @@
import { import { enableEncryption, revealWorkspaceKey, setWorkspaceKey } from '@yaakapp-internal/crypto';
disableEncryption,
enableEncryption,
revealWorkspaceKey,
setWorkspaceKey,
} from '@yaakapp-internal/crypto';
import type { WorkspaceMeta } from '@yaakapp-internal/models'; import type { WorkspaceMeta } from '@yaakapp-internal/models';
import classNames from 'classnames'; import classNames from 'classnames';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
@@ -11,7 +6,6 @@ import { useEffect, useState } from 'react';
import { activeWorkspaceAtom, activeWorkspaceMetaAtom } from '../hooks/useActiveWorkspace'; import { activeWorkspaceAtom, activeWorkspaceMetaAtom } from '../hooks/useActiveWorkspace';
import { createFastMutation } from '../hooks/useFastMutation'; import { createFastMutation } from '../hooks/useFastMutation';
import { useStateWithDeps } from '../hooks/useStateWithDeps'; import { useStateWithDeps } from '../hooks/useStateWithDeps';
import { showConfirm } from '../lib/confirm';
import { CopyIconButton } from './CopyIconButton'; import { CopyIconButton } from './CopyIconButton';
import { Banner } from './core/Banner'; import { Banner } from './core/Banner';
import type { ButtonProps } from './core/Button'; import type { ButtonProps } from './core/Button';
@@ -75,9 +69,6 @@ export function WorkspaceEncryptionSetting({ size, expanded, onDone, onEnabledEn
onDone?.(); onDone?.();
onEnabledEncryption?.(); onEnabledEncryption?.();
}} }}
onDisabled={() => {
onDone?.();
}}
/> />
); );
} }
@@ -118,7 +109,6 @@ export function WorkspaceEncryptionSetting({ size, expanded, onDone, onEnabledEn
return ( return (
<div className="mb-auto flex flex-col-reverse"> <div className="mb-auto flex flex-col-reverse">
<Button <Button
className="mt-3"
color={expanded ? 'info' : 'secondary'} color={expanded ? 'info' : 'secondary'}
size={size} size={size}
onClick={async () => { onClick={async () => {
@@ -159,39 +149,13 @@ const setWorkspaceKeyMut = createFastMutation({
function EnterWorkspaceKey({ function EnterWorkspaceKey({
workspaceMeta, workspaceMeta,
onEnabled, onEnabled,
onDisabled,
error, error,
}: { }: {
workspaceMeta: WorkspaceMeta; workspaceMeta: WorkspaceMeta;
onEnabled?: () => void; onEnabled?: () => void;
onDisabled?: () => void;
error?: string | null; error?: string | null;
}) { }) {
const [key, setKey] = useState<string>(''); const [key, setKey] = useState<string>('');
const handleForgotKey = async () => {
const confirmed = await showConfirm({
id: 'disable-encryption',
title: 'Disable Encryption',
color: 'danger',
confirmText: 'Disable Encryption',
description: (
<>
This will disable encryption for this workspace. Any previously encrypted values will fail
to decrypt and will need to be re-entered manually.
<br />
<br />
This action cannot be undone.
</>
),
});
if (confirmed) {
await disableEncryption(workspaceMeta.workspaceId);
onDisabled?.();
}
};
return ( return (
<VStack space={4} className="w-full"> <VStack space={4} className="w-full">
{error ? ( {error ? (
@@ -228,13 +192,6 @@ function EnterWorkspaceKey({
Submit Submit
</Button> </Button>
</HStack> </HStack>
<button
type="button"
onClick={handleForgotKey}
className="text-text-subtlest text-sm hover:text-text-subtle"
>
Forgot your key?
</button>
</VStack> </VStack>
); );
} }

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