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 <noreply@anthropic.com>
This commit is contained in:
Gregory Schier
2026-03-08 22:32:49 -07:00
parent 4c041e68a9
commit 12ece44197
31 changed files with 477 additions and 545 deletions

208
PLAN.md
View File

@@ -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<String>,
pub default_hotkey_mac: Option<Vec<String>>,
pub default_hotkey_other: Option<Vec<String>>,
}
pub fn action_metadata(action: &ActionInvocation) -> ActionMetadata { ... }
```
### Dispatch function
```rust
pub fn execute_action(ctx: &ProxyCtx, invocation: ActionInvocation) -> Result<(), RpcError> {
match invocation {
ActionInvocation::Global { action: GlobalAction::Core(a) } => match a {
GlobalCoreAction::ProxyStart => {
// Start proxy on default port (9090)
// Business logic lives here, not in a separate RPC command
let mut handle = ctx.handle.lock()...;
let proxy_handle = yaak_proxy::start_proxy(9090)?;
// ...
Ok(())
}
GlobalCoreAction::ProxyStop => {
let mut handle = ctx.handle.lock()...;
handle.take(); // Drop stops the proxy
Ok(())
}
GlobalCoreAction::ClearHistory => { /* ... */ Ok(()) }
},
ActionInvocation::Global { action: GlobalAction::Frontend(_) } => {
// Emit event — frontend listener handles it
ctx.events.emit("action_invoke", &invocation);
Ok(())
}
ActionInvocation::HttpExchange { action, http_exchange_id } => {
// similar pattern
todo!()
}
}
}
```
## TypeScript Side
```typescript
// Dispatch any action — always goes through Rust
async function dispatch(invocation: ActionInvocation) {
await rpc("execute_action", invocation);
}
// Listen for frontend actions emitted by Rust
listen("action_invoke", (invocation: ActionInvocation) => {
// Route to the right handler
const handler = frontendHandlers[invocation.scope]?.[invocation.action.action];
handler?.(invocation);
});
// Type-safe exhaustive handlers
type FrontendHandlers = {
global: Record<GlobalFrontendAction, () => void>;
http_exchange: Record<HttpExchangeFrontendAction, (ctx: { http_exchange_id: string }) => void>;
};
```
## Crate Location
`crates-proxy/yaak-proxy-actions/` — action enums, metadata, `execute_action()` function, ts-rs exports. Referenced by `yaak-proxy-lib` to register as an RPC command.
## Implementation Steps
### Step 1: Rust action definitions + dispatch
- Create `crates-proxy/yaak-proxy-actions/` with enums, `ActionInvocation`, metadata, `execute_action()`
- ts-rs generates `bindings/gen_actions.ts`
- Add `execute_action` to `define_rpc!` in `yaak-proxy-lib`
### Step 2: TypeScript dispatch + handlers
- Create `apps/yaak-proxy/actions.ts`
- Import generated types, define `FrontendHandlers`, wire `dispatch()`
- Listen for `action_invoke` events (for Rust-initiated frontend actions)
### Step 3: Wire up UI
- Toolbar buttons call `dispatch()` instead of inline `rpc()` calls
- Add context menu on exchange table rows using action items
- Build a basic command palette from the action registry
## Verification
- `cargo check -p yaak-proxy-actions`
- `tsgo --noEmit` from repo root
- Toolbar start/stop still works (now via actions)
- Right-click exchange row shows context menu with correct labels
- Command palette lists available actions

View File

@@ -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<TreeProps<TreeModel>['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}

View File

@@ -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<SidebarModel>, collapsed: Record<string, boolean>) => {
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<SidebarModel>, collapsed: Record<string, boolean>) => {
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<DropdownItem[]>>(
async (items) => {
@@ -356,7 +363,7 @@ function Sidebar({ className }: { className?: string }) {
hotKeyLabelOnly: true,
hidden: !onlyHttpRequests,
leftSlot: <Icon icon="send_horizontal" />,
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: <Icon icon="copy" />,
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: <Icon icon="arrow_right_circle" />,
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: <Icon icon="trash" />,
onSelect: () => actions['sidebar.selected.delete'].cb(items),
onSelect: () => handleDeleteSelected(items),
},
...modelCreationItems,
];
return menuItems;
},
[actions],
[],
);
const renderContextMenuFn = useCallback<NonNullable<TreeProps<SidebarModel>['renderContextMenu']>>(
@@ -469,8 +472,6 @@ function Sidebar({ className }: { className?: string }) {
[],
);
const hotkeys = useMemo<TreeProps<SidebarModel>['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<Extension | null>(null);
@@ -556,14 +557,26 @@ function Sidebar({ className }: { className?: string }) {
{
label: 'Expand All Folders',
leftSlot: <Icon icon="chevrons_up_down" />,
onSelect: actions['sidebar.expand_all'].cb,
onSelect: () => jotaiStore.set(collapsedFamily(treeId), {}),
hotKeyAction: 'sidebar.expand_all',
hotKeyLabelOnly: true,
},
{
label: 'Collapse All Folders',
leftSlot: <Icon icon="chevrons_down_up" />,
onSelect: actions['sidebar.collapse_all'].cb,
onSelect: () => {
if (tree == null) return;
const next = (node: TreeNode<SidebarModel>, collapsed: Record<string, boolean>) => {
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}

View File

@@ -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<ButtonProps, "onClick" | "children"> & {
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 (
<Button {...props} disabled={props.disabled || busy} isLoading={busy} onClick={onClick}>
{children ?? meta?.label ?? "…"}
</Button>
);
}

35
apps/yaak-proxy/hooks.ts Normal file
View File

@@ -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<ActionMetadata | null>(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;
}

View File

@@ -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);
}

View File

@@ -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 (
<div
className={classNames(
@@ -82,20 +59,16 @@ function App() {
</HeaderSize>
<main className="overflow-auto p-4">
<div className="flex items-center gap-3 mb-4">
<Button disabled={busy} onClick={startProxy} size="sm" tone="primary">
Start Proxy
</Button>
<Button
disabled={busy}
onClick={stopProxy}
<ActionButton
action={{ scope: "global", action: "proxy_start" }}
size="sm"
tone="primary"
/>
<ActionButton
action={{ scope: "global", action: "proxy_stop" }}
size="sm"
variant="border"
>
Stop Proxy
</Button>
<span className="text-xs text-text-subtlest">
{status}
</span>
/>
</div>
<div className="text-xs font-mono">

View File

@@ -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",

View File

@@ -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<K extends keyof RpcSchema> = RpcSchema[K][0];
type Res<K extends keyof RpcSchema> = RpcSchema[K][1];

View File

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

View File

@@ -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<string, never>;
export type ListActionsResponse = { actions: Array<[ActionInvocation, ActionMetadata]>, };
export type ListModelsRequest = Record<string, never>;
export type ListModelsResponse = { httpExchanges: Array<HttpExchange>, };
export type ProxyStartRequest = { port: number | null, };
export type ProxyStartResponse = { port: number, alreadyRunning: boolean, };
export type ProxyStopRequest = Record<string, never>;
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], };

View File

@@ -0,0 +1,2 @@
export * from "./gen_rpc";
export * from "./gen_models";

View File

@@ -0,0 +1,6 @@
{
"name": "@yaakapp-internal/proxy-lib",
"private": true,
"version": "1.0.0",
"main": "bindings/index.ts"
}

View File

@@ -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<String>,
}
fn default_hotkey(mac: &str, other: &str) -> Option<String> {
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"),
},
),
]
}

View File

@@ -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<bool,
}
}
fn list_actions(_ctx: &ProxyCtx, _req: ListActionsRequest) -> Result<ListActionsResponse, RpcError> {
Ok(ListActionsResponse {
actions: crate::actions::all_global_actions(),
})
}
fn list_models(ctx: &ProxyCtx, _req: ListModelsRequest) -> Result<ListModelsResponse, RpcError> {
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 {

View File

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

View File

@@ -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<EnvironmentVariable>, 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<EnvironmentVariable>, 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<string, any>, description: string, message: string, metadata: Array<HttpRequestHeader>, 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<string, any>, description: string, message: string, metadata: Array<HttpRequestHeader>, 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<string, any>, authenticationType: string | null, body: Record<string, any>, bodyType: string | null, description: string, headers: Array<HttpRequestHeader>, method: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, };
export type HttpRequest = { model: "http_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, body: Record<string, any>, bodyType: string | null, description: string, headers: Array<HttpRequestHeader>, method: string, name: string, sortPriority: number, url: string,
/**
* URL parameters used for both path placeholders (`:id`) and query string entries.
*/
urlParameters: Array<HttpUrlParameter>, };
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<string, any>, authenticationType: string | null, };
@@ -69,10 +86,10 @@ export type ParentHeaders = { headers: Array<HttpRequestHeader>, };
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<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, };
export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string,
/**
* URL parameters used for both path placeholders (`:id`) and query string entries.
*/
urlParameters: Array<HttpUrlParameter>, };
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, settingDnsOverrides: Array<DnsOverride>, };

View File

@@ -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#"

91
package-lock.json generated
View File

@@ -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

View File

@@ -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",

View File

@@ -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<T extends { id: string }> {
className?: string;
onActivate?: (item: T) => void;
onDragEnd?: (opt: { items: T[]; parent: T; children: T[]; insertAt: number }) => void;
hotkeys?: {
actions: Partial<Record<HotkeyAction, { cb: (items: T[]) => 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<T extends { id: string }>(
getContextMenu,
getEditOptions,
getItemKey,
hotkeys,
onActivate,
onDragEnd,
renderContextMenu,
@@ -202,6 +197,7 @@ function TreeInner<T extends { id: string }>(
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<T extends { id: string }>(
return (
<CollapsedAtomContext.Provider value={collapsedAtom}>
<TreeHotKeys treeId={treeId} hotkeys={hotkeys} selectableItems={selectableItems} />
{showContextMenu &&
renderContextMenu?.({
items: showContextMenu.items,
@@ -742,68 +737,6 @@ function DropRegionAfterList({
return <div ref={setNodeRef} onContextMenu={onContextMenu} />;
}
interface TreeHotKeyProps<T extends { id: string }> {
action: HotkeyAction;
selectableItems: SelectableTreeNode<T>[];
treeId: string;
onDone: (items: T[]) => void;
priority?: number;
enable?: boolean | (() => boolean);
}
function TreeHotKey<T extends { id: string }>({
treeId,
action,
onDone,
selectableItems,
enable,
...options
}: TreeHotKeyProps<T>) {
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<T extends { id: string }>({
treeId,
hotkeys,
selectableItems,
}: {
treeId: string;
hotkeys: TreeProps<T>['hotkeys'];
selectableItems: SelectableTreeNode<T>[];
}) {
if (hotkeys == null) return null;
return (
<>
{Object.entries(hotkeys.actions).map(([hotkey, { cb, ...options }]) => (
<TreeHotKey
key={hotkey}
action={hotkey as HotkeyAction}
treeId={treeId}
onDone={cb}
selectableItems={selectableItems}
{...options}
/>
))}
</>
);
}
function getValidSelectableItems<T extends { id: string }>(
store: JotaiStore,
collapsedAtom: CollapsedAtom,

View File

@@ -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';

View File

@@ -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';

View File

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