diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 00000000..e9c3366b --- /dev/null +++ b/PLAN.md @@ -0,0 +1,208 @@ +# Unified Actions System (Proxy App) + +## Context + +The proxy app is greenfield — no existing hotkeys, context menus, or command palette to migrate. This is an opportunity to build the actions system right from the start, so every interactive feature is powered by a single shared registry. + +## Goals + +- One place to define every user-facing action (label, icon, default hotkey) +- Actions can be triggered from hotkeys, context menus, command palette, native menus, or toolbar buttons +- Rust-defined enums exported to TypeScript via ts-rs for type safety +- Actions are either **Core** (handled in Rust) or **Frontend** (handled in TypeScript) +- All dispatch goes through one Rust `execute_action()` function — callable as an RPC command (from frontend) or directly (from native menus / Rust code) + +## Relationship to RPC + +Actions sit **on top of** the RPC layer. RPC is the transport; actions are user intent. + +- `execute_action` is an RPC command — the frontend calls it to trigger any action +- Core action handlers live in Rust and contain the business logic directly +- Frontend action handlers live in TypeScript — when Rust receives a frontend action, it emits a Tauri event and the frontend listener handles it +- Actions carry no params — they use defaults or derive what they need from scope context (e.g., `http_exchange_id`). Standalone RPC commands like `proxy_start`/`proxy_stop` go away — `execute_action` is the only entry point. + +``` +Native Tauri menu / Rust code + → execute_action(ActionInvocation) + → Core? → call handler directly + → Frontend? → emit event to frontend → frontend handles + +Frontend hotkey / context menu / command palette + → rpc("execute_action", ActionInvocation) + → same execute_action() function + → Core? → call handler directly + → Frontend? → emit event back to frontend → frontend handles +``` + +## Scopes + +| Scope | Context | Example actions | +|-------|---------|-----------------| +| `Global` | (none) | start/stop proxy, clear history, zoom, toggle command palette | +| `HttpExchange` | `http_exchange_id: String` | view details, copy URL, copy as cURL, delete, replay | + +Start small — more scopes can be added later as the app grows. + +## Rust Types + +### Action enums (per scope, split Core / Frontend) + +```rust +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export, export_to = "gen_actions.ts")] +pub enum GlobalCoreAction { + ProxyStart, + ProxyStop, + ClearHistory, +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export, export_to = "gen_actions.ts")] +pub enum GlobalFrontendAction { + ToggleCommandPalette, + ZoomIn, + ZoomOut, + ZoomReset, +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export, export_to = "gen_actions.ts")] +pub enum HttpExchangeCoreAction { + Delete, + Replay, +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export, export_to = "gen_actions.ts")] +pub enum HttpExchangeFrontendAction { + ViewDetails, + CopyUrl, + CopyAsCurl, +} +``` + +### Invocation enum + +```rust +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(tag = "scope", rename_all = "snake_case")] +#[ts(export, export_to = "gen_actions.ts")] +pub enum ActionInvocation { + Global { action: GlobalAction }, + HttpExchange { action: HttpExchangeAction, http_exchange_id: String }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(tag = "handler", content = "action", rename_all = "snake_case")] +#[ts(export, export_to = "gen_actions.ts")] +pub enum GlobalAction { + Core(GlobalCoreAction), + Frontend(GlobalFrontendAction), +} +// same for HttpExchangeAction +``` + +### Action metadata + +```rust +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "gen_actions.ts")] +pub struct ActionMetadata { + pub label: String, + pub icon: Option, + pub default_hotkey_mac: Option>, + pub default_hotkey_other: Option>, +} + +pub fn action_metadata(action: &ActionInvocation) -> ActionMetadata { ... } +``` + +### Dispatch function + +```rust +pub fn execute_action(ctx: &ProxyCtx, invocation: ActionInvocation) -> Result<(), RpcError> { + match invocation { + ActionInvocation::Global { action: GlobalAction::Core(a) } => match a { + GlobalCoreAction::ProxyStart => { + // Start proxy on default port (9090) + // Business logic lives here, not in a separate RPC command + let mut handle = ctx.handle.lock()...; + let proxy_handle = yaak_proxy::start_proxy(9090)?; + // ... + Ok(()) + } + GlobalCoreAction::ProxyStop => { + let mut handle = ctx.handle.lock()...; + handle.take(); // Drop stops the proxy + Ok(()) + } + GlobalCoreAction::ClearHistory => { /* ... */ Ok(()) } + }, + ActionInvocation::Global { action: GlobalAction::Frontend(_) } => { + // Emit event — frontend listener handles it + ctx.events.emit("action_invoke", &invocation); + Ok(()) + } + ActionInvocation::HttpExchange { action, http_exchange_id } => { + // similar pattern + todo!() + } + } +} +``` + +## TypeScript Side + +```typescript +// Dispatch any action — always goes through Rust +async function dispatch(invocation: ActionInvocation) { + await rpc("execute_action", invocation); +} + +// Listen for frontend actions emitted by Rust +listen("action_invoke", (invocation: ActionInvocation) => { + // Route to the right handler + const handler = frontendHandlers[invocation.scope]?.[invocation.action.action]; + handler?.(invocation); +}); + +// Type-safe exhaustive handlers +type FrontendHandlers = { + global: Record void>; + http_exchange: Record void>; +}; +``` + +## Crate Location + +`crates-proxy/yaak-proxy-actions/` — action enums, metadata, `execute_action()` function, ts-rs exports. Referenced by `yaak-proxy-lib` to register as an RPC command. + +## Implementation Steps + +### Step 1: Rust action definitions + dispatch +- Create `crates-proxy/yaak-proxy-actions/` with enums, `ActionInvocation`, metadata, `execute_action()` +- ts-rs generates `bindings/gen_actions.ts` +- Add `execute_action` to `define_rpc!` in `yaak-proxy-lib` + +### Step 2: TypeScript dispatch + handlers +- Create `apps/yaak-proxy/actions.ts` +- Import generated types, define `FrontendHandlers`, wire `dispatch()` +- Listen for `action_invoke` events (for Rust-initiated frontend actions) + +### Step 3: Wire up UI +- Toolbar buttons call `dispatch()` instead of inline `rpc()` calls +- Add context menu on exchange table rows using action items +- Build a basic command palette from the action registry + +## Verification + +- `cargo check -p yaak-proxy-actions` +- `tsgo --noEmit` from repo root +- Toolbar start/stop still works (now via actions) +- Right-click exchange row shows context menu with correct labels +- Command palette lists available actions diff --git a/apps/yaak-proxy/main.tsx b/apps/yaak-proxy/main.tsx index 01199df2..11061c4a 100644 --- a/apps/yaak-proxy/main.tsx +++ b/apps/yaak-proxy/main.tsx @@ -28,7 +28,6 @@ listen("model_write", (payload) => { function App() { const [status, setStatus] = useState("Idle"); - const [port, setPort] = useState(null); const [busy, setBusy] = useState(false); const osType = type(); const exchanges = useAtomValue(httpExchangesAtom); @@ -37,9 +36,8 @@ function App() { setBusy(true); setStatus("Starting..."); try { - const result = await rpc("proxy_start", { port: 9090 }); - setPort(result.port); - setStatus(result.alreadyRunning ? "Already running" : "Running"); + await rpc("execute_action", { scope: "global", action: "proxy_start" }); + setStatus("Running"); } catch (err) { setStatus(`Failed: ${String(err)}`); } finally { @@ -51,9 +49,8 @@ function App() { setBusy(true); setStatus("Stopping..."); try { - const stopped = await rpc("proxy_stop", {}); - setPort(null); - setStatus(stopped ? "Stopped" : "Not running"); + await rpc("execute_action", { scope: "global", action: "proxy_stop" }); + setStatus("Stopped"); } catch (err) { setStatus(`Failed: ${String(err)}`); } finally { @@ -98,7 +95,6 @@ function App() { {status} - {port != null && ` · :${port}`} diff --git a/crates-proxy/yaak-proxy-lib/src/actions.rs b/crates-proxy/yaak-proxy-lib/src/actions.rs new file mode 100644 index 00000000..2c86407e --- /dev/null +++ b/crates-proxy/yaak-proxy-lib/src/actions.rs @@ -0,0 +1,17 @@ +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export, export_to = "gen_rpc.ts")] +pub enum GlobalAction { + ProxyStart, + ProxyStop, +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(tag = "scope", rename_all = "snake_case")] +#[ts(export, export_to = "gen_rpc.ts")] +pub enum ActionInvocation { + Global { action: GlobalAction }, +} diff --git a/crates-proxy/yaak-proxy-lib/src/lib.rs b/crates-proxy/yaak-proxy-lib/src/lib.rs index 2e3f93b5..24ef93f7 100644 --- a/crates-proxy/yaak-proxy-lib/src/lib.rs +++ b/crates-proxy/yaak-proxy-lib/src/lib.rs @@ -1,3 +1,4 @@ +pub mod actions; pub mod db; pub mod models; @@ -10,6 +11,7 @@ use ts_rs::TS; use yaak_database::{ModelChangeEvent, UpdateSource}; use yaak_proxy::{CapturedRequest, ProxyEvent, ProxyHandle, RequestState}; use yaak_rpc::{RpcError, RpcEventEmitter, define_rpc}; +use crate::actions::{ActionInvocation, GlobalAction}; use crate::db::ProxyQueryManager; use crate::models::{HttpExchange, ModelPayload, ProxyHeader}; @@ -33,24 +35,6 @@ impl ProxyCtx { // -- Request/response types -- -#[derive(Deserialize, TS)] -#[ts(export, export_to = "gen_rpc.ts")] -pub struct ProxyStartRequest { - pub port: Option, -} - -#[derive(Serialize, TS)] -#[ts(export, export_to = "gen_rpc.ts")] -#[serde(rename_all = "camelCase")] -pub struct ProxyStartResponse { - pub port: u16, - pub already_running: bool, -} - -#[derive(Deserialize, TS)] -#[ts(export, export_to = "gen_rpc.ts")] -pub struct ProxyStopRequest {} - #[derive(Deserialize, TS)] #[ts(export, export_to = "gen_rpc.ts")] pub struct ListModelsRequest {} @@ -64,37 +48,41 @@ pub struct ListModelsResponse { // -- Handlers -- -fn proxy_start(ctx: &ProxyCtx, req: ProxyStartRequest) -> Result { - let mut handle = ctx - .handle - .lock() - .map_err(|_| RpcError { message: "lock poisoned".into() })?; +fn execute_action(ctx: &ProxyCtx, invocation: ActionInvocation) -> Result { + match invocation { + ActionInvocation::Global { action } => match action { + GlobalAction::ProxyStart => { + let mut handle = ctx + .handle + .lock() + .map_err(|_| RpcError { message: "lock poisoned".into() })?; - if let Some(existing) = handle.as_ref() { - return Ok(ProxyStartResponse { port: existing.port, already_running: true }); + if handle.is_some() { + return Ok(true); // already running + } + + let mut proxy_handle = yaak_proxy::start_proxy(9090) + .map_err(|e| RpcError { message: e })?; + + if let Some(event_rx) = proxy_handle.take_event_rx() { + let db = ctx.db.clone(); + let events = ctx.events.clone(); + std::thread::spawn(move || run_event_loop(event_rx, db, events)); + } + + *handle = Some(proxy_handle); + Ok(true) + } + GlobalAction::ProxyStop => { + let mut handle = ctx + .handle + .lock() + .map_err(|_| RpcError { message: "lock poisoned".into() })?; + handle.take(); + Ok(true) + } + }, } - - let mut proxy_handle = yaak_proxy::start_proxy(req.port.unwrap_or(0)) - .map_err(|e| RpcError { message: e })?; - let port = proxy_handle.port; - - // Spawn event loop before storing the handle - if let Some(event_rx) = proxy_handle.take_event_rx() { - let db = ctx.db.clone(); - let events = ctx.events.clone(); - std::thread::spawn(move || run_event_loop(event_rx, db, events)); - } - - *handle = Some(proxy_handle); - Ok(ProxyStartResponse { port, already_running: false }) -} - -fn proxy_stop(ctx: &ProxyCtx, _req: ProxyStopRequest) -> Result { - let mut handle = ctx - .handle - .lock() - .map_err(|_| RpcError { message: "lock poisoned".into() })?; - Ok(handle.take().is_some()) } fn list_models(ctx: &ProxyCtx, _req: ListModelsRequest) -> Result { @@ -211,8 +199,7 @@ fn write_entry(db: &ProxyQueryManager, events: &RpcEventEmitter, r: &CapturedReq define_rpc! { ProxyCtx; commands { - proxy_start(ProxyStartRequest) -> ProxyStartResponse, - proxy_stop(ProxyStopRequest) -> bool, + execute_action(ActionInvocation) -> bool, list_models(ListModelsRequest) -> ListModelsResponse, } events {