mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-04-22 16:48:30 +02:00
Compare commits
1 Commits
v2026.1.0
...
omnara/rep
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa3e6e6508 |
@@ -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>'
|
|
||||||
```
|
|
||||||
|
|||||||
94
.github/workflows/release.yml
vendored
94
.github/workflows/release.yml
vendored
@@ -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'
|
||||||
|
|||||||
4
Cargo.lock
generated
4
Cargo.lock
generated
@@ -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",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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,8 +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) fn cmd_default_headers() -> Vec<HttpRequestHeader> {
|
|
||||||
default_headers()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ 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::{
|
||||||
GitCommit, GitRemote, GitStatusSummary, PullResult, PushResult, git_add, git_add_credential,
|
GitCommit, GitRemote, GitStatusSummary, PullResult, PushResult,
|
||||||
git_add_remote, git_checkout_branch, git_commit, git_create_branch, git_delete_branch,
|
git_add, git_add_credential, git_add_remote, git_checkout_branch, git_commit,
|
||||||
git_fetch_all, git_init, git_log, git_merge_branch, git_pull, git_push, git_remotes,
|
git_create_branch, git_delete_branch, git_fetch_all, git_init, git_log,
|
||||||
git_rm_remote, git_status, git_unstage,
|
git_merge_branch, git_pull, git_push, git_remotes, git_rm_remote, git_status,
|
||||||
|
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
|
||||||
@@ -51,22 +52,22 @@ pub async fn cmd_git_initialize(dir: &Path) -> Result<()> {
|
|||||||
|
|
||||||
#[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]
|
||||||
|
|||||||
@@ -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,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";
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -189,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>>,
|
||||||
@@ -223,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()))?)
|
||||||
@@ -362,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();
|
||||||
|
|
||||||
@@ -385,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(
|
||||||
@@ -404,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();
|
||||||
@@ -455,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,
|
||||||
@@ -511,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,
|
||||||
),
|
),
|
||||||
@@ -1057,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?)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1151,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?)
|
||||||
@@ -1718,7 +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_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,
|
||||||
@@ -1762,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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 })))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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,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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -6,4 +6,3 @@ publish = false
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
tokio = { workspace = true, features = ["process"] }
|
|
||||||
|
|||||||
@@ -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,3 +1,2 @@
|
|||||||
pub mod command;
|
|
||||||
pub mod platform;
|
pub mod platform;
|
||||||
pub mod serde;
|
pub mod serde;
|
||||||
|
|||||||
@@ -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
crates/yaak-git/bindings/gen_models.ts
generated
4
crates/yaak-git/bindings/gen_models.ts
generated
@@ -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, };
|
||||||
|
|||||||
@@ -1,24 +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;
|
|
||||||
|
|
||||||
pub(crate) async fn new_binary_command(dir: &Path) -> Result<Command> {
|
use crate::error::Error::GitNotFound;
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
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 mut cmd = new_xplatform_command("git");
|
let mut cmd = Command::new("git");
|
||||||
cmd.arg("-C").arg(dir);
|
cmd.arg("-C").arg(dir);
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(cmd)
|
Ok(cmd)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
use crate::binary::new_binary_command;
|
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::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(
|
pub async fn git_add_credential(
|
||||||
@@ -18,8 +18,7 @@ pub async fn git_add_credential(
|
|||||||
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(dir)
|
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())
|
||||||
@@ -27,21 +26,19 @@ pub async fn git_add_credential(
|
|||||||
|
|
||||||
{
|
{
|
||||||
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()));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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?;
|
||||||
|
|||||||
@@ -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!(
|
||||||
|
|||||||
@@ -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()?)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 scheme’s 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>),
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>> =
|
||||||
|
|||||||
8
crates/yaak-models/bindings/gen_models.ts
generated
8
crates/yaak-models/bindings/gen_models.ts
generated
@@ -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, };
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|||||||
8
crates/yaak-plugins/bindings/gen_models.ts
generated
8
crates/yaak-plugins/bindings/gen_models.ts
generated
@@ -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, };
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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. 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, };
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
8
package-lock.json
generated
8
package-lock.json
generated
@@ -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": {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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, };
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 **/),
|
||||||
],
|
],
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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' };
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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>•</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>•</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { patchModel, workspaceMetasAtom, workspacesAtom } from '@yaakapp-internal/models';
|
import { patchModel, workspaceMetasAtom, workspacesAtom } from '@yaakapp-internal/models';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { useState } from 'react';
|
||||||
import { useAuthTab } from '../hooks/useAuthTab';
|
import { useAuthTab } from '../hooks/useAuthTab';
|
||||||
import { useHeadersTab } from '../hooks/useHeadersTab';
|
import { useHeadersTab } from '../hooks/useHeadersTab';
|
||||||
import { useInheritedHeaders } from '../hooks/useInheritedHeaders';
|
import { useInheritedHeaders } from '../hooks/useInheritedHeaders';
|
||||||
@@ -8,12 +9,10 @@ import { router } from '../lib/router';
|
|||||||
import { CopyIconButton } from './CopyIconButton';
|
import { CopyIconButton } from './CopyIconButton';
|
||||||
import { Banner } from './core/Banner';
|
import { Banner } from './core/Banner';
|
||||||
import { Button } from './core/Button';
|
import { Button } from './core/Button';
|
||||||
import { CountBadge } from './core/CountBadge';
|
|
||||||
import { InlineCode } from './core/InlineCode';
|
import { InlineCode } from './core/InlineCode';
|
||||||
import { PlainInput } from './core/PlainInput';
|
import { PlainInput } from './core/PlainInput';
|
||||||
import { HStack, VStack } from './core/Stacks';
|
import { HStack, VStack } from './core/Stacks';
|
||||||
import { TabContent, Tabs } from './core/Tabs/Tabs';
|
import { TabContent, Tabs } from './core/Tabs/Tabs';
|
||||||
import { DnsOverridesEditor } from './DnsOverridesEditor';
|
|
||||||
import { HeadersEditor } from './HeadersEditor';
|
import { HeadersEditor } from './HeadersEditor';
|
||||||
import { HttpAuthenticationEditor } from './HttpAuthenticationEditor';
|
import { HttpAuthenticationEditor } from './HttpAuthenticationEditor';
|
||||||
import { MarkdownEditor } from './MarkdownEditor';
|
import { MarkdownEditor } from './MarkdownEditor';
|
||||||
@@ -28,13 +27,11 @@ interface Props {
|
|||||||
|
|
||||||
const TAB_AUTH = 'auth';
|
const TAB_AUTH = 'auth';
|
||||||
const TAB_DATA = 'data';
|
const TAB_DATA = 'data';
|
||||||
const TAB_DNS = 'dns';
|
|
||||||
const TAB_HEADERS = 'headers';
|
const TAB_HEADERS = 'headers';
|
||||||
const TAB_GENERAL = 'general';
|
const TAB_GENERAL = 'general';
|
||||||
|
|
||||||
export type WorkspaceSettingsTab =
|
export type WorkspaceSettingsTab =
|
||||||
| typeof TAB_AUTH
|
| typeof TAB_AUTH
|
||||||
| typeof TAB_DNS
|
|
||||||
| typeof TAB_HEADERS
|
| typeof TAB_HEADERS
|
||||||
| typeof TAB_GENERAL
|
| typeof TAB_GENERAL
|
||||||
| typeof TAB_DATA;
|
| typeof TAB_DATA;
|
||||||
@@ -44,6 +41,7 @@ const DEFAULT_TAB: WorkspaceSettingsTab = TAB_GENERAL;
|
|||||||
export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
|
export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
|
||||||
const workspace = useAtomValue(workspacesAtom).find((w) => w.id === workspaceId);
|
const workspace = useAtomValue(workspacesAtom).find((w) => w.id === workspaceId);
|
||||||
const workspaceMeta = useAtomValue(workspaceMetasAtom).find((m) => m.workspaceId === workspaceId);
|
const workspaceMeta = useAtomValue(workspaceMetasAtom).find((m) => m.workspaceId === workspaceId);
|
||||||
|
const [activeTab, setActiveTab] = useState<string>(tab ?? DEFAULT_TAB);
|
||||||
const authTab = useAuthTab(TAB_AUTH, workspace ?? null);
|
const authTab = useAuthTab(TAB_AUTH, workspace ?? null);
|
||||||
const headersTab = useHeadersTab(TAB_HEADERS, workspace ?? null);
|
const headersTab = useHeadersTab(TAB_HEADERS, workspace ?? null);
|
||||||
const inheritedHeaders = useInheritedHeaders(workspace ?? null);
|
const inheritedHeaders = useInheritedHeaders(workspace ?? null);
|
||||||
@@ -65,7 +63,8 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs
|
<Tabs
|
||||||
defaultValue={tab ?? DEFAULT_TAB}
|
value={activeTab}
|
||||||
|
onChangeValue={setActiveTab}
|
||||||
label="Folder Settings"
|
label="Folder Settings"
|
||||||
className="pt-4 pb-2 px-3"
|
className="pt-4 pb-2 px-3"
|
||||||
tabListClassName="pl-4"
|
tabListClassName="pl-4"
|
||||||
@@ -78,16 +77,7 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
|
|||||||
},
|
},
|
||||||
...headersTab,
|
...headersTab,
|
||||||
...authTab,
|
...authTab,
|
||||||
{
|
|
||||||
value: TAB_DNS,
|
|
||||||
label: 'DNS',
|
|
||||||
rightSlot:
|
|
||||||
workspace.settingDnsOverrides.length > 0 ? (
|
|
||||||
<CountBadge count={workspace.settingDnsOverrides.length} />
|
|
||||||
) : null,
|
|
||||||
},
|
|
||||||
]}
|
]}
|
||||||
storageKey="workspace_settings_tabs"
|
|
||||||
>
|
>
|
||||||
<TabContent value={TAB_AUTH} className="overflow-y-auto h-full px-4">
|
<TabContent value={TAB_AUTH} className="overflow-y-auto h-full px-4">
|
||||||
<HttpAuthenticationEditor model={workspace} />
|
<HttpAuthenticationEditor model={workspace} />
|
||||||
@@ -95,7 +85,6 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
|
|||||||
<TabContent value={TAB_HEADERS} className="overflow-y-auto h-full px-4">
|
<TabContent value={TAB_HEADERS} className="overflow-y-auto h-full px-4">
|
||||||
<HeadersEditor
|
<HeadersEditor
|
||||||
inheritedHeaders={inheritedHeaders}
|
inheritedHeaders={inheritedHeaders}
|
||||||
inheritedHeadersLabel="Defaults"
|
|
||||||
forceUpdateKey={workspace.id}
|
forceUpdateKey={workspace.id}
|
||||||
headers={workspace.headers}
|
headers={workspace.headers}
|
||||||
onChange={(headers) => patchModel(workspace, { headers })}
|
onChange={(headers) => patchModel(workspace, { headers })}
|
||||||
@@ -164,9 +153,6 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
|
|||||||
<WorkspaceEncryptionSetting size="xs" />
|
<WorkspaceEncryptionSetting size="xs" />
|
||||||
</VStack>
|
</VStack>
|
||||||
</TabContent>
|
</TabContent>
|
||||||
<TabContent value={TAB_DNS} className="overflow-y-auto h-full px-4">
|
|
||||||
<DnsOverridesEditor workspace={workspace} />
|
|
||||||
</TabContent>
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useVirtualizer, type Virtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import type { ReactElement, ReactNode, UIEvent } from 'react';
|
import type { ReactElement, ReactNode, UIEvent } from 'react';
|
||||||
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
|
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
|
||||||
import { IconButton } from './IconButton';
|
import { IconButton } from './IconButton';
|
||||||
@@ -7,19 +7,9 @@ interface Props<T> {
|
|||||||
data: T[];
|
data: T[];
|
||||||
render: (item: T, index: number) => ReactElement<HTMLElement>;
|
render: (item: T, index: number) => ReactElement<HTMLElement>;
|
||||||
header?: ReactNode;
|
header?: ReactNode;
|
||||||
/** Make container focusable for keyboard navigation */
|
|
||||||
focusable?: boolean;
|
|
||||||
/** Callback to expose the virtualizer for keyboard navigation */
|
|
||||||
onVirtualizerReady?: (virtualizer: Virtualizer<HTMLDivElement, Element>) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AutoScroller<T>({
|
export function AutoScroller<T>({ data, render, header }: Props<T>) {
|
||||||
data,
|
|
||||||
render,
|
|
||||||
header,
|
|
||||||
focusable = false,
|
|
||||||
onVirtualizerReady,
|
|
||||||
}: Props<T>) {
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [autoScroll, setAutoScroll] = useState<boolean>(true);
|
const [autoScroll, setAutoScroll] = useState<boolean>(true);
|
||||||
|
|
||||||
@@ -30,11 +20,6 @@ export function AutoScroller<T>({
|
|||||||
estimateSize: () => 27, // react-virtual requires a height, so we'll give it one
|
estimateSize: () => 27, // react-virtual requires a height, so we'll give it one
|
||||||
});
|
});
|
||||||
|
|
||||||
// Expose virtualizer to parent for keyboard navigation
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
onVirtualizerReady?.(rowVirtualizer);
|
|
||||||
}, [rowVirtualizer, onVirtualizerReady]);
|
|
||||||
|
|
||||||
// Scroll to new items
|
// Scroll to new items
|
||||||
const handleScroll = useCallback(
|
const handleScroll = useCallback(
|
||||||
(e: UIEvent<HTMLDivElement>) => {
|
(e: UIEvent<HTMLDivElement>) => {
|
||||||
@@ -63,7 +48,7 @@ export function AutoScroller<T>({
|
|||||||
}, [autoScroll, data.length]);
|
}, [autoScroll, data.length]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full relative grid grid-rows-[auto_minmax(0,1fr)]">
|
<div className="h-full w-full relative grid grid-rows-[minmax(0,auto)_minmax(0,1fr)]">
|
||||||
{!autoScroll && (
|
{!autoScroll && (
|
||||||
<div className="absolute bottom-0 right-0 m-2">
|
<div className="absolute bottom-0 right-0 m-2">
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -78,12 +63,7 @@ export function AutoScroller<T>({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{header ?? <span aria-hidden />}
|
{header ?? <span aria-hidden />}
|
||||||
<div
|
<div ref={containerRef} className="h-full w-full overflow-y-auto" onScroll={handleScroll}>
|
||||||
ref={containerRef}
|
|
||||||
className="h-full w-full overflow-y-auto focus:outline-none"
|
|
||||||
onScroll={handleScroll}
|
|
||||||
tabIndex={focusable ? 0 : undefined}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||||
|
|||||||
@@ -766,24 +766,8 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
|
|||||||
</m.div>
|
</m.div>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Hotkeys must be rendered even when menu is closed (so they work globally)
|
|
||||||
const hotKeyElements = items.map(
|
|
||||||
(item, i) =>
|
|
||||||
item.type !== 'separator' &&
|
|
||||||
item.type !== 'content' &&
|
|
||||||
!item.hotKeyLabelOnly &&
|
|
||||||
item.hotKeyAction && (
|
|
||||||
<MenuItemHotKey
|
|
||||||
key={`${item.hotKeyAction}::${i}`}
|
|
||||||
onSelect={handleSelect}
|
|
||||||
item={item}
|
|
||||||
action={item.hotKeyAction}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
return <>{hotKeyElements}</>;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isSubmenu) {
|
if (isSubmenu) {
|
||||||
@@ -792,7 +776,20 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{hotKeyElements}
|
{items.map(
|
||||||
|
(item, i) =>
|
||||||
|
item.type !== 'separator' &&
|
||||||
|
item.type !== 'content' &&
|
||||||
|
!item.hotKeyLabelOnly &&
|
||||||
|
item.hotKeyAction && (
|
||||||
|
<MenuItemHotKey
|
||||||
|
key={`${item.hotKeyAction}::${i}`}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
item={item}
|
||||||
|
action={item.hotKeyAction}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)}
|
||||||
<Overlay noBackdrop open={isOpen} portalName="dropdown-menu">
|
<Overlay noBackdrop open={isOpen} portalName="dropdown-menu">
|
||||||
{menuContent}
|
{menuContent}
|
||||||
</Overlay>
|
</Overlay>
|
||||||
|
|||||||
@@ -101,7 +101,7 @@
|
|||||||
|
|
||||||
.template-tag {
|
.template-tag {
|
||||||
/* Colors */
|
/* Colors */
|
||||||
@apply bg-surface text-text border-border-subtle whitespace-nowrap cursor-default;
|
@apply bg-surface text-text border-border-subtle whitespace-nowrap;
|
||||||
@apply hover:border-border-subtle hover:text-text hover:bg-surface-highlight;
|
@apply hover:border-border-subtle hover:text-text hover:bg-surface-highlight;
|
||||||
|
|
||||||
@apply inline border px-1 mx-[0.5px] rounded dark:shadow;
|
@apply inline border px-1 mx-[0.5px] rounded dark:shadow;
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export interface EditorProps {
|
|||||||
heightMode?: 'auto' | 'full';
|
heightMode?: 'auto' | 'full';
|
||||||
hideGutter?: boolean;
|
hideGutter?: boolean;
|
||||||
id?: string;
|
id?: string;
|
||||||
language?: EditorLanguage | 'pairs' | 'url' | 'timeline' | null;
|
language?: EditorLanguage | 'pairs' | 'url' | null;
|
||||||
graphQLSchema?: GraphQLSchema | null;
|
graphQLSchema?: GraphQLSchema | null;
|
||||||
onBlur?: () => void;
|
onBlur?: () => void;
|
||||||
onChange?: (value: string) => void;
|
onChange?: (value: string) => void;
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ import type { EditorProps } from './Editor';
|
|||||||
import { jsonParseLinter } from './json-lint';
|
import { jsonParseLinter } from './json-lint';
|
||||||
import { pairs } from './pairs/extension';
|
import { pairs } from './pairs/extension';
|
||||||
import { text } from './text/extension';
|
import { text } from './text/extension';
|
||||||
import { timeline } from './timeline/extension';
|
|
||||||
import type { TwigCompletionOption } from './twig/completion';
|
import type { TwigCompletionOption } from './twig/completion';
|
||||||
import { twig } from './twig/extension';
|
import { twig } from './twig/extension';
|
||||||
import { pathParametersPlugin } from './twig/pathParameters';
|
import { pathParametersPlugin } from './twig/pathParameters';
|
||||||
@@ -96,7 +95,6 @@ const syntaxExtensions: Record<
|
|||||||
url: url,
|
url: url,
|
||||||
pairs: pairs,
|
pairs: pairs,
|
||||||
text: text,
|
text: text,
|
||||||
timeline: timeline,
|
|
||||||
markdown: markdown,
|
markdown: markdown,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
import { LanguageSupport, LRLanguage } from '@codemirror/language';
|
|
||||||
import { parser } from './timeline';
|
|
||||||
|
|
||||||
export const timelineLanguage = LRLanguage.define({
|
|
||||||
name: 'timeline',
|
|
||||||
parser,
|
|
||||||
languageData: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
export function timeline() {
|
|
||||||
return new LanguageSupport(timelineLanguage);
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { styleTags, tags as t } from '@lezer/highlight';
|
|
||||||
|
|
||||||
export const highlight = styleTags({
|
|
||||||
OutgoingText: t.propertyName, // > lines - primary color (matches timeline icons)
|
|
||||||
IncomingText: t.tagName, // < lines - info color (matches timeline icons)
|
|
||||||
InfoText: t.comment, // * lines - subtle color (matches timeline icons)
|
|
||||||
});
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
@top Timeline { line* }
|
|
||||||
|
|
||||||
line { OutgoingLine | IncomingLine | InfoLine | PlainLine }
|
|
||||||
|
|
||||||
@skip {} {
|
|
||||||
OutgoingLine { OutgoingText Newline }
|
|
||||||
IncomingLine { IncomingText Newline }
|
|
||||||
InfoLine { InfoText Newline }
|
|
||||||
PlainLine { PlainText Newline }
|
|
||||||
}
|
|
||||||
|
|
||||||
@tokens {
|
|
||||||
OutgoingText { "> " ![\n]* }
|
|
||||||
IncomingText { "< " ![\n]* }
|
|
||||||
InfoText { "* " ![\n]* }
|
|
||||||
PlainText { ![\n]+ }
|
|
||||||
Newline { "\n" }
|
|
||||||
@precedence { OutgoingText, IncomingText, InfoText, PlainText }
|
|
||||||
}
|
|
||||||
|
|
||||||
@external propSource highlight from "./highlight"
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
|
||||||
export const
|
|
||||||
Timeline = 1,
|
|
||||||
OutgoingLine = 2,
|
|
||||||
OutgoingText = 3,
|
|
||||||
Newline = 4,
|
|
||||||
IncomingLine = 5,
|
|
||||||
IncomingText = 6,
|
|
||||||
InfoLine = 7,
|
|
||||||
InfoText = 8,
|
|
||||||
PlainLine = 9,
|
|
||||||
PlainText = 10
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
|
||||||
import {LRParser} from "@lezer/lr"
|
|
||||||
import {highlight} from "./highlight"
|
|
||||||
export const parser = LRParser.deserialize({
|
|
||||||
version: 14,
|
|
||||||
states: "!pQQOPOOO`OPO'#C^OeOPO'#CaOjOPO'#CcOoOPO'#CeOOOO'#Ci'#CiOOOO'#Cg'#CgQQOPOOOOOO,58x,58xOOOO,58{,58{OOOO,58},58}OOOO,59P,59POOOO-E6e-E6e",
|
|
||||||
stateData: "z~ORPOUQOWROYSO~OSWO~OSXO~OSYO~OSZO~ORUWYW~",
|
|
||||||
goto: "m^PP_PP_P_P_PcPiTTOVQVOR[VTUOV",
|
|
||||||
nodeNames: "⚠ Timeline OutgoingLine OutgoingText Newline IncomingLine IncomingText InfoLine InfoText PlainLine PlainText",
|
|
||||||
maxTerm: 13,
|
|
||||||
propSources: [highlight],
|
|
||||||
skippedNodes: [0],
|
|
||||||
repeatNodeCount: 1,
|
|
||||||
tokenData: "%h~RZOYtYZ!]Zztz{!b{!^t!^!_#d!_!`t!`!a$f!a;'St;'S;=`!V<%lOt~ySY~OYtZ;'St;'S;=`!V<%lOt~!YP;=`<%lt~!bOS~~!gUY~OYtZptpq!yq;'St;'S;=`!V<%lOt~#QSW~Y~OY!yZ;'S!y;'S;=`#^<%lO!y~#aP;=`<%l!y~#iUY~OYtZptpq#{q;'St;'S;=`!V<%lOt~$SSU~Y~OY#{Z;'S#{;'S;=`$`<%lO#{~$cP;=`<%l#{~$kUY~OYtZptpq$}q;'St;'S;=`!V<%lOt~%USR~Y~OY$}Z;'S$};'S;=`%b<%lO$}~%eP;=`<%l$}",
|
|
||||||
tokenizers: [0],
|
|
||||||
topRules: {"Timeline":[0,1]},
|
|
||||||
tokenPrec: 36
|
|
||||||
})
|
|
||||||
@@ -1,258 +0,0 @@
|
|||||||
import type { Virtualizer } from '@tanstack/react-virtual';
|
|
||||||
import { format } from 'date-fns';
|
|
||||||
import type { ReactNode } from 'react';
|
|
||||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
|
||||||
import { useEventViewerKeyboard } from '../../hooks/useEventViewerKeyboard';
|
|
||||||
import { CopyIconButton } from '../CopyIconButton';
|
|
||||||
import { AutoScroller } from './AutoScroller';
|
|
||||||
import { Banner } from './Banner';
|
|
||||||
import { Button } from './Button';
|
|
||||||
import { Separator } from './Separator';
|
|
||||||
import { SplitLayout } from './SplitLayout';
|
|
||||||
import { HStack } from './Stacks';
|
|
||||||
import { IconButton } from './IconButton';
|
|
||||||
import classNames from 'classnames';
|
|
||||||
|
|
||||||
interface EventViewerProps<T> {
|
|
||||||
/** Array of events to display */
|
|
||||||
events: T[];
|
|
||||||
|
|
||||||
/** Get unique key for each event */
|
|
||||||
getEventKey: (event: T, index: number) => string;
|
|
||||||
|
|
||||||
/** Render the event row - receives event, index, isActive, and onClick */
|
|
||||||
renderRow: (props: {
|
|
||||||
event: T;
|
|
||||||
index: number;
|
|
||||||
isActive: boolean;
|
|
||||||
onClick: () => void;
|
|
||||||
}) => ReactNode;
|
|
||||||
|
|
||||||
/** Render the detail pane for the selected event */
|
|
||||||
renderDetail?: (props: { event: T; index: number; onClose: () => void }) => ReactNode;
|
|
||||||
|
|
||||||
/** Optional header above the event list (e.g., connection status) */
|
|
||||||
header?: ReactNode;
|
|
||||||
|
|
||||||
/** Error message to display as a banner */
|
|
||||||
error?: string | null;
|
|
||||||
|
|
||||||
/** Name for SplitLayout state persistence */
|
|
||||||
splitLayoutName: string;
|
|
||||||
|
|
||||||
/** Default ratio for the split (0.0 - 1.0) */
|
|
||||||
defaultRatio?: number;
|
|
||||||
|
|
||||||
/** Enable keyboard navigation (arrow keys) */
|
|
||||||
enableKeyboardNav?: boolean;
|
|
||||||
|
|
||||||
/** Loading state */
|
|
||||||
isLoading?: boolean;
|
|
||||||
|
|
||||||
/** Message to show while loading */
|
|
||||||
loadingMessage?: string;
|
|
||||||
|
|
||||||
/** Message to show when no events */
|
|
||||||
emptyMessage?: string;
|
|
||||||
|
|
||||||
/** Callback when active index changes (for controlled state in parent) */
|
|
||||||
onActiveIndexChange?: (index: number | null) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EventViewer<T>({
|
|
||||||
events,
|
|
||||||
getEventKey,
|
|
||||||
renderRow,
|
|
||||||
renderDetail,
|
|
||||||
header,
|
|
||||||
error,
|
|
||||||
splitLayoutName,
|
|
||||||
defaultRatio = 0.4,
|
|
||||||
enableKeyboardNav = true,
|
|
||||||
isLoading = false,
|
|
||||||
loadingMessage = 'Loading events...',
|
|
||||||
emptyMessage = 'No events recorded',
|
|
||||||
onActiveIndexChange,
|
|
||||||
}: EventViewerProps<T>) {
|
|
||||||
const [activeIndex, setActiveIndexInternal] = useState<number | null>(null);
|
|
||||||
const [isPanelOpen, setIsPanelOpen] = useState(false);
|
|
||||||
|
|
||||||
// Wrap setActiveIndex to notify parent
|
|
||||||
const setActiveIndex = useCallback(
|
|
||||||
(indexOrUpdater: number | null | ((prev: number | null) => number | null)) => {
|
|
||||||
setActiveIndexInternal((prev) => {
|
|
||||||
const newIndex =
|
|
||||||
typeof indexOrUpdater === 'function' ? indexOrUpdater(prev) : indexOrUpdater;
|
|
||||||
onActiveIndexChange?.(newIndex);
|
|
||||||
return newIndex;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[onActiveIndexChange],
|
|
||||||
);
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const virtualizerRef = useRef<Virtualizer<HTMLDivElement, Element> | null>(null);
|
|
||||||
|
|
||||||
const activeEvent = useMemo(
|
|
||||||
() => (activeIndex != null ? events[activeIndex] : null),
|
|
||||||
[activeIndex, events],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check if the event list container is focused
|
|
||||||
const isContainerFocused = useCallback(() => {
|
|
||||||
return containerRef.current?.contains(document.activeElement) ?? false;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Keyboard navigation
|
|
||||||
useEventViewerKeyboard({
|
|
||||||
totalCount: events.length,
|
|
||||||
activeIndex,
|
|
||||||
setActiveIndex,
|
|
||||||
virtualizer: virtualizerRef.current,
|
|
||||||
isContainerFocused,
|
|
||||||
enabled: enableKeyboardNav,
|
|
||||||
closePanel: () => setIsPanelOpen(false),
|
|
||||||
openPanel: () => setIsPanelOpen(true),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle virtualizer ready callback
|
|
||||||
const handleVirtualizerReady = useCallback(
|
|
||||||
(virtualizer: Virtualizer<HTMLDivElement, Element>) => {
|
|
||||||
virtualizerRef.current = virtualizer;
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Handle row click - select and open panel, scroll into view
|
|
||||||
const handleRowClick = useCallback(
|
|
||||||
(index: number) => {
|
|
||||||
setActiveIndex(index);
|
|
||||||
setIsPanelOpen(true);
|
|
||||||
// Scroll to ensure selected item is visible after panel opens
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
virtualizerRef.current?.scrollToIndex(index, { align: 'auto' });
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[setActiveIndex],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
|
||||||
setIsPanelOpen(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <div className="p-3 text-text-subtlest italic">{loadingMessage}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (events.length === 0 && !error) {
|
|
||||||
return <div className="p-3 text-text-subtlest italic">{emptyMessage}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={containerRef} className="h-full">
|
|
||||||
<SplitLayout
|
|
||||||
layout="vertical"
|
|
||||||
name={splitLayoutName}
|
|
||||||
defaultRatio={defaultRatio}
|
|
||||||
minHeightPx={10}
|
|
||||||
firstSlot={({ style }) => (
|
|
||||||
<div style={style} className="w-full h-full grid grid-rows-[auto_minmax(0,1fr)]">
|
|
||||||
{header ?? <span aria-hidden />}
|
|
||||||
<AutoScroller
|
|
||||||
data={events}
|
|
||||||
focusable={enableKeyboardNav}
|
|
||||||
onVirtualizerReady={handleVirtualizerReady}
|
|
||||||
header={
|
|
||||||
error && (
|
|
||||||
<Banner color="danger" className="m-3">
|
|
||||||
{error}
|
|
||||||
</Banner>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
render={(event, index) => (
|
|
||||||
<div key={getEventKey(event, index)}>
|
|
||||||
{renderRow({
|
|
||||||
event,
|
|
||||||
index,
|
|
||||||
isActive: index === activeIndex,
|
|
||||||
onClick: () => handleRowClick(index),
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
secondSlot={
|
|
||||||
activeEvent != null && renderDetail && isPanelOpen
|
|
||||||
? ({ style }) => (
|
|
||||||
<div style={style} className="grid grid-rows-[auto_minmax(0,1fr)] bg-surface">
|
|
||||||
<div className="pb-3 px-2">
|
|
||||||
<Separator />
|
|
||||||
</div>
|
|
||||||
<div className="mx-2 overflow-y-auto">
|
|
||||||
{renderDetail({ event: activeEvent, index: activeIndex ?? 0, onClose: handleClose })}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EventDetailAction {
|
|
||||||
/** Unique key for React */
|
|
||||||
key: string;
|
|
||||||
/** Button label */
|
|
||||||
label: string;
|
|
||||||
/** Optional icon */
|
|
||||||
icon?: ReactNode;
|
|
||||||
/** Click handler */
|
|
||||||
onClick: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EventDetailHeaderProps {
|
|
||||||
title: string;
|
|
||||||
prefix?: ReactNode;
|
|
||||||
timestamp?: string;
|
|
||||||
actions?: EventDetailAction[];
|
|
||||||
copyText?: string;
|
|
||||||
onClose?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EventDetailHeader({
|
|
||||||
title,
|
|
||||||
prefix,
|
|
||||||
timestamp,
|
|
||||||
actions,
|
|
||||||
copyText,
|
|
||||||
onClose,
|
|
||||||
}: EventDetailHeaderProps) {
|
|
||||||
const formattedTime = timestamp ? format(new Date(`${timestamp}Z`), 'HH:mm:ss.SSS') : null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between gap-2 mb-2 h-xs">
|
|
||||||
<HStack space={2} className="items-center min-w-0">
|
|
||||||
{prefix}
|
|
||||||
<h3 className="font-semibold select-auto cursor-auto truncate">{title}</h3>
|
|
||||||
</HStack>
|
|
||||||
<HStack space={2} className="items-center">
|
|
||||||
{actions?.map((action) => (
|
|
||||||
<Button key={action.key} variant="border" size="xs" onClick={action.onClick}>
|
|
||||||
{action.icon}
|
|
||||||
{action.label}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
{copyText != null && (
|
|
||||||
<CopyIconButton text={copyText} size="xs" title="Copy" variant="border" iconSize="sm" />
|
|
||||||
)}
|
|
||||||
{formattedTime && (
|
|
||||||
<span className="text-text-subtlest font-mono text-editor ml-2">{formattedTime}</span>
|
|
||||||
)}
|
|
||||||
<div className={classNames(copyText != null || formattedTime || (actions ?? []).length > 0 && "border-l border-l-surface-highlight ml-2 pl-3")}>
|
|
||||||
<IconButton color="custom" className="text-text-subtle -mr-3" size="xs" icon="x" title="Close event panel" onClick={onClose} />
|
|
||||||
</div>
|
|
||||||
</HStack>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import classNames from 'classnames';
|
|
||||||
import { format } from 'date-fns';
|
|
||||||
import type { ReactNode } from 'react';
|
|
||||||
|
|
||||||
interface EventViewerRowProps {
|
|
||||||
isActive: boolean;
|
|
||||||
onClick: () => void;
|
|
||||||
icon: ReactNode;
|
|
||||||
content: ReactNode;
|
|
||||||
timestamp?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EventViewerRow({
|
|
||||||
isActive,
|
|
||||||
onClick,
|
|
||||||
icon,
|
|
||||||
content,
|
|
||||||
timestamp,
|
|
||||||
}: EventViewerRowProps) {
|
|
||||||
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}
|
|
||||||
<div className="w-full truncate">{content}</div>
|
|
||||||
{timestamp && <div className="opacity-50">{format(`${timestamp}Z`, 'HH:mm:ss.SSS')}</div>}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -19,8 +19,7 @@ export function HttpResponseDurationTag({ response }: Props) {
|
|||||||
return () => clearInterval(timeout.current);
|
return () => clearInterval(timeout.current);
|
||||||
}, [response.createdAt, response.state]);
|
}, [response.createdAt, response.state]);
|
||||||
|
|
||||||
const dnsValue = response.elapsedDns > 0 ? formatMillis(response.elapsedDns) : '--';
|
const title = `HEADER: ${formatMillis(response.elapsedHeaders)}\nTOTAL: ${formatMillis(response.elapsed)}`;
|
||||||
const title = `DNS: ${dnsValue}\nHEADER: ${formatMillis(response.elapsedHeaders)}\nTOTAL: ${formatMillis(response.elapsed)}`;
|
|
||||||
|
|
||||||
const elapsed = response.state === 'closed' ? response.elapsed : fallbackElapsed;
|
const elapsed = response.state === 'closed' ? response.elapsed : fallbackElapsed;
|
||||||
|
|
||||||
|
|||||||
@@ -78,7 +78,6 @@ import {
|
|||||||
GitCommitVerticalIcon,
|
GitCommitVerticalIcon,
|
||||||
GitForkIcon,
|
GitForkIcon,
|
||||||
GitPullRequestIcon,
|
GitPullRequestIcon,
|
||||||
GlobeIcon,
|
|
||||||
GripVerticalIcon,
|
GripVerticalIcon,
|
||||||
HandIcon,
|
HandIcon,
|
||||||
HardDriveDownloadIcon,
|
HardDriveDownloadIcon,
|
||||||
@@ -213,7 +212,6 @@ const icons = {
|
|||||||
git_commit_vertical: GitCommitVerticalIcon,
|
git_commit_vertical: GitCommitVerticalIcon,
|
||||||
git_fork: GitForkIcon,
|
git_fork: GitForkIcon,
|
||||||
git_pull_request: GitPullRequestIcon,
|
git_pull_request: GitPullRequestIcon,
|
||||||
globe: GlobeIcon,
|
|
||||||
grip_vertical: GripVerticalIcon,
|
grip_vertical: GripVerticalIcon,
|
||||||
circle_off: CircleOffIcon,
|
circle_off: CircleOffIcon,
|
||||||
hand: HandIcon,
|
hand: HandIcon,
|
||||||
|
|||||||
@@ -4,15 +4,15 @@ import type { HTMLAttributes, ReactElement, ReactNode } from 'react';
|
|||||||
interface Props {
|
interface Props {
|
||||||
children:
|
children:
|
||||||
| ReactElement<HTMLAttributes<HTMLTableColElement>>
|
| ReactElement<HTMLAttributes<HTMLTableColElement>>
|
||||||
| (ReactElement<HTMLAttributes<HTMLTableColElement>> | null)[];
|
| ReactElement<HTMLAttributes<HTMLTableColElement>>[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function KeyValueRows({ children }: Props) {
|
export function KeyValueRows({ children }: Props) {
|
||||||
const childArray = Array.isArray(children) ? children.filter(Boolean) : [children];
|
children = Array.isArray(children) ? children : [children];
|
||||||
return (
|
return (
|
||||||
<table className="text-editor font-mono min-w-0 w-full mb-auto">
|
<table className="text-editor font-mono min-w-0 w-full mb-auto">
|
||||||
<tbody className="divide-y divide-surface-highlight">
|
<tbody className="divide-y divide-surface-highlight">
|
||||||
{childArray.map((child, i) => (
|
{children.map((child, i) => (
|
||||||
// biome-ignore lint/suspicious/noArrayIndexKey: none
|
// biome-ignore lint/suspicious/noArrayIndexKey: none
|
||||||
<tr key={i}>{child}</tr>
|
<tr key={i}>{child}</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -136,8 +136,8 @@ export function PairEditor({
|
|||||||
rowsRef.current[id] = n;
|
rowsRef.current[id] = n;
|
||||||
const validHandles = Object.values(rowsRef.current).filter((v) => v != null);
|
const validHandles = Object.values(rowsRef.current).filter((v) => v != null);
|
||||||
|
|
||||||
// Use >= because more might be added if an ID of one changes (eg. editing placeholder in URL regenerates fresh pairs every keystroke)
|
// NOTE: Ignore the last placeholder pair
|
||||||
const ready = validHandles.length >= pairs.length - 1;
|
const ready = validHandles.length === pairs.length - 1;
|
||||||
if (ready) {
|
if (ready) {
|
||||||
setRef?.(handle);
|
setRef?.(handle);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,17 +10,8 @@ import {
|
|||||||
useSensors,
|
useSensors,
|
||||||
} from '@dnd-kit/core';
|
} from '@dnd-kit/core';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import type { ReactNode, Ref } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import {
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
forwardRef,
|
|
||||||
memo,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useImperativeHandle,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
|
||||||
import { useKeyValue } from '../../../hooks/useKeyValue';
|
import { useKeyValue } from '../../../hooks/useKeyValue';
|
||||||
import { computeSideForDragMove } from '../../../lib/dnd';
|
import { computeSideForDragMove } from '../../../lib/dnd';
|
||||||
import { DropMarker } from '../../DropMarker';
|
import { DropMarker } from '../../DropMarker';
|
||||||
@@ -46,120 +37,41 @@ export type TabItem =
|
|||||||
rightSlot?: ReactNode;
|
rightSlot?: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface TabsStorage {
|
|
||||||
order: string[];
|
|
||||||
activeTabs: Record<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TabsRef {
|
|
||||||
/** Programmatically set the active tab */
|
|
||||||
setActiveTab: (value: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
label: string;
|
label: string;
|
||||||
/** Default tab value. If not provided, defaults to first tab. */
|
value?: string;
|
||||||
defaultValue?: string;
|
onChangeValue: (value: string) => void;
|
||||||
/** Called when active tab changes */
|
|
||||||
onChangeValue?: (value: string) => void;
|
|
||||||
tabs: TabItem[];
|
tabs: TabItem[];
|
||||||
tabListClassName?: string;
|
tabListClassName?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
addBorders?: boolean;
|
addBorders?: boolean;
|
||||||
layout?: 'horizontal' | 'vertical';
|
layout?: 'horizontal' | 'vertical';
|
||||||
/** Storage key for persisting tab order and active tab. When provided, enables drag-to-reorder and active tab persistence. */
|
|
||||||
storageKey?: string | string[];
|
storageKey?: string | string[];
|
||||||
/** Key to identify which context this tab belongs to (e.g., request ID). Used for per-context active tab persistence. */
|
|
||||||
activeTabKey?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Tabs = forwardRef<TabsRef, Props>(function Tabs(
|
export function Tabs({
|
||||||
{
|
value,
|
||||||
defaultValue,
|
onChangeValue,
|
||||||
onChangeValue: onChangeValueProp,
|
label,
|
||||||
label,
|
children,
|
||||||
children,
|
tabs: originalTabs,
|
||||||
tabs: originalTabs,
|
className,
|
||||||
className,
|
tabListClassName,
|
||||||
tabListClassName,
|
addBorders,
|
||||||
addBorders,
|
layout = 'vertical',
|
||||||
layout = 'vertical',
|
storageKey,
|
||||||
storageKey,
|
}: Props) {
|
||||||
activeTabKey,
|
|
||||||
}: Props,
|
|
||||||
forwardedRef: Ref<TabsRef>,
|
|
||||||
) {
|
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
const reorderable = !!storageKey;
|
const reorderable = !!storageKey;
|
||||||
|
|
||||||
// Use key-value storage for persistence if storageKey is provided
|
// Use key-value storage for persistence if storageKey is provided
|
||||||
// Handle migration from old format (string[]) to new format (TabsStorage)
|
const { value: savedOrder, set: setSavedOrder } = useKeyValue<string[]>({
|
||||||
const { value: rawStorage, set: setStorage } = useKeyValue<TabsStorage | string[]>({
|
namespace: 'global',
|
||||||
namespace: 'no_sync',
|
key: storageKey ?? ['tabs_order', 'default'],
|
||||||
key: storageKey ?? ['tabs', 'default'],
|
fallback: [],
|
||||||
fallback: { order: [], activeTabs: {} },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Migrate old format (string[]) to new format (TabsStorage)
|
|
||||||
const storage: TabsStorage = Array.isArray(rawStorage)
|
|
||||||
? { order: rawStorage, activeTabs: {} }
|
|
||||||
: (rawStorage ?? { order: [], activeTabs: {} });
|
|
||||||
|
|
||||||
const savedOrder = storage.order;
|
|
||||||
|
|
||||||
// Get the active tab value - prefer storage (if activeTabKey), then defaultValue, then first tab
|
|
||||||
const storedActiveTab = activeTabKey ? storage?.activeTabs?.[activeTabKey] : undefined;
|
|
||||||
const [internalValue, setInternalValue] = useState<string | undefined>(undefined);
|
|
||||||
const value = storedActiveTab ?? internalValue ?? defaultValue ?? originalTabs[0]?.value;
|
|
||||||
|
|
||||||
// Helper to normalize storage (handle migration from old format)
|
|
||||||
const normalizeStorage = useCallback(
|
|
||||||
(s: TabsStorage | string[]): TabsStorage =>
|
|
||||||
Array.isArray(s) ? { order: s, activeTabs: {} } : s,
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Handle tab change - update internal state, storage if we have a key, and call prop callback
|
|
||||||
const onChangeValue = useCallback(
|
|
||||||
async (newValue: string) => {
|
|
||||||
setInternalValue(newValue);
|
|
||||||
if (storageKey && activeTabKey) {
|
|
||||||
await setStorage((s) => {
|
|
||||||
const normalized = normalizeStorage(s);
|
|
||||||
return {
|
|
||||||
...normalized,
|
|
||||||
activeTabs: { ...normalized.activeTabs, [activeTabKey]: newValue },
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
onChangeValueProp?.(newValue);
|
|
||||||
},
|
|
||||||
[storageKey, activeTabKey, setStorage, onChangeValueProp, normalizeStorage],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Expose imperative methods via ref
|
|
||||||
useImperativeHandle(
|
|
||||||
forwardedRef,
|
|
||||||
() => ({
|
|
||||||
setActiveTab: (value: string) => {
|
|
||||||
onChangeValue(value);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
[onChangeValue],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Helper to save order
|
|
||||||
const setSavedOrder = useCallback(
|
|
||||||
async (order: string[]) => {
|
|
||||||
await setStorage((s) => {
|
|
||||||
const normalized = normalizeStorage(s);
|
|
||||||
return { ...normalized, order };
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[setStorage, normalizeStorage],
|
|
||||||
);
|
|
||||||
|
|
||||||
// State for ordered tabs
|
// State for ordered tabs
|
||||||
const [orderedTabs, setOrderedTabs] = useState<TabItem[]>(originalTabs);
|
const [orderedTabs, setOrderedTabs] = useState<TabItem[]>(originalTabs);
|
||||||
const [isDragging, setIsDragging] = useState<TabItem | null>(null);
|
const [isDragging, setIsDragging] = useState<TabItem | null>(null);
|
||||||
@@ -200,6 +112,8 @@ export const Tabs = forwardRef<TabsRef, Props>(function Tabs(
|
|||||||
|
|
||||||
const tabs = storageKey ? orderedTabs : originalTabs;
|
const tabs = storageKey ? orderedTabs : originalTabs;
|
||||||
|
|
||||||
|
value = value ?? tabs[0]?.value;
|
||||||
|
|
||||||
// Update tabs when value changes
|
// Update tabs when value changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const tabs = ref.current?.querySelectorAll<HTMLDivElement>('[data-tab]');
|
const tabs = ref.current?.querySelectorAll<HTMLDivElement>('[data-tab]');
|
||||||
@@ -301,10 +215,13 @@ export const Tabs = forwardRef<TabsRef, Props>(function Tabs(
|
|||||||
items.push(
|
items.push(
|
||||||
<div
|
<div
|
||||||
key={`marker-${t.value}`}
|
key={`marker-${t.value}`}
|
||||||
className={classNames('relative', layout === 'vertical' ? 'w-0' : 'h-0')}
|
className={classNames(
|
||||||
|
'relative',
|
||||||
|
layout === 'vertical' ? 'w-0' : 'h-0',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<DropMarker orientation={layout === 'vertical' ? 'vertical' : 'horizontal'} />
|
<DropMarker orientation={layout === 'vertical' ? 'vertical' : 'horizontal'} />
|
||||||
</div>,
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,7 +235,7 @@ export const Tabs = forwardRef<TabsRef, Props>(function Tabs(
|
|||||||
reorderable={reorderable}
|
reorderable={reorderable}
|
||||||
isDragging={isDragging?.value === t.value}
|
isDragging={isDragging?.value === t.value}
|
||||||
onChangeValue={onChangeValue}
|
onChangeValue={onChangeValue}
|
||||||
/>,
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
return items;
|
return items;
|
||||||
@@ -347,7 +264,12 @@ export const Tabs = forwardRef<TabsRef, Props>(function Tabs(
|
|||||||
>
|
>
|
||||||
{tabButtons}
|
{tabButtons}
|
||||||
{hoveredIndex === tabs.length && (
|
{hoveredIndex === tabs.length && (
|
||||||
<div className={classNames('relative', layout === 'vertical' ? 'w-0' : 'h-0')}>
|
<div
|
||||||
|
className={classNames(
|
||||||
|
'relative',
|
||||||
|
layout === 'vertical' ? 'w-0' : 'h-0',
|
||||||
|
)}
|
||||||
|
>
|
||||||
<DropMarker orientation={layout === 'vertical' ? 'vertical' : 'horizontal'} />
|
<DropMarker orientation={layout === 'vertical' ? 'vertical' : 'horizontal'} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -398,7 +320,7 @@ export const Tabs = forwardRef<TabsRef, Props>(function Tabs(
|
|||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
|
||||||
interface TabButtonProps {
|
interface TabButtonProps {
|
||||||
tab: TabItem;
|
tab: TabItem;
|
||||||
@@ -407,7 +329,7 @@ interface TabButtonProps {
|
|||||||
layout: 'horizontal' | 'vertical';
|
layout: 'horizontal' | 'vertical';
|
||||||
reorderable: boolean;
|
reorderable: boolean;
|
||||||
isDragging: boolean;
|
isDragging: boolean;
|
||||||
onChangeValue?: (value: string) => void;
|
onChangeValue: (value: string) => void;
|
||||||
overlay?: boolean;
|
overlay?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -428,8 +350,6 @@ function TabButton({
|
|||||||
} = useDraggable({
|
} = useDraggable({
|
||||||
id: tab.value,
|
id: tab.value,
|
||||||
disabled: !reorderable,
|
disabled: !reorderable,
|
||||||
// The button inside handles focus
|
|
||||||
attributes: { tabIndex: -1 },
|
|
||||||
});
|
});
|
||||||
const { setNodeRef: setDroppableRef } = useDroppable({
|
const { setNodeRef: setDroppableRef } = useDroppable({
|
||||||
id: tab.value,
|
id: tab.value,
|
||||||
@@ -449,12 +369,7 @@ function TabButton({
|
|||||||
const btnProps: Partial<ButtonProps> = {
|
const btnProps: Partial<ButtonProps> = {
|
||||||
color: 'custom',
|
color: 'custom',
|
||||||
justify: layout === 'horizontal' ? 'start' : 'center',
|
justify: layout === 'horizontal' ? 'start' : 'center',
|
||||||
onClick: isActive
|
onClick: isActive ? undefined : () => onChangeValue(tab.value),
|
||||||
? undefined
|
|
||||||
: (e: React.MouseEvent) => {
|
|
||||||
e.preventDefault(); // Prevent dropdown from opening on first click
|
|
||||||
onChangeValue?.(tab.value);
|
|
||||||
},
|
|
||||||
className: classNames(
|
className: classNames(
|
||||||
'flex items-center rounded whitespace-nowrap',
|
'flex items-center rounded whitespace-nowrap',
|
||||||
'!px-2 ml-[1px]',
|
'!px-2 ml-[1px]',
|
||||||
@@ -511,7 +426,11 @@ function TabButton({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Button leftSlot={tab.leftSlot} rightSlot={tab.rightSlot} {...btnProps}>
|
<Button
|
||||||
|
leftSlot={tab.leftSlot}
|
||||||
|
rightSlot={tab.rightSlot}
|
||||||
|
{...btnProps}
|
||||||
|
>
|
||||||
{'label' in tab && tab.label ? tab.label : tab.value}
|
{'label' in tab && tab.label ? tab.label : tab.value}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
@@ -554,32 +473,3 @@ export const TabContent = memo(function TabContent({
|
|||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Programmatically set the active tab for a Tabs component that uses storageKey + activeTabKey.
|
|
||||||
* This is useful when you need to change the tab from outside the component (e.g., in response to an event).
|
|
||||||
*/
|
|
||||||
export async function setActiveTab({
|
|
||||||
storageKey,
|
|
||||||
activeTabKey,
|
|
||||||
value,
|
|
||||||
}: {
|
|
||||||
storageKey: string;
|
|
||||||
activeTabKey: string;
|
|
||||||
value: string;
|
|
||||||
}): Promise<void> {
|
|
||||||
const { getKeyValue, setKeyValue } = await import('../../../lib/keyValueStore');
|
|
||||||
const current = getKeyValue<TabsStorage>({
|
|
||||||
namespace: 'no_sync',
|
|
||||||
key: storageKey,
|
|
||||||
fallback: { order: [], activeTabs: {} },
|
|
||||||
});
|
|
||||||
await setKeyValue({
|
|
||||||
namespace: 'no_sync',
|
|
||||||
key: storageKey,
|
|
||||||
value: {
|
|
||||||
...current,
|
|
||||||
activeTabs: { ...current.activeTabs, [activeTabKey]: value },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user