mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-30 22:22:02 +02:00
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:
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,822 +0,0 @@
|
||||
import type { DragEndEvent, DragMoveEvent, DragStartEvent } from '@dnd-kit/core';
|
||||
import {
|
||||
DndContext,
|
||||
MeasuringStrategy,
|
||||
PointerSensor,
|
||||
pointerWithin,
|
||||
useDroppable,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import { type } from '@tauri-apps/plugin-os';
|
||||
import classNames from 'classnames';
|
||||
import type { ComponentType, MouseEvent, ReactElement, Ref, RefAttributes } from 'react';
|
||||
import {
|
||||
forwardRef,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
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 { useStore } from 'jotai';
|
||||
import {
|
||||
draggingIdsFamily,
|
||||
focusIdsFamily,
|
||||
hoveredParentFamily,
|
||||
selectedIdsFamily,
|
||||
} from './atoms';
|
||||
import { type CollapsedAtom, CollapsedAtomContext } from './context';
|
||||
import type { ContextMenuRenderer, JotaiStore, SelectableTreeNode, TreeNode } from './common';
|
||||
import { closestVisibleNode, equalSubtree, getSelectedItems, hasAncestor } from './common';
|
||||
import { TreeDragOverlay } from './TreeDragOverlay';
|
||||
import type { TreeItemClickEvent, TreeItemHandle, TreeItemProps } from './TreeItem';
|
||||
import type { TreeItemListProps } from './TreeItemList';
|
||||
import { TreeItemList } from './TreeItemList';
|
||||
import { useSelectableItems } from './useSelectableItems';
|
||||
|
||||
/** So we re-calculate after expanding a folder during drag */
|
||||
const measuring = { droppable: { strategy: MeasuringStrategy.Always } };
|
||||
|
||||
export interface TreeProps<T extends { id: string }> {
|
||||
root: TreeNode<T>;
|
||||
treeId: string;
|
||||
collapsedAtom: CollapsedAtom;
|
||||
getItemKey: (item: T) => string;
|
||||
getContextMenu?: (items: T[]) => unknown[] | Promise<unknown[]>;
|
||||
renderContextMenu?: ContextMenuRenderer;
|
||||
ItemInner: ComponentType<{ treeId: string; item: T }>;
|
||||
ItemLeftSlotInner?: ComponentType<{ treeId: string; item: T }>;
|
||||
ItemRightSlot?: ComponentType<{ treeId: string; item: T }>;
|
||||
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;
|
||||
onChange: (item: T, text: string) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TreeHandle {
|
||||
treeId: string;
|
||||
focus: () => boolean;
|
||||
hasFocus: () => boolean;
|
||||
selectItem: (id: string, focus?: boolean) => void;
|
||||
renameItem: (id: string) => void;
|
||||
showContextMenu: () => void;
|
||||
}
|
||||
|
||||
function TreeInner<T extends { id: string }>(
|
||||
{
|
||||
className,
|
||||
collapsedAtom,
|
||||
getContextMenu,
|
||||
getEditOptions,
|
||||
getItemKey,
|
||||
hotkeys,
|
||||
onActivate,
|
||||
onDragEnd,
|
||||
renderContextMenu,
|
||||
ItemInner,
|
||||
ItemLeftSlotInner,
|
||||
ItemRightSlot,
|
||||
root,
|
||||
treeId,
|
||||
}: TreeProps<T>,
|
||||
ref: Ref<TreeHandle>,
|
||||
) {
|
||||
const store = useStore();
|
||||
const treeRef = useRef<HTMLDivElement>(null);
|
||||
const selectableItems = useSelectableItems(root);
|
||||
const [showContextMenu, setShowContextMenu] = useState<{
|
||||
items: unknown[];
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
const treeItemRefs = useRef<Record<string, TreeItemHandle>>({});
|
||||
const handleAddTreeItemRef = useCallback((item: T, r: TreeItemHandle | null) => {
|
||||
if (r == null) {
|
||||
delete treeItemRefs.current[item.id];
|
||||
} else {
|
||||
treeItemRefs.current[item.id] = r;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Select the first item on first render
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: Only used for initial render
|
||||
useEffect(() => {
|
||||
const ids = store.get(selectedIdsFamily(treeId));
|
||||
const fallback = selectableItems[0];
|
||||
if (ids.length === 0 && fallback != null) {
|
||||
store.set(selectedIdsFamily(treeId), [fallback.node.item.id]);
|
||||
store.set(focusIdsFamily(treeId), {
|
||||
anchorId: fallback.node.item.id,
|
||||
lastId: fallback.node.item.id,
|
||||
});
|
||||
}
|
||||
}, [treeId]);
|
||||
|
||||
const handleCloseContextMenu = useCallback(() => {
|
||||
setShowContextMenu(null);
|
||||
}, []);
|
||||
|
||||
const isTreeFocused = useCallback(() => {
|
||||
return treeRef.current?.contains(document.activeElement);
|
||||
}, []);
|
||||
|
||||
const tryFocus = useCallback(() => {
|
||||
const $el = treeRef.current?.querySelector<HTMLButtonElement>(
|
||||
'.tree-item button[tabindex="0"]',
|
||||
);
|
||||
if ($el == null) {
|
||||
return false;
|
||||
}
|
||||
$el.focus();
|
||||
$el.scrollIntoView({ block: 'nearest' });
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const ensureTabbableItem = useCallback(() => {
|
||||
const lastSelectedId = store.get(focusIdsFamily(treeId)).lastId;
|
||||
const lastSelectedItem = selectableItems.find(
|
||||
(i) => i.node.item.id === lastSelectedId && !i.node.hidden,
|
||||
);
|
||||
|
||||
// If no item found, default to selecting the first item (prefer leaf node);
|
||||
if (lastSelectedItem == null) {
|
||||
const firstLeafItem = selectableItems.find((i) => !i.node.hidden && i.node.children == null);
|
||||
const firstItem = firstLeafItem ?? selectableItems.find((i) => !i.node.hidden);
|
||||
if (firstItem != null) {
|
||||
const id = firstItem.node.item.id;
|
||||
store.set(selectedIdsFamily(treeId), [id]);
|
||||
store.set(focusIdsFamily(treeId), { anchorId: id, lastId: id });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const closest = closestVisibleNode(store, collapsedAtom, lastSelectedItem.node);
|
||||
if (closest != null) {
|
||||
const id = closest.item.id;
|
||||
store.set(selectedIdsFamily(treeId), [id]);
|
||||
store.set(focusIdsFamily(treeId), { anchorId: id, lastId: id });
|
||||
}
|
||||
}, [selectableItems, treeId]);
|
||||
|
||||
// Ensure there's always a tabbable item after collapsed state changes
|
||||
useEffect(() => {
|
||||
const unsub = store.sub(collapsedAtom, ensureTabbableItem);
|
||||
return unsub;
|
||||
}, [ensureTabbableItem, treeId]);
|
||||
|
||||
// Ensure there's always a tabbable item after render
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(ensureTabbableItem);
|
||||
});
|
||||
|
||||
const hasFocus = useCallback(() => {
|
||||
return treeRef.current?.contains(document.activeElement) ?? false;
|
||||
}, []);
|
||||
|
||||
const setSelected = useCallback(
|
||||
(ids: string[], focus: boolean) => {
|
||||
store.set(selectedIdsFamily(treeId), ids);
|
||||
// TODO: Figure out a better way than timeout
|
||||
if (!focus) return;
|
||||
setTimeout(tryFocus, 50);
|
||||
},
|
||||
[treeId, tryFocus],
|
||||
);
|
||||
|
||||
const treeHandle = useMemo<TreeHandle>(
|
||||
() => ({
|
||||
treeId,
|
||||
focus: tryFocus,
|
||||
hasFocus: hasFocus,
|
||||
renameItem: (id) => treeItemRefs.current[id]?.rename(),
|
||||
selectItem: (id, focus) => {
|
||||
if (store.get(selectedIdsFamily(treeId)).includes(id)) {
|
||||
// Already selected
|
||||
return;
|
||||
}
|
||||
store.set(focusIdsFamily(treeId), { anchorId: id, lastId: id });
|
||||
setSelected([id], focus === true);
|
||||
},
|
||||
showContextMenu: async () => {
|
||||
if (getContextMenu == null) return;
|
||||
const items = getSelectedItems(store, treeId, selectableItems);
|
||||
const menuItems = await getContextMenu(items);
|
||||
const lastSelectedId = store.get(focusIdsFamily(treeId)).lastId;
|
||||
const rect = lastSelectedId ? treeItemRefs.current[lastSelectedId]?.rect() : null;
|
||||
if (rect == null) return;
|
||||
setShowContextMenu({ items: menuItems, x: rect.x, y: rect.y });
|
||||
},
|
||||
}),
|
||||
[getContextMenu, hasFocus, selectableItems, setSelected, treeId, tryFocus],
|
||||
);
|
||||
|
||||
useImperativeHandle(ref, (): TreeHandle => treeHandle, [treeHandle]);
|
||||
|
||||
const handleGetContextMenu = useMemo(() => {
|
||||
if (getContextMenu == null) return;
|
||||
return (item: T) => {
|
||||
const items = getSelectedItems(store, treeId, selectableItems);
|
||||
const isSelected = items.find((i) => i.id === item.id);
|
||||
if (isSelected) {
|
||||
// If right-clicked an item that was in the multiple-selection, use the entire selection
|
||||
return getContextMenu(items);
|
||||
}
|
||||
// If right-clicked an item that was NOT in the multiple-selection, just use that one
|
||||
// Also update the selection with it
|
||||
setSelected([item.id], false);
|
||||
store.set(focusIdsFamily(treeId), (prev) => ({ ...prev, lastId: item.id }));
|
||||
return getContextMenu([item]);
|
||||
};
|
||||
}, [getContextMenu, selectableItems, setSelected, treeId]);
|
||||
|
||||
const handleSelect = useCallback<NonNullable<TreeItemProps<T>['onClick']>>(
|
||||
(item, { shiftKey, metaKey, ctrlKey }) => {
|
||||
const anchorSelectedId = store.get(focusIdsFamily(treeId)).anchorId;
|
||||
const selectedIdsAtom = selectedIdsFamily(treeId);
|
||||
const selectedIds = store.get(selectedIdsAtom);
|
||||
|
||||
// Mark the item as the last one selected
|
||||
store.set(focusIdsFamily(treeId), (prev) => ({ ...prev, lastId: item.id }));
|
||||
|
||||
if (shiftKey) {
|
||||
const validSelectableItems = getValidSelectableItems(store, collapsedAtom, selectableItems);
|
||||
const anchorIndex = validSelectableItems.findIndex(
|
||||
(i) => i.node.item.id === anchorSelectedId,
|
||||
);
|
||||
const currIndex = validSelectableItems.findIndex((v) => v.node.item.id === item.id);
|
||||
|
||||
// Nothing was selected yet, so just select this item
|
||||
if (selectedIds.length === 0 || anchorIndex === -1 || currIndex === -1) {
|
||||
setSelected([item.id], true);
|
||||
store.set(focusIdsFamily(treeId), (prev) => ({ ...prev, anchorId: item.id }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (currIndex > anchorIndex) {
|
||||
// Selecting down
|
||||
const itemsToSelect = validSelectableItems.slice(anchorIndex, currIndex + 1);
|
||||
setSelected(
|
||||
itemsToSelect.map((v) => v.node.item.id),
|
||||
true,
|
||||
);
|
||||
} else if (currIndex < anchorIndex) {
|
||||
// Selecting up
|
||||
const itemsToSelect = validSelectableItems.slice(currIndex, anchorIndex + 1);
|
||||
setSelected(
|
||||
itemsToSelect.map((v) => v.node.item.id),
|
||||
true,
|
||||
);
|
||||
} else {
|
||||
setSelected([item.id], true);
|
||||
}
|
||||
} else if (type() === 'macos' ? metaKey : ctrlKey) {
|
||||
const withoutCurr = selectedIds.filter((id) => id !== item.id);
|
||||
if (withoutCurr.length === selectedIds.length) {
|
||||
// It wasn't in there, so add it
|
||||
setSelected([...selectedIds, item.id], true);
|
||||
} else {
|
||||
// It was in there, so remove it
|
||||
setSelected(withoutCurr, true);
|
||||
}
|
||||
} else {
|
||||
// Select single
|
||||
setSelected([item.id], true);
|
||||
store.set(focusIdsFamily(treeId), (prev) => ({ ...prev, anchorId: item.id }));
|
||||
}
|
||||
},
|
||||
[selectableItems, setSelected, treeId],
|
||||
);
|
||||
|
||||
const handleClick = useCallback<NonNullable<TreeItemProps<T>['onClick']>>(
|
||||
(item, e) => {
|
||||
if (e.shiftKey || e.ctrlKey || e.metaKey) {
|
||||
handleSelect(item, e);
|
||||
} else {
|
||||
handleSelect(item, e);
|
||||
onActivate?.(item);
|
||||
}
|
||||
},
|
||||
[handleSelect, onActivate],
|
||||
);
|
||||
|
||||
const selectPrevItem = useCallback(
|
||||
(e: TreeItemClickEvent) => {
|
||||
const lastSelectedId = store.get(focusIdsFamily(treeId)).lastId;
|
||||
const validSelectableItems = getValidSelectableItems(store, collapsedAtom, selectableItems);
|
||||
const index = validSelectableItems.findIndex((i) => i.node.item.id === lastSelectedId);
|
||||
const item = validSelectableItems[index - 1];
|
||||
if (item != null) {
|
||||
handleSelect(item.node.item, e);
|
||||
}
|
||||
},
|
||||
[handleSelect, selectableItems, treeId],
|
||||
);
|
||||
|
||||
const selectNextItem = useCallback(
|
||||
(e: TreeItemClickEvent) => {
|
||||
const lastSelectedId = store.get(focusIdsFamily(treeId)).lastId;
|
||||
const validSelectableItems = getValidSelectableItems(store, collapsedAtom, selectableItems);
|
||||
const index = validSelectableItems.findIndex((i) => i.node.item.id === lastSelectedId);
|
||||
const item = validSelectableItems[index + 1];
|
||||
if (item != null) {
|
||||
handleSelect(item.node.item, e);
|
||||
}
|
||||
},
|
||||
[handleSelect, selectableItems, treeId],
|
||||
);
|
||||
|
||||
const selectParentItem = useCallback(
|
||||
(e: TreeItemClickEvent) => {
|
||||
const lastSelectedId = store.get(focusIdsFamily(treeId)).lastId;
|
||||
const lastSelectedItem =
|
||||
selectableItems.find((i) => i.node.item.id === lastSelectedId)?.node ?? null;
|
||||
if (lastSelectedItem?.parent != null) {
|
||||
handleSelect(lastSelectedItem.parent.item, e);
|
||||
}
|
||||
},
|
||||
[handleSelect, selectableItems, treeId],
|
||||
);
|
||||
|
||||
useKey(
|
||||
(e) => e.key === 'ArrowUp' || e.key.toLowerCase() === 'k',
|
||||
(e) => {
|
||||
if (!isTreeFocused()) return;
|
||||
e.preventDefault();
|
||||
selectPrevItem(e);
|
||||
},
|
||||
undefined,
|
||||
[selectableItems, handleSelect],
|
||||
);
|
||||
|
||||
useKey(
|
||||
(e) => e.key === 'ArrowDown' || e.key.toLowerCase() === 'j',
|
||||
(e) => {
|
||||
if (!isTreeFocused()) return;
|
||||
e.preventDefault();
|
||||
selectNextItem(e);
|
||||
},
|
||||
undefined,
|
||||
[selectableItems, handleSelect],
|
||||
);
|
||||
|
||||
// If the selected item is a collapsed folder, expand it. Otherwise, select next item
|
||||
useKey(
|
||||
(e) => e.key === 'ArrowRight' || e.key === 'l',
|
||||
(e) => {
|
||||
if (!isTreeFocused()) return;
|
||||
e.preventDefault();
|
||||
|
||||
const collapsed = store.get(collapsedAtom);
|
||||
const lastSelectedId = store.get(focusIdsFamily(treeId)).lastId;
|
||||
const lastSelectedItem = selectableItems.find((i) => i.node.item.id === lastSelectedId);
|
||||
|
||||
if (
|
||||
lastSelectedId &&
|
||||
lastSelectedItem?.node.children != null &&
|
||||
collapsed[lastSelectedItem.node.item.id] === true
|
||||
) {
|
||||
store.set(collapsedAtom, { ...collapsed, [lastSelectedId]: false });
|
||||
} else {
|
||||
selectNextItem(e);
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
[selectableItems, handleSelect],
|
||||
);
|
||||
|
||||
// If the selected item is in a folder, select its parent.
|
||||
// If the selected item is an expanded folder, collapse it.
|
||||
useKey(
|
||||
(e) => e.key === 'ArrowLeft' || e.key === 'h',
|
||||
(e) => {
|
||||
if (!isTreeFocused()) return;
|
||||
e.preventDefault();
|
||||
|
||||
const collapsed = store.get(collapsedAtom);
|
||||
const lastSelectedId = store.get(focusIdsFamily(treeId)).lastId;
|
||||
const lastSelectedItem = selectableItems.find((i) => i.node.item.id === lastSelectedId);
|
||||
|
||||
if (
|
||||
lastSelectedId &&
|
||||
lastSelectedItem?.node.children != null &&
|
||||
collapsed[lastSelectedItem.node.item.id] !== true
|
||||
) {
|
||||
store.set(collapsedAtom, { ...collapsed, [lastSelectedId]: true });
|
||||
} else {
|
||||
selectParentItem(e);
|
||||
}
|
||||
},
|
||||
{ options: {} },
|
||||
[selectableItems, handleSelect],
|
||||
);
|
||||
|
||||
useKeyPressEvent('Escape', async () => {
|
||||
if (!treeRef.current?.contains(document.activeElement)) return;
|
||||
clearDragState();
|
||||
const lastSelectedId = store.get(focusIdsFamily(treeId)).lastId;
|
||||
if (lastSelectedId == null) return;
|
||||
setSelected([lastSelectedId], false);
|
||||
});
|
||||
|
||||
const handleDragMove = useCallback(
|
||||
function handleDragMove(e: DragMoveEvent) {
|
||||
const over = e.over;
|
||||
if (!over) {
|
||||
// Clear the drop indicator when hovering outside the tree
|
||||
store.set(hoveredParentFamily(treeId), {
|
||||
parentId: null,
|
||||
parentDepth: null,
|
||||
childIndex: null,
|
||||
index: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Not sure when or if this happens
|
||||
if (e.active.rect.current.initial == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Root is anything past the end of the list, so set it to the end
|
||||
const hoveringRoot = over.id === root.item.id;
|
||||
if (hoveringRoot) {
|
||||
store.set(hoveredParentFamily(treeId), {
|
||||
parentId: root.item.id,
|
||||
parentDepth: root.depth,
|
||||
index: selectableItems.length,
|
||||
childIndex: selectableItems.length,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const overSelectableItem = selectableItems.find((i) => i.node.item.id === over.id) ?? null;
|
||||
if (overSelectableItem == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const draggingItems = store.get(draggingIdsFamily(treeId));
|
||||
for (const id of draggingItems) {
|
||||
const item = selectableItems.find((i) => i.node.item.id === id)?.node ?? null;
|
||||
if (item == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isSameParent = item.parent?.item.id === overSelectableItem.node.parent?.item.id;
|
||||
if (item.localDrag && !isSameParent) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const node = overSelectableItem.node;
|
||||
const side = computeSideForDragMove(node.item.id, e);
|
||||
|
||||
const item = node.item;
|
||||
let hoveredParent = node.parent;
|
||||
const dragIndex = selectableItems.findIndex((n) => n.node.item.id === item.id) ?? -1;
|
||||
const hovered = selectableItems[dragIndex]?.node ?? null;
|
||||
const hoveredIndex = dragIndex + (side === 'before' ? 0 : 1);
|
||||
let hoveredChildIndex = overSelectableItem.index + (side === 'before' ? 0 : 1);
|
||||
|
||||
// Move into the folder if it's open and we're moving after it
|
||||
if (hovered?.children != null && side === 'after') {
|
||||
hoveredParent = hovered;
|
||||
hoveredChildIndex = 0;
|
||||
}
|
||||
|
||||
const parentId = hoveredParent?.item.id ?? null;
|
||||
const parentDepth = hoveredParent?.depth ?? null;
|
||||
const index = hoveredIndex;
|
||||
const childIndex = hoveredChildIndex;
|
||||
const existing = store.get(hoveredParentFamily(treeId));
|
||||
if (
|
||||
!(
|
||||
parentId === existing.parentId &&
|
||||
parentDepth === existing.parentDepth &&
|
||||
index === existing.index &&
|
||||
childIndex === existing.childIndex
|
||||
)
|
||||
) {
|
||||
store.set(hoveredParentFamily(treeId), {
|
||||
parentId,
|
||||
parentDepth,
|
||||
index,
|
||||
childIndex,
|
||||
});
|
||||
}
|
||||
},
|
||||
[root.depth, root.item.id, selectableItems, treeId],
|
||||
);
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
function handleDragStart(e: DragStartEvent) {
|
||||
const selectedItems = getSelectedItems(store, treeId, selectableItems);
|
||||
const isDraggingSelectedItem = selectedItems.find((i) => i.id === e.active.id);
|
||||
|
||||
// If we started dragging an already-selected item, we'll use that
|
||||
if (isDraggingSelectedItem) {
|
||||
store.set(
|
||||
draggingIdsFamily(treeId),
|
||||
selectedItems.map((i) => i.id),
|
||||
);
|
||||
} else {
|
||||
// If we started dragging a non-selected item, only drag that item
|
||||
const activeItem = selectableItems.find((i) => i.node.item.id === e.active.id)?.node.item;
|
||||
if (activeItem != null) {
|
||||
store.set(draggingIdsFamily(treeId), [activeItem.id]);
|
||||
// Also update selection to just be this one
|
||||
handleSelect(activeItem, {
|
||||
shiftKey: false,
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
[handleSelect, selectableItems, treeId],
|
||||
);
|
||||
|
||||
const clearDragState = useCallback(() => {
|
||||
store.set(hoveredParentFamily(treeId), {
|
||||
parentId: null,
|
||||
parentDepth: null,
|
||||
index: null,
|
||||
childIndex: null,
|
||||
});
|
||||
store.set(draggingIdsFamily(treeId), []);
|
||||
}, [treeId]);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
function handleDragEnd(e: DragEndEvent) {
|
||||
// Get this from the store so our callback doesn't change all the time
|
||||
const {
|
||||
index: hoveredIndex,
|
||||
parentId: hoveredParentId,
|
||||
childIndex: hoveredChildIndex,
|
||||
} = store.get(hoveredParentFamily(treeId));
|
||||
const draggingItems = store.get(draggingIdsFamily(treeId));
|
||||
clearDragState();
|
||||
|
||||
// Dropped outside the tree?
|
||||
if (e.over == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hoveredParentS =
|
||||
hoveredParentId === root.item.id
|
||||
? { node: root, depth: 0, index: 0 }
|
||||
: (selectableItems.find((i) => i.node.item.id === hoveredParentId) ?? null);
|
||||
const hoveredParent = hoveredParentS?.node ?? null;
|
||||
|
||||
if (hoveredParent == null || hoveredIndex == null || !draggingItems?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve the actual tree nodes for each dragged item (keeps order of draggingItems)
|
||||
const draggedNodes: TreeNode<T>[] = draggingItems
|
||||
.map((id) => {
|
||||
return selectableItems.find((i) => i.node.item.id === id)?.node ?? null;
|
||||
})
|
||||
.filter((n) => n != null)
|
||||
// Filter out invalid drags (dragging into descendant)
|
||||
.filter(
|
||||
(n) => hoveredParent.item.id !== n.item.id && !hasAncestor(hoveredParent, n.item.id),
|
||||
);
|
||||
|
||||
// Work on a local copy of target children
|
||||
const nextChildren = [...(hoveredParent.children ?? [])];
|
||||
|
||||
// Remove any of the dragged nodes already in the target, adjusting hoveredIndex
|
||||
let insertAt = hoveredChildIndex ?? 0;
|
||||
for (const node of draggedNodes) {
|
||||
const i = nextChildren.findIndex((n) => n.item.id === node.item.id);
|
||||
if (i !== -1) {
|
||||
nextChildren.splice(i, 1);
|
||||
if (i < insertAt) insertAt -= 1; // account for removed-before
|
||||
}
|
||||
}
|
||||
|
||||
// Batch callback
|
||||
onDragEnd?.({
|
||||
items: draggedNodes.map((n) => n.item),
|
||||
parent: hoveredParent.item,
|
||||
children: nextChildren.map((c) => c.item),
|
||||
insertAt,
|
||||
});
|
||||
},
|
||||
[treeId, clearDragState, selectableItems, root, onDragEnd],
|
||||
);
|
||||
|
||||
const treeItemListProps: Omit<
|
||||
TreeItemListProps<T>,
|
||||
'nodes' | 'treeId' | 'activeIdAtom' | 'hoveredParent' | 'hoveredIndex'
|
||||
> = {
|
||||
getItemKey,
|
||||
getContextMenu: handleGetContextMenu,
|
||||
renderContextMenu,
|
||||
onClick: handleClick,
|
||||
getEditOptions,
|
||||
ItemInner,
|
||||
ItemLeftSlotInner,
|
||||
ItemRightSlot,
|
||||
};
|
||||
|
||||
const handleContextMenu = useCallback(
|
||||
async (e: MouseEvent<HTMLElement>) => {
|
||||
if (getContextMenu == null) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const items = await getContextMenu([]);
|
||||
setShowContextMenu({ items, x: e.clientX, y: e.clientY });
|
||||
},
|
||||
[getContextMenu],
|
||||
);
|
||||
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
|
||||
|
||||
return (
|
||||
<CollapsedAtomContext.Provider value={collapsedAtom}>
|
||||
<TreeHotKeys treeId={treeId} hotkeys={hotkeys} selectableItems={selectableItems} />
|
||||
{showContextMenu &&
|
||||
renderContextMenu?.({
|
||||
items: showContextMenu.items,
|
||||
position: showContextMenu,
|
||||
onClose: handleCloseContextMenu,
|
||||
})}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={pointerWithin}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={clearDragState}
|
||||
onDragAbort={clearDragState}
|
||||
onDragMove={handleDragMove}
|
||||
measuring={measuring}
|
||||
autoScroll
|
||||
>
|
||||
<div
|
||||
ref={treeRef}
|
||||
className={classNames(
|
||||
className,
|
||||
'outline-none h-full',
|
||||
'overflow-y-auto overflow-x-hidden',
|
||||
'grid grid-rows-[auto_1fr]',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
'[&_.tree-item.selected_.tree-item-inner]:text-text',
|
||||
'[&:focus-within]:[&_.tree-item.selected]:bg-surface-active',
|
||||
'[&:not(:focus-within)]:[&_.tree-item.selected:not([data-context-menu-open])]:bg-surface-highlight',
|
||||
'[&_.tree-item.selected[data-context-menu-open]]:bg-surface-active',
|
||||
// Round the items, but only if the ends of the selection.
|
||||
// Also account for the drop marker being in between items
|
||||
'[&_.tree-item]:rounded-md',
|
||||
'[&_.tree-item.selected+.tree-item.selected]:rounded-t-none',
|
||||
'[&_.tree-item.selected+.drop-marker+.tree-item.selected]:rounded-t-none',
|
||||
'[&_.tree-item.selected:has(+.tree-item.selected)]:rounded-b-none',
|
||||
'[&_.tree-item.selected:has(+.drop-marker+.tree-item.selected)]:rounded-b-none',
|
||||
)}
|
||||
>
|
||||
<TreeItemList
|
||||
addTreeItemRef={handleAddTreeItemRef}
|
||||
nodes={selectableItems}
|
||||
treeId={treeId}
|
||||
{...treeItemListProps}
|
||||
/>
|
||||
</div>
|
||||
{/* Assign root ID so we can reuse our same move/end logic */}
|
||||
<DropRegionAfterList id={root.item.id} onContextMenu={handleContextMenu} />
|
||||
</div>
|
||||
<TreeDragOverlay
|
||||
treeId={treeId}
|
||||
selectableItems={selectableItems}
|
||||
ItemInner={ItemInner}
|
||||
getItemKey={getItemKey}
|
||||
/>
|
||||
</DndContext>
|
||||
</CollapsedAtomContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// 1) Preserve generics through forwardRef:
|
||||
const Tree_ = forwardRef(TreeInner) as <T extends { id: string }>(
|
||||
props: TreeProps<T> & RefAttributes<TreeHandle>,
|
||||
) => ReactElement | null;
|
||||
|
||||
export const Tree = memo(
|
||||
Tree_,
|
||||
({ root: prevNode, ...prevProps }, { root: nextNode, ...nextProps }) => {
|
||||
for (const key of Object.keys(prevProps)) {
|
||||
if (prevProps[key as keyof typeof prevProps] !== nextProps[key as keyof typeof nextProps]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return equalSubtree(prevNode, nextNode, nextProps.getItemKey);
|
||||
},
|
||||
) as typeof Tree_;
|
||||
|
||||
function DropRegionAfterList({
|
||||
id,
|
||||
onContextMenu,
|
||||
}: {
|
||||
id: string;
|
||||
onContextMenu?: (e: MouseEvent<HTMLDivElement>) => void;
|
||||
}) {
|
||||
const { setNodeRef } = useDroppable({ id });
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: Meh
|
||||
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,
|
||||
selectableItems: SelectableTreeNode<T>[],
|
||||
) {
|
||||
const collapsed = store.get(collapsedAtom);
|
||||
return selectableItems.filter((i) => {
|
||||
if (i.node.hidden) return false;
|
||||
let p = i.node.parent;
|
||||
while (p) {
|
||||
if (collapsed[p.item.id]) return false;
|
||||
p = p.parent;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { DragOverlay } from '@dnd-kit/core';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { draggingIdsFamily } from './atoms';
|
||||
import type { SelectableTreeNode } from './common';
|
||||
import type { TreeProps } from './Tree';
|
||||
import { TreeItemList } from './TreeItemList';
|
||||
|
||||
export function TreeDragOverlay<T extends { id: string }>({
|
||||
treeId,
|
||||
selectableItems,
|
||||
getItemKey,
|
||||
ItemInner,
|
||||
ItemLeftSlotInner,
|
||||
}: {
|
||||
treeId: string;
|
||||
selectableItems: SelectableTreeNode<T>[];
|
||||
} & Pick<TreeProps<T>, 'getItemKey' | 'ItemInner' | 'ItemLeftSlotInner'>) {
|
||||
const draggingItems = useAtomValue(draggingIdsFamily(treeId));
|
||||
return (
|
||||
<DragOverlay dropAnimation={null}>
|
||||
<TreeItemList
|
||||
treeId={`${treeId}.dragging`}
|
||||
nodes={selectableItems.filter((i) => draggingItems.includes(i.node.item.id))}
|
||||
getItemKey={getItemKey}
|
||||
ItemInner={ItemInner}
|
||||
ItemLeftSlotInner={ItemLeftSlotInner}
|
||||
forceDepth={0}
|
||||
/>
|
||||
</DragOverlay>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import classNames from 'classnames';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { memo } from 'react';
|
||||
import { DropMarker } from '@yaakapp-internal/ui';
|
||||
import { hoveredParentDepthFamily, isIndexHoveredFamily } from './atoms';
|
||||
import type { TreeNode } from './common';
|
||||
import { useIsCollapsed } from './context';
|
||||
|
||||
export const TreeDropMarker = memo(function TreeDropMarker<T extends { id: string }>({
|
||||
className,
|
||||
treeId,
|
||||
node,
|
||||
index,
|
||||
}: {
|
||||
treeId: string;
|
||||
index: number;
|
||||
node: TreeNode<T> | null;
|
||||
className?: string;
|
||||
}) {
|
||||
const isHovered = useAtomValue(isIndexHoveredFamily({ treeId, index }));
|
||||
const parentDepth = useAtomValue(hoveredParentDepthFamily(treeId));
|
||||
const collapsed = useIsCollapsed(node?.item.id);
|
||||
|
||||
// Only show if we're hovering over this index
|
||||
if (!isHovered) return null;
|
||||
|
||||
// Don't show if we're right under a collapsed folder, or empty folder. We have a separate
|
||||
// delayed expansion animation for that.
|
||||
if (collapsed || node?.children?.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="drop-marker relative" style={{ paddingLeft: `${parentDepth}rem` }}>
|
||||
<DropMarker className={classNames(className)} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
import classNames from 'classnames';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { memo } from 'react';
|
||||
import { hoveredParentDepthFamily, isAncestorHoveredFamily } from './atoms';
|
||||
|
||||
export const TreeIndentGuide = memo(function TreeIndentGuide({
|
||||
treeId,
|
||||
depth,
|
||||
ancestorIds,
|
||||
}: {
|
||||
treeId: string;
|
||||
depth: number;
|
||||
ancestorIds: string[];
|
||||
}) {
|
||||
const parentDepth = useAtomValue(hoveredParentDepthFamily(treeId));
|
||||
const isHovered = useAtomValue(isAncestorHoveredFamily({ treeId, ancestorIds }));
|
||||
|
||||
return (
|
||||
<div className="flex">
|
||||
{Array.from({ length: depth }).map((_, i) => (
|
||||
<div
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: none
|
||||
key={i}
|
||||
className={classNames(
|
||||
'w-[calc(1rem+0.5px)] border-r border-r-text-subtlest',
|
||||
!(parentDepth === i + 1 && isHovered) && 'opacity-30',
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,388 +0,0 @@
|
||||
import type { DragMoveEvent } from '@dnd-kit/core';
|
||||
import { useDndContext, useDndMonitor, useDraggable, useDroppable } from '@dnd-kit/core';
|
||||
import classNames from 'classnames';
|
||||
import { useAtomValue, useStore } from 'jotai';
|
||||
import type {
|
||||
MouseEvent,
|
||||
PointerEvent,
|
||||
FocusEvent as ReactFocusEvent,
|
||||
KeyboardEvent as ReactKeyboardEvent,
|
||||
} from 'react';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { computeSideForDragMove } from '@yaakapp-internal/ui';
|
||||
import type { ContextMenuRenderer } from './common';
|
||||
import { Icon } from '@yaakapp-internal/ui';
|
||||
import { isLastFocusedFamily, isSelectedFamily } from './atoms';
|
||||
import { useCollapsedAtom, useIsAncestorCollapsed, useIsCollapsed, useSetCollapsed } from './context';
|
||||
import type { TreeNode } from './common';
|
||||
import { getNodeKey } from './common';
|
||||
import type { TreeProps } from './Tree';
|
||||
import { TreeIndentGuide } from './TreeIndentGuide';
|
||||
|
||||
export interface TreeItemClickEvent {
|
||||
shiftKey: boolean;
|
||||
ctrlKey: boolean;
|
||||
metaKey: boolean;
|
||||
}
|
||||
|
||||
export type TreeItemProps<T extends { id: string }> = Pick<
|
||||
TreeProps<T>,
|
||||
'ItemInner' | 'ItemLeftSlotInner' | 'ItemRightSlot' | 'treeId' | 'getEditOptions' | 'getItemKey' | 'renderContextMenu'
|
||||
> & {
|
||||
node: TreeNode<T>;
|
||||
className?: string;
|
||||
onClick?: (item: T, e: TreeItemClickEvent) => void;
|
||||
getContextMenu?: (item: T) => unknown[] | Promise<unknown[]>;
|
||||
depth: number;
|
||||
setRef?: (item: T, n: TreeItemHandle | null) => void;
|
||||
};
|
||||
|
||||
export interface TreeItemHandle {
|
||||
rename: () => void;
|
||||
isRenaming: boolean;
|
||||
rect: () => DOMRect;
|
||||
focus: () => void;
|
||||
scrollIntoView: () => void;
|
||||
}
|
||||
|
||||
const HOVER_CLOSED_FOLDER_DELAY = 800;
|
||||
|
||||
function TreeItem_<T extends { id: string }>({
|
||||
treeId,
|
||||
node,
|
||||
ItemInner,
|
||||
ItemLeftSlotInner,
|
||||
ItemRightSlot,
|
||||
getContextMenu,
|
||||
renderContextMenu,
|
||||
onClick,
|
||||
getEditOptions,
|
||||
className,
|
||||
depth,
|
||||
setRef,
|
||||
}: TreeItemProps<T>) {
|
||||
const store = useStore();
|
||||
const listItemRef = useRef<HTMLLIElement>(null);
|
||||
const draggableRef = useRef<HTMLButtonElement>(null);
|
||||
const collapsedAtom = useCollapsedAtom();
|
||||
const isSelected = useAtomValue(isSelectedFamily({ treeId, itemId: node.item.id }));
|
||||
const isCollapsed = useIsCollapsed(node.item.id);
|
||||
const setCollapsed = useSetCollapsed(node.item.id);
|
||||
const isLastSelected = useAtomValue(isLastFocusedFamily({ treeId, itemId: node.item.id }));
|
||||
const [editing, setEditing] = useState<boolean>(false);
|
||||
const [dropHover, setDropHover] = useState<null | 'drop' | 'animate'>(null);
|
||||
const startedHoverTimeout = useRef<NodeJS.Timeout>(undefined);
|
||||
const handle = useMemo<TreeItemHandle>(
|
||||
() => ({
|
||||
focus: () => {
|
||||
draggableRef.current?.focus();
|
||||
},
|
||||
rename: () => {
|
||||
if (getEditOptions != null) {
|
||||
setEditing(true);
|
||||
}
|
||||
},
|
||||
isRenaming: editing,
|
||||
rect: () => {
|
||||
if (listItemRef.current == null) {
|
||||
return new DOMRect(0, 0, 0, 0);
|
||||
}
|
||||
return listItemRef.current.getBoundingClientRect();
|
||||
},
|
||||
scrollIntoView: () => {
|
||||
listItemRef.current?.scrollIntoView({ block: 'nearest' });
|
||||
},
|
||||
}),
|
||||
[editing, getEditOptions],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setRef?.(node.item, handle);
|
||||
}, [setRef, handle, node.item]);
|
||||
|
||||
const ancestorIds = useMemo(() => {
|
||||
const ids: string[] = [];
|
||||
let p = node.parent;
|
||||
|
||||
while (p) {
|
||||
ids.push(p.item.id);
|
||||
p = p.parent;
|
||||
}
|
||||
|
||||
return ids;
|
||||
}, [node]);
|
||||
|
||||
const isAncestorCollapsed = useIsAncestorCollapsed(ancestorIds);
|
||||
|
||||
const [showContextMenu, setShowContextMenu] = useState<{
|
||||
items: unknown[];
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => onClick?.(node.item, e),
|
||||
[node, onClick],
|
||||
);
|
||||
|
||||
const toggleCollapsed = useCallback(() => {
|
||||
setCollapsed((prev) => !prev);
|
||||
}, [setCollapsed]);
|
||||
|
||||
const handleSubmitNameEdit = useCallback(
|
||||
async (el: HTMLInputElement) => {
|
||||
getEditOptions?.(node.item).onChange(node.item, el.value);
|
||||
onClick?.(node.item, { shiftKey: false, ctrlKey: false, metaKey: false });
|
||||
// Slight delay for the model to propagate to the local store
|
||||
setTimeout(() => setEditing(false), 200);
|
||||
},
|
||||
[getEditOptions, node.item, onClick],
|
||||
);
|
||||
|
||||
const handleEditFocus = useCallback(function handleEditFocus(el: HTMLInputElement | null) {
|
||||
el?.focus();
|
||||
el?.select();
|
||||
}, []);
|
||||
|
||||
const handleEditBlur = useCallback(
|
||||
async function editBlur(e: ReactFocusEvent<HTMLInputElement>) {
|
||||
await handleSubmitNameEdit(e.currentTarget);
|
||||
},
|
||||
[handleSubmitNameEdit],
|
||||
);
|
||||
|
||||
const handleEditKeyDown = useCallback(
|
||||
async (e: ReactKeyboardEvent<HTMLInputElement>) => {
|
||||
e.stopPropagation(); // Don't trigger other tree keys (like arrows)
|
||||
switch (e.key) {
|
||||
case 'Enter':
|
||||
if (editing) {
|
||||
e.preventDefault();
|
||||
await handleSubmitNameEdit(e.currentTarget);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
if (editing) {
|
||||
e.preventDefault();
|
||||
setEditing(false);
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
[editing, handleSubmitNameEdit],
|
||||
);
|
||||
|
||||
const handleDoubleClick = useCallback(() => {
|
||||
const isFolder = node.children != null;
|
||||
if (isFolder) {
|
||||
toggleCollapsed();
|
||||
} else if (getEditOptions != null) {
|
||||
setEditing(true);
|
||||
}
|
||||
}, [getEditOptions, node.children, toggleCollapsed]);
|
||||
|
||||
const clearDropHover = () => {
|
||||
if (startedHoverTimeout.current) {
|
||||
clearTimeout(startedHoverTimeout.current);
|
||||
startedHoverTimeout.current = undefined;
|
||||
}
|
||||
setDropHover(null);
|
||||
};
|
||||
|
||||
const dndContext = useDndContext();
|
||||
|
||||
// Toggle auto-expand of folders when hovering over them
|
||||
useDndMonitor({
|
||||
onDragEnd() {
|
||||
clearDropHover();
|
||||
},
|
||||
onDragMove(e: DragMoveEvent) {
|
||||
const side = computeSideForDragMove(node.item.id, e);
|
||||
const isFolder = node.children != null;
|
||||
const hasChildren = (node.children?.length ?? 0) > 0;
|
||||
const collapsedMap = store.get(collapsedAtom);
|
||||
const itemCollapsed = !!collapsedMap[node.item.id];
|
||||
if (itemCollapsed && isFolder && hasChildren && side === 'after') {
|
||||
setDropHover('animate');
|
||||
clearTimeout(startedHoverTimeout.current);
|
||||
startedHoverTimeout.current = setTimeout(() => {
|
||||
store.set(collapsedAtom, { ...store.get(collapsedAtom), [node.item.id]: false });
|
||||
clearDropHover();
|
||||
// Force re-measure everything because all containers below the folder have been pushed down
|
||||
requestAnimationFrame(() => {
|
||||
dndContext.measureDroppableContainers(
|
||||
dndContext.droppableContainers.toArray().map((c) => c.id),
|
||||
);
|
||||
});
|
||||
}, HOVER_CLOSED_FOLDER_DELAY);
|
||||
} else if (isFolder && !hasChildren && side === 'after') {
|
||||
setDropHover('drop');
|
||||
} else {
|
||||
clearDropHover();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const handleContextMenu = useCallback(
|
||||
async (e: MouseEvent<HTMLElement>) => {
|
||||
if (getContextMenu == null) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Set data attribute on the list item to preserve active state
|
||||
if (listItemRef.current) {
|
||||
listItemRef.current.setAttribute('data-context-menu-open', 'true');
|
||||
}
|
||||
|
||||
const items = await getContextMenu(node.item);
|
||||
setShowContextMenu({ items, x: e.clientX ?? 100, y: e.clientY ?? 100 });
|
||||
},
|
||||
[getContextMenu, node.item],
|
||||
);
|
||||
|
||||
const handleCloseContextMenu = useCallback(() => {
|
||||
// Remove data attribute when context menu closes
|
||||
if (listItemRef.current) {
|
||||
listItemRef.current.removeAttribute('data-context-menu-open');
|
||||
}
|
||||
setShowContextMenu(null);
|
||||
}, []);
|
||||
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef: setDraggableRef,
|
||||
} = useDraggable({ id: node.item.id, disabled: node.draggable === false || editing });
|
||||
|
||||
const { setNodeRef: setDroppableRef } = useDroppable({ id: node.item.id });
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
function handlePointerDown(e: PointerEvent<HTMLButtonElement>) {
|
||||
const handleByTree = e.metaKey || e.ctrlKey || e.shiftKey;
|
||||
if (!handleByTree) {
|
||||
listeners?.onPointerDown?.(e);
|
||||
}
|
||||
},
|
||||
[listeners],
|
||||
);
|
||||
|
||||
const handleSetDraggableRef = useCallback(
|
||||
(node: HTMLButtonElement | null) => {
|
||||
draggableRef.current = node;
|
||||
setDraggableRef(node);
|
||||
setDroppableRef(node);
|
||||
},
|
||||
[setDraggableRef, setDroppableRef],
|
||||
);
|
||||
|
||||
if (node.hidden || isAncestorCollapsed) return null;
|
||||
|
||||
return (
|
||||
<li
|
||||
ref={listItemRef}
|
||||
onContextMenu={handleContextMenu}
|
||||
className={classNames(
|
||||
className,
|
||||
'tree-item',
|
||||
'h-sm',
|
||||
'grid grid-cols-[auto_minmax(0,1fr)]',
|
||||
editing && 'ring-1 focus-within:ring-focus',
|
||||
dropHover != null && 'relative z-10 ring-2 ring-primary',
|
||||
dropHover === 'animate' && 'animate-blinkRing',
|
||||
isSelected && 'selected',
|
||||
)}
|
||||
>
|
||||
<TreeIndentGuide treeId={treeId} depth={depth} ancestorIds={ancestorIds} />
|
||||
<div
|
||||
className={classNames(
|
||||
'text-text-subtle',
|
||||
'grid grid-cols-[auto_minmax(0,1fr)_auto] gap-x-2 items-center rounded-md',
|
||||
)}
|
||||
>
|
||||
{showContextMenu &&
|
||||
renderContextMenu?.({
|
||||
items: showContextMenu.items,
|
||||
position: showContextMenu,
|
||||
onClose: handleCloseContextMenu,
|
||||
})}
|
||||
{node.children != null ? (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
className="h-full pl-[0.5rem] outline-none"
|
||||
onClick={toggleCollapsed}
|
||||
>
|
||||
<Icon
|
||||
icon={node.children.length === 0 ? 'dot' : 'chevron_right'}
|
||||
className={classNames(
|
||||
'transition-transform text-text-subtlest',
|
||||
'ml-auto',
|
||||
'w-[1rem] h-[1rem]',
|
||||
!isCollapsed && node.children.length > 0 && 'rotate-90',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<span aria-hidden /> // Make the grid happy
|
||||
)}
|
||||
|
||||
<button
|
||||
ref={handleSetDraggableRef}
|
||||
onPointerDown={handlePointerDown}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
disabled={editing}
|
||||
className="cursor-default tree-item-inner pr-1 focus:outline-none flex items-center gap-2 h-full whitespace-nowrap"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
tabIndex={isLastSelected ? 0 : -1}
|
||||
>
|
||||
{ItemLeftSlotInner != null && <ItemLeftSlotInner treeId={treeId} item={node.item} />}
|
||||
{getEditOptions != null && editing ? (
|
||||
(() => {
|
||||
const { defaultValue, placeholder } = getEditOptions(node.item);
|
||||
return (
|
||||
<input
|
||||
data-disable-hotkey
|
||||
ref={handleEditFocus}
|
||||
defaultValue={defaultValue}
|
||||
placeholder={placeholder}
|
||||
className="bg-transparent outline-none w-full cursor-text"
|
||||
onBlur={handleEditBlur}
|
||||
onKeyDown={handleEditKeyDown}
|
||||
/>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<ItemInner treeId={treeId} item={node.item} />
|
||||
)}
|
||||
</button>
|
||||
{ItemRightSlot != null ? (
|
||||
<ItemRightSlot treeId={treeId} item={node.item} />
|
||||
) : (
|
||||
<span aria-hidden />
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export const TreeItem = memo(
|
||||
TreeItem_,
|
||||
({ node: prevNode, ...prevProps }, { node: nextNode, ...nextProps }) => {
|
||||
const nonEqualKeys = [];
|
||||
for (const key of Object.keys(prevProps)) {
|
||||
if (prevProps[key as keyof typeof prevProps] !== nextProps[key as keyof typeof nextProps]) {
|
||||
nonEqualKeys.push(key);
|
||||
}
|
||||
}
|
||||
if (nonEqualKeys.length > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
getNodeKey(prevNode, prevProps.getItemKey) === getNodeKey(nextNode, nextProps.getItemKey)
|
||||
);
|
||||
},
|
||||
) as typeof TreeItem_;
|
||||
@@ -1,49 +0,0 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import { Fragment } from 'react';
|
||||
import type { SelectableTreeNode } from './common';
|
||||
import type { TreeProps } from './Tree';
|
||||
import { TreeDropMarker } from './TreeDropMarker';
|
||||
import type { TreeItemHandle, TreeItemProps } from './TreeItem';
|
||||
import { TreeItem } from './TreeItem';
|
||||
|
||||
export type TreeItemListProps<T extends { id: string }> = Pick<
|
||||
TreeProps<T>,
|
||||
'ItemInner' | 'ItemLeftSlotInner' | 'ItemRightSlot' | 'treeId' | 'getItemKey' | 'getEditOptions' | 'renderContextMenu'
|
||||
> &
|
||||
Pick<TreeItemProps<T>, 'onClick' | 'getContextMenu'> & {
|
||||
nodes: SelectableTreeNode<T>[];
|
||||
style?: CSSProperties;
|
||||
className?: string;
|
||||
forceDepth?: number;
|
||||
addTreeItemRef?: (item: T, n: TreeItemHandle | null) => void;
|
||||
};
|
||||
|
||||
export function TreeItemList<T extends { id: string }>({
|
||||
className,
|
||||
getItemKey,
|
||||
nodes,
|
||||
style,
|
||||
treeId,
|
||||
forceDepth,
|
||||
addTreeItemRef,
|
||||
...props
|
||||
}: TreeItemListProps<T>) {
|
||||
return (
|
||||
<ul style={style} className={className}>
|
||||
<TreeDropMarker node={null} treeId={treeId} index={0} />
|
||||
{nodes.map((child, i) => (
|
||||
<Fragment key={getItemKey(child.node.item)}>
|
||||
<TreeItem
|
||||
treeId={treeId}
|
||||
setRef={addTreeItemRef}
|
||||
node={child.node}
|
||||
getItemKey={getItemKey}
|
||||
depth={forceDepth == null ? child.depth : forceDepth}
|
||||
{...props}
|
||||
/>
|
||||
<TreeDropMarker node={child.node} treeId={treeId} index={i + 1} />
|
||||
</Fragment>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import { atom } from 'jotai';
|
||||
import { atomFamily, selectAtom } from 'jotai/utils';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const selectedIdsFamily = atomFamily((_treeId: string) => {
|
||||
return atom<string[]>([]);
|
||||
});
|
||||
|
||||
export const isSelectedFamily = atomFamily(
|
||||
({ treeId, itemId }: { treeId: string; itemId: string }) => {
|
||||
return selectAtom(selectedIdsFamily(treeId), (ids) => ids.includes(itemId), Object.is);
|
||||
},
|
||||
(a, b) => a.treeId === b.treeId && a.itemId === b.itemId,
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const focusIdsFamily = atomFamily((_treeId: string) => {
|
||||
return atom<{ lastId: string | null; anchorId: string | null }>({ lastId: null, anchorId: null });
|
||||
});
|
||||
|
||||
export const isLastFocusedFamily = atomFamily(
|
||||
({ treeId, itemId }: { treeId: string; itemId: string }) =>
|
||||
selectAtom(focusIdsFamily(treeId), (v) => v.lastId === itemId, Object.is),
|
||||
(a, b) => a.treeId === b.treeId && a.itemId === b.itemId,
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const draggingIdsFamily = atomFamily((_treeId: string) => {
|
||||
return atom<string[]>([]);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const hoveredParentFamily = atomFamily((_treeId: string) => {
|
||||
return atom<{
|
||||
index: number | null;
|
||||
childIndex: number | null;
|
||||
parentId: string | null;
|
||||
parentDepth: number | null;
|
||||
}>({
|
||||
index: null,
|
||||
childIndex: null,
|
||||
parentId: null,
|
||||
parentDepth: null,
|
||||
});
|
||||
});
|
||||
|
||||
export const isParentHoveredFamily = atomFamily(
|
||||
({ treeId, parentId }: { treeId: string; parentId: string | null }) =>
|
||||
selectAtom(hoveredParentFamily(treeId), (v) => v.parentId === parentId, Object.is),
|
||||
(a, b) => a.treeId === b.treeId && a.parentId === b.parentId,
|
||||
);
|
||||
|
||||
export const isAncestorHoveredFamily = atomFamily(
|
||||
({ treeId, ancestorIds }: { treeId: string; ancestorIds: string[] }) =>
|
||||
selectAtom(
|
||||
hoveredParentFamily(treeId),
|
||||
(v) => v.parentId && ancestorIds.includes(v.parentId),
|
||||
Object.is,
|
||||
),
|
||||
(a, b) => a.treeId === b.treeId && a.ancestorIds.join(',') === b.ancestorIds.join(','),
|
||||
);
|
||||
|
||||
export const isIndexHoveredFamily = atomFamily(
|
||||
({ treeId, index }: { treeId: string; index: number }) =>
|
||||
selectAtom(hoveredParentFamily(treeId), (v) => v.index === index, Object.is),
|
||||
(a, b) => a.treeId === b.treeId && a.index === b.index,
|
||||
);
|
||||
|
||||
export const hoveredParentDepthFamily = atomFamily((treeId: string) =>
|
||||
selectAtom(
|
||||
hoveredParentFamily(treeId),
|
||||
(s) => s.parentDepth,
|
||||
(a, b) => Object.is(a, b), // prevents re-render unless the value changes
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import type { createStore } from 'jotai';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { CollapsedAtom } from './context';
|
||||
import { selectedIdsFamily } from './atoms';
|
||||
|
||||
export type JotaiStore = ReturnType<typeof createStore>;
|
||||
|
||||
export type ContextMenuRenderer = (props: {
|
||||
items: unknown[];
|
||||
position: { x: number; y: number };
|
||||
onClose: () => void;
|
||||
}) => ReactNode;
|
||||
|
||||
export interface TreeNode<T extends { id: string }> {
|
||||
children?: TreeNode<T>[];
|
||||
item: T;
|
||||
hidden?: boolean;
|
||||
parent: TreeNode<T> | null;
|
||||
depth: number;
|
||||
draggable?: boolean;
|
||||
localDrag?: boolean;
|
||||
}
|
||||
|
||||
export interface SelectableTreeNode<T extends { id: string }> {
|
||||
node: TreeNode<T>;
|
||||
depth: number;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export function getSelectedItems<T extends { id: string }>(
|
||||
store: JotaiStore,
|
||||
treeId: string,
|
||||
selectableItems: SelectableTreeNode<T>[],
|
||||
) {
|
||||
const selectedItemIds = store.get(selectedIdsFamily(treeId));
|
||||
return selectableItems
|
||||
.filter((i) => selectedItemIds.includes(i.node.item.id))
|
||||
.map((i) => i.node.item);
|
||||
}
|
||||
|
||||
export function equalSubtree<T extends { id: string }>(
|
||||
a: TreeNode<T>,
|
||||
b: TreeNode<T>,
|
||||
getItemKey: (t: T) => string,
|
||||
): boolean {
|
||||
if (getNodeKey(a, getItemKey) !== getNodeKey(b, getItemKey)) return false;
|
||||
const ak = a.children ?? [];
|
||||
const bk = b.children ?? [];
|
||||
|
||||
if (ak.length !== bk.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i = 0; i < ak.length; i++) {
|
||||
// biome-ignore lint/style/noNonNullAssertion: none
|
||||
if (!equalSubtree(ak[i]!, bk[i]!, getItemKey)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getNodeKey<T extends { id: string }>(a: TreeNode<T>, getItemKey: (i: T) => string) {
|
||||
return getItemKey(a.item) + a.hidden;
|
||||
}
|
||||
|
||||
export function hasAncestor<T extends { id: string }>(node: TreeNode<T>, ancestorId: string) {
|
||||
if (node.parent == null) return false;
|
||||
if (node.parent.item.id === ancestorId) return true;
|
||||
|
||||
// Check parents recursively
|
||||
return hasAncestor(node.parent, ancestorId);
|
||||
}
|
||||
|
||||
export function isVisibleNode<T extends { id: string }>(store: JotaiStore, collapsedAtom: CollapsedAtom, node: TreeNode<T>) {
|
||||
const collapsed = store.get(collapsedAtom);
|
||||
let p = node.parent;
|
||||
while (p) {
|
||||
if (collapsed[p.item.id]) return false; // any collapsed ancestor hides this node
|
||||
p = p.parent;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function closestVisibleNode<T extends { id: string }>(
|
||||
store: JotaiStore,
|
||||
collapsedAtom: CollapsedAtom,
|
||||
node: TreeNode<T>,
|
||||
): TreeNode<T> | null {
|
||||
let n: TreeNode<T> | null = node;
|
||||
while (n) {
|
||||
if (isVisibleNode(store, collapsedAtom, n) && !n.hidden) return n;
|
||||
if (n.parent == null) return null;
|
||||
n = n.parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import type { WritableAtom } from 'jotai';
|
||||
import { useAtomValue, useStore } from 'jotai';
|
||||
import { selectAtom } from 'jotai/utils';
|
||||
import { createContext, useCallback, useContext, useMemo } from 'react';
|
||||
|
||||
type CollapsedMap = Record<string, boolean>;
|
||||
type SetAction = CollapsedMap | ((prev: CollapsedMap) => CollapsedMap);
|
||||
export type CollapsedAtom = WritableAtom<CollapsedMap, [SetAction], void>;
|
||||
|
||||
export const CollapsedAtomContext = createContext<CollapsedAtom | null>(null);
|
||||
|
||||
export function useCollapsedAtom(): CollapsedAtom {
|
||||
const atom = useContext(CollapsedAtomContext);
|
||||
if (!atom) throw new Error('CollapsedAtomContext not provided');
|
||||
return atom;
|
||||
}
|
||||
|
||||
export function useIsCollapsed(itemId: string | undefined) {
|
||||
const collapsedAtom = useCollapsedAtom();
|
||||
const derivedAtom = useMemo(
|
||||
() => selectAtom(collapsedAtom, (map) => !!map[itemId ?? 'n/a'], Object.is),
|
||||
[collapsedAtom, itemId],
|
||||
);
|
||||
return useAtomValue(derivedAtom);
|
||||
}
|
||||
|
||||
export function useSetCollapsed(itemId: string | undefined) {
|
||||
const collapsedAtom = useCollapsedAtom();
|
||||
const store = useStore();
|
||||
return useCallback(
|
||||
(next: boolean | ((prev: boolean) => boolean)) => {
|
||||
const key = itemId ?? 'n/a';
|
||||
const prevMap = store.get(collapsedAtom);
|
||||
const prevValue = !!prevMap[key];
|
||||
const value = typeof next === 'function' ? next(prevValue) : next;
|
||||
if (value === prevValue) return;
|
||||
store.set(collapsedAtom, { ...prevMap, [key]: value });
|
||||
},
|
||||
[collapsedAtom, itemId, store],
|
||||
);
|
||||
}
|
||||
|
||||
export function useCollapsedMap() {
|
||||
const collapsedAtom = useCollapsedAtom();
|
||||
return useAtomValue(collapsedAtom);
|
||||
}
|
||||
|
||||
export function useIsAncestorCollapsed(ancestorIds: string[]) {
|
||||
const collapsedAtom = useCollapsedAtom();
|
||||
const derivedAtom = useMemo(
|
||||
() =>
|
||||
selectAtom(
|
||||
collapsedAtom,
|
||||
(collapsed) => ancestorIds.some((id) => collapsed[id]),
|
||||
(a, b) => a === b,
|
||||
),
|
||||
[collapsedAtom, ancestorIds],
|
||||
);
|
||||
return useAtomValue(derivedAtom);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { SelectableTreeNode, TreeNode } from './common';
|
||||
|
||||
export function useSelectableItems<T extends { id: string }>(root: TreeNode<T>) {
|
||||
return useMemo(() => {
|
||||
const selectableItems: SelectableTreeNode<T>[] = [];
|
||||
|
||||
// Put requests and folders into a tree structure
|
||||
const next = (node: TreeNode<T>, depth = 0) => {
|
||||
if (node.children == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Recurse to children
|
||||
let selectableIndex = 0;
|
||||
for (const child of node.children) {
|
||||
selectableItems.push({
|
||||
node: child,
|
||||
index: selectableIndex++,
|
||||
depth,
|
||||
});
|
||||
|
||||
next(child, depth + 1);
|
||||
}
|
||||
};
|
||||
|
||||
next(root);
|
||||
return selectableItems;
|
||||
}, [root]);
|
||||
}
|
||||
31
apps/yaak-proxy/ActionButton.tsx
Normal file
31
apps/yaak-proxy/ActionButton.tsx
Normal 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
35
apps/yaak-proxy/hooks.ts
Normal 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;
|
||||
}
|
||||
64
apps/yaak-proxy/hotkeys.ts
Normal file
64
apps/yaak-proxy/hotkeys.ts
Normal 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);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user