mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-02-02 02:32:07 -05:00
Compare commits
13 Commits
v2026.1.1
...
actions-sy
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
986143c4ae | ||
|
|
50b0e23d53 | ||
|
|
c4ce458f79 | ||
|
|
f02ae35634 | ||
|
|
c2f068970b | ||
|
|
eec2d6bc38 | ||
|
|
efa22e470e | ||
|
|
c00d2e981f | ||
|
|
9c45254952 | ||
|
|
d031ff231a | ||
|
|
f056894ddb | ||
|
|
1b0315165f | ||
|
|
bd7e840a57 |
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -89,6 +89,8 @@ jobs:
|
||||
|
||||
- run: npm ci
|
||||
- run: npm run bootstrap
|
||||
env:
|
||||
YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}
|
||||
- run: npm run lint
|
||||
- name: Run JS Tests
|
||||
run: npm test
|
||||
|
||||
29
Cargo.lock
generated
29
Cargo.lock
generated
@@ -7994,6 +7994,33 @@ dependencies = [
|
||||
"rustix 1.0.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yaak-actions"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"ts-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yaak-actions-builtin"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"yaak-actions",
|
||||
"yaak-crypto",
|
||||
"yaak-http",
|
||||
"yaak-models",
|
||||
"yaak-plugins",
|
||||
"yaak-templates",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yaak-app"
|
||||
version = "0.0.0"
|
||||
@@ -8063,6 +8090,8 @@ dependencies = [
|
||||
"log",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"yaak-actions",
|
||||
"yaak-actions-builtin",
|
||||
"yaak-crypto",
|
||||
"yaak-http",
|
||||
"yaak-models",
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
resolver = "2"
|
||||
members = [
|
||||
# Shared crates (no Tauri dependency)
|
||||
"crates/yaak-actions",
|
||||
"crates/yaak-actions-builtin",
|
||||
"crates/yaak-core",
|
||||
"crates/yaak-common",
|
||||
"crates/yaak-crypto",
|
||||
@@ -45,6 +47,8 @@ tokio = "1.48.0"
|
||||
ts-rs = "11.1.0"
|
||||
|
||||
# Internal crates - shared
|
||||
yaak-actions = { path = "crates/yaak-actions" }
|
||||
yaak-actions-builtin = { path = "crates/yaak-actions-builtin" }
|
||||
yaak-core = { path = "crates/yaak-core" }
|
||||
yaak-common = { path = "crates/yaak-common" }
|
||||
yaak-crypto = { path = "crates/yaak-crypto" }
|
||||
|
||||
@@ -15,6 +15,8 @@ env_logger = "0.11"
|
||||
log = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
||||
yaak-actions = { workspace = true }
|
||||
yaak-actions-builtin = { workspace = true }
|
||||
yaak-crypto = { workspace = true }
|
||||
yaak-http = { workspace = true }
|
||||
yaak-models = { workspace = true }
|
||||
|
||||
@@ -1,21 +1,13 @@
|
||||
use clap::{Parser, Subcommand};
|
||||
use log::info;
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
use yaak_crypto::manager::EncryptionManager;
|
||||
use yaak_http::path_placeholders::apply_path_placeholders;
|
||||
use yaak_http::sender::{HttpSender, ReqwestSender};
|
||||
use yaak_http::types::{SendableHttpRequest, SendableHttpRequestOptions};
|
||||
use yaak_models::models::{HttpRequest, HttpRequestHeader, HttpUrlParameter};
|
||||
use yaak_models::render::make_vars_hashmap;
|
||||
use yaak_models::models::HttpRequest;
|
||||
use yaak_models::util::UpdateSource;
|
||||
use yaak_plugins::events::{PluginContext, RenderPurpose};
|
||||
use yaak_plugins::events::PluginContext;
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
use yaak_plugins::template_callback::PluginTemplateCallback;
|
||||
use yaak_templates::{RenderOptions, parse_and_render, render_json_value_raw};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "yaakcli")]
|
||||
@@ -72,86 +64,6 @@ enum Commands {
|
||||
},
|
||||
}
|
||||
|
||||
/// Render an HTTP request with template variables and plugin functions
|
||||
async fn render_http_request(
|
||||
r: &HttpRequest,
|
||||
environment_chain: Vec<yaak_models::models::Environment>,
|
||||
cb: &PluginTemplateCallback,
|
||||
opt: &RenderOptions,
|
||||
) -> yaak_templates::error::Result<HttpRequest> {
|
||||
let vars = &make_vars_hashmap(environment_chain);
|
||||
|
||||
let mut url_parameters = Vec::new();
|
||||
for p in r.url_parameters.clone() {
|
||||
if !p.enabled {
|
||||
continue;
|
||||
}
|
||||
url_parameters.push(HttpUrlParameter {
|
||||
enabled: p.enabled,
|
||||
name: parse_and_render(p.name.as_str(), vars, cb, opt).await?,
|
||||
value: parse_and_render(p.value.as_str(), vars, cb, opt).await?,
|
||||
id: p.id,
|
||||
})
|
||||
}
|
||||
|
||||
let mut headers = Vec::new();
|
||||
for p in r.headers.clone() {
|
||||
if !p.enabled {
|
||||
continue;
|
||||
}
|
||||
headers.push(HttpRequestHeader {
|
||||
enabled: p.enabled,
|
||||
name: parse_and_render(p.name.as_str(), vars, cb, opt).await?,
|
||||
value: parse_and_render(p.value.as_str(), vars, cb, opt).await?,
|
||||
id: p.id,
|
||||
})
|
||||
}
|
||||
|
||||
let mut body = BTreeMap::new();
|
||||
for (k, v) in r.body.clone() {
|
||||
body.insert(k, render_json_value_raw(v, vars, cb, opt).await?);
|
||||
}
|
||||
|
||||
let authentication = {
|
||||
let mut disabled = false;
|
||||
let mut auth = BTreeMap::new();
|
||||
match r.authentication.get("disabled") {
|
||||
Some(Value::Bool(true)) => {
|
||||
disabled = true;
|
||||
}
|
||||
Some(Value::String(tmpl)) => {
|
||||
disabled = parse_and_render(tmpl.as_str(), vars, cb, opt)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.is_empty();
|
||||
info!(
|
||||
"Rendering authentication.disabled as a template: {disabled} from \"{tmpl}\""
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if disabled {
|
||||
auth.insert("disabled".to_string(), Value::Bool(true));
|
||||
} else {
|
||||
for (k, v) in r.authentication.clone() {
|
||||
if k == "disabled" {
|
||||
auth.insert(k, Value::Bool(false));
|
||||
} else {
|
||||
auth.insert(k, render_json_value_raw(v, vars, cb, opt).await?);
|
||||
}
|
||||
}
|
||||
}
|
||||
auth
|
||||
};
|
||||
|
||||
let url = parse_and_render(r.url.clone().as_str(), vars, cb, opt).await?;
|
||||
|
||||
// Apply path placeholders (e.g., /users/:id -> /users/123)
|
||||
let (url, url_parameters) = apply_path_placeholders(&url, &url_parameters);
|
||||
|
||||
Ok(HttpRequest { url, url_parameters, headers, body, authentication, ..r.to_owned() })
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let cli = Cli::parse();
|
||||
@@ -176,10 +88,6 @@ async fn main() {
|
||||
|
||||
let db = query_manager.connect();
|
||||
|
||||
// Initialize encryption manager for secure() template function
|
||||
// Use the same app_id as the Tauri app for keyring access
|
||||
let encryption_manager = Arc::new(EncryptionManager::new(query_manager.clone(), app_id));
|
||||
|
||||
// Initialize plugin manager for template functions
|
||||
let vendored_plugin_dir = data_dir.join("vendored-plugins");
|
||||
let installed_plugin_dir = data_dir.join("installed-plugins");
|
||||
@@ -198,9 +106,9 @@ async fn main() {
|
||||
// Create plugin manager (plugins may not be available in CLI context)
|
||||
let plugin_manager = Arc::new(
|
||||
PluginManager::new(
|
||||
vendored_plugin_dir,
|
||||
installed_plugin_dir,
|
||||
node_bin_path,
|
||||
vendored_plugin_dir.clone(),
|
||||
installed_plugin_dir.clone(),
|
||||
node_bin_path.clone(),
|
||||
plugin_runtime_main,
|
||||
false,
|
||||
)
|
||||
@@ -239,94 +147,67 @@ async fn main() {
|
||||
}
|
||||
}
|
||||
Commands::Send { request_id } => {
|
||||
let request = db.get_http_request(&request_id).expect("Failed to get request");
|
||||
use yaak_actions::{
|
||||
ActionExecutor, ActionId, ActionParams, ActionResult, ActionTarget, CurrentContext,
|
||||
};
|
||||
use yaak_actions_builtin::{BuiltinActionDependencies, register_http_actions};
|
||||
|
||||
// Resolve environment chain for variable substitution
|
||||
let environment_chain = db
|
||||
.resolve_environments(
|
||||
&request.workspace_id,
|
||||
request.folder_id.as_deref(),
|
||||
cli.environment.as_deref(),
|
||||
)
|
||||
.unwrap_or_default();
|
||||
|
||||
// Create template callback with plugin support
|
||||
let plugin_context = PluginContext::new(None, Some(request.workspace_id.clone()));
|
||||
let template_callback = PluginTemplateCallback::new(
|
||||
plugin_manager.clone(),
|
||||
encryption_manager.clone(),
|
||||
&plugin_context,
|
||||
RenderPurpose::Send,
|
||||
);
|
||||
|
||||
// Render templates in the request
|
||||
let rendered_request = render_http_request(
|
||||
&request,
|
||||
environment_chain,
|
||||
&template_callback,
|
||||
&RenderOptions::throw(),
|
||||
// Create dependencies
|
||||
let deps = BuiltinActionDependencies::new_standalone(
|
||||
&db_path,
|
||||
&blob_path,
|
||||
&app_id,
|
||||
vendored_plugin_dir.clone(),
|
||||
installed_plugin_dir.clone(),
|
||||
node_bin_path.clone(),
|
||||
)
|
||||
.await
|
||||
.expect("Failed to render request templates");
|
||||
.expect("Failed to initialize dependencies");
|
||||
|
||||
if cli.verbose {
|
||||
println!("> {} {}", rendered_request.method, rendered_request.url);
|
||||
}
|
||||
// Create executor and register actions
|
||||
let executor = ActionExecutor::new();
|
||||
executor.register_builtin_groups().await.expect("Failed to register groups");
|
||||
register_http_actions(&executor, &deps).await.expect("Failed to register HTTP actions");
|
||||
|
||||
// Convert to sendable request
|
||||
let sendable = SendableHttpRequest::from_http_request(
|
||||
&rendered_request,
|
||||
SendableHttpRequestOptions::default(),
|
||||
)
|
||||
.await
|
||||
.expect("Failed to build request");
|
||||
|
||||
// Create event channel for progress
|
||||
let (event_tx, mut event_rx) = mpsc::channel(100);
|
||||
|
||||
// Spawn task to print events if verbose
|
||||
let verbose = cli.verbose;
|
||||
let verbose_handle = if verbose {
|
||||
Some(tokio::spawn(async move {
|
||||
while let Some(event) = event_rx.recv().await {
|
||||
println!("{}", event);
|
||||
}
|
||||
}))
|
||||
} else {
|
||||
// Drain events silently
|
||||
tokio::spawn(async move { while event_rx.recv().await.is_some() {} });
|
||||
None
|
||||
// Prepare context
|
||||
let context = CurrentContext {
|
||||
target: Some(ActionTarget::HttpRequest { id: request_id.clone() }),
|
||||
environment_id: cli.environment.clone(),
|
||||
workspace_id: None,
|
||||
has_window: false,
|
||||
can_prompt: false,
|
||||
};
|
||||
|
||||
// Send the request
|
||||
let sender = ReqwestSender::new().expect("Failed to create HTTP client");
|
||||
let response = sender.send(sendable, event_tx).await.expect("Failed to send request");
|
||||
// Prepare params
|
||||
let params = ActionParams {
|
||||
data: serde_json::json!({
|
||||
"render": true,
|
||||
"follow_redirects": false,
|
||||
"timeout_ms": 30000,
|
||||
}),
|
||||
};
|
||||
|
||||
// Wait for event handler to finish
|
||||
if let Some(handle) = verbose_handle {
|
||||
let _ = handle.await;
|
||||
}
|
||||
// Invoke action
|
||||
let action_id = ActionId::builtin("http", "send-request");
|
||||
let result = executor.invoke(&action_id, context, params).await.expect("Action failed");
|
||||
|
||||
// Print response
|
||||
if verbose {
|
||||
println!();
|
||||
}
|
||||
println!(
|
||||
"HTTP {} {}",
|
||||
response.status,
|
||||
response.status_reason.as_deref().unwrap_or("")
|
||||
);
|
||||
|
||||
if verbose {
|
||||
for (name, value) in &response.headers {
|
||||
println!("{}: {}", name, value);
|
||||
// Handle result
|
||||
match result {
|
||||
ActionResult::Success { data, message } => {
|
||||
if let Some(msg) = message {
|
||||
println!("{}", msg);
|
||||
}
|
||||
if let Some(data) = data {
|
||||
println!("{}", serde_json::to_string_pretty(&data).unwrap());
|
||||
}
|
||||
}
|
||||
ActionResult::RequiresInput { .. } => {
|
||||
eprintln!("Action requires input (not supported in CLI)");
|
||||
}
|
||||
ActionResult::Cancelled => {
|
||||
eprintln!("Action cancelled");
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
// Print body
|
||||
let (body, _stats) = response.text().await.expect("Failed to read response body");
|
||||
println!("{}", body);
|
||||
}
|
||||
Commands::Get { url } => {
|
||||
if cli.verbose {
|
||||
|
||||
18
crates/yaak-actions-builtin/Cargo.toml
Normal file
18
crates/yaak-actions-builtin/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "yaak-actions-builtin"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
authors = ["Gregory Schier"]
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
yaak-actions = { workspace = true }
|
||||
yaak-http = { workspace = true }
|
||||
yaak-models = { workspace = true }
|
||||
yaak-templates = { workspace = true }
|
||||
yaak-plugins = { workspace = true }
|
||||
yaak-crypto = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true, features = ["sync", "rt-multi-thread"] }
|
||||
log = { workspace = true }
|
||||
88
crates/yaak-actions-builtin/src/dependencies.rs
Normal file
88
crates/yaak-actions-builtin/src/dependencies.rs
Normal file
@@ -0,0 +1,88 @@
|
||||
//! Dependency injection for built-in actions.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use yaak_crypto::manager::EncryptionManager;
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_plugins::events::PluginContext;
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
|
||||
/// Dependencies needed by built-in action implementations.
|
||||
///
|
||||
/// This struct bundles all the dependencies that action handlers need,
|
||||
/// providing a clean way to initialize them in different contexts
|
||||
/// (CLI, Tauri app, MCP server, etc.).
|
||||
pub struct BuiltinActionDependencies {
|
||||
pub query_manager: Arc<QueryManager>,
|
||||
pub plugin_manager: Arc<PluginManager>,
|
||||
pub encryption_manager: Arc<EncryptionManager>,
|
||||
}
|
||||
|
||||
impl BuiltinActionDependencies {
|
||||
/// Create dependencies for standalone usage (CLI, MCP server, etc.)
|
||||
///
|
||||
/// This initializes all the necessary managers following the same pattern
|
||||
/// as the yaak-cli implementation.
|
||||
pub async fn new_standalone(
|
||||
db_path: &Path,
|
||||
blob_path: &Path,
|
||||
app_id: &str,
|
||||
plugin_vendored_dir: PathBuf,
|
||||
plugin_installed_dir: PathBuf,
|
||||
node_path: PathBuf,
|
||||
) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
// Initialize database
|
||||
let (query_manager, _, _) = yaak_models::init_standalone(db_path, blob_path)?;
|
||||
|
||||
// Initialize encryption manager (takes QueryManager by value)
|
||||
let encryption_manager = Arc::new(EncryptionManager::new(
|
||||
query_manager.clone(),
|
||||
app_id.to_string(),
|
||||
));
|
||||
|
||||
let query_manager = Arc::new(query_manager);
|
||||
|
||||
// Find plugin runtime
|
||||
let plugin_runtime_main = std::env::var("YAAK_PLUGIN_RUNTIME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| {
|
||||
// Development fallback
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../crates-tauri/yaak-app/vendored/plugin-runtime/index.cjs")
|
||||
});
|
||||
|
||||
// Initialize plugin manager
|
||||
let plugin_manager = Arc::new(
|
||||
PluginManager::new(
|
||||
plugin_vendored_dir,
|
||||
plugin_installed_dir,
|
||||
node_path,
|
||||
plugin_runtime_main,
|
||||
false, // not sandboxed in CLI
|
||||
)
|
||||
.await,
|
||||
);
|
||||
|
||||
// Initialize plugins from database
|
||||
let db = query_manager.connect();
|
||||
let plugins = db.list_plugins().unwrap_or_default();
|
||||
if !plugins.is_empty() {
|
||||
let errors = plugin_manager
|
||||
.initialize_all_plugins(plugins, &PluginContext::new_empty())
|
||||
.await;
|
||||
for (plugin_dir, error_msg) in errors {
|
||||
log::warn!(
|
||||
"Failed to initialize plugin '{}': {}",
|
||||
plugin_dir,
|
||||
error_msg
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
query_manager,
|
||||
plugin_manager,
|
||||
encryption_manager,
|
||||
})
|
||||
}
|
||||
}
|
||||
24
crates/yaak-actions-builtin/src/http/mod.rs
Normal file
24
crates/yaak-actions-builtin/src/http/mod.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
//! HTTP action implementations.
|
||||
|
||||
pub mod send;
|
||||
|
||||
use crate::BuiltinActionDependencies;
|
||||
use yaak_actions::{ActionError, ActionExecutor, ActionSource};
|
||||
|
||||
/// Register all HTTP-related actions with the executor.
|
||||
pub async fn register_http_actions(
|
||||
executor: &ActionExecutor,
|
||||
deps: &BuiltinActionDependencies,
|
||||
) -> Result<(), ActionError> {
|
||||
let handler = send::HttpSendActionHandler {
|
||||
query_manager: deps.query_manager.clone(),
|
||||
plugin_manager: deps.plugin_manager.clone(),
|
||||
encryption_manager: deps.encryption_manager.clone(),
|
||||
};
|
||||
|
||||
executor
|
||||
.register(send::metadata(), ActionSource::Builtin, handler)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
293
crates/yaak-actions-builtin/src/http/send.rs
Normal file
293
crates/yaak-actions-builtin/src/http/send.rs
Normal file
@@ -0,0 +1,293 @@
|
||||
//! HTTP send action implementation.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
use serde_json::{json, Value};
|
||||
use tokio::sync::mpsc;
|
||||
use yaak_actions::{
|
||||
ActionError, ActionGroupId, ActionHandler, ActionId, ActionMetadata,
|
||||
ActionParams, ActionResult, ActionScope, CurrentContext,
|
||||
RequiredContext,
|
||||
};
|
||||
use yaak_crypto::manager::EncryptionManager;
|
||||
use yaak_http::path_placeholders::apply_path_placeholders;
|
||||
use yaak_http::sender::{HttpSender, ReqwestSender};
|
||||
use yaak_http::types::{SendableHttpRequest, SendableHttpRequestOptions};
|
||||
use yaak_models::models::{HttpRequest, HttpRequestHeader, HttpUrlParameter};
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_models::render::make_vars_hashmap;
|
||||
use yaak_plugins::events::{PluginContext, RenderPurpose};
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
use yaak_plugins::template_callback::PluginTemplateCallback;
|
||||
use yaak_templates::{parse_and_render, render_json_value_raw, RenderOptions};
|
||||
|
||||
/// Handler for HTTP send action.
|
||||
pub struct HttpSendActionHandler {
|
||||
pub query_manager: Arc<QueryManager>,
|
||||
pub plugin_manager: Arc<PluginManager>,
|
||||
pub encryption_manager: Arc<EncryptionManager>,
|
||||
}
|
||||
|
||||
/// Metadata for the HTTP send action.
|
||||
pub fn metadata() -> ActionMetadata {
|
||||
ActionMetadata {
|
||||
id: ActionId::builtin("http", "send-request"),
|
||||
label: "Send HTTP Request".to_string(),
|
||||
description: Some("Execute an HTTP request and return the response".to_string()),
|
||||
icon: Some("play".to_string()),
|
||||
scope: ActionScope::HttpRequest,
|
||||
keyboard_shortcut: None,
|
||||
requires_selection: true,
|
||||
enabled_condition: None,
|
||||
group_id: Some(ActionGroupId::builtin("send")),
|
||||
order: 10,
|
||||
required_context: RequiredContext::requires_target(),
|
||||
}
|
||||
}
|
||||
|
||||
impl ActionHandler for HttpSendActionHandler {
|
||||
fn handle(
|
||||
&self,
|
||||
context: CurrentContext,
|
||||
params: ActionParams,
|
||||
) -> std::pin::Pin<
|
||||
Box<dyn std::future::Future<Output = Result<ActionResult, ActionError>> + Send + 'static>,
|
||||
> {
|
||||
let query_manager = self.query_manager.clone();
|
||||
let plugin_manager = self.plugin_manager.clone();
|
||||
let encryption_manager = self.encryption_manager.clone();
|
||||
|
||||
Box::pin(async move {
|
||||
// Extract request_id from context
|
||||
let request_id = context
|
||||
.target
|
||||
.as_ref()
|
||||
.ok_or_else(|| {
|
||||
ActionError::ContextMissing {
|
||||
missing_fields: vec!["target".to_string()],
|
||||
}
|
||||
})?
|
||||
.id()
|
||||
.ok_or_else(|| {
|
||||
ActionError::ContextMissing {
|
||||
missing_fields: vec!["target.id".to_string()],
|
||||
}
|
||||
})?
|
||||
.to_string();
|
||||
|
||||
// Fetch request and environment from database (synchronous)
|
||||
let (request, environment_chain) = {
|
||||
let db = query_manager.connect();
|
||||
|
||||
// Fetch HTTP request from database
|
||||
let request = db.get_http_request(&request_id).map_err(|e| {
|
||||
ActionError::Internal(format!("Failed to fetch request {}: {}", request_id, e))
|
||||
})?;
|
||||
|
||||
// Resolve environment chain for variable substitution
|
||||
let environment_chain = if let Some(env_id) = &context.environment_id {
|
||||
db.resolve_environments(
|
||||
&request.workspace_id,
|
||||
request.folder_id.as_deref(),
|
||||
Some(env_id),
|
||||
)
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
db.resolve_environments(
|
||||
&request.workspace_id,
|
||||
request.folder_id.as_deref(),
|
||||
None,
|
||||
)
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
(request, environment_chain)
|
||||
}; // db is dropped here
|
||||
|
||||
// Create template callback with plugin support
|
||||
let plugin_context = PluginContext::new(None, Some(request.workspace_id.clone()));
|
||||
let template_callback = PluginTemplateCallback::new(
|
||||
plugin_manager,
|
||||
encryption_manager,
|
||||
&plugin_context,
|
||||
RenderPurpose::Send,
|
||||
);
|
||||
|
||||
// Render templates in the request
|
||||
let rendered_request = render_http_request(
|
||||
&request,
|
||||
environment_chain,
|
||||
&template_callback,
|
||||
&RenderOptions::throw(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| ActionError::Internal(format!("Failed to render request: {}", e)))?;
|
||||
|
||||
// Build sendable request
|
||||
let options = SendableHttpRequestOptions {
|
||||
timeout: params
|
||||
.data
|
||||
.get("timeout_ms")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|ms| std::time::Duration::from_millis(ms)),
|
||||
follow_redirects: params
|
||||
.data
|
||||
.get("follow_redirects")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false),
|
||||
};
|
||||
|
||||
let sendable = SendableHttpRequest::from_http_request(&rendered_request, options)
|
||||
.await
|
||||
.map_err(|e| ActionError::Internal(format!("Failed to build request: {}", e)))?;
|
||||
|
||||
// Create event channel
|
||||
let (event_tx, mut event_rx) = mpsc::channel(100);
|
||||
|
||||
// Spawn task to drain events
|
||||
let _event_handle = tokio::spawn(async move {
|
||||
while event_rx.recv().await.is_some() {
|
||||
// For now, just drain events
|
||||
// In the future, we could log them or emit them to UI
|
||||
}
|
||||
});
|
||||
|
||||
// Send the request
|
||||
let sender = ReqwestSender::new()
|
||||
.map_err(|e| ActionError::Internal(format!("Failed to create HTTP client: {}", e)))?;
|
||||
let response = sender
|
||||
.send(sendable, event_tx)
|
||||
.await
|
||||
.map_err(|e| ActionError::Internal(format!("Failed to send request: {}", e)))?;
|
||||
|
||||
// Consume response body
|
||||
let status = response.status;
|
||||
let status_reason = response.status_reason.clone();
|
||||
let headers = response.headers.clone();
|
||||
let url = response.url.clone();
|
||||
|
||||
let (body_text, stats) = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| ActionError::Internal(format!("Failed to read response body: {}", e)))?;
|
||||
|
||||
// Return success result with response data
|
||||
Ok(ActionResult::Success {
|
||||
data: Some(json!({
|
||||
"status": status,
|
||||
"statusReason": status_reason,
|
||||
"headers": headers,
|
||||
"body": body_text,
|
||||
"contentLength": stats.size_decompressed,
|
||||
"url": url,
|
||||
})),
|
||||
message: Some(format!("HTTP {}", status)),
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper function to render templates in an HTTP request.
|
||||
/// Copied from yaak-cli implementation.
|
||||
async fn render_http_request(
|
||||
r: &HttpRequest,
|
||||
environment_chain: Vec<yaak_models::models::Environment>,
|
||||
cb: &PluginTemplateCallback,
|
||||
opt: &RenderOptions,
|
||||
) -> Result<HttpRequest, String> {
|
||||
let vars = &make_vars_hashmap(environment_chain);
|
||||
|
||||
let mut url_parameters = Vec::new();
|
||||
for p in r.url_parameters.clone() {
|
||||
if !p.enabled {
|
||||
continue;
|
||||
}
|
||||
url_parameters.push(HttpUrlParameter {
|
||||
enabled: p.enabled,
|
||||
name: parse_and_render(p.name.as_str(), vars, cb, opt)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?,
|
||||
value: parse_and_render(p.value.as_str(), vars, cb, opt)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?,
|
||||
id: p.id,
|
||||
})
|
||||
}
|
||||
|
||||
let mut headers = Vec::new();
|
||||
for p in r.headers.clone() {
|
||||
if !p.enabled {
|
||||
continue;
|
||||
}
|
||||
headers.push(HttpRequestHeader {
|
||||
enabled: p.enabled,
|
||||
name: parse_and_render(p.name.as_str(), vars, cb, opt)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?,
|
||||
value: parse_and_render(p.value.as_str(), vars, cb, opt)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?,
|
||||
id: p.id,
|
||||
})
|
||||
}
|
||||
|
||||
let mut body = BTreeMap::new();
|
||||
for (k, v) in r.body.clone() {
|
||||
body.insert(
|
||||
k,
|
||||
render_json_value_raw(v, vars, cb, opt)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?,
|
||||
);
|
||||
}
|
||||
|
||||
let authentication = {
|
||||
let mut disabled = false;
|
||||
let mut auth = BTreeMap::new();
|
||||
match r.authentication.get("disabled") {
|
||||
Some(Value::Bool(true)) => {
|
||||
disabled = true;
|
||||
}
|
||||
Some(Value::String(tmpl)) => {
|
||||
disabled = parse_and_render(tmpl.as_str(), vars, cb, opt)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.is_empty();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if disabled {
|
||||
auth.insert("disabled".to_string(), Value::Bool(true));
|
||||
} else {
|
||||
for (k, v) in r.authentication.clone() {
|
||||
if k == "disabled" {
|
||||
auth.insert(k, Value::Bool(false));
|
||||
} else {
|
||||
auth.insert(
|
||||
k,
|
||||
render_json_value_raw(v, vars, cb, opt)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
auth
|
||||
};
|
||||
|
||||
let url = parse_and_render(r.url.clone().as_str(), vars, cb, opt)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Apply path placeholders (e.g., /users/:id -> /users/123)
|
||||
let (url, url_parameters) = apply_path_placeholders(&url, &url_parameters);
|
||||
|
||||
Ok(HttpRequest {
|
||||
url,
|
||||
url_parameters,
|
||||
headers,
|
||||
body,
|
||||
authentication,
|
||||
..r.to_owned()
|
||||
})
|
||||
}
|
||||
11
crates/yaak-actions-builtin/src/lib.rs
Normal file
11
crates/yaak-actions-builtin/src/lib.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
//! Built-in action implementations for Yaak.
|
||||
//!
|
||||
//! This crate provides concrete implementations of built-in actions using
|
||||
//! the yaak-actions framework. It depends on domain-specific crates like
|
||||
//! yaak-http, yaak-models, yaak-plugins, etc.
|
||||
|
||||
pub mod dependencies;
|
||||
pub mod http;
|
||||
|
||||
pub use dependencies::BuiltinActionDependencies;
|
||||
pub use http::register_http_actions;
|
||||
15
crates/yaak-actions/Cargo.toml
Normal file
15
crates/yaak-actions/Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "yaak-actions"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Centralized action system for Yaak"
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["sync"] }
|
||||
ts-rs = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
||||
14
crates/yaak-actions/bindings/ActionAvailability.ts
generated
Normal file
14
crates/yaak-actions/bindings/ActionAvailability.ts
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* Availability status for an action.
|
||||
*/
|
||||
export type ActionAvailability = { "status": "available" } | { "status": "available-with-prompt",
|
||||
/**
|
||||
* Fields that will require prompting.
|
||||
*/
|
||||
prompt_fields: Array<string>, } | { "status": "unavailable",
|
||||
/**
|
||||
* Fields that are missing.
|
||||
*/
|
||||
missing_fields: Array<string>, } | { "status": "not-found" };
|
||||
13
crates/yaak-actions/bindings/ActionError.ts
generated
Normal file
13
crates/yaak-actions/bindings/ActionError.ts
generated
Normal file
@@ -0,0 +1,13 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { ActionGroupId } from "./ActionGroupId";
|
||||
import type { ActionId } from "./ActionId";
|
||||
import type { ActionScope } from "./ActionScope";
|
||||
|
||||
/**
|
||||
* Errors that can occur during action operations.
|
||||
*/
|
||||
export type ActionError = { "type": "not-found" } & ActionId | { "type": "disabled", action_id: ActionId, reason: string, } | { "type": "invalid-scope", expected: ActionScope, actual: ActionScope, } | { "type": "timeout" } & ActionId | { "type": "plugin-error" } & string | { "type": "validation-error" } & string | { "type": "permission-denied" } & string | { "type": "cancelled" } | { "type": "internal" } & string | { "type": "context-missing",
|
||||
/**
|
||||
* The context fields that are missing.
|
||||
*/
|
||||
missing_fields: Array<string>, } | { "type": "group-not-found" } & ActionGroupId | { "type": "group-already-exists" } & ActionGroupId;
|
||||
10
crates/yaak-actions/bindings/ActionGroupId.ts
generated
Normal file
10
crates/yaak-actions/bindings/ActionGroupId.ts
generated
Normal file
@@ -0,0 +1,10 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* Unique identifier for an action group.
|
||||
*
|
||||
* Format: `namespace:group-name`
|
||||
* - Built-in: `yaak:export`
|
||||
* - Plugin: `plugin.my-plugin:utilities`
|
||||
*/
|
||||
export type ActionGroupId = string;
|
||||
32
crates/yaak-actions/bindings/ActionGroupMetadata.ts
generated
Normal file
32
crates/yaak-actions/bindings/ActionGroupMetadata.ts
generated
Normal file
@@ -0,0 +1,32 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { ActionGroupId } from "./ActionGroupId";
|
||||
import type { ActionScope } from "./ActionScope";
|
||||
|
||||
/**
|
||||
* Metadata about an action group.
|
||||
*/
|
||||
export type ActionGroupMetadata = {
|
||||
/**
|
||||
* Unique identifier for this group.
|
||||
*/
|
||||
id: ActionGroupId,
|
||||
/**
|
||||
* Display name for the group.
|
||||
*/
|
||||
name: string,
|
||||
/**
|
||||
* Optional description of the group's purpose.
|
||||
*/
|
||||
description: string | null,
|
||||
/**
|
||||
* Icon to display for the group.
|
||||
*/
|
||||
icon: string | null,
|
||||
/**
|
||||
* Sort order for displaying groups (lower = earlier).
|
||||
*/
|
||||
order: number,
|
||||
/**
|
||||
* Optional scope restriction (if set, group only appears in this scope).
|
||||
*/
|
||||
scope: ActionScope | null, };
|
||||
18
crates/yaak-actions/bindings/ActionGroupSource.ts
generated
Normal file
18
crates/yaak-actions/bindings/ActionGroupSource.ts
generated
Normal file
@@ -0,0 +1,18 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* Where an action group was registered from.
|
||||
*/
|
||||
export type ActionGroupSource = { "type": "builtin" } | { "type": "plugin",
|
||||
/**
|
||||
* Plugin reference ID.
|
||||
*/
|
||||
ref_id: string,
|
||||
/**
|
||||
* Plugin name.
|
||||
*/
|
||||
name: string, } | { "type": "dynamic",
|
||||
/**
|
||||
* Source identifier.
|
||||
*/
|
||||
source_id: string, };
|
||||
16
crates/yaak-actions/bindings/ActionGroupWithActions.ts
generated
Normal file
16
crates/yaak-actions/bindings/ActionGroupWithActions.ts
generated
Normal file
@@ -0,0 +1,16 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { ActionGroupMetadata } from "./ActionGroupMetadata";
|
||||
import type { ActionMetadata } from "./ActionMetadata";
|
||||
|
||||
/**
|
||||
* A group with its actions for UI rendering.
|
||||
*/
|
||||
export type ActionGroupWithActions = {
|
||||
/**
|
||||
* Group metadata.
|
||||
*/
|
||||
group: ActionGroupMetadata,
|
||||
/**
|
||||
* Actions in this group.
|
||||
*/
|
||||
actions: Array<ActionMetadata>, };
|
||||
10
crates/yaak-actions/bindings/ActionId.ts
generated
Normal file
10
crates/yaak-actions/bindings/ActionId.ts
generated
Normal file
@@ -0,0 +1,10 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* Unique identifier for an action.
|
||||
*
|
||||
* Format: `namespace:category:name`
|
||||
* - Built-in: `yaak:http-request:send`
|
||||
* - Plugin: `plugin.copy-curl:http-request:copy`
|
||||
*/
|
||||
export type ActionId = string;
|
||||
54
crates/yaak-actions/bindings/ActionMetadata.ts
generated
Normal file
54
crates/yaak-actions/bindings/ActionMetadata.ts
generated
Normal file
@@ -0,0 +1,54 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { ActionGroupId } from "./ActionGroupId";
|
||||
import type { ActionId } from "./ActionId";
|
||||
import type { ActionScope } from "./ActionScope";
|
||||
import type { RequiredContext } from "./RequiredContext";
|
||||
|
||||
/**
|
||||
* Metadata about an action for discovery.
|
||||
*/
|
||||
export type ActionMetadata = {
|
||||
/**
|
||||
* Unique identifier for this action.
|
||||
*/
|
||||
id: ActionId,
|
||||
/**
|
||||
* Display label for the action.
|
||||
*/
|
||||
label: string,
|
||||
/**
|
||||
* Optional description of what the action does.
|
||||
*/
|
||||
description: string | null,
|
||||
/**
|
||||
* Icon name to display.
|
||||
*/
|
||||
icon: string | null,
|
||||
/**
|
||||
* The scope this action applies to.
|
||||
*/
|
||||
scope: ActionScope,
|
||||
/**
|
||||
* Keyboard shortcut (e.g., "Cmd+Enter").
|
||||
*/
|
||||
keyboardShortcut: string | null,
|
||||
/**
|
||||
* Whether the action requires a selection/target.
|
||||
*/
|
||||
requiresSelection: boolean,
|
||||
/**
|
||||
* Optional condition expression for when action is enabled.
|
||||
*/
|
||||
enabledCondition: string | null,
|
||||
/**
|
||||
* Optional group this action belongs to.
|
||||
*/
|
||||
groupId: ActionGroupId | null,
|
||||
/**
|
||||
* Sort order within a group (lower = earlier).
|
||||
*/
|
||||
order: number,
|
||||
/**
|
||||
* Context requirements for this action.
|
||||
*/
|
||||
requiredContext: RequiredContext, };
|
||||
10
crates/yaak-actions/bindings/ActionParams.ts
generated
Normal file
10
crates/yaak-actions/bindings/ActionParams.ts
generated
Normal file
@@ -0,0 +1,10 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* Parameters passed to action handlers.
|
||||
*/
|
||||
export type ActionParams = {
|
||||
/**
|
||||
* Arbitrary JSON parameters.
|
||||
*/
|
||||
data: unknown, };
|
||||
23
crates/yaak-actions/bindings/ActionResult.ts
generated
Normal file
23
crates/yaak-actions/bindings/ActionResult.ts
generated
Normal file
@@ -0,0 +1,23 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { InputPrompt } from "./InputPrompt";
|
||||
|
||||
/**
|
||||
* Result of action execution.
|
||||
*/
|
||||
export type ActionResult = { "type": "success",
|
||||
/**
|
||||
* Optional data to return.
|
||||
*/
|
||||
data: unknown,
|
||||
/**
|
||||
* Optional message to display.
|
||||
*/
|
||||
message: string | null, } | { "type": "requires-input",
|
||||
/**
|
||||
* Prompt to show user.
|
||||
*/
|
||||
prompt: InputPrompt,
|
||||
/**
|
||||
* Continuation token.
|
||||
*/
|
||||
continuation_id: string, } | { "type": "cancelled" };
|
||||
6
crates/yaak-actions/bindings/ActionScope.ts
generated
Normal file
6
crates/yaak-actions/bindings/ActionScope.ts
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* The scope in which an action can be invoked.
|
||||
*/
|
||||
export type ActionScope = "global" | "http-request" | "websocket-request" | "grpc-request" | "workspace" | "folder" | "environment" | "cookie-jar";
|
||||
18
crates/yaak-actions/bindings/ActionSource.ts
generated
Normal file
18
crates/yaak-actions/bindings/ActionSource.ts
generated
Normal file
@@ -0,0 +1,18 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* Where an action was registered from.
|
||||
*/
|
||||
export type ActionSource = { "type": "builtin" } | { "type": "plugin",
|
||||
/**
|
||||
* Plugin reference ID.
|
||||
*/
|
||||
ref_id: string,
|
||||
/**
|
||||
* Plugin name.
|
||||
*/
|
||||
name: string, } | { "type": "dynamic",
|
||||
/**
|
||||
* Source identifier.
|
||||
*/
|
||||
source_id: string, };
|
||||
6
crates/yaak-actions/bindings/ActionTarget.ts
generated
Normal file
6
crates/yaak-actions/bindings/ActionTarget.ts
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* The target entity for an action.
|
||||
*/
|
||||
export type ActionTarget = { "type": "none" } | { "type": "http-request", id: string, } | { "type": "websocket-request", id: string, } | { "type": "grpc-request", id: string, } | { "type": "workspace", id: string, } | { "type": "folder", id: string, } | { "type": "environment", id: string, } | { "type": "multiple", targets: Array<ActionTarget>, };
|
||||
6
crates/yaak-actions/bindings/ContextRequirement.ts
generated
Normal file
6
crates/yaak-actions/bindings/ContextRequirement.ts
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* How strictly a context field is required.
|
||||
*/
|
||||
export type ContextRequirement = "not-required" | "optional" | "required" | "required-with-prompt";
|
||||
27
crates/yaak-actions/bindings/CurrentContext.ts
generated
Normal file
27
crates/yaak-actions/bindings/CurrentContext.ts
generated
Normal file
@@ -0,0 +1,27 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { ActionTarget } from "./ActionTarget";
|
||||
|
||||
/**
|
||||
* Current context state from the application.
|
||||
*/
|
||||
export type CurrentContext = {
|
||||
/**
|
||||
* Current workspace ID (if any).
|
||||
*/
|
||||
workspaceId: string | null,
|
||||
/**
|
||||
* Current environment ID (if any).
|
||||
*/
|
||||
environmentId: string | null,
|
||||
/**
|
||||
* Currently selected target (if any).
|
||||
*/
|
||||
target: ActionTarget | null,
|
||||
/**
|
||||
* Whether a window context is available.
|
||||
*/
|
||||
hasWindow: boolean,
|
||||
/**
|
||||
* Whether the context provider can prompt for missing fields.
|
||||
*/
|
||||
canPrompt: boolean, };
|
||||
7
crates/yaak-actions/bindings/InputPrompt.ts
generated
Normal file
7
crates/yaak-actions/bindings/InputPrompt.ts
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { SelectOption } from "./SelectOption";
|
||||
|
||||
/**
|
||||
* A prompt for user input.
|
||||
*/
|
||||
export type InputPrompt = { "type": "text", label: string, placeholder: string | null, default_value: string | null, } | { "type": "select", label: string, options: Array<SelectOption>, } | { "type": "confirm", label: string, };
|
||||
23
crates/yaak-actions/bindings/RequiredContext.ts
generated
Normal file
23
crates/yaak-actions/bindings/RequiredContext.ts
generated
Normal file
@@ -0,0 +1,23 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { ContextRequirement } from "./ContextRequirement";
|
||||
|
||||
/**
|
||||
* Specifies what context fields an action requires.
|
||||
*/
|
||||
export type RequiredContext = {
|
||||
/**
|
||||
* Action requires a workspace to be active.
|
||||
*/
|
||||
workspace: ContextRequirement,
|
||||
/**
|
||||
* Action requires an environment to be selected.
|
||||
*/
|
||||
environment: ContextRequirement,
|
||||
/**
|
||||
* Action requires a specific target entity (request, folder, etc.).
|
||||
*/
|
||||
target: ContextRequirement,
|
||||
/**
|
||||
* Action requires a window context (for UI operations).
|
||||
*/
|
||||
window: ContextRequirement, };
|
||||
6
crates/yaak-actions/bindings/SelectOption.ts
generated
Normal file
6
crates/yaak-actions/bindings/SelectOption.ts
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* An option in a select prompt.
|
||||
*/
|
||||
export type SelectOption = { label: string, value: string, };
|
||||
331
crates/yaak-actions/src/context.rs
Normal file
331
crates/yaak-actions/src/context.rs
Normal file
@@ -0,0 +1,331 @@
|
||||
//! Action context types and context-aware filtering.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
|
||||
use crate::ActionScope;
|
||||
|
||||
/// Specifies what context fields an action requires.
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RequiredContext {
|
||||
/// Action requires a workspace to be active.
|
||||
#[serde(default)]
|
||||
pub workspace: ContextRequirement,
|
||||
|
||||
/// Action requires an environment to be selected.
|
||||
#[serde(default)]
|
||||
pub environment: ContextRequirement,
|
||||
|
||||
/// Action requires a specific target entity (request, folder, etc.).
|
||||
#[serde(default)]
|
||||
pub target: ContextRequirement,
|
||||
|
||||
/// Action requires a window context (for UI operations).
|
||||
#[serde(default)]
|
||||
pub window: ContextRequirement,
|
||||
}
|
||||
|
||||
impl RequiredContext {
|
||||
/// Action requires a target entity.
|
||||
pub fn requires_target() -> Self {
|
||||
Self {
|
||||
target: ContextRequirement::Required,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Action requires workspace and target.
|
||||
pub fn requires_workspace_and_target() -> Self {
|
||||
Self {
|
||||
workspace: ContextRequirement::Required,
|
||||
target: ContextRequirement::Required,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Action works globally, no specific context needed.
|
||||
pub fn global() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Action requires target with prompt if missing.
|
||||
pub fn requires_target_with_prompt() -> Self {
|
||||
Self {
|
||||
target: ContextRequirement::RequiredWithPrompt,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Action requires environment with prompt if missing.
|
||||
pub fn requires_environment_with_prompt() -> Self {
|
||||
Self {
|
||||
environment: ContextRequirement::RequiredWithPrompt,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How strictly a context field is required.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ContextRequirement {
|
||||
/// Field is not needed.
|
||||
#[default]
|
||||
NotRequired,
|
||||
|
||||
/// Field is optional but will be used if available.
|
||||
Optional,
|
||||
|
||||
/// Field must be present; action will fail without it.
|
||||
Required,
|
||||
|
||||
/// Field must be present; prompt user to select if missing.
|
||||
RequiredWithPrompt,
|
||||
}
|
||||
|
||||
/// Current context state from the application.
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CurrentContext {
|
||||
/// Current workspace ID (if any).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub workspace_id: Option<String>,
|
||||
|
||||
/// Current environment ID (if any).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub environment_id: Option<String>,
|
||||
|
||||
/// Currently selected target (if any).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub target: Option<ActionTarget>,
|
||||
|
||||
/// Whether a window context is available.
|
||||
#[serde(default)]
|
||||
pub has_window: bool,
|
||||
|
||||
/// Whether the context provider can prompt for missing fields.
|
||||
#[serde(default)]
|
||||
pub can_prompt: bool,
|
||||
}
|
||||
|
||||
/// The target entity for an action.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
#[serde(tag = "type", rename_all = "kebab-case")]
|
||||
pub enum ActionTarget {
|
||||
/// No target.
|
||||
None,
|
||||
/// HTTP request target.
|
||||
HttpRequest { id: String },
|
||||
/// WebSocket request target.
|
||||
WebsocketRequest { id: String },
|
||||
/// gRPC request target.
|
||||
GrpcRequest { id: String },
|
||||
/// Workspace target.
|
||||
Workspace { id: String },
|
||||
/// Folder target.
|
||||
Folder { id: String },
|
||||
/// Environment target.
|
||||
Environment { id: String },
|
||||
/// Multiple targets.
|
||||
Multiple { targets: Vec<ActionTarget> },
|
||||
}
|
||||
|
||||
impl ActionTarget {
|
||||
/// Get the scope this target corresponds to.
|
||||
pub fn scope(&self) -> Option<ActionScope> {
|
||||
match self {
|
||||
Self::None => None,
|
||||
Self::HttpRequest { .. } => Some(ActionScope::HttpRequest),
|
||||
Self::WebsocketRequest { .. } => Some(ActionScope::WebsocketRequest),
|
||||
Self::GrpcRequest { .. } => Some(ActionScope::GrpcRequest),
|
||||
Self::Workspace { .. } => Some(ActionScope::Workspace),
|
||||
Self::Folder { .. } => Some(ActionScope::Folder),
|
||||
Self::Environment { .. } => Some(ActionScope::Environment),
|
||||
Self::Multiple { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the ID of the target (if single target).
|
||||
pub fn id(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::HttpRequest { id }
|
||||
| Self::WebsocketRequest { id }
|
||||
| Self::GrpcRequest { id }
|
||||
| Self::Workspace { id }
|
||||
| Self::Folder { id }
|
||||
| Self::Environment { id } => Some(id),
|
||||
Self::None | Self::Multiple { .. } => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Availability status for an action.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
#[serde(tag = "status", rename_all = "kebab-case")]
|
||||
pub enum ActionAvailability {
|
||||
/// Action is ready to execute.
|
||||
Available,
|
||||
|
||||
/// Action can execute but will prompt for missing context.
|
||||
AvailableWithPrompt {
|
||||
/// Fields that will require prompting.
|
||||
prompt_fields: Vec<String>,
|
||||
},
|
||||
|
||||
/// Action cannot execute due to missing context.
|
||||
Unavailable {
|
||||
/// Fields that are missing.
|
||||
missing_fields: Vec<String>,
|
||||
},
|
||||
|
||||
/// Action not found in registry.
|
||||
NotFound,
|
||||
}
|
||||
|
||||
impl ActionAvailability {
|
||||
/// Check if the action is available (possibly with prompts).
|
||||
pub fn is_available(&self) -> bool {
|
||||
matches!(self, Self::Available | Self::AvailableWithPrompt { .. })
|
||||
}
|
||||
|
||||
/// Check if the action is immediately available without prompts.
|
||||
pub fn is_immediately_available(&self) -> bool {
|
||||
matches!(self, Self::Available)
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if required context is satisfied by current context.
|
||||
pub fn check_context_availability(
|
||||
required: &RequiredContext,
|
||||
current: &CurrentContext,
|
||||
) -> ActionAvailability {
|
||||
let mut missing_fields = Vec::new();
|
||||
let mut prompt_fields = Vec::new();
|
||||
|
||||
// Check workspace
|
||||
check_field(
|
||||
"workspace",
|
||||
current.workspace_id.is_some(),
|
||||
&required.workspace,
|
||||
current.can_prompt,
|
||||
&mut missing_fields,
|
||||
&mut prompt_fields,
|
||||
);
|
||||
|
||||
// Check environment
|
||||
check_field(
|
||||
"environment",
|
||||
current.environment_id.is_some(),
|
||||
&required.environment,
|
||||
current.can_prompt,
|
||||
&mut missing_fields,
|
||||
&mut prompt_fields,
|
||||
);
|
||||
|
||||
// Check target
|
||||
check_field(
|
||||
"target",
|
||||
current.target.is_some(),
|
||||
&required.target,
|
||||
current.can_prompt,
|
||||
&mut missing_fields,
|
||||
&mut prompt_fields,
|
||||
);
|
||||
|
||||
// Check window
|
||||
check_field(
|
||||
"window",
|
||||
current.has_window,
|
||||
&required.window,
|
||||
false, // Can't prompt for window
|
||||
&mut missing_fields,
|
||||
&mut prompt_fields,
|
||||
);
|
||||
|
||||
if !missing_fields.is_empty() {
|
||||
ActionAvailability::Unavailable { missing_fields }
|
||||
} else if !prompt_fields.is_empty() {
|
||||
ActionAvailability::AvailableWithPrompt { prompt_fields }
|
||||
} else {
|
||||
ActionAvailability::Available
|
||||
}
|
||||
}
|
||||
|
||||
fn check_field(
|
||||
name: &str,
|
||||
has_value: bool,
|
||||
requirement: &ContextRequirement,
|
||||
can_prompt: bool,
|
||||
missing: &mut Vec<String>,
|
||||
promptable: &mut Vec<String>,
|
||||
) {
|
||||
match requirement {
|
||||
ContextRequirement::NotRequired | ContextRequirement::Optional => {}
|
||||
ContextRequirement::Required => {
|
||||
if !has_value {
|
||||
missing.push(name.to_string());
|
||||
}
|
||||
}
|
||||
ContextRequirement::RequiredWithPrompt => {
|
||||
if !has_value {
|
||||
if can_prompt {
|
||||
promptable.push(name.to_string());
|
||||
} else {
|
||||
missing.push(name.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_context_available() {
|
||||
let required = RequiredContext::requires_target();
|
||||
let current = CurrentContext {
|
||||
target: Some(ActionTarget::HttpRequest {
|
||||
id: "123".to_string(),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let availability = check_context_availability(&required, ¤t);
|
||||
assert!(matches!(availability, ActionAvailability::Available));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_context_missing() {
|
||||
let required = RequiredContext::requires_target();
|
||||
let current = CurrentContext::default();
|
||||
|
||||
let availability = check_context_availability(&required, ¤t);
|
||||
assert!(matches!(
|
||||
availability,
|
||||
ActionAvailability::Unavailable { missing_fields } if missing_fields == vec!["target"]
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_context_promptable() {
|
||||
let required = RequiredContext::requires_target_with_prompt();
|
||||
let current = CurrentContext {
|
||||
can_prompt: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let availability = check_context_availability(&required, ¤t);
|
||||
assert!(matches!(
|
||||
availability,
|
||||
ActionAvailability::AvailableWithPrompt { prompt_fields } if prompt_fields == vec!["target"]
|
||||
));
|
||||
}
|
||||
}
|
||||
131
crates/yaak-actions/src/error.rs
Normal file
131
crates/yaak-actions/src/error.rs
Normal file
@@ -0,0 +1,131 @@
|
||||
//! Error types for the action system.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
use ts_rs::TS;
|
||||
|
||||
use crate::{ActionGroupId, ActionId};
|
||||
|
||||
/// Errors that can occur during action operations.
|
||||
#[derive(Debug, Error, Clone, Serialize, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
#[serde(tag = "type", rename_all = "kebab-case")]
|
||||
pub enum ActionError {
|
||||
/// Action not found in registry.
|
||||
#[error("Action not found: {0}")]
|
||||
NotFound(ActionId),
|
||||
|
||||
/// Action is disabled in current context.
|
||||
#[error("Action is disabled: {action_id} - {reason}")]
|
||||
Disabled { action_id: ActionId, reason: String },
|
||||
|
||||
/// Invalid scope for the action.
|
||||
#[error("Invalid scope: expected {expected:?}, got {actual:?}")]
|
||||
InvalidScope {
|
||||
expected: crate::ActionScope,
|
||||
actual: crate::ActionScope,
|
||||
},
|
||||
|
||||
/// Action execution timed out.
|
||||
#[error("Action timed out: {0}")]
|
||||
Timeout(ActionId),
|
||||
|
||||
/// Error from plugin execution.
|
||||
#[error("Plugin error: {0}")]
|
||||
PluginError(String),
|
||||
|
||||
/// Validation error in action parameters.
|
||||
#[error("Validation error: {0}")]
|
||||
ValidationError(String),
|
||||
|
||||
/// Permission denied for action.
|
||||
#[error("Permission denied: {0}")]
|
||||
PermissionDenied(String),
|
||||
|
||||
/// Action was cancelled by user.
|
||||
#[error("Action cancelled by user")]
|
||||
Cancelled,
|
||||
|
||||
/// Internal error.
|
||||
#[error("Internal error: {0}")]
|
||||
Internal(String),
|
||||
|
||||
/// Required context is missing.
|
||||
#[error("Required context missing: {missing_fields:?}")]
|
||||
ContextMissing {
|
||||
/// The context fields that are missing.
|
||||
missing_fields: Vec<String>,
|
||||
},
|
||||
|
||||
/// Action group not found.
|
||||
#[error("Group not found: {0}")]
|
||||
GroupNotFound(ActionGroupId),
|
||||
|
||||
/// Action group already exists.
|
||||
#[error("Group already exists: {0}")]
|
||||
GroupAlreadyExists(ActionGroupId),
|
||||
}
|
||||
|
||||
impl ActionError {
|
||||
/// Get a user-friendly error message.
|
||||
pub fn user_message(&self) -> String {
|
||||
match self {
|
||||
Self::NotFound(id) => format!("Action '{}' is not available", id),
|
||||
Self::Disabled { reason, .. } => reason.clone(),
|
||||
Self::InvalidScope { expected, actual } => {
|
||||
format!("Action requires {:?} scope, but got {:?}", expected, actual)
|
||||
}
|
||||
Self::Timeout(_) => "The operation took too long and was cancelled".into(),
|
||||
Self::PluginError(msg) => format!("Plugin error: {}", msg),
|
||||
Self::ValidationError(msg) => format!("Invalid input: {}", msg),
|
||||
Self::PermissionDenied(resource) => format!("Permission denied for {}", resource),
|
||||
Self::Cancelled => "Operation was cancelled".into(),
|
||||
Self::Internal(_) => "An unexpected error occurred".into(),
|
||||
Self::ContextMissing { missing_fields } => {
|
||||
format!("Missing required context: {}", missing_fields.join(", "))
|
||||
}
|
||||
Self::GroupNotFound(id) => format!("Action group '{}' not found", id),
|
||||
Self::GroupAlreadyExists(id) => format!("Action group '{}' already exists", id),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this error should be reported to telemetry.
|
||||
pub fn is_reportable(&self) -> bool {
|
||||
matches!(self, Self::Internal(_) | Self::PluginError(_))
|
||||
}
|
||||
|
||||
/// Whether this error can potentially be resolved by user interaction.
|
||||
pub fn is_promptable(&self) -> bool {
|
||||
matches!(self, Self::ContextMissing { .. })
|
||||
}
|
||||
|
||||
/// Whether this is a user-initiated cancellation.
|
||||
pub fn is_cancelled(&self) -> bool {
|
||||
matches!(self, Self::Cancelled)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_error_messages() {
|
||||
let err = ActionError::ContextMissing {
|
||||
missing_fields: vec!["workspace".into()],
|
||||
};
|
||||
assert_eq!(err.user_message(), "Missing required context: workspace");
|
||||
assert!(err.is_promptable());
|
||||
assert!(!err.is_cancelled());
|
||||
|
||||
let cancelled = ActionError::Cancelled;
|
||||
assert!(cancelled.is_cancelled());
|
||||
assert!(!cancelled.is_promptable());
|
||||
|
||||
let not_found = ActionError::NotFound(ActionId::builtin("test", "action"));
|
||||
assert_eq!(
|
||||
not_found.user_message(),
|
||||
"Action 'yaak:test:action' is not available"
|
||||
);
|
||||
}
|
||||
}
|
||||
606
crates/yaak-actions/src/executor.rs
Normal file
606
crates/yaak-actions/src/executor.rs
Normal file
@@ -0,0 +1,606 @@
|
||||
//! Action executor - central hub for action registration and invocation.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::{
|
||||
check_context_availability, ActionAvailability, ActionError, ActionGroupId,
|
||||
ActionGroupMetadata, ActionGroupSource, ActionGroupWithActions, ActionHandler, ActionId,
|
||||
ActionMetadata, ActionParams, ActionResult, ActionScope, ActionSource, CurrentContext,
|
||||
RegisteredActionGroup,
|
||||
};
|
||||
|
||||
/// Options for listing actions.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ListActionsOptions {
|
||||
/// Filter by scope.
|
||||
pub scope: Option<ActionScope>,
|
||||
/// Filter by group.
|
||||
pub group_id: Option<ActionGroupId>,
|
||||
/// Search term for label/description.
|
||||
pub search: Option<String>,
|
||||
}
|
||||
|
||||
/// A registered action with its handler.
|
||||
struct RegisteredAction {
|
||||
/// Action metadata.
|
||||
metadata: ActionMetadata,
|
||||
/// Where the action was registered from.
|
||||
source: ActionSource,
|
||||
/// The handler for this action.
|
||||
handler: Arc<dyn ActionHandler>,
|
||||
}
|
||||
|
||||
/// Central hub for action registration and invocation.
|
||||
///
|
||||
/// The executor owns all action metadata and handlers, ensuring every
|
||||
/// registered action has a handler by construction.
|
||||
pub struct ActionExecutor {
|
||||
/// All registered actions indexed by ID.
|
||||
actions: RwLock<HashMap<ActionId, RegisteredAction>>,
|
||||
|
||||
/// Actions indexed by scope for efficient filtering.
|
||||
scope_index: RwLock<HashMap<ActionScope, Vec<ActionId>>>,
|
||||
|
||||
/// All registered groups indexed by ID.
|
||||
groups: RwLock<HashMap<ActionGroupId, RegisteredActionGroup>>,
|
||||
}
|
||||
|
||||
impl Default for ActionExecutor {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActionExecutor {
|
||||
/// Create a new empty executor.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
actions: RwLock::new(HashMap::new()),
|
||||
scope_index: RwLock::new(HashMap::new()),
|
||||
groups: RwLock::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Action Registration
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Register an action with its handler.
|
||||
///
|
||||
/// Every action must have a handler - this is enforced by the API.
|
||||
pub async fn register<H: ActionHandler + 'static>(
|
||||
&self,
|
||||
metadata: ActionMetadata,
|
||||
source: ActionSource,
|
||||
handler: H,
|
||||
) -> Result<ActionId, ActionError> {
|
||||
let id = metadata.id.clone();
|
||||
let scope = metadata.scope.clone();
|
||||
|
||||
let action = RegisteredAction {
|
||||
metadata,
|
||||
source,
|
||||
handler: Arc::new(handler),
|
||||
};
|
||||
|
||||
// Insert action
|
||||
{
|
||||
let mut actions = self.actions.write().await;
|
||||
actions.insert(id.clone(), action);
|
||||
}
|
||||
|
||||
// Update scope index
|
||||
{
|
||||
let mut index = self.scope_index.write().await;
|
||||
index.entry(scope).or_default().push(id.clone());
|
||||
}
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Unregister an action.
|
||||
pub async fn unregister(&self, id: &ActionId) -> Result<(), ActionError> {
|
||||
let mut actions = self.actions.write().await;
|
||||
|
||||
let action = actions
|
||||
.remove(id)
|
||||
.ok_or_else(|| ActionError::NotFound(id.clone()))?;
|
||||
|
||||
// Update scope index
|
||||
{
|
||||
let mut index = self.scope_index.write().await;
|
||||
if let Some(ids) = index.get_mut(&action.metadata.scope) {
|
||||
ids.retain(|i| i != id);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from group if assigned
|
||||
if let Some(group_id) = &action.metadata.group_id {
|
||||
let mut groups = self.groups.write().await;
|
||||
if let Some(group) = groups.get_mut(group_id) {
|
||||
group.action_ids.retain(|i| i != id);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Unregister all actions from a specific source.
|
||||
pub async fn unregister_source(&self, source_id: &str) -> Vec<ActionId> {
|
||||
let actions_to_remove: Vec<ActionId> = {
|
||||
let actions = self.actions.read().await;
|
||||
actions
|
||||
.iter()
|
||||
.filter(|(_, a)| match &a.source {
|
||||
ActionSource::Plugin { ref_id, .. } => ref_id == source_id,
|
||||
ActionSource::Dynamic {
|
||||
source_id: sid, ..
|
||||
} => sid == source_id,
|
||||
ActionSource::Builtin => false,
|
||||
})
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect()
|
||||
};
|
||||
|
||||
for id in &actions_to_remove {
|
||||
let _ = self.unregister(id).await;
|
||||
}
|
||||
|
||||
actions_to_remove
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Action Invocation
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Invoke an action with the given context and parameters.
|
||||
///
|
||||
/// This will:
|
||||
/// 1. Look up the action metadata
|
||||
/// 2. Check context availability
|
||||
/// 3. Execute the handler
|
||||
pub async fn invoke(
|
||||
&self,
|
||||
action_id: &ActionId,
|
||||
context: CurrentContext,
|
||||
params: ActionParams,
|
||||
) -> Result<ActionResult, ActionError> {
|
||||
// Get action and handler
|
||||
let (metadata, handler) = {
|
||||
let actions = self.actions.read().await;
|
||||
let action = actions
|
||||
.get(action_id)
|
||||
.ok_or_else(|| ActionError::NotFound(action_id.clone()))?;
|
||||
(action.metadata.clone(), action.handler.clone())
|
||||
};
|
||||
|
||||
// Check context availability
|
||||
let availability = check_context_availability(&metadata.required_context, &context);
|
||||
|
||||
match availability {
|
||||
ActionAvailability::Available | ActionAvailability::AvailableWithPrompt { .. } => {
|
||||
// Context is satisfied, proceed with execution
|
||||
}
|
||||
ActionAvailability::Unavailable { missing_fields } => {
|
||||
return Err(ActionError::ContextMissing { missing_fields });
|
||||
}
|
||||
ActionAvailability::NotFound => {
|
||||
return Err(ActionError::NotFound(action_id.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
// Execute handler
|
||||
handler.handle(context, params).await
|
||||
}
|
||||
|
||||
/// Invoke an action, skipping context validation.
|
||||
///
|
||||
/// Use this when you've already validated the context externally.
|
||||
pub async fn invoke_unchecked(
|
||||
&self,
|
||||
action_id: &ActionId,
|
||||
context: CurrentContext,
|
||||
params: ActionParams,
|
||||
) -> Result<ActionResult, ActionError> {
|
||||
// Get handler
|
||||
let handler = {
|
||||
let actions = self.actions.read().await;
|
||||
let action = actions
|
||||
.get(action_id)
|
||||
.ok_or_else(|| ActionError::NotFound(action_id.clone()))?;
|
||||
action.handler.clone()
|
||||
};
|
||||
|
||||
// Execute handler
|
||||
handler.handle(context, params).await
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Action Queries
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Get action metadata by ID.
|
||||
pub async fn get(&self, id: &ActionId) -> Option<ActionMetadata> {
|
||||
let actions = self.actions.read().await;
|
||||
actions.get(id).map(|a| a.metadata.clone())
|
||||
}
|
||||
|
||||
/// List all actions, optionally filtered.
|
||||
pub async fn list(&self, options: ListActionsOptions) -> Vec<ActionMetadata> {
|
||||
let actions = self.actions.read().await;
|
||||
|
||||
let mut result: Vec<_> = actions
|
||||
.values()
|
||||
.filter(|a| {
|
||||
// Scope filter
|
||||
if let Some(scope) = &options.scope {
|
||||
if &a.metadata.scope != scope {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Group filter
|
||||
if let Some(group_id) = &options.group_id {
|
||||
if a.metadata.group_id.as_ref() != Some(group_id) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Search filter
|
||||
if let Some(search) = &options.search {
|
||||
let search = search.to_lowercase();
|
||||
let matches_label = a.metadata.label.to_lowercase().contains(&search);
|
||||
let matches_desc = a
|
||||
.metadata
|
||||
.description
|
||||
.as_ref()
|
||||
.map(|d| d.to_lowercase().contains(&search))
|
||||
.unwrap_or(false);
|
||||
if !matches_label && !matches_desc {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
})
|
||||
.map(|a| a.metadata.clone())
|
||||
.collect();
|
||||
|
||||
// Sort by order then label
|
||||
result.sort_by(|a, b| a.order.cmp(&b.order).then_with(|| a.label.cmp(&b.label)));
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// List actions available in the given context.
|
||||
pub async fn list_available(
|
||||
&self,
|
||||
context: &CurrentContext,
|
||||
options: ListActionsOptions,
|
||||
) -> Vec<(ActionMetadata, ActionAvailability)> {
|
||||
let all_actions = self.list(options).await;
|
||||
|
||||
all_actions
|
||||
.into_iter()
|
||||
.map(|action| {
|
||||
let availability =
|
||||
check_context_availability(&action.required_context, context);
|
||||
(action, availability)
|
||||
})
|
||||
.filter(|(_, availability)| availability.is_available())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get availability status for a specific action.
|
||||
pub async fn get_availability(
|
||||
&self,
|
||||
id: &ActionId,
|
||||
context: &CurrentContext,
|
||||
) -> ActionAvailability {
|
||||
let actions = self.actions.read().await;
|
||||
|
||||
match actions.get(id) {
|
||||
Some(action) => {
|
||||
check_context_availability(&action.metadata.required_context, context)
|
||||
}
|
||||
None => ActionAvailability::NotFound,
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Group Registration
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Register an action group.
|
||||
pub async fn register_group(
|
||||
&self,
|
||||
metadata: ActionGroupMetadata,
|
||||
source: ActionGroupSource,
|
||||
) -> Result<ActionGroupId, ActionError> {
|
||||
let id = metadata.id.clone();
|
||||
|
||||
let mut groups = self.groups.write().await;
|
||||
if groups.contains_key(&id) {
|
||||
return Err(ActionError::GroupAlreadyExists(id));
|
||||
}
|
||||
|
||||
groups.insert(
|
||||
id.clone(),
|
||||
RegisteredActionGroup {
|
||||
metadata,
|
||||
action_ids: Vec::new(),
|
||||
source,
|
||||
},
|
||||
);
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Unregister a group (does not unregister its actions).
|
||||
pub async fn unregister_group(&self, id: &ActionGroupId) -> Result<(), ActionError> {
|
||||
let mut groups = self.groups.write().await;
|
||||
groups
|
||||
.remove(id)
|
||||
.ok_or_else(|| ActionError::GroupNotFound(id.clone()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add an action to a group.
|
||||
pub async fn add_to_group(
|
||||
&self,
|
||||
action_id: &ActionId,
|
||||
group_id: &ActionGroupId,
|
||||
) -> Result<(), ActionError> {
|
||||
// Update action's group_id
|
||||
{
|
||||
let mut actions = self.actions.write().await;
|
||||
let action = actions
|
||||
.get_mut(action_id)
|
||||
.ok_or_else(|| ActionError::NotFound(action_id.clone()))?;
|
||||
action.metadata.group_id = Some(group_id.clone());
|
||||
}
|
||||
|
||||
// Add to group's action list
|
||||
{
|
||||
let mut groups = self.groups.write().await;
|
||||
let group = groups
|
||||
.get_mut(group_id)
|
||||
.ok_or_else(|| ActionError::GroupNotFound(group_id.clone()))?;
|
||||
|
||||
if !group.action_ids.contains(action_id) {
|
||||
group.action_ids.push(action_id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Group Queries
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Get a group by ID.
|
||||
pub async fn get_group(&self, id: &ActionGroupId) -> Option<ActionGroupMetadata> {
|
||||
let groups = self.groups.read().await;
|
||||
groups.get(id).map(|g| g.metadata.clone())
|
||||
}
|
||||
|
||||
/// List all groups, optionally filtered by scope.
|
||||
pub async fn list_groups(&self, scope: Option<ActionScope>) -> Vec<ActionGroupMetadata> {
|
||||
let groups = self.groups.read().await;
|
||||
|
||||
let mut result: Vec<_> = groups
|
||||
.values()
|
||||
.filter(|g| {
|
||||
scope.as_ref().map_or(true, |s| {
|
||||
g.metadata.scope.as_ref().map_or(true, |gs| gs == s)
|
||||
})
|
||||
})
|
||||
.map(|g| g.metadata.clone())
|
||||
.collect();
|
||||
|
||||
result.sort_by_key(|g| g.order);
|
||||
result
|
||||
}
|
||||
|
||||
/// List all actions in a specific group.
|
||||
pub async fn list_by_group(&self, group_id: &ActionGroupId) -> Vec<ActionMetadata> {
|
||||
let groups = self.groups.read().await;
|
||||
let actions = self.actions.read().await;
|
||||
|
||||
groups
|
||||
.get(group_id)
|
||||
.map(|group| {
|
||||
let mut result: Vec<_> = group
|
||||
.action_ids
|
||||
.iter()
|
||||
.filter_map(|id| actions.get(id).map(|a| a.metadata.clone()))
|
||||
.collect();
|
||||
result.sort_by_key(|a| a.order);
|
||||
result
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Get actions organized by their groups.
|
||||
pub async fn list_grouped(&self, scope: Option<ActionScope>) -> Vec<ActionGroupWithActions> {
|
||||
let group_list = self.list_groups(scope).await;
|
||||
let mut result = Vec::new();
|
||||
|
||||
for group in group_list {
|
||||
let actions = self.list_by_group(&group.id).await;
|
||||
result.push(ActionGroupWithActions { group, actions });
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Built-in Registration
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Register all built-in groups.
|
||||
pub async fn register_builtin_groups(&self) -> Result<(), ActionError> {
|
||||
for group in crate::groups::builtin::all() {
|
||||
self.register_group(group, ActionGroupSource::Builtin).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{handler_fn, RequiredContext};
|
||||
|
||||
async fn create_test_executor() -> ActionExecutor {
|
||||
let executor = ActionExecutor::new();
|
||||
executor
|
||||
.register(
|
||||
ActionMetadata {
|
||||
id: ActionId::builtin("test", "echo"),
|
||||
label: "Echo".to_string(),
|
||||
description: None,
|
||||
icon: None,
|
||||
scope: ActionScope::Global,
|
||||
keyboard_shortcut: None,
|
||||
requires_selection: false,
|
||||
enabled_condition: None,
|
||||
group_id: None,
|
||||
order: 0,
|
||||
required_context: RequiredContext::default(),
|
||||
},
|
||||
ActionSource::Builtin,
|
||||
handler_fn(|_ctx, params| async move {
|
||||
let msg: String = params.get("message").unwrap_or_default();
|
||||
Ok(ActionResult::with_message(msg))
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
executor
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_register_and_invoke() {
|
||||
let executor = create_test_executor().await;
|
||||
let action_id = ActionId::builtin("test", "echo");
|
||||
|
||||
let params = ActionParams::from_json(serde_json::json!({
|
||||
"message": "Hello, World!"
|
||||
}));
|
||||
|
||||
let result = executor
|
||||
.invoke(&action_id, CurrentContext::default(), params)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
match result {
|
||||
ActionResult::Success { message, .. } => {
|
||||
assert_eq!(message, Some("Hello, World!".to_string()));
|
||||
}
|
||||
_ => panic!("Expected Success result"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_invoke_not_found() {
|
||||
let executor = ActionExecutor::new();
|
||||
let action_id = ActionId::builtin("test", "unknown");
|
||||
|
||||
let result = executor
|
||||
.invoke(&action_id, CurrentContext::default(), ActionParams::empty())
|
||||
.await;
|
||||
|
||||
assert!(matches!(result, Err(ActionError::NotFound(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_by_scope() {
|
||||
let executor = ActionExecutor::new();
|
||||
|
||||
executor
|
||||
.register(
|
||||
ActionMetadata {
|
||||
id: ActionId::builtin("global", "one"),
|
||||
label: "Global One".to_string(),
|
||||
description: None,
|
||||
icon: None,
|
||||
scope: ActionScope::Global,
|
||||
keyboard_shortcut: None,
|
||||
requires_selection: false,
|
||||
enabled_condition: None,
|
||||
group_id: None,
|
||||
order: 0,
|
||||
required_context: RequiredContext::default(),
|
||||
},
|
||||
ActionSource::Builtin,
|
||||
handler_fn(|_ctx, _params| async move { Ok(ActionResult::ok()) }),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
executor
|
||||
.register(
|
||||
ActionMetadata {
|
||||
id: ActionId::builtin("http", "one"),
|
||||
label: "HTTP One".to_string(),
|
||||
description: None,
|
||||
icon: None,
|
||||
scope: ActionScope::HttpRequest,
|
||||
keyboard_shortcut: None,
|
||||
requires_selection: false,
|
||||
enabled_condition: None,
|
||||
group_id: None,
|
||||
order: 0,
|
||||
required_context: RequiredContext::default(),
|
||||
},
|
||||
ActionSource::Builtin,
|
||||
handler_fn(|_ctx, _params| async move { Ok(ActionResult::ok()) }),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let global_actions = executor
|
||||
.list(ListActionsOptions {
|
||||
scope: Some(ActionScope::Global),
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
assert_eq!(global_actions.len(), 1);
|
||||
|
||||
let http_actions = executor
|
||||
.list(ListActionsOptions {
|
||||
scope: Some(ActionScope::HttpRequest),
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
assert_eq!(http_actions.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_groups() {
|
||||
let executor = ActionExecutor::new();
|
||||
executor.register_builtin_groups().await.unwrap();
|
||||
|
||||
let groups = executor.list_groups(None).await;
|
||||
assert!(!groups.is_empty());
|
||||
|
||||
let export_group = executor.get_group(&ActionGroupId::builtin("export")).await;
|
||||
assert!(export_group.is_some());
|
||||
assert_eq!(export_group.unwrap().name, "Export");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_unregister() {
|
||||
let executor = create_test_executor().await;
|
||||
let action_id = ActionId::builtin("test", "echo");
|
||||
|
||||
assert!(executor.get(&action_id).await.is_some());
|
||||
|
||||
executor.unregister(&action_id).await.unwrap();
|
||||
assert!(executor.get(&action_id).await.is_none());
|
||||
}
|
||||
}
|
||||
208
crates/yaak-actions/src/groups.rs
Normal file
208
crates/yaak-actions/src/groups.rs
Normal file
@@ -0,0 +1,208 @@
|
||||
//! Action group types and management.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
|
||||
use crate::{ActionId, ActionMetadata, ActionScope};
|
||||
|
||||
/// Unique identifier for an action group.
|
||||
///
|
||||
/// Format: `namespace:group-name`
|
||||
/// - Built-in: `yaak:export`
|
||||
/// - Plugin: `plugin.my-plugin:utilities`
|
||||
#[derive(Clone, Debug, Hash, Eq, PartialEq, Serialize, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct ActionGroupId(pub String);
|
||||
|
||||
impl ActionGroupId {
|
||||
/// Create a namespaced group ID.
|
||||
pub fn new(namespace: &str, name: &str) -> Self {
|
||||
Self(format!("{}:{}", namespace, name))
|
||||
}
|
||||
|
||||
/// Create ID for built-in groups.
|
||||
pub fn builtin(name: &str) -> Self {
|
||||
Self::new("yaak", name)
|
||||
}
|
||||
|
||||
/// Create ID for plugin groups.
|
||||
pub fn plugin(plugin_ref_id: &str, name: &str) -> Self {
|
||||
Self::new(&format!("plugin.{}", plugin_ref_id), name)
|
||||
}
|
||||
|
||||
/// Get the raw string value.
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ActionGroupId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Metadata about an action group.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ActionGroupMetadata {
|
||||
/// Unique identifier for this group.
|
||||
pub id: ActionGroupId,
|
||||
|
||||
/// Display name for the group.
|
||||
pub name: String,
|
||||
|
||||
/// Optional description of the group's purpose.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
|
||||
/// Icon to display for the group.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub icon: Option<String>,
|
||||
|
||||
/// Sort order for displaying groups (lower = earlier).
|
||||
#[serde(default)]
|
||||
pub order: i32,
|
||||
|
||||
/// Optional scope restriction (if set, group only appears in this scope).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub scope: Option<ActionScope>,
|
||||
}
|
||||
|
||||
/// Where an action group was registered from.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
#[serde(tag = "type", rename_all = "kebab-case")]
|
||||
pub enum ActionGroupSource {
|
||||
/// Built into Yaak core.
|
||||
Builtin,
|
||||
/// Registered by a plugin.
|
||||
Plugin {
|
||||
/// Plugin reference ID.
|
||||
ref_id: String,
|
||||
/// Plugin name.
|
||||
name: String,
|
||||
},
|
||||
/// Registered at runtime.
|
||||
Dynamic {
|
||||
/// Source identifier.
|
||||
source_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// A registered action group with its actions.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RegisteredActionGroup {
|
||||
/// Group metadata.
|
||||
pub metadata: ActionGroupMetadata,
|
||||
|
||||
/// IDs of actions in this group (ordered by action's order field).
|
||||
pub action_ids: Vec<ActionId>,
|
||||
|
||||
/// Where the group was registered from.
|
||||
pub source: ActionGroupSource,
|
||||
}
|
||||
|
||||
/// A group with its actions for UI rendering.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ActionGroupWithActions {
|
||||
/// Group metadata.
|
||||
pub group: ActionGroupMetadata,
|
||||
|
||||
/// Actions in this group.
|
||||
pub actions: Vec<ActionMetadata>,
|
||||
}
|
||||
|
||||
/// Built-in action group definitions.
|
||||
pub mod builtin {
|
||||
use super::*;
|
||||
|
||||
/// Export group - export and copy actions.
|
||||
pub fn export() -> ActionGroupMetadata {
|
||||
ActionGroupMetadata {
|
||||
id: ActionGroupId::builtin("export"),
|
||||
name: "Export".into(),
|
||||
description: Some("Export and copy actions".into()),
|
||||
icon: Some("download".into()),
|
||||
order: 100,
|
||||
scope: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Code generation group.
|
||||
pub fn code_generation() -> ActionGroupMetadata {
|
||||
ActionGroupMetadata {
|
||||
id: ActionGroupId::builtin("code-generation"),
|
||||
name: "Code Generation".into(),
|
||||
description: Some("Generate code snippets from requests".into()),
|
||||
icon: Some("code".into()),
|
||||
order: 200,
|
||||
scope: Some(ActionScope::HttpRequest),
|
||||
}
|
||||
}
|
||||
|
||||
/// Send group - request sending actions.
|
||||
pub fn send() -> ActionGroupMetadata {
|
||||
ActionGroupMetadata {
|
||||
id: ActionGroupId::builtin("send"),
|
||||
name: "Send".into(),
|
||||
description: Some("Actions for sending requests".into()),
|
||||
icon: Some("play".into()),
|
||||
order: 50,
|
||||
scope: Some(ActionScope::HttpRequest),
|
||||
}
|
||||
}
|
||||
|
||||
/// Import group.
|
||||
pub fn import() -> ActionGroupMetadata {
|
||||
ActionGroupMetadata {
|
||||
id: ActionGroupId::builtin("import"),
|
||||
name: "Import".into(),
|
||||
description: Some("Import data from files".into()),
|
||||
icon: Some("upload".into()),
|
||||
order: 150,
|
||||
scope: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Workspace management group.
|
||||
pub fn workspace() -> ActionGroupMetadata {
|
||||
ActionGroupMetadata {
|
||||
id: ActionGroupId::builtin("workspace"),
|
||||
name: "Workspace".into(),
|
||||
description: Some("Workspace management actions".into()),
|
||||
icon: Some("folder".into()),
|
||||
order: 300,
|
||||
scope: Some(ActionScope::Workspace),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all built-in group definitions.
|
||||
pub fn all() -> Vec<ActionGroupMetadata> {
|
||||
vec![send(), export(), import(), code_generation(), workspace()]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_group_id_creation() {
|
||||
let id = ActionGroupId::builtin("export");
|
||||
assert_eq!(id.as_str(), "yaak:export");
|
||||
|
||||
let plugin_id = ActionGroupId::plugin("my-plugin", "utilities");
|
||||
assert_eq!(plugin_id.as_str(), "plugin.my-plugin:utilities");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_builtin_groups() {
|
||||
let groups = builtin::all();
|
||||
assert!(!groups.is_empty());
|
||||
assert!(groups.iter().any(|g| g.id == ActionGroupId::builtin("export")));
|
||||
}
|
||||
}
|
||||
103
crates/yaak-actions/src/handler.rs
Normal file
103
crates/yaak-actions/src/handler.rs
Normal file
@@ -0,0 +1,103 @@
|
||||
//! Action handler types and execution.
|
||||
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{ActionError, ActionParams, ActionResult, CurrentContext};
|
||||
|
||||
/// A boxed future for async action handlers.
|
||||
pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
|
||||
|
||||
/// Function signature for action handlers.
|
||||
pub type ActionHandlerFn = Arc<
|
||||
dyn Fn(CurrentContext, ActionParams) -> BoxFuture<'static, Result<ActionResult, ActionError>>
|
||||
+ Send
|
||||
+ Sync,
|
||||
>;
|
||||
|
||||
/// Trait for types that can handle action invocations.
|
||||
pub trait ActionHandler: Send + Sync {
|
||||
/// Execute the action with the given context and parameters.
|
||||
fn handle(
|
||||
&self,
|
||||
context: CurrentContext,
|
||||
params: ActionParams,
|
||||
) -> BoxFuture<'static, Result<ActionResult, ActionError>>;
|
||||
}
|
||||
|
||||
/// Wrapper to create an ActionHandler from a function.
|
||||
pub struct FnHandler<F>(pub F);
|
||||
|
||||
impl<F, Fut> ActionHandler for FnHandler<F>
|
||||
where
|
||||
F: Fn(CurrentContext, ActionParams) -> Fut + Send + Sync,
|
||||
Fut: Future<Output = Result<ActionResult, ActionError>> + Send + 'static,
|
||||
{
|
||||
fn handle(
|
||||
&self,
|
||||
context: CurrentContext,
|
||||
params: ActionParams,
|
||||
) -> BoxFuture<'static, Result<ActionResult, ActionError>> {
|
||||
Box::pin((self.0)(context, params))
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an action handler from an async function.
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// let handler = handler_fn(|ctx, params| async move {
|
||||
/// Ok(ActionResult::ok())
|
||||
/// });
|
||||
/// ```
|
||||
pub fn handler_fn<F, Fut>(f: F) -> FnHandler<F>
|
||||
where
|
||||
F: Fn(CurrentContext, ActionParams) -> Fut + Send + Sync,
|
||||
Fut: Future<Output = Result<ActionResult, ActionError>> + Send + 'static,
|
||||
{
|
||||
FnHandler(f)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_handler_fn() {
|
||||
let handler = handler_fn(|_ctx, _params| async move { Ok(ActionResult::ok()) });
|
||||
|
||||
let result = handler
|
||||
.handle(CurrentContext::default(), ActionParams::empty())
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_handler_with_params() {
|
||||
let handler = handler_fn(|_ctx, params| async move {
|
||||
let name: Option<String> = params.get("name");
|
||||
Ok(ActionResult::with_message(format!(
|
||||
"Hello, {}!",
|
||||
name.unwrap_or_else(|| "World".to_string())
|
||||
)))
|
||||
});
|
||||
|
||||
let params = ActionParams::from_json(serde_json::json!({
|
||||
"name": "Yaak"
|
||||
}));
|
||||
|
||||
let result = handler
|
||||
.handle(CurrentContext::default(), params)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
match result {
|
||||
ActionResult::Success { message, .. } => {
|
||||
assert_eq!(message, Some("Hello, Yaak!".to_string()));
|
||||
}
|
||||
_ => panic!("Expected Success result"),
|
||||
}
|
||||
}
|
||||
}
|
||||
18
crates/yaak-actions/src/lib.rs
Normal file
18
crates/yaak-actions/src/lib.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
//! Centralized action system for Yaak.
|
||||
//!
|
||||
//! This crate provides a unified hub for registering and invoking actions
|
||||
//! across all entry points: plugins, Tauri desktop app, CLI, deep links, and MCP server.
|
||||
|
||||
mod context;
|
||||
mod error;
|
||||
mod executor;
|
||||
mod groups;
|
||||
mod handler;
|
||||
mod types;
|
||||
|
||||
pub use context::*;
|
||||
pub use error::*;
|
||||
pub use executor::*;
|
||||
pub use groups::*;
|
||||
pub use handler::*;
|
||||
pub use types::*;
|
||||
273
crates/yaak-actions/src/types.rs
Normal file
273
crates/yaak-actions/src/types.rs
Normal file
@@ -0,0 +1,273 @@
|
||||
//! Core types for the action system.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
|
||||
use crate::{ActionGroupId, RequiredContext};
|
||||
|
||||
/// Unique identifier for an action.
|
||||
///
|
||||
/// Format: `namespace:category:name`
|
||||
/// - Built-in: `yaak:http-request:send`
|
||||
/// - Plugin: `plugin.copy-curl:http-request:copy`
|
||||
#[derive(Clone, Debug, Hash, Eq, PartialEq, Serialize, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct ActionId(pub String);
|
||||
|
||||
impl ActionId {
|
||||
/// Create a namespaced action ID.
|
||||
pub fn new(namespace: &str, category: &str, name: &str) -> Self {
|
||||
Self(format!("{}:{}:{}", namespace, category, name))
|
||||
}
|
||||
|
||||
/// Create ID for built-in actions.
|
||||
pub fn builtin(category: &str, name: &str) -> Self {
|
||||
Self::new("yaak", category, name)
|
||||
}
|
||||
|
||||
/// Create ID for plugin actions.
|
||||
pub fn plugin(plugin_ref_id: &str, category: &str, name: &str) -> Self {
|
||||
Self::new(&format!("plugin.{}", plugin_ref_id), category, name)
|
||||
}
|
||||
|
||||
/// Get the raw string value.
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ActionId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// The scope in which an action can be invoked.
|
||||
#[derive(Clone, Debug, Hash, Eq, PartialEq, Serialize, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ActionScope {
|
||||
/// Global actions available everywhere.
|
||||
Global,
|
||||
/// Actions on HTTP requests.
|
||||
HttpRequest,
|
||||
/// Actions on WebSocket requests.
|
||||
WebsocketRequest,
|
||||
/// Actions on gRPC requests.
|
||||
GrpcRequest,
|
||||
/// Actions on workspaces.
|
||||
Workspace,
|
||||
/// Actions on folders.
|
||||
Folder,
|
||||
/// Actions on environments.
|
||||
Environment,
|
||||
/// Actions on cookie jars.
|
||||
CookieJar,
|
||||
}
|
||||
|
||||
/// Metadata about an action for discovery.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ActionMetadata {
|
||||
/// Unique identifier for this action.
|
||||
pub id: ActionId,
|
||||
|
||||
/// Display label for the action.
|
||||
pub label: String,
|
||||
|
||||
/// Optional description of what the action does.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
|
||||
/// Icon name to display.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub icon: Option<String>,
|
||||
|
||||
/// The scope this action applies to.
|
||||
pub scope: ActionScope,
|
||||
|
||||
/// Keyboard shortcut (e.g., "Cmd+Enter").
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub keyboard_shortcut: Option<String>,
|
||||
|
||||
/// Whether the action requires a selection/target.
|
||||
#[serde(default)]
|
||||
pub requires_selection: bool,
|
||||
|
||||
/// Optional condition expression for when action is enabled.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled_condition: Option<String>,
|
||||
|
||||
/// Optional group this action belongs to.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub group_id: Option<ActionGroupId>,
|
||||
|
||||
/// Sort order within a group (lower = earlier).
|
||||
#[serde(default)]
|
||||
pub order: i32,
|
||||
|
||||
/// Context requirements for this action.
|
||||
#[serde(default)]
|
||||
pub required_context: RequiredContext,
|
||||
}
|
||||
|
||||
/// Where an action was registered from.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
#[serde(tag = "type", rename_all = "kebab-case")]
|
||||
pub enum ActionSource {
|
||||
/// Built into Yaak core.
|
||||
Builtin,
|
||||
/// Registered by a plugin.
|
||||
Plugin {
|
||||
/// Plugin reference ID.
|
||||
ref_id: String,
|
||||
/// Plugin name.
|
||||
name: String,
|
||||
},
|
||||
/// Registered at runtime (e.g., by MCP tools).
|
||||
Dynamic {
|
||||
/// Source identifier.
|
||||
source_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Parameters passed to action handlers.
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct ActionParams {
|
||||
/// Arbitrary JSON parameters.
|
||||
#[serde(default)]
|
||||
#[ts(type = "unknown")]
|
||||
pub data: serde_json::Value,
|
||||
}
|
||||
|
||||
impl ActionParams {
|
||||
/// Create empty params.
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
data: serde_json::Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create params from a JSON value.
|
||||
pub fn from_json(data: serde_json::Value) -> Self {
|
||||
Self { data }
|
||||
}
|
||||
|
||||
/// Get a typed value from the params.
|
||||
pub fn get<T: serde::de::DeserializeOwned>(&self, key: &str) -> Option<T> {
|
||||
self.data
|
||||
.get(key)
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of action execution.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
#[serde(tag = "type", rename_all = "kebab-case")]
|
||||
pub enum ActionResult {
|
||||
/// Action completed successfully.
|
||||
Success {
|
||||
/// Optional data to return.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[ts(type = "unknown")]
|
||||
data: Option<serde_json::Value>,
|
||||
/// Optional message to display.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
message: Option<String>,
|
||||
},
|
||||
|
||||
/// Action requires user input to continue.
|
||||
RequiresInput {
|
||||
/// Prompt to show user.
|
||||
prompt: InputPrompt,
|
||||
/// Continuation token.
|
||||
continuation_id: String,
|
||||
},
|
||||
|
||||
/// Action was cancelled by the user.
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl ActionResult {
|
||||
/// Create a success result with no data.
|
||||
pub fn ok() -> Self {
|
||||
Self::Success {
|
||||
data: None,
|
||||
message: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a success result with a message.
|
||||
pub fn with_message(message: impl Into<String>) -> Self {
|
||||
Self::Success {
|
||||
data: None,
|
||||
message: Some(message.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a success result with data.
|
||||
pub fn with_data(data: serde_json::Value) -> Self {
|
||||
Self::Success {
|
||||
data: Some(data),
|
||||
message: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A prompt for user input.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
#[serde(tag = "type", rename_all = "kebab-case")]
|
||||
pub enum InputPrompt {
|
||||
/// Text input prompt.
|
||||
Text {
|
||||
label: String,
|
||||
placeholder: Option<String>,
|
||||
default_value: Option<String>,
|
||||
},
|
||||
/// Selection prompt.
|
||||
Select {
|
||||
label: String,
|
||||
options: Vec<SelectOption>,
|
||||
},
|
||||
/// Confirmation prompt.
|
||||
Confirm { label: String },
|
||||
}
|
||||
|
||||
/// An option in a select prompt.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct SelectOption {
|
||||
pub label: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_action_id_creation() {
|
||||
let id = ActionId::builtin("http-request", "send");
|
||||
assert_eq!(id.as_str(), "yaak:http-request:send");
|
||||
|
||||
let plugin_id = ActionId::plugin("copy-curl", "http-request", "copy");
|
||||
assert_eq!(plugin_id.as_str(), "plugin.copy-curl:http-request:copy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_action_params() {
|
||||
let params = ActionParams::from_json(serde_json::json!({
|
||||
"name": "test",
|
||||
"count": 42
|
||||
}));
|
||||
|
||||
assert_eq!(params.get::<String>("name"), Some("test".to_string()));
|
||||
assert_eq!(params.get::<i32>("count"), Some(42));
|
||||
assert_eq!(params.get::<String>("missing"), None);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { useMemo } from 'react';
|
||||
import { BranchDeleteResult, CloneResult, GitCommit, GitRemote, GitStatusSummary, PullResult, PushResult } from './bindings/gen_git';
|
||||
|
||||
export * from './bindings/gen_git';
|
||||
export * from './bindings/gen_models';
|
||||
|
||||
export interface GitCredentials {
|
||||
username: string;
|
||||
|
||||
@@ -31,7 +31,14 @@ pub enum HttpResponseEvent {
|
||||
},
|
||||
SendUrl {
|
||||
method: String,
|
||||
scheme: String,
|
||||
username: String,
|
||||
password: String,
|
||||
host: String,
|
||||
port: u16,
|
||||
path: String,
|
||||
query: String,
|
||||
fragment: String,
|
||||
},
|
||||
ReceiveUrl {
|
||||
version: Version,
|
||||
@@ -65,7 +72,16 @@ impl Display for HttpResponseEvent {
|
||||
};
|
||||
write!(f, "* Redirect {} -> {} ({})", status, url, behavior_str)
|
||||
}
|
||||
HttpResponseEvent::SendUrl { method, path } => write!(f, "> {} {}", method, path),
|
||||
HttpResponseEvent::SendUrl { method, scheme, username, password, host, port, path, query, fragment } => {
|
||||
let auth_str = if username.is_empty() && password.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("{}:{}@", username, password)
|
||||
};
|
||||
let query_str = if query.is_empty() { String::new() } else { format!("?{}", query) };
|
||||
let fragment_str = if fragment.is_empty() { String::new() } else { format!("#{}", fragment) };
|
||||
write!(f, "> {} {}://{}{}:{}{}{}{}", method, scheme, auth_str, host, port, path, query_str, fragment_str)
|
||||
}
|
||||
HttpResponseEvent::ReceiveUrl { version, status } => {
|
||||
write!(f, "< {} {}", version_to_str(version), status)
|
||||
}
|
||||
@@ -104,7 +120,9 @@ impl From<HttpResponseEvent> for yaak_models::models::HttpResponseEventData {
|
||||
RedirectBehavior::DropBody => "drop_body".to_string(),
|
||||
},
|
||||
},
|
||||
HttpResponseEvent::SendUrl { method, path } => D::SendUrl { method, path },
|
||||
HttpResponseEvent::SendUrl { method, scheme, username, password, host, port, path, query, fragment } => {
|
||||
D::SendUrl { method, scheme, username, password, host, port, path, query, fragment }
|
||||
}
|
||||
HttpResponseEvent::ReceiveUrl { version, status } => {
|
||||
D::ReceiveUrl { version: format!("{:?}", version), status }
|
||||
}
|
||||
@@ -415,8 +433,15 @@ impl HttpSender for ReqwestSender {
|
||||
));
|
||||
|
||||
send_event(HttpResponseEvent::SendUrl {
|
||||
path: sendable_req.url().path().to_string(),
|
||||
method: sendable_req.method().to_string(),
|
||||
scheme: sendable_req.url().scheme().to_string(),
|
||||
username: sendable_req.url().username().to_string(),
|
||||
password: sendable_req.url().password().unwrap_or_default().to_string(),
|
||||
host: sendable_req.url().host_str().unwrap_or_default().to_string(),
|
||||
port: sendable_req.url().port_or_known_default().unwrap_or(0),
|
||||
path: sendable_req.url().path().to_string(),
|
||||
query: sendable_req.url().query().unwrap_or_default().to_string(),
|
||||
fragment: sendable_req.url().fragment().unwrap_or_default().to_string(),
|
||||
});
|
||||
|
||||
let mut request_headers = Vec::new();
|
||||
|
||||
2
crates/yaak-models/bindings/gen_models.ts
generated
2
crates/yaak-models/bindings/gen_models.ts
generated
@@ -49,7 +49,7 @@ export type HttpResponseEvent = { model: "http_response_event", id: string, crea
|
||||
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
|
||||
* 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, scheme: string, username: string, password: string, host: string, port: number, path: string, query: string, fragment: 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 HttpResponseHeader = { name: string, value: string, };
|
||||
|
||||
|
||||
@@ -1495,7 +1495,21 @@ pub enum HttpResponseEventData {
|
||||
},
|
||||
SendUrl {
|
||||
method: String,
|
||||
#[serde(default)]
|
||||
scheme: String,
|
||||
#[serde(default)]
|
||||
username: String,
|
||||
#[serde(default)]
|
||||
password: String,
|
||||
#[serde(default)]
|
||||
host: String,
|
||||
#[serde(default)]
|
||||
port: u16,
|
||||
path: String,
|
||||
#[serde(default)]
|
||||
query: String,
|
||||
#[serde(default)]
|
||||
fragment: String,
|
||||
},
|
||||
ReceiveUrl {
|
||||
version: String,
|
||||
|
||||
123
package-lock.json
generated
123
package-lock.json
generated
@@ -63,7 +63,7 @@
|
||||
"src-web"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.3.10",
|
||||
"@biomejs/biome": "^2.3.13",
|
||||
"@tauri-apps/cli": "^2.9.6",
|
||||
"@yaakapp/cli": "^0.3.4",
|
||||
"dotenv-cli": "^11.0.0",
|
||||
@@ -501,9 +501,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/biome": {
|
||||
"version": "2.3.11",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.11.tgz",
|
||||
"integrity": "sha512-/zt+6qazBWguPG6+eWmiELqO+9jRsMZ/DBU3lfuU2ngtIQYzymocHhKiZRyrbra4aCOoyTg/BmY+6WH5mv9xmQ==",
|
||||
"version": "2.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.13.tgz",
|
||||
"integrity": "sha512-Fw7UsV0UAtWIBIm0M7g5CRerpu1eKyKAXIazzxhbXYUyMkwNrkX/KLkGI7b+uVDQ5cLUMfOC9vR60q9IDYDstA==",
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"bin": {
|
||||
@@ -517,20 +517,20 @@
|
||||
"url": "https://opencollective.com/biome"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@biomejs/cli-darwin-arm64": "2.3.11",
|
||||
"@biomejs/cli-darwin-x64": "2.3.11",
|
||||
"@biomejs/cli-linux-arm64": "2.3.11",
|
||||
"@biomejs/cli-linux-arm64-musl": "2.3.11",
|
||||
"@biomejs/cli-linux-x64": "2.3.11",
|
||||
"@biomejs/cli-linux-x64-musl": "2.3.11",
|
||||
"@biomejs/cli-win32-arm64": "2.3.11",
|
||||
"@biomejs/cli-win32-x64": "2.3.11"
|
||||
"@biomejs/cli-darwin-arm64": "2.3.13",
|
||||
"@biomejs/cli-darwin-x64": "2.3.13",
|
||||
"@biomejs/cli-linux-arm64": "2.3.13",
|
||||
"@biomejs/cli-linux-arm64-musl": "2.3.13",
|
||||
"@biomejs/cli-linux-x64": "2.3.13",
|
||||
"@biomejs/cli-linux-x64-musl": "2.3.13",
|
||||
"@biomejs/cli-win32-arm64": "2.3.13",
|
||||
"@biomejs/cli-win32-x64": "2.3.13"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-darwin-arm64": {
|
||||
"version": "2.3.11",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.11.tgz",
|
||||
"integrity": "sha512-/uXXkBcPKVQY7rc9Ys2CrlirBJYbpESEDme7RKiBD6MmqR2w3j0+ZZXRIL2xiaNPsIMMNhP1YnA+jRRxoOAFrA==",
|
||||
"version": "2.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.13.tgz",
|
||||
"integrity": "sha512-0OCwP0/BoKzyJHnFdaTk/i7hIP9JHH9oJJq6hrSCPmJPo8JWcJhprK4gQlhFzrwdTBAW4Bjt/RmCf3ZZe59gwQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -545,9 +545,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-darwin-x64": {
|
||||
"version": "2.3.11",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.11.tgz",
|
||||
"integrity": "sha512-fh7nnvbweDPm2xEmFjfmq7zSUiox88plgdHF9OIW4i99WnXrAC3o2P3ag9judoUMv8FCSUnlwJCM1B64nO5Fbg==",
|
||||
"version": "2.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.13.tgz",
|
||||
"integrity": "sha512-AGr8OoemT/ejynbIu56qeil2+F2WLkIjn2d8jGK1JkchxnMUhYOfnqc9sVzcRxpG9Ycvw4weQ5sprRvtb7Yhcw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -562,9 +562,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-arm64": {
|
||||
"version": "2.3.11",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.11.tgz",
|
||||
"integrity": "sha512-l4xkGa9E7Uc0/05qU2lMYfN1H+fzzkHgaJoy98wO+b/7Gl78srbCRRgwYSW+BTLixTBrM6Ede5NSBwt7rd/i6g==",
|
||||
"version": "2.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.13.tgz",
|
||||
"integrity": "sha512-xvOiFkrDNu607MPMBUQ6huHmBG1PZLOrqhtK6pXJW3GjfVqJg0Z/qpTdhXfcqWdSZHcT+Nct2fOgewZvytESkw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -579,9 +579,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-arm64-musl": {
|
||||
"version": "2.3.11",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.11.tgz",
|
||||
"integrity": "sha512-XPSQ+XIPZMLaZ6zveQdwNjbX+QdROEd1zPgMwD47zvHV+tCGB88VH+aynyGxAHdzL+Tm/+DtKST5SECs4iwCLg==",
|
||||
"version": "2.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.13.tgz",
|
||||
"integrity": "sha512-TUdDCSY+Eo/EHjhJz7P2GnWwfqet+lFxBZzGHldrvULr59AgahamLs/N85SC4+bdF86EhqDuuw9rYLvLFWWlXA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -596,9 +596,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-x64": {
|
||||
"version": "2.3.11",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.11.tgz",
|
||||
"integrity": "sha512-/1s9V/H3cSe0r0Mv/Z8JryF5x9ywRxywomqZVLHAoa/uN0eY7F8gEngWKNS5vbbN/BsfpCG5yeBT5ENh50Frxg==",
|
||||
"version": "2.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.13.tgz",
|
||||
"integrity": "sha512-s+YsZlgiXNq8XkgHs6xdvKDFOj/bwTEevqEY6rC2I3cBHbxXYU1LOZstH3Ffw9hE5tE1sqT7U23C00MzkXztMw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -613,9 +613,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-x64-musl": {
|
||||
"version": "2.3.11",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.11.tgz",
|
||||
"integrity": "sha512-vU7a8wLs5C9yJ4CB8a44r12aXYb8yYgBn+WeyzbMjaCMklzCv1oXr8x+VEyWodgJt9bDmhiaW/I0RHbn7rsNmw==",
|
||||
"version": "2.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.13.tgz",
|
||||
"integrity": "sha512-0bdwFVSbbM//Sds6OjtnmQGp4eUjOTt6kHvR/1P0ieR9GcTUAlPNvPC3DiavTqq302W34Ae2T6u5VVNGuQtGlQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -630,9 +630,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-win32-arm64": {
|
||||
"version": "2.3.11",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.11.tgz",
|
||||
"integrity": "sha512-PZQ6ElCOnkYapSsysiTy0+fYX+agXPlWugh6+eQ6uPKI3vKAqNp6TnMhoM3oY2NltSB89hz59o8xIfOdyhi9Iw==",
|
||||
"version": "2.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.13.tgz",
|
||||
"integrity": "sha512-QweDxY89fq0VvrxME+wS/BXKmqMrOTZlN9SqQ79kQSIc3FrEwvW/PvUegQF6XIVaekncDykB5dzPqjbwSKs9DA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -647,9 +647,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-win32-x64": {
|
||||
"version": "2.3.11",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.11.tgz",
|
||||
"integrity": "sha512-43VrG813EW+b5+YbDbz31uUsheX+qFKCpXeY9kfdAx+ww3naKxeVkTD9zLIWxUPfJquANMHrmW3wbe/037G0Qg==",
|
||||
"version": "2.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.13.tgz",
|
||||
"integrity": "sha512-trDw2ogdM2lyav9WFQsdsfdVy1dvZALymRpgmWsvSez0BJzBjulhOT/t+wyKeh3pZWvwP3VMs1SoOKwO3wecMQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -807,6 +807,21 @@
|
||||
"@lezer/xml": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-yaml": {
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.2.tgz",
|
||||
"integrity": "sha512-dxrfG8w5Ce/QbT7YID7mWZFKhdhsaTNOYjOkSIMt1qmC4VQnXSDSYVHHHn8k6kJUfIhtLo8t1JJgltlxWdsITw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.2.0",
|
||||
"@lezer/lr": "^1.0.0",
|
||||
"@lezer/yaml": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/language": {
|
||||
"version": "6.12.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.1.tgz",
|
||||
@@ -832,6 +847,19 @@
|
||||
"crelt": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/merge": {
|
||||
"version": "6.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/merge/-/merge-6.11.2.tgz",
|
||||
"integrity": "sha512-NO5EJd2rLRbwVWLgMdhIntDIhfDtMOKYEZgqV5WnkNUS2oXOCVWLPjG/kgl/Jth2fGiOuG947bteqxP9nBXmMg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"style-mod": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/search": {
|
||||
"version": "6.5.11",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.11.tgz",
|
||||
@@ -1614,6 +1642,17 @@
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/yaml": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.3.tgz",
|
||||
"integrity": "sha512-GuBLekbw9jDBDhGur82nuwkxKQ+a3W5H0GfaAthDXcAu+XdpS43VlnxA9E9hllkpSP5ellRDKjLLj7Lu9Wr6xA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@marijn/find-cluster-break": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
|
||||
@@ -7811,9 +7850,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.11.4",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz",
|
||||
"integrity": "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==",
|
||||
"version": "4.11.7",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.7.tgz",
|
||||
"integrity": "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
@@ -15721,7 +15760,7 @@
|
||||
},
|
||||
"packages/plugin-runtime-types": {
|
||||
"name": "@yaakapp/api",
|
||||
"version": "0.7.1",
|
||||
"version": "0.8.0",
|
||||
"dependencies": {
|
||||
"@types/node": "^24.0.13"
|
||||
},
|
||||
@@ -15743,7 +15782,7 @@
|
||||
"@hono/mcp": "^0.2.3",
|
||||
"@hono/node-server": "^1.19.7",
|
||||
"@modelcontextprotocol/sdk": "^1.25.2",
|
||||
"hono": "^4.11.4",
|
||||
"hono": "^4.11.7",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -15984,7 +16023,9 @@
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
"@codemirror/lang-markdown": "^6.3.2",
|
||||
"@codemirror/lang-xml": "^6.1.0",
|
||||
"@codemirror/lang-yaml": "^6.1.2",
|
||||
"@codemirror/language": "^6.11.0",
|
||||
"@codemirror/merge": "^6.11.2",
|
||||
"@codemirror/search": "^6.5.11",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@gilbarbara/deep-equal": "^0.3.1",
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
"js-yaml": "^4.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.3.10",
|
||||
"@biomejs/biome": "^2.3.13",
|
||||
"@tauri-apps/cli": "^2.9.6",
|
||||
"@yaakapp/cli": "^0.3.4",
|
||||
"dotenv-cli": "^11.0.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@yaakapp/api",
|
||||
"version": "0.7.1",
|
||||
"version": "0.8.0",
|
||||
"keywords": [
|
||||
"api-client",
|
||||
"insomnia-alternative",
|
||||
|
||||
@@ -338,8 +338,8 @@ export class PluginInstance {
|
||||
if (payload.type === 'call_http_authentication_request' && this.#mod?.authentication) {
|
||||
const auth = this.#mod.authentication;
|
||||
if (typeof auth?.onApply === 'function') {
|
||||
auth.args = await applyDynamicFormInput(ctx, auth.args, payload);
|
||||
payload.values = applyFormInputDefaults(auth.args, payload.values);
|
||||
const resolvedArgs = await applyDynamicFormInput(ctx, auth.args, payload);
|
||||
payload.values = applyFormInputDefaults(resolvedArgs, payload.values);
|
||||
this.#sendPayload(
|
||||
context,
|
||||
{
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"@hono/mcp": "^0.2.3",
|
||||
"@hono/node-server": "^1.19.7",
|
||||
"@modelcontextprotocol/sdk": "^1.25.2",
|
||||
"hono": "^4.11.4",
|
||||
"hono": "^4.11.7",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"build": "yaakcli build",
|
||||
"dev": "yaakcli dev"
|
||||
"dev": "yaakcli dev",
|
||||
"test": "vitest --run tests"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,8 @@ export const plugin: PluginDefinition = {
|
||||
},
|
||||
],
|
||||
async onApply(_ctx, { values }) {
|
||||
const { username, password } = values;
|
||||
const username = values.username ?? '';
|
||||
const password = values.password ?? '';
|
||||
const value = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||
return { setHeaders: [{ name: 'Authorization', value }] };
|
||||
},
|
||||
|
||||
77
plugins/auth-basic/tests/index.test.ts
Normal file
77
plugins/auth-basic/tests/index.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { Context } from '@yaakapp/api';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { plugin } from '../src';
|
||||
|
||||
const ctx = {} as Context;
|
||||
|
||||
describe('auth-basic', () => {
|
||||
test('Both username and password', async () => {
|
||||
expect(
|
||||
await plugin.authentication?.onApply(ctx, {
|
||||
values: { username: 'user', password: 'pass' },
|
||||
headers: [],
|
||||
url: 'https://yaak.app',
|
||||
method: 'POST',
|
||||
contextId: '111',
|
||||
}),
|
||||
).toEqual({
|
||||
setHeaders: [{ name: 'Authorization', value: `Basic ${Buffer.from('user:pass').toString('base64')}` }],
|
||||
});
|
||||
});
|
||||
|
||||
test('Empty password', async () => {
|
||||
expect(
|
||||
await plugin.authentication?.onApply(ctx, {
|
||||
values: { username: 'apikey', password: '' },
|
||||
headers: [],
|
||||
url: 'https://yaak.app',
|
||||
method: 'POST',
|
||||
contextId: '111',
|
||||
}),
|
||||
).toEqual({
|
||||
setHeaders: [{ name: 'Authorization', value: `Basic ${Buffer.from('apikey:').toString('base64')}` }],
|
||||
});
|
||||
});
|
||||
|
||||
test('Missing password (undefined)', async () => {
|
||||
expect(
|
||||
await plugin.authentication?.onApply(ctx, {
|
||||
values: { username: 'apikey' },
|
||||
headers: [],
|
||||
url: 'https://yaak.app',
|
||||
method: 'POST',
|
||||
contextId: '111',
|
||||
}),
|
||||
).toEqual({
|
||||
setHeaders: [{ name: 'Authorization', value: `Basic ${Buffer.from('apikey:').toString('base64')}` }],
|
||||
});
|
||||
});
|
||||
|
||||
test('Missing username (undefined)', async () => {
|
||||
expect(
|
||||
await plugin.authentication?.onApply(ctx, {
|
||||
values: { password: 'secret' },
|
||||
headers: [],
|
||||
url: 'https://yaak.app',
|
||||
method: 'POST',
|
||||
contextId: '111',
|
||||
}),
|
||||
).toEqual({
|
||||
setHeaders: [{ name: 'Authorization', value: `Basic ${Buffer.from(':secret').toString('base64')}` }],
|
||||
});
|
||||
});
|
||||
|
||||
test('No values (both undefined)', async () => {
|
||||
expect(
|
||||
await plugin.authentication?.onApply(ctx, {
|
||||
values: {},
|
||||
headers: [],
|
||||
url: 'https://yaak.app',
|
||||
method: 'POST',
|
||||
contextId: '111',
|
||||
}),
|
||||
).toEqual({
|
||||
setHeaders: [{ name: 'Authorization', value: `Basic ${Buffer.from(':').toString('base64')}` }],
|
||||
});
|
||||
});
|
||||
});
|
||||
335
plugins/auth-oauth2/src/callbackServer.ts
Normal file
335
plugins/auth-oauth2/src/callbackServer.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import http from 'node:http';
|
||||
import type { Context } from '@yaakapp/api';
|
||||
|
||||
export const HOSTED_CALLBACK_URL = 'https://oauth.yaak.app/redirect';
|
||||
export const DEFAULT_LOCALHOST_PORT = 8765;
|
||||
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
/** Singleton: only one callback server runs at a time across all OAuth flows. */
|
||||
let activeServer: CallbackServerResult | null = null;
|
||||
|
||||
export interface CallbackServerResult {
|
||||
/** The port the server is listening on */
|
||||
port: number;
|
||||
/** The full redirect URI to register with the OAuth provider */
|
||||
redirectUri: string;
|
||||
/** Promise that resolves with the callback URL when received */
|
||||
waitForCallback: () => Promise<string>;
|
||||
/** Stop the server */
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a local HTTP server to receive OAuth callbacks.
|
||||
* Only one server runs at a time — if a previous server is still active,
|
||||
* it is stopped before starting the new one.
|
||||
* Returns the port, redirect URI, and a promise that resolves when the callback is received.
|
||||
*/
|
||||
export function startCallbackServer(options: {
|
||||
/** Specific port to use, or 0 for random available port */
|
||||
port?: number;
|
||||
/** Path for the callback endpoint */
|
||||
path?: string;
|
||||
/** Timeout in milliseconds (default 5 minutes) */
|
||||
timeoutMs?: number;
|
||||
}): Promise<CallbackServerResult> {
|
||||
// Stop any previously active server before starting a new one
|
||||
if (activeServer) {
|
||||
console.log('[oauth2] Stopping previous callback server before starting new one');
|
||||
activeServer.stop();
|
||||
activeServer = null;
|
||||
}
|
||||
|
||||
const { port = 0, path = '/callback', timeoutMs = CALLBACK_TIMEOUT_MS } = options;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let callbackResolve: ((url: string) => void) | null = null;
|
||||
let callbackReject: ((err: Error) => void) | null = null;
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
|
||||
let stopped = false;
|
||||
|
||||
const server = http.createServer((req: IncomingMessage, res: ServerResponse) => {
|
||||
const reqUrl = new URL(req.url ?? '/', `http://${req.headers.host}`);
|
||||
|
||||
// Only handle the callback path
|
||||
if (reqUrl.pathname !== path && reqUrl.pathname !== `${path}/`) {
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||
res.end('Not Found');
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'POST') {
|
||||
// POST: read JSON body with the final callback URL and resolve
|
||||
let body = '';
|
||||
req.on('data', (chunk: Buffer) => {
|
||||
body += chunk.toString();
|
||||
});
|
||||
req.on('end', () => {
|
||||
try {
|
||||
const { url: callbackUrl } = JSON.parse(body);
|
||||
if (!callbackUrl || typeof callbackUrl !== 'string') {
|
||||
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
||||
res.end('Missing url in request body');
|
||||
return;
|
||||
}
|
||||
|
||||
// Send success response
|
||||
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||
res.end('OK');
|
||||
|
||||
// Resolve the callback promise
|
||||
if (callbackResolve) {
|
||||
callbackResolve(callbackUrl);
|
||||
callbackResolve = null;
|
||||
callbackReject = null;
|
||||
}
|
||||
|
||||
// Stop the server after a short delay to ensure response is sent
|
||||
setTimeout(() => stopServer(), 100);
|
||||
} catch {
|
||||
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
||||
res.end('Invalid JSON');
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// GET: serve intermediate page that reads the fragment and POSTs back
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end(getFragmentForwardingHtml());
|
||||
});
|
||||
|
||||
server.on('error', (err: Error) => {
|
||||
if (!stopped) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
const stopServer = () => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
|
||||
// Clear the singleton reference
|
||||
if (activeServer?.stop === stopServer) {
|
||||
activeServer = null;
|
||||
}
|
||||
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
timeoutHandle = null;
|
||||
}
|
||||
|
||||
server.close();
|
||||
|
||||
if (callbackReject) {
|
||||
callbackReject(new Error('Callback server stopped'));
|
||||
callbackResolve = null;
|
||||
callbackReject = null;
|
||||
}
|
||||
};
|
||||
|
||||
server.listen(port, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
reject(new Error('Failed to get server address'));
|
||||
return;
|
||||
}
|
||||
|
||||
const actualPort = address.port;
|
||||
const redirectUri = `http://127.0.0.1:${actualPort}${path}`;
|
||||
|
||||
console.log(`[oauth2] Callback server listening on ${redirectUri}`);
|
||||
|
||||
const result: CallbackServerResult = {
|
||||
port: actualPort,
|
||||
redirectUri,
|
||||
waitForCallback: () => {
|
||||
return new Promise<string>((res, rej) => {
|
||||
if (stopped) {
|
||||
rej(new Error('Callback server already stopped'));
|
||||
return;
|
||||
}
|
||||
|
||||
callbackResolve = res;
|
||||
callbackReject = rej;
|
||||
|
||||
// Set timeout
|
||||
timeoutHandle = setTimeout(() => {
|
||||
if (callbackReject) {
|
||||
callbackReject(new Error('Authorization timed out'));
|
||||
callbackResolve = null;
|
||||
callbackReject = null;
|
||||
}
|
||||
stopServer();
|
||||
}, timeoutMs);
|
||||
});
|
||||
},
|
||||
stop: stopServer,
|
||||
};
|
||||
|
||||
activeServer = result;
|
||||
resolve(result);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the redirect URI for the hosted callback page.
|
||||
* The hosted page will redirect to the local server with the OAuth response.
|
||||
*/
|
||||
export function buildHostedCallbackRedirectUri(localPort: number, localPath: string): string {
|
||||
const localRedirectUri = `http://127.0.0.1:${localPort}${localPath}`;
|
||||
// The hosted callback page will read params and redirect to the local server
|
||||
return `${HOSTED_CALLBACK_URL}?redirect_to=${encodeURIComponent(localRedirectUri)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open an authorization URL in the system browser, start a local callback server,
|
||||
* and wait for the OAuth provider to redirect back.
|
||||
*
|
||||
* Returns the raw callback URL and the redirect URI that was registered with the
|
||||
* OAuth provider (needed for token exchange).
|
||||
*/
|
||||
export async function getRedirectUrlViaExternalBrowser(
|
||||
ctx: Context,
|
||||
authorizationUrl: URL,
|
||||
options: {
|
||||
callbackType: 'localhost' | 'hosted';
|
||||
callbackPort?: number;
|
||||
},
|
||||
): Promise<{ callbackUrl: string; redirectUri: string }> {
|
||||
const { callbackType, callbackPort } = options;
|
||||
|
||||
// Determine port based on callback type:
|
||||
// - localhost: use specified port or default stable port
|
||||
// - hosted: use random port (0) since hosted page redirects to local
|
||||
const port = callbackType === 'localhost' ? (callbackPort ?? DEFAULT_LOCALHOST_PORT) : 0;
|
||||
|
||||
console.log(
|
||||
`[oauth2] Starting callback server (type: ${callbackType}, port: ${port || 'random'})`,
|
||||
);
|
||||
|
||||
const server = await startCallbackServer({
|
||||
port,
|
||||
path: '/callback',
|
||||
});
|
||||
|
||||
try {
|
||||
// Determine the redirect URI to send to the OAuth provider
|
||||
let oauthRedirectUri: string;
|
||||
|
||||
if (callbackType === 'hosted') {
|
||||
oauthRedirectUri = buildHostedCallbackRedirectUri(server.port, '/callback');
|
||||
console.log('[oauth2] Using hosted callback redirect:', oauthRedirectUri);
|
||||
} else {
|
||||
oauthRedirectUri = server.redirectUri;
|
||||
console.log('[oauth2] Using localhost callback redirect:', oauthRedirectUri);
|
||||
}
|
||||
|
||||
// Set the redirect URI on the authorization URL
|
||||
authorizationUrl.searchParams.set('redirect_uri', oauthRedirectUri);
|
||||
|
||||
const authorizationUrlStr = authorizationUrl.toString();
|
||||
console.log('[oauth2] Opening external browser:', authorizationUrlStr);
|
||||
|
||||
// Show toast to inform user
|
||||
await ctx.toast.show({
|
||||
message: 'Opening browser for authorization...',
|
||||
icon: 'info',
|
||||
timeout: 3000,
|
||||
});
|
||||
|
||||
// Open the system browser
|
||||
await ctx.window.openExternalUrl(authorizationUrlStr);
|
||||
|
||||
// Wait for the callback
|
||||
console.log('[oauth2] Waiting for callback on', server.redirectUri);
|
||||
const callbackUrl = await server.waitForCallback();
|
||||
|
||||
console.log('[oauth2] Received callback:', callbackUrl);
|
||||
|
||||
return { callbackUrl, redirectUri: oauthRedirectUri };
|
||||
} finally {
|
||||
server.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Intermediate HTML page that reads the URL fragment and _fragment query param,
|
||||
* reconstructs a proper OAuth callback URL, and POSTs it back to the server.
|
||||
*
|
||||
* Handles three cases:
|
||||
* - Localhost implicit: fragment is in location.hash (e.g. #access_token=...)
|
||||
* - Hosted implicit: fragment was converted to ?_fragment=... by the hosted redirect page
|
||||
* - Auth code: no fragment, code is already in query params
|
||||
*/
|
||||
function getFragmentForwardingHtml(): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Yaak</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: hsl(244,23%,14%);
|
||||
color: hsl(245,23%,85%);
|
||||
}
|
||||
.container { text-align: center; }
|
||||
.logo { display: block; width: 100px; height: 100px; margin: 0 auto 32px; border-radius: 50%; }
|
||||
h1 { font-size: 28px; font-weight: 600; margin-bottom: 12px; }
|
||||
p { font-size: 16px; color: hsl(245,18%,58%); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<svg class="logo" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(649.94,712.03,-712.03,649.94,179.25,220.59)"><stop offset="0" stop-color="#4cc48c"/><stop offset=".5" stop-color="#476cc9"/><stop offset="1" stop-color="#ba1ab7"/></linearGradient></defs><rect x="0" y="0" width="1024" height="1024" fill="url(#g)"/><g transform="matrix(0.822,0,0,0.822,91.26,91.26)"><path d="M766.775,105.176C902.046,190.129 992.031,340.639 992.031,512C992.031,706.357 876.274,873.892 710,949.361C684.748,838.221 632.417,791.074 538.602,758.96C536.859,790.593 545.561,854.983 522.327,856.611C477.951,859.719 321.557,782.368 310.75,710.135C300.443,641.237 302.536,535.834 294.475,482.283C86.974,483.114 245.65,303.256 245.65,303.256L261.925,368.357L294.475,368.357C294.475,368.357 298.094,296.03 310.75,286.981C326.511,275.713 366.457,254.592 473.502,254.431C519.506,190.629 692.164,133.645 766.775,105.176ZM603.703,352.082C598.577,358.301 614.243,384.787 623.39,401.682C639.967,432.299 672.34,459.32 760.231,456.739C780.796,456.135 808.649,456.743 831.555,448.316C919.689,369.191 665.548,260.941 652.528,270.706C629.157,288.235 677.433,340.481 685.079,352.082C663.595,350.818 630.521,352.121 603.703,352.082ZM515.817,516.822C491.026,516.822 470.898,536.949 470.898,561.741C470.898,586.532 491.026,606.66 515.817,606.66C540.609,606.66 560.736,586.532 560.736,561.741C560.736,536.949 540.609,516.822 515.817,516.822ZM656.608,969.83C610.979,984.25 562.391,992.031 512,992.031C247.063,992.031 31.969,776.937 31.969,512C31.969,247.063 247.063,31.969 512,31.969C581.652,31.969 647.859,46.835 707.634,73.574C674.574,86.913 627.224,104.986 620,103.081C343.573,30.201 98.64,283.528 98.64,511.993C98.64,761.842 376.244,989.043 627.831,910C637.21,907.053 645.743,936.753 656.608,969.83Z" fill="#fff"/></g></svg>
|
||||
<h1 id="title">Authorizing...</h1>
|
||||
<p id="message">Please wait</p>
|
||||
</div>
|
||||
<script>
|
||||
(function() {
|
||||
var title = document.getElementById('title');
|
||||
var message = document.getElementById('message');
|
||||
var url = new URL(window.location.href);
|
||||
var fragment = window.location.hash;
|
||||
var fragmentParam = url.searchParams.get('_fragment');
|
||||
|
||||
// Build the final callback URL:
|
||||
// 1. If _fragment query param exists (from hosted redirect), convert it back to a real fragment
|
||||
// 2. If location.hash exists (direct localhost implicit), use it as-is
|
||||
// 3. Otherwise (auth code flow), use the URL as-is with query params
|
||||
if (fragmentParam) {
|
||||
url.searchParams.delete('_fragment');
|
||||
url.hash = fragmentParam;
|
||||
} else if (fragment && fragment.length > 1) {
|
||||
url.hash = fragment;
|
||||
}
|
||||
|
||||
// POST the final URL back to the callback server
|
||||
fetch(url.pathname, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: url.toString() })
|
||||
}).then(function(res) {
|
||||
if (res.ok) {
|
||||
title.textContent = 'Authorization Complete';
|
||||
message.textContent = 'You may close this tab and return to Yaak';
|
||||
} else {
|
||||
title.textContent = 'Authorization Failed';
|
||||
message.textContent = 'Something went wrong. Please try again.';
|
||||
}
|
||||
}).catch(function() {
|
||||
title.textContent = 'Authorization Failed';
|
||||
message.textContent = 'Something went wrong. Please try again.';
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import type { Context } from '@yaakapp/api';
|
||||
import { getRedirectUrlViaExternalBrowser } from '../callbackServer';
|
||||
import { fetchAccessToken } from '../fetchAccessToken';
|
||||
import { getOrRefreshAccessToken } from '../getOrRefreshAccessToken';
|
||||
import type { AccessToken, TokenStoreArgs } from '../store';
|
||||
@@ -10,6 +11,15 @@ export const PKCE_SHA256 = 'S256';
|
||||
export const PKCE_PLAIN = 'plain';
|
||||
export const DEFAULT_PKCE_METHOD = PKCE_SHA256;
|
||||
|
||||
export type CallbackType = 'localhost' | 'hosted';
|
||||
|
||||
export interface ExternalBrowserOptions {
|
||||
useExternalBrowser: boolean;
|
||||
callbackType: CallbackType;
|
||||
/** Port for localhost callback (only used when callbackType is 'localhost') */
|
||||
callbackPort?: number;
|
||||
}
|
||||
|
||||
export async function getAuthorizationCode(
|
||||
ctx: Context,
|
||||
contextId: string,
|
||||
@@ -25,6 +35,7 @@ export async function getAuthorizationCode(
|
||||
credentialsInBody,
|
||||
pkce,
|
||||
tokenName,
|
||||
externalBrowser,
|
||||
}: {
|
||||
authorizationUrl: string;
|
||||
accessTokenUrl: string;
|
||||
@@ -40,6 +51,7 @@ export async function getAuthorizationCode(
|
||||
codeVerifier: string;
|
||||
} | null;
|
||||
tokenName: 'access_token' | 'id_token';
|
||||
externalBrowser?: ExternalBrowserOptions;
|
||||
},
|
||||
): Promise<AccessToken> {
|
||||
const tokenArgs: TokenStoreArgs = {
|
||||
@@ -68,7 +80,6 @@ export async function getAuthorizationCode(
|
||||
}
|
||||
authorizationUrl.searchParams.set('response_type', 'code');
|
||||
authorizationUrl.searchParams.set('client_id', clientId);
|
||||
if (redirectUri) authorizationUrl.searchParams.set('redirect_uri', redirectUri);
|
||||
if (scope) authorizationUrl.searchParams.set('scope', scope);
|
||||
if (state) authorizationUrl.searchParams.set('state', state);
|
||||
if (audience) authorizationUrl.searchParams.set('audience', audience);
|
||||
@@ -80,12 +91,65 @@ export async function getAuthorizationCode(
|
||||
authorizationUrl.searchParams.set('code_challenge_method', pkce.challengeMethod);
|
||||
}
|
||||
|
||||
let code: string;
|
||||
let actualRedirectUri: string | null = redirectUri;
|
||||
|
||||
// Use external browser flow if enabled
|
||||
if (externalBrowser?.useExternalBrowser) {
|
||||
const result = await getRedirectUrlViaExternalBrowser(ctx, authorizationUrl, {
|
||||
callbackType: externalBrowser.callbackType,
|
||||
callbackPort: externalBrowser.callbackPort,
|
||||
});
|
||||
// Pass null to skip redirect URI matching — the callback came from our own local server
|
||||
const extractedCode = extractCode(result.callbackUrl, null);
|
||||
if (!extractedCode) {
|
||||
throw new Error('No authorization code found in callback URL');
|
||||
}
|
||||
code = extractedCode;
|
||||
actualRedirectUri = result.redirectUri;
|
||||
} else {
|
||||
// Use embedded browser flow (original behavior)
|
||||
if (redirectUri) {
|
||||
authorizationUrl.searchParams.set('redirect_uri', redirectUri);
|
||||
}
|
||||
code = await getCodeViaEmbeddedBrowser(ctx, contextId, authorizationUrl, redirectUri);
|
||||
}
|
||||
|
||||
console.log('[oauth2] Code found');
|
||||
const response = await fetchAccessToken(ctx, {
|
||||
grantType: 'authorization_code',
|
||||
accessTokenUrl,
|
||||
clientId,
|
||||
clientSecret,
|
||||
scope,
|
||||
audience,
|
||||
credentialsInBody,
|
||||
params: [
|
||||
{ name: 'code', value: code },
|
||||
...(pkce ? [{ name: 'code_verifier', value: pkce.codeVerifier }] : []),
|
||||
...(actualRedirectUri ? [{ name: 'redirect_uri', value: actualRedirectUri }] : []),
|
||||
],
|
||||
});
|
||||
|
||||
return storeToken(ctx, tokenArgs, response, tokenName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get authorization code using the embedded browser window.
|
||||
* This is the original flow that monitors navigation events.
|
||||
*/
|
||||
async function getCodeViaEmbeddedBrowser(
|
||||
ctx: Context,
|
||||
contextId: string,
|
||||
authorizationUrl: URL,
|
||||
redirectUri: string | null,
|
||||
): Promise<string> {
|
||||
const dataDirKey = await getDataDirKey(ctx, contextId);
|
||||
const authorizationUrlStr = authorizationUrl.toString();
|
||||
console.log('[oauth2] Authorizing', authorizationUrlStr);
|
||||
console.log('[oauth2] Authorizing via embedded browser', authorizationUrlStr);
|
||||
|
||||
// biome-ignore lint/suspicious/noAsyncPromiseExecutor: none
|
||||
const code = await new Promise<string>(async (resolve, reject) => {
|
||||
// biome-ignore lint/suspicious/noAsyncPromiseExecutor: Required for this pattern
|
||||
return new Promise<string>(async (resolve, reject) => {
|
||||
let foundCode = false;
|
||||
const { close } = await ctx.window.openUrl({
|
||||
dataDirKey,
|
||||
@@ -110,31 +174,12 @@ export async function getAuthorizationCode(
|
||||
return;
|
||||
}
|
||||
|
||||
// Close the window here, because we don't need it anymore!
|
||||
foundCode = true;
|
||||
close();
|
||||
resolve(code);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
console.log('[oauth2] Code found');
|
||||
const response = await fetchAccessToken(ctx, {
|
||||
grantType: 'authorization_code',
|
||||
accessTokenUrl,
|
||||
clientId,
|
||||
clientSecret,
|
||||
scope,
|
||||
audience,
|
||||
credentialsInBody,
|
||||
params: [
|
||||
{ name: 'code', value: code },
|
||||
...(pkce ? [{ name: 'code_verifier', value: pkce.codeVerifier }] : []),
|
||||
...(redirectUri ? [{ name: 'redirect_uri', value: redirectUri }] : []),
|
||||
],
|
||||
});
|
||||
|
||||
return storeToken(ctx, tokenArgs, response, tokenName);
|
||||
}
|
||||
|
||||
export function genPkceCodeVerifier() {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { Context } from '@yaakapp/api';
|
||||
import { getRedirectUrlViaExternalBrowser } from '../callbackServer';
|
||||
import type { AccessToken, AccessTokenRawResponse } from '../store';
|
||||
import { getDataDirKey, getToken, storeToken } from '../store';
|
||||
import { isTokenExpired } from '../util';
|
||||
import type { ExternalBrowserOptions } from './authorizationCode';
|
||||
|
||||
export async function getImplicit(
|
||||
ctx: Context,
|
||||
@@ -15,6 +17,7 @@ export async function getImplicit(
|
||||
state,
|
||||
audience,
|
||||
tokenName,
|
||||
externalBrowser,
|
||||
}: {
|
||||
authorizationUrl: string;
|
||||
responseType: string;
|
||||
@@ -24,6 +27,7 @@ export async function getImplicit(
|
||||
state: string | null;
|
||||
audience: string | null;
|
||||
tokenName: 'access_token' | 'id_token';
|
||||
externalBrowser?: ExternalBrowserOptions;
|
||||
},
|
||||
): Promise<AccessToken> {
|
||||
const tokenArgs = {
|
||||
@@ -43,9 +47,8 @@ export async function getImplicit(
|
||||
} catch {
|
||||
throw new Error(`Invalid authorization URL "${authorizationUrlRaw}"`);
|
||||
}
|
||||
authorizationUrl.searchParams.set('response_type', 'token');
|
||||
authorizationUrl.searchParams.set('response_type', responseType);
|
||||
authorizationUrl.searchParams.set('client_id', clientId);
|
||||
if (redirectUri) authorizationUrl.searchParams.set('redirect_uri', redirectUri);
|
||||
if (scope) authorizationUrl.searchParams.set('scope', scope);
|
||||
if (state) authorizationUrl.searchParams.set('state', state);
|
||||
if (audience) authorizationUrl.searchParams.set('audience', audience);
|
||||
@@ -56,11 +59,55 @@ export async function getImplicit(
|
||||
);
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noAsyncPromiseExecutor: none
|
||||
const newToken = await new Promise<AccessToken>(async (resolve, reject) => {
|
||||
let newToken: AccessToken;
|
||||
|
||||
// Use external browser flow if enabled
|
||||
if (externalBrowser?.useExternalBrowser) {
|
||||
const result = await getRedirectUrlViaExternalBrowser(ctx, authorizationUrl, {
|
||||
callbackType: externalBrowser.callbackType,
|
||||
callbackPort: externalBrowser.callbackPort,
|
||||
});
|
||||
newToken = await extractImplicitToken(ctx, result.callbackUrl, tokenArgs, tokenName);
|
||||
} else {
|
||||
// Use embedded browser flow (original behavior)
|
||||
if (redirectUri) {
|
||||
authorizationUrl.searchParams.set('redirect_uri', redirectUri);
|
||||
}
|
||||
newToken = await getTokenViaEmbeddedBrowser(
|
||||
ctx,
|
||||
contextId,
|
||||
authorizationUrl,
|
||||
tokenArgs,
|
||||
tokenName,
|
||||
);
|
||||
}
|
||||
|
||||
return newToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get token using the embedded browser window.
|
||||
* This is the original flow that monitors navigation events.
|
||||
*/
|
||||
async function getTokenViaEmbeddedBrowser(
|
||||
ctx: Context,
|
||||
contextId: string,
|
||||
authorizationUrl: URL,
|
||||
tokenArgs: {
|
||||
contextId: string;
|
||||
clientId: string;
|
||||
accessTokenUrl: null;
|
||||
authorizationUrl: string;
|
||||
},
|
||||
tokenName: 'access_token' | 'id_token',
|
||||
): Promise<AccessToken> {
|
||||
const dataDirKey = await getDataDirKey(ctx, contextId);
|
||||
const authorizationUrlStr = authorizationUrl.toString();
|
||||
console.log('[oauth2] Authorizing via embedded browser (implicit)', authorizationUrlStr);
|
||||
|
||||
// biome-ignore lint/suspicious/noAsyncPromiseExecutor: Required for this pattern
|
||||
return new Promise<AccessToken>(async (resolve, reject) => {
|
||||
let foundAccessToken = false;
|
||||
const authorizationUrlStr = authorizationUrl.toString();
|
||||
const dataDirKey = await getDataDirKey(ctx, contextId);
|
||||
const { close } = await ctx.window.openUrl({
|
||||
dataDirKey,
|
||||
url: authorizationUrlStr,
|
||||
@@ -97,6 +144,56 @@ export async function getImplicit(
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return newToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the implicit grant token from a callback URL and store it.
|
||||
*/
|
||||
async function extractImplicitToken(
|
||||
ctx: Context,
|
||||
callbackUrl: string,
|
||||
tokenArgs: {
|
||||
contextId: string;
|
||||
clientId: string;
|
||||
accessTokenUrl: null;
|
||||
authorizationUrl: string;
|
||||
},
|
||||
tokenName: 'access_token' | 'id_token',
|
||||
): Promise<AccessToken> {
|
||||
const url = new URL(callbackUrl);
|
||||
|
||||
// Check for errors
|
||||
if (url.searchParams.has('error')) {
|
||||
throw new Error(`Failed to authorize: ${url.searchParams.get('error')}`);
|
||||
}
|
||||
|
||||
// Extract token from fragment
|
||||
const hash = url.hash.slice(1);
|
||||
const params = new URLSearchParams(hash);
|
||||
|
||||
// Also check query params (in case fragment was converted)
|
||||
const accessToken = params.get(tokenName) ?? url.searchParams.get(tokenName);
|
||||
if (!accessToken) {
|
||||
throw new Error(`No ${tokenName} found in callback URL`);
|
||||
}
|
||||
|
||||
// Build response from params (prefer fragment, fall back to query)
|
||||
const response: AccessTokenRawResponse = {
|
||||
access_token: params.get('access_token') ?? url.searchParams.get('access_token') ?? '',
|
||||
token_type: params.get('token_type') ?? url.searchParams.get('token_type') ?? undefined,
|
||||
expires_in: params.has('expires_in')
|
||||
? parseInt(params.get('expires_in') ?? '0', 10)
|
||||
: url.searchParams.has('expires_in')
|
||||
? parseInt(url.searchParams.get('expires_in') ?? '0', 10)
|
||||
: undefined,
|
||||
scope: params.get('scope') ?? url.searchParams.get('scope') ?? undefined,
|
||||
};
|
||||
|
||||
// Include id_token if present
|
||||
const idToken = params.get('id_token') ?? url.searchParams.get('id_token');
|
||||
if (idToken) {
|
||||
response.id_token = idToken;
|
||||
}
|
||||
|
||||
return storeToken(ctx, tokenArgs, response);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ import type {
|
||||
JsonPrimitive,
|
||||
PluginDefinition,
|
||||
} from '@yaakapp/api';
|
||||
import { DEFAULT_LOCALHOST_PORT, HOSTED_CALLBACK_URL } from './callbackServer';
|
||||
import {
|
||||
type CallbackType,
|
||||
DEFAULT_PKCE_METHOD,
|
||||
genPkceCodeVerifier,
|
||||
getAuthorizationCode,
|
||||
@@ -134,8 +136,6 @@ export const plugin: PluginDefinition = {
|
||||
defaultValue: defaultGrantType,
|
||||
options: grantTypes,
|
||||
},
|
||||
|
||||
// Always-present fields
|
||||
{
|
||||
type: 'text',
|
||||
name: 'clientId',
|
||||
@@ -169,11 +169,105 @@ export const plugin: PluginDefinition = {
|
||||
completionOptions: accessTokenUrls.map((url) => ({ label: url, value: url })),
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'redirectUri',
|
||||
label: 'Redirect URI',
|
||||
optional: true,
|
||||
dynamic: hiddenIfNot(['authorization_code', 'implicit']),
|
||||
type: 'banner',
|
||||
inputs: [
|
||||
{
|
||||
type: 'checkbox',
|
||||
name: 'useExternalBrowser',
|
||||
label: 'Use External Browser',
|
||||
description:
|
||||
'Open authorization URL in your system browser instead of the embedded browser. ' +
|
||||
'Useful when the OAuth provider blocks embedded browsers or you need existing browser sessions.',
|
||||
dynamic: hiddenIfNot(['authorization_code', 'implicit']),
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'redirectUri',
|
||||
label: 'Redirect URI',
|
||||
description:
|
||||
'URI the OAuth provider redirects to after authorization. Yaak intercepts this automatically in its embedded browser so any valid URI will work.',
|
||||
optional: true,
|
||||
dynamic: hiddenIfNot(
|
||||
['authorization_code', 'implicit'],
|
||||
({ useExternalBrowser }) => !useExternalBrowser,
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'h_stack',
|
||||
inputs: [
|
||||
{
|
||||
type: 'select',
|
||||
name: 'callbackType',
|
||||
label: 'Callback Type',
|
||||
description:
|
||||
'"Hosted Redirect" uses an external Yaak-hosted endpoint. "Localhost" starts a local server to receive the callback.',
|
||||
defaultValue: 'hosted',
|
||||
options: [
|
||||
{ label: 'Hosted Redirect', value: 'hosted' },
|
||||
{ label: 'Localhost', value: 'localhost' },
|
||||
],
|
||||
dynamic: hiddenIfNot(
|
||||
['authorization_code', 'implicit'],
|
||||
({ useExternalBrowser }) => !!useExternalBrowser,
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'callbackPort',
|
||||
label: 'Callback Port',
|
||||
placeholder: `${DEFAULT_LOCALHOST_PORT}`,
|
||||
description:
|
||||
'Port for the local callback server. Defaults to ' +
|
||||
DEFAULT_LOCALHOST_PORT +
|
||||
' if empty.',
|
||||
optional: true,
|
||||
dynamic: hiddenIfNot(
|
||||
['authorization_code', 'implicit'],
|
||||
({ useExternalBrowser, callbackType }) =>
|
||||
!!useExternalBrowser && callbackType === 'localhost',
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'banner',
|
||||
color: 'info',
|
||||
inputs: [
|
||||
{
|
||||
type: 'markdown',
|
||||
content: 'Redirect URI to Register',
|
||||
async dynamic(_ctx, { values }) {
|
||||
const grantType = String(values.grantType ?? defaultGrantType);
|
||||
const useExternalBrowser = !!values.useExternalBrowser;
|
||||
const callbackType = (stringArg(values, 'callbackType') ||
|
||||
'localhost') as CallbackType;
|
||||
|
||||
// Only show for authorization_code and implicit with external browser enabled
|
||||
if (
|
||||
!['authorization_code', 'implicit'].includes(grantType) ||
|
||||
!useExternalBrowser
|
||||
) {
|
||||
return { hidden: true };
|
||||
}
|
||||
|
||||
// Compute the redirect URI based on callback type
|
||||
let redirectUri: string;
|
||||
if (callbackType === 'hosted') {
|
||||
redirectUri = HOSTED_CALLBACK_URL;
|
||||
} else {
|
||||
const port = intArg(values, 'callbackPort') || DEFAULT_LOCALHOST_PORT;
|
||||
redirectUri = `http://127.0.0.1:${port}/callback`;
|
||||
}
|
||||
|
||||
return {
|
||||
hidden: false,
|
||||
content: `Register \`${redirectUri}\` as a redirect URI with your OAuth provider.`,
|
||||
};
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
@@ -182,12 +276,8 @@ export const plugin: PluginDefinition = {
|
||||
optional: true,
|
||||
dynamic: hiddenIfNot(['authorization_code', 'implicit']),
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'audience',
|
||||
label: 'Audience',
|
||||
optional: true,
|
||||
},
|
||||
{ type: 'text', name: 'scope', label: 'Scope', optional: true },
|
||||
{ type: 'text', name: 'audience', label: 'Audience', optional: true },
|
||||
{
|
||||
type: 'select',
|
||||
name: 'tokenName',
|
||||
@@ -203,44 +293,54 @@ export const plugin: PluginDefinition = {
|
||||
dynamic: hiddenIfNot(['authorization_code', 'implicit']),
|
||||
},
|
||||
{
|
||||
type: 'checkbox',
|
||||
name: 'usePkce',
|
||||
label: 'Use PKCE',
|
||||
dynamic: hiddenIfNot(['authorization_code']),
|
||||
},
|
||||
{
|
||||
type: 'select',
|
||||
name: 'pkceChallengeMethod',
|
||||
label: 'Code Challenge Method',
|
||||
options: [
|
||||
{ label: 'SHA-256', value: PKCE_SHA256 },
|
||||
{ label: 'Plain', value: PKCE_PLAIN },
|
||||
type: 'banner',
|
||||
inputs: [
|
||||
{
|
||||
type: 'checkbox',
|
||||
name: 'usePkce',
|
||||
label: 'Use PKCE',
|
||||
dynamic: hiddenIfNot(['authorization_code']),
|
||||
},
|
||||
{
|
||||
type: 'select',
|
||||
name: 'pkceChallengeMethod',
|
||||
label: 'Code Challenge Method',
|
||||
options: [
|
||||
{ label: 'SHA-256', value: PKCE_SHA256 },
|
||||
{ label: 'Plain', value: PKCE_PLAIN },
|
||||
],
|
||||
defaultValue: DEFAULT_PKCE_METHOD,
|
||||
dynamic: hiddenIfNot(['authorization_code'], ({ usePkce }) => !!usePkce),
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'pkceCodeChallenge',
|
||||
label: 'Code Verifier',
|
||||
placeholder: 'Automatically generated when not set',
|
||||
optional: true,
|
||||
dynamic: hiddenIfNot(['authorization_code'], ({ usePkce }) => !!usePkce),
|
||||
},
|
||||
],
|
||||
defaultValue: DEFAULT_PKCE_METHOD,
|
||||
dynamic: hiddenIfNot(['authorization_code'], ({ usePkce }) => !!usePkce),
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'pkceCodeChallenge',
|
||||
label: 'Code Verifier',
|
||||
placeholder: 'Automatically generated when not set',
|
||||
optional: true,
|
||||
dynamic: hiddenIfNot(['authorization_code'], ({ usePkce }) => !!usePkce),
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'username',
|
||||
label: 'Username',
|
||||
optional: true,
|
||||
dynamic: hiddenIfNot(['password']),
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'password',
|
||||
label: 'Password',
|
||||
password: true,
|
||||
optional: true,
|
||||
dynamic: hiddenIfNot(['password']),
|
||||
type: 'h_stack',
|
||||
inputs: [
|
||||
{
|
||||
type: 'text',
|
||||
name: 'username',
|
||||
label: 'Username',
|
||||
optional: true,
|
||||
dynamic: hiddenIfNot(['password']),
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'password',
|
||||
label: 'Password',
|
||||
password: true,
|
||||
optional: true,
|
||||
dynamic: hiddenIfNot(['password']),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'select',
|
||||
@@ -258,7 +358,6 @@ export const plugin: PluginDefinition = {
|
||||
type: 'accordion',
|
||||
label: 'Advanced',
|
||||
inputs: [
|
||||
{ type: 'text', name: 'scope', label: 'Scope', optional: true },
|
||||
{
|
||||
type: 'text',
|
||||
name: 'headerName',
|
||||
@@ -321,6 +420,16 @@ export const plugin: PluginDefinition = {
|
||||
const credentialsInBody = values.credentials === 'body';
|
||||
const tokenName = values.tokenName === 'id_token' ? 'id_token' : 'access_token';
|
||||
|
||||
// Build external browser options if enabled
|
||||
const useExternalBrowser = !!values.useExternalBrowser;
|
||||
const externalBrowserOptions = useExternalBrowser
|
||||
? {
|
||||
useExternalBrowser: true,
|
||||
callbackType: (stringArg(values, 'callbackType') || 'localhost') as CallbackType,
|
||||
callbackPort: intArg(values, 'callbackPort') ?? undefined,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
let token: AccessToken;
|
||||
if (grantType === 'authorization_code') {
|
||||
const authorizationUrl = stringArg(values, 'authorizationUrl');
|
||||
@@ -348,6 +457,7 @@ export const plugin: PluginDefinition = {
|
||||
}
|
||||
: null,
|
||||
tokenName: tokenName,
|
||||
externalBrowser: externalBrowserOptions,
|
||||
});
|
||||
} else if (grantType === 'implicit') {
|
||||
const authorizationUrl = stringArg(values, 'authorizationUrl');
|
||||
@@ -362,6 +472,7 @@ export const plugin: PluginDefinition = {
|
||||
audience: stringArgOrNull(values, 'audience'),
|
||||
state: stringArgOrNull(values, 'state'),
|
||||
tokenName: tokenName,
|
||||
externalBrowser: externalBrowserOptions,
|
||||
});
|
||||
} else if (grantType === 'client_credentials') {
|
||||
const accessTokenUrl = stringArg(values, 'accessTokenUrl');
|
||||
@@ -414,3 +525,10 @@ function stringArg(values: Record<string, JsonPrimitive | undefined>, name: stri
|
||||
if (!arg) return '';
|
||||
return arg;
|
||||
}
|
||||
|
||||
function intArg(values: Record<string, JsonPrimitive | undefined>, name: string): number | null {
|
||||
const arg = values[name];
|
||||
if (arg == null || arg === '') return null;
|
||||
const num = parseInt(`${arg}`, 10);
|
||||
return Number.isNaN(num) ? null : num;
|
||||
}
|
||||
|
||||
@@ -19,9 +19,6 @@ export const synthwave84: Theme = {
|
||||
danger: 'hsl(340, 100%, 65%)',
|
||||
},
|
||||
components: {
|
||||
dialog: {
|
||||
surface: 'hsl(253, 45%, 12%)',
|
||||
},
|
||||
sidebar: {
|
||||
surface: 'hsl(253, 42%, 18%)',
|
||||
border: 'hsl(253, 40%, 22%)',
|
||||
|
||||
@@ -83,7 +83,7 @@ export function DynamicForm<T extends Record<string, JsonPrimitive>>({
|
||||
function FormInputsStack<T extends Record<string, JsonPrimitive>>({
|
||||
className,
|
||||
...props
|
||||
}: FormInputsProps<T> & { className?: string}) {
|
||||
}: FormInputsProps<T> & { className?: string }) {
|
||||
return (
|
||||
<VStack
|
||||
space={3}
|
||||
@@ -198,6 +198,9 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
|
||||
/>
|
||||
);
|
||||
case 'accordion':
|
||||
if (!hasVisibleInputs(input.inputs)) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div key={i + stateKey}>
|
||||
<DetailsBanner
|
||||
@@ -219,6 +222,9 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
|
||||
</div>
|
||||
);
|
||||
case 'h_stack':
|
||||
if (!hasVisibleInputs(input.inputs)) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-wrap sm:flex-nowrap gap-3 items-end" key={i + stateKey}>
|
||||
<FormInputs
|
||||
@@ -233,6 +239,9 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
|
||||
</div>
|
||||
);
|
||||
case 'banner':
|
||||
if (!hasVisibleInputs(input.inputs)) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Banner
|
||||
key={i + stateKey}
|
||||
@@ -603,3 +612,8 @@ function KeyValueArg({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function hasVisibleInputs(inputs: FormInput[] | undefined): boolean {
|
||||
if (!inputs) return false;
|
||||
return inputs.some((i) => !i.hidden);
|
||||
}
|
||||
|
||||
@@ -98,13 +98,14 @@ export function GrpcResponsePane({ style, methodType, activeRequest }: Props) {
|
||||
renderRow={({ event, isActive, onClick }) => (
|
||||
<GrpcEventRow event={event} isActive={isActive} onClick={onClick} />
|
||||
)}
|
||||
renderDetail={({ event }) => (
|
||||
renderDetail={({ event, onClose }) => (
|
||||
<GrpcEventDetail
|
||||
event={event}
|
||||
showLarge={showLarge}
|
||||
showingLarge={showingLarge}
|
||||
setShowLarge={setShowLarge}
|
||||
setShowingLarge={setShowingLarge}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@@ -147,19 +148,26 @@ function GrpcEventDetail({
|
||||
showingLarge,
|
||||
setShowLarge,
|
||||
setShowingLarge,
|
||||
onClose,
|
||||
}: {
|
||||
event: GrpcEvent;
|
||||
showLarge: boolean;
|
||||
showingLarge: boolean;
|
||||
setShowLarge: (v: boolean) => void;
|
||||
setShowingLarge: (v: boolean) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
if (event.eventType === 'client_message' || event.eventType === 'server_message') {
|
||||
const title = `Message ${event.eventType === 'client_message' ? 'Sent' : 'Received'}`;
|
||||
|
||||
return (
|
||||
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
|
||||
<EventDetailHeader title={title} timestamp={event.createdAt} copyText={event.content} />
|
||||
<EventDetailHeader
|
||||
title={title}
|
||||
timestamp={event.createdAt}
|
||||
copyText={event.content}
|
||||
onClose={onClose}
|
||||
/>
|
||||
{!showLarge && event.content.length > 1000 * 1000 ? (
|
||||
<VStack space={2} className="italic text-text-subtlest">
|
||||
Message previews larger than 1MB are hidden
|
||||
@@ -197,7 +205,7 @@ function GrpcEventDetail({
|
||||
// Error or connection_end - show metadata/trailers
|
||||
return (
|
||||
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
|
||||
<EventDetailHeader title={event.content} timestamp={event.createdAt} />
|
||||
<EventDetailHeader title={event.content} timestamp={event.createdAt} onClose={onClose} />
|
||||
{event.error && (
|
||||
<div className="select-text cursor-text text-sm font-mono py-1 text-warning">
|
||||
{event.error}
|
||||
|
||||
@@ -62,9 +62,7 @@ export function HttpAuthenticationEditor({ model }: Props) {
|
||||
<p>
|
||||
Apply auth to all requests in <strong>{resolvedModelName(model)}</strong>
|
||||
</p>
|
||||
<Link href="https://yaak.app/docs/using-yaak/request-inheritance">
|
||||
Documentation
|
||||
</Link>
|
||||
<Link href="https://yaak.app/docs/using-yaak/request-inheritance">Documentation</Link>
|
||||
</EmptyStateText>
|
||||
);
|
||||
}
|
||||
@@ -140,7 +138,12 @@ export function HttpAuthenticationEditor({ model }: Props) {
|
||||
}),
|
||||
)}
|
||||
>
|
||||
<IconButton title="Authentication Actions" icon="settings" size="xs" />
|
||||
<IconButton
|
||||
title="Authentication Actions"
|
||||
icon="settings"
|
||||
size="xs"
|
||||
className="!text-secondary"
|
||||
/>
|
||||
</Dropdown>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
@@ -149,14 +149,27 @@ function EventDetails({
|
||||
);
|
||||
}
|
||||
|
||||
// Request URL - show method and path separately
|
||||
// Request URL - show all URL parts separately
|
||||
if (e.type === 'send_url') {
|
||||
const auth = e.username || e.password ? `${e.username}:${e.password}@` : '';
|
||||
const isDefaultPort =
|
||||
(e.scheme === 'http' && e.port === 80) || (e.scheme === 'https' && e.port === 443);
|
||||
const portStr = isDefaultPort ? '' : `:${e.port}`;
|
||||
const query = e.query ? `?${e.query}` : '';
|
||||
const fragment = e.fragment ? `#${e.fragment}` : '';
|
||||
const fullUrl = `${e.scheme}://${auth}${e.host}${portStr}${e.path}${query}${fragment}`;
|
||||
return (
|
||||
<KeyValueRows>
|
||||
<KeyValueRow label="Method">
|
||||
<HttpMethodTagRaw forceColor method={e.method} />
|
||||
</KeyValueRow>
|
||||
<KeyValueRow label="URL">{fullUrl}</KeyValueRow>
|
||||
<KeyValueRow label="Method">{e.method}</KeyValueRow>
|
||||
<KeyValueRow label="Scheme">{e.scheme}</KeyValueRow>
|
||||
{e.username ? <KeyValueRow label="Username">{e.username}</KeyValueRow> : null}
|
||||
{e.password ? <KeyValueRow label="Password">{e.password}</KeyValueRow> : null}
|
||||
<KeyValueRow label="Host">{e.host}</KeyValueRow>
|
||||
{!isDefaultPort ? <KeyValueRow label="Port">{e.port}</KeyValueRow> : null}
|
||||
<KeyValueRow label="Path">{e.path}</KeyValueRow>
|
||||
{e.query ? <KeyValueRow label="Query">{e.query}</KeyValueRow> : null}
|
||||
{e.fragment ? <KeyValueRow label="Fragment">{e.fragment}</KeyValueRow> : null}
|
||||
</KeyValueRows>
|
||||
);
|
||||
}
|
||||
@@ -244,7 +257,10 @@ type EventTextParts = { prefix: '>' | '<' | '*'; text: string };
|
||||
function getEventTextParts(event: HttpResponseEventData): EventTextParts {
|
||||
switch (event.type) {
|
||||
case 'send_url':
|
||||
return { prefix: '>', text: `${event.method} ${event.path}` };
|
||||
return {
|
||||
prefix: '>',
|
||||
text: `${event.method} ${event.path}${event.query ? `?${event.query}` : ''}${event.fragment ? `#${event.fragment}` : ''}`,
|
||||
};
|
||||
case 'receive_url':
|
||||
return { prefix: '<', text: `${event.version} ${event.status}` };
|
||||
case 'header_up':
|
||||
@@ -265,9 +281,15 @@ function getEventTextParts(event: HttpResponseEventData): EventTextParts {
|
||||
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 override ${event.hostname} -> ${event.addresses.join(', ')}`,
|
||||
};
|
||||
}
|
||||
return { prefix: '*', text: `DNS resolved ${event.hostname} to ${event.addresses.join(', ')} (${event.duration}ms)` };
|
||||
return {
|
||||
prefix: '*',
|
||||
text: `DNS resolved ${event.hostname} to ${event.addresses.join(', ')} (${event.duration}ms)`,
|
||||
};
|
||||
default:
|
||||
return { prefix: '*', text: '[unknown event]' };
|
||||
}
|
||||
@@ -314,7 +336,7 @@ function getEventDisplay(event: HttpResponseEventData): EventDisplay {
|
||||
icon: 'arrow_big_up_dash',
|
||||
color: 'primary',
|
||||
label: 'Request',
|
||||
summary: `${event.method} ${event.path}`,
|
||||
summary: `${event.method} ${event.path}${event.query ? `?${event.query}` : ''}${event.fragment ? `#${event.fragment}` : ''}`,
|
||||
};
|
||||
case 'receive_url':
|
||||
return {
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useStateWithDeps } from '../hooks/useStateWithDeps';
|
||||
import { languageFromContentType } from '../lib/contentType';
|
||||
import { Button } from './core/Button';
|
||||
import { Editor } from './core/Editor/LazyEditor';
|
||||
import { EventDetailHeader, EventViewer, type EventDetailAction } from './core/EventViewer';
|
||||
import { type EventDetailAction, EventDetailHeader, EventViewer } from './core/EventViewer';
|
||||
import { EventViewerRow } from './core/EventViewerRow';
|
||||
import { HotkeyList } from './core/HotkeyList';
|
||||
import { Icon } from './core/Icon';
|
||||
@@ -75,7 +75,7 @@ export function WebsocketResponsePane({ activeRequest }: Props) {
|
||||
renderRow={({ event, isActive, onClick }) => (
|
||||
<WebsocketEventRow event={event} isActive={isActive} onClick={onClick} />
|
||||
)}
|
||||
renderDetail={({ event, index }) => (
|
||||
renderDetail={({ event, index, onClose }) => (
|
||||
<WebsocketEventDetail
|
||||
event={event}
|
||||
hexDump={hexDumps[index] ?? event.messageType === 'binary'}
|
||||
@@ -84,6 +84,7 @@ export function WebsocketResponsePane({ activeRequest }: Props) {
|
||||
showingLarge={showingLarge}
|
||||
setShowLarge={setShowLarge}
|
||||
setShowingLarge={setShowingLarge}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@@ -145,6 +146,7 @@ function WebsocketEventDetail({
|
||||
showingLarge,
|
||||
setShowLarge,
|
||||
setShowingLarge,
|
||||
onClose,
|
||||
}: {
|
||||
event: WebsocketEvent;
|
||||
hexDump: boolean;
|
||||
@@ -153,6 +155,7 @@ function WebsocketEventDetail({
|
||||
showingLarge: boolean;
|
||||
setShowLarge: (v: boolean) => void;
|
||||
setShowingLarge: (v: boolean) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const message = useMemo(() => {
|
||||
if (hexDump) {
|
||||
@@ -185,11 +188,12 @@ function WebsocketEventDetail({
|
||||
return (
|
||||
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
|
||||
<EventDetailHeader
|
||||
title={title}
|
||||
timestamp={event.createdAt}
|
||||
actions={actions}
|
||||
copyText={formattedMessage || undefined}
|
||||
/>
|
||||
title={title}
|
||||
timestamp={event.createdAt}
|
||||
actions={actions}
|
||||
copyText={formattedMessage || undefined}
|
||||
onClose={onClose}
|
||||
/>
|
||||
{!showLarge && event.message.length > 1000 * 1000 ? (
|
||||
<VStack space={2} className="italic text-text-subtlest">
|
||||
Message previews larger than 1MB are hidden
|
||||
|
||||
39
src-web/components/core/Editor/DiffViewer.css
Normal file
39
src-web/components/core/Editor/DiffViewer.css
Normal file
@@ -0,0 +1,39 @@
|
||||
.cm-wrapper.cm-multiline .cm-mergeView {
|
||||
@apply h-full w-full overflow-auto pr-0.5;
|
||||
|
||||
.cm-mergeViewEditors {
|
||||
@apply w-full min-h-full;
|
||||
}
|
||||
|
||||
.cm-mergeViewEditor {
|
||||
@apply w-full min-h-full relative;
|
||||
|
||||
.cm-collapsedLines {
|
||||
@apply bg-none bg-surface border border-border py-1 mx-0.5 text-text opacity-80 hover:opacity-100 rounded cursor-default;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-line {
|
||||
@apply pl-1.5;
|
||||
}
|
||||
.cm-changedLine {
|
||||
/* Round top corners only if previous line is not a changed line */
|
||||
&:not(.cm-changedLine + &) {
|
||||
@apply rounded-t;
|
||||
}
|
||||
/* Round bottom corners only if next line is not a changed line */
|
||||
&:not(:has(+ .cm-changedLine)) {
|
||||
@apply rounded-b;
|
||||
}
|
||||
}
|
||||
|
||||
/* Let content grow and disable individual scrolling for sync */
|
||||
.cm-editor {
|
||||
@apply h-auto relative !important;
|
||||
position: relative !important;
|
||||
}
|
||||
|
||||
.cm-scroller {
|
||||
@apply overflow-visible !important;
|
||||
}
|
||||
}
|
||||
64
src-web/components/core/Editor/DiffViewer.tsx
Normal file
64
src-web/components/core/Editor/DiffViewer.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { yaml } from '@codemirror/lang-yaml';
|
||||
import { syntaxHighlighting } from '@codemirror/language';
|
||||
import { MergeView } from '@codemirror/merge';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import classNames from 'classnames';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import './DiffViewer.css';
|
||||
import { readonlyExtensions, syntaxHighlightStyle } from './extensions';
|
||||
|
||||
interface Props {
|
||||
/** Original/previous version (left side) */
|
||||
original: string;
|
||||
/** Modified/current version (right side) */
|
||||
modified: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DiffViewer({ original, modified, className }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<MergeView | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
// Clean up previous instance
|
||||
viewRef.current?.destroy();
|
||||
|
||||
const sharedExtensions = [
|
||||
yaml(),
|
||||
syntaxHighlighting(syntaxHighlightStyle),
|
||||
...readonlyExtensions,
|
||||
EditorView.lineWrapping,
|
||||
];
|
||||
|
||||
viewRef.current = new MergeView({
|
||||
a: {
|
||||
doc: original,
|
||||
extensions: sharedExtensions,
|
||||
},
|
||||
b: {
|
||||
doc: modified,
|
||||
extensions: sharedExtensions,
|
||||
},
|
||||
parent: containerRef.current,
|
||||
collapseUnchanged: { margin: 2, minSize: 3 },
|
||||
highlightChanges: false,
|
||||
gutter: true,
|
||||
orientation: 'a-b',
|
||||
revertControls: undefined,
|
||||
});
|
||||
|
||||
return () => {
|
||||
viewRef.current?.destroy();
|
||||
viewRef.current = null;
|
||||
};
|
||||
}, [original, modified]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={classNames('cm-wrapper cm-multiline h-full w-full', className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -101,8 +101,8 @@
|
||||
|
||||
.template-tag {
|
||||
/* Colors */
|
||||
@apply bg-surface text-text border-border-subtle whitespace-nowrap cursor-default;
|
||||
@apply hover:border-border-subtle hover:text-text hover:bg-surface-highlight;
|
||||
@apply bg-surface text-text-subtle border-border-subtle whitespace-nowrap cursor-default;
|
||||
@apply hover:border-border hover:text-text hover:bg-surface-highlight;
|
||||
|
||||
@apply inline border px-1 mx-[0.5px] rounded dark:shadow;
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
CookieIcon,
|
||||
CopyCheck,
|
||||
CopyIcon,
|
||||
CornerRightDownIcon,
|
||||
CornerRightUpIcon,
|
||||
CreditCardIcon,
|
||||
CrosshairIcon,
|
||||
@@ -63,6 +64,7 @@ import {
|
||||
FlaskConicalIcon,
|
||||
FolderCodeIcon,
|
||||
FolderCogIcon,
|
||||
FolderDownIcon,
|
||||
FolderGitIcon,
|
||||
FolderIcon,
|
||||
FolderInputIcon,
|
||||
@@ -179,6 +181,7 @@ const icons = {
|
||||
cookie: CookieIcon,
|
||||
copy: CopyIcon,
|
||||
copy_check: CopyCheck,
|
||||
corner_right_down: CornerRightDownIcon,
|
||||
corner_right_up: CornerRightUpIcon,
|
||||
credit_card: CreditCardIcon,
|
||||
crosshair: CrosshairIcon,
|
||||
@@ -205,6 +208,7 @@ const icons = {
|
||||
folder_output: FolderOutputIcon,
|
||||
folder_symlink: FolderSymlinkIcon,
|
||||
folder_sync: FolderSyncIcon,
|
||||
folder_down: FolderDownIcon,
|
||||
folder_up: FolderUpIcon,
|
||||
gift: GiftIcon,
|
||||
git_branch: GitBranchIcon,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useCachedNode } from '@dnd-kit/core/dist/hooks/utilities';
|
||||
import type { GitStatusEntry } from '@yaakapp-internal/git';
|
||||
import { useGit } from '@yaakapp-internal/git';
|
||||
import type {
|
||||
@@ -9,14 +10,16 @@ import type {
|
||||
Workspace,
|
||||
} from '@yaakapp-internal/models';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { modelToYaml } from '../../lib/diffYaml';
|
||||
import { isSubEnvironment } from '../../lib/model_util';
|
||||
import { resolvedModelName } from '../../lib/resolvedModelName';
|
||||
import { showErrorToast } from '../../lib/toast';
|
||||
import { Banner } from '../core/Banner';
|
||||
import { Button } from '../core/Button';
|
||||
import type { CheckboxProps } from '../core/Checkbox';
|
||||
import { Checkbox } from '../core/Checkbox';
|
||||
import { DiffViewer } from '../core/Editor/DiffViewer';
|
||||
import { Icon } from '../core/Icon';
|
||||
import { InlineCode } from '../core/InlineCode';
|
||||
import { Input } from '../core/Input';
|
||||
@@ -48,6 +51,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
||||
const [isPushing, setIsPushing] = useState(false);
|
||||
const [commitError, setCommitError] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<string>('');
|
||||
const [selectedEntry, setSelectedEntry] = useState<GitStatusEntry | null>(null);
|
||||
|
||||
const handleCreateCommit = async () => {
|
||||
setCommitError(null);
|
||||
@@ -138,6 +142,35 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
||||
return next(workspace, []);
|
||||
}, [workspace, internalEntries]);
|
||||
|
||||
const checkNode = useCallback(
|
||||
(treeNode: CommitTreeNode) => {
|
||||
const checked = nodeCheckedStatus(treeNode);
|
||||
const newChecked = checked === 'indeterminate' ? true : !checked;
|
||||
setCheckedAndChildren(treeNode, newChecked, unstage.mutate, add.mutate);
|
||||
// TODO: Also ensure parents are added properly
|
||||
},
|
||||
[add.mutate, unstage.mutate],
|
||||
);
|
||||
|
||||
const checkEntry = useCallback(
|
||||
(entry: GitStatusEntry) => {
|
||||
if (entry.staged) unstage.mutate({ relaPaths: [entry.relaPath] });
|
||||
else add.mutate({ relaPaths: [entry.relaPath] });
|
||||
},
|
||||
[add.mutate, unstage.mutate],
|
||||
);
|
||||
|
||||
const handleSelectChild = useCallback(
|
||||
(entry: GitStatusEntry) => {
|
||||
if (entry === selectedEntry) {
|
||||
setSelectedEntry(null);
|
||||
} else {
|
||||
setSelectedEntry(entry);
|
||||
}
|
||||
},
|
||||
[selectedEntry],
|
||||
);
|
||||
|
||||
if (tree == null) {
|
||||
return null;
|
||||
}
|
||||
@@ -146,77 +179,92 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
||||
return <EmptyStateText>No changes since last commit</EmptyStateText>;
|
||||
}
|
||||
|
||||
const checkNode = (treeNode: CommitTreeNode) => {
|
||||
const checked = nodeCheckedStatus(treeNode);
|
||||
const newChecked = checked === 'indeterminate' ? true : !checked;
|
||||
setCheckedAndChildren(treeNode, newChecked, unstage.mutate, add.mutate);
|
||||
// TODO: Also ensure parents are added properly
|
||||
};
|
||||
|
||||
const checkEntry = (entry: GitStatusEntry) => {
|
||||
if (entry.staged) unstage.mutate({ relaPaths: [entry.relaPath] });
|
||||
else add.mutate({ relaPaths: [entry.relaPath] });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-rows-1 h-full">
|
||||
<div className="h-full px-2 pb-4">
|
||||
<SplitLayout
|
||||
name="commit"
|
||||
layout="vertical"
|
||||
defaultRatio={0.3}
|
||||
name="commit-horizontal"
|
||||
layout="horizontal"
|
||||
defaultRatio={0.6}
|
||||
firstSlot={({ style }) => (
|
||||
<div style={style} className="h-full overflow-y-auto pb-3">
|
||||
<TreeNodeChildren node={tree} depth={0} onCheck={checkNode} />
|
||||
{externalEntries.find((e) => e.status !== 'current') && (
|
||||
<>
|
||||
<Separator className="mt-3 mb-1">External file changes</Separator>
|
||||
{externalEntries.map((entry) => (
|
||||
<ExternalTreeNode
|
||||
key={entry.relaPath + entry.status}
|
||||
entry={entry}
|
||||
onCheck={checkEntry}
|
||||
<div style={style} className="h-full px-4">
|
||||
<SplitLayout
|
||||
name="commit-vertical"
|
||||
layout="vertical"
|
||||
defaultRatio={0.35}
|
||||
firstSlot={({ style: innerStyle }) => (
|
||||
<div
|
||||
style={innerStyle}
|
||||
className="h-full overflow-y-auto pb-3 pr-0.5 transform-cpu"
|
||||
>
|
||||
<TreeNodeChildren
|
||||
node={tree}
|
||||
depth={0}
|
||||
onCheck={checkNode}
|
||||
onSelect={handleSelectChild}
|
||||
selectedPath={selectedEntry?.relaPath ?? null}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{externalEntries.find((e) => e.status !== 'current') && (
|
||||
<>
|
||||
<Separator className="mt-3 mb-1">External file changes</Separator>
|
||||
{externalEntries.map((entry) => (
|
||||
<ExternalTreeNode
|
||||
key={entry.relaPath + entry.status}
|
||||
entry={entry}
|
||||
onCheck={checkEntry}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
secondSlot={({ style: innerStyle }) => (
|
||||
<div style={innerStyle} className="grid grid-rows-[minmax(0,1fr)_auto] gap-3 pb-2">
|
||||
<Input
|
||||
className="!text-base font-sans rounded-md"
|
||||
placeholder="Commit message..."
|
||||
onChange={setMessage}
|
||||
stateKey={null}
|
||||
label="Commit message"
|
||||
fullHeight
|
||||
multiLine
|
||||
hideLabel
|
||||
/>
|
||||
{commitError && <Banner color="danger">{commitError}</Banner>}
|
||||
<HStack alignItems="center">
|
||||
<InlineCode>{status.data?.headRefShorthand}</InlineCode>
|
||||
<HStack space={2} className="ml-auto">
|
||||
<Button
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={handleCreateCommit}
|
||||
disabled={!hasAddedAnything}
|
||||
isLoading={isPushing}
|
||||
>
|
||||
Commit
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
size="sm"
|
||||
disabled={!hasAddedAnything}
|
||||
onClick={handleCreateCommitAndPush}
|
||||
isLoading={isPushing}
|
||||
>
|
||||
Commit and Push
|
||||
</Button>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
secondSlot={({ style }) => (
|
||||
<div style={style} className="grid grid-rows-[minmax(0,1fr)_auto] gap-3 pb-2">
|
||||
<Input
|
||||
className="!text-base font-sans rounded-md"
|
||||
placeholder="Commit message..."
|
||||
onChange={setMessage}
|
||||
stateKey={null}
|
||||
label="Commit message"
|
||||
fullHeight
|
||||
multiLine
|
||||
hideLabel
|
||||
/>
|
||||
{commitError && <Banner color="danger">{commitError}</Banner>}
|
||||
<HStack alignItems="center">
|
||||
<InlineCode>{status.data?.headRefShorthand}</InlineCode>
|
||||
<HStack space={2} className="ml-auto">
|
||||
<Button
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={handleCreateCommit}
|
||||
disabled={!hasAddedAnything}
|
||||
isLoading={isPushing}
|
||||
>
|
||||
Commit
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
size="sm"
|
||||
disabled={!hasAddedAnything}
|
||||
onClick={handleCreateCommitAndPush}
|
||||
isLoading={isPushing}
|
||||
>
|
||||
Commit and Push
|
||||
</Button>
|
||||
</HStack>
|
||||
</HStack>
|
||||
<div style={style} className="h-full px-4 border-l border-l-border-subtle">
|
||||
{selectedEntry ? (
|
||||
<DiffPanel entry={selectedEntry} />
|
||||
) : (
|
||||
<EmptyStateText>Select a change to view diff</EmptyStateText>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
@@ -228,61 +276,77 @@ function TreeNodeChildren({
|
||||
node,
|
||||
depth,
|
||||
onCheck,
|
||||
onSelect,
|
||||
selectedPath,
|
||||
}: {
|
||||
node: CommitTreeNode | null;
|
||||
depth: number;
|
||||
onCheck: (node: CommitTreeNode, checked: boolean) => void;
|
||||
onSelect: (entry: GitStatusEntry) => void;
|
||||
selectedPath: string | null;
|
||||
}) {
|
||||
if (node === null) return null;
|
||||
if (!isNodeRelevant(node)) return null;
|
||||
|
||||
const checked = nodeCheckedStatus(node);
|
||||
const isSelected = selectedPath === node.status.relaPath;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
depth > 0 && 'pl-1 ml-[10px] border-l border-dashed border-border-subtle',
|
||||
depth > 0 && 'pl-4 ml-2 border-l border-dashed border-border-subtle relative',
|
||||
)}
|
||||
>
|
||||
<div className="flex gap-3 w-full h-xs">
|
||||
<div
|
||||
className={classNames(
|
||||
'relative flex gap-1 w-full h-xs items-center',
|
||||
isSelected ? 'text-text' : 'text-text-subtle',
|
||||
)}
|
||||
>
|
||||
{isSelected && (
|
||||
<div className="absolute -left-[100vw] right-0 top-0 bottom-0 bg-surface-active opacity-30 -z-10" />
|
||||
)}
|
||||
<Checkbox
|
||||
fullWidth
|
||||
className="w-full hover:bg-surface-highlight rounded px-1 group"
|
||||
checked={checked}
|
||||
title={checked ? 'Unstage change' : 'Stage change'}
|
||||
hideLabel
|
||||
onChange={(checked) => onCheck(node, checked)}
|
||||
title={
|
||||
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] gap-1 w-full items-center">
|
||||
{node.model.model !== 'http_request' &&
|
||||
node.model.model !== 'grpc_request' &&
|
||||
node.model.model !== 'websocket_request' ? (
|
||||
<Icon
|
||||
color="secondary"
|
||||
icon={
|
||||
node.model.model === 'folder'
|
||||
? 'folder'
|
||||
: node.model.model === 'environment'
|
||||
? 'variable'
|
||||
: 'house'
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<span aria-hidden />
|
||||
)}
|
||||
<div className="truncate">{resolvedModelName(node.model)}</div>
|
||||
{node.status.status !== 'current' && (
|
||||
<InlineCode
|
||||
className={classNames(
|
||||
'py-0 ml-auto bg-transparent w-[6rem] text-center',
|
||||
node.status.status === 'modified' && 'text-info',
|
||||
node.status.status === 'untracked' && 'text-success',
|
||||
node.status.status === 'removed' && 'text-danger',
|
||||
)}
|
||||
>
|
||||
{node.status.status}
|
||||
</InlineCode>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={classNames('flex-1 min-w-0 flex items-center gap-1 px-1 py-0.5 text-left')}
|
||||
onClick={() => node.status.status !== 'current' && onSelect(node.status)}
|
||||
>
|
||||
{node.model.model !== 'http_request' &&
|
||||
node.model.model !== 'grpc_request' &&
|
||||
node.model.model !== 'websocket_request' ? (
|
||||
<Icon
|
||||
color="secondary"
|
||||
icon={
|
||||
node.model.model === 'folder'
|
||||
? 'folder'
|
||||
: node.model.model === 'environment'
|
||||
? 'variable'
|
||||
: 'house'
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<span aria-hidden className="w-4" />
|
||||
)}
|
||||
<div className="truncate flex-1">{resolvedModelName(node.model)}</div>
|
||||
{node.status.status !== 'current' && (
|
||||
<InlineCode
|
||||
className={classNames(
|
||||
'py-0 bg-transparent w-[6rem] text-center shrink-0',
|
||||
node.status.status === 'modified' && 'text-info',
|
||||
node.status.status === 'untracked' && 'text-success',
|
||||
node.status.status === 'removed' && 'text-danger',
|
||||
)}
|
||||
>
|
||||
{node.status.status}
|
||||
</InlineCode>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{node.children.map((childNode) => {
|
||||
@@ -292,6 +356,8 @@ function TreeNodeChildren({
|
||||
node={childNode}
|
||||
depth={depth + 1}
|
||||
onCheck={onCheck}
|
||||
onSelect={onSelect}
|
||||
selectedPath={selectedPath}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -401,3 +467,17 @@ function isNodeRelevant(node: CommitTreeNode): boolean {
|
||||
// Recursively check children
|
||||
return node.children.some((c) => isNodeRelevant(c));
|
||||
}
|
||||
|
||||
function DiffPanel({ entry }: { entry: GitStatusEntry }) {
|
||||
const prevYaml = modelToYaml(entry.prev);
|
||||
const nextYaml = modelToYaml(entry.next);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="text-sm text-text-subtle mb-2 px-1">
|
||||
{resolvedModelName(entry.next ?? entry.prev)} ({entry.status})
|
||||
</div>
|
||||
<DiffViewer original={prevYaml ?? ''} modified={nextYaml ?? ''} className="flex-1 min-h-0" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -211,7 +211,7 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
|
||||
id: 'commit',
|
||||
title: 'Commit Changes',
|
||||
size: 'full',
|
||||
className: '!max-h-[min(80vh,40rem)] !max-w-[min(50rem,90vw)]',
|
||||
noPadding: true,
|
||||
render: ({ hide }) => (
|
||||
<GitCommitDialog syncDir={syncDir} onDone={hide} workspace={workspace} />
|
||||
),
|
||||
|
||||
@@ -51,10 +51,9 @@ function ActualEventStreamViewer({ response }: Props) {
|
||||
<span className="truncate text-xs">{event.data.slice(0, 1000)}</span>
|
||||
</HStack>
|
||||
}
|
||||
|
||||
/>
|
||||
)}
|
||||
renderDetail={({ event, index }) => (
|
||||
renderDetail={({ event, index, onClose }) => (
|
||||
<EventDetail
|
||||
event={event}
|
||||
index={index}
|
||||
@@ -62,6 +61,7 @@ function ActualEventStreamViewer({ response }: Props) {
|
||||
showingLarge={showingLarge}
|
||||
setShowLarge={setShowLarge}
|
||||
setShowingLarge={setShowingLarge}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@@ -75,6 +75,7 @@ function EventDetail({
|
||||
showingLarge,
|
||||
setShowLarge,
|
||||
setShowingLarge,
|
||||
onClose,
|
||||
}: {
|
||||
event: ServerSentEvent;
|
||||
index: number;
|
||||
@@ -82,6 +83,7 @@ function EventDetail({
|
||||
showingLarge: boolean;
|
||||
setShowLarge: (v: boolean) => void;
|
||||
setShowingLarge: (v: boolean) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const language = useMemo<'text' | 'json'>(() => {
|
||||
if (!event?.data) return 'text';
|
||||
@@ -90,7 +92,11 @@ function EventDetail({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<EventDetailHeader title="Message Received" prefix={<EventLabels event={event} index={index} />} />
|
||||
<EventDetailHeader
|
||||
title="Message Received"
|
||||
prefix={<EventLabels event={event} index={index} />}
|
||||
onClose={onClose}
|
||||
/>
|
||||
{!showLarge && event.data.length > 1000 * 1000 ? (
|
||||
<VStack space={2} className="italic text-text-subtlest">
|
||||
Message previews larger than 1MB are hidden
|
||||
|
||||
@@ -56,10 +56,10 @@ export function MultipartViewer({ data, boundary, idPrefix = 'multipart' }: Prop
|
||||
addBorders
|
||||
label="Multipart"
|
||||
layout="horizontal"
|
||||
tabListClassName="border-r border-r-border"
|
||||
tabs={parts.map((part) => ({
|
||||
tabListClassName="border-r border-r-border -ml-3"
|
||||
tabs={parts.map((part, i) => ({
|
||||
label: part.name ?? '',
|
||||
value: part.name ?? '',
|
||||
value: tabValue(part, i),
|
||||
rightSlot:
|
||||
part.filename && part.headers.contentType.mediaType?.startsWith('image/') ? (
|
||||
<div className="h-5 w-5 overflow-auto flex items-center justify-end">
|
||||
@@ -77,7 +77,7 @@ export function MultipartViewer({ data, boundary, idPrefix = 'multipart' }: Prop
|
||||
<TabContent
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: Nothing else to key on
|
||||
key={idPrefix + part.name + i}
|
||||
value={part.name ?? ''}
|
||||
value={tabValue(part, i)}
|
||||
className="pl-3 !pt-0"
|
||||
>
|
||||
<Part part={part} />
|
||||
@@ -115,7 +115,7 @@ function Part({ part }: { part: MultipartPart }) {
|
||||
}
|
||||
|
||||
if (mimeType?.match(/csv|tab-separated/i)) {
|
||||
return <CsvViewer text={content} />;
|
||||
return <CsvViewer text={content} className="bg-primary h-10 w-10" />;
|
||||
}
|
||||
|
||||
if (mimeType?.match(/^text\/html/i) || detectedLanguage === 'html') {
|
||||
@@ -132,3 +132,7 @@ function Part({ part }: { part: MultipartPart }) {
|
||||
|
||||
return <TextViewer text={content} language={detectedLanguage} stateKey={null} />;
|
||||
}
|
||||
|
||||
function tabValue(part: MultipartPart, i: number) {
|
||||
return `${part.name ?? ''}::${i}`;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Folder } from '@yaakapp-internal/models';
|
||||
import { patchModel } from '@yaakapp-internal/models';
|
||||
import { modelTypeLabel, patchModel } from '@yaakapp-internal/models';
|
||||
import { useMemo } from 'react';
|
||||
import { openFolderSettings } from '../commands/openFolderSettings';
|
||||
import { openWorkspaceSettings } from '../commands/openWorkspaceSettings';
|
||||
@@ -57,49 +57,103 @@ export function useAuthTab<T extends string>(tabValue: T, model: AuthenticatedMo
|
||||
},
|
||||
{ label: 'No Auth', shortLabel: 'No Auth', value: 'none' },
|
||||
],
|
||||
itemsAfter:
|
||||
parentModel &&
|
||||
model.authenticationType &&
|
||||
model.authenticationType !== 'none' &&
|
||||
(parentModel.authenticationType == null || parentModel.authenticationType === 'none')
|
||||
? [
|
||||
{ type: 'separator', label: 'Actions' },
|
||||
{
|
||||
label: `Promote to ${capitalize(parentModel.model)}`,
|
||||
leftSlot: (
|
||||
<Icon
|
||||
icon={parentModel.model === 'workspace' ? 'corner_right_up' : 'folder_up'}
|
||||
/>
|
||||
),
|
||||
onSelect: async () => {
|
||||
const confirmed = await showConfirm({
|
||||
id: 'promote-auth-confirm',
|
||||
title: 'Promote Authentication',
|
||||
confirmText: 'Promote',
|
||||
description: (
|
||||
<>
|
||||
Move authentication config to{' '}
|
||||
<InlineCode>{resolvedModelName(parentModel)}</InlineCode>?
|
||||
</>
|
||||
),
|
||||
});
|
||||
if (confirmed) {
|
||||
await patchModel(model, { authentication: {}, authenticationType: null });
|
||||
await patchModel(parentModel, {
|
||||
authentication: model.authentication,
|
||||
authenticationType: model.authenticationType,
|
||||
});
|
||||
itemsAfter: (() => {
|
||||
const actions: (
|
||||
| { type: 'separator'; label: string }
|
||||
| { label: string; leftSlot: React.ReactNode; onSelect: () => Promise<void> }
|
||||
)[] = [];
|
||||
|
||||
if (parentModel.model === 'folder') {
|
||||
openFolderSettings(parentModel.id, 'auth');
|
||||
} else {
|
||||
openWorkspaceSettings('auth');
|
||||
}
|
||||
// Promote: move auth from current model up to parent
|
||||
if (
|
||||
parentModel &&
|
||||
model.authenticationType &&
|
||||
model.authenticationType !== 'none' &&
|
||||
(parentModel.authenticationType == null || parentModel.authenticationType === 'none')
|
||||
) {
|
||||
actions.push(
|
||||
{ type: 'separator', label: 'Actions' },
|
||||
{
|
||||
label: `Promote to ${capitalize(parentModel.model)}`,
|
||||
leftSlot: (
|
||||
<Icon
|
||||
icon={parentModel.model === 'workspace' ? 'corner_right_up' : 'folder_up'}
|
||||
/>
|
||||
),
|
||||
onSelect: async () => {
|
||||
const confirmed = await showConfirm({
|
||||
id: 'promote-auth-confirm',
|
||||
title: 'Promote Authentication',
|
||||
confirmText: 'Promote',
|
||||
description: (
|
||||
<>
|
||||
Move authentication config to{' '}
|
||||
<InlineCode>{resolvedModelName(parentModel)}</InlineCode>?
|
||||
</>
|
||||
),
|
||||
});
|
||||
if (confirmed) {
|
||||
await patchModel(model, { authentication: {}, authenticationType: null });
|
||||
await patchModel(parentModel, {
|
||||
authentication: model.authentication,
|
||||
authenticationType: model.authenticationType,
|
||||
});
|
||||
|
||||
if (parentModel.model === 'folder') {
|
||||
openFolderSettings(parentModel.id, 'auth');
|
||||
} else {
|
||||
openWorkspaceSettings('auth');
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Copy from ancestor: copy auth config down to current model
|
||||
const ancestorWithAuth = ancestors.find(
|
||||
(a) => a.authenticationType != null && a.authenticationType !== 'none',
|
||||
);
|
||||
if (ancestorWithAuth) {
|
||||
if (actions.length === 0) {
|
||||
actions.push({ type: 'separator', label: 'Actions' });
|
||||
}
|
||||
actions.push({
|
||||
label: `Copy from ${modelTypeLabel(ancestorWithAuth)}`,
|
||||
leftSlot: (
|
||||
<Icon
|
||||
icon={
|
||||
ancestorWithAuth.model === 'workspace' ? 'corner_right_down' : 'folder_down'
|
||||
}
|
||||
/>
|
||||
),
|
||||
onSelect: async () => {
|
||||
const confirmed = await showConfirm({
|
||||
id: 'copy-auth-confirm',
|
||||
title: 'Copy Authentication',
|
||||
confirmText: 'Copy',
|
||||
description: (
|
||||
<>
|
||||
Copy{' '}
|
||||
{authentication.find((a) => a.name === ancestorWithAuth.authenticationType)
|
||||
?.label ?? 'authentication'}{' '}
|
||||
config from <InlineCode>{resolvedModelName(ancestorWithAuth)}</InlineCode>?
|
||||
This will override the current authentication but will not affect the{' '}
|
||||
{modelTypeLabel(ancestorWithAuth).toLowerCase()}.
|
||||
</>
|
||||
),
|
||||
});
|
||||
if (confirmed) {
|
||||
await patchModel(model, {
|
||||
authentication: { ...ancestorWithAuth.authentication },
|
||||
authenticationType: ancestorWithAuth.authenticationType,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return actions.length > 0 ? actions : undefined;
|
||||
})(),
|
||||
onChange: async (authenticationType) => {
|
||||
let authentication: Folder['authentication'] = model.authentication;
|
||||
if (model.authenticationType !== authenticationType) {
|
||||
@@ -113,5 +167,5 @@ export function useAuthTab<T extends string>(tabValue: T, model: AuthenticatedMo
|
||||
};
|
||||
|
||||
return [tab];
|
||||
}, [authentication, inheritedAuth, model, parentModel, tabValue]);
|
||||
}, [authentication, inheritedAuth, model, parentModel, tabValue, ancestors]);
|
||||
}
|
||||
|
||||
15
src-web/lib/diffYaml.ts
Normal file
15
src-web/lib/diffYaml.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { SyncModel } from '@yaakapp-internal/git';
|
||||
import { stringify } from 'yaml';
|
||||
|
||||
/**
|
||||
* Convert a SyncModel to a clean YAML string for diffing.
|
||||
* Removes noisy fields like updatedAt that change on every edit.
|
||||
*/
|
||||
export function modelToYaml(model: SyncModel | null): string {
|
||||
if (!model) return '';
|
||||
|
||||
return stringify(model, {
|
||||
indent: 2,
|
||||
lineWidth: 0,
|
||||
});
|
||||
}
|
||||
@@ -104,11 +104,12 @@ function templateTagColorVariables(color: YaakColor | null): Partial<CSSVariable
|
||||
if (color == null) return {};
|
||||
|
||||
return {
|
||||
text: color.lift(0.6).css(),
|
||||
text: color.lift(0.7).css(),
|
||||
textSubtle: color.lift(0.4).css(),
|
||||
textSubtlest: color.css(),
|
||||
surface: color.lower(0.2).translucify(0.8).css(),
|
||||
border: color.lower(0.2).translucify(0.2).css(),
|
||||
border: color.translucify(0.6).css(),
|
||||
borderSubtle: color.translucify(0.8).css(),
|
||||
surfaceHighlight: color.lower(0.1).translucify(0.7).css(),
|
||||
};
|
||||
}
|
||||
@@ -137,6 +138,16 @@ function bannerColorVariables(color: YaakColor | null): Partial<CSSVariables> {
|
||||
};
|
||||
}
|
||||
|
||||
function inputCSS(color: YaakColor | null): Partial<CSSVariables> {
|
||||
if (color == null) return {};
|
||||
|
||||
const theme: Partial<ThemeComponentColors> = {
|
||||
border: color.css(),
|
||||
};
|
||||
|
||||
return theme;
|
||||
}
|
||||
|
||||
function buttonSolidColorVariables(
|
||||
color: YaakColor | null,
|
||||
isDefault = false,
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
"@codemirror/lang-markdown": "^6.3.2",
|
||||
"@codemirror/lang-xml": "^6.1.0",
|
||||
"@codemirror/lang-yaml": "^6.1.2",
|
||||
"@codemirror/language": "^6.11.0",
|
||||
"@codemirror/merge": "^6.11.2",
|
||||
"@codemirror/search": "^6.5.11",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@gilbarbara/deep-equal": "^0.3.1",
|
||||
|
||||
Reference in New Issue
Block a user