Compare commits

..

3 Commits

Author SHA1 Message Date
Gregory Schier
f3bd552b15 Fix lint 2026-02-05 15:46:59 -08:00
Gregory Schier
3ad57c125f Add syntax highlighting for all httpsnippet languages
Added 15 new EditorLanguage variants: c, clojure, csharp, go, java,
kotlin, objective_c, ocaml, php, powershell, python, r, ruby, shell,
swift.

Uses first-class CodeMirror packages for go, java, php, python and
@codemirror/legacy-modes for the rest. Also sorts preferred clients
(fetch) to the top for JavaScript/Node targets.
2026-02-05 15:44:36 -08:00
Gregory Schier
09e78d9210 Add dynamic() support to prompt.form() plugin API
- prompt.form() inputs can now have dynamic() callbacks that update
  reactively when form values change (same pattern as auth/template plugins)
- Changed PromptFormRequest routing from one-shot to bidirectional events
- Added PromptFormResponse.done field to distinguish intermediate updates
- Added optional size (enum) to PromptFormRequest for dialog sizing
- Added optional rows to FormInputEditor for fixed height editors
- New httpsnippet plugin: generates code snippets with dynamic language
  and library selectors that update the code preview in real-time
2026-02-05 15:17:39 -08:00
21 changed files with 909 additions and 62 deletions

24
Cargo.lock generated
View File

