From 12ece44197a4d9f2b130afb066e4b7883c9275fb Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Sun, 8 Mar 2026 22:32:49 -0700 Subject: [PATCH] Move Tree component to @yaakapp-internal/ui package Decouple Tree from client app's hotkey system by adding getSelectedItems() to TreeHandle and having callers register hotkeys externally. Extract shared action callbacks to eliminate duplication between hotkey and context menu handlers. Co-Authored-By: Claude Opus 4.6 --- PLAN.md | 208 ---------------- .../components/EnvironmentEditDialog.tsx | 89 ++++--- apps/yaak-client/components/Sidebar.tsx | 224 +++++++++--------- apps/yaak-proxy/ActionButton.tsx | 31 +++ apps/yaak-proxy/hooks.ts | 35 +++ apps/yaak-proxy/hotkeys.ts | 64 +++++ apps/yaak-proxy/main.tsx | 57 ++--- apps/yaak-proxy/package.json | 1 + apps/yaak-proxy/rpc.ts | 2 +- apps/yaak-proxy/store.ts | 2 +- .../yaak-proxy-lib/bindings/gen_rpc.ts | 18 +- crates-proxy/yaak-proxy-lib/bindings/index.ts | 2 + crates-proxy/yaak-proxy-lib/package.json | 6 + crates-proxy/yaak-proxy-lib/src/actions.rs | 36 +++ crates-proxy/yaak-proxy-lib/src/lib.rs | 19 +- .../yaak-models/bindings/ModelChangeEvent.ts | 3 + crates/yaak-models/bindings/gen_models.ts | 39 ++- .../yaak-models/src/queries/model_changes.rs | 8 +- package-lock.json | 91 +++---- package.json | 3 +- .../ui/src/components}/tree/Tree.tsx | 73 +----- .../src/components}/tree/TreeDragOverlay.tsx | 0 .../src/components}/tree/TreeDropMarker.tsx | 2 +- .../src/components}/tree/TreeIndentGuide.tsx | 0 .../ui/src/components}/tree/TreeItem.tsx | 4 +- .../ui/src/components}/tree/TreeItemList.tsx | 0 .../ui/src/components}/tree/atoms.ts | 0 .../ui/src/components}/tree/common.ts | 0 .../ui/src/components}/tree/context.ts | 0 .../components}/tree/useSelectableItems.ts | 0 packages/ui/src/index.ts | 5 + 31 files changed, 477 insertions(+), 545 deletions(-) delete mode 100644 PLAN.md create mode 100644 apps/yaak-proxy/ActionButton.tsx create mode 100644 apps/yaak-proxy/hooks.ts create mode 100644 apps/yaak-proxy/hotkeys.ts create mode 100644 crates-proxy/yaak-proxy-lib/bindings/index.ts create mode 100644 crates-proxy/yaak-proxy-lib/package.json create mode 100644 crates/yaak-models/bindings/ModelChangeEvent.ts rename {apps/yaak-client/components/core => packages/ui/src/components}/tree/Tree.tsx (93%) rename {apps/yaak-client/components/core => packages/ui/src/components}/tree/TreeDragOverlay.tsx (100%) rename {apps/yaak-client/components/core => packages/ui/src/components}/tree/TreeDropMarker.tsx (95%) rename {apps/yaak-client/components/core => packages/ui/src/components}/tree/TreeIndentGuide.tsx (100%) rename {apps/yaak-client/components/core => packages/ui/src/components}/tree/TreeItem.tsx (99%) rename {apps/yaak-client/components/core => packages/ui/src/components}/tree/TreeItemList.tsx (100%) rename {apps/yaak-client/components/core => packages/ui/src/components}/tree/atoms.ts (100%) rename {apps/yaak-client/components/core => packages/ui/src/components}/tree/common.ts (100%) rename {apps/yaak-client/components/core => packages/ui/src/components}/tree/context.ts (100%) rename {apps/yaak-client/components/core => packages/ui/src/components}/tree/useSelectableItems.ts (100%) diff --git a/PLAN.md b/PLAN.md deleted file mode 100644 index e9c3366b..00000000 --- a/PLAN.md +++ /dev/null @@ -1,208 +0,0 @@ -# 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-client/components/EnvironmentEditDialog.tsx b/apps/yaak-client/components/EnvironmentEditDialog.tsx index ddce40b6..6376dcff 100644 --- a/apps/yaak-client/components/EnvironmentEditDialog.tsx +++ b/apps/yaak-client/components/EnvironmentEditDialog.tsx @@ -1,13 +1,14 @@ import type { Environment, Workspace } from '@yaakapp-internal/models'; import { duplicateModel, patchModel } from '@yaakapp-internal/models'; import { atom, useAtomValue } from 'jotai'; -import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useLayoutEffect, useRef, useState } from 'react'; import { createSubEnvironmentAndActivate } from '../commands/createEnvironment'; import { activeWorkspaceAtom, activeWorkspaceIdAtom } from '../hooks/useActiveWorkspace'; import { environmentsBreakdownAtom, useEnvironmentsBreakdown, } from '../hooks/useEnvironmentsBreakdown'; +import { useHotKey } from '../hooks/useHotKey'; import { deleteModelWithConfirm } from '../lib/deleteModelWithConfirm'; import { jotaiStore } from '../lib/jotai'; import { isBaseEnvironment, isSubEnvironment } from '../lib/model_util'; @@ -16,7 +17,8 @@ import { showColorPicker } from '../lib/showColorPicker'; import { Banner } from './core/Banner'; import type { ContextMenuProps, DropdownItem } from './core/Dropdown'; import { ContextMenu } from './core/Dropdown'; -import { Icon } from '@yaakapp-internal/ui'; +import { Icon, Tree } from '@yaakapp-internal/ui'; +import type { TreeNode, TreeHandle, TreeProps } from '@yaakapp-internal/ui'; import { IconButton } from './core/IconButton'; import { IconTooltip } from './core/IconTooltip'; import { InlineCode } from './core/InlineCode'; @@ -24,9 +26,6 @@ import type { PairEditorHandle } from './core/PairEditor'; import { SplitLayout } from './core/SplitLayout'; import { atomFamily } from 'jotai/utils'; import { atomWithKVStorage } from '../lib/atoms/atomWithKVStorage'; -import type { TreeNode } from './core/tree/common'; -import type { TreeHandle, TreeProps } from './core/tree/Tree'; -import { Tree } from './core/tree/Tree'; import { EnvironmentColorIndicator } from './EnvironmentColorIndicator'; import { EnvironmentEditor } from './EnvironmentEditor'; import { EnvironmentSharableTooltip } from './EnvironmentSharableTooltip'; @@ -137,44 +136,43 @@ function EnvironmentEditDialogSidebar({ [baseEnvironment?.id, selectedEnvironmentId, setSelectedEnvironmentId], ); - const actions = useMemo(() => { - const enable = () => treeRef.current?.hasFocus() ?? false; + const treeHasFocus = useCallback(() => treeRef.current?.hasFocus() ?? false, []); - const actions = { - 'sidebar.selected.rename': { - enable, - allowDefault: true, - priority: 100, - cb: async (items: TreeModel[]) => { - const item = items[0]; - if (items.length === 1 && item != null) { - treeRef.current?.renameItem(item.id); - } - }, - }, - 'sidebar.selected.delete': { - priority: 100, - enable, - cb: (items: TreeModel[]) => deleteModelWithConfirm(items), - }, - 'sidebar.selected.duplicate': { - priority: 100, - enable, - cb: async (items: TreeModel[]) => { - if (items.length === 1 && items[0]) { - const item = items[0]; - const newId = await duplicateModel(item); - setSelectedEnvironmentId(newId); - } else { - await Promise.all(items.map(duplicateModel)); - } - }, - }, - } as const; - return actions; + const getSelectedTreeModels = useCallback( + () => treeRef.current?.getSelectedItems() as TreeModel[] | undefined, + [], + ); + + const handleRenameSelected = useCallback(() => { + const items = getSelectedTreeModels(); + if (items?.length === 1 && items[0] != null) { + treeRef.current?.renameItem(items[0].id); + } + }, [getSelectedTreeModels]); + + const handleDeleteSelected = useCallback( + (items: TreeModel[]) => deleteModelWithConfirm(items), + [], + ); + + const handleDuplicateSelected = useCallback(async (items: TreeModel[]) => { + if (items.length === 1 && items[0]) { + const newId = await duplicateModel(items[0]); + setSelectedEnvironmentId(newId); + } else { + await Promise.all(items.map(duplicateModel)); + } }, [setSelectedEnvironmentId]); - const hotkeys = useMemo['hotkeys']>(() => ({ actions }), [actions]); + useHotKey('sidebar.selected.rename', handleRenameSelected, { enable: treeHasFocus, allowDefault: true, priority: 100 }); + useHotKey('sidebar.selected.delete', useCallback(() => { + const items = getSelectedTreeModels(); + if (items) handleDeleteSelected(items); + }, [getSelectedTreeModels, handleDeleteSelected]), { enable: treeHasFocus, priority: 100 }); + useHotKey('sidebar.selected.duplicate', useCallback(async () => { + const items = getSelectedTreeModels(); + if (items) await handleDuplicateSelected(items); + }, [getSelectedTreeModels, handleDuplicateSelected]), { enable: treeHasFocus, priority: 100 }); const getContextMenu = useCallback( (items: TreeModel[]): ContextMenuProps['items'] => { @@ -203,12 +201,10 @@ function EnvironmentEditDialogSidebar({ hidden: isBaseEnvironment(environment) || !singleEnvironment, hotKeyAction: 'sidebar.selected.rename', hotKeyLabelOnly: true, - onSelect: async () => { + onSelect: () => { // Not sure why this is needed, but without it the // edit input blurs immediately after opening. - requestAnimationFrame(() => { - actions['sidebar.selected.rename'].cb(items); - }); + requestAnimationFrame(() => handleRenameSelected()); }, }, { @@ -217,7 +213,7 @@ function EnvironmentEditDialogSidebar({ hidden: isBaseEnvironment(environment), hotKeyAction: 'sidebar.selected.duplicate', hotKeyLabelOnly: true, - onSelect: () => actions['sidebar.selected.duplicate'].cb(items), + onSelect: () => handleDuplicateSelected(items), }, { label: environment.color ? 'Change Color' : 'Assign Color', @@ -253,7 +249,7 @@ function EnvironmentEditDialogSidebar({ return menuItems; }, - [actions, baseEnvironments.length, handleDeleteEnvironment], + [baseEnvironments.length, handleDeleteEnvironment, setSelectedEnvironmentId], ); const handleDragEnd = useCallback(async function handleDragEnd({ @@ -317,7 +313,6 @@ function EnvironmentEditDialogSidebar({ treeId={treeId} collapsedAtom={collapsedFamily(treeId)} className="px-2 pb-10" - hotkeys={hotkeys} root={tree} getContextMenu={getContextMenu} renderContextMenu={renderContextMenuFn} diff --git a/apps/yaak-client/components/Sidebar.tsx b/apps/yaak-client/components/Sidebar.tsx index ee28654c..adc169af 100644 --- a/apps/yaak-client/components/Sidebar.tsx +++ b/apps/yaak-client/components/Sidebar.tsx @@ -54,16 +54,18 @@ import { filter } from './core/Editor/filter/extension'; import { evaluate, parseQuery } from './core/Editor/filter/query'; import { HttpMethodTag } from './core/HttpMethodTag'; import { HttpStatusTag } from './core/HttpStatusTag'; -import { Icon, LoadingIcon } from '@yaakapp-internal/ui'; +import { + Icon, + LoadingIcon, + Tree, + isSelectedFamily, + selectedIdsFamily, +} from '@yaakapp-internal/ui'; +import type { TreeNode, TreeHandle, TreeProps, TreeItemProps } from '@yaakapp-internal/ui'; import { IconButton } from './core/IconButton'; import { InlineCode } from './core/InlineCode'; import type { InputHandle } from './core/Input'; import { Input } from './core/Input'; -import { isSelectedFamily, selectedIdsFamily } from './core/tree/atoms'; -import type { TreeNode } from './core/tree/common'; -import type { TreeHandle, TreeProps } from './core/tree/Tree'; -import { Tree } from './core/tree/Tree'; -import type { TreeItemProps } from './core/tree/TreeItem'; import { atomWithKVStorage } from '../lib/atoms/atomWithKVStorage'; import { GitDropdown } from './git/GitDropdown'; @@ -234,93 +236,98 @@ function Sidebar({ className }: { className?: string }) { [], ); - const actions = useMemo(() => { - const enable = () => treeRef.current?.hasFocus() ?? false; + const treeHasFocus = useCallback(() => treeRef.current?.hasFocus() ?? false, []); - const actions = { - 'sidebar.context_menu': { - enable, - cb: () => treeRef.current?.showContextMenu(), - }, - 'sidebar.expand_all': { - enable: isSidebarFocused, - cb: () => { - jotaiStore.set(collapsedFamily(treeId), {}); - }, - }, - 'sidebar.collapse_all': { - enable: isSidebarFocused, - cb: () => { - if (tree == null) return; + const getSelectedTreeModels = useCallback( + () => treeRef.current?.getSelectedItems() as SidebarModel[] | undefined, + [], + ); - const next = (node: TreeNode, collapsed: Record) => { - let newCollapsed = { ...collapsed }; - for (const n of node.children ?? []) { - if (n.item.model !== 'folder') continue; - newCollapsed[n.item.id] = true; - newCollapsed = next(n, newCollapsed); - } - return newCollapsed; - }; - const collapsed = next(tree, {}); - jotaiStore.set(collapsedFamily(treeId), collapsed); - }, - }, - 'sidebar.selected.delete': { - enable, - cb: async (items: SidebarModel[]) => { - await deleteModelWithConfirm(items); - }, - }, - 'sidebar.selected.rename': { - enable, - allowDefault: true, - cb: async (items: SidebarModel[]) => { - const item = items[0]; - if (items.length === 1 && item != null) { - treeRef.current?.renameItem(item.id); - } - }, - }, - 'sidebar.selected.duplicate': { - // Higher priority so this takes precedence over model.duplicate (same Meta+d binding) - priority: 10, - enable, - cb: async (items: SidebarModel[]) => { - if (items.length === 1 && items[0]) { - const item = items[0]; - const newId = await duplicateModel(item); - navigateToRequestOrFolderOrWorkspace(newId, item.model); - } else { - await Promise.all(items.map(duplicateModel)); - } - }, - }, - 'sidebar.selected.move': { - enable, - cb: async (items: SidebarModel[]) => { - const requests = items.filter( - (i): i is HttpRequest | GrpcRequest | WebsocketRequest => - i.model === 'http_request' || i.model === 'grpc_request' || i.model === 'websocket_request' - ); - if (requests.length > 0) { - moveToWorkspace.mutate(requests); - } - }, - }, - 'request.send': { - enable, - cb: async (items: SidebarModel[]) => { - await Promise.all( - items - .filter((i) => i.model === 'http_request') - .map((i) => sendAnyHttpRequest.mutate(i.id)), - ); - }, - }, - } as const; - return actions; - }, [tree, treeId]); + const handleRenameSelected = useCallback((items: SidebarModel[]) => { + if (items.length === 1 && items[0] != null) { + treeRef.current?.renameItem(items[0].id); + } + }, []); + + const handleDeleteSelected = useCallback( + async (items: SidebarModel[]) => { await deleteModelWithConfirm(items); }, + [], + ); + + const handleDuplicateSelected = useCallback(async (items: SidebarModel[]) => { + if (items.length === 1 && items[0]) { + const newId = await duplicateModel(items[0]); + navigateToRequestOrFolderOrWorkspace(newId, items[0].model); + } else { + await Promise.all(items.map(duplicateModel)); + } + }, []); + + const handleMoveSelected = useCallback((items: SidebarModel[]) => { + const requests = items.filter( + (i): i is HttpRequest | GrpcRequest | WebsocketRequest => + i.model === 'http_request' || i.model === 'grpc_request' || i.model === 'websocket_request' + ); + if (requests.length > 0) { + moveToWorkspace.mutate(requests); + } + }, []); + + const handleSendSelected = useCallback(async (items: SidebarModel[]) => { + await Promise.all( + items + .filter((i) => i.model === 'http_request') + .map((i) => sendAnyHttpRequest.mutate(i.id)), + ); + }, []); + + useHotKey('sidebar.context_menu', useCallback(() => { + treeRef.current?.showContextMenu(); + }, []), { enable: treeHasFocus }); + + useHotKey('sidebar.expand_all', useCallback(() => { + jotaiStore.set(collapsedFamily(treeId), {}); + }, [treeId]), { enable: isSidebarFocused }); + + useHotKey('sidebar.collapse_all', useCallback(() => { + if (tree == null) return; + const next = (node: TreeNode, collapsed: Record) => { + let newCollapsed = { ...collapsed }; + for (const n of node.children ?? []) { + if (n.item.model !== 'folder') continue; + newCollapsed[n.item.id] = true; + newCollapsed = next(n, newCollapsed); + } + return newCollapsed; + }; + const collapsed = next(tree, {}); + jotaiStore.set(collapsedFamily(treeId), collapsed); + }, [tree, treeId]), { enable: isSidebarFocused }); + + useHotKey('sidebar.selected.delete', useCallback(() => { + const items = getSelectedTreeModels(); + if (items) handleDeleteSelected(items); + }, [getSelectedTreeModels, handleDeleteSelected]), { enable: treeHasFocus }); + + useHotKey('sidebar.selected.rename', useCallback(() => { + const items = getSelectedTreeModels(); + if (items) handleRenameSelected(items); + }, [getSelectedTreeModels, handleRenameSelected]), { enable: treeHasFocus, allowDefault: true }); + + useHotKey('sidebar.selected.duplicate', useCallback(async () => { + const items = getSelectedTreeModels(); + if (items) await handleDuplicateSelected(items); + }, [getSelectedTreeModels, handleDuplicateSelected]), { priority: 10, enable: treeHasFocus }); + + useHotKey('sidebar.selected.move', useCallback(() => { + const items = getSelectedTreeModels(); + if (items) handleMoveSelected(items); + }, [getSelectedTreeModels, handleMoveSelected]), { enable: treeHasFocus }); + + useHotKey('request.send', useCallback(async () => { + const items = getSelectedTreeModels(); + if (items) await handleSendSelected(items); + }, [getSelectedTreeModels, handleSendSelected]), { enable: treeHasFocus }); const getContextMenu = useCallback<(items: SidebarModel[]) => Promise>( async (items) => { @@ -356,7 +363,7 @@ function Sidebar({ className }: { className?: string }) { hotKeyLabelOnly: true, hidden: !onlyHttpRequests, leftSlot: , - onSelect: () => actions['request.send'].cb(items), + onSelect: () => handleSendSelected(items), }, ...(items.length === 1 && child.model === 'http_request' ? await getHttpRequestActions() @@ -426,16 +433,14 @@ function Sidebar({ className }: { className?: string }) { hidden: items.length > 1, hotKeyAction: 'sidebar.selected.rename', hotKeyLabelOnly: true, - onSelect: () => { - treeRef.current?.renameItem(child.id); - }, + onSelect: () => handleRenameSelected(items), }, { label: 'Duplicate', hotKeyAction: 'model.duplicate', hotKeyLabelOnly: true, // Would trigger for every request (bad) leftSlot: , - onSelect: () => actions['sidebar.selected.duplicate'].cb(items), + onSelect: () => handleDuplicateSelected(items), }, { label: items.length <= 1 ? 'Move' : `Move ${requestItems.length} Requests`, @@ -443,9 +448,7 @@ function Sidebar({ className }: { className?: string }) { hotKeyLabelOnly: true, leftSlot: , hidden: workspaces.length <= 1 || requestItems.length === 0 || requestItems.length !== items.length, - onSelect: () => { - actions['sidebar.selected.move'].cb(items); - }, + onSelect: () => handleMoveSelected(items), }, { color: 'danger', @@ -453,13 +456,13 @@ function Sidebar({ className }: { className?: string }) { hotKeyAction: 'sidebar.selected.delete', hotKeyLabelOnly: true, leftSlot: , - onSelect: () => actions['sidebar.selected.delete'].cb(items), + onSelect: () => handleDeleteSelected(items), }, ...modelCreationItems, ]; return menuItems; }, - [actions], + [], ); const renderContextMenuFn = useCallback['renderContextMenu']>>( @@ -469,8 +472,6 @@ function Sidebar({ className }: { className?: string }) { [], ); - const hotkeys = useMemo['hotkeys']>(() => ({ actions }), [actions]); - // Use a language compartment for the filter so we can reconfigure it when the autocompletion changes const filterLanguageCompartmentRef = useRef(new Compartment()); const filterCompartmentMountExtRef = useRef(null); @@ -556,14 +557,26 @@ function Sidebar({ className }: { className?: string }) { { label: 'Expand All Folders', leftSlot: , - onSelect: actions['sidebar.expand_all'].cb, + onSelect: () => jotaiStore.set(collapsedFamily(treeId), {}), hotKeyAction: 'sidebar.expand_all', hotKeyLabelOnly: true, }, { label: 'Collapse All Folders', leftSlot: , - onSelect: actions['sidebar.collapse_all'].cb, + onSelect: () => { + if (tree == null) return; + const next = (node: TreeNode, collapsed: Record) => { + let newCollapsed = { ...collapsed }; + for (const n of node.children ?? []) { + if (n.item.model !== 'folder') continue; + newCollapsed[n.item.id] = true; + newCollapsed = next(n, newCollapsed); + } + return newCollapsed; + }; + jotaiStore.set(collapsedFamily(treeId), next(tree, {})); + }, hotKeyAction: 'sidebar.collapse_all', hotKeyLabelOnly: true, }, @@ -589,7 +602,6 @@ function Sidebar({ className }: { className?: string }) { root={tree} treeId={treeId} collapsedAtom={collapsedFamily(treeId)} - hotkeys={hotkeys} getItemKey={getItemKey} ItemInner={SidebarInnerItem} ItemLeftSlotInner={SidebarLeftSlot} diff --git a/apps/yaak-proxy/ActionButton.tsx b/apps/yaak-proxy/ActionButton.tsx new file mode 100644 index 00000000..717fd7bc --- /dev/null +++ b/apps/yaak-proxy/ActionButton.tsx @@ -0,0 +1,31 @@ +import { Button, type ButtonProps } from "@yaakapp-internal/ui"; +import { useCallback, useState } from "react"; +import type { ActionInvocation } from "@yaakapp-internal/proxy-lib"; +import { useActionMetadata } from "./hooks"; +import { rpc } from "./rpc"; + +type ActionButtonProps = Omit & { + action: ActionInvocation; + /** Override the label from metadata */ + children?: React.ReactNode; +}; + +export function ActionButton({ action, children, ...props }: ActionButtonProps) { + const meta = useActionMetadata(action); + const [busy, setBusy] = useState(false); + + const onClick = useCallback(async () => { + setBusy(true); + try { + await rpc("execute_action", action); + } finally { + setBusy(false); + } + }, [action]); + + return ( + + ); +} diff --git a/apps/yaak-proxy/hooks.ts b/apps/yaak-proxy/hooks.ts new file mode 100644 index 00000000..d89b444d --- /dev/null +++ b/apps/yaak-proxy/hooks.ts @@ -0,0 +1,35 @@ +import { useEffect, useState } from "react"; +import type { + ActionInvocation, + ActionMetadata, +} from "@yaakapp-internal/proxy-lib"; +import { rpc } from "./rpc"; + +let cachedActions: [ActionInvocation, ActionMetadata][] | null = null; + +/** Fetch and cache all action metadata. */ +async function getActions(): Promise<[ActionInvocation, ActionMetadata][]> { + if (!cachedActions) { + const { actions } = await rpc("list_actions", {}); + cachedActions = actions; + } + return cachedActions; +} + +/** Look up metadata for a specific action invocation. */ +export function useActionMetadata( + action: ActionInvocation, +): ActionMetadata | null { + const [meta, setMeta] = useState(null); + + useEffect(() => { + getActions().then((actions) => { + const match = actions.find( + ([inv]) => inv.scope === action.scope && inv.action === action.action, + ); + setMeta(match?.[1] ?? null); + }); + }, [action]); + + return meta; +} diff --git a/apps/yaak-proxy/hotkeys.ts b/apps/yaak-proxy/hotkeys.ts new file mode 100644 index 00000000..18a3f549 --- /dev/null +++ b/apps/yaak-proxy/hotkeys.ts @@ -0,0 +1,64 @@ +import type { + ActionInvocation, + ActionMetadata, +} from "@yaakapp-internal/proxy-lib"; +import { rpc } from "./rpc"; + +type ActionBinding = { + invocation: ActionInvocation; + meta: ActionMetadata; + keys: { key: string; ctrl: boolean; shift: boolean; alt: boolean; meta: boolean }; +}; + +/** Parse a hotkey string like "Ctrl+Shift+P" into its parts. */ +function parseHotkey(hotkey: string): ActionBinding["keys"] { + const parts = hotkey.split("+").map((p) => p.trim().toLowerCase()); + return { + ctrl: parts.includes("ctrl") || parts.includes("control"), + shift: parts.includes("shift"), + alt: parts.includes("alt"), + meta: parts.includes("meta") || parts.includes("cmd") || parts.includes("command"), + key: parts.filter( + (p) => !["ctrl", "control", "shift", "alt", "meta", "cmd", "command"].includes(p), + )[0] ?? "", + }; +} + +function matchesEvent(binding: ActionBinding["keys"], e: KeyboardEvent): boolean { + return ( + e.ctrlKey === binding.ctrl && + e.shiftKey === binding.shift && + e.altKey === binding.alt && + e.metaKey === binding.meta && + e.key.toLowerCase() === binding.key + ); +} + +/** Fetch all actions from Rust and register a global keydown listener. */ +export async function initHotkeys(): Promise<() => void> { + const { actions } = await rpc("list_actions", {}); + + const bindings: ActionBinding[] = actions + .filter( + (entry): entry is [ActionInvocation, ActionMetadata & { defaultHotkey: string }] => + entry[1].defaultHotkey != null, + ) + .map(([invocation, meta]) => ({ + invocation, + meta, + keys: parseHotkey(meta.defaultHotkey), + })); + + function onKeyDown(e: KeyboardEvent) { + for (const binding of bindings) { + if (matchesEvent(binding.keys, e)) { + e.preventDefault(); + rpc("execute_action", binding.invocation); + return; + } + } + } + + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); +} diff --git a/apps/yaak-proxy/main.tsx b/apps/yaak-proxy/main.tsx index 11061c4a..98e3e20a 100644 --- a/apps/yaak-proxy/main.tsx +++ b/apps/yaak-proxy/main.tsx @@ -1,11 +1,13 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { type } from "@tauri-apps/plugin-os"; -import { Button, HeaderSize } from "@yaakapp-internal/ui"; +import { HeaderSize } from "@yaakapp-internal/ui"; +import { ActionButton } from "./ActionButton"; import classNames from "classnames"; import { createStore, Provider, useAtomValue } from "jotai"; -import { StrictMode, useState } from "react"; +import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import "./main.css"; +import { initHotkeys } from "./hotkeys"; import { listen, rpc } from "./rpc"; import { applyChange, dataAtom, httpExchangesAtom, replaceAll } from "./store"; @@ -19,6 +21,9 @@ rpc("list_models", {}).then((res) => { ); }); +// Register hotkeys from action metadata +initHotkeys(); + // Subscribe to model change events from the backend listen("model_write", (payload) => { jotaiStore.set(dataAtom, (prev) => @@ -27,37 +32,9 @@ listen("model_write", (payload) => { }); function App() { - const [status, setStatus] = useState("Idle"); - const [busy, setBusy] = useState(false); const osType = type(); const exchanges = useAtomValue(httpExchangesAtom); - async function startProxy() { - setBusy(true); - setStatus("Starting..."); - try { - await rpc("execute_action", { scope: "global", action: "proxy_start" }); - setStatus("Running"); - } catch (err) { - setStatus(`Failed: ${String(err)}`); - } finally { - setBusy(false); - } - } - - async function stopProxy() { - setBusy(true); - setStatus("Stopping..."); - try { - await rpc("execute_action", { scope: "global", action: "proxy_stop" }); - setStatus("Stopped"); - } catch (err) { - setStatus(`Failed: ${String(err)}`); - } finally { - setBusy(false); - } - } - return (
- - - - {status} - + />
diff --git a/apps/yaak-proxy/package.json b/apps/yaak-proxy/package.json index 7abad0b0..2daad042 100644 --- a/apps/yaak-proxy/package.json +++ b/apps/yaak-proxy/package.json @@ -14,6 +14,7 @@ "@tauri-apps/plugin-os": "^2.3.2", "@yaakapp-internal/theme": "^1.0.0", "@yaakapp-internal/model-store": "^1.0.0", + "@yaakapp-internal/proxy-lib": "^1.0.0", "@yaakapp-internal/ui": "^1.0.0", "classnames": "^2.5.1", "jotai": "^2.18.0", diff --git a/apps/yaak-proxy/rpc.ts b/apps/yaak-proxy/rpc.ts index 10c01628..87548f79 100644 --- a/apps/yaak-proxy/rpc.ts +++ b/apps/yaak-proxy/rpc.ts @@ -3,7 +3,7 @@ import { listen as tauriListen } from "@tauri-apps/api/event"; import type { RpcEventSchema, RpcSchema, -} from "../../crates-proxy/yaak-proxy-lib/bindings/gen_rpc"; +} from "@yaakapp-internal/proxy-lib"; type Req = RpcSchema[K][0]; type Res = RpcSchema[K][1]; diff --git a/apps/yaak-proxy/store.ts b/apps/yaak-proxy/store.ts index 59782b18..31f281ee 100644 --- a/apps/yaak-proxy/store.ts +++ b/apps/yaak-proxy/store.ts @@ -1,5 +1,5 @@ import { createModelStore } from "@yaakapp-internal/model-store"; -import type { HttpExchange } from "../../crates-proxy/yaak-proxy-lib/bindings/gen_models"; +import type { HttpExchange } from "@yaakapp-internal/proxy-lib"; type ProxyModels = { http_exchange: HttpExchange; diff --git a/crates-proxy/yaak-proxy-lib/bindings/gen_rpc.ts b/crates-proxy/yaak-proxy-lib/bindings/gen_rpc.ts index 1e1bff59..d40577fa 100644 --- a/crates-proxy/yaak-proxy-lib/bindings/gen_rpc.ts +++ b/crates-proxy/yaak-proxy-lib/bindings/gen_rpc.ts @@ -1,16 +1,20 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { HttpExchange, ModelPayload } from "./gen_models"; +export type ActionInvocation = { "scope": "global", action: GlobalAction, }; + +export type ActionMetadata = { label: string, defaultHotkey: string | null, }; + +export type GlobalAction = "proxy_start" | "proxy_stop"; + +export type ListActionsRequest = Record; + +export type ListActionsResponse = { actions: Array<[ActionInvocation, ActionMetadata]>, }; + export type ListModelsRequest = Record; export type ListModelsResponse = { httpExchanges: Array, }; -export type ProxyStartRequest = { port: number | null, }; - -export type ProxyStartResponse = { port: number, alreadyRunning: boolean, }; - -export type ProxyStopRequest = Record; - export type RpcEventSchema = { model_write: ModelPayload, }; -export type RpcSchema = { proxy_start: [ProxyStartRequest, ProxyStartResponse], proxy_stop: [ProxyStopRequest, boolean], list_models: [ListModelsRequest, ListModelsResponse], }; +export type RpcSchema = { execute_action: [ActionInvocation, boolean], list_actions: [ListActionsRequest, ListActionsResponse], list_models: [ListModelsRequest, ListModelsResponse], }; diff --git a/crates-proxy/yaak-proxy-lib/bindings/index.ts b/crates-proxy/yaak-proxy-lib/bindings/index.ts new file mode 100644 index 00000000..a05bf1ab --- /dev/null +++ b/crates-proxy/yaak-proxy-lib/bindings/index.ts @@ -0,0 +1,2 @@ +export * from "./gen_rpc"; +export * from "./gen_models"; diff --git a/crates-proxy/yaak-proxy-lib/package.json b/crates-proxy/yaak-proxy-lib/package.json new file mode 100644 index 00000000..70d12b4f --- /dev/null +++ b/crates-proxy/yaak-proxy-lib/package.json @@ -0,0 +1,6 @@ +{ + "name": "@yaakapp-internal/proxy-lib", + "private": true, + "version": "1.0.0", + "main": "bindings/index.ts" +} diff --git a/crates-proxy/yaak-proxy-lib/src/actions.rs b/crates-proxy/yaak-proxy-lib/src/actions.rs index 2c86407e..278e97a6 100644 --- a/crates-proxy/yaak-proxy-lib/src/actions.rs +++ b/crates-proxy/yaak-proxy-lib/src/actions.rs @@ -15,3 +15,39 @@ pub enum GlobalAction { pub enum ActionInvocation { Global { action: GlobalAction }, } + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct ActionMetadata { + pub label: String, + pub default_hotkey: Option, +} + +fn default_hotkey(mac: &str, other: &str) -> Option { + if cfg!(target_os = "macos") { + Some(mac.into()) + } else { + Some(other.into()) + } +} + +/// All global actions with their metadata, used by `list_actions` RPC. +pub fn all_global_actions() -> Vec<(ActionInvocation, ActionMetadata)> { + vec![ + ( + ActionInvocation::Global { action: GlobalAction::ProxyStart }, + ActionMetadata { + label: "Start Proxy".into(), + default_hotkey: default_hotkey("Meta+Shift+P", "Ctrl+Shift+P"), + }, + ), + ( + ActionInvocation::Global { action: GlobalAction::ProxyStop }, + ActionMetadata { + label: "Stop Proxy".into(), + default_hotkey: default_hotkey("Meta+Shift+S", "Ctrl+Shift+S"), + }, + ), + ] +} diff --git a/crates-proxy/yaak-proxy-lib/src/lib.rs b/crates-proxy/yaak-proxy-lib/src/lib.rs index 24ef93f7..b36459dd 100644 --- a/crates-proxy/yaak-proxy-lib/src/lib.rs +++ b/crates-proxy/yaak-proxy-lib/src/lib.rs @@ -11,7 +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::actions::{ActionInvocation, ActionMetadata, GlobalAction}; use crate::db::ProxyQueryManager; use crate::models::{HttpExchange, ModelPayload, ProxyHeader}; @@ -35,6 +35,16 @@ impl ProxyCtx { // -- Request/response types -- +#[derive(Deserialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct ListActionsRequest {} + +#[derive(Serialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct ListActionsResponse { + pub actions: Vec<(ActionInvocation, ActionMetadata)>, +} + #[derive(Deserialize, TS)] #[ts(export, export_to = "gen_rpc.ts")] pub struct ListModelsRequest {} @@ -85,6 +95,12 @@ fn execute_action(ctx: &ProxyCtx, invocation: ActionInvocation) -> Result Result { + Ok(ListActionsResponse { + actions: crate::actions::all_global_actions(), + }) +} + fn list_models(ctx: &ProxyCtx, _req: ListModelsRequest) -> Result { ctx.db.with_conn(|db| { Ok(ListModelsResponse { @@ -200,6 +216,7 @@ define_rpc! { ProxyCtx; commands { execute_action(ActionInvocation) -> bool, + list_actions(ListActionsRequest) -> ListActionsResponse, list_models(ListModelsRequest) -> ListModelsResponse, } events { diff --git a/crates/yaak-models/bindings/ModelChangeEvent.ts b/crates/yaak-models/bindings/ModelChangeEvent.ts new file mode 100644 index 00000000..36c8397c --- /dev/null +++ b/crates/yaak-models/bindings/ModelChangeEvent.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ModelChangeEvent = { "type": "upsert", created: boolean, } | { "type": "delete" }; diff --git a/crates/yaak-models/bindings/gen_models.ts b/crates/yaak-models/bindings/gen_models.ts index 8fcf3467..19a202bd 100644 --- a/crates/yaak-models/bindings/gen_models.ts +++ b/crates/yaak-models/bindings/gen_models.ts @@ -1,4 +1,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ModelChangeEvent } from "./ModelChangeEvent"; export type AnyModel = CookieJar | Environment | Folder | GraphQlIntrospection | GrpcConnection | GrpcEvent | GrpcRequest | HttpRequest | HttpResponse | HttpResponseEvent | KeyValue | Plugin | Settings | SyncState | WebsocketConnection | WebsocketEvent | WebsocketRequest | Workspace | WorkspaceMeta; @@ -18,7 +19,12 @@ export type EditorKeymap = "default" | "vim" | "vscode" | "emacs"; export type EncryptedKey = { encryptedKey: string, }; -export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null, variables: Array, color: string | null, sortPriority: number, }; +export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null, +/** + * Variables defined in this environment scope. + * Child environments override parent variables by name. + */ +variables: Array, color: string | null, sortPriority: number, }; export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, }; @@ -34,9 +40,17 @@ export type GrpcEvent = { model: "grpc_event", id: string, createdAt: string, up export type GrpcEventType = "info" | "error" | "client_message" | "server_message" | "connection_start" | "connection_end"; -export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record, description: string, message: string, metadata: Array, method: string | null, name: string, service: string | null, sortPriority: number, url: string, }; +export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record, description: string, message: string, metadata: Array, method: string | null, name: string, service: string | null, sortPriority: number, +/** + * Server URL (http for plaintext or https for secure) + */ +url: string, }; -export type HttpRequest = { model: "http_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record, authenticationType: string | null, body: Record, bodyType: string | null, description: string, headers: Array, method: string, name: string, sortPriority: number, url: string, urlParameters: Array, }; +export type HttpRequest = { model: "http_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record, authenticationType: string | null, body: Record, bodyType: string | null, description: string, headers: Array, method: string, name: string, sortPriority: number, url: string, +/** + * URL parameters used for both path placeholders (`:id`) and query string entries. + */ +urlParameters: Array, }; export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, }; @@ -55,12 +69,15 @@ export type HttpResponseHeader = { name: string, value: string, }; export type HttpResponseState = "initialized" | "connected" | "closed"; -export type HttpUrlParameter = { enabled?: boolean, name: string, value: string, id?: string, }; +export type HttpUrlParameter = { enabled?: boolean, +/** + * Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id` + * Other entries are appended as query parameters + */ +name: string, value: string, id?: string, }; export type KeyValue = { model: "key_value", id: string, createdAt: string, updatedAt: string, key: string, namespace: string, value: string, }; -export type ModelChangeEvent = { "type": "upsert", created: boolean, } | { "type": "delete" }; - export type ModelPayload = { model: AnyModel, updateSource: UpdateSource, change: ModelChangeEvent, }; export type ParentAuthentication = { authentication: Record, authenticationType: string | null, }; @@ -69,10 +86,10 @@ export type ParentHeaders = { headers: Array, }; export type Plugin = { model: "plugin", id: string, createdAt: string, updatedAt: string, checkedAt: string | null, directory: string, enabled: boolean, url: string | null, source: PluginSource, }; -export type PluginSource = "bundled" | "filesystem" | "registry"; - export type PluginKeyValue = { model: "plugin_key_value", createdAt: string, updatedAt: string, pluginName: string, key: string, value: string, }; +export type PluginSource = "bundled" | "filesystem" | "registry"; + export type ProxySetting = { "type": "enabled", http: string, https: string, auth: ProxySettingAuth | null, bypass: string, disabled: boolean, } | { "type": "disabled" }; export type ProxySettingAuth = { user: string, password: string, }; @@ -93,7 +110,11 @@ export type WebsocketEventType = "binary" | "close" | "frame" | "open" | "ping" export type WebsocketMessageType = "text" | "binary"; -export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record, authenticationType: string | null, description: string, headers: Array, message: string, name: string, sortPriority: number, url: string, urlParameters: Array, }; +export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record, authenticationType: string | null, description: string, headers: Array, message: string, name: string, sortPriority: number, url: string, +/** + * URL parameters used for both path placeholders (`:id`) and query string entries. + */ +urlParameters: Array, }; export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record, authenticationType: string | null, description: string, headers: Array, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, settingDnsOverrides: Array, }; diff --git a/crates/yaak-models/src/queries/model_changes.rs b/crates/yaak-models/src/queries/model_changes.rs index de263a05..386511ea 100644 --- a/crates/yaak-models/src/queries/model_changes.rs +++ b/crates/yaak-models/src/queries/model_changes.rs @@ -162,7 +162,7 @@ mod tests { let changes = db.list_model_changes_after(0, 10).expect("Failed to list changes"); assert_eq!(changes.len(), 1); - db.conn + db.conn() .resolve() .execute( "UPDATE model_changes SET created_at = '2000-01-01 00:00:00.000' WHERE id = ?1", @@ -199,7 +199,7 @@ mod tests { assert_eq!(all.len(), 2); let fixed_ts = "2026-02-16 00:00:00.000"; - db.conn + db.conn() .resolve() .execute("UPDATE model_changes SET created_at = ?1", params![fixed_ts]) .expect("Failed to normalize timestamps"); @@ -229,7 +229,7 @@ mod tests { let changes = db.list_model_changes_after(0, 10).expect("Failed to list changes"); assert_eq!(changes.len(), 1); - db.conn + db.conn() .resolve() .execute( "UPDATE model_changes SET created_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW', '-2 hours') WHERE id = ?1", @@ -264,7 +264,7 @@ mod tests { "change": { "type": "upsert", "created": false } }); - db.conn + db.conn() .resolve() .execute( r#" diff --git a/package-lock.json b/package-lock.json index 1146f997..c7bca5de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -66,6 +66,7 @@ "crates/yaak-sync", "crates/yaak-templates", "crates/yaak-ws", + "crates-proxy/yaak-proxy-lib", "apps/yaak-client", "apps/yaak-proxy" ], @@ -77,7 +78,7 @@ "@codemirror/legacy-modes": "^6.5.2" }, "devDependencies": { - "@biomejs/biome": "^2.3.13", + "@biomejs/biome": "^2.4.6", "@tauri-apps/cli": "^2.9.6", "@yaakapp/cli": "^0.5.1", "dotenv-cli": "^11.0.0", @@ -230,6 +231,7 @@ "@tauri-apps/api": "^2.9.1", "@tauri-apps/plugin-os": "^2.3.2", "@yaakapp-internal/model-store": "^1.0.0", + "@yaakapp-internal/proxy-lib": "^1.0.0", "@yaakapp-internal/theme": "^1.0.0", "@yaakapp-internal/ui": "^1.0.0", "classnames": "^2.5.1", @@ -245,6 +247,10 @@ "vite": "^7.0.8" } }, + "crates-proxy/yaak-proxy-lib": { + "name": "@yaakapp-internal/proxy-lib", + "version": "1.0.0" + }, "crates-tauri/yaak-app-client": { "name": "@yaakapp-internal/tauri-client", "version": "1.0.0" @@ -675,11 +681,10 @@ } }, "node_modules/@biomejs/biome": { - "version": "2.3.13", - "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.13.tgz", - "integrity": "sha512-Fw7UsV0UAtWIBIm0M7g5CRerpu1eKyKAXIazzxhbXYUyMkwNrkX/KLkGI7b+uVDQ5cLUMfOC9vR60q9IDYDstA==", + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.6.tgz", + "integrity": "sha512-QnHe81PMslpy3mnpL8DnO2M4S4ZnYPkjlGCLWBZT/3R9M6b5daArWMMtEfP52/n174RKnwRIf3oT8+wc9ihSfQ==", "dev": true, - "license": "MIT OR Apache-2.0", "bin": { "biome": "bin/biome" }, @@ -691,25 +696,24 @@ "url": "https://opencollective.com/biome" }, "optionalDependencies": { - "@biomejs/cli-darwin-arm64": "2.3.13", - "@biomejs/cli-darwin-x64": "2.3.13", - "@biomejs/cli-linux-arm64": "2.3.13", - "@biomejs/cli-linux-arm64-musl": "2.3.13", - "@biomejs/cli-linux-x64": "2.3.13", - "@biomejs/cli-linux-x64-musl": "2.3.13", - "@biomejs/cli-win32-arm64": "2.3.13", - "@biomejs/cli-win32-x64": "2.3.13" + "@biomejs/cli-darwin-arm64": "2.4.6", + "@biomejs/cli-darwin-x64": "2.4.6", + "@biomejs/cli-linux-arm64": "2.4.6", + "@biomejs/cli-linux-arm64-musl": "2.4.6", + "@biomejs/cli-linux-x64": "2.4.6", + "@biomejs/cli-linux-x64-musl": "2.4.6", + "@biomejs/cli-win32-arm64": "2.4.6", + "@biomejs/cli-win32-x64": "2.4.6" } }, "node_modules/@biomejs/cli-darwin-arm64": { - "version": "2.3.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.13.tgz", - "integrity": "sha512-0OCwP0/BoKzyJHnFdaTk/i7hIP9JHH9oJJq6hrSCPmJPo8JWcJhprK4gQlhFzrwdTBAW4Bjt/RmCf3ZZe59gwQ==", + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.6.tgz", + "integrity": "sha512-NW18GSyxr+8sJIqgoGwVp5Zqm4SALH4b4gftIA0n62PTuBs6G2tHlwNAOj0Vq0KKSs7Sf88VjjmHh0O36EnzrQ==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT OR Apache-2.0", "optional": true, "os": [ "darwin" @@ -719,14 +723,13 @@ } }, "node_modules/@biomejs/cli-darwin-x64": { - "version": "2.3.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.13.tgz", - "integrity": "sha512-AGr8OoemT/ejynbIu56qeil2+F2WLkIjn2d8jGK1JkchxnMUhYOfnqc9sVzcRxpG9Ycvw4weQ5sprRvtb7Yhcw==", + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.6.tgz", + "integrity": "sha512-4uiE/9tuI7cnjtY9b07RgS7gGyYOAfIAGeVJWEfeCnAarOAS7qVmuRyX6d7JTKw28/mt+rUzMasYeZ+0R/U1Mw==", "cpu": [ "x64" ], "dev": true, - "license": "MIT OR Apache-2.0", "optional": true, "os": [ "darwin" @@ -736,14 +739,13 @@ } }, "node_modules/@biomejs/cli-linux-arm64": { - "version": "2.3.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.13.tgz", - "integrity": "sha512-xvOiFkrDNu607MPMBUQ6huHmBG1PZLOrqhtK6pXJW3GjfVqJg0Z/qpTdhXfcqWdSZHcT+Nct2fOgewZvytESkw==", + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.6.tgz", + "integrity": "sha512-kMLaI7OF5GN1Q8Doymjro1P8rVEoy7BKQALNz6fiR8IC1WKduoNyteBtJlHT7ASIL0Cx2jR6VUOBIbcB1B8pew==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT OR Apache-2.0", "optional": true, "os": [ "linux" @@ -753,14 +755,13 @@ } }, "node_modules/@biomejs/cli-linux-arm64-musl": { - "version": "2.3.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.13.tgz", - "integrity": "sha512-TUdDCSY+Eo/EHjhJz7P2GnWwfqet+lFxBZzGHldrvULr59AgahamLs/N85SC4+bdF86EhqDuuw9rYLvLFWWlXA==", + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.6.tgz", + "integrity": "sha512-F/JdB7eN22txiTqHM5KhIVt0jVkzZwVYrdTR1O3Y4auBOQcXxHK4dxULf4z43QyZI5tsnQJrRBHZy7wwtL+B3A==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT OR Apache-2.0", "optional": true, "os": [ "linux" @@ -770,14 +771,13 @@ } }, "node_modules/@biomejs/cli-linux-x64": { - "version": "2.3.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.13.tgz", - "integrity": "sha512-s+YsZlgiXNq8XkgHs6xdvKDFOj/bwTEevqEY6rC2I3cBHbxXYU1LOZstH3Ffw9hE5tE1sqT7U23C00MzkXztMw==", + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.6.tgz", + "integrity": "sha512-oHXmUFEoH8Lql1xfc3QkFLiC1hGR7qedv5eKNlC185or+o4/4HiaU7vYODAH3peRCfsuLr1g6v2fK9dFFOYdyw==", "cpu": [ "x64" ], "dev": true, - "license": "MIT OR Apache-2.0", "optional": true, "os": [ "linux" @@ -787,14 +787,13 @@ } }, "node_modules/@biomejs/cli-linux-x64-musl": { - "version": "2.3.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.13.tgz", - "integrity": "sha512-0bdwFVSbbM//Sds6OjtnmQGp4eUjOTt6kHvR/1P0ieR9GcTUAlPNvPC3DiavTqq302W34Ae2T6u5VVNGuQtGlQ==", + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.6.tgz", + "integrity": "sha512-C9s98IPDu7DYarjlZNuzJKTjVHN03RUnmHV5htvqsx6vEUXCDSJ59DNwjKVD5XYoSS4N+BYhq3RTBAL8X6svEg==", "cpu": [ "x64" ], "dev": true, - "license": "MIT OR Apache-2.0", "optional": true, "os": [ "linux" @@ -804,14 +803,13 @@ } }, "node_modules/@biomejs/cli-win32-arm64": { - "version": "2.3.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.13.tgz", - "integrity": "sha512-QweDxY89fq0VvrxME+wS/BXKmqMrOTZlN9SqQ79kQSIc3FrEwvW/PvUegQF6XIVaekncDykB5dzPqjbwSKs9DA==", + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.6.tgz", + "integrity": "sha512-xzThn87Pf3YrOGTEODFGONmqXpTwUNxovQb72iaUOdcw8sBSY3+3WD8Hm9IhMYLnPi0n32s3L3NWU6+eSjfqFg==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT OR Apache-2.0", "optional": true, "os": [ "win32" @@ -821,14 +819,13 @@ } }, "node_modules/@biomejs/cli-win32-x64": { - "version": "2.3.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.13.tgz", - "integrity": "sha512-trDw2ogdM2lyav9WFQsdsfdVy1dvZALymRpgmWsvSez0BJzBjulhOT/t+wyKeh3pZWvwP3VMs1SoOKwO3wecMQ==", + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.6.tgz", + "integrity": "sha512-7++XhnsPlr1HDbor5amovPjOH6vsrFOCdp93iKXhFn6bcMUI6soodj3WWKfgEO6JosKU1W5n3uky3WW9RlRjTg==", "cpu": [ "x64" ], "dev": true, - "license": "MIT OR Apache-2.0", "optional": true, "os": [ "win32" @@ -4445,6 +4442,10 @@ "resolved": "crates/yaak-plugins", "link": true }, + "node_modules/@yaakapp-internal/proxy-lib": { + "resolved": "crates-proxy/yaak-proxy-lib", + "link": true + }, "node_modules/@yaakapp-internal/sse": { "resolved": "crates/yaak-sse", "link": true diff --git a/package.json b/package.json index dfbe6b42..cdf0e05f 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "crates/yaak-sync", "crates/yaak-templates", "crates/yaak-ws", + "crates-proxy/yaak-proxy-lib", "apps/yaak-client", "apps/yaak-proxy" ], @@ -105,7 +106,7 @@ "js-yaml": "^4.1.1" }, "devDependencies": { - "@biomejs/biome": "^2.3.13", + "@biomejs/biome": "^2.4.6", "@tauri-apps/cli": "^2.9.6", "@yaakapp/cli": "^0.5.1", "dotenv-cli": "^11.0.0", diff --git a/apps/yaak-client/components/core/tree/Tree.tsx b/packages/ui/src/components/tree/Tree.tsx similarity index 93% rename from apps/yaak-client/components/core/tree/Tree.tsx rename to packages/ui/src/components/tree/Tree.tsx index e5dd008a..bab535d8 100644 --- a/apps/yaak-client/components/core/tree/Tree.tsx +++ b/packages/ui/src/components/tree/Tree.tsx @@ -22,9 +22,7 @@ import { useState, } from 'react'; import { useKey, useKeyPressEvent } from 'react-use'; -import type { HotKeyOptions, HotkeyAction } from '../../../hooks/useHotKey'; -import { useHotKey } from '../../../hooks/useHotKey'; -import { computeSideForDragMove } from '@yaakapp-internal/ui'; +import { computeSideForDragMove } from '../../lib/dnd'; import { useStore } from 'jotai'; import { draggingIdsFamily, @@ -57,9 +55,6 @@ export interface TreeProps { className?: string; onActivate?: (item: T) => void; onDragEnd?: (opt: { items: T[]; parent: T; children: T[]; insertAt: number }) => void; - hotkeys?: { - actions: Partial void } & HotKeyOptions>>; - }; getEditOptions?: (item: T) => { defaultValue: string; placeholder?: string; @@ -71,6 +66,7 @@ export interface TreeHandle { treeId: string; focus: () => boolean; hasFocus: () => boolean; + getSelectedItems: () => { id: string }[]; selectItem: (id: string, focus?: boolean) => void; renameItem: (id: string) => void; showContextMenu: () => void; @@ -83,7 +79,6 @@ function TreeInner( getContextMenu, getEditOptions, getItemKey, - hotkeys, onActivate, onDragEnd, renderContextMenu, @@ -202,6 +197,7 @@ function TreeInner( treeId, focus: tryFocus, hasFocus: hasFocus, + getSelectedItems: () => getSelectedItems(store, treeId, selectableItems), renameItem: (id) => treeItemRefs.current[id]?.rename(), selectItem: (id, focus) => { if (store.get(selectedIdsFamily(treeId)).includes(id)) { @@ -650,7 +646,6 @@ function TreeInner( return ( - {showContextMenu && renderContextMenu?.({ items: showContextMenu.items, @@ -742,68 +737,6 @@ function DropRegionAfterList({ return
; } -interface TreeHotKeyProps { - action: HotkeyAction; - selectableItems: SelectableTreeNode[]; - treeId: string; - onDone: (items: T[]) => void; - priority?: number; - enable?: boolean | (() => boolean); -} - -function TreeHotKey({ - treeId, - action, - onDone, - selectableItems, - enable, - ...options -}: TreeHotKeyProps) { - const store = useStore(); - useHotKey( - action, - () => { - onDone(getSelectedItems(store, treeId, selectableItems)); - }, - { - ...options, - enable: () => { - if (enable == null) return true; - if (typeof enable === 'function') return enable(); - return enable; - }, - }, - ); - return null; -} - -function TreeHotKeys({ - treeId, - hotkeys, - selectableItems, -}: { - treeId: string; - hotkeys: TreeProps['hotkeys']; - selectableItems: SelectableTreeNode[]; -}) { - if (hotkeys == null) return null; - - return ( - <> - {Object.entries(hotkeys.actions).map(([hotkey, { cb, ...options }]) => ( - - ))} - - ); -} - function getValidSelectableItems( store: JotaiStore, collapsedAtom: CollapsedAtom, diff --git a/apps/yaak-client/components/core/tree/TreeDragOverlay.tsx b/packages/ui/src/components/tree/TreeDragOverlay.tsx similarity index 100% rename from apps/yaak-client/components/core/tree/TreeDragOverlay.tsx rename to packages/ui/src/components/tree/TreeDragOverlay.tsx diff --git a/apps/yaak-client/components/core/tree/TreeDropMarker.tsx b/packages/ui/src/components/tree/TreeDropMarker.tsx similarity index 95% rename from apps/yaak-client/components/core/tree/TreeDropMarker.tsx rename to packages/ui/src/components/tree/TreeDropMarker.tsx index 4cbc667c..11564bb7 100644 --- a/apps/yaak-client/components/core/tree/TreeDropMarker.tsx +++ b/packages/ui/src/components/tree/TreeDropMarker.tsx @@ -1,7 +1,7 @@ import classNames from 'classnames'; import { useAtomValue } from 'jotai'; import { memo } from 'react'; -import { DropMarker } from '@yaakapp-internal/ui'; +import { DropMarker } from '../DropMarker'; import { hoveredParentDepthFamily, isIndexHoveredFamily } from './atoms'; import type { TreeNode } from './common'; import { useIsCollapsed } from './context'; diff --git a/apps/yaak-client/components/core/tree/TreeIndentGuide.tsx b/packages/ui/src/components/tree/TreeIndentGuide.tsx similarity index 100% rename from apps/yaak-client/components/core/tree/TreeIndentGuide.tsx rename to packages/ui/src/components/tree/TreeIndentGuide.tsx diff --git a/apps/yaak-client/components/core/tree/TreeItem.tsx b/packages/ui/src/components/tree/TreeItem.tsx similarity index 99% rename from apps/yaak-client/components/core/tree/TreeItem.tsx rename to packages/ui/src/components/tree/TreeItem.tsx index ddc362d8..3fd5584d 100644 --- a/apps/yaak-client/components/core/tree/TreeItem.tsx +++ b/packages/ui/src/components/tree/TreeItem.tsx @@ -9,9 +9,9 @@ import type { KeyboardEvent as ReactKeyboardEvent, } from 'react'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { computeSideForDragMove } from '@yaakapp-internal/ui'; +import { computeSideForDragMove } from '../../lib/dnd'; import type { ContextMenuRenderer } from './common'; -import { Icon } from '@yaakapp-internal/ui'; +import { Icon } from '../Icon'; import { isLastFocusedFamily, isSelectedFamily } from './atoms'; import { useCollapsedAtom, useIsAncestorCollapsed, useIsCollapsed, useSetCollapsed } from './context'; import type { TreeNode } from './common'; diff --git a/apps/yaak-client/components/core/tree/TreeItemList.tsx b/packages/ui/src/components/tree/TreeItemList.tsx similarity index 100% rename from apps/yaak-client/components/core/tree/TreeItemList.tsx rename to packages/ui/src/components/tree/TreeItemList.tsx diff --git a/apps/yaak-client/components/core/tree/atoms.ts b/packages/ui/src/components/tree/atoms.ts similarity index 100% rename from apps/yaak-client/components/core/tree/atoms.ts rename to packages/ui/src/components/tree/atoms.ts diff --git a/apps/yaak-client/components/core/tree/common.ts b/packages/ui/src/components/tree/common.ts similarity index 100% rename from apps/yaak-client/components/core/tree/common.ts rename to packages/ui/src/components/tree/common.ts diff --git a/apps/yaak-client/components/core/tree/context.ts b/packages/ui/src/components/tree/context.ts similarity index 100% rename from apps/yaak-client/components/core/tree/context.ts rename to packages/ui/src/components/tree/context.ts diff --git a/apps/yaak-client/components/core/tree/useSelectableItems.ts b/packages/ui/src/components/tree/useSelectableItems.ts similarity index 100% rename from apps/yaak-client/components/core/tree/useSelectableItems.ts rename to packages/ui/src/components/tree/useSelectableItems.ts diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 6e1ad69d..d089c9f4 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -11,3 +11,8 @@ export { useDebouncedState } from "./hooks/useDebouncedState"; export { HEADER_SIZE_MD, HEADER_SIZE_LG, WINDOW_CONTROLS_WIDTH } from "./lib/constants"; export { DropMarker } from "./components/DropMarker"; export { computeSideForDragMove } from "./lib/dnd"; +export { Tree } from "./components/tree/Tree"; +export type { TreeHandle, TreeProps } from "./components/tree/Tree"; +export type { TreeNode } from "./components/tree/common"; +export type { TreeItemProps } from "./components/tree/TreeItem"; +export { isSelectedFamily, selectedIdsFamily } from "./components/tree/atoms";