diff --git a/crates-tauri/yaak-app/src/plugin_events.rs b/crates-tauri/yaak-app/src/plugin_events.rs index b28ec501..52ecb7a7 100644 --- a/crates-tauri/yaak-app/src/plugin_events.rs +++ b/crates-tauri/yaak-app/src/plugin_events.rs @@ -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( } 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::(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 diff --git a/crates/yaak-plugins/bindings/gen_events.ts b/crates/yaak-plugins/bindings/gen_events.ts index 04b6cd60..d7f4e55c 100644 --- a/crates/yaak-plugins/bindings/gen_events.ts +++ b/crates/yaak-plugins/bindings/gen_events.ts @@ -66,7 +66,9 @@ export type DeleteModelRequest = { model: string, id: string, }; export type DeleteModelResponse = { model: AnyModel, }; -export type EditorLanguage = "text" | "javascript" | "json" | "html" | "xml" | "graphql" | "markdown"; +export type DialogSize = "sm" | "md" | "lg" | "full" | "dynamic"; + +export type EditorLanguage = "text" | "javascript" | "json" | "html" | "xml" | "graphql" | "markdown" | "c" | "clojure" | "csharp" | "go" | "http" | "java" | "kotlin" | "objective_c" | "ocaml" | "php" | "powershell" | "python" | "r" | "ruby" | "shell" | "swift"; export type EmptyPayload = {}; @@ -172,7 +174,11 @@ hideGutter?: boolean, /** * Language for syntax highlighting */ -language?: EditorLanguage, readOnly?: boolean, completionOptions?: Array, +language?: EditorLanguage, readOnly?: boolean, +/** + * Fixed number of visible rows + */ +rows?: number, completionOptions?: Array, /** * The name of the input. The value will be stored at this object attribute in the resulting data */ @@ -476,9 +482,9 @@ 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, confirmText?: string, cancelText?: string, }; +export type PromptFormRequest = { id: string, title: string, description?: string, inputs: Array, confirmText?: string, cancelText?: string, size?: DialogSize, }; -export type PromptFormResponse = { values: { [key in string]?: JsonPrimitive } | null, }; +export type PromptFormResponse = { values: { [key in string]?: JsonPrimitive } | null, done?: boolean, }; export type PromptTextRequest = { id: string, title: string, label: string, description?: string, defaultValue?: string, placeholder?: string, /** diff --git a/crates/yaak-plugins/bindings/gen_models.ts b/crates/yaak-plugins/bindings/gen_models.ts index 1963f828..e8b314c2 100644 --- a/crates/yaak-plugins/bindings/gen_models.ts +++ b/crates/yaak-plugins/bindings/gen_models.ts @@ -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, 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, duration: bigint, overridden: boolean, }; export type HttpResponseHeader = { name: string, value: string, }; diff --git a/crates/yaak-plugins/src/events.rs b/crates/yaak-plugins/src/events.rs index f1a3ba06..aa21de80 100644 --- a/crates/yaak-plugins/src/events.rs +++ b/crates/yaak-plugins/src/events.rs @@ -587,6 +587,19 @@ pub struct PromptFormRequest { pub confirm_text: Option, #[ts(optional)] pub cancel_text: Option, + #[ts(optional)] + pub size: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export, export_to = "gen_events.ts")] +pub enum DialogSize { + 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>, + #[ts(optional)] + pub done: Option, } #[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] @@ -936,6 +951,22 @@ pub enum EditorLanguage { Xml, Graphql, Markdown, + C, + Clojure, + Csharp, + Go, + Http, + Java, + Kotlin, + ObjectiveC, + Ocaml, + Php, + Powershell, + Python, + R, + Ruby, + Shell, + Swift, } impl Default for EditorLanguage { @@ -966,6 +997,10 @@ pub struct FormInputEditor { #[ts(optional)] pub read_only: Option, + /// Fixed number of visible rows + #[ts(optional)] + pub rows: Option, + #[ts(optional)] pub completion_options: Option>, } diff --git a/package-lock.json b/package-lock.json index 1775b80d..0ad5040d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 5b427526..2501b08a 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/packages/plugin-runtime-types/src/bindings/gen_events.ts b/packages/plugin-runtime-types/src/bindings/gen_events.ts index 04b6cd60..d7f4e55c 100644 --- a/packages/plugin-runtime-types/src/bindings/gen_events.ts +++ b/packages/plugin-runtime-types/src/bindings/gen_events.ts @@ -66,7 +66,9 @@ export type DeleteModelRequest = { model: string, id: string, }; export type DeleteModelResponse = { model: AnyModel, }; -export type EditorLanguage = "text" | "javascript" | "json" | "html" | "xml" | "graphql" | "markdown"; +export type DialogSize = "sm" | "md" | "lg" | "full" | "dynamic"; + +export type EditorLanguage = "text" | "javascript" | "json" | "html" | "xml" | "graphql" | "markdown" | "c" | "clojure" | "csharp" | "go" | "http" | "java" | "kotlin" | "objective_c" | "ocaml" | "php" | "powershell" | "python" | "r" | "ruby" | "shell" | "swift"; export type EmptyPayload = {}; @@ -172,7 +174,11 @@ hideGutter?: boolean, /** * Language for syntax highlighting */ -language?: EditorLanguage, readOnly?: boolean, completionOptions?: Array, +language?: EditorLanguage, readOnly?: boolean, +/** + * Fixed number of visible rows + */ +rows?: number, completionOptions?: Array, /** * The name of the input. The value will be stored at this object attribute in the resulting data */ @@ -476,9 +482,9 @@ 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, confirmText?: string, cancelText?: string, }; +export type PromptFormRequest = { id: string, title: string, description?: string, inputs: Array, confirmText?: string, cancelText?: string, size?: DialogSize, }; -export type PromptFormResponse = { values: { [key in string]?: JsonPrimitive } | null, }; +export type PromptFormResponse = { values: { [key in string]?: JsonPrimitive } | null, done?: boolean, }; export type PromptTextRequest = { id: string, title: string, label: string, description?: string, defaultValue?: string, placeholder?: string, /** diff --git a/packages/plugin-runtime-types/src/bindings/gen_models.ts b/packages/plugin-runtime-types/src/bindings/gen_models.ts index 1963f828..e8b314c2 100644 --- a/packages/plugin-runtime-types/src/bindings/gen_models.ts +++ b/packages/plugin-runtime-types/src/bindings/gen_models.ts @@ -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, 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, duration: bigint, overridden: boolean, }; export type HttpResponseHeader = { name: string, value: string, }; diff --git a/packages/plugin-runtime-types/src/plugins/Context.ts b/packages/plugin-runtime-types/src/plugins/Context.ts index 1e0a26ff..7191a4c6 100644 --- a/packages/plugin-runtime-types/src/plugins/Context.ts +++ b/packages/plugin-runtime-types/src/plugins/Context.ts @@ -1,10 +1,12 @@ import type { FindHttpResponsesRequest, FindHttpResponsesResponse, + FormInput, GetCookieValueRequest, GetCookieValueResponse, GetHttpRequestByIdRequest, GetHttpRequestByIdResponse, + JsonPrimitive, ListCookieNamesResponse, ListFoldersRequest, ListFoldersResponse, @@ -27,6 +29,39 @@ 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 { MaybePromise } from '../helpers'; + +export type CallPromptFormDynamicArgs = { + values: { [key in string]?: JsonPrimitive }; +}; + +type AddDynamicMethod = { + dynamic?: ( + ctx: Context, + args: CallPromptFormDynamicArgs, + ) => MaybePromise | null | undefined>; +}; + +// biome-ignore lint/suspicious/noExplicitAny: distributive conditional type pattern +type AddDynamic = T extends any + ? T extends { inputs?: FormInput[] } + ? Omit & { + inputs: Array>; + dynamic?: ( + ctx: Context, + args: CallPromptFormDynamicArgs, + ) => MaybePromise< + Partial & { inputs: Array> }> | null | undefined + >; + } + : T & AddDynamicMethod + : never; + +export type DynamicPromptFormArg = AddDynamic; + +type DynamicPromptFormRequest = Omit & { + inputs: DynamicPromptFormArg[]; +}; export type WorkspaceHandle = Pick; @@ -39,7 +74,7 @@ export interface Context { }; prompt: { text(args: PromptTextRequest): Promise; - form(args: PromptFormRequest): Promise; + form(args: DynamicPromptFormRequest): Promise; }; store: { set(key: string, value: T): Promise; diff --git a/packages/plugin-runtime-types/src/plugins/index.ts b/packages/plugin-runtime-types/src/plugins/index.ts index 84d65fb8..94abd4e3 100644 --- a/packages/plugin-runtime-types/src/plugins/index.ts +++ b/packages/plugin-runtime-types/src/plugins/index.ts @@ -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 './Context'; +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 diff --git a/packages/plugin-runtime/src/PluginInstance.ts b/packages/plugin-runtime/src/PluginInstance.ts index a3ebe6ee..bca142dc 100644 --- a/packages/plugin-runtime/src/PluginInstance.ts +++ b/packages/plugin-runtime/src/PluginInstance.ts @@ -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(); 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(); } @@ -299,7 +307,7 @@ export class PluginInstance { const replyPayload: InternalEventPayload = { type: 'get_template_function_config_response', pluginRefId: this.#workerData.pluginRefId, - function: { ...fn, args: resolvedArgs }, + function: { ...fn, args: stripDynamicCallbacks(resolvedArgs) }, }; this.#sendPayload(context, replyPayload, replyId); return; @@ -326,7 +334,7 @@ export class PluginInstance { const replyPayload: InternalEventPayload = { type: 'get_http_authentication_config_response', - args: resolvedArgs, + args: stripDynamicCallbacks(resolvedArgs), actions: resolvedActions, pluginRefId: this.#workerData.pluginRefId, }; @@ -664,10 +672,66 @@ export class PluginInstance { return reply.value; }, form: async (args) => { - const reply: PromptFormResponse = await this.#sendForReply(context, { - type: 'prompt_form_request', - ...args, + // Resolve dynamic callbacks on initial inputs using default values + const defaults = applyFormInputDefaults(args.inputs, {}); + const callArgs: CallPromptFormDynamicArgs = { values: defaults }; + const resolvedInputs = await applyDynamicFormInput( + this.#newCtx(context), + args.inputs, + callArgs, + ); + const strippedInputs = stripDynamicCallbacks(resolvedInputs); + + // 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((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 + // Skip empty values (fired on initial mount before user interaction) + const storedInputs = this.#pendingDynamicForms.get(eventToSend.id); + if (storedInputs && values && Object.keys(values).length > 0) { + 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 +970,17 @@ export class PluginInstance { } } +function stripDynamicCallbacks(inputs: { dynamic?: unknown }[]): 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 = ''; diff --git a/packages/plugin-runtime/src/common.ts b/packages/plugin-runtime/src/common.ts index d13ad38a..f0d0b4b4 100644 --- a/packages/plugin-runtime/src/common.ts +++ b/packages/plugin-runtime/src/common.ts @@ -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; + +export async function applyDynamicFormInput( + ctx: Context, + args: AnyDynamicArg[], + callArgs: AnyCallArgs, +): Promise { + 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); diff --git a/plugins-external/httpsnippet/README.md b/plugins-external/httpsnippet/README.md new file mode 100644 index 00000000..b9e95a9f --- /dev/null +++ b/plugins-external/httpsnippet/README.md @@ -0,0 +1,9 @@ +# Yaak HTTP Snippet Plugin + +Generate code snippets for HTTP requests in various languages and frameworks, +powered by [httpsnippet](https://github.com/Kong/httpsnippet). + +## Supported Languages + +C, Clojure, C#, Go, HTTP, Java, JavaScript, Kotlin, Node.js, Objective-C, +OCaml, PHP, PowerShell, Python, R, Ruby, Shell, and Swift. diff --git a/plugins-external/httpsnippet/package.json b/plugins-external/httpsnippet/package.json new file mode 100644 index 00000000..359493ab --- /dev/null +++ b/plugins-external/httpsnippet/package.json @@ -0,0 +1,24 @@ +{ + "name": "@yaak/httpsnippet", + "private": true, + "version": "1.0.1", + "displayName": "HTTP Snippet", + "description": "Generate code snippets for HTTP requests in various languages and frameworks", + "minYaakVersion": "2026.2.0-beta.10", + "repository": { + "type": "git", + "url": "https://github.com/mountain-loop/yaak.git", + "directory": "plugins-external/httpsnippet" + }, + "scripts": { + "build": "yaakcli build", + "dev": "yaakcli dev" + }, + "dependencies": { + "@readme/httpsnippet": "^11.0.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.9.3" + } +} diff --git a/plugins-external/httpsnippet/src/index.ts b/plugins-external/httpsnippet/src/index.ts new file mode 100644 index 00000000..b9f301fc --- /dev/null +++ b/plugins-external/httpsnippet/src/index.ts @@ -0,0 +1,314 @@ +import { availableTargets, type HarRequest, HTTPSnippet } from '@readme/httpsnippet'; +import type { EditorLanguage, HttpRequest, PluginDefinition } from '@yaakapp/api'; + +// Get all available targets and build select options +const targets = availableTargets(); + +// Targets to exclude from the language list +const excludedTargets = new Set(['json']); + +// Build language (target) options +const languageOptions = targets + .filter((target) => !excludedTargets.has(target.key)) + .map((target) => ({ + label: target.title, + value: target.key, + })); + +// Preferred clients per target (shown first in the list) +const preferredClients: Record = { + 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'; + +// Map httpsnippet target key to editor language for syntax highlighting +const editorLanguageMap: Record = { + c: 'c', + clojure: 'clojure', + csharp: 'csharp', + go: 'go', + http: 'http', + java: 'java', + javascript: 'javascript', + 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) { + // 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 = { + 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 = { 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(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('selectedTarget'); + const initialTarget = storedTarget || defaultTarget; + const storedClient = await ctx.store.get(`selectedClient:${initialTarget}`); + const initialClient = storedClient || getDefaultClient(initialTarget); + + // 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:${selectedTarget}`, 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', + }); + } + } + }, + }, + ], +}; diff --git a/plugins-external/mcp-server/package.json b/plugins-external/mcp-server/package.json index 78093832..39cb666d 100644 --- a/plugins-external/mcp-server/package.json +++ b/plugins-external/mcp-server/package.json @@ -1,10 +1,10 @@ { "name": "@yaak/mcp-server", "private": true, - "version": "0.1.7", + "version": "0.2.1", "displayName": "MCP Server", "description": "Expose Yaak functionality via Model Context Protocol", - "minYaakVersion": "2025.10.0-beta.6", + "minYaakVersion": "2026.1.0", "repository": { "type": "git", "url": "https://github.com/mountain-loop/yaak.git", diff --git a/src-web/components/DynamicForm.tsx b/src-web/components/DynamicForm.tsx index b78ed951..cab5d603 100644 --- a/src-web/components/DynamicForm.tsx +++ b/src-web/components/DynamicForm.tsx @@ -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} > [0]) => { + return () => new LanguageSupport(StreamLanguage.define(mode)); +}; + const syntaxExtensions: Record< NonNullable, null | (() => LanguageSupport) @@ -98,6 +116,22 @@ const syntaxExtensions: Record< text: text, timeline: timeline, markdown: markdown, + c: legacyLang(c), + clojure: legacyLang(clojure), + csharp: legacyLang(csharp), + go: go, + http: legacyLang(http), + 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']; diff --git a/src-web/components/core/Prompt.tsx b/src-web/components/core/Prompt.tsx index 43f49dac..f75b39af 100644 --- a/src-web/components/core/Prompt.tsx +++ b/src-web/components/core/Prompt.tsx @@ -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 | null) => void; confirmText?: string; cancelText?: string; + onValuesChange?: (values: Record) => 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>({}); + const [inputs, setInputs] = useState(initialInputs); const handleSubmit = useCallback( (e: FormEvent) => { 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 ( diff --git a/src-web/lib/initGlobalListeners.tsx b/src-web/lib/initGlobalListeners.tsx index d2e5d1c8..796c4234 100644 --- a/src-web/lib/initGlobalListeners.tsx +++ b/src-web/lib/initGlobalListeners.tsx @@ -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 void>(); + // Listen for plugin events listenToTauriEvent('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 | 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); } }); diff --git a/src-web/lib/prompt-form.tsx b/src-web/lib/prompt-form.tsx index fe0e7b0c..eb2c8dce 100644 --- a/src-web/lib/prompt-form.tsx +++ b/src-web/lib/prompt-form.tsx @@ -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 & +type FormArgs = Pick & Omit & { id: string; + onValuesChange?: (values: Record) => 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, }), });