@@ -1316,12 +1316,12 @@ checksum = "da692b8d1080ea3045efaab14434d40468c3d8657e42abddfffca87b428f4c1b"
[[package]]
name = "deranged"
version = "0.5.5"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587"
checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e"
dependencies = [
"powerfmt",
"serde_core",
"serde",
]
[[package]]
@@ -3446,9 +3446,9 @@ checksum = "5e0826a989adedc2a244799e823aece04662b66609d96af8dff7ac6df9a8925d"
[[package]]
name = "num-conv"
version = "0.2.0"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
[[package]]
name = "num-traits"
@@ -6341,9 +6341,9 @@ dependencies = [
[[package]]
name = "time"
version = "0.3.47"
version = "0.3.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40"
dependencies = [
"deranged",
"itoa",
@@ -6351,22 +6351,22 @@ dependencies = [
"num-conv",
"num_threads",
"powerfmt",
"serde_core",
"serde",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.8"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c"
[[package]]
name = "time-macros"
version = "0.2.27"
version = "0.2.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49"
dependencies = [
"num-conv",
"time-core",

View File

@@ -12,7 +12,7 @@ use chrono::Utc;
use cookie::Cookie;
use log::error;
use std::sync::Arc;
use tauri::{AppHandle, Emitter, Manager, Runtime};
use tauri::{AppHandle, Emitter, Listener, Manager, Runtime};
use tauri_plugin_clipboard_manager::ClipboardExt;
use tauri_plugin_opener::OpenerExt;
use yaak_crypto::manager::EncryptionManager;
@@ -59,7 +59,55 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
}
InternalEventPayload::PromptFormRequest(_) => {
let window = get_window_from_plugin_context(app_handle, &plugin_context)?;
Ok(call_frontend(&window, event).await)
if event.reply_id.is_some() {
// Follow-up update from plugin runtime with resolved inputs — forward to frontend
window.emit_to(window.label(), "plugin_event", event.clone())?;
Ok(None)
} else {
// Initial request — set up bidirectional communication
window.emit_to(window.label(), "plugin_event", event.clone()).unwrap();
let event_id = event.id.clone();
let plugin_handle = plugin_handle.clone();
let plugin_context = plugin_context.clone();
let window = window.clone();
// Spawn async task to handle bidirectional form communication
tauri::async_runtime::spawn(async move {
let (tx, mut rx) = tokio::sync::mpsc::channel::<InternalEvent>(128);
// Listen for replies from the frontend
let listener_id = window.listen(event_id, move |ev: tauri::Event| {
let resp: InternalEvent = serde_json::from_str(ev.payload()).unwrap();
let _ = tx.try_send(resp);
});
// Forward each reply to the plugin runtime
while let Some(resp) = rx.recv().await {
let is_done = matches!(
&resp.payload,
InternalEventPayload::PromptFormResponse(r) if r.done.unwrap_or(false)
);
let event_to_send = plugin_handle.build_event_to_send(
&plugin_context,
&resp.payload,
Some(resp.reply_id.unwrap_or_default()),
);
if let Err(e) = plugin_handle.send(&event_to_send).await {
log::warn!("Failed to forward form response to plugin: {:?}", e);
}
if is_done {
break;
}
}
window.unlisten(listener_id);
});
Ok(None)
}
}
InternalEventPayload::FindHttpResponsesRequest(req) => {
let http_responses = app_handle

View File

@@ -66,7 +66,7 @@ export type DeleteModelRequest = { model: string, id: string, };
export type DeleteModelResponse = { model: AnyModel, };
export type EditorLanguage = "text" | "javascript" | "json" | "html" | "xml" | "graphql" | "markdown";
export type EditorLanguage = "text" | "javascript" | "json" | "html" | "xml" | "graphql" | "markdown" | "c" | "clojure" | "csharp" | "go" | "java" | "kotlin" | "objective_c" | "ocaml" | "php" | "powershell" | "python" | "r" | "ruby" | "shell" | "swift";
export type EmptyPayload = {};
@@ -172,7 +172,11 @@ hideGutter?: boolean,
/**
* Language for syntax highlighting
*/
language?: EditorLanguage, readOnly?: boolean, completionOptions?: Array<GenericCompletionOption>,
language?: EditorLanguage, readOnly?: boolean,
/**
* Fixed number of visible rows
*/
rows?: number, completionOptions?: Array<GenericCompletionOption>,
/**
* The name of the input. The value will be stored at this object attribute in the resulting data
*/
@@ -476,9 +480,11 @@ label: string, title?: string, size?: WindowSize, dataDirKey?: string, };
export type PluginContext = { id: string, label: string | null, workspaceId: string | null, };
export type PromptFormRequest = { id: string, title: string, description?: string, inputs: Array<FormInput>, confirmText?: string, cancelText?: string, };
export type PromptFormRequest = { id: string, title: string, description?: string, inputs: Array<FormInput>, confirmText?: string, cancelText?: string, size?: PromptFormSize, };
export type PromptFormResponse = { values: { [key in string]?: JsonPrimitive } | null, };
export type PromptFormResponse = { values: { [key in string]?: JsonPrimitive } | null, done?: boolean, };
export type PromptFormSize = "sm" | "md" | "lg" | "full" | "dynamic";
export type PromptTextRequest = { id: string, title: string, label: string, description?: string, defaultValue?: string, placeholder?: string,
/**

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.
* 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, };

View File

@@ -587,6 +587,19 @@ pub struct PromptFormRequest {
pub confirm_text: Option<String>,
#[ts(optional)]
pub cancel_text: Option<String>,
#[ts(optional)]
pub size: Option<PromptFormSize>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "gen_events.ts")]
pub enum PromptFormSize {
Sm,
Md,
Lg,
Full,
Dynamic,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
@@ -594,6 +607,8 @@ pub struct PromptFormRequest {
#[ts(export, export_to = "gen_events.ts")]
pub struct PromptFormResponse {
pub values: Option<HashMap<String, JsonPrimitive>>,
#[ts(optional)]
pub done: Option<bool>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
@@ -936,6 +951,21 @@ pub enum EditorLanguage {
Xml,
Graphql,
Markdown,
C,
Clojure,
Csharp,
Go,
Java,
Kotlin,
ObjectiveC,
Ocaml,
Php,
Powershell,
Python,
R,
Ruby,
Shell,
Swift,
}
impl Default for EditorLanguage {
@@ -966,6 +996,10 @@ pub struct FormInputEditor {
#[ts(optional)]
pub read_only: Option<bool>,
/// Fixed number of visible rows
#[ts(optional)]
pub rows: Option<i32>,
#[ts(optional)]
pub completion_options: Option<Vec<GenericCompletionOption>>,
}

193
package-lock.json generated
View File

@@ -13,6 +13,7 @@
"packages/plugin-runtime-types",
"plugins-external/mcp-server",
"plugins-external/template-function-faker",
"plugins-external/httpsnippet",
"plugins/action-copy-curl",
"plugins/action-copy-grpcurl",
"plugins/action-send-folder",
@@ -62,6 +63,13 @@
"crates/yaak-ws",
"src-web"
],
"dependencies": {
"@codemirror/lang-go": "^6.0.1",
"@codemirror/lang-java": "^6.0.2",
"@codemirror/lang-php": "^6.0.2",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/legacy-modes": "^6.5.2"
},
"devDependencies": {
"@biomejs/biome": "^2.3.13",
"@tauri-apps/cli": "^2.9.6",
@@ -736,6 +744,19 @@
"@lezer/css": "^1.1.7"
}
},
"node_modules/@codemirror/lang-go": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-go/-/lang-go-6.0.1.tgz",
"integrity": "sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.6.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.0",
"@lezer/go": "^1.0.0"
}
},
"node_modules/@codemirror/lang-html": {
"version": "6.4.11",
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz",
@@ -753,6 +774,16 @@
"@lezer/html": "^1.3.12"
}
},
"node_modules/@codemirror/lang-java": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-java/-/lang-java-6.0.2.tgz",
"integrity": "sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@lezer/java": "^1.0.0"
}
},
"node_modules/@codemirror/lang-javascript": {
"version": "6.2.4",
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz",
@@ -793,6 +824,32 @@
"@lezer/markdown": "^1.0.0"
}
},
"node_modules/@codemirror/lang-php": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-php/-/lang-php-6.0.2.tgz",
"integrity": "sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==",
"license": "MIT",
"dependencies": {
"@codemirror/lang-html": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.0",
"@lezer/php": "^1.0.0"
}
},
"node_modules/@codemirror/lang-python": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.2.1.tgz",
"integrity": "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.3.2",
"@codemirror/language": "^6.8.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.2.1",
"@lezer/python": "^1.1.4"
}
},
"node_modules/@codemirror/lang-xml": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@codemirror/lang-xml/-/lang-xml-6.1.0.tgz",
@@ -836,6 +893,15 @@
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/legacy-modes": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.2.tgz",
"integrity": "sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "6.9.2",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.2.tgz",
@@ -1570,6 +1636,17 @@
"lezer-generator": "src/lezer-generator.cjs"
}
},
"node_modules/@lezer/go": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@lezer/go/-/go-1.0.1.tgz",
"integrity": "sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/highlight": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
@@ -1590,6 +1667,17 @@
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/java": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@lezer/java/-/java-1.1.3.tgz",
"integrity": "sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/javascript": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz",
@@ -1631,6 +1719,28 @@
"@lezer/highlight": "^1.0.0"
}
},
"node_modules/@lezer/php": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@lezer/php/-/php-1.0.5.tgz",
"integrity": "sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.1.0"
}
},
"node_modules/@lezer/python": {
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.18.tgz",
"integrity": "sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/xml": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.6.tgz",
@@ -2022,6 +2132,19 @@
"node": ">=16.9"
}
},
"node_modules/@readme/httpsnippet": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/@readme/httpsnippet/-/httpsnippet-11.0.0.tgz",
"integrity": "sha512-XSyaAsJkZfmMO9R4WDlVJARZgd4wlImftSkMkKclidniXA1h6DTya9iTqJenQo9mHQLh3u6kAC3CDRaIV+LbLw==",
"license": "MIT",
"dependencies": {
"qs": "^6.11.2",
"stringify-object": "^3.3.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@replit/codemirror-emacs": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@replit/codemirror-emacs/-/codemirror-emacs-6.1.0.tgz",
@@ -4045,6 +4168,10 @@
"resolved": "plugins/filter-xpath",
"link": true
},
"node_modules/@yaak/httpsnippet": {
"resolved": "plugins-external/httpsnippet",
"link": true
},
"node_modules/@yaak/importer-curl": {
"resolved": "plugins/importer-curl",
"link": true
@@ -7411,6 +7538,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-own-enumerable-property-symbols": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz",
"integrity": "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==",
"license": "ISC"
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
@@ -8529,6 +8662,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-obj": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz",
"integrity": "sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-plain-obj": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
@@ -13789,6 +13931,29 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/stringify-object": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz",
"integrity": "sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==",
"license": "BSD-2-Clause",
"dependencies": {
"get-own-enumerable-property-symbols": "^3.0.0",
"is-obj": "^1.0.1",
"is-regexp": "^1.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/stringify-object/node_modules/is-regexp": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz",
"integrity": "sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/strip-ansi": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
@@ -15788,6 +15953,34 @@
"undici-types": "~7.16.0"
}
},
"plugins-external/httpsnippet": {
"name": "@yaak/httpsnippet",
"version": "1.0.0",
"dependencies": {
"@readme/httpsnippet": "^11.0.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.9.3"
}
},
"plugins-external/httpsnippet/node_modules/@types/node": {
"version": "22.19.9",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.9.tgz",
"integrity": "sha512-PD03/U8g1F9T9MI+1OBisaIARhSzeidsUjQaf51fOxrfjeiKN9bLVO06lHuHYjxdnqLWJijJHfqXPSJri2EM2A==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"plugins-external/httpsnippet/node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"plugins-external/mcp-server": {
"name": "@yaak/mcp-server",
"version": "0.1.7",

View File

@@ -12,6 +12,7 @@
"packages/plugin-runtime-types",
"plugins-external/mcp-server",
"plugins-external/template-function-faker",
"plugins-external/httpsnippet",
"plugins/action-copy-curl",
"plugins/action-copy-grpcurl",
"plugins/action-send-folder",
@@ -104,5 +105,12 @@
"npm-run-all": "^4.1.5",
"typescript": "^5.8.3",
"vitest": "^3.2.4"
},
"dependencies": {
"@codemirror/lang-go": "^6.0.1",
"@codemirror/lang-java": "^6.0.2",
"@codemirror/lang-php": "^6.0.2",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/legacy-modes": "^6.5.2"
}
}

View File

@@ -66,7 +66,7 @@ export type DeleteModelRequest = { model: string, id: string, };
export type DeleteModelResponse = { model: AnyModel, };
export type EditorLanguage = "text" | "javascript" | "json" | "html" | "xml" | "graphql" | "markdown";
export type EditorLanguage = "text" | "javascript" | "json" | "html" | "xml" | "graphql" | "markdown" | "c" | "clojure" | "csharp" | "go" | "java" | "kotlin" | "objective_c" | "ocaml" | "php" | "powershell" | "python" | "r" | "ruby" | "shell" | "swift";
export type EmptyPayload = {};
@@ -172,7 +172,11 @@ hideGutter?: boolean,
/**
* Language for syntax highlighting
*/
language?: EditorLanguage, readOnly?: boolean, completionOptions?: Array<GenericCompletionOption>,
language?: EditorLanguage, readOnly?: boolean,
/**
* Fixed number of visible rows
*/
rows?: number, completionOptions?: Array<GenericCompletionOption>,
/**
* The name of the input. The value will be stored at this object attribute in the resulting data
*/
@@ -476,9 +480,11 @@ label: string, title?: string, size?: WindowSize, dataDirKey?: string, };
export type PluginContext = { id: string, label: string | null, workspaceId: string | null, };
export type PromptFormRequest = { id: string, title: string, description?: string, inputs: Array<FormInput>, confirmText?: string, cancelText?: string, };
export type PromptFormRequest = { id: string, title: string, description?: string, inputs: Array<FormInput>, confirmText?: string, cancelText?: string, size?: PromptFormSize, };
export type PromptFormResponse = { values: { [key in string]?: JsonPrimitive } | null, };
export type PromptFormResponse = { values: { [key in string]?: JsonPrimitive } | null, done?: boolean, };
export type PromptFormSize = "sm" | "md" | "lg" | "full" | "dynamic";
export type PromptTextRequest = { id: string, title: string, label: string, description?: string, defaultValue?: string, placeholder?: string,
/**

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.
* 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, };

View File

@@ -27,6 +27,11 @@ import type {
} from '../bindings/gen_events.ts';
import type { Folder, HttpRequest } from '../bindings/gen_models.ts';
import type { JsonValue } from '../bindings/serde_json/JsonValue';
import type { DynamicPromptFormArg } from './PromptFormPlugin';
type DynamicPromptFormRequest = Omit<PromptFormRequest, 'inputs'> & {
inputs: DynamicPromptFormArg[];
};
export type WorkspaceHandle = Pick<WorkspaceInfo, 'id' | 'name'>;
@@ -39,7 +44,7 @@ export interface Context {
};
prompt: {
text(args: PromptTextRequest): Promise<PromptTextResponse['value']>;
form(args: PromptFormRequest): Promise<PromptFormResponse['values']>;
form(args: DynamicPromptFormRequest): Promise<PromptFormResponse['values']>;
};
store: {
set<T>(key: string, value: T): Promise<void>;

View File

@@ -0,0 +1,31 @@
import type { FormInput, JsonPrimitive } from '../bindings/gen_events';
import type { MaybePromise } from '../helpers';
import type { Context } from './Context';
export type CallPromptFormDynamicArgs = {
values: { [key in string]?: JsonPrimitive };
};
type AddDynamicMethod<T> = {
dynamic?: (
ctx: Context,
args: CallPromptFormDynamicArgs,
) => MaybePromise<Partial<T> | null | undefined>;
};
// biome-ignore lint/suspicious/noExplicitAny: distributive conditional type pattern
type AddDynamic<T> = T extends any
? T extends { inputs?: FormInput[] }
? Omit<T, 'inputs'> & {
inputs: Array<AddDynamic<FormInput>>;
dynamic?: (
ctx: Context,
args: CallPromptFormDynamicArgs,
) => MaybePromise<
Partial<Omit<T, 'inputs'> & { inputs: Array<AddDynamic<FormInput>> }> | null | undefined
>;
}
: T & AddDynamicMethod<T>
: never;
export type DynamicPromptFormArg = AddDynamic<FormInput>;

View File

@@ -2,21 +2,22 @@ import type { AuthenticationPlugin } from './AuthenticationPlugin';
import type { Context } from './Context';
import type { FilterPlugin } from './FilterPlugin';
import type { FolderActionPlugin } from './FolderActionPlugin';
import type { GrpcRequestActionPlugin } from './GrpcRequestActionPlugin';
import type { HttpRequestActionPlugin } from './HttpRequestActionPlugin';
import type { WebsocketRequestActionPlugin } from './WebsocketRequestActionPlugin';
import type { WorkspaceActionPlugin } from './WorkspaceActionPlugin';
import type { FolderActionPlugin } from './FolderActionPlugin';
import type { ImporterPlugin } from './ImporterPlugin';
import type { TemplateFunctionPlugin } from './TemplateFunctionPlugin';
import type { ThemePlugin } from './ThemePlugin';
import type { WebsocketRequestActionPlugin } from './WebsocketRequestActionPlugin';
import type { WorkspaceActionPlugin } from './WorkspaceActionPlugin';
export type { Context };
export type { DynamicTemplateFunctionArg } from './TemplateFunctionPlugin';
export type { DynamicAuthenticationArg } from './AuthenticationPlugin';
export type { CallPromptFormDynamicArgs, DynamicPromptFormArg } from './PromptFormPlugin';
export type { DynamicTemplateFunctionArg } from './TemplateFunctionPlugin';
export type { TemplateFunctionPlugin };
export type { WorkspaceActionPlugin } from './WorkspaceActionPlugin';
export type { FolderActionPlugin } from './FolderActionPlugin';
export type { WorkspaceActionPlugin } from './WorkspaceActionPlugin';
/**
* The global structure of a Yaak plugin

View File

@@ -1,7 +1,12 @@
import console from 'node:console';
import { type Stats, statSync, watch } from 'node:fs';
import path from 'node:path';
import type { Context, PluginDefinition } from '@yaakapp/api';
import type {
CallPromptFormDynamicArgs,
Context,
DynamicPromptFormArg,
PluginDefinition,
} from '@yaakapp/api';
import {
applyFormInputDefaults,
validateTemplateFunctionArgs,
@@ -12,6 +17,7 @@ import type {
DeleteModelResponse,
FindHttpResponsesResponse,
Folder,
FormInput,
GetCookieValueRequest,
GetCookieValueResponse,
GetHttpRequestByIdResponse,
@@ -55,6 +61,7 @@ export class PluginInstance {
#mod: PluginDefinition;
#pluginToAppEvents: EventChannel;
#appToPluginEvents: EventChannel;
#pendingDynamicForms = new Map<string, DynamicPromptFormArg[]>();
constructor(workerData: PluginWorkerData, pluginEvents: EventChannel) {
this.#workerData = workerData;
@@ -106,6 +113,7 @@ export class PluginInstance {
async terminate() {
await this.#mod?.dispose?.();
this.#pendingDynamicForms.clear();
this.#unimportModule();
}
@@ -664,10 +672,58 @@ export class PluginInstance {
return reply.value;
},
form: async (args) => {
const reply: PromptFormResponse = await this.#sendForReply(context, {
type: 'prompt_form_request',
...args,
// Strip dynamic callbacks before serializing (they can't cross the wire)
const strippedInputs = stripDynamicCallbacks(args.inputs);
// Build the event manually so we can get the event ID for keying
const eventToSend = this.#buildEventToSend(
context,
{ type: 'prompt_form_request', ...args, inputs: strippedInputs },
null,
);
// Store original inputs (with dynamic callbacks) for later resolution
this.#pendingDynamicForms.set(eventToSend.id, args.inputs);
const reply = await new Promise<PromptFormResponse>((resolve) => {
const cb = (event: InternalEvent) => {
if (event.replyId !== eventToSend.id) return;
if (event.payload.type === 'prompt_form_response') {
const { done, values } = event.payload as PromptFormResponse;
if (done) {
// Final response — resolve the promise and clean up
this.#appToPluginEvents.unlisten(cb);
this.#pendingDynamicForms.delete(eventToSend.id);
resolve({ values } as PromptFormResponse);
} else {
// Intermediate value change — resolve dynamic inputs and send back
const storedInputs = this.#pendingDynamicForms.get(eventToSend.id);
if (storedInputs && values) {
const ctx = this.#newCtx(context);
const callArgs: CallPromptFormDynamicArgs = { values };
applyDynamicFormInput(ctx, storedInputs, callArgs)
.then((resolvedInputs) => {
const stripped = stripDynamicCallbacks(resolvedInputs);
this.#sendPayload(
context,
{ type: 'prompt_form_request', ...args, inputs: stripped },
eventToSend.id,
);
})
.catch((err) => {
console.error('Failed to resolve dynamic form inputs', err);
});
}
}
}
};
this.#appToPluginEvents.listen(cb);
// Send the initial event after we start listening (to prevent race)
this.#sendEvent(eventToSend);
});
return reply.values;
},
},
@@ -906,6 +962,17 @@ export class PluginInstance {
}
}
function stripDynamicCallbacks(inputs: DynamicPromptFormArg[]): FormInput[] {
return inputs.map((input) => {
// biome-ignore lint/suspicious/noExplicitAny: stripping dynamic from union type
const { dynamic, ...rest } = input as any;
if ('inputs' in rest && Array.isArray(rest.inputs)) {
rest.inputs = stripDynamicCallbacks(rest.inputs);
}
return rest as FormInput;
});
}
function genId(len = 5): string {
const alphabet = '01234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
let id = '';

View File

@@ -1,9 +1,21 @@
import type { Context, DynamicAuthenticationArg, DynamicTemplateFunctionArg } from '@yaakapp/api';
import type {
CallPromptFormDynamicArgs,
Context,
DynamicAuthenticationArg,
DynamicPromptFormArg,
DynamicTemplateFunctionArg,
} from '@yaakapp/api';
import type {
CallHttpAuthenticationActionArgs,
CallTemplateFunctionArgs,
} from '@yaakapp-internal/plugins';
type AnyDynamicArg = DynamicTemplateFunctionArg | DynamicAuthenticationArg | DynamicPromptFormArg;
type AnyCallArgs =
| CallTemplateFunctionArgs
| CallHttpAuthenticationActionArgs
| CallPromptFormDynamicArgs;
export async function applyDynamicFormInput(
ctx: Context,
args: DynamicTemplateFunctionArg[],
@@ -18,30 +30,40 @@ export async function applyDynamicFormInput(
export async function applyDynamicFormInput(
ctx: Context,
args: (DynamicTemplateFunctionArg | DynamicAuthenticationArg)[],
callArgs: CallTemplateFunctionArgs | CallHttpAuthenticationActionArgs,
): Promise<(DynamicTemplateFunctionArg | DynamicAuthenticationArg)[]> {
const resolvedArgs: (DynamicTemplateFunctionArg | DynamicAuthenticationArg)[] = [];
args: DynamicPromptFormArg[],
callArgs: CallPromptFormDynamicArgs,
): Promise<DynamicPromptFormArg[]>;
export async function applyDynamicFormInput(
ctx: Context,
args: AnyDynamicArg[],
callArgs: AnyCallArgs,
): Promise<AnyDynamicArg[]> {
const resolvedArgs: AnyDynamicArg[] = [];
for (const { dynamic, ...arg } of args) {
const dynamicResult =
typeof dynamic === 'function'
? await dynamic(
ctx,
callArgs as CallTemplateFunctionArgs & CallHttpAuthenticationActionArgs,
callArgs as CallTemplateFunctionArgs &
CallHttpAuthenticationActionArgs &
CallPromptFormDynamicArgs,
)
: undefined;
const newArg = {
...arg,
...dynamicResult,
} as DynamicTemplateFunctionArg | DynamicAuthenticationArg;
} as AnyDynamicArg;
if ('inputs' in newArg && Array.isArray(newArg.inputs)) {
try {
newArg.inputs = await applyDynamicFormInput(
ctx,
newArg.inputs as DynamicTemplateFunctionArg[],
callArgs as CallTemplateFunctionArgs & CallHttpAuthenticationActionArgs,
callArgs as CallTemplateFunctionArgs &
CallHttpAuthenticationActionArgs &
CallPromptFormDynamicArgs,
);
} catch (e) {
console.error('Failed to apply dynamic form input', e);

View File

@@ -0,0 +1,24 @@
{
"name": "@yaak/httpsnippet",
"private": true,
"version": "1.0.0",
"displayName": "HTTP Snippet",
"description": "Generate code snippets for HTTP requests in various languages and frameworks",
"repository": {
"type": "git",
"url": "https://github.com/mountain-loop/yaak.git",
"directory": "plugins-external/httpsnippet"
},
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"test": "vitest --run tests"
},
"dependencies": {
"@readme/httpsnippet": "^11.0.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.9.3"
}
}

View File

@@ -0,0 +1,311 @@
import { type HarRequest, availableTargets, HTTPSnippet } from '@readme/httpsnippet';
import type { EditorLanguage, HttpRequest, PluginDefinition } from '@yaakapp/api';
// Get all available targets and build select options
const targets = availableTargets();
// Build language (target) options
const languageOptions = targets.map((target) => ({
label: target.title,
value: target.key,
}));
// Preferred clients per target (shown first in the list)
const preferredClients: Record<string, string> = {
javascript: 'fetch',
node: 'fetch',
};
// Get client options for a given target key
function getClientOptions(targetKey: string) {
const target = targets.find((t) => t.key === targetKey);
if (!target) return [];
const preferred = preferredClients[targetKey];
return target.clients
.map((client) => ({
label: client.title,
value: client.key,
}))
.sort((a, b) => {
if (a.value === preferred) return -1;
if (b.value === preferred) return 1;
return 0;
});
}
// Get default client for a target
function getDefaultClient(targetKey: string): string {
const options = getClientOptions(targetKey);
return options[0]?.value ?? '';
}
// Defaults
const defaultTarget = 'javascript';
const defaultClient = 'fetch';
// Map httpsnippet target key to editor language for syntax highlighting
const editorLanguageMap: Record<string, EditorLanguage> = {
c: 'c',
clojure: 'clojure',
csharp: 'csharp',
go: 'go',
http: 'text',
java: 'java',
javascript: 'javascript',
json: 'json',
kotlin: 'kotlin',
node: 'javascript',
objc: 'objective_c',
ocaml: 'ocaml',
php: 'php',
powershell: 'powershell',
python: 'python',
r: 'r',
ruby: 'ruby',
shell: 'shell',
swift: 'swift',
};
function getEditorLanguage(targetKey: string): EditorLanguage {
return editorLanguageMap[targetKey] ?? 'text';
}
// Convert Yaak HttpRequest to HAR format
function toHarRequest(request: Partial<HttpRequest>) {
// Build URL with query parameters
let finalUrl = request.url || '';
const urlParams = (request.urlParameters ?? []).filter((p) => p.enabled !== false && !!p.name);
if (urlParams.length > 0) {
const [base, hash] = finalUrl.split('#');
const separator = base?.includes('?') ? '&' : '?';
const queryString = urlParams
.map((p) => `${encodeURIComponent(p.name)}=${encodeURIComponent(p.value)}`)
.join('&');
finalUrl = base + separator + queryString + (hash ? `#${hash}` : '');
}
// Build headers array
const headers: Array<{ name: string; value: string }> = (request.headers ?? [])
.filter((h) => h.enabled !== false && !!h.name)
.map((h) => ({ name: h.name, value: h.value }));
// Handle authentication
if (request.authentication?.disabled !== true) {
if (request.authenticationType === 'basic') {
const credentials = btoa(
`${request.authentication?.username ?? ''}:${request.authentication?.password ?? ''}`,
);
headers.push({ name: 'Authorization', value: `Basic ${credentials}` });
} else if (request.authenticationType === 'bearer') {
const prefix = request.authentication?.prefix ?? 'Bearer';
const token = request.authentication?.token ?? '';
headers.push({ name: 'Authorization', value: `${prefix} ${token}`.trim() });
} else if (request.authenticationType === 'apikey') {
if (request.authentication?.location === 'header') {
headers.push({
name: request.authentication?.key ?? 'X-Api-Key',
value: request.authentication?.value ?? '',
});
} else if (request.authentication?.location === 'query') {
const sep = finalUrl.includes('?') ? '&' : '?';
finalUrl = [
finalUrl,
sep,
encodeURIComponent(request.authentication?.key ?? 'token'),
'=',
encodeURIComponent(request.authentication?.value ?? ''),
].join('');
}
}
}
// Build HAR request object
const har: Record<string, unknown> = {
method: request.method || 'GET',
url: finalUrl,
headers,
};
// Handle request body
const bodyType = request.bodyType ?? 'none';
if (bodyType !== 'none' && request.body) {
if (bodyType === 'application/x-www-form-urlencoded' && Array.isArray(request.body.form)) {
const params = request.body.form
.filter((p: { enabled?: boolean; name?: string }) => p.enabled !== false && !!p.name)
.map((p: { name: string; value: string }) => ({ name: p.name, value: p.value }));
har.postData = {
mimeType: 'application/x-www-form-urlencoded',
params,
};
} else if (bodyType === 'multipart/form-data' && Array.isArray(request.body.form)) {
const params = request.body.form
.filter((p: { enabled?: boolean; name?: string }) => p.enabled !== false && !!p.name)
.map((p: { name: string; value: string; file?: string; contentType?: string }) => {
const param: Record<string, string> = { name: p.name, value: p.value || '' };
if (p.file) param.fileName = p.file;
if (p.contentType) param.contentType = p.contentType;
return param;
});
har.postData = {
mimeType: 'multipart/form-data',
params,
};
} else if (bodyType === 'graphql' && typeof request.body.query === 'string') {
const body = {
query: request.body.query || '',
variables: maybeParseJSON(request.body.variables, undefined),
};
har.postData = {
mimeType: 'application/json',
text: JSON.stringify(body),
};
} else if (typeof request.body.text === 'string') {
har.postData = {
mimeType: bodyType,
text: request.body.text,
};
}
}
return har;
}
function maybeParseJSON<T>(v: unknown, fallback: T): T | unknown {
if (typeof v !== 'string') return fallback;
try {
return JSON.parse(v);
} catch {
return fallback;
}
}
export const plugin: PluginDefinition = {
httpRequestActions: [
{
label: 'Generate Code Snippet',
icon: 'copy',
async onSelect(ctx, args) {
// Render the request with variables resolved
const renderedRequest = await ctx.httpRequest.render({
httpRequest: args.httpRequest,
purpose: 'send',
});
// Convert to HAR format
const harRequest = toHarRequest(renderedRequest) as HarRequest;
// Get previously selected language or use defaults
const storedTarget = await ctx.store.get<string>('selectedTarget');
const storedClient = await ctx.store.get<string>('selectedClient');
const initialTarget = storedTarget || defaultTarget;
const initialClient = storedClient || defaultClient;
// Create snippet generator
const snippet = new HTTPSnippet(harRequest);
const generateSnippet = (target: string, client: string): string => {
const result = snippet.convert(target as any, client);
return (Array.isArray(result) ? result.join('\n') : result || '').replace(/\r\n/g, '\n');
};
// Generate initial code preview
let initialCode = '';
try {
initialCode = generateSnippet(initialTarget, initialClient);
} catch {
initialCode = '// Error generating snippet';
}
// Show dialog with language/library selectors and code preview
const result = await ctx.prompt.form({
id: 'httpsnippet',
title: 'Generate Code Snippet',
confirmText: 'Copy to Clipboard',
cancelText: 'Cancel',
size: 'md',
inputs: [
{
type: 'h_stack',
inputs: [
{
type: 'select',
name: 'target',
label: 'Language',
defaultValue: initialTarget,
options: languageOptions,
},
{
type: 'select',
name: `client-${initialTarget}`,
label: 'Library',
defaultValue: initialClient,
options: getClientOptions(initialTarget),
dynamic(_ctx, { values }) {
const targetKey = String(values.target || defaultTarget);
const options = getClientOptions(targetKey);
return {
name: `client-${targetKey}`,
options,
defaultValue: options[0]?.value ?? '',
};
},
},
],
},
{
type: 'editor',
name: 'code',
label: 'Preview',
language: getEditorLanguage(initialTarget),
defaultValue: initialCode,
readOnly: true,
rows: 15,
dynamic(_ctx, { values }) {
const targetKey = String(values.target || defaultTarget);
const clientKey = String(
values[`client-${targetKey}`] || getDefaultClient(targetKey),
);
let code: string;
try {
code = generateSnippet(targetKey, clientKey);
} catch {
code = '// Error generating snippet';
}
return {
defaultValue: code,
language: getEditorLanguage(targetKey),
};
},
},
],
});
if (result) {
// Store the selected language and library for next time
const selectedTarget = String(result.target || initialTarget);
const selectedClient = String(
result[`client-${selectedTarget}`] || getDefaultClient(selectedTarget),
);
await ctx.store.set('selectedTarget', selectedTarget);
await ctx.store.set('selectedClient', selectedClient);
// Generate snippet for the selected language
try {
const codeText = generateSnippet(selectedTarget, selectedClient);
await ctx.clipboard.copyText(codeText);
await ctx.toast.show({
message: 'Code snippet copied to clipboard',
icon: 'copy',
color: 'success',
});
} catch (err) {
await ctx.toast.show({
message: `Failed to generate snippet: ${err}`,
icon: 'alert_triangle',
color: 'danger',
});
}
}
},
},
],
};

View File

@@ -360,8 +360,9 @@ function EditorArg({
className={classNames(
'border border-border rounded-md overflow-hidden px-2 py-1',
'focus-within:border-border-focus',
'max-h-[10rem]', // So it doesn't take up too much space
!arg.rows && 'max-h-[10rem]', // So it doesn't take up too much space
)}
style={arg.rows ? { height: `${arg.rows * 1.4 + 0.75}rem` } : undefined}
>
<Editor
id={id}

View File

@@ -5,11 +5,14 @@ import {
completionKeymap,
} from '@codemirror/autocomplete';
import { history, historyKeymap } from '@codemirror/commands';
import { go } from '@codemirror/lang-go';
import { java } from '@codemirror/lang-java';
import { javascript } from '@codemirror/lang-javascript';
import { json } from '@codemirror/lang-json';
import { markdown } from '@codemirror/lang-markdown';
import { php } from '@codemirror/lang-php';
import { python } from '@codemirror/lang-python';
import { xml } from '@codemirror/lang-xml';
import type { LanguageSupport } from '@codemirror/language';
import {
bracketMatching,
codeFolding,
@@ -17,8 +20,18 @@ import {
foldKeymap,
HighlightStyle,
indentOnInput,
LanguageSupport,
StreamLanguage,
syntaxHighlighting,
} from '@codemirror/language';
import { c, csharp, kotlin, objectiveC } from '@codemirror/legacy-modes/mode/clike';
import { clojure } from '@codemirror/legacy-modes/mode/clojure';
import { oCaml } from '@codemirror/legacy-modes/mode/mllike';
import { powerShell } from '@codemirror/legacy-modes/mode/powershell';
import { r } from '@codemirror/legacy-modes/mode/r';
import { ruby } from '@codemirror/legacy-modes/mode/ruby';
import { shell } from '@codemirror/legacy-modes/mode/shell';
import { swift } from '@codemirror/legacy-modes/mode/swift';
import { linter, lintGutter, lintKeymap } from '@codemirror/lint';
import { search, searchKeymap } from '@codemirror/search';
@@ -83,6 +96,10 @@ const syntaxTheme = EditorView.theme({}, { dark: true });
const closeBracketsExtensions: Extension = [closeBrackets(), keymap.of([...closeBracketsKeymap])];
const legacyLang = (mode: Parameters<typeof StreamLanguage.define>[0]) => {
return () => new LanguageSupport(StreamLanguage.define(mode));
};
const syntaxExtensions: Record<
NonNullable<EditorProps['language']>,
null | (() => LanguageSupport)
@@ -98,6 +115,21 @@ const syntaxExtensions: Record<
text: text,
timeline: timeline,
markdown: markdown,
c: legacyLang(c),
clojure: legacyLang(clojure),
csharp: legacyLang(csharp),
go: go,
java: java,
kotlin: legacyLang(kotlin),
objective_c: legacyLang(objectiveC),
ocaml: legacyLang(oCaml),
php: php,
powershell: legacyLang(powerShell),
python: python,
r: legacyLang(r),
ruby: legacyLang(ruby),
shell: legacyLang(shell),
swift: legacyLang(swift),
};
const closeBracketsFor: (keyof typeof syntaxExtensions)[] = ['json', 'javascript', 'graphql'];

View File

@@ -1,6 +1,6 @@
import type { FormInput, JsonPrimitive } from '@yaakapp-internal/plugins';
import type { FormEvent } from 'react';
import { useCallback, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { generateId } from '../../lib/generateId';
import { DynamicForm } from '../DynamicForm';
import { Button } from './Button';
@@ -12,16 +12,21 @@ export interface PromptProps {
onResult: (value: Record<string, JsonPrimitive> | null) => void;
confirmText?: string;
cancelText?: string;
onValuesChange?: (values: Record<string, JsonPrimitive>) => void;
onInputsUpdated?: (cb: (inputs: FormInput[]) => void) => void;
}
export function Prompt({
onCancel,
inputs,
inputs: initialInputs,
onResult,
confirmText = 'Confirm',
cancelText = 'Cancel',
onValuesChange,
onInputsUpdated,
}: PromptProps) {
const [value, setValue] = useState<Record<string, JsonPrimitive>>({});
const [inputs, setInputs] = useState<FormInput[]>(initialInputs);
const handleSubmit = useCallback(
(e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
@@ -30,6 +35,16 @@ export function Prompt({
[onResult, value],
);
// Register callback for external input updates (from plugin dynamic resolution)
useEffect(() => {
onInputsUpdated?.(setInputs);
}, [onInputsUpdated]);
// Notify of value changes for dynamic resolution
useEffect(() => {
onValuesChange?.(value);
}, [value, onValuesChange]);
const id = `prompt.form.${useRef(generateId()).current}`;
return (

View File

@@ -1,6 +1,12 @@
import { emit } from '@tauri-apps/api/event';
import { openUrl } from '@tauri-apps/plugin-opener';
import type { InternalEvent, ShowToastRequest } from '@yaakapp-internal/plugins';
import { debounce } from '@yaakapp-internal/lib';
import type {
FormInput,
InternalEvent,
JsonPrimitive,
ShowToastRequest,
} from '@yaakapp-internal/plugins';
import { updateAllPlugins } from '@yaakapp-internal/plugins';
import type {
PluginUpdateNotification,
@@ -32,6 +38,9 @@ export function initGlobalListeners() {
listenToTauriEvent('settings', () => openSettings.mutate(null));
// Track active dynamic form dialogs so follow-up input updates can reach them
const activeForms = new Map<string, (inputs: FormInput[]) => void>();
// Listen for plugin events
listenToTauriEvent<InternalEvent>('plugin_event', async ({ payload: event }) => {
if (event.payload.type === 'prompt_text_request') {
@@ -49,26 +58,47 @@ export function initGlobalListeners() {
};
await emit(event.id, result);
} else if (event.payload.type === 'prompt_form_request') {
if (event.replyId != null) {
// Follow-up update from plugin runtime — update the active dialog's inputs
const updateInputs = activeForms.get(event.replyId);
if (updateInputs) {
updateInputs(event.payload.inputs);
}
return;
}
// Initial request — show the dialog with bidirectional support
const emitFormResponse = (values: Record<string, JsonPrimitive> | null, done: boolean) => {
const result: InternalEvent = {
id: generateId(),
replyId: event.id,
pluginName: event.pluginName,
pluginRefId: event.pluginRefId,
context: event.context,
payload: {
type: 'prompt_form_response',
values,
done,
},
};
emit(event.id, result);
};
const values = await showPromptForm({
id: event.payload.id,
title: event.payload.title,
description: event.payload.description,
size: event.payload.size,
inputs: event.payload.inputs,
confirmText: event.payload.confirmText,
cancelText: event.payload.cancelText,
onValuesChange: debounce((values) => emitFormResponse(values, false), 150),
onInputsUpdated: (cb) => activeForms.set(event.id, cb),
});
const result: InternalEvent = {
id: generateId(),
replyId: event.id,
pluginName: event.pluginName,
pluginRefId: event.pluginRefId,
context: event.context,
payload: {
type: 'prompt_form_response',
values,
},
};
await emit(event.id, result);
// Clean up and send final response
activeForms.delete(event.id);
emitFormResponse(values, true);
}
});

View File

@@ -1,21 +1,32 @@
import type { FormInput, JsonPrimitive } from '@yaakapp-internal/plugins';
import type { DialogProps } from '../components/core/Dialog';
import type { PromptProps } from '../components/core/Prompt';
import { Prompt } from '../components/core/Prompt';
import { showDialog } from './dialog';
type FormArgs = Pick<DialogProps, 'title' | 'description'> &
type FormArgs = Pick<DialogProps, 'title' | 'description' | 'size'> &
Omit<PromptProps, 'onClose' | 'onCancel' | 'onResult'> & {
id: string;
onValuesChange?: (values: Record<string, JsonPrimitive>) => void;
onInputsUpdated?: (cb: (inputs: FormInput[]) => void) => void;
};
export async function showPromptForm({ id, title, description, ...props }: FormArgs) {
export async function showPromptForm({
id,
title,
description,
size,
onValuesChange,
onInputsUpdated,
...props
}: FormArgs) {
return new Promise((resolve: PromptProps['onResult']) => {
showDialog({
id,
title,
description,
hideX: true,
size: 'sm',
size: size ?? 'sm',
disableBackdropClose: true, // Prevent accidental dismisses
onClose: () => {
// Click backdrop, close, or escape
@@ -32,6 +43,8 @@ export async function showPromptForm({ id, title, description, ...props }: FormA
resolve(v);
hide();
},
onValuesChange,
onInputsUpdated,
...props,
}),
});