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";