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
This commit is contained in:
Gregory Schier
2026-02-05 15:15:22 -08:00
parent cc5d4742f0
commit 09e78d9210
19 changed files with 697 additions and 47 deletions

View File

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

View File

@@ -172,7 +172,11 @@ hideGutter?: boolean,
/**
* Language for syntax highlighting
*/
language?: EditorLanguage, readOnly?: boolean, completionOptions?: Array<GenericCompletionOption>,
language?: EditorLanguage, readOnly?: boolean,
/**
* Fixed number of visible rows
*/
rows?: number, completionOptions?: Array<GenericCompletionOption>,
/**
* The name of the input. The value will be stored at this object attribute in the resulting data
*/
@@ -476,9 +480,11 @@ label: string, title?: string, size?: WindowSize, dataDirKey?: string, };
export type PluginContext = { id: string, label: string | null, workspaceId: string | null, };
export type PromptFormRequest = { id: string, title: string, description?: string, inputs: Array<FormInput>, confirmText?: string, cancelText?: string, };
export type PromptFormRequest = { id: string, title: string, description?: string, inputs: Array<FormInput>, confirmText?: string, cancelText?: string, size?: PromptFormSize, };
export type PromptFormResponse = { values: { [key in string]?: JsonPrimitive } | null, };
export type PromptFormResponse = { values: { [key in string]?: JsonPrimitive } | null, done?: boolean, };
export type PromptFormSize = "sm" | "md" | "lg" | "full" | "dynamic";
export type PromptTextRequest = { id: string, title: string, label: string, description?: string, defaultValue?: string, placeholder?: string,
/**

View File

@@ -49,7 +49,7 @@ export type HttpResponseEvent = { model: "http_response_event", id: string, crea
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
* The `From` impl is in yaak-http to avoid circular dependencies.
*/
export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, path: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array<string>, duration: bigint, overridden: boolean, };
export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, scheme: string, username: string, password: string, host: string, port: number, path: string, query: string, fragment: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array<string>, duration: bigint, overridden: boolean, };
export type HttpResponseHeader = { name: string, value: string, };

View File

@@ -587,6 +587,19 @@ pub struct PromptFormRequest {
pub confirm_text: Option<String>,
#[ts(optional)]
pub cancel_text: Option<String>,
#[ts(optional)]
pub size: Option<PromptFormSize>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "gen_events.ts")]
pub enum PromptFormSize {
Sm,
Md,
Lg,
Full,
Dynamic,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
@@ -594,6 +607,8 @@ pub struct PromptFormRequest {
#[ts(export, export_to = "gen_events.ts")]
pub struct PromptFormResponse {
pub values: Option<HashMap<String, JsonPrimitive>>,
#[ts(optional)]
pub done: Option<bool>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
@@ -966,6 +981,10 @@ pub struct FormInputEditor {
#[ts(optional)]
pub read_only: Option<bool>,
/// Fixed number of visible rows
#[ts(optional)]
pub rows: Option<i32>,
#[ts(optional)]
pub completion_options: Option<Vec<GenericCompletionOption>>,
}

84
package-lock.json generated
View File

@@ -13,6 +13,7 @@
"packages/plugin-runtime-types",
"plugins-external/mcp-server",
"plugins-external/template-function-faker",
"plugins-external/httpsnippet",
"plugins/action-copy-curl",
"plugins/action-copy-grpcurl",
"plugins/action-send-folder",
@@ -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",

View File

@@ -12,6 +12,7 @@
"packages/plugin-runtime-types",
"plugins-external/mcp-server",
"plugins-external/template-function-faker",
"plugins-external/httpsnippet",
"plugins/action-copy-curl",
"plugins/action-copy-grpcurl",
"plugins/action-send-folder",

View File

@@ -172,7 +172,11 @@ hideGutter?: boolean,
/**
* Language for syntax highlighting
*/
language?: EditorLanguage, readOnly?: boolean, completionOptions?: Array<GenericCompletionOption>,
language?: EditorLanguage, readOnly?: boolean,
/**
* Fixed number of visible rows
*/
rows?: number, completionOptions?: Array<GenericCompletionOption>,
/**
* The name of the input. The value will be stored at this object attribute in the resulting data
*/
@@ -476,9 +480,11 @@ label: string, title?: string, size?: WindowSize, dataDirKey?: string, };
export type PluginContext = { id: string, label: string | null, workspaceId: string | null, };
export type PromptFormRequest = { id: string, title: string, description?: string, inputs: Array<FormInput>, confirmText?: string, cancelText?: string, };
export type PromptFormRequest = { id: string, title: string, description?: string, inputs: Array<FormInput>, confirmText?: string, cancelText?: string, size?: PromptFormSize, };
export type PromptFormResponse = { values: { [key in string]?: JsonPrimitive } | null, };
export type PromptFormResponse = { values: { [key in string]?: JsonPrimitive } | null, done?: boolean, };
export type PromptFormSize = "sm" | "md" | "lg" | "full" | "dynamic";
export type PromptTextRequest = { id: string, title: string, label: string, description?: string, defaultValue?: string, placeholder?: string,
/**

View File

@@ -49,7 +49,7 @@ export type HttpResponseEvent = { model: "http_response_event", id: string, crea
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
* The `From` impl is in yaak-http to avoid circular dependencies.
*/
export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, path: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array<string>, duration: bigint, overridden: boolean, };
export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, scheme: string, username: string, password: string, host: string, port: number, path: string, query: string, fragment: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array<string>, duration: bigint, overridden: boolean, };
export type HttpResponseHeader = { name: string, value: string, };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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<HttpRequest>) {
// Build URL with query parameters
let finalUrl = request.url || '';
const urlParams = (request.urlParameters ?? []).filter((p) => p.enabled !== false && !!p.name);
if (urlParams.length > 0) {
const [base, hash] = finalUrl.split('#');
const separator = base?.includes('?') ? '&' : '?';
const queryString = urlParams
.map((p) => `${encodeURIComponent(p.name)}=${encodeURIComponent(p.value)}`)
.join('&');
finalUrl = base + separator + queryString + (hash ? `#${hash}` : '');
}
// Build headers array
const headers: Array<{ name: string; value: string }> = (request.headers ?? [])
.filter((h) => h.enabled !== false && !!h.name)
.map((h) => ({ name: h.name, value: h.value }));
// Handle authentication
if (request.authentication?.disabled !== true) {
if (request.authenticationType === 'basic') {
const credentials = btoa(
`${request.authentication?.username ?? ''}:${request.authentication?.password ?? ''}`,
);
headers.push({ name: 'Authorization', value: `Basic ${credentials}` });
} else if (request.authenticationType === 'bearer') {
const prefix = request.authentication?.prefix ?? 'Bearer';
const token = request.authentication?.token ?? '';
headers.push({ name: 'Authorization', value: `${prefix} ${token}`.trim() });
} else if (request.authenticationType === 'apikey') {
if (request.authentication?.location === 'header') {
headers.push({
name: request.authentication?.key ?? 'X-Api-Key',
value: request.authentication?.value ?? '',
});
} else if (request.authentication?.location === 'query') {
const sep = finalUrl.includes('?') ? '&' : '?';
finalUrl = [
finalUrl,
sep,
encodeURIComponent(request.authentication?.key ?? 'token'),
'=',
encodeURIComponent(request.authentication?.value ?? ''),
].join('');
}
}
}
// Build HAR request object
const har: Record<string, unknown> = {
method: request.method || 'GET',
url: finalUrl,
headers,
};
// Handle request body
const bodyType = request.bodyType ?? 'none';
if (bodyType !== 'none' && request.body) {
if (bodyType === 'application/x-www-form-urlencoded' && Array.isArray(request.body.form)) {
const params = request.body.form
.filter((p: { enabled?: boolean; name?: string }) => p.enabled !== false && !!p.name)
.map((p: { name: string; value: string }) => ({ name: p.name, value: p.value }));
har.postData = {
mimeType: 'application/x-www-form-urlencoded',
params,
};
} else if (bodyType === 'multipart/form-data' && Array.isArray(request.body.form)) {
const params = request.body.form
.filter((p: { enabled?: boolean; name?: string }) => p.enabled !== false && !!p.name)
.map((p: { name: string; value: string; file?: string; contentType?: string }) => {
const param: Record<string, string> = { name: p.name, value: p.value || '' };
if (p.file) param.fileName = p.file;
if (p.contentType) param.contentType = p.contentType;
return param;
});
har.postData = {
mimeType: 'multipart/form-data',
params,
};
} else if (bodyType === 'graphql' && typeof request.body.query === 'string') {
const body = {
query: request.body.query || '',
variables: maybeParseJSON(request.body.variables, undefined),
};
har.postData = {
mimeType: 'application/json',
text: JSON.stringify(body),
};
} else if (typeof request.body.text === 'string') {
har.postData = {
mimeType: bodyType,
text: request.body.text,
};
}
}
return har;
}
function maybeParseJSON<T>(v: unknown, fallback: T): T | unknown {
if (typeof v !== 'string') return fallback;
try {
return JSON.parse(v);
} catch {
return fallback;
}
}
export const plugin: PluginDefinition = {
httpRequestActions: [
{
label: 'Generate Code Snippet',
icon: 'copy',
async onSelect(ctx, args) {
// Render the request with variables resolved
const renderedRequest = await ctx.httpRequest.render({
httpRequest: args.httpRequest,
purpose: 'send',
});
// Convert to HAR format
const harRequest = toHarRequest(renderedRequest);
// Get previously selected language or use defaults
const storedTarget = await ctx.store.get<string>('selectedTarget');
const storedClient = await ctx.store.get<string>('selectedClient');
const initialTarget = storedTarget || defaultTarget;
const initialClient = storedClient || defaultClient;
// Create snippet generator
const snippet = new HTTPSnippet(harRequest);
// 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',
});
}
}
},
},
],
};

View File

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

View File

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

View File

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

View File

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