mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-07-08 13:55:14 +02:00
Consolidate RPC commands into unified execute_action dispatcher
Replace individual RPC commands (proxy_start, proxy_stop) with a single execute_action(ActionInvocation) handler. This simplifies the RPC interface and enables action chaining through events for workflows like duplicate-then-navigate.
This commit is contained in:
@@ -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<String>,
|
||||||
|
pub default_hotkey_mac: Option<Vec<String>>,
|
||||||
|
pub default_hotkey_other: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<GlobalFrontendAction, () => void>;
|
||||||
|
http_exchange: Record<HttpExchangeFrontendAction, (ctx: { http_exchange_id: string }) => 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
|
||||||
@@ -28,7 +28,6 @@ listen("model_write", (payload) => {
|
|||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [status, setStatus] = useState("Idle");
|
const [status, setStatus] = useState("Idle");
|
||||||
const [port, setPort] = useState<number | null>(null);
|
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const osType = type();
|
const osType = type();
|
||||||
const exchanges = useAtomValue(httpExchangesAtom);
|
const exchanges = useAtomValue(httpExchangesAtom);
|
||||||
@@ -37,9 +36,8 @@ function App() {
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
setStatus("Starting...");
|
setStatus("Starting...");
|
||||||
try {
|
try {
|
||||||
const result = await rpc("proxy_start", { port: 9090 });
|
await rpc("execute_action", { scope: "global", action: "proxy_start" });
|
||||||
setPort(result.port);
|
setStatus("Running");
|
||||||
setStatus(result.alreadyRunning ? "Already running" : "Running");
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus(`Failed: ${String(err)}`);
|
setStatus(`Failed: ${String(err)}`);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -51,9 +49,8 @@ function App() {
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
setStatus("Stopping...");
|
setStatus("Stopping...");
|
||||||
try {
|
try {
|
||||||
const stopped = await rpc("proxy_stop", {});
|
await rpc("execute_action", { scope: "global", action: "proxy_stop" });
|
||||||
setPort(null);
|
setStatus("Stopped");
|
||||||
setStatus(stopped ? "Stopped" : "Not running");
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus(`Failed: ${String(err)}`);
|
setStatus(`Failed: ${String(err)}`);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -98,7 +95,6 @@ function App() {
|
|||||||
</Button>
|
</Button>
|
||||||
<span className="text-xs text-text-subtlest">
|
<span className="text-xs text-text-subtlest">
|
||||||
{status}
|
{status}
|
||||||
{port != null && ` · :${port}`}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod actions;
|
||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
|
|
||||||
@@ -10,6 +11,7 @@ use ts_rs::TS;
|
|||||||
use yaak_database::{ModelChangeEvent, UpdateSource};
|
use yaak_database::{ModelChangeEvent, UpdateSource};
|
||||||
use yaak_proxy::{CapturedRequest, ProxyEvent, ProxyHandle, RequestState};
|
use yaak_proxy::{CapturedRequest, ProxyEvent, ProxyHandle, RequestState};
|
||||||
use yaak_rpc::{RpcError, RpcEventEmitter, define_rpc};
|
use yaak_rpc::{RpcError, RpcEventEmitter, define_rpc};
|
||||||
|
use crate::actions::{ActionInvocation, GlobalAction};
|
||||||
use crate::db::ProxyQueryManager;
|
use crate::db::ProxyQueryManager;
|
||||||
use crate::models::{HttpExchange, ModelPayload, ProxyHeader};
|
use crate::models::{HttpExchange, ModelPayload, ProxyHeader};
|
||||||
|
|
||||||
@@ -33,24 +35,6 @@ impl ProxyCtx {
|
|||||||
|
|
||||||
// -- Request/response types --
|
// -- Request/response types --
|
||||||
|
|
||||||
#[derive(Deserialize, TS)]
|
|
||||||
#[ts(export, export_to = "gen_rpc.ts")]
|
|
||||||
pub struct ProxyStartRequest {
|
|
||||||
pub port: Option<u16>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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)]
|
#[derive(Deserialize, TS)]
|
||||||
#[ts(export, export_to = "gen_rpc.ts")]
|
#[ts(export, export_to = "gen_rpc.ts")]
|
||||||
pub struct ListModelsRequest {}
|
pub struct ListModelsRequest {}
|
||||||
@@ -64,37 +48,41 @@ pub struct ListModelsResponse {
|
|||||||
|
|
||||||
// -- Handlers --
|
// -- Handlers --
|
||||||
|
|
||||||
fn proxy_start(ctx: &ProxyCtx, req: ProxyStartRequest) -> Result<ProxyStartResponse, RpcError> {
|
fn execute_action(ctx: &ProxyCtx, invocation: ActionInvocation) -> Result<bool, RpcError> {
|
||||||
let mut handle = ctx
|
match invocation {
|
||||||
.handle
|
ActionInvocation::Global { action } => match action {
|
||||||
.lock()
|
GlobalAction::ProxyStart => {
|
||||||
.map_err(|_| RpcError { message: "lock poisoned".into() })?;
|
let mut handle = ctx
|
||||||
|
.handle
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| RpcError { message: "lock poisoned".into() })?;
|
||||||
|
|
||||||
if let Some(existing) = handle.as_ref() {
|
if handle.is_some() {
|
||||||
return Ok(ProxyStartResponse { port: existing.port, already_running: true });
|
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<bool, RpcError> {
|
|
||||||
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<ListModelsResponse, RpcError> {
|
fn list_models(ctx: &ProxyCtx, _req: ListModelsRequest) -> Result<ListModelsResponse, RpcError> {
|
||||||
@@ -211,8 +199,7 @@ fn write_entry(db: &ProxyQueryManager, events: &RpcEventEmitter, r: &CapturedReq
|
|||||||
define_rpc! {
|
define_rpc! {
|
||||||
ProxyCtx;
|
ProxyCtx;
|
||||||
commands {
|
commands {
|
||||||
proxy_start(ProxyStartRequest) -> ProxyStartResponse,
|
execute_action(ActionInvocation) -> bool,
|
||||||
proxy_stop(ProxyStopRequest) -> bool,
|
|
||||||
list_models(ListModelsRequest) -> ListModelsResponse,
|
list_models(ListModelsRequest) -> ListModelsResponse,
|
||||||
}
|
}
|
||||||
events {
|
events {
|
||||||
|
|||||||
Reference in New Issue
Block a user