From 09e78d92105af9d3405d00ed906cc43cb0388f34 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Thu, 5 Feb 2026 15:15:22 -0800 Subject: [PATCH] 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 --- crates-tauri/yaak-app/src/plugin_events.rs | 52 +++- crates/yaak-plugins/bindings/gen_events.ts | 12 +- crates/yaak-plugins/bindings/gen_models.ts | 2 +- crates/yaak-plugins/src/events.rs | 19 ++ package-lock.json | 84 ++++++ package.json | 1 + .../src/bindings/gen_events.ts | 12 +- .../src/bindings/gen_models.ts | 2 +- .../src/plugins/Context.ts | 7 +- .../src/plugins/PromptFormPlugin.ts | 31 ++ .../plugin-runtime-types/src/plugins/index.ts | 11 +- packages/plugin-runtime/src/PluginInstance.ts | 75 ++++- packages/plugin-runtime/src/common.ts | 38 ++- plugins-external/httpsnippet/package.json | 24 ++ plugins-external/httpsnippet/src/index.ts | 277 ++++++++++++++++++ src-web/components/DynamicForm.tsx | 3 +- src-web/components/core/Prompt.tsx | 19 +- src-web/lib/initGlobalListeners.tsx | 56 +++- src-web/lib/prompt-form.tsx | 19 +- 19 files changed, 697 insertions(+), 47 deletions(-) create mode 100644 packages/plugin-runtime-types/src/plugins/PromptFormPlugin.ts create mode 100644 plugins-external/httpsnippet/package.json create mode 100644 plugins-external/httpsnippet/src/index.ts 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..3b3b42d0 100644 --- a/crates/yaak-plugins/bindings/gen_events.ts +++ b/crates/yaak-plugins/bindings/gen_events.ts @@ -172,7 +172,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 +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, confirmText?: string, cancelText?: string, }; +export type PromptFormRequest = { id: string, title: string, description?: string, inputs: Array, 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, /** 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..34889e58 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 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>, + #[ts(optional)] + pub done: Option, } #[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] @@ -966,6 +981,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..b612f0a8 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", @@ -2022,6 +2023,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 +4059,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 +7429,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 +8553,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 +13822,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 +15844,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..5a302944 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", diff --git a/packages/plugin-runtime-types/src/bindings/gen_events.ts b/packages/plugin-runtime-types/src/bindings/gen_events.ts index 04b6cd60..3b3b42d0 100644 --- a/packages/plugin-runtime-types/src/bindings/gen_events.ts +++ b/packages/plugin-runtime-types/src/bindings/gen_events.ts @@ -172,7 +172,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 +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, confirmText?: string, cancelText?: string, }; +export type PromptFormRequest = { id: string, title: string, description?: string, inputs: Array, 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, /** 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..632c294b 100644 --- a/packages/plugin-runtime-types/src/plugins/Context.ts +++ b/packages/plugin-runtime-types/src/plugins/Context.ts @@ -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 & { + inputs: DynamicPromptFormArg[]; +}; export type WorkspaceHandle = Pick; @@ -39,7 +44,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/PromptFormPlugin.ts b/packages/plugin-runtime-types/src/plugins/PromptFormPlugin.ts new file mode 100644 index 00000000..bc028376 --- /dev/null +++ b/packages/plugin-runtime-types/src/plugins/PromptFormPlugin.ts @@ -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 = { + 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; diff --git a/packages/plugin-runtime-types/src/plugins/index.ts b/packages/plugin-runtime-types/src/plugins/index.ts index 84d65fb8..53f2cefb 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 './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 diff --git a/packages/plugin-runtime/src/PluginInstance.ts b/packages/plugin-runtime/src/PluginInstance.ts index a3ebe6ee..c58f006b 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(); } @@ -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((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 = ''; 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/package.json b/plugins-external/httpsnippet/package.json new file mode 100644 index 00000000..57e0e6a8 --- /dev/null +++ b/plugins-external/httpsnippet/package.json @@ -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" + } +} diff --git a/plugins-external/httpsnippet/src/index.ts b/plugins-external/httpsnippet/src/index.ts new file mode 100644 index 00000000..3550baf0 --- /dev/null +++ b/plugins-external/httpsnippet/src/index.ts @@ -0,0 +1,277 @@ +import { availableTargets, HTTPSnippet } from '@readme/httpsnippet'; +import type { 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, +})); + +// Get client options for a given target key +function getClientOptions(targetKey: string) { + const target = targets.find((t) => t.key === targetKey); + if (!target) return []; + return target.clients.map((client) => ({ + label: client.title, + value: client.key, + })); +} + +// Get default client for a target +function getDefaultClient(targetKey: string): string { + const target = targets.find((t) => t.key === targetKey); + return target?.clients[0]?.key ?? ''; +} + +// Defaults +const defaultTarget = 'javascript'; +const defaultClient = 'fetch'; + +// Map target key to editor language for syntax highlighting +function getEditorLanguage(targetKey: string): 'javascript' | 'json' | 'text' { + if (['javascript', 'node'].includes(targetKey)) return 'javascript'; + if (targetKey === 'json') return 'json'; + return '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); + + // Get previously selected language or use defaults + const storedTarget = await ctx.store.get('selectedTarget'); + const storedClient = await ctx.store.get('selectedClient'); + const initialTarget = storedTarget || defaultTarget; + const initialClient = storedClient || defaultClient; + + // Create snippet generator + const snippet = new HTTPSnippet(harRequest); + + // Generate initial code preview + let initialCode = ''; + try { + const result = snippet.convert(initialTarget as any, initialClient); + initialCode = Array.isArray(result) ? result.join('\n') : result || ''; + } 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 { + const result = snippet.convert(targetKey as any, clientKey); + code = Array.isArray(result) ? result.join('\n') : result || ''; + } 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 code = snippet.convert(selectedTarget as any, selectedClient); + const codeText = Array.isArray(code) ? code.join('\n') : code || ''; + 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/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} > | 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, }), });