Compare commits

..

18 Commits

Author SHA1 Message Date
Gregory Schier 986143c4ae Add yaak-actions-builtin crate and integrate with CLI 2026-02-01 09:01:37 -08:00
Gregory Schier 50b0e23d53 Add yaak-actions crate for centralized action system
Implements a unified action system that serves as a single source of truth
for all operations in Yaak (Tauri app, CLI, plugins, deep links, MCP server).

Key features:
- ActionExecutor: Combined registry and execution engine with async RwLock
- ActionHandler: Trait-based handlers using async closures
- Context system: RequiredContext and CurrentContext for action availability
- Action groups: Organize related actions
- TypeScript bindings: Auto-generated via ts-rs for frontend use

Design highlights:
- Handlers are closures (no dependencies on other yaak crates)
- Registration requires both metadata and handler (prevents orphan actions)
- Flexible return values via serde_json::Value
- All methods are async using tokio

All 33 tests passing. Ready for integration with yaak-core and yaak-app.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-01 09:00:31 -08:00
Rahul Mishra c4ce458f79 fix: pass down onClose properly (#376) 2026-01-31 07:34:40 -08:00
Gregory Schier f02ae35634 Fix auth plugin dynamic form inputs broken after first request
The call_http_authentication_request handler was mutating auth.args with the result of applyDynamicFormInput(), which strips the dynamic callback functions. This permanently corrupted the plugin module's args, making all dynamic form controls (checkboxes, selects, etc.) unresponsive for that auth type after sending the first request.
2026-01-30 12:47:02 -08:00
Gregory Schier c2f068970b Add external browser support for OAuth2 authorization (#375)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 10:29:49 -08:00
Gregory Schier eec2d6bc38 Fix multipart tab value 2026-01-29 09:01:44 -08:00
Gregory Schier efa22e470e Add diff viewer to git commit dialog (#374)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 08:50:56 -08:00
Gregory Schier c00d2e981f Fix basic auth failing when password field is empty or unset
Handle undefined username/password values by defaulting to empty string,
preventing "undefined" from being encoded in the Authorization header.

Fixes https://feedback.yaak.app/p/strange-basic-auth-behaviour-in-202612

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 15:07:03 -08:00
Gregory Schier 9c45254952 Fix template tag theme colors 2026-01-28 13:08:22 -08:00
Gregory Schier d031ff231a Bump plugin runtime types 2026-01-28 08:43:19 -08:00
Gregory Schier f056894ddb Show full URL parts in Timeline debug view (#373)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 08:41:17 -08:00
dependabot[bot] 1b0315165f Bump hono from 4.11.4 to 4.11.7 (#372) 2026-01-28 08:37:10 -08:00
Gregory Schier bd7e840a57 Fix x64 macOS build bundling wrong architecture binaries
Set YAAK_TARGET_ARCH before npm run bootstrap so vendor scripts
download the correct x64 binaries instead of arm64 ones.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 20:00:59 -08:00
Gregory Schier 8969748c3c Add option to disable encryption when key is forgotten (#371) 2026-01-26 15:40:02 -08:00
Gregory Schier 4e15ac10a6 Add folder CRUD operations to MCP server (#369)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 15:08:24 -08:00
Gregory Schier 47a3d44888 Git branch flow improvements (#370) 2026-01-26 14:45:51 -08:00
Gregory Schier eb10910d20 Update HttpMethodTag.tsx 2026-01-22 06:03:04 -08:00
Gregory Schier 6ba83d424d Fix request method dropdown for GraphQL not showing HTTP method 2026-01-22 06:02:49 -08:00
98 changed files with 5122 additions and 1051 deletions
+2
View File
@@ -89,6 +89,8 @@ jobs:
- run: npm ci - run: npm ci
- run: npm run bootstrap - run: npm run bootstrap
env:
YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}
- run: npm run lint - run: npm run lint
- name: Run JS Tests - name: Run JS Tests
run: npm test run: npm test
Generated
+29
View File
@@ -7994,6 +7994,33 @@ dependencies = [
"rustix 1.0.7", "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]] [[package]]
name = "yaak-app" name = "yaak-app"
version = "0.0.0" version = "0.0.0"
@@ -8063,6 +8090,8 @@ dependencies = [
"log", "log",
"serde_json", "serde_json",
"tokio", "tokio",
"yaak-actions",
"yaak-actions-builtin",
"yaak-crypto", "yaak-crypto",
"yaak-http", "yaak-http",
"yaak-models", "yaak-models",
+4
View File
@@ -2,6 +2,8 @@
resolver = "2" resolver = "2"
members = [ members = [
# Shared crates (no Tauri dependency) # Shared crates (no Tauri dependency)
"crates/yaak-actions",
"crates/yaak-actions-builtin",
"crates/yaak-core", "crates/yaak-core",
"crates/yaak-common", "crates/yaak-common",
"crates/yaak-crypto", "crates/yaak-crypto",
@@ -45,6 +47,8 @@ tokio = "1.48.0"
ts-rs = "11.1.0" ts-rs = "11.1.0"
# Internal crates - shared # Internal crates - shared
yaak-actions = { path = "crates/yaak-actions" }
yaak-actions-builtin = { path = "crates/yaak-actions-builtin" }
yaak-core = { path = "crates/yaak-core" } yaak-core = { path = "crates/yaak-core" }
yaak-common = { path = "crates/yaak-common" } yaak-common = { path = "crates/yaak-common" }
yaak-crypto = { path = "crates/yaak-crypto" } yaak-crypto = { path = "crates/yaak-crypto" }
+2
View File
@@ -15,6 +15,8 @@ env_logger = "0.11"
log = { workspace = true } log = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
yaak-actions = { workspace = true }
yaak-actions-builtin = { workspace = true }
yaak-crypto = { workspace = true } yaak-crypto = { workspace = true }
yaak-http = { workspace = true } yaak-http = { workspace = true }
yaak-models = { workspace = true } yaak-models = { workspace = true }
+54 -173
View File
@@ -1,21 +1,13 @@
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use log::info;
use serde_json::Value;
use std::collections::BTreeMap;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::mpsc; 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::sender::{HttpSender, ReqwestSender};
use yaak_http::types::{SendableHttpRequest, SendableHttpRequestOptions}; use yaak_http::types::{SendableHttpRequest, SendableHttpRequestOptions};
use yaak_models::models::{HttpRequest, HttpRequestHeader, HttpUrlParameter}; use yaak_models::models::HttpRequest;
use yaak_models::render::make_vars_hashmap;
use yaak_models::util::UpdateSource; use yaak_models::util::UpdateSource;
use yaak_plugins::events::{PluginContext, RenderPurpose}; use yaak_plugins::events::PluginContext;
use yaak_plugins::manager::PluginManager; use yaak_plugins::manager::PluginManager;
use yaak_plugins::template_callback::PluginTemplateCallback;
use yaak_templates::{RenderOptions, parse_and_render, render_json_value_raw};
#[derive(Parser)] #[derive(Parser)]
#[command(name = "yaakcli")] #[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] #[tokio::main]
async fn main() { async fn main() {
let cli = Cli::parse(); let cli = Cli::parse();
@@ -176,10 +88,6 @@ async fn main() {
let db = query_manager.connect(); 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 // Initialize plugin manager for template functions
let vendored_plugin_dir = data_dir.join("vendored-plugins"); let vendored_plugin_dir = data_dir.join("vendored-plugins");
let installed_plugin_dir = data_dir.join("installed-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) // Create plugin manager (plugins may not be available in CLI context)
let plugin_manager = Arc::new( let plugin_manager = Arc::new(
PluginManager::new( PluginManager::new(
vendored_plugin_dir, vendored_plugin_dir.clone(),
installed_plugin_dir, installed_plugin_dir.clone(),
node_bin_path, node_bin_path.clone(),
plugin_runtime_main, plugin_runtime_main,
false, false,
) )
@@ -239,94 +147,67 @@ async fn main() {
} }
} }
Commands::Send { request_id } => { 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 // Create dependencies
let environment_chain = db let deps = BuiltinActionDependencies::new_standalone(
.resolve_environments( &db_path,
&request.workspace_id, &blob_path,
request.folder_id.as_deref(), &app_id,
cli.environment.as_deref(), vendored_plugin_dir.clone(),
) installed_plugin_dir.clone(),
.unwrap_or_default(); node_bin_path.clone(),
// 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(),
) )
.await .await
.expect("Failed to render request templates"); .expect("Failed to initialize dependencies");
if cli.verbose { // Create executor and register actions
println!("> {} {}", rendered_request.method, rendered_request.url); 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 // Prepare context
let sendable = SendableHttpRequest::from_http_request( let context = CurrentContext {
&rendered_request, target: Some(ActionTarget::HttpRequest { id: request_id.clone() }),
SendableHttpRequestOptions::default(), environment_id: cli.environment.clone(),
) workspace_id: None,
.await has_window: false,
.expect("Failed to build request"); can_prompt: false,
// 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
}; };
// Send the request // Prepare params
let sender = ReqwestSender::new().expect("Failed to create HTTP client"); let params = ActionParams {
let response = sender.send(sendable, event_tx).await.expect("Failed to send request"); data: serde_json::json!({
"render": true,
"follow_redirects": false,
"timeout_ms": 30000,
}),
};
// Wait for event handler to finish // Invoke action
if let Some(handle) = verbose_handle { let action_id = ActionId::builtin("http", "send-request");
let _ = handle.await; let result = executor.invoke(&action_id, context, params).await.expect("Action failed");
}
// Print response // Handle result
if verbose { match result {
println!(); ActionResult::Success { data, message } => {
if let Some(msg) = message {
println!("{}", msg);
} }
println!( if let Some(data) = data {
"HTTP {} {}", println!("{}", serde_json::to_string_pretty(&data).unwrap());
response.status, }
response.status_reason.as_deref().unwrap_or("") }
); ActionResult::RequiresInput { .. } => {
eprintln!("Action requires input (not supported in CLI)");
if verbose { }
for (name, value) in &response.headers { ActionResult::Cancelled => {
println!("{}: {}", name, value); eprintln!("Action cancelled");
} }
println!();
} }
// Print body
let (body, _stats) = response.text().await.expect("Failed to read response body");
println!("{}", body);
} }
Commands::Get { url } => { Commands::Get { url } => {
if cli.verbose { if cli.verbose {
+9
View File
@@ -100,6 +100,15 @@ pub(crate) async fn cmd_set_workspace_key<R: Runtime>(
Ok(()) Ok(())
} }
#[command]
pub(crate) async fn cmd_disable_encryption<R: Runtime>(
window: WebviewWindow<R>,
workspace_id: &str,
) -> Result<()> {
window.crypto().disable_encryption(workspace_id)?;
Ok(())
}
#[command] #[command]
pub(crate) fn cmd_default_headers() -> Vec<HttpRequestHeader> { pub(crate) fn cmd_default_headers() -> Vec<HttpRequestHeader> {
default_headers() default_headers()
+31 -12
View File
@@ -6,9 +6,10 @@ use crate::error::Result;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use tauri::command; use tauri::command;
use yaak_git::{ use yaak_git::{
GitCommit, GitRemote, GitStatusSummary, PullResult, PushResult, git_add, git_add_credential, BranchDeleteResult, CloneResult, GitCommit, GitRemote, GitStatusSummary, PullResult,
git_add_remote, git_checkout_branch, git_commit, git_create_branch, git_delete_branch, PushResult, git_add, git_add_credential, git_add_remote, git_checkout_branch, git_clone,
git_fetch_all, git_init, git_log, git_merge_branch, git_pull, git_push, git_remotes, git_commit, git_create_branch, git_delete_branch, git_delete_remote_branch, git_fetch_all,
git_init, git_log, git_merge_branch, git_pull, git_push, git_remotes, git_rename_branch,
git_rm_remote, git_status, git_unstage, git_rm_remote, git_status, git_unstage,
}; };
@@ -16,22 +17,36 @@ use yaak_git::{
#[command] #[command]
pub async fn cmd_git_checkout(dir: &Path, branch: &str, force: bool) -> Result<String> { pub async fn cmd_git_checkout(dir: &Path, branch: &str, force: bool) -> Result<String> {
Ok(git_checkout_branch(dir, branch, force)?) Ok(git_checkout_branch(dir, branch, force).await?)
} }
#[command] #[command]
pub async fn cmd_git_branch(dir: &Path, branch: &str) -> Result<()> { pub async fn cmd_git_branch(dir: &Path, branch: &str, base: Option<&str>) -> Result<()> {
Ok(git_create_branch(dir, branch)?) Ok(git_create_branch(dir, branch, base).await?)
} }
#[command] #[command]
pub async fn cmd_git_delete_branch(dir: &Path, branch: &str) -> Result<()> { pub async fn cmd_git_delete_branch(
Ok(git_delete_branch(dir, branch)?) dir: &Path,
branch: &str,
force: Option<bool>,
) -> Result<BranchDeleteResult> {
Ok(git_delete_branch(dir, branch, force.unwrap_or(false)).await?)
} }
#[command] #[command]
pub async fn cmd_git_merge_branch(dir: &Path, branch: &str, force: bool) -> Result<()> { pub async fn cmd_git_delete_remote_branch(dir: &Path, branch: &str) -> Result<()> {
Ok(git_merge_branch(dir, branch, force)?) Ok(git_delete_remote_branch(dir, branch).await?)
}
#[command]
pub async fn cmd_git_merge_branch(dir: &Path, branch: &str) -> Result<()> {
Ok(git_merge_branch(dir, branch).await?)
}
#[command]
pub async fn cmd_git_rename_branch(dir: &Path, old_name: &str, new_name: &str) -> Result<()> {
Ok(git_rename_branch(dir, old_name, new_name).await?)
} }
#[command] #[command]
@@ -49,6 +64,11 @@ pub async fn cmd_git_initialize(dir: &Path) -> Result<()> {
Ok(git_init(dir)?) Ok(git_init(dir)?)
} }
#[command]
pub async fn cmd_git_clone(url: &str, dir: &Path) -> Result<CloneResult> {
Ok(git_clone(url, dir).await?)
}
#[command] #[command]
pub async fn cmd_git_commit(dir: &Path, message: &str) -> Result<()> { pub async fn cmd_git_commit(dir: &Path, message: &str) -> Result<()> {
Ok(git_commit(dir, message).await?) Ok(git_commit(dir, message).await?)
@@ -87,12 +107,11 @@ pub async fn cmd_git_unstage(dir: &Path, rela_paths: Vec<PathBuf>) -> Result<()>
#[command] #[command]
pub async fn cmd_git_add_credential( pub async fn cmd_git_add_credential(
dir: &Path,
remote_url: &str, remote_url: &str,
username: &str, username: &str,
password: &str, password: &str,
) -> Result<()> { ) -> Result<()> {
Ok(git_add_credential(dir, remote_url, username, password).await?) Ok(git_add_credential(remote_url, username, password).await?)
} }
#[command] #[command]
+7
View File
@@ -101,6 +101,7 @@ struct AppMetaData {
app_data_dir: String, app_data_dir: String,
app_log_dir: String, app_log_dir: String,
vendored_plugin_dir: String, vendored_plugin_dir: String,
default_project_dir: String,
feature_updater: bool, feature_updater: bool,
feature_license: bool, feature_license: bool,
} }
@@ -111,6 +112,7 @@ async fn cmd_metadata(app_handle: AppHandle) -> YaakResult<AppMetaData> {
let app_log_dir = app_handle.path().app_log_dir()?; let app_log_dir = app_handle.path().app_log_dir()?;
let vendored_plugin_dir = let vendored_plugin_dir =
app_handle.path().resolve("vendored/plugins", BaseDirectory::Resource)?; app_handle.path().resolve("vendored/plugins", BaseDirectory::Resource)?;
let default_project_dir = app_handle.path().home_dir()?.join("YaakProjects");
Ok(AppMetaData { Ok(AppMetaData {
is_dev: is_dev(), is_dev: is_dev(),
version: app_handle.package_info().version.to_string(), version: app_handle.package_info().version.to_string(),
@@ -118,6 +120,7 @@ async fn cmd_metadata(app_handle: AppHandle) -> YaakResult<AppMetaData> {
app_data_dir: app_data_dir.to_string_lossy().to_string(), app_data_dir: app_data_dir.to_string_lossy().to_string(),
app_log_dir: app_log_dir.to_string_lossy().to_string(), app_log_dir: app_log_dir.to_string_lossy().to_string(),
vendored_plugin_dir: vendored_plugin_dir.to_string_lossy().to_string(), vendored_plugin_dir: vendored_plugin_dir.to_string_lossy().to_string(),
default_project_dir: default_project_dir.to_string_lossy().to_string(),
feature_license: cfg!(feature = "license"), feature_license: cfg!(feature = "license"),
feature_updater: cfg!(feature = "updater"), feature_updater: cfg!(feature = "updater"),
}) })
@@ -1719,6 +1722,7 @@ pub fn run() {
// Migrated commands // Migrated commands
crate::commands::cmd_decrypt_template, crate::commands::cmd_decrypt_template,
crate::commands::cmd_default_headers, crate::commands::cmd_default_headers,
crate::commands::cmd_disable_encryption,
crate::commands::cmd_enable_encryption, crate::commands::cmd_enable_encryption,
crate::commands::cmd_get_themes, crate::commands::cmd_get_themes,
crate::commands::cmd_reveal_workspace_key, crate::commands::cmd_reveal_workspace_key,
@@ -1747,10 +1751,13 @@ pub fn run() {
git_ext::cmd_git_checkout, git_ext::cmd_git_checkout,
git_ext::cmd_git_branch, git_ext::cmd_git_branch,
git_ext::cmd_git_delete_branch, git_ext::cmd_git_delete_branch,
git_ext::cmd_git_delete_remote_branch,
git_ext::cmd_git_merge_branch, git_ext::cmd_git_merge_branch,
git_ext::cmd_git_rename_branch,
git_ext::cmd_git_status, git_ext::cmd_git_status,
git_ext::cmd_git_log, git_ext::cmd_git_log,
git_ext::cmd_git_initialize, git_ext::cmd_git_initialize,
git_ext::cmd_git_clone,
git_ext::cmd_git_commit, git_ext::cmd_git_commit,
git_ext::cmd_git_fetch_all, git_ext::cmd_git_fetch_all,
git_ext::cmd_git_push, git_ext::cmd_git_push,
+18
View 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 }
@@ -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,
})
}
}
@@ -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(())
}
@@ -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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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, &current);
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, &current);
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, &current);
assert!(matches!(
availability,
ActionAvailability::AvailableWithPrompt { prompt_fields } if prompt_fields == vec!["target"]
));
}
}
+131
View 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
View 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
View 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
View 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
View 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
View 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);
}
}
+4
View File
@@ -11,3 +11,7 @@ export function revealWorkspaceKey(workspaceId: string) {
export function setWorkspaceKey(args: { workspaceId: string; key: string }) { export function setWorkspaceKey(args: { workspaceId: string; key: string }) {
return invoke<void>('cmd_set_workspace_key', args); return invoke<void>('cmd_set_workspace_key', args);
} }
export function disableEncryption(workspaceId: string) {
return invoke<void>('cmd_disable_encryption', { workspaceId });
}
+29
View File
@@ -115,6 +115,35 @@ impl EncryptionManager {
self.set_workspace_key(workspace_id, &wkey) self.set_workspace_key(workspace_id, &wkey)
} }
pub fn disable_encryption(&self, workspace_id: &str) -> Result<()> {
info!("Disabling encryption for {workspace_id}");
self.query_manager.with_tx::<(), Error>(|tx| {
let workspace = tx.get_workspace(workspace_id)?;
let workspace_meta = tx.get_or_create_workspace_meta(workspace_id)?;
// Clear encryption challenge on workspace
tx.upsert_workspace(
&Workspace { encryption_key_challenge: None, ..workspace },
&UpdateSource::Background,
)?;
// Clear encryption key on workspace meta
tx.upsert_workspace_meta(
&WorkspaceMeta { encryption_key: None, ..workspace_meta },
&UpdateSource::Background,
)?;
Ok(())
})?;
// Remove from cache
let mut cache = self.cached_workspace_keys.lock().unwrap();
cache.remove(workspace_id);
Ok(())
}
fn get_workspace_key(&self, workspace_id: &str) -> Result<WorkspaceKey> { fn get_workspace_key(&self, workspace_id: &str) -> Result<WorkspaceKey> {
{ {
let cache = self.cached_workspace_keys.lock().unwrap(); let cache = self.cached_workspace_keys.lock().unwrap();
+4
View File
@@ -1,6 +1,10 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { SyncModel } from "./gen_models"; import type { SyncModel } from "./gen_models";
export type BranchDeleteResult = { "type": "success", message: string, } | { "type": "not_fully_merged" };
export type CloneResult = { "type": "success" } | { "type": "cancelled" } | { "type": "needs_credentials", url: string, error: string | null, };
export type GitAuthor = { name: string | null, email: string | null, }; export type GitAuthor = { name: string | null, email: string | null, };
export type GitCommit = { author: GitAuthor, when: string, message: string | null, }; export type GitCommit = { author: GitAuthor, when: string, message: string | null, };
+40 -6
View File
@@ -3,9 +3,10 @@ import { invoke } from '@tauri-apps/api/core';
import { createFastMutation } from '@yaakapp/app/hooks/useFastMutation'; import { createFastMutation } from '@yaakapp/app/hooks/useFastMutation';
import { queryClient } from '@yaakapp/app/lib/queryClient'; import { queryClient } from '@yaakapp/app/lib/queryClient';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { GitCommit, GitRemote, GitStatusSummary, PullResult, PushResult } from './bindings/gen_git'; import { BranchDeleteResult, CloneResult, GitCommit, GitRemote, GitStatusSummary, PullResult, PushResult } from './bindings/gen_git';
export * from './bindings/gen_git'; export * from './bindings/gen_git';
export * from './bindings/gen_models';
export interface GitCredentials { export interface GitCredentials {
username: string; username: string;
@@ -59,7 +60,6 @@ export const gitMutations = (dir: string, callbacks: GitCallbacks) => {
if (creds == null) throw new Error('Canceled'); if (creds == null) throw new Error('Canceled');
await invoke('cmd_git_add_credential', { await invoke('cmd_git_add_credential', {
dir,
remoteUrl: result.url, remoteUrl: result.url,
username: creds.username, username: creds.username,
password: creds.password, password: creds.password,
@@ -90,21 +90,31 @@ export const gitMutations = (dir: string, callbacks: GitCallbacks) => {
mutationFn: (args) => invoke('cmd_git_rm_remote', { dir, ...args }), mutationFn: (args) => invoke('cmd_git_rm_remote', { dir, ...args }),
onSuccess, onSuccess,
}), }),
branch: createFastMutation<void, string, { branch: string }>({ createBranch: createFastMutation<void, string, { branch: string; base?: string }>({
mutationKey: ['git', 'branch', dir], mutationKey: ['git', 'branch', dir],
mutationFn: (args) => invoke('cmd_git_branch', { dir, ...args }), mutationFn: (args) => invoke('cmd_git_branch', { dir, ...args }),
onSuccess, onSuccess,
}), }),
mergeBranch: createFastMutation<void, string, { branch: string; force: boolean }>({ mergeBranch: createFastMutation<void, string, { branch: string }>({
mutationKey: ['git', 'merge', dir], mutationKey: ['git', 'merge', dir],
mutationFn: (args) => invoke('cmd_git_merge_branch', { dir, ...args }), mutationFn: (args) => invoke('cmd_git_merge_branch', { dir, ...args }),
onSuccess, onSuccess,
}), }),
deleteBranch: createFastMutation<void, string, { branch: string }>({ deleteBranch: createFastMutation<BranchDeleteResult, string, { branch: string, force?: boolean }>({
mutationKey: ['git', 'delete-branch', dir], mutationKey: ['git', 'delete-branch', dir],
mutationFn: (args) => invoke('cmd_git_delete_branch', { dir, ...args }), mutationFn: (args) => invoke('cmd_git_delete_branch', { dir, ...args }),
onSuccess, onSuccess,
}), }),
deleteRemoteBranch: createFastMutation<void, string, { branch: string }>({
mutationKey: ['git', 'delete-remote-branch', dir],
mutationFn: (args) => invoke('cmd_git_delete_remote_branch', { dir, ...args }),
onSuccess,
}),
renameBranch: createFastMutation<void, string, { oldName: string, newName: string }>({
mutationKey: ['git', 'rename-branch', dir],
mutationFn: (args) => invoke('cmd_git_rename_branch', { dir, ...args }),
onSuccess,
}),
checkout: createFastMutation<string, string, { branch: string; force: boolean }>({ checkout: createFastMutation<string, string, { branch: string; force: boolean }>({
mutationKey: ['git', 'checkout', dir], mutationKey: ['git', 'checkout', dir],
mutationFn: (args) => invoke('cmd_git_checkout', { dir, ...args }), mutationFn: (args) => invoke('cmd_git_checkout', { dir, ...args }),
@@ -144,7 +154,6 @@ export const gitMutations = (dir: string, callbacks: GitCallbacks) => {
if (creds == null) throw new Error('Canceled'); if (creds == null) throw new Error('Canceled');
await invoke('cmd_git_add_credential', { await invoke('cmd_git_add_credential', {
dir,
remoteUrl: result.url, remoteUrl: result.url,
username: creds.username, username: creds.username,
password: creds.password, password: creds.password,
@@ -166,3 +175,28 @@ export const gitMutations = (dir: string, callbacks: GitCallbacks) => {
async function getRemotes(dir: string) { async function getRemotes(dir: string) {
return invoke<GitRemote[]>('cmd_git_remotes', { dir }); return invoke<GitRemote[]>('cmd_git_remotes', { dir });
} }
/**
* Clone a git repository, prompting for credentials if needed.
*/
export async function gitClone(
url: string,
dir: string,
promptCredentials: (args: { url: string; error: string | null }) => Promise<GitCredentials | null>,
): Promise<CloneResult> {
const result = await invoke<CloneResult>('cmd_git_clone', { url, dir });
if (result.type !== 'needs_credentials') return result;
// Prompt for credentials
const creds = await promptCredentials({ url: result.url, error: result.error });
if (creds == null) return {type: 'cancelled'};
// Store credentials and retry
await invoke('cmd_git_add_credential', {
remoteUrl: result.url,
username: creds.username,
password: creds.password,
});
return invoke<CloneResult>('cmd_git_clone', { url, dir });
}
+9 -3
View File
@@ -5,7 +5,15 @@ use std::process::Stdio;
use tokio::process::Command; use tokio::process::Command;
use yaak_common::command::new_xplatform_command; use yaak_common::command::new_xplatform_command;
/// Create a git command that runs in the specified directory
pub(crate) async fn new_binary_command(dir: &Path) -> Result<Command> { pub(crate) async fn new_binary_command(dir: &Path) -> Result<Command> {
let mut cmd = new_binary_command_global().await?;
cmd.arg("-C").arg(dir);
Ok(cmd)
}
/// Create a git command without a specific directory (for global operations)
pub(crate) async fn new_binary_command_global() -> Result<Command> {
// 1. Probe that `git` exists and is runnable // 1. Probe that `git` exists and is runnable
let mut probe = new_xplatform_command("git"); let mut probe = new_xplatform_command("git");
probe.arg("--version").stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()); probe.arg("--version").stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null());
@@ -17,8 +25,6 @@ pub(crate) async fn new_binary_command(dir: &Path) -> Result<Command> {
} }
// 2. Build the reusable git command // 2. Build the reusable git command
let mut cmd = new_xplatform_command("git"); let cmd = new_xplatform_command("git");
cmd.arg("-C").arg(dir);
Ok(cmd) Ok(cmd)
} }
+119 -65
View File
@@ -1,99 +1,153 @@
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use crate::binary::new_binary_command;
use crate::error::Error::GenericError; use crate::error::Error::GenericError;
use crate::error::Result; use crate::error::Result;
use crate::merge::do_merge;
use crate::repository::open_repo;
use crate::util::{bytes_to_string, get_branch_by_name, get_current_branch};
use git2::BranchType;
use git2::build::CheckoutBuilder;
use log::info;
use std::path::Path; use std::path::Path;
pub fn git_checkout_branch(dir: &Path, branch_name: &str, force: bool) -> Result<String> { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
if branch_name.starts_with("origin/") { #[serde(rename_all = "snake_case", tag = "type")]
return git_checkout_remote_branch(dir, branch_name, force); #[ts(export, export_to = "gen_git.ts")]
pub enum BranchDeleteResult {
Success { message: String },
NotFullyMerged,
} }
let repo = open_repo(dir)?; pub async fn git_checkout_branch(dir: &Path, branch_name: &str, force: bool) -> Result<String> {
let branch = get_branch_by_name(&repo, branch_name)?; let branch_name = branch_name.trim_start_matches("origin/");
let branch_ref = branch.into_reference();
let branch_tree = branch_ref.peel_to_tree()?;
let mut options = CheckoutBuilder::default(); let mut args = vec!["checkout"];
if force { if force {
options.force(); args.push("--force");
} }
args.push(branch_name);
repo.checkout_tree(branch_tree.as_object(), Some(&mut options))?; let out = new_binary_command(dir)
repo.set_head(branch_ref.name().unwrap())?; .await?
.args(&args)
.output()
.await
.map_err(|e| GenericError(format!("failed to run git checkout: {e}")))?;
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
let combined = format!("{}{}", stdout, stderr);
if !out.status.success() {
return Err(GenericError(format!("Failed to checkout: {}", combined.trim())));
}
Ok(branch_name.to_string()) Ok(branch_name.to_string())
} }
pub(crate) fn git_checkout_remote_branch( pub async fn git_create_branch(dir: &Path, name: &str, base: Option<&str>) -> Result<()> {
dir: &Path, let mut cmd = new_binary_command(dir).await?;
branch_name: &str, cmd.arg("branch").arg(name);
force: bool, if let Some(base_branch) = base {
) -> Result<String> { cmd.arg(base_branch);
let branch_name = branch_name.trim_start_matches("origin/");
let repo = open_repo(dir)?;
let refname = format!("refs/remotes/origin/{}", branch_name);
let remote_ref = repo.find_reference(&refname)?;
let commit = remote_ref.peel_to_commit()?;
let mut new_branch = repo.branch(branch_name, &commit, false)?;
let upstream_name = format!("origin/{}", branch_name);
new_branch.set_upstream(Some(&upstream_name))?;
git_checkout_branch(dir, branch_name, force)
} }
pub fn git_create_branch(dir: &Path, name: &str) -> Result<()> { let out =
let repo = open_repo(dir)?; cmd.output().await.map_err(|e| GenericError(format!("failed to run git branch: {e}")))?;
let head = match repo.head() {
Ok(h) => h,
Err(e) if e.code() == git2::ErrorCode::UnbornBranch => {
let msg = "Cannot create branch when there are no commits";
return Err(GenericError(msg.into()));
}
Err(e) => return Err(e.into()),
};
let head = head.peel_to_commit()?;
repo.branch(name, &head, false)?; let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
let combined = format!("{}{}", stdout, stderr);
if !out.status.success() {
return Err(GenericError(format!("Failed to create branch: {}", combined.trim())));
}
Ok(()) Ok(())
} }
pub fn git_delete_branch(dir: &Path, name: &str) -> Result<()> { pub async fn git_delete_branch(dir: &Path, name: &str, force: bool) -> Result<BranchDeleteResult> {
let repo = open_repo(dir)?; let mut cmd = new_binary_command(dir).await?;
let mut branch = get_branch_by_name(&repo, name)?;
if branch.is_head() { let out =
info!("Deleting head branch"); if force { cmd.args(["branch", "-D", name]) } else { cmd.args(["branch", "-d", name]) }
let branches = repo.branches(Some(BranchType::Local))?; .output()
let other_branch = branches.into_iter().filter_map(|b| b.ok()).find(|b| !b.0.is_head()); .await
let other_branch = match other_branch { .map_err(|e| GenericError(format!("failed to run git branch -d: {e}")))?;
None => return Err(GenericError("Cannot delete only branch".into())),
Some(b) => bytes_to_string(b.0.name_bytes()?)?,
};
git_checkout_branch(dir, &other_branch, true)?; let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
let combined = format!("{}{}", stdout, stderr);
if !out.status.success() && stderr.to_lowercase().contains("not fully merged") {
return Ok(BranchDeleteResult::NotFullyMerged);
} }
branch.delete()?; if !out.status.success() {
return Err(GenericError(format!("Failed to delete branch: {}", combined.trim())));
}
Ok(BranchDeleteResult::Success { message: combined })
}
pub async fn git_merge_branch(dir: &Path, name: &str) -> Result<()> {
let out = new_binary_command(dir)
.await?
.args(["merge", name])
.output()
.await
.map_err(|e| GenericError(format!("failed to run git merge: {e}")))?;
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
let combined = format!("{}{}", stdout, stderr);
if !out.status.success() {
// Check for merge conflicts
if combined.to_lowercase().contains("conflict") {
return Err(GenericError(
"Merge conflicts detected. Please resolve them manually.".to_string(),
));
}
return Err(GenericError(format!("Failed to merge: {}", combined.trim())));
}
Ok(()) Ok(())
} }
pub fn git_merge_branch(dir: &Path, name: &str, _force: bool) -> Result<()> { pub async fn git_delete_remote_branch(dir: &Path, name: &str) -> Result<()> {
let repo = open_repo(dir)?; // Remote branch names come in as "origin/branch-name", extract the branch name
let local_branch = get_current_branch(&repo)?.unwrap(); let branch_name = name.trim_start_matches("origin/");
let commit_to_merge = get_branch_by_name(&repo, name)?.into_reference(); let out = new_binary_command(dir)
let commit_to_merge = repo.reference_to_annotated_commit(&commit_to_merge)?; .await?
.args(["push", "origin", "--delete", branch_name])
.output()
.await
.map_err(|e| GenericError(format!("failed to run git push --delete: {e}")))?;
do_merge(&repo, &local_branch, &commit_to_merge)?; let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
let combined = format!("{}{}", stdout, stderr);
if !out.status.success() {
return Err(GenericError(format!("Failed to delete remote branch: {}", combined.trim())));
}
Ok(())
}
pub async fn git_rename_branch(dir: &Path, old_name: &str, new_name: &str) -> Result<()> {
let out = new_binary_command(dir)
.await?
.args(["branch", "-m", old_name, new_name])
.output()
.await
.map_err(|e| GenericError(format!("failed to run git branch -m: {e}")))?;
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
let combined = format!("{}{}", stdout, stderr);
if !out.status.success() {
return Err(GenericError(format!("Failed to rename branch: {}", combined.trim())));
}
Ok(()) Ok(())
} }
+53
View File
@@ -0,0 +1,53 @@
use crate::binary::new_binary_command;
use crate::error::Error::GenericError;
use crate::error::Result;
use log::info;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
use ts_rs::TS;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case", tag = "type")]
#[ts(export, export_to = "gen_git.ts")]
pub enum CloneResult {
Success,
Cancelled,
NeedsCredentials { url: String, error: Option<String> },
}
pub async fn git_clone(url: &str, dir: &Path) -> Result<CloneResult> {
let parent = dir.parent().ok_or_else(|| GenericError("Invalid clone directory".to_string()))?;
fs::create_dir_all(parent)
.map_err(|e| GenericError(format!("Failed to create directory: {e}")))?;
let mut cmd = new_binary_command(parent).await?;
cmd.args(["clone", url]).arg(dir).env("GIT_TERMINAL_PROMPT", "0");
let out =
cmd.output().await.map_err(|e| GenericError(format!("failed to run git clone: {e}")))?;
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
let combined = format!("{}{}", stdout, stderr);
let combined_lower = combined.to_lowercase();
info!("Cloned status={}: {combined}", out.status);
if !out.status.success() {
// Check for credentials error
if combined_lower.contains("could not read") {
return Ok(CloneResult::NeedsCredentials { url: url.to_string(), error: None });
}
if combined_lower.contains("unable to access")
|| combined_lower.contains("authentication failed")
{
return Ok(CloneResult::NeedsCredentials {
url: url.to_string(),
error: Some(combined.to_string()),
});
}
return Err(GenericError(format!("Failed to clone: {}", combined.trim())));
}
Ok(CloneResult::Success)
}
+3 -9
View File
@@ -1,24 +1,18 @@
use crate::binary::new_binary_command; use crate::binary::new_binary_command_global;
use crate::error::Error::GenericError; use crate::error::Error::GenericError;
use crate::error::Result; use crate::error::Result;
use std::path::Path;
use std::process::Stdio; use std::process::Stdio;
use tokio::io::AsyncWriteExt; use tokio::io::AsyncWriteExt;
use url::Url; use url::Url;
pub async fn git_add_credential( pub async fn git_add_credential(remote_url: &str, username: &str, password: &str) -> Result<()> {
dir: &Path,
remote_url: &str,
username: &str,
password: &str,
) -> Result<()> {
let url = Url::parse(remote_url) let url = Url::parse(remote_url)
.map_err(|e| GenericError(format!("Failed to parse remote url {remote_url}: {e:?}")))?; .map_err(|e| GenericError(format!("Failed to parse remote url {remote_url}: {e:?}")))?;
let protocol = url.scheme(); let protocol = url.scheme();
let host = url.host_str().unwrap(); let host = url.host_str().unwrap();
let path = Some(url.path()); let path = Some(url.path());
let mut child = new_binary_command(dir) let mut child = new_binary_command_global()
.await? .await?
.args(["credential", "approve"]) .args(["credential", "approve"])
.stdin(Stdio::piped()) .stdin(Stdio::piped())
+7 -2
View File
@@ -1,13 +1,14 @@
mod add; mod add;
mod binary; mod binary;
mod branch; mod branch;
mod clone;
mod commit; mod commit;
mod credential; mod credential;
pub mod error; pub mod error;
mod fetch; mod fetch;
mod init; mod init;
mod log; mod log;
mod merge;
mod pull; mod pull;
mod push; mod push;
mod remotes; mod remotes;
@@ -18,7 +19,11 @@ mod util;
// Re-export all git functions for external use // Re-export all git functions for external use
pub use add::git_add; pub use add::git_add;
pub use branch::{git_checkout_branch, git_create_branch, git_delete_branch, git_merge_branch}; pub use branch::{
BranchDeleteResult, git_checkout_branch, git_create_branch, git_delete_branch,
git_delete_remote_branch, git_merge_branch, git_rename_branch,
};
pub use clone::{CloneResult, git_clone};
pub use commit::git_commit; pub use commit::git_commit;
pub use credential::git_add_credential; pub use credential::git_add_credential;
pub use fetch::git_fetch_all; pub use fetch::git_fetch_all;
-135
View File
@@ -1,135 +0,0 @@
use crate::error::Error::MergeConflicts;
use crate::util::bytes_to_string;
use git2::{AnnotatedCommit, Branch, IndexEntry, Reference, Repository};
use log::{debug, info};
pub(crate) fn do_merge(
repo: &Repository,
local_branch: &Branch,
commit_to_merge: &AnnotatedCommit,
) -> crate::error::Result<()> {
debug!("Merging remote branches");
let analysis = repo.merge_analysis(&[&commit_to_merge])?;
if analysis.0.is_fast_forward() {
let refname = bytes_to_string(local_branch.get().name_bytes())?;
match repo.find_reference(&refname) {
Ok(mut r) => {
merge_fast_forward(repo, &mut r, &commit_to_merge)?;
}
Err(_) => {
// The branch doesn't exist, so set the reference to the commit directly. Usually
// this is because you are pulling into an empty repository.
repo.reference(
&refname,
commit_to_merge.id(),
true,
&format!("Setting {} to {}", refname, commit_to_merge.id()),
)?;
repo.set_head(&refname)?;
repo.checkout_head(Some(
git2::build::CheckoutBuilder::default()
.allow_conflicts(true)
.conflict_style_merge(true)
.force(),
))?;
}
};
} else if analysis.0.is_normal() {
let head_commit = repo.reference_to_annotated_commit(&repo.head()?)?;
merge_normal(repo, &head_commit, commit_to_merge)?;
} else {
debug!("Skipping merge. Nothing to do")
}
Ok(())
}
pub(crate) fn merge_fast_forward(
repo: &Repository,
local_reference: &mut Reference,
remote_commit: &AnnotatedCommit,
) -> crate::error::Result<()> {
info!("Performing fast forward");
let name = match local_reference.name() {
Some(s) => s.to_string(),
None => String::from_utf8_lossy(local_reference.name_bytes()).to_string(),
};
let msg = format!("Fast-Forward: Setting {} to id: {}", name, remote_commit.id());
local_reference.set_target(remote_commit.id(), &msg)?;
repo.set_head(&name)?;
repo.checkout_head(Some(
git2::build::CheckoutBuilder::default()
// For some reason, the force is required to make the working directory actually get
// updated I suspect we should be adding some logic to handle dirty working directory
// states, but this is just an example so maybe not.
.force(),
))?;
Ok(())
}
pub(crate) fn merge_normal(
repo: &Repository,
local: &AnnotatedCommit,
remote: &AnnotatedCommit,
) -> crate::error::Result<()> {
info!("Performing normal merge");
let local_tree = repo.find_commit(local.id())?.tree()?;
let remote_tree = repo.find_commit(remote.id())?.tree()?;
let ancestor = repo.find_commit(repo.merge_base(local.id(), remote.id())?)?.tree()?;
let mut idx = repo.merge_trees(&ancestor, &local_tree, &remote_tree, None)?;
if idx.has_conflicts() {
let conflicts = idx.conflicts()?;
for conflict in conflicts {
if let Ok(conflict) = conflict {
print_conflict(&conflict);
}
}
return Err(MergeConflicts);
}
let result_tree = repo.find_tree(idx.write_tree_to(repo)?)?;
// now create the merge commit
let msg = format!("Merge: {} into {}", remote.id(), local.id());
let sig = repo.signature()?;
let local_commit = repo.find_commit(local.id())?;
let remote_commit = repo.find_commit(remote.id())?;
// Do our merge commit and set current branch head to that commit.
let _merge_commit = repo.commit(
Some("HEAD"),
&sig,
&sig,
&msg,
&result_tree,
&[&local_commit, &remote_commit],
)?;
// Set working tree to match head.
repo.checkout_head(None)?;
Ok(())
}
fn print_conflict(conflict: &git2::IndexConflict) {
let ancestor = conflict.ancestor.as_ref().map(path_from_index_entry);
let ours = conflict.our.as_ref().map(path_from_index_entry);
let theirs = conflict.their.as_ref().map(path_from_index_entry);
println!("Conflict detected:");
if let Some(path) = ancestor {
println!(" Common ancestor: {:?}", path);
}
if let Some(path) = ours {
println!(" Ours: {:?}", path);
}
if let Some(path) = theirs {
println!(" Theirs: {:?}", path);
}
}
fn path_from_index_entry(entry: &IndexEntry) -> String {
String::from_utf8_lossy(entry.path.as_slice()).into_owned()
}
-4
View File
@@ -47,10 +47,6 @@ pub(crate) fn remote_branch_names(repo: &Repository) -> Result<Vec<String>> {
Ok(branches) Ok(branches)
} }
pub(crate) fn get_branch_by_name<'s>(repo: &'s Repository, name: &str) -> Result<Branch<'s>> {
Ok(repo.find_branch(name, BranchType::Local)?)
}
pub(crate) fn bytes_to_string(bytes: &[u8]) -> Result<String> { pub(crate) fn bytes_to_string(bytes: &[u8]) -> Result<String> {
Ok(String::from_utf8(bytes.to_vec())?) Ok(String::from_utf8(bytes.to_vec())?)
} }
+28 -3
View File
@@ -31,7 +31,14 @@ pub enum HttpResponseEvent {
}, },
SendUrl { SendUrl {
method: String, method: String,
scheme: String,
username: String,
password: String,
host: String,
port: u16,
path: String, path: String,
query: String,
fragment: String,
}, },
ReceiveUrl { ReceiveUrl {
version: Version, version: Version,
@@ -65,7 +72,16 @@ impl Display for HttpResponseEvent {
}; };
write!(f, "* Redirect {} -> {} ({})", status, url, behavior_str) 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 } => { HttpResponseEvent::ReceiveUrl { version, status } => {
write!(f, "< {} {}", version_to_str(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(), 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 } => { HttpResponseEvent::ReceiveUrl { version, status } => {
D::ReceiveUrl { version: format!("{:?}", version), status } D::ReceiveUrl { version: format!("{:?}", version), status }
} }
@@ -415,8 +433,15 @@ impl HttpSender for ReqwestSender {
)); ));
send_event(HttpResponseEvent::SendUrl { send_event(HttpResponseEvent::SendUrl {
path: sendable_req.url().path().to_string(),
method: sendable_req.method().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(); let mut request_headers = Vec::new();
+1 -1
View File
@@ -49,7 +49,7 @@ export type HttpResponseEvent = { model: "http_response_event", id: string, crea
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support. * This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
* The `From` impl is in yaak-http to avoid circular dependencies. * The `From` impl is in yaak-http to avoid circular dependencies.
*/ */
export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, path: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array<string>, duration: bigint, overridden: boolean, }; export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, 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, }; export type HttpResponseHeader = { name: string, value: string, };
+14
View File
@@ -1495,7 +1495,21 @@ pub enum HttpResponseEventData {
}, },
SendUrl { SendUrl {
method: String, method: String,
#[serde(default)]
scheme: String,
#[serde(default)]
username: String,
#[serde(default)]
password: String,
#[serde(default)]
host: String,
#[serde(default)]
port: u16,
path: String, path: String,
#[serde(default)]
query: String,
#[serde(default)]
fragment: String,
}, },
ReceiveUrl { ReceiveUrl {
version: String, version: String,
+82 -41
View File
@@ -63,7 +63,7 @@
"src-web" "src-web"
], ],
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.3.10", "@biomejs/biome": "^2.3.13",
"@tauri-apps/cli": "^2.9.6", "@tauri-apps/cli": "^2.9.6",
"@yaakapp/cli": "^0.3.4", "@yaakapp/cli": "^0.3.4",
"dotenv-cli": "^11.0.0", "dotenv-cli": "^11.0.0",
@@ -501,9 +501,9 @@
} }
}, },
"node_modules/@biomejs/biome": { "node_modules/@biomejs/biome": {
"version": "2.3.11", "version": "2.3.13",
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.11.tgz", "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.13.tgz",
"integrity": "sha512-/zt+6qazBWguPG6+eWmiELqO+9jRsMZ/DBU3lfuU2ngtIQYzymocHhKiZRyrbra4aCOoyTg/BmY+6WH5mv9xmQ==", "integrity": "sha512-Fw7UsV0UAtWIBIm0M7g5CRerpu1eKyKAXIazzxhbXYUyMkwNrkX/KLkGI7b+uVDQ5cLUMfOC9vR60q9IDYDstA==",
"dev": true, "dev": true,
"license": "MIT OR Apache-2.0", "license": "MIT OR Apache-2.0",
"bin": { "bin": {
@@ -517,20 +517,20 @@
"url": "https://opencollective.com/biome" "url": "https://opencollective.com/biome"
}, },
"optionalDependencies": { "optionalDependencies": {
"@biomejs/cli-darwin-arm64": "2.3.11", "@biomejs/cli-darwin-arm64": "2.3.13",
"@biomejs/cli-darwin-x64": "2.3.11", "@biomejs/cli-darwin-x64": "2.3.13",
"@biomejs/cli-linux-arm64": "2.3.11", "@biomejs/cli-linux-arm64": "2.3.13",
"@biomejs/cli-linux-arm64-musl": "2.3.11", "@biomejs/cli-linux-arm64-musl": "2.3.13",
"@biomejs/cli-linux-x64": "2.3.11", "@biomejs/cli-linux-x64": "2.3.13",
"@biomejs/cli-linux-x64-musl": "2.3.11", "@biomejs/cli-linux-x64-musl": "2.3.13",
"@biomejs/cli-win32-arm64": "2.3.11", "@biomejs/cli-win32-arm64": "2.3.13",
"@biomejs/cli-win32-x64": "2.3.11" "@biomejs/cli-win32-x64": "2.3.13"
} }
}, },
"node_modules/@biomejs/cli-darwin-arm64": { "node_modules/@biomejs/cli-darwin-arm64": {
"version": "2.3.11", "version": "2.3.13",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.11.tgz", "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.13.tgz",
"integrity": "sha512-/uXXkBcPKVQY7rc9Ys2CrlirBJYbpESEDme7RKiBD6MmqR2w3j0+ZZXRIL2xiaNPsIMMNhP1YnA+jRRxoOAFrA==", "integrity": "sha512-0OCwP0/BoKzyJHnFdaTk/i7hIP9JHH9oJJq6hrSCPmJPo8JWcJhprK4gQlhFzrwdTBAW4Bjt/RmCf3ZZe59gwQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -545,9 +545,9 @@
} }
}, },
"node_modules/@biomejs/cli-darwin-x64": { "node_modules/@biomejs/cli-darwin-x64": {
"version": "2.3.11", "version": "2.3.13",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.11.tgz", "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.13.tgz",
"integrity": "sha512-fh7nnvbweDPm2xEmFjfmq7zSUiox88plgdHF9OIW4i99WnXrAC3o2P3ag9judoUMv8FCSUnlwJCM1B64nO5Fbg==", "integrity": "sha512-AGr8OoemT/ejynbIu56qeil2+F2WLkIjn2d8jGK1JkchxnMUhYOfnqc9sVzcRxpG9Ycvw4weQ5sprRvtb7Yhcw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -562,9 +562,9 @@
} }
}, },
"node_modules/@biomejs/cli-linux-arm64": { "node_modules/@biomejs/cli-linux-arm64": {
"version": "2.3.11", "version": "2.3.13",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.11.tgz", "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.13.tgz",
"integrity": "sha512-l4xkGa9E7Uc0/05qU2lMYfN1H+fzzkHgaJoy98wO+b/7Gl78srbCRRgwYSW+BTLixTBrM6Ede5NSBwt7rd/i6g==", "integrity": "sha512-xvOiFkrDNu607MPMBUQ6huHmBG1PZLOrqhtK6pXJW3GjfVqJg0Z/qpTdhXfcqWdSZHcT+Nct2fOgewZvytESkw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -579,9 +579,9 @@
} }
}, },
"node_modules/@biomejs/cli-linux-arm64-musl": { "node_modules/@biomejs/cli-linux-arm64-musl": {
"version": "2.3.11", "version": "2.3.13",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.11.tgz", "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.13.tgz",
"integrity": "sha512-XPSQ+XIPZMLaZ6zveQdwNjbX+QdROEd1zPgMwD47zvHV+tCGB88VH+aynyGxAHdzL+Tm/+DtKST5SECs4iwCLg==", "integrity": "sha512-TUdDCSY+Eo/EHjhJz7P2GnWwfqet+lFxBZzGHldrvULr59AgahamLs/N85SC4+bdF86EhqDuuw9rYLvLFWWlXA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -596,9 +596,9 @@
} }
}, },
"node_modules/@biomejs/cli-linux-x64": { "node_modules/@biomejs/cli-linux-x64": {
"version": "2.3.11", "version": "2.3.13",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.11.tgz", "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.13.tgz",
"integrity": "sha512-/1s9V/H3cSe0r0Mv/Z8JryF5x9ywRxywomqZVLHAoa/uN0eY7F8gEngWKNS5vbbN/BsfpCG5yeBT5ENh50Frxg==", "integrity": "sha512-s+YsZlgiXNq8XkgHs6xdvKDFOj/bwTEevqEY6rC2I3cBHbxXYU1LOZstH3Ffw9hE5tE1sqT7U23C00MzkXztMw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -613,9 +613,9 @@
} }
}, },
"node_modules/@biomejs/cli-linux-x64-musl": { "node_modules/@biomejs/cli-linux-x64-musl": {
"version": "2.3.11", "version": "2.3.13",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.11.tgz", "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.13.tgz",
"integrity": "sha512-vU7a8wLs5C9yJ4CB8a44r12aXYb8yYgBn+WeyzbMjaCMklzCv1oXr8x+VEyWodgJt9bDmhiaW/I0RHbn7rsNmw==", "integrity": "sha512-0bdwFVSbbM//Sds6OjtnmQGp4eUjOTt6kHvR/1P0ieR9GcTUAlPNvPC3DiavTqq302W34Ae2T6u5VVNGuQtGlQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -630,9 +630,9 @@
} }
}, },
"node_modules/@biomejs/cli-win32-arm64": { "node_modules/@biomejs/cli-win32-arm64": {
"version": "2.3.11", "version": "2.3.13",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.11.tgz", "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.13.tgz",
"integrity": "sha512-PZQ6ElCOnkYapSsysiTy0+fYX+agXPlWugh6+eQ6uPKI3vKAqNp6TnMhoM3oY2NltSB89hz59o8xIfOdyhi9Iw==", "integrity": "sha512-QweDxY89fq0VvrxME+wS/BXKmqMrOTZlN9SqQ79kQSIc3FrEwvW/PvUegQF6XIVaekncDykB5dzPqjbwSKs9DA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -647,9 +647,9 @@
} }
}, },
"node_modules/@biomejs/cli-win32-x64": { "node_modules/@biomejs/cli-win32-x64": {
"version": "2.3.11", "version": "2.3.13",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.11.tgz", "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.13.tgz",
"integrity": "sha512-43VrG813EW+b5+YbDbz31uUsheX+qFKCpXeY9kfdAx+ww3naKxeVkTD9zLIWxUPfJquANMHrmW3wbe/037G0Qg==", "integrity": "sha512-trDw2ogdM2lyav9WFQsdsfdVy1dvZALymRpgmWsvSez0BJzBjulhOT/t+wyKeh3pZWvwP3VMs1SoOKwO3wecMQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -807,6 +807,21 @@
"@lezer/xml": "^1.0.0" "@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": { "node_modules/@codemirror/language": {
"version": "6.12.1", "version": "6.12.1",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.1.tgz", "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.1.tgz",
@@ -832,6 +847,19 @@
"crelt": "^1.0.5" "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": { "node_modules/@codemirror/search": {
"version": "6.5.11", "version": "6.5.11",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.11.tgz", "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.11.tgz",
@@ -1614,6 +1642,17 @@
"@lezer/lr": "^1.0.0" "@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": { "node_modules/@marijn/find-cluster-break": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
@@ -7811,9 +7850,9 @@
} }
}, },
"node_modules/hono": { "node_modules/hono": {
"version": "4.11.4", "version": "4.11.7",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz", "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.7.tgz",
"integrity": "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==", "integrity": "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=16.9.0" "node": ">=16.9.0"
@@ -15721,7 +15760,7 @@
}, },
"packages/plugin-runtime-types": { "packages/plugin-runtime-types": {
"name": "@yaakapp/api", "name": "@yaakapp/api",
"version": "0.7.1", "version": "0.8.0",
"dependencies": { "dependencies": {
"@types/node": "^24.0.13" "@types/node": "^24.0.13"
}, },
@@ -15743,7 +15782,7 @@
"@hono/mcp": "^0.2.3", "@hono/mcp": "^0.2.3",
"@hono/node-server": "^1.19.7", "@hono/node-server": "^1.19.7",
"@modelcontextprotocol/sdk": "^1.25.2", "@modelcontextprotocol/sdk": "^1.25.2",
"hono": "^4.11.4", "hono": "^4.11.7",
"zod": "^3.25.76" "zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
@@ -15984,7 +16023,9 @@
"@codemirror/lang-json": "^6.0.1", "@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-markdown": "^6.3.2", "@codemirror/lang-markdown": "^6.3.2",
"@codemirror/lang-xml": "^6.1.0", "@codemirror/lang-xml": "^6.1.0",
"@codemirror/lang-yaml": "^6.1.2",
"@codemirror/language": "^6.11.0", "@codemirror/language": "^6.11.0",
"@codemirror/merge": "^6.11.2",
"@codemirror/search": "^6.5.11", "@codemirror/search": "^6.5.11",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@gilbarbara/deep-equal": "^0.3.1", "@gilbarbara/deep-equal": "^0.3.1",
+1 -1
View File
@@ -95,7 +95,7 @@
"js-yaml": "^4.1.1" "js-yaml": "^4.1.1"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.3.10", "@biomejs/biome": "^2.3.13",
"@tauri-apps/cli": "^2.9.6", "@tauri-apps/cli": "^2.9.6",
"@yaakapp/cli": "^0.3.4", "@yaakapp/cli": "^0.3.4",
"dotenv-cli": "^11.0.0", "dotenv-cli": "^11.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@yaakapp/api", "name": "@yaakapp/api",
"version": "0.7.1", "version": "0.8.0",
"keywords": [ "keywords": [
"api-client", "api-client",
"insomnia-alternative", "insomnia-alternative",
@@ -25,7 +25,7 @@ import type {
TemplateRenderRequest, TemplateRenderRequest,
WorkspaceInfo, WorkspaceInfo,
} from '../bindings/gen_events.ts'; } from '../bindings/gen_events.ts';
import type { HttpRequest } from '../bindings/gen_models.ts'; import type { Folder, HttpRequest } from '../bindings/gen_models.ts';
import type { JsonValue } from '../bindings/serde_json/JsonValue'; import type { JsonValue } from '../bindings/serde_json/JsonValue';
export type WorkspaceHandle = Pick<WorkspaceInfo, 'id' | 'name'>; export type WorkspaceHandle = Pick<WorkspaceInfo, 'id' | 'name'>;
@@ -82,6 +82,15 @@ export interface Context {
}; };
folder: { folder: {
list(args?: ListFoldersRequest): Promise<ListFoldersResponse['folders']>; list(args?: ListFoldersRequest): Promise<ListFoldersResponse['folders']>;
getById(args: { id: string }): Promise<Folder | null>;
create(
args: Omit<Partial<Folder>, 'id' | 'model' | 'createdAt' | 'updatedAt'> &
Pick<Folder, 'workspaceId' | 'name'>,
): Promise<Folder>;
update(
args: Omit<Partial<Folder>, 'model' | 'createdAt' | 'updatedAt'> & Pick<Folder, 'id'>,
): Promise<Folder>;
delete(args: { id: string }): Promise<Folder>;
}; };
httpResponse: { httpResponse: {
find(args: FindHttpResponsesRequest): Promise<FindHttpResponsesResponse['httpResponses']>; find(args: FindHttpResponsesRequest): Promise<FindHttpResponsesResponse['httpResponses']>;
+41 -2
View File
@@ -11,6 +11,7 @@ import type {
DeleteKeyValueResponse, DeleteKeyValueResponse,
DeleteModelResponse, DeleteModelResponse,
FindHttpResponsesResponse, FindHttpResponsesResponse,
Folder,
GetCookieValueRequest, GetCookieValueRequest,
GetCookieValueResponse, GetCookieValueResponse,
GetHttpRequestByIdResponse, GetHttpRequestByIdResponse,
@@ -337,8 +338,8 @@ export class PluginInstance {
if (payload.type === 'call_http_authentication_request' && this.#mod?.authentication) { if (payload.type === 'call_http_authentication_request' && this.#mod?.authentication) {
const auth = this.#mod.authentication; const auth = this.#mod.authentication;
if (typeof auth?.onApply === 'function') { if (typeof auth?.onApply === 'function') {
auth.args = await applyDynamicFormInput(ctx, auth.args, payload); const resolvedArgs = await applyDynamicFormInput(ctx, auth.args, payload);
payload.values = applyFormInputDefaults(auth.args, payload.values); payload.values = applyFormInputDefaults(resolvedArgs, payload.values);
this.#sendPayload( this.#sendPayload(
context, context,
{ {
@@ -782,6 +783,44 @@ export class PluginInstance {
const { folders } = await this.#sendForReply<ListFoldersResponse>(context, payload); const { folders } = await this.#sendForReply<ListFoldersResponse>(context, payload);
return folders; return folders;
}, },
getById: async (args: { id: string }) => {
const payload = { type: 'list_folders_request' } as const;
const { folders } = await this.#sendForReply<ListFoldersResponse>(context, payload);
return folders.find((f) => f.id === args.id) ?? null;
},
create: async (args) => {
const payload = {
type: 'upsert_model_request',
model: {
name: '',
...args,
id: '',
model: 'folder',
},
} as InternalEventPayload;
const response = await this.#sendForReply<UpsertModelResponse>(context, payload);
return response.model as Folder;
},
update: async (args) => {
const payload = {
type: 'upsert_model_request',
model: {
model: 'folder',
...args,
},
} as InternalEventPayload;
const response = await this.#sendForReply<UpsertModelResponse>(context, payload);
return response.model as Folder;
},
delete: async (args: { id: string }) => {
const payload = {
type: 'delete_model_request',
model: 'folder',
id: args.id,
} as InternalEventPayload;
const response = await this.#sendForReply<DeleteModelResponse>(context, payload);
return response.model as Folder;
},
}, },
cookies: { cookies: {
getValue: async (args: GetCookieValueRequest) => { getValue: async (args: GetCookieValueRequest) => {
+1 -1
View File
@@ -18,7 +18,7 @@
"@hono/mcp": "^0.2.3", "@hono/mcp": "^0.2.3",
"@hono/node-server": "^1.19.7", "@hono/node-server": "^1.19.7",
"@modelcontextprotocol/sdk": "^1.25.2", "@modelcontextprotocol/sdk": "^1.25.2",
"hono": "^4.11.4", "hono": "^4.11.7",
"zod": "^3.25.76" "zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
+119 -4
View File
@@ -2,6 +2,12 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import * as z from 'zod'; import * as z from 'zod';
import type { McpServerContext } from '../types.js'; import type { McpServerContext } from '../types.js';
import { getWorkspaceContext } from './helpers.js'; import { getWorkspaceContext } from './helpers.js';
import {
authenticationSchema,
authenticationTypeSchema,
headersSchema,
workspaceIdSchema,
} from './schemas.js';
export function registerFolderTools(server: McpServer, ctx: McpServerContext) { export function registerFolderTools(server: McpServer, ctx: McpServerContext) {
server.registerTool( server.registerTool(
@@ -10,10 +16,7 @@ export function registerFolderTools(server: McpServer, ctx: McpServerContext) {
title: 'List Folders', title: 'List Folders',
description: 'List all folders in a workspace', description: 'List all folders in a workspace',
inputSchema: { inputSchema: {
workspaceId: z workspaceId: workspaceIdSchema,
.string()
.optional()
.describe('Workspace ID (required if multiple workspaces are open)'),
}, },
}, },
async ({ workspaceId }) => { async ({ workspaceId }) => {
@@ -30,4 +33,116 @@ export function registerFolderTools(server: McpServer, ctx: McpServerContext) {
}; };
}, },
); );
server.registerTool(
'get_folder',
{
title: 'Get Folder',
description: 'Get details of a specific folder by ID',
inputSchema: {
id: z.string().describe('The folder ID'),
workspaceId: workspaceIdSchema,
},
},
async ({ id, workspaceId }) => {
const workspaceCtx = await getWorkspaceContext(ctx, workspaceId);
const folder = await workspaceCtx.yaak.folder.getById({ id });
return {
content: [
{
type: 'text' as const,
text: JSON.stringify(folder, null, 2),
},
],
};
},
);
server.registerTool(
'create_folder',
{
title: 'Create Folder',
description: 'Create a new folder in a workspace',
inputSchema: {
workspaceId: workspaceIdSchema,
name: z.string().describe('Folder name'),
folderId: z.string().optional().describe('Parent folder ID (for nested folders)'),
description: z.string().optional().describe('Folder description'),
sortPriority: z.number().optional().describe('Sort priority for ordering'),
headers: headersSchema.describe('Default headers to apply to requests in this folder'),
authenticationType: authenticationTypeSchema,
authentication: authenticationSchema,
},
},
async ({ workspaceId: ogWorkspaceId, ...args }) => {
const workspaceCtx = await getWorkspaceContext(ctx, ogWorkspaceId);
const workspaceId = await workspaceCtx.yaak.window.workspaceId();
if (!workspaceId) {
throw new Error('No workspace is open');
}
const folder = await workspaceCtx.yaak.folder.create({
workspaceId: workspaceId,
...args,
});
return {
content: [{ type: 'text' as const, text: JSON.stringify(folder, null, 2) }],
};
},
);
server.registerTool(
'update_folder',
{
title: 'Update Folder',
description: 'Update an existing folder',
inputSchema: {
id: z.string().describe('Folder ID to update'),
workspaceId: workspaceIdSchema,
name: z.string().optional().describe('Folder name'),
folderId: z.string().optional().describe('Parent folder ID (for nested folders)'),
description: z.string().optional().describe('Folder description'),
sortPriority: z.number().optional().describe('Sort priority for ordering'),
headers: headersSchema.describe('Default headers to apply to requests in this folder'),
authenticationType: authenticationTypeSchema,
authentication: authenticationSchema,
},
},
async ({ id, workspaceId, ...updates }) => {
const workspaceCtx = await getWorkspaceContext(ctx, workspaceId);
// Fetch existing folder to merge with updates
const existing = await workspaceCtx.yaak.folder.getById({ id });
if (!existing) {
throw new Error(`Folder with ID ${id} not found`);
}
// Merge existing fields with updates
const folder = await workspaceCtx.yaak.folder.update({
...existing,
...updates,
id,
});
return {
content: [{ type: 'text' as const, text: JSON.stringify(folder, null, 2) }],
};
},
);
server.registerTool(
'delete_folder',
{
title: 'Delete Folder',
description: 'Delete a folder by ID',
inputSchema: {
id: z.string().describe('Folder ID to delete'),
},
},
async ({ id }) => {
const folder = await ctx.yaak.folder.delete({ id });
return {
content: [{ type: 'text' as const, text: `Deleted: ${folder.name} (${folder.id})` }],
};
},
);
} }
@@ -2,6 +2,15 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import * as z from 'zod'; import * as z from 'zod';
import type { McpServerContext } from '../types.js'; import type { McpServerContext } from '../types.js';
import { getWorkspaceContext } from './helpers.js'; import { getWorkspaceContext } from './helpers.js';
import {
authenticationSchema,
authenticationTypeSchema,
bodySchema,
bodyTypeSchema,
headersSchema,
urlParametersSchema,
workspaceIdSchema,
} from './schemas.js';
export function registerHttpRequestTools(server: McpServer, ctx: McpServerContext) { export function registerHttpRequestTools(server: McpServer, ctx: McpServerContext) {
server.registerTool( server.registerTool(
@@ -10,10 +19,7 @@ export function registerHttpRequestTools(server: McpServer, ctx: McpServerContex
title: 'List HTTP Requests', title: 'List HTTP Requests',
description: 'List all HTTP requests in a workspace', description: 'List all HTTP requests in a workspace',
inputSchema: { inputSchema: {
workspaceId: z workspaceId: workspaceIdSchema,
.string()
.optional()
.describe('Workspace ID (required if multiple workspaces are open)'),
}, },
}, },
async ({ workspaceId }) => { async ({ workspaceId }) => {
@@ -38,10 +44,7 @@ export function registerHttpRequestTools(server: McpServer, ctx: McpServerContex
description: 'Get details of a specific HTTP request by ID', description: 'Get details of a specific HTTP request by ID',
inputSchema: { inputSchema: {
id: z.string().describe('The HTTP request ID'), id: z.string().describe('The HTTP request ID'),
workspaceId: z workspaceId: workspaceIdSchema,
.string()
.optional()
.describe('Workspace ID (required if multiple workspaces are open)'),
}, },
}, },
async ({ id, workspaceId }) => { async ({ id, workspaceId }) => {
@@ -67,10 +70,7 @@ export function registerHttpRequestTools(server: McpServer, ctx: McpServerContex
inputSchema: { inputSchema: {
id: z.string().describe('The HTTP request ID to send'), id: z.string().describe('The HTTP request ID to send'),
environmentId: z.string().optional().describe('Optional environment ID to use'), environmentId: z.string().optional().describe('Optional environment ID to use'),
workspaceId: z workspaceId: workspaceIdSchema,
.string()
.optional()
.describe('Workspace ID (required if multiple workspaces are open)'),
}, },
}, },
async ({ id, workspaceId }) => { async ({ id, workspaceId }) => {
@@ -99,10 +99,7 @@ export function registerHttpRequestTools(server: McpServer, ctx: McpServerContex
title: 'Create HTTP Request', title: 'Create HTTP Request',
description: 'Create a new HTTP request', description: 'Create a new HTTP request',
inputSchema: { inputSchema: {
workspaceId: z workspaceId: workspaceIdSchema,
.string()
.optional()
.describe('Workspace ID (required if multiple workspaces are open)'),
name: z name: z
.string() .string()
.optional() .optional()
@@ -111,62 +108,12 @@ export function registerHttpRequestTools(server: McpServer, ctx: McpServerContex
method: z.string().optional().describe('HTTP method (defaults to GET)'), method: z.string().optional().describe('HTTP method (defaults to GET)'),
folderId: z.string().optional().describe('Parent folder ID'), folderId: z.string().optional().describe('Parent folder ID'),
description: z.string().optional().describe('Request description'), description: z.string().optional().describe('Request description'),
headers: z headers: headersSchema.describe('Request headers'),
.array( urlParameters: urlParametersSchema,
z.object({ bodyType: bodyTypeSchema,
name: z.string(), body: bodySchema,
value: z.string(), authenticationType: authenticationTypeSchema,
enabled: z.boolean().default(true), authentication: authenticationSchema,
}),
)
.optional()
.describe('Request headers'),
urlParameters: z
.array(
z.object({
name: z.string(),
value: z.string(),
enabled: z.boolean().default(true),
}),
)
.optional()
.describe('URL query parameters'),
bodyType: z
.string()
.optional()
.describe(
'Body type. Supported values: "binary", "graphql", "application/x-www-form-urlencoded", "multipart/form-data", or any text-based type (e.g., "application/json", "text/plain")',
),
body: z
.record(z.string(), z.any())
.optional()
.describe(
'Body content object. Structure varies by bodyType:\n' +
'- "binary": { filePath: "/path/to/file" }\n' +
'- "graphql": { query: "{ users { name } }", variables: "{\\"id\\": \\"123\\"}" }\n' +
'- "application/x-www-form-urlencoded": { form: [{ name: "key", value: "val", enabled: true }] }\n' +
'- "multipart/form-data": { form: [{ name: "field", value: "text", file: "/path/to/file", enabled: true }] }\n' +
'- text-based (application/json, etc.): { text: "raw body content" }',
),
authenticationType: z
.string()
.optional()
.describe(
'Authentication type. Common values: "basic", "bearer", "oauth2", "apikey", "jwt", "awsv4", "oauth1", "ntlm", "none". Use null to inherit from parent folder/workspace.',
),
authentication: z
.record(z.string(), z.any())
.optional()
.describe(
'Authentication configuration object. Structure varies by authenticationType:\n' +
'- "basic": { username: "user", password: "pass" }\n' +
'- "bearer": { token: "abc123", prefix: "Bearer" }\n' +
'- "oauth2": { clientId: "...", clientSecret: "...", grantType: "authorization_code", authorizationUrl: "...", accessTokenUrl: "...", scope: "...", ... }\n' +
'- "apikey": { location: "header" | "query", key: "X-API-Key", value: "..." }\n' +
'- "jwt": { algorithm: "HS256", secret: "...", payload: "{ ... }" }\n' +
'- "awsv4": { accessKeyId: "...", secretAccessKey: "...", service: "sts", region: "us-east-1", sessionToken: "..." }\n' +
'- "none": {}',
),
}, },
}, },
async ({ workspaceId: ogWorkspaceId, ...args }) => { async ({ workspaceId: ogWorkspaceId, ...args }) => {
@@ -194,68 +141,18 @@ export function registerHttpRequestTools(server: McpServer, ctx: McpServerContex
description: 'Update an existing HTTP request', description: 'Update an existing HTTP request',
inputSchema: { inputSchema: {
id: z.string().describe('HTTP request ID to update'), id: z.string().describe('HTTP request ID to update'),
workspaceId: z.string().describe('Workspace ID'), workspaceId: workspaceIdSchema,
name: z.string().optional().describe('Request name'), name: z.string().optional().describe('Request name'),
url: z.string().optional().describe('Request URL'), url: z.string().optional().describe('Request URL'),
method: z.string().optional().describe('HTTP method'), method: z.string().optional().describe('HTTP method'),
folderId: z.string().optional().describe('Parent folder ID'), folderId: z.string().optional().describe('Parent folder ID'),
description: z.string().optional().describe('Request description'), description: z.string().optional().describe('Request description'),
headers: z headers: headersSchema.describe('Request headers'),
.array( urlParameters: urlParametersSchema,
z.object({ bodyType: bodyTypeSchema,
name: z.string(), body: bodySchema,
value: z.string(), authenticationType: authenticationTypeSchema,
enabled: z.boolean().default(true), authentication: authenticationSchema,
}),
)
.optional()
.describe('Request headers'),
urlParameters: z
.array(
z.object({
name: z.string(),
value: z.string(),
enabled: z.boolean().default(true),
}),
)
.optional()
.describe('URL query parameters'),
bodyType: z
.string()
.optional()
.describe(
'Body type. Supported values: "binary", "graphql", "application/x-www-form-urlencoded", "multipart/form-data", or any text-based type (e.g., "application/json", "text/plain")',
),
body: z
.record(z.string(), z.any())
.optional()
.describe(
'Body content object. Structure varies by bodyType:\n' +
'- "binary": { filePath: "/path/to/file" }\n' +
'- "graphql": { query: "{ users { name } }", variables: "{\\"id\\": \\"123\\"}" }\n' +
'- "application/x-www-form-urlencoded": { form: [{ name: "key", value: "val", enabled: true }] }\n' +
'- "multipart/form-data": { form: [{ name: "field", value: "text", file: "/path/to/file", enabled: true }] }\n' +
'- text-based (application/json, etc.): { text: "raw body content" }',
),
authenticationType: z
.string()
.optional()
.describe(
'Authentication type. Common values: "basic", "bearer", "oauth2", "apikey", "jwt", "awsv4", "oauth1", "ntlm", "none". Use null to inherit from parent folder/workspace.',
),
authentication: z
.record(z.string(), z.any())
.optional()
.describe(
'Authentication configuration object. Structure varies by authenticationType:\n' +
'- "basic": { username: "user", password: "pass" }\n' +
'- "bearer": { token: "abc123", prefix: "Bearer" }\n' +
'- "oauth2": { clientId: "...", clientSecret: "...", grantType: "authorization_code", authorizationUrl: "...", accessTokenUrl: "...", scope: "...", ... }\n' +
'- "apikey": { location: "header" | "query", key: "X-API-Key", value: "..." }\n' +
'- "jwt": { algorithm: "HS256", secret: "...", payload: "{ ... }" }\n' +
'- "awsv4": { accessKeyId: "...", secretAccessKey: "...", service: "sts", region: "us-east-1", sessionToken: "..." }\n' +
'- "none": {}',
),
}, },
}, },
async ({ id, workspaceId, ...updates }) => { async ({ id, workspaceId, ...updates }) => {
@@ -0,0 +1,67 @@
import * as z from 'zod';
export const workspaceIdSchema = z
.string()
.optional()
.describe('Workspace ID (required if multiple workspaces are open)');
export const headersSchema = z
.array(
z.object({
name: z.string(),
value: z.string(),
enabled: z.boolean().default(true),
}),
)
.optional();
export const urlParametersSchema = z
.array(
z.object({
name: z.string(),
value: z.string(),
enabled: z.boolean().default(true),
}),
)
.optional()
.describe('URL query parameters');
export const bodyTypeSchema = z
.string()
.optional()
.describe(
'Body type. Supported values: "binary", "graphql", "application/x-www-form-urlencoded", "multipart/form-data", or any text-based type (e.g., "application/json", "text/plain")',
);
export const bodySchema = z
.record(z.string(), z.any())
.optional()
.describe(
'Body content object. Structure varies by bodyType:\n' +
'- "binary": { filePath: "/path/to/file" }\n' +
'- "graphql": { query: "{ users { name } }", variables: "{\\"id\\": \\"123\\"}" }\n' +
'- "application/x-www-form-urlencoded": { form: [{ name: "key", value: "val", enabled: true }] }\n' +
'- "multipart/form-data": { form: [{ name: "field", value: "text", file: "/path/to/file", enabled: true }] }\n' +
'- text-based (application/json, etc.): { text: "raw body content" }',
);
export const authenticationTypeSchema = z
.string()
.optional()
.describe(
'Authentication type. Common values: "basic", "bearer", "oauth2", "apikey", "jwt", "awsv4", "oauth1", "ntlm", "none". Use null to inherit from parent.',
);
export const authenticationSchema = z
.record(z.string(), z.any())
.optional()
.describe(
'Authentication configuration object. Structure varies by authenticationType:\n' +
'- "basic": { username: "user", password: "pass" }\n' +
'- "bearer": { token: "abc123", prefix: "Bearer" }\n' +
'- "oauth2": { clientId: "...", clientSecret: "...", grantType: "authorization_code", authorizationUrl: "...", accessTokenUrl: "...", scope: "...", ... }\n' +
'- "apikey": { location: "header" | "query", key: "X-API-Key", value: "..." }\n' +
'- "jwt": { algorithm: "HS256", secret: "...", payload: "{ ... }" }\n' +
'- "awsv4": { accessKeyId: "...", secretAccessKey: "...", service: "sts", region: "us-east-1", sessionToken: "..." }\n' +
'- "none": {}',
);
+2 -1
View File
@@ -11,6 +11,7 @@
"version": "0.1.0", "version": "0.1.0",
"scripts": { "scripts": {
"build": "yaakcli build", "build": "yaakcli build",
"dev": "yaakcli dev" "dev": "yaakcli dev",
"test": "vitest --run tests"
} }
} }
+2 -1
View File
@@ -21,7 +21,8 @@ export const plugin: PluginDefinition = {
}, },
], ],
async onApply(_ctx, { values }) { 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')}`; const value = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
return { setHeaders: [{ name: 'Authorization', value }] }; return { setHeaders: [{ name: 'Authorization', value }] };
}, },
+77
View 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
View 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 { createHash, randomBytes } from 'node:crypto';
import type { Context } from '@yaakapp/api'; import type { Context } from '@yaakapp/api';
import { getRedirectUrlViaExternalBrowser } from '../callbackServer';
import { fetchAccessToken } from '../fetchAccessToken'; import { fetchAccessToken } from '../fetchAccessToken';
import { getOrRefreshAccessToken } from '../getOrRefreshAccessToken'; import { getOrRefreshAccessToken } from '../getOrRefreshAccessToken';
import type { AccessToken, TokenStoreArgs } from '../store'; import type { AccessToken, TokenStoreArgs } from '../store';
@@ -10,6 +11,15 @@ export const PKCE_SHA256 = 'S256';
export const PKCE_PLAIN = 'plain'; export const PKCE_PLAIN = 'plain';
export const DEFAULT_PKCE_METHOD = PKCE_SHA256; 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( export async function getAuthorizationCode(
ctx: Context, ctx: Context,
contextId: string, contextId: string,
@@ -25,6 +35,7 @@ export async function getAuthorizationCode(
credentialsInBody, credentialsInBody,
pkce, pkce,
tokenName, tokenName,
externalBrowser,
}: { }: {
authorizationUrl: string; authorizationUrl: string;
accessTokenUrl: string; accessTokenUrl: string;
@@ -40,6 +51,7 @@ export async function getAuthorizationCode(
codeVerifier: string; codeVerifier: string;
} | null; } | null;
tokenName: 'access_token' | 'id_token'; tokenName: 'access_token' | 'id_token';
externalBrowser?: ExternalBrowserOptions;
}, },
): Promise<AccessToken> { ): Promise<AccessToken> {
const tokenArgs: TokenStoreArgs = { const tokenArgs: TokenStoreArgs = {
@@ -68,7 +80,6 @@ export async function getAuthorizationCode(
} }
authorizationUrl.searchParams.set('response_type', 'code'); authorizationUrl.searchParams.set('response_type', 'code');
authorizationUrl.searchParams.set('client_id', clientId); authorizationUrl.searchParams.set('client_id', clientId);
if (redirectUri) authorizationUrl.searchParams.set('redirect_uri', redirectUri);
if (scope) authorizationUrl.searchParams.set('scope', scope); if (scope) authorizationUrl.searchParams.set('scope', scope);
if (state) authorizationUrl.searchParams.set('state', state); if (state) authorizationUrl.searchParams.set('state', state);
if (audience) authorizationUrl.searchParams.set('audience', audience); if (audience) authorizationUrl.searchParams.set('audience', audience);
@@ -80,12 +91,65 @@ export async function getAuthorizationCode(
authorizationUrl.searchParams.set('code_challenge_method', pkce.challengeMethod); 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 dataDirKey = await getDataDirKey(ctx, contextId);
const authorizationUrlStr = authorizationUrl.toString(); const authorizationUrlStr = authorizationUrl.toString();
console.log('[oauth2] Authorizing', authorizationUrlStr); console.log('[oauth2] Authorizing via embedded browser', authorizationUrlStr);
// biome-ignore lint/suspicious/noAsyncPromiseExecutor: none // biome-ignore lint/suspicious/noAsyncPromiseExecutor: Required for this pattern
const code = await new Promise<string>(async (resolve, reject) => { return new Promise<string>(async (resolve, reject) => {
let foundCode = false; let foundCode = false;
const { close } = await ctx.window.openUrl({ const { close } = await ctx.window.openUrl({
dataDirKey, dataDirKey,
@@ -110,31 +174,12 @@ export async function getAuthorizationCode(
return; return;
} }
// Close the window here, because we don't need it anymore!
foundCode = true; foundCode = true;
close(); close();
resolve(code); 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() { export function genPkceCodeVerifier() {
+105 -8
View File
@@ -1,7 +1,9 @@
import type { Context } from '@yaakapp/api'; import type { Context } from '@yaakapp/api';
import { getRedirectUrlViaExternalBrowser } from '../callbackServer';
import type { AccessToken, AccessTokenRawResponse } from '../store'; import type { AccessToken, AccessTokenRawResponse } from '../store';
import { getDataDirKey, getToken, storeToken } from '../store'; import { getDataDirKey, getToken, storeToken } from '../store';
import { isTokenExpired } from '../util'; import { isTokenExpired } from '../util';
import type { ExternalBrowserOptions } from './authorizationCode';
export async function getImplicit( export async function getImplicit(
ctx: Context, ctx: Context,
@@ -15,6 +17,7 @@ export async function getImplicit(
state, state,
audience, audience,
tokenName, tokenName,
externalBrowser,
}: { }: {
authorizationUrl: string; authorizationUrl: string;
responseType: string; responseType: string;
@@ -24,6 +27,7 @@ export async function getImplicit(
state: string | null; state: string | null;
audience: string | null; audience: string | null;
tokenName: 'access_token' | 'id_token'; tokenName: 'access_token' | 'id_token';
externalBrowser?: ExternalBrowserOptions;
}, },
): Promise<AccessToken> { ): Promise<AccessToken> {
const tokenArgs = { const tokenArgs = {
@@ -43,9 +47,8 @@ export async function getImplicit(
} catch { } catch {
throw new Error(`Invalid authorization URL "${authorizationUrlRaw}"`); 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); authorizationUrl.searchParams.set('client_id', clientId);
if (redirectUri) authorizationUrl.searchParams.set('redirect_uri', redirectUri);
if (scope) authorizationUrl.searchParams.set('scope', scope); if (scope) authorizationUrl.searchParams.set('scope', scope);
if (state) authorizationUrl.searchParams.set('state', state); if (state) authorizationUrl.searchParams.set('state', state);
if (audience) authorizationUrl.searchParams.set('audience', audience); if (audience) authorizationUrl.searchParams.set('audience', audience);
@@ -56,11 +59,55 @@ export async function getImplicit(
); );
} }
// biome-ignore lint/suspicious/noAsyncPromiseExecutor: none let newToken: AccessToken;
const newToken = await new Promise<AccessToken>(async (resolve, reject) => {
let foundAccessToken = false; // Use external browser flow if enabled
const authorizationUrlStr = authorizationUrl.toString(); 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 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 { close } = await ctx.window.openUrl({ const { close } = await ctx.window.openUrl({
dataDirKey, dataDirKey,
url: authorizationUrlStr, 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);
} }
+128 -10
View File
@@ -5,7 +5,9 @@ import type {
JsonPrimitive, JsonPrimitive,
PluginDefinition, PluginDefinition,
} from '@yaakapp/api'; } from '@yaakapp/api';
import { DEFAULT_LOCALHOST_PORT, HOSTED_CALLBACK_URL } from './callbackServer';
import { import {
type CallbackType,
DEFAULT_PKCE_METHOD, DEFAULT_PKCE_METHOD,
genPkceCodeVerifier, genPkceCodeVerifier,
getAuthorizationCode, getAuthorizationCode,
@@ -134,8 +136,6 @@ export const plugin: PluginDefinition = {
defaultValue: defaultGrantType, defaultValue: defaultGrantType,
options: grantTypes, options: grantTypes,
}, },
// Always-present fields
{ {
type: 'text', type: 'text',
name: 'clientId', name: 'clientId',
@@ -168,12 +168,106 @@ export const plugin: PluginDefinition = {
dynamic: hiddenIfNot(['authorization_code', 'password', 'client_credentials']), dynamic: hiddenIfNot(['authorization_code', 'password', 'client_credentials']),
completionOptions: accessTokenUrls.map((url) => ({ label: url, value: url })), completionOptions: accessTokenUrls.map((url) => ({ label: url, value: url })),
}, },
{
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', type: 'text',
name: 'redirectUri', name: 'redirectUri',
label: 'Redirect URI', 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, optional: true,
dynamic: hiddenIfNot(['authorization_code', 'implicit']), 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', type: 'text',
@@ -182,12 +276,8 @@ export const plugin: PluginDefinition = {
optional: true, optional: true,
dynamic: hiddenIfNot(['authorization_code', 'implicit']), dynamic: hiddenIfNot(['authorization_code', 'implicit']),
}, },
{ { type: 'text', name: 'scope', label: 'Scope', optional: true },
type: 'text', { type: 'text', name: 'audience', label: 'Audience', optional: true },
name: 'audience',
label: 'Audience',
optional: true,
},
{ {
type: 'select', type: 'select',
name: 'tokenName', name: 'tokenName',
@@ -202,6 +292,9 @@ export const plugin: PluginDefinition = {
], ],
dynamic: hiddenIfNot(['authorization_code', 'implicit']), dynamic: hiddenIfNot(['authorization_code', 'implicit']),
}, },
{
type: 'banner',
inputs: [
{ {
type: 'checkbox', type: 'checkbox',
name: 'usePkce', name: 'usePkce',
@@ -227,6 +320,11 @@ export const plugin: PluginDefinition = {
optional: true, optional: true,
dynamic: hiddenIfNot(['authorization_code'], ({ usePkce }) => !!usePkce), dynamic: hiddenIfNot(['authorization_code'], ({ usePkce }) => !!usePkce),
}, },
],
},
{
type: 'h_stack',
inputs: [
{ {
type: 'text', type: 'text',
name: 'username', name: 'username',
@@ -242,6 +340,8 @@ export const plugin: PluginDefinition = {
optional: true, optional: true,
dynamic: hiddenIfNot(['password']), dynamic: hiddenIfNot(['password']),
}, },
],
},
{ {
type: 'select', type: 'select',
name: 'responseType', name: 'responseType',
@@ -258,7 +358,6 @@ export const plugin: PluginDefinition = {
type: 'accordion', type: 'accordion',
label: 'Advanced', label: 'Advanced',
inputs: [ inputs: [
{ type: 'text', name: 'scope', label: 'Scope', optional: true },
{ {
type: 'text', type: 'text',
name: 'headerName', name: 'headerName',
@@ -321,6 +420,16 @@ export const plugin: PluginDefinition = {
const credentialsInBody = values.credentials === 'body'; const credentialsInBody = values.credentials === 'body';
const tokenName = values.tokenName === 'id_token' ? 'id_token' : 'access_token'; 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; let token: AccessToken;
if (grantType === 'authorization_code') { if (grantType === 'authorization_code') {
const authorizationUrl = stringArg(values, 'authorizationUrl'); const authorizationUrl = stringArg(values, 'authorizationUrl');
@@ -348,6 +457,7 @@ export const plugin: PluginDefinition = {
} }
: null, : null,
tokenName: tokenName, tokenName: tokenName,
externalBrowser: externalBrowserOptions,
}); });
} else if (grantType === 'implicit') { } else if (grantType === 'implicit') {
const authorizationUrl = stringArg(values, 'authorizationUrl'); const authorizationUrl = stringArg(values, 'authorizationUrl');
@@ -362,6 +472,7 @@ export const plugin: PluginDefinition = {
audience: stringArgOrNull(values, 'audience'), audience: stringArgOrNull(values, 'audience'),
state: stringArgOrNull(values, 'state'), state: stringArgOrNull(values, 'state'),
tokenName: tokenName, tokenName: tokenName,
externalBrowser: externalBrowserOptions,
}); });
} else if (grantType === 'client_credentials') { } else if (grantType === 'client_credentials') {
const accessTokenUrl = stringArg(values, 'accessTokenUrl'); const accessTokenUrl = stringArg(values, 'accessTokenUrl');
@@ -414,3 +525,10 @@ function stringArg(values: Record<string, JsonPrimitive | undefined>, name: stri
if (!arg) return ''; if (!arg) return '';
return arg; 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%)', danger: 'hsl(340, 100%, 65%)',
}, },
components: { components: {
dialog: {
surface: 'hsl(253, 45%, 12%)',
},
sidebar: { sidebar: {
surface: 'hsl(253, 42%, 18%)', surface: 'hsl(253, 42%, 18%)',
border: 'hsl(253, 40%, 22%)', border: 'hsl(253, 40%, 22%)',
@@ -0,0 +1,161 @@
import { open } from '@tauri-apps/plugin-dialog';
import { gitClone } from '@yaakapp-internal/git';
import { useState } from 'react';
import { openWorkspaceFromSyncDir } from '../commands/openWorkspaceFromSyncDir';
import { appInfo } from '../lib/appInfo';
import { showErrorToast } from '../lib/toast';
import { Banner } from './core/Banner';
import { Button } from './core/Button';
import { Checkbox } from './core/Checkbox';
import { IconButton } from './core/IconButton';
import { PlainInput } from './core/PlainInput';
import { VStack } from './core/Stacks';
import { promptCredentials } from './git/credentials';
interface Props {
hide: () => void;
}
// Detect path separator from an existing path (defaults to /)
function getPathSeparator(path: string): string {
return path.includes('\\') ? '\\' : '/';
}
export function CloneGitRepositoryDialog({ hide }: Props) {
const [url, setUrl] = useState<string>('');
const [baseDirectory, setBaseDirectory] = useState<string>(appInfo.defaultProjectDir);
const [directoryOverride, setDirectoryOverride] = useState<string | null>(null);
const [hasSubdirectory, setHasSubdirectory] = useState(false);
const [subdirectory, setSubdirectory] = useState<string>('');
const [isCloning, setIsCloning] = useState(false);
const [error, setError] = useState<string | null>(null);
const repoName = extractRepoName(url);
const sep = getPathSeparator(baseDirectory);
const computedDirectory = repoName ? `${baseDirectory}${sep}${repoName}` : baseDirectory;
const directory = directoryOverride ?? computedDirectory;
const workspaceDirectory =
hasSubdirectory && subdirectory ? `${directory}${sep}${subdirectory}` : directory;
const handleSelectDirectory = async () => {
const dir = await open({
title: 'Select Directory',
directory: true,
multiple: false,
});
if (dir != null) {
setBaseDirectory(dir);
setDirectoryOverride(null);
}
};
const handleClone = async (e: React.FormEvent) => {
e.preventDefault();
if (!url || !directory) return;
setIsCloning(true);
setError(null);
try {
const result = await gitClone(url, directory, promptCredentials);
if (result.type === 'needs_credentials') {
setError(
result.error ?? 'Authentication failed. Please check your credentials and try again.',
);
return;
}
// Open the workspace from the cloned directory (or subdirectory)
await openWorkspaceFromSyncDir.mutateAsync(workspaceDirectory);
hide();
} catch (err) {
setError(String(err));
showErrorToast({
id: 'git-clone-error',
title: 'Clone Failed',
message: String(err),
});
} finally {
setIsCloning(false);
}
};
return (
<VStack as="form" space={3} alignItems="start" className="pb-3" onSubmit={handleClone}>
{error && (
<Banner color="danger" className="w-full">
{error}
</Banner>
)}
<PlainInput
required
label="Repository URL"
placeholder="https://github.com/user/repo.git"
defaultValue={url}
onChange={setUrl}
/>
<PlainInput
label="Directory"
placeholder={appInfo.defaultProjectDir}
defaultValue={directory}
onChange={setDirectoryOverride}
rightSlot={
<IconButton
size="xs"
className="mr-0.5 !h-auto my-0.5"
icon="folder"
title="Browse"
onClick={handleSelectDirectory}
/>
}
/>
<Checkbox
checked={hasSubdirectory}
onChange={setHasSubdirectory}
title="Workspace is in a subdirectory"
help="Enable if the Yaak workspace files are not at the root of the repository"
/>
{hasSubdirectory && (
<PlainInput
label="Subdirectory"
placeholder="path/to/workspace"
defaultValue={subdirectory}
onChange={setSubdirectory}
/>
)}
<Button
type="submit"
color="primary"
className="w-full mt-3"
disabled={!url || !directory || isCloning}
isLoading={isCloning}
>
{isCloning ? 'Cloning...' : 'Clone Repository'}
</Button>
</VStack>
);
}
function extractRepoName(url: string): string {
// Handle various Git URL formats:
// https://github.com/user/repo.git
// git@github.com:user/repo.git
// https://github.com/user/repo
const match = url.match(/\/([^/]+?)(\.git)?$/);
if (match?.[1]) {
return match[1];
}
// Fallback for SSH-style URLs
const sshMatch = url.match(/:([^/]+?)(\.git)?$/);
if (sshMatch?.[1]) {
return sshMatch[1];
}
return '';
}
+14
View File
@@ -198,6 +198,9 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
/> />
); );
case 'accordion': case 'accordion':
if (!hasVisibleInputs(input.inputs)) {
return null;
}
return ( return (
<div key={i + stateKey}> <div key={i + stateKey}>
<DetailsBanner <DetailsBanner
@@ -219,6 +222,9 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
</div> </div>
); );
case 'h_stack': case 'h_stack':
if (!hasVisibleInputs(input.inputs)) {
return null;
}
return ( return (
<div className="flex flex-wrap sm:flex-nowrap gap-3 items-end" key={i + stateKey}> <div className="flex flex-wrap sm:flex-nowrap gap-3 items-end" key={i + stateKey}>
<FormInputs <FormInputs
@@ -233,6 +239,9 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
</div> </div>
); );
case 'banner': case 'banner':
if (!hasVisibleInputs(input.inputs)) {
return null;
}
return ( return (
<Banner <Banner
key={i + stateKey} key={i + stateKey}
@@ -603,3 +612,8 @@ function KeyValueArg({
</div> </div>
); );
} }
function hasVisibleInputs(inputs: FormInput[] | undefined): boolean {
if (!inputs) return false;
return inputs.some((i) => !i.hidden);
}
+11 -3
View File
@@ -98,13 +98,14 @@ export function GrpcResponsePane({ style, methodType, activeRequest }: Props) {
renderRow={({ event, isActive, onClick }) => ( renderRow={({ event, isActive, onClick }) => (
<GrpcEventRow event={event} isActive={isActive} onClick={onClick} /> <GrpcEventRow event={event} isActive={isActive} onClick={onClick} />
)} )}
renderDetail={({ event }) => ( renderDetail={({ event, onClose }) => (
<GrpcEventDetail <GrpcEventDetail
event={event} event={event}
showLarge={showLarge} showLarge={showLarge}
showingLarge={showingLarge} showingLarge={showingLarge}
setShowLarge={setShowLarge} setShowLarge={setShowLarge}
setShowingLarge={setShowingLarge} setShowingLarge={setShowingLarge}
onClose={onClose}
/> />
)} )}
/> />
@@ -147,19 +148,26 @@ function GrpcEventDetail({
showingLarge, showingLarge,
setShowLarge, setShowLarge,
setShowingLarge, setShowingLarge,
onClose,
}: { }: {
event: GrpcEvent; event: GrpcEvent;
showLarge: boolean; showLarge: boolean;
showingLarge: boolean; showingLarge: boolean;
setShowLarge: (v: boolean) => void; setShowLarge: (v: boolean) => void;
setShowingLarge: (v: boolean) => void; setShowingLarge: (v: boolean) => void;
onClose: () => void;
}) { }) {
if (event.eventType === 'client_message' || event.eventType === 'server_message') { if (event.eventType === 'client_message' || event.eventType === 'server_message') {
const title = `Message ${event.eventType === 'client_message' ? 'Sent' : 'Received'}`; const title = `Message ${event.eventType === 'client_message' ? 'Sent' : 'Received'}`;
return ( return (
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]"> <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 ? ( {!showLarge && event.content.length > 1000 * 1000 ? (
<VStack space={2} className="italic text-text-subtlest"> <VStack space={2} className="italic text-text-subtlest">
Message previews larger than 1MB are hidden Message previews larger than 1MB are hidden
@@ -197,7 +205,7 @@ function GrpcEventDetail({
// Error or connection_end - show metadata/trailers // Error or connection_end - show metadata/trailers
return ( return (
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]"> <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 && ( {event.error && (
<div className="select-text cursor-text text-sm font-mono py-1 text-warning"> <div className="select-text cursor-text text-sm font-mono py-1 text-warning">
{event.error} {event.error}
@@ -62,9 +62,7 @@ export function HttpAuthenticationEditor({ model }: Props) {
<p> <p>
Apply auth to all requests in <strong>{resolvedModelName(model)}</strong> Apply auth to all requests in <strong>{resolvedModelName(model)}</strong>
</p> </p>
<Link href="https://yaak.app/docs/using-yaak/request-inheritance"> <Link href="https://yaak.app/docs/using-yaak/request-inheritance">Documentation</Link>
Documentation
</Link>
</EmptyStateText> </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> </Dropdown>
)} )}
</HStack> </HStack>
+30 -8
View File
@@ -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') { 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 ( return (
<KeyValueRows> <KeyValueRows>
<KeyValueRow label="Method"> <KeyValueRow label="URL">{fullUrl}</KeyValueRow>
<HttpMethodTagRaw forceColor method={e.method} /> <KeyValueRow label="Method">{e.method}</KeyValueRow>
</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> <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> </KeyValueRows>
); );
} }
@@ -244,7 +257,10 @@ type EventTextParts = { prefix: '>' | '<' | '*'; text: string };
function getEventTextParts(event: HttpResponseEventData): EventTextParts { function getEventTextParts(event: HttpResponseEventData): EventTextParts {
switch (event.type) { switch (event.type) {
case 'send_url': 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': case 'receive_url':
return { prefix: '<', text: `${event.version} ${event.status}` }; return { prefix: '<', text: `${event.version} ${event.status}` };
case 'header_up': case 'header_up':
@@ -265,9 +281,15 @@ function getEventTextParts(event: HttpResponseEventData): EventTextParts {
return { prefix: '*', text: `[${formatBytes(event.bytes)} received]` }; return { prefix: '*', text: `[${formatBytes(event.bytes)} received]` };
case 'dns_resolved': case 'dns_resolved':
if (event.overridden) { 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: default:
return { prefix: '*', text: '[unknown event]' }; return { prefix: '*', text: '[unknown event]' };
} }
@@ -314,7 +336,7 @@ function getEventDisplay(event: HttpResponseEventData): EventDisplay {
icon: 'arrow_big_up_dash', icon: 'arrow_big_up_dash',
color: 'primary', color: 'primary',
label: 'Request', label: 'Request',
summary: `${event.method} ${event.path}`, summary: `${event.method} ${event.path}${event.query ? `?${event.query}` : ''}${event.fragment ? `#${event.fragment}` : ''}`,
}; };
case 'receive_url': case 'receive_url':
return { return {
+1 -1
View File
@@ -71,7 +71,7 @@ export const RequestMethodDropdown = memo(function RequestMethodDropdown({
onChange={handleChange} onChange={handleChange}
> >
<Button size="xs" className={classNames(className, 'text-text-subtle hover:text')}> <Button size="xs" className={classNames(className, 'text-text-subtle hover:text')}>
<HttpMethodTag request={request} /> <HttpMethodTag request={request} noAlias />
</Button> </Button>
</RadioDropdown> </RadioDropdown>
); );
+6 -2
View File
@@ -13,7 +13,7 @@ import { useStateWithDeps } from '../hooks/useStateWithDeps';
import { languageFromContentType } from '../lib/contentType'; import { languageFromContentType } from '../lib/contentType';
import { Button } from './core/Button'; import { Button } from './core/Button';
import { Editor } from './core/Editor/LazyEditor'; import { Editor } from './core/Editor/LazyEditor';
import { EventDetailHeader, EventViewer, type EventDetailAction } from './core/EventViewer'; import { type EventDetailAction, EventDetailHeader, EventViewer } from './core/EventViewer';
import { EventViewerRow } from './core/EventViewerRow'; import { EventViewerRow } from './core/EventViewerRow';
import { HotkeyList } from './core/HotkeyList'; import { HotkeyList } from './core/HotkeyList';
import { Icon } from './core/Icon'; import { Icon } from './core/Icon';
@@ -75,7 +75,7 @@ export function WebsocketResponsePane({ activeRequest }: Props) {
renderRow={({ event, isActive, onClick }) => ( renderRow={({ event, isActive, onClick }) => (
<WebsocketEventRow event={event} isActive={isActive} onClick={onClick} /> <WebsocketEventRow event={event} isActive={isActive} onClick={onClick} />
)} )}
renderDetail={({ event, index }) => ( renderDetail={({ event, index, onClose }) => (
<WebsocketEventDetail <WebsocketEventDetail
event={event} event={event}
hexDump={hexDumps[index] ?? event.messageType === 'binary'} hexDump={hexDumps[index] ?? event.messageType === 'binary'}
@@ -84,6 +84,7 @@ export function WebsocketResponsePane({ activeRequest }: Props) {
showingLarge={showingLarge} showingLarge={showingLarge}
setShowLarge={setShowLarge} setShowLarge={setShowLarge}
setShowingLarge={setShowingLarge} setShowingLarge={setShowingLarge}
onClose={onClose}
/> />
)} )}
/> />
@@ -145,6 +146,7 @@ function WebsocketEventDetail({
showingLarge, showingLarge,
setShowLarge, setShowLarge,
setShowingLarge, setShowingLarge,
onClose,
}: { }: {
event: WebsocketEvent; event: WebsocketEvent;
hexDump: boolean; hexDump: boolean;
@@ -153,6 +155,7 @@ function WebsocketEventDetail({
showingLarge: boolean; showingLarge: boolean;
setShowLarge: (v: boolean) => void; setShowLarge: (v: boolean) => void;
setShowingLarge: (v: boolean) => void; setShowingLarge: (v: boolean) => void;
onClose: () => void;
}) { }) {
const message = useMemo(() => { const message = useMemo(() => {
if (hexDump) { if (hexDump) {
@@ -189,6 +192,7 @@ function WebsocketEventDetail({
timestamp={event.createdAt} timestamp={event.createdAt}
actions={actions} actions={actions}
copyText={formattedMessage || undefined} copyText={formattedMessage || undefined}
onClose={onClose}
/> />
{!showLarge && event.message.length > 1000 * 1000 ? ( {!showLarge && event.message.length > 1000 * 1000 ? (
<VStack space={2} className="italic text-text-subtlest"> <VStack space={2} className="italic text-text-subtlest">
+47 -22
View File
@@ -18,6 +18,7 @@ import { useWorkspaceActions } from '../hooks/useWorkspaceActions';
import { showDialog } from '../lib/dialog'; import { showDialog } from '../lib/dialog';
import { jotaiStore } from '../lib/jotai'; import { jotaiStore } from '../lib/jotai';
import { revealInFinderText } from '../lib/reveal'; import { revealInFinderText } from '../lib/reveal';
import { CloneGitRepositoryDialog } from './CloneGitRepositoryDialog';
import type { ButtonProps } from './core/Button'; import type { ButtonProps } from './core/Button';
import { Button } from './core/Button'; import { Button } from './core/Button';
import type { DropdownItem } from './core/Dropdown'; import type { DropdownItem } from './core/Dropdown';
@@ -39,9 +40,19 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
const { mutate: deleteSendHistory } = useDeleteSendHistory(); const { mutate: deleteSendHistory } = useDeleteSendHistory();
const workspaceActions = useWorkspaceActions(); const workspaceActions = useWorkspaceActions();
const { workspaceItems, itemsAfter } = useMemo<{ const openCloneGitRepositoryDialog = useCallback(() => {
showDialog({
id: 'clone-git-repository',
size: 'md',
title: 'Clone Git Repository',
render: ({ hide }) => <CloneGitRepositoryDialog hide={hide} />,
});
}, []);
const { workspaceItems, itemsAfter, itemsBefore } = useMemo<{
workspaceItems: RadioDropdownItem[]; workspaceItems: RadioDropdownItem[];
itemsAfter: DropdownItem[]; itemsAfter: DropdownItem[];
itemsBefore: DropdownItem[];
}>(() => { }>(() => {
const workspaceItems: RadioDropdownItem[] = workspaces.map((w) => ({ const workspaceItems: RadioDropdownItem[] = workspaces.map((w) => ({
key: w.id, key: w.id,
@@ -50,6 +61,38 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
leftSlot: w.id === workspace?.id ? <Icon icon="check" /> : <Icon icon="empty" />, leftSlot: w.id === workspace?.id ? <Icon icon="check" /> : <Icon icon="empty" />,
})); }));
const itemsBefore: DropdownItem[] = [
{
label: 'New Workspace',
leftSlot: <Icon icon="plus" />,
submenu: [
{
label: 'Create Empty',
leftSlot: <Icon icon="plus_circle" />,
onSelect: createWorkspace,
},
{
label: 'Open Folder',
leftSlot: <Icon icon="folder_open" />,
onSelect: async () => {
const dir = await open({
title: 'Select Workspace Directory',
directory: true,
multiple: false,
});
if (dir == null) return;
openWorkspaceFromSyncDir.mutate(dir);
},
},
{
label: 'Clone Git Repository',
leftSlot: <Icon icon="hard_drive_download" />,
onSelect: openCloneGitRepositoryDialog,
},
],
},
];
const itemsAfter: DropdownItem[] = [ const itemsAfter: DropdownItem[] = [
...workspaceActions.map((a) => ({ ...workspaceActions.map((a) => ({
label: a.label, label: a.label,
@@ -80,34 +123,15 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
leftSlot: <Icon icon="history" />, leftSlot: <Icon icon="history" />,
onSelect: deleteSendHistory, onSelect: deleteSendHistory,
}, },
{ type: 'separator' },
{
label: 'New Workspace',
leftSlot: <Icon icon="plus" />,
onSelect: createWorkspace,
},
{
label: 'Open Existing Workspace',
leftSlot: <Icon icon="folder_open" />,
onSelect: async () => {
const dir = await open({
title: 'Select Workspace Directory',
directory: true,
multiple: false,
});
if (dir == null) return;
openWorkspaceFromSyncDir.mutate(dir);
},
},
]; ];
return { workspaceItems, itemsAfter }; return { workspaceItems, itemsAfter, itemsBefore };
}, [ }, [
workspaces, workspaces,
workspaceMeta, workspaceMeta,
deleteSendHistory, deleteSendHistory,
createWorkspace, createWorkspace,
openCloneGitRepositoryDialog,
workspace?.id, workspace?.id,
workspace, workspace,
workspaceActions.map, workspaceActions.map,
@@ -144,6 +168,7 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
<RadioDropdown <RadioDropdown
items={workspaceItems} items={workspaceItems}
itemsAfter={itemsAfter} itemsAfter={itemsAfter}
itemsBefore={itemsBefore}
onChange={handleSwitchWorkspace} onChange={handleSwitchWorkspace}
value={workspace?.id ?? null} value={workspace?.id ?? null}
> >
@@ -1,4 +1,9 @@
import { enableEncryption, revealWorkspaceKey, setWorkspaceKey } from '@yaakapp-internal/crypto'; import {
disableEncryption,
enableEncryption,
revealWorkspaceKey,
setWorkspaceKey,
} from '@yaakapp-internal/crypto';
import type { WorkspaceMeta } from '@yaakapp-internal/models'; import type { WorkspaceMeta } from '@yaakapp-internal/models';
import classNames from 'classnames'; import classNames from 'classnames';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
@@ -6,6 +11,7 @@ import { useEffect, useState } from 'react';
import { activeWorkspaceAtom, activeWorkspaceMetaAtom } from '../hooks/useActiveWorkspace'; import { activeWorkspaceAtom, activeWorkspaceMetaAtom } from '../hooks/useActiveWorkspace';
import { createFastMutation } from '../hooks/useFastMutation'; import { createFastMutation } from '../hooks/useFastMutation';
import { useStateWithDeps } from '../hooks/useStateWithDeps'; import { useStateWithDeps } from '../hooks/useStateWithDeps';
import { showConfirm } from '../lib/confirm';
import { CopyIconButton } from './CopyIconButton'; import { CopyIconButton } from './CopyIconButton';
import { Banner } from './core/Banner'; import { Banner } from './core/Banner';
import type { ButtonProps } from './core/Button'; import type { ButtonProps } from './core/Button';
@@ -69,6 +75,9 @@ export function WorkspaceEncryptionSetting({ size, expanded, onDone, onEnabledEn
onDone?.(); onDone?.();
onEnabledEncryption?.(); onEnabledEncryption?.();
}} }}
onDisabled={() => {
onDone?.();
}}
/> />
); );
} }
@@ -109,6 +118,7 @@ export function WorkspaceEncryptionSetting({ size, expanded, onDone, onEnabledEn
return ( return (
<div className="mb-auto flex flex-col-reverse"> <div className="mb-auto flex flex-col-reverse">
<Button <Button
className="mt-3"
color={expanded ? 'info' : 'secondary'} color={expanded ? 'info' : 'secondary'}
size={size} size={size}
onClick={async () => { onClick={async () => {
@@ -149,13 +159,39 @@ const setWorkspaceKeyMut = createFastMutation({
function EnterWorkspaceKey({ function EnterWorkspaceKey({
workspaceMeta, workspaceMeta,
onEnabled, onEnabled,
onDisabled,
error, error,
}: { }: {
workspaceMeta: WorkspaceMeta; workspaceMeta: WorkspaceMeta;
onEnabled?: () => void; onEnabled?: () => void;
onDisabled?: () => void;
error?: string | null; error?: string | null;
}) { }) {
const [key, setKey] = useState<string>(''); const [key, setKey] = useState<string>('');
const handleForgotKey = async () => {
const confirmed = await showConfirm({
id: 'disable-encryption',
title: 'Disable Encryption',
color: 'danger',
confirmText: 'Disable Encryption',
description: (
<>
This will disable encryption for this workspace. Any previously encrypted values will fail
to decrypt and will need to be re-entered manually.
<br />
<br />
This action cannot be undone.
</>
),
});
if (confirmed) {
await disableEncryption(workspaceMeta.workspaceId);
onDisabled?.();
}
};
return ( return (
<VStack space={4} className="w-full"> <VStack space={4} className="w-full">
{error ? ( {error ? (
@@ -192,6 +228,13 @@ function EnterWorkspaceKey({
Submit Submit
</Button> </Button>
</HStack> </HStack>
<button
type="button"
onClick={handleForgotKey}
className="text-text-subtlest text-sm hover:text-text-subtle"
>
Forgot your key?
</button>
</VStack> </VStack>
); );
} }
+53 -11
View File
@@ -66,6 +66,8 @@ export type DropdownItemDefault = {
keepOpenOnSelect?: boolean; keepOpenOnSelect?: boolean;
onSelect?: () => void | Promise<void>; onSelect?: () => void | Promise<void>;
submenu?: DropdownItem[]; submenu?: DropdownItem[];
/** If true, submenu opens on click instead of hover */
submenuOpenOnClick?: boolean;
icon?: IconProps['icon']; icon?: IconProps['icon'];
}; };
@@ -272,6 +274,7 @@ interface MenuProps {
defaultSelectedIndex: number | null; defaultSelectedIndex: number | null;
triggerShape: Pick<DOMRect, 'top' | 'bottom' | 'left' | 'right'> | null; triggerShape: Pick<DOMRect, 'top' | 'bottom' | 'left' | 'right'> | null;
onClose: () => void; onClose: () => void;
onCloseAll?: () => void;
showTriangle?: boolean; showTriangle?: boolean;
fullWidth?: boolean; fullWidth?: boolean;
isOpen: boolean; isOpen: boolean;
@@ -288,6 +291,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
items, items,
fullWidth, fullWidth,
onClose, onClose,
onCloseAll,
triggerShape, triggerShape,
defaultSelectedIndex, defaultSelectedIndex,
showTriangle, showTriangle,
@@ -300,7 +304,16 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
defaultSelectedIndex ?? -1, defaultSelectedIndex ?? -1,
[defaultSelectedIndex], [defaultSelectedIndex],
); );
const [filter, setFilter] = useState<string>(''); const [filter, setFilter] = useState<string>('');
// Clear filter when menu opens
useEffect(() => {
if (isOpen) {
setFilter('');
}
}, [isOpen]);
const [activeSubmenu, setActiveSubmenu] = useState<{ const [activeSubmenu, setActiveSubmenu] = useState<{
item: DropdownItemDefault; item: DropdownItemDefault;
parent: HTMLButtonElement; parent: HTMLButtonElement;
@@ -320,10 +333,18 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
onClose(); onClose();
setFilter('');
setActiveSubmenu(null); setActiveSubmenu(null);
}, [onClose]); }, [onClose]);
// Close the entire menu hierarchy (used when selecting an item)
const handleCloseAll = useCallback(() => {
if (onCloseAll) {
onCloseAll();
} else {
handleClose();
}
}, [onCloseAll, handleClose]);
// Handle type-ahead filtering (only for the deepest open menu) // Handle type-ahead filtering (only for the deepest open menu)
const handleMenuKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => { const handleMenuKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
// Skip if this menu has a submenu open - let the submenu handle typing // Skip if this menu has a submenu open - let the submenu handle typing
@@ -393,6 +414,14 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
[items, setSelectedIndex], [items, setSelectedIndex],
); );
// Ensure selection is on a valid item (not hidden/separator/content)
useEffect(() => {
const item = items[selectedIndex ?? -1];
if (item?.hidden || item?.type === 'separator' || item?.type === 'content') {
handleNext();
}
}, [selectedIndex, items, handleNext]);
useKey( useKey(
'ArrowUp', 'ArrowUp',
(e) => { (e) => {
@@ -433,7 +462,13 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
); );
const handleSelect = useCallback( const handleSelect = useCallback(
async (item: DropdownItem) => { async (item: DropdownItem, parentEl?: HTMLButtonElement) => {
// Handle click-to-open submenu
if ('submenu' in item && item.submenu && item.submenuOpenOnClick && parentEl) {
setActiveSubmenu({ item, parent: parentEl });
return;
}
if (!('onSelect' in item) || !item.onSelect) return; if (!('onSelect' in item) || !item.onSelect) return;
setSelectedIndex(null); setSelectedIndex(null);
@@ -446,9 +481,9 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
} }
} }
if (!item.keepOpenOnSelect) handleClose(); if (!item.keepOpenOnSelect) handleCloseAll();
}, },
[handleClose, setSelectedIndex], [handleCloseAll, setSelectedIndex],
); );
useImperativeHandle(ref, () => { useImperativeHandle(ref, () => {
@@ -476,17 +511,23 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
const parentRect = triggerShape; const parentRect = triggerShape;
const docRect = document.documentElement.getBoundingClientRect(); const docRect = document.documentElement.getBoundingClientRect();
const spaceRight = docRect.width - parentRect.right; const spaceRight = docRect.width - parentRect.right;
const spaceBelow = docRect.height - parentRect.top;
const spaceAbove = parentRect.bottom;
const openLeft = spaceRight < 200; // Heuristic to open on left if not enough space on right const openLeft = spaceRight < 200; // Heuristic to open on left if not enough space on right
// Estimate submenu height (items * ~28px + padding), flip if not enough space below
const estimatedHeight = items.length * 28 + 20;
const openUpward = spaceBelow < estimatedHeight && spaceAbove > spaceBelow;
return { return {
upsideDown: false, upsideDown: openUpward,
container: { container: {
top: parentRect.top, top: openUpward ? undefined : parentRect.top,
bottom: openUpward ? docRect.height - parentRect.bottom : undefined,
left: openLeft ? undefined : parentRect.right, left: openLeft ? undefined : parentRect.right,
right: openLeft ? docRect.width - parentRect.left : undefined, right: openLeft ? docRect.width - parentRect.left : undefined,
}, },
menu: { menu: {
maxHeight: `${docRect.height - parentRect.top - 20}px`, maxHeight: `${(openUpward ? spaceAbove : spaceBelow) - 20}px`,
}, },
triangle: {}, // No triangle for submenus triangle: {}, // No triangle for submenus
}; };
@@ -586,7 +627,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
clearTimeout(submenuTimeoutRef.current); clearTimeout(submenuTimeoutRef.current);
} }
if (item.submenu) { if (item.submenu && !item.submenuOpenOnClick) {
setActiveSubmenu({ item, parent }); setActiveSubmenu({ item, parent });
} else if (activeSubmenu) { } else if (activeSubmenu) {
submenuTimeoutRef.current = window.setTimeout(() => { submenuTimeoutRef.current = window.setTimeout(() => {
@@ -759,6 +800,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
items={activeSubmenu.item.submenu ?? []} items={activeSubmenu.item.submenu ?? []}
defaultSelectedIndex={activeSubmenu.viaKeyboard ? 0 : null} defaultSelectedIndex={activeSubmenu.viaKeyboard ? 0 : null}
onClose={() => setActiveSubmenu(null)} onClose={() => setActiveSubmenu(null)}
onCloseAll={handleCloseAll}
triggerShape={submenuTriggerShape} triggerShape={submenuTriggerShape}
/> />
</div> </div>
@@ -804,7 +846,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
interface MenuItemProps { interface MenuItemProps {
className?: string; className?: string;
item: DropdownItemDefault; item: DropdownItemDefault;
onSelect: (item: DropdownItemDefault) => Promise<void>; onSelect: (item: DropdownItemDefault, el?: HTMLButtonElement) => Promise<void>;
onFocus: (item: DropdownItemDefault) => void; onFocus: (item: DropdownItemDefault) => void;
onHover: (item: DropdownItemDefault, el: HTMLButtonElement) => void; onHover: (item: DropdownItemDefault, el: HTMLButtonElement) => void;
focused: boolean; focused: boolean;
@@ -824,7 +866,7 @@ function MenuItem({
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const handleClick = useCallback(async () => { const handleClick = useCallback(async () => {
if (item.waitForOnSelect) setIsLoading(true); if (item.waitForOnSelect) setIsLoading(true);
await onSelect?.(item); await onSelect?.(item, buttonRef.current ?? undefined);
if (item.waitForOnSelect) setIsLoading(false); if (item.waitForOnSelect) setIsLoading(false);
}, [item, onSelect]); }, [item, onSelect]);
@@ -854,7 +896,7 @@ function MenuItem({
}; };
const rightSlot = item.submenu ? ( const rightSlot = item.submenu ? (
<Icon icon="chevron_right" /> <Icon icon="chevron_right" color='secondary' />
) : ( ) : (
(item.rightSlot ?? <Hotkey action={item.hotKeyAction ?? null} />) (item.rightSlot ?? <Hotkey action={item.hotKeyAction ?? null} />)
); );
@@ -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;
}
}
@@ -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)}
/>
);
}
+2 -2
View File
@@ -101,8 +101,8 @@
.template-tag { .template-tag {
/* Colors */ /* Colors */
@apply bg-surface text-text border-border-subtle whitespace-nowrap cursor-default; @apply bg-surface text-text-subtle border-border-subtle whitespace-nowrap cursor-default;
@apply hover:border-border-subtle hover:text-text hover:bg-surface-highlight; @apply hover:border-border hover:text-text hover:bg-surface-highlight;
@apply inline border px-1 mx-[0.5px] rounded dark:shadow; @apply inline border px-1 mx-[0.5px] rounded dark:shadow;
+3 -2
View File
@@ -8,6 +8,7 @@ interface Props {
request: HttpRequest | GrpcRequest | WebsocketRequest; request: HttpRequest | GrpcRequest | WebsocketRequest;
className?: string; className?: string;
short?: boolean; short?: boolean;
noAlias?: boolean;
} }
const methodNames: Record<string, string> = { const methodNames: Record<string, string> = {
@@ -24,9 +25,9 @@ const methodNames: Record<string, string> = {
websocket: 'WS', websocket: 'WS',
}; };
export const HttpMethodTag = memo(function HttpMethodTag({ request, className, short }: Props) { export const HttpMethodTag = memo(function HttpMethodTag({ request, className, short, noAlias }: Props) {
const method = const method =
request.model === 'http_request' && request.bodyType === 'graphql' request.model === 'http_request' && (request.bodyType === 'graphql' && !noAlias)
? 'graphql' ? 'graphql'
: request.model === 'grpc_request' : request.model === 'grpc_request'
? 'grpc' ? 'grpc'
+4
View File
@@ -44,6 +44,7 @@ import {
CookieIcon, CookieIcon,
CopyCheck, CopyCheck,
CopyIcon, CopyIcon,
CornerRightDownIcon,
CornerRightUpIcon, CornerRightUpIcon,
CreditCardIcon, CreditCardIcon,
CrosshairIcon, CrosshairIcon,
@@ -63,6 +64,7 @@ import {
FlaskConicalIcon, FlaskConicalIcon,
FolderCodeIcon, FolderCodeIcon,
FolderCogIcon, FolderCogIcon,
FolderDownIcon,
FolderGitIcon, FolderGitIcon,
FolderIcon, FolderIcon,
FolderInputIcon, FolderInputIcon,
@@ -179,6 +181,7 @@ const icons = {
cookie: CookieIcon, cookie: CookieIcon,
copy: CopyIcon, copy: CopyIcon,
copy_check: CopyCheck, copy_check: CopyCheck,
corner_right_down: CornerRightDownIcon,
corner_right_up: CornerRightUpIcon, corner_right_up: CornerRightUpIcon,
credit_card: CreditCardIcon, credit_card: CreditCardIcon,
crosshair: CrosshairIcon, crosshair: CrosshairIcon,
@@ -205,6 +208,7 @@ const icons = {
folder_output: FolderOutputIcon, folder_output: FolderOutputIcon,
folder_symlink: FolderSymlinkIcon, folder_symlink: FolderSymlinkIcon,
folder_sync: FolderSyncIcon, folder_sync: FolderSyncIcon,
folder_down: FolderDownIcon,
folder_up: FolderUpIcon, folder_up: FolderUpIcon,
gift: GiftIcon, gift: GiftIcon,
git_branch: GitBranchIcon, git_branch: GitBranchIcon,
+114 -34
View File
@@ -1,3 +1,4 @@
import { useCachedNode } from '@dnd-kit/core/dist/hooks/utilities';
import type { GitStatusEntry } from '@yaakapp-internal/git'; import type { GitStatusEntry } from '@yaakapp-internal/git';
import { useGit } from '@yaakapp-internal/git'; import { useGit } from '@yaakapp-internal/git';
import type { import type {
@@ -9,14 +10,16 @@ import type {
Workspace, Workspace,
} from '@yaakapp-internal/models'; } from '@yaakapp-internal/models';
import classNames from 'classnames'; import classNames from 'classnames';
import { useCallback, useMemo, useState } from 'react';
import { useMemo, useState } from 'react'; import { modelToYaml } from '../../lib/diffYaml';
import { isSubEnvironment } from '../../lib/model_util';
import { resolvedModelName } from '../../lib/resolvedModelName'; import { resolvedModelName } from '../../lib/resolvedModelName';
import { showErrorToast } from '../../lib/toast'; import { showErrorToast } from '../../lib/toast';
import { Banner } from '../core/Banner'; import { Banner } from '../core/Banner';
import { Button } from '../core/Button'; import { Button } from '../core/Button';
import type { CheckboxProps } from '../core/Checkbox'; import type { CheckboxProps } from '../core/Checkbox';
import { Checkbox } from '../core/Checkbox'; import { Checkbox } from '../core/Checkbox';
import { DiffViewer } from '../core/Editor/DiffViewer';
import { Icon } from '../core/Icon'; import { Icon } from '../core/Icon';
import { InlineCode } from '../core/InlineCode'; import { InlineCode } from '../core/InlineCode';
import { Input } from '../core/Input'; import { Input } from '../core/Input';
@@ -48,6 +51,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
const [isPushing, setIsPushing] = useState(false); const [isPushing, setIsPushing] = useState(false);
const [commitError, setCommitError] = useState<string | null>(null); const [commitError, setCommitError] = useState<string | null>(null);
const [message, setMessage] = useState<string>(''); const [message, setMessage] = useState<string>('');
const [selectedEntry, setSelectedEntry] = useState<GitStatusEntry | null>(null);
const handleCreateCommit = async () => { const handleCreateCommit = async () => {
setCommitError(null); setCommitError(null);
@@ -138,6 +142,35 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
return next(workspace, []); return next(workspace, []);
}, [workspace, internalEntries]); }, [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) { if (tree == null) {
return null; return null;
} }
@@ -146,27 +179,30 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
return <EmptyStateText>No changes since last commit</EmptyStateText>; 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 ( return (
<div className="grid grid-rows-1 h-full"> <div className="h-full px-2 pb-4">
<SplitLayout <SplitLayout
name="commit" name="commit-horizontal"
layout="vertical" layout="horizontal"
defaultRatio={0.3} defaultRatio={0.6}
firstSlot={({ style }) => ( firstSlot={({ style }) => (
<div style={style} className="h-full overflow-y-auto pb-3"> <div style={style} className="h-full px-4">
<TreeNodeChildren node={tree} depth={0} onCheck={checkNode} /> <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') && ( {externalEntries.find((e) => e.status !== 'current') && (
<> <>
<Separator className="mt-3 mb-1">External file changes</Separator> <Separator className="mt-3 mb-1">External file changes</Separator>
@@ -181,8 +217,8 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
)} )}
</div> </div>
)} )}
secondSlot={({ style }) => ( secondSlot={({ style: innerStyle }) => (
<div style={style} className="grid grid-rows-[minmax(0,1fr)_auto] gap-3 pb-2"> <div style={innerStyle} className="grid grid-rows-[minmax(0,1fr)_auto] gap-3 pb-2">
<Input <Input
className="!text-base font-sans rounded-md" className="!text-base font-sans rounded-md"
placeholder="Commit message..." placeholder="Commit message..."
@@ -221,6 +257,18 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
)} )}
/> />
</div> </div>
)}
secondSlot={({ style }) => (
<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>
)}
/>
</div>
); );
} }
@@ -228,29 +276,47 @@ function TreeNodeChildren({
node, node,
depth, depth,
onCheck, onCheck,
onSelect,
selectedPath,
}: { }: {
node: CommitTreeNode | null; node: CommitTreeNode | null;
depth: number; depth: number;
onCheck: (node: CommitTreeNode, checked: boolean) => void; onCheck: (node: CommitTreeNode, checked: boolean) => void;
onSelect: (entry: GitStatusEntry) => void;
selectedPath: string | null;
}) { }) {
if (node === null) return null; if (node === null) return null;
if (!isNodeRelevant(node)) return null; if (!isNodeRelevant(node)) return null;
const checked = nodeCheckedStatus(node); const checked = nodeCheckedStatus(node);
const isSelected = selectedPath === node.status.relaPath;
return ( return (
<div <div
className={classNames( 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 <Checkbox
fullWidth
className="w-full hover:bg-surface-highlight rounded px-1 group"
checked={checked} checked={checked}
title={checked ? 'Unstage change' : 'Stage change'}
hideLabel
onChange={(checked) => onCheck(node, checked)} onChange={(checked) => onCheck(node, checked)}
title={ />
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] gap-1 w-full items-center"> <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 !== 'http_request' &&
node.model.model !== 'grpc_request' && node.model.model !== 'grpc_request' &&
node.model.model !== 'websocket_request' ? ( node.model.model !== 'websocket_request' ? (
@@ -265,13 +331,13 @@ function TreeNodeChildren({
} }
/> />
) : ( ) : (
<span aria-hidden /> <span aria-hidden className="w-4" />
)} )}
<div className="truncate">{resolvedModelName(node.model)}</div> <div className="truncate flex-1">{resolvedModelName(node.model)}</div>
{node.status.status !== 'current' && ( {node.status.status !== 'current' && (
<InlineCode <InlineCode
className={classNames( className={classNames(
'py-0 ml-auto bg-transparent w-[6rem] text-center', 'py-0 bg-transparent w-[6rem] text-center shrink-0',
node.status.status === 'modified' && 'text-info', node.status.status === 'modified' && 'text-info',
node.status.status === 'untracked' && 'text-success', node.status.status === 'untracked' && 'text-success',
node.status.status === 'removed' && 'text-danger', node.status.status === 'removed' && 'text-danger',
@@ -280,9 +346,7 @@ function TreeNodeChildren({
{node.status.status} {node.status.status}
</InlineCode> </InlineCode>
)} )}
</div> </button>
}
/>
</div> </div>
{node.children.map((childNode) => { {node.children.map((childNode) => {
@@ -292,6 +356,8 @@ function TreeNodeChildren({
node={childNode} node={childNode}
depth={depth + 1} depth={depth + 1}
onCheck={onCheck} onCheck={onCheck}
onSelect={onSelect}
selectedPath={selectedPath}
/> />
); );
})} })}
@@ -401,3 +467,17 @@ function isNodeRelevant(node: CommitTreeNode): boolean {
// Recursively check children // Recursively check children
return node.children.some((c) => isNodeRelevant(c)); 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>
);
}
+252 -103
View File
@@ -17,7 +17,6 @@ import type { DropdownItem } from '../core/Dropdown';
import { Dropdown } from '../core/Dropdown'; import { Dropdown } from '../core/Dropdown';
import { Icon } from '../core/Icon'; import { Icon } from '../core/Icon';
import { InlineCode } from '../core/InlineCode'; import { InlineCode } from '../core/InlineCode';
import { BranchSelectionDialog } from './BranchSelectionDialog';
import { gitCallbacks } from './callbacks'; import { gitCallbacks } from './callbacks';
import { GitCommitDialog } from './GitCommitDialog'; import { GitCommitDialog } from './GitCommitDialog';
import { GitRemotesDialog } from './GitRemotesDialog'; import { GitRemotesDialog } from './GitRemotesDialog';
@@ -39,7 +38,18 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
const workspace = useAtomValue(activeWorkspaceAtom); const workspace = useAtomValue(activeWorkspaceAtom);
const [ const [
{ status, log }, { status, log },
{ branch, deleteBranch, fetchAll, mergeBranch, push, pull, checkout, init }, {
createBranch,
deleteBranch,
deleteRemoteBranch,
renameBranch,
fetchAll,
mergeBranch,
push,
pull,
checkout,
init,
},
] = useGit(syncDir, gitCallbacks(syncDir)); ] = useGit(syncDir, gitCallbacks(syncDir));
const localBranches = status.data?.localBranches ?? []; const localBranches = status.data?.localBranches ?? [];
@@ -47,8 +57,6 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
const remoteOnlyBranches = remoteBranches.filter( const remoteOnlyBranches = remoteBranches.filter(
(b) => !localBranches.includes(b.replace(/^origin\//, '')), (b) => !localBranches.includes(b.replace(/^origin\//, '')),
); );
const currentBranch = status.data?.headRefShorthand ?? 'UNKNOWN';
if (workspace == null) { if (workspace == null) {
return null; return null;
} }
@@ -58,6 +66,13 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
return <SetupGitDropdown workspaceId={workspace.id} initRepo={init.mutate} />; return <SetupGitDropdown workspaceId={workspace.id} initRepo={init.mutate} />;
} }
// Still loading
if (status.data == null) {
return null;
}
const currentBranch = status.data.headRefShorthand;
const tryCheckout = (branch: string, force: boolean) => { const tryCheckout = (branch: string, force: boolean) => {
checkout.mutate( checkout.mutate(
{ branch, force }, { branch, force },
@@ -104,7 +119,7 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
const items: DropdownItem[] = [ const items: DropdownItem[] = [
{ {
label: 'View History', label: 'View History...',
hidden: (log.data ?? []).length === 0, hidden: (log.data ?? []).length === 0,
leftSlot: <Icon icon="history" />, leftSlot: <Icon icon="history" />,
onSelect: async () => { onSelect: async () => {
@@ -118,13 +133,13 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
}, },
}, },
{ {
label: 'Manage Remotes', label: 'Manage Remotes...',
leftSlot: <Icon icon="hard_drive_download" />, leftSlot: <Icon icon="hard_drive_download" />,
onSelect: () => GitRemotesDialog.show(syncDir), onSelect: () => GitRemotesDialog.show(syncDir),
}, },
{ type: 'separator' }, { type: 'separator' },
{ {
label: 'New Branch', label: 'New Branch...',
leftSlot: <Icon icon="git_branch_plus" />, leftSlot: <Icon icon="git_branch_plus" />,
async onSelect() { async onSelect() {
const name = await showPrompt({ const name = await showPrompt({
@@ -134,7 +149,7 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
}); });
if (!name) return; if (!name) return;
await branch.mutateAsync( await createBranch.mutateAsync(
{ branch: name }, { branch: name },
{ {
disableToastError: true, disableToastError: true,
@@ -150,95 +165,6 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
tryCheckout(name, false); tryCheckout(name, false);
}, },
}, },
{
label: 'Merge Branch',
leftSlot: <Icon icon="merge" />,
hidden: localBranches.length <= 1,
async onSelect() {
showDialog({
id: 'git-merge',
title: 'Merge Branch',
size: 'sm',
description: (
<>
Select a branch to merge into <InlineCode>{currentBranch}</InlineCode>
</>
),
render: ({ hide }) => (
<BranchSelectionDialog
selectText="Merge"
branches={localBranches.filter((b) => b !== currentBranch)}
onCancel={hide}
onSelect={async (branch) => {
await mergeBranch.mutateAsync(
{ branch, force: false },
{
disableToastError: true,
onSettled: hide,
onSuccess() {
showToast({
id: 'git-merged-branch',
message: (
<>
Merged <InlineCode>{branch}</InlineCode> into{' '}
<InlineCode>{currentBranch}</InlineCode>
</>
),
});
sync({ force: true });
},
onError(err) {
showErrorToast({
id: 'git-merged-branch-error',
title: 'Error merging branch',
message: String(err),
});
},
},
);
}}
/>
),
});
},
},
{
label: 'Delete Branch',
leftSlot: <Icon icon="trash" />,
hidden: localBranches.length <= 1,
color: 'danger',
async onSelect() {
if (currentBranch == null) return;
const confirmed = await showConfirmDelete({
id: 'git-delete-branch',
title: 'Delete Branch',
description: (
<>
Permanently delete <InlineCode>{currentBranch}</InlineCode>?
</>
),
});
if (confirmed) {
await deleteBranch.mutateAsync(
{ branch: currentBranch },
{
disableToastError: true,
onError(err) {
showErrorToast({
id: 'git-delete-branch-error',
title: 'Error deleting branch',
message: String(err),
});
},
async onSuccess() {
await sync({ force: true });
},
},
);
}
},
},
{ type: 'separator' }, { type: 'separator' },
{ {
label: 'Push', label: 'Push',
@@ -278,14 +204,14 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
}, },
}, },
{ {
label: 'Commit', label: 'Commit...',
leftSlot: <Icon icon="git_commit_vertical" />, leftSlot: <Icon icon="git_commit_vertical" />,
onSelect() { onSelect() {
showDialog({ showDialog({
id: 'commit', id: 'commit',
title: 'Commit Changes', title: 'Commit Changes',
size: 'full', size: 'full',
className: '!max-h-[min(80vh,40rem)] !max-w-[min(50rem,90vw)]', noPadding: true,
render: ({ hide }) => ( render: ({ hide }) => (
<GitCommitDialog syncDir={syncDir} onDone={hide} workspace={workspace} /> <GitCommitDialog syncDir={syncDir} onDone={hide} workspace={workspace} />
), ),
@@ -298,16 +224,239 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
return { return {
label: branch, label: branch,
leftSlot: <Icon icon={isCurrent ? 'check' : 'empty'} />, leftSlot: <Icon icon={isCurrent ? 'check' : 'empty'} />,
onSelect: isCurrent ? undefined : () => tryCheckout(branch, false), submenuOpenOnClick: true,
}; submenu: [
{
label: 'Checkout',
hidden: isCurrent,
onSelect: () => tryCheckout(branch, false),
},
{
label: (
<>
Merge into <InlineCode>{currentBranch}</InlineCode>
</>
),
hidden: isCurrent,
async onSelect() {
await mergeBranch.mutateAsync(
{ branch },
{
disableToastError: true,
onSuccess() {
showToast({
id: 'git-merged-branch',
message: (
<>
Merged <InlineCode>{branch}</InlineCode> into{' '}
<InlineCode>{currentBranch}</InlineCode>
</>
),
});
sync({ force: true });
},
onError(err) {
showErrorToast({
id: 'git-merged-branch-error',
title: 'Error merging branch',
message: String(err),
});
},
},
);
},
},
{
label: 'New Branch...',
async onSelect() {
const name = await showPrompt({
id: 'git-new-branch-from',
title: 'New Branch',
description: (
<>
Create a new branch from <InlineCode>{branch}</InlineCode>
</>
),
label: 'Branch Name',
});
if (!name) return;
await createBranch.mutateAsync(
{ branch: name, base: branch },
{
disableToastError: true,
onError: (err) => {
showErrorToast({
id: 'git-branch-error',
title: 'Error creating branch',
message: String(err),
});
},
},
);
tryCheckout(name, false);
},
},
{
label: 'Rename...',
async onSelect() {
const newName = await showPrompt({
id: 'git-rename-branch',
title: 'Rename Branch',
label: 'New Branch Name',
defaultValue: branch,
});
if (!newName || newName === branch) return;
await renameBranch.mutateAsync(
{ oldName: branch, newName },
{
disableToastError: true,
onSuccess() {
showToast({
id: 'git-rename-branch-success',
message: (
<>
Renamed <InlineCode>{branch}</InlineCode> to{' '}
<InlineCode>{newName}</InlineCode>
</>
),
color: 'success',
});
},
onError(err) {
showErrorToast({
id: 'git-rename-branch-error',
title: 'Error renaming branch',
message: String(err),
});
},
},
);
},
},
{ type: 'separator', hidden: isCurrent },
{
label: 'Delete',
color: 'danger',
hidden: isCurrent,
onSelect: async () => {
const confirmed = await showConfirmDelete({
id: 'git-delete-branch',
title: 'Delete Branch',
description: (
<>
Permanently delete <InlineCode>{branch}</InlineCode>?
</>
),
});
if (!confirmed) {
return;
}
const result = await deleteBranch.mutateAsync(
{ branch },
{
disableToastError: true,
onError(err) {
showErrorToast({
id: 'git-delete-branch-error',
title: 'Error deleting branch',
message: String(err),
});
},
},
);
if (result.type === 'not_fully_merged') {
const confirmed = await showConfirm({
id: 'force-branch-delete',
title: 'Branch not fully merged',
description: (
<>
<p>
Branch <InlineCode>{branch}</InlineCode> is not fully merged.
</p>
<p>Do you want to delete it anyway?</p>
</>
),
});
if (confirmed) {
await deleteBranch.mutateAsync(
{ branch, force: true },
{
disableToastError: true,
onError(err) {
showErrorToast({
id: 'git-force-delete-branch-error',
title: 'Error force deleting branch',
message: String(err),
});
},
},
);
}
}
},
},
],
} satisfies DropdownItem;
}), }),
...remoteOnlyBranches.map((branch) => { ...remoteOnlyBranches.map((branch) => {
const isCurrent = currentBranch === branch; const isCurrent = currentBranch === branch;
return { return {
label: branch, label: branch,
leftSlot: <Icon icon={isCurrent ? 'check' : 'empty'} />, leftSlot: <Icon icon={isCurrent ? 'check' : 'empty'} />,
onSelect: isCurrent ? undefined : () => tryCheckout(branch, false), submenuOpenOnClick: true,
}; submenu: [
{
label: 'Checkout',
hidden: isCurrent,
onSelect: () => tryCheckout(branch, false),
},
{
label: 'Delete',
color: 'danger',
async onSelect() {
const confirmed = await showConfirmDelete({
id: 'git-delete-remote-branch',
title: 'Delete Remote Branch',
description: (
<>
Permanently delete <InlineCode>{branch}</InlineCode> from the remote?
</>
),
});
if (!confirmed) return;
await deleteRemoteBranch.mutateAsync(
{ branch },
{
disableToastError: true,
onSuccess() {
showToast({
id: 'git-delete-remote-branch-success',
message: (
<>
Deleted remote branch <InlineCode>{branch}</InlineCode>
</>
),
color: 'success',
});
},
onError(err) {
showErrorToast({
id: 'git-delete-remote-branch-error',
title: 'Error deleting remote branch',
message: String(err),
});
},
},
);
},
},
],
} satisfies DropdownItem;
}), }),
]; ];
+5 -37
View File
@@ -1,7 +1,5 @@
import type { GitCallbacks } from '@yaakapp-internal/git'; import type { GitCallbacks } from '@yaakapp-internal/git';
import { showPromptForm } from '../../lib/prompt-form'; import { promptCredentials } from './credentials';
import { Banner } from '../core/Banner';
import { InlineCode } from '../core/InlineCode';
import { addGitRemote } from './showAddRemoteDialog'; import { addGitRemote } from './showAddRemoteDialog';
export function gitCallbacks(dir: string): GitCallbacks { export function gitCallbacks(dir: string): GitCallbacks {
@@ -9,40 +7,10 @@ export function gitCallbacks(dir: string): GitCallbacks {
addRemote: async () => { addRemote: async () => {
return addGitRemote(dir); return addGitRemote(dir);
}, },
promptCredentials: async ({ url: remoteUrl, error }) => { promptCredentials: async ({ url, error }) => {
const isGitHub = /github\.com/i.test(remoteUrl); const creds = await promptCredentials({ url, error });
const userLabel = isGitHub ? 'GitHub Username' : 'Username'; if (creds == null) throw new Error('Cancelled credentials prompt');
const passLabel = isGitHub ? 'GitHub Personal Access Token' : 'Password / Token'; return creds;
const userDescription = isGitHub ? 'Use your GitHub username (not your email).' : undefined;
const passDescription = isGitHub
? 'GitHub requires a Personal Access Token (PAT) for write operations over HTTPS. Passwords are not supported.'
: 'Enter your password or access token for this Git server.';
const r = await showPromptForm({
id: 'git-credentials',
title: 'Credentials Required',
description: error ? (
<Banner color="danger">{error}</Banner>
) : (
<>
Enter credentials for <InlineCode>{remoteUrl}</InlineCode>
</>
),
inputs: [
{ type: 'text', name: 'username', label: userLabel, description: userDescription },
{
type: 'text',
name: 'password',
label: passLabel,
description: passDescription,
password: true,
},
],
});
if (r == null) throw new Error('Cancelled credentials prompt');
const username = String(r.username || '');
const password = String(r.password || '');
return { username, password };
}, },
}; };
} }
+50
View File
@@ -0,0 +1,50 @@
import { showPromptForm } from '../../lib/prompt-form';
import { Banner } from '../core/Banner';
import { InlineCode } from '../core/InlineCode';
export interface GitCredentials {
username: string;
password: string;
}
export async function promptCredentials({
url: remoteUrl,
error,
}: {
url: string;
error: string | null;
}): Promise<GitCredentials | null> {
const isGitHub = /github\.com/i.test(remoteUrl);
const userLabel = isGitHub ? 'GitHub Username' : 'Username';
const passLabel = isGitHub ? 'GitHub Personal Access Token' : 'Password / Token';
const userDescription = isGitHub ? 'Use your GitHub username (not your email).' : undefined;
const passDescription = isGitHub
? 'GitHub requires a Personal Access Token (PAT) for write operations over HTTPS. Passwords are not supported.'
: 'Enter your password or access token for this Git server.';
const r = await showPromptForm({
id: 'git-credentials',
title: 'Credentials Required',
description: error ? (
<Banner color="danger">{error}</Banner>
) : (
<>
Enter credentials for <InlineCode>{remoteUrl}</InlineCode>
</>
),
inputs: [
{ type: 'text', name: 'username', label: userLabel, description: userDescription },
{
type: 'text',
name: 'password',
label: passLabel,
description: passDescription,
password: true,
},
],
});
if (r == null) return null;
const username = String(r.username || '');
const password = String(r.password || '');
return { username, password };
}
@@ -51,10 +51,9 @@ function ActualEventStreamViewer({ response }: Props) {
<span className="truncate text-xs">{event.data.slice(0, 1000)}</span> <span className="truncate text-xs">{event.data.slice(0, 1000)}</span>
</HStack> </HStack>
} }
/> />
)} )}
renderDetail={({ event, index }) => ( renderDetail={({ event, index, onClose }) => (
<EventDetail <EventDetail
event={event} event={event}
index={index} index={index}
@@ -62,6 +61,7 @@ function ActualEventStreamViewer({ response }: Props) {
showingLarge={showingLarge} showingLarge={showingLarge}
setShowLarge={setShowLarge} setShowLarge={setShowLarge}
setShowingLarge={setShowingLarge} setShowingLarge={setShowingLarge}
onClose={onClose}
/> />
)} )}
/> />
@@ -75,6 +75,7 @@ function EventDetail({
showingLarge, showingLarge,
setShowLarge, setShowLarge,
setShowingLarge, setShowingLarge,
onClose,
}: { }: {
event: ServerSentEvent; event: ServerSentEvent;
index: number; index: number;
@@ -82,6 +83,7 @@ function EventDetail({
showingLarge: boolean; showingLarge: boolean;
setShowLarge: (v: boolean) => void; setShowLarge: (v: boolean) => void;
setShowingLarge: (v: boolean) => void; setShowingLarge: (v: boolean) => void;
onClose: () => void;
}) { }) {
const language = useMemo<'text' | 'json'>(() => { const language = useMemo<'text' | 'json'>(() => {
if (!event?.data) return 'text'; if (!event?.data) return 'text';
@@ -90,7 +92,11 @@ function EventDetail({
return ( return (
<div className="flex flex-col h-full"> <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 ? ( {!showLarge && event.data.length > 1000 * 1000 ? (
<VStack space={2} className="italic text-text-subtlest"> <VStack space={2} className="italic text-text-subtlest">
Message previews larger than 1MB are hidden Message previews larger than 1MB are hidden
@@ -56,10 +56,10 @@ export function MultipartViewer({ data, boundary, idPrefix = 'multipart' }: Prop
addBorders addBorders
label="Multipart" label="Multipart"
layout="horizontal" layout="horizontal"
tabListClassName="border-r border-r-border" tabListClassName="border-r border-r-border -ml-3"
tabs={parts.map((part) => ({ tabs={parts.map((part, i) => ({
label: part.name ?? '', label: part.name ?? '',
value: part.name ?? '', value: tabValue(part, i),
rightSlot: rightSlot:
part.filename && part.headers.contentType.mediaType?.startsWith('image/') ? ( part.filename && part.headers.contentType.mediaType?.startsWith('image/') ? (
<div className="h-5 w-5 overflow-auto flex items-center justify-end"> <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 <TabContent
// biome-ignore lint/suspicious/noArrayIndexKey: Nothing else to key on // biome-ignore lint/suspicious/noArrayIndexKey: Nothing else to key on
key={idPrefix + part.name + i} key={idPrefix + part.name + i}
value={part.name ?? ''} value={tabValue(part, i)}
className="pl-3 !pt-0" className="pl-3 !pt-0"
> >
<Part part={part} /> <Part part={part} />
@@ -115,7 +115,7 @@ function Part({ part }: { part: MultipartPart }) {
} }
if (mimeType?.match(/csv|tab-separated/i)) { 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') { 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} />; return <TextViewer text={content} language={detectedLanguage} stateKey={null} />;
} }
function tabValue(part: MultipartPart, i: number) {
return `${part.name ?? ''}::${i}`;
}
+60 -6
View File
@@ -1,5 +1,5 @@
import type { Folder } from '@yaakapp-internal/models'; 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 { useMemo } from 'react';
import { openFolderSettings } from '../commands/openFolderSettings'; import { openFolderSettings } from '../commands/openFolderSettings';
import { openWorkspaceSettings } from '../commands/openWorkspaceSettings'; import { openWorkspaceSettings } from '../commands/openWorkspaceSettings';
@@ -57,12 +57,20 @@ export function useAuthTab<T extends string>(tabValue: T, model: AuthenticatedMo
}, },
{ label: 'No Auth', shortLabel: 'No Auth', value: 'none' }, { label: 'No Auth', shortLabel: 'No Auth', value: 'none' },
], ],
itemsAfter: itemsAfter: (() => {
const actions: (
| { type: 'separator'; label: string }
| { label: string; leftSlot: React.ReactNode; onSelect: () => Promise<void> }
)[] = [];
// Promote: move auth from current model up to parent
if (
parentModel && parentModel &&
model.authenticationType && model.authenticationType &&
model.authenticationType !== 'none' && model.authenticationType !== 'none' &&
(parentModel.authenticationType == null || parentModel.authenticationType === 'none') (parentModel.authenticationType == null || parentModel.authenticationType === 'none')
? [ ) {
actions.push(
{ type: 'separator', label: 'Actions' }, { type: 'separator', label: 'Actions' },
{ {
label: `Promote to ${capitalize(parentModel.model)}`, label: `Promote to ${capitalize(parentModel.model)}`,
@@ -98,8 +106,54 @@ export function useAuthTab<T extends string>(tabValue: T, model: AuthenticatedMo
} }
}, },
}, },
] );
: 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) => { onChange: async (authenticationType) => {
let authentication: Folder['authentication'] = model.authentication; let authentication: Folder['authentication'] = model.authentication;
if (model.authenticationType !== authenticationType) { if (model.authenticationType !== authenticationType) {
@@ -113,5 +167,5 @@ export function useAuthTab<T extends string>(tabValue: T, model: AuthenticatedMo
}; };
return [tab]; return [tab];
}, [authentication, inheritedAuth, model, parentModel, tabValue]); }, [authentication, inheritedAuth, model, parentModel, tabValue, ancestors]);
} }
+11 -5
View File
@@ -23,14 +23,17 @@ export function createFastMutation<TData = unknown, TError = unknown, TVariables
variables: TVariables, variables: TVariables,
args?: CallbackMutationOptions<TData, TError, TVariables>, args?: CallbackMutationOptions<TData, TError, TVariables>,
) => { ) => {
const { mutationKey, mutationFn, onSuccess, onError, onSettled, disableToastError } = { const { mutationKey, mutationFn, disableToastError } = {
...defaultArgs, ...defaultArgs,
...args, ...args,
}; };
try { try {
const data = await mutationFn(variables); const data = await mutationFn(variables);
onSuccess?.(data); // Run both default and custom onSuccess callbacks
onSettled?.(); defaultArgs.onSuccess?.(data);
args?.onSuccess?.(data);
defaultArgs.onSettled?.();
args?.onSettled?.();
return data; return data;
} catch (err: unknown) { } catch (err: unknown) {
const stringKey = mutationKey.join('.'); const stringKey = mutationKey.join('.');
@@ -44,8 +47,11 @@ export function createFastMutation<TData = unknown, TError = unknown, TVariables
timeout: 5000, timeout: 5000,
}); });
} }
onError?.(e); // Run both default and custom onError callbacks
onSettled?.(); defaultArgs.onError?.(e);
args?.onError?.(e);
defaultArgs.onSettled?.();
args?.onSettled?.();
throw e; throw e;
} }
}; };
+1
View File
@@ -8,6 +8,7 @@ export interface AppInfo {
appDataDir: string; appDataDir: string;
appLogDir: string; appLogDir: string;
vendoredPluginDir: string; vendoredPluginDir: string;
defaultProjectDir: string;
identifier: string; identifier: string;
featureLicense: boolean; featureLicense: boolean;
featureUpdater: boolean; featureUpdater: boolean;
+15
View 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,
});
}
+2
View File
@@ -25,6 +25,8 @@ type TauriCmd =
| 'cmd_get_sse_events' | 'cmd_get_sse_events'
| 'cmd_get_themes' | 'cmd_get_themes'
| 'cmd_get_workspace_meta' | 'cmd_get_workspace_meta'
| 'cmd_git_add_credential'
| 'cmd_git_clone'
| 'cmd_grpc_go' | 'cmd_grpc_go'
| 'cmd_grpc_reflect' | 'cmd_grpc_reflect'
| 'cmd_grpc_request_actions' | 'cmd_grpc_request_actions'
+13 -2
View File
@@ -104,11 +104,12 @@ function templateTagColorVariables(color: YaakColor | null): Partial<CSSVariable
if (color == null) return {}; if (color == null) return {};
return { return {
text: color.lift(0.6).css(), text: color.lift(0.7).css(),
textSubtle: color.lift(0.4).css(), textSubtle: color.lift(0.4).css(),
textSubtlest: color.css(), textSubtlest: color.css(),
surface: color.lower(0.2).translucify(0.8).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(), 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( function buttonSolidColorVariables(
color: YaakColor | null, color: YaakColor | null,
isDefault = false, isDefault = false,
+2
View File
@@ -14,7 +14,9 @@
"@codemirror/lang-json": "^6.0.1", "@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-markdown": "^6.3.2", "@codemirror/lang-markdown": "^6.3.2",
"@codemirror/lang-xml": "^6.1.0", "@codemirror/lang-xml": "^6.1.0",
"@codemirror/lang-yaml": "^6.1.2",
"@codemirror/language": "^6.11.0", "@codemirror/language": "^6.11.0",
"@codemirror/merge": "^6.11.2",
"@codemirror/search": "^6.5.11", "@codemirror/search": "^6.5.11",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@gilbarbara/deep-equal": "^0.3.1", "@gilbarbara/deep-equal": "^0.3.1",