mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-02-23 11:05:01 +01:00
New sidebar and folder view (#263)
This commit is contained in:
@@ -221,7 +221,7 @@ export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown
|
||||
);
|
||||
});
|
||||
|
||||
interface ContextMenuProps {
|
||||
export interface ContextMenuProps {
|
||||
triggerPosition: { x: number; y: number } | null;
|
||||
className?: string;
|
||||
items: DropdownProps['items'];
|
||||
|
||||
@@ -24,11 +24,13 @@ import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import type { WrappedEnvironmentVariable } from '../../../hooks/useEnvironmentVariables';
|
||||
import { useEnvironmentVariables } from '../../../hooks/useEnvironmentVariables';
|
||||
import { useRandomKey } from '../../../hooks/useRandomKey';
|
||||
import { useRequestEditor } from '../../../hooks/useRequestEditor';
|
||||
import { useTemplateFunctionCompletionOptions } from '../../../hooks/useTemplateFunctions';
|
||||
import { showDialog } from '../../../lib/dialog';
|
||||
@@ -114,7 +116,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
|
||||
disabled,
|
||||
extraExtensions,
|
||||
forcedEnvironmentId,
|
||||
forceUpdateKey,
|
||||
forceUpdateKey: forceUpdateKeyFromAbove,
|
||||
format,
|
||||
heightMode,
|
||||
hideGutter,
|
||||
@@ -145,6 +147,10 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
|
||||
? allEnvironmentVariables.filter(autocompleteVariables)
|
||||
: allEnvironmentVariables;
|
||||
}, [allEnvironmentVariables, autocompleteVariables]);
|
||||
// Track a local key for updates. If the default value is changed when the input is not in focus,
|
||||
// regenerate this to force the field to update.
|
||||
const [focusedUpdateKey, regenerateFocusedUpdateKey] = useRandomKey();
|
||||
const forceUpdateKey = `${forceUpdateKeyFromAbove}::${focusedUpdateKey}`;
|
||||
|
||||
if (settings && wrapLines === undefined) {
|
||||
wrapLines = settings.editorSoftWrap;
|
||||
@@ -340,6 +346,17 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
|
||||
[],
|
||||
);
|
||||
|
||||
// Force input to update when receiving change and not in focus
|
||||
useLayoutEffect(() => {
|
||||
const currDoc = cm.current?.view.state.doc.toString() || '';
|
||||
const nextDoc = defaultValue || '';
|
||||
const notFocused = !cm.current?.view.hasFocus;
|
||||
const hasChanged = currDoc !== nextDoc;
|
||||
if (notFocused && hasChanged) {
|
||||
regenerateFocusedUpdateKey();
|
||||
}
|
||||
}, [defaultValue, regenerateFocusedUpdateKey]);
|
||||
|
||||
const [, { focusParamValue }] = useRequestEditor();
|
||||
const onClickPathParameter = useCallback(
|
||||
async (name: string) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { settingsAtom } from '@yaakapp-internal/models';
|
||||
import type { GrpcRequest, HttpRequest, WebsocketRequest } from '@yaakapp-internal/models';
|
||||
import { settingsAtom } from '@yaakapp-internal/models';
|
||||
import classNames from 'classnames';
|
||||
import { useAtomValue } from 'jotai';
|
||||
|
||||
@@ -18,42 +18,69 @@ const methodNames: Record<string, string> = {
|
||||
options: 'OPTN',
|
||||
head: 'HEAD',
|
||||
query: 'QURY',
|
||||
graphql: 'GQL',
|
||||
grpc: 'GRPC',
|
||||
websocket: 'WS',
|
||||
};
|
||||
|
||||
export function HttpMethodTag({ request, className, short }: Props) {
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const method =
|
||||
request.model === 'http_request' && request.bodyType === 'graphql'
|
||||
? 'GQL'
|
||||
? 'graphql'
|
||||
: request.model === 'grpc_request'
|
||||
? 'GRPC'
|
||||
? 'grpc'
|
||||
: request.model === 'websocket_request'
|
||||
? 'WS'
|
||||
? 'websocket'
|
||||
: request.method;
|
||||
let label = method.toUpperCase();
|
||||
|
||||
return (
|
||||
<HttpMethodTagRaw
|
||||
method={method}
|
||||
colored={settings.coloredMethods}
|
||||
className={className}
|
||||
short={short}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function HttpMethodTagRaw({
|
||||
className,
|
||||
method,
|
||||
colored,
|
||||
short,
|
||||
}: {
|
||||
method: string;
|
||||
className?: string;
|
||||
colored: boolean;
|
||||
short?: boolean;
|
||||
}) {
|
||||
let label = method.toUpperCase();
|
||||
if (short) {
|
||||
label = methodNames[method.toLowerCase()] ?? method.slice(0, 4);
|
||||
label = label.padStart(4, ' ');
|
||||
}
|
||||
|
||||
const m = method.toUpperCase();
|
||||
|
||||
return (
|
||||
<span
|
||||
className={classNames(
|
||||
className,
|
||||
!settings.coloredMethods && 'text-text-subtle',
|
||||
settings.coloredMethods && method === 'GQL' && 'text-info',
|
||||
settings.coloredMethods && method === 'WS' && 'text-info',
|
||||
settings.coloredMethods && method === 'GRPC' && 'text-info',
|
||||
settings.coloredMethods && method === 'OPTIONS' && 'text-info',
|
||||
settings.coloredMethods && method === 'HEAD' && 'text-info',
|
||||
settings.coloredMethods && method === 'GET' && 'text-primary',
|
||||
settings.coloredMethods && method === 'PUT' && 'text-warning',
|
||||
settings.coloredMethods && method === 'PATCH' && 'text-notice',
|
||||
settings.coloredMethods && method === 'POST' && 'text-success',
|
||||
settings.coloredMethods && method === 'DELETE' && 'text-danger',
|
||||
!colored && 'text-text-subtle',
|
||||
colored && m === 'GRAPHQL' && 'text-info',
|
||||
colored && m === 'WEBSOCKET' && 'text-info',
|
||||
colored && m === 'GRPC' && 'text-info',
|
||||
colored && m === 'QUERY' && 'text-secondary',
|
||||
colored && m === 'OPTIONS' && 'text-info',
|
||||
colored && m === 'HEAD' && 'text-secondary',
|
||||
colored && m === 'GET' && 'text-primary',
|
||||
colored && m === 'PUT' && 'text-warning',
|
||||
colored && m === 'PATCH' && 'text-notice',
|
||||
colored && m === 'POST' && 'text-success',
|
||||
colored && m === 'DELETE' && 'text-danger',
|
||||
'font-mono flex-shrink-0 whitespace-pre',
|
||||
'pt-[0.25em]', // Fix for monospace font not vertically centering
|
||||
'pt-[0.15em]', // Fix for monospace font not vertically centering
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
|
||||
@@ -12,6 +12,7 @@ export function HttpResponseDurationTag({ response }: Props) {
|
||||
// Calculate the duration of the response for use when the response hasn't finished yet
|
||||
useEffect(() => {
|
||||
clearInterval(timeout.current);
|
||||
if (response.state === 'closed') return;
|
||||
timeout.current = setInterval(() => {
|
||||
setFallbackElapsed(Date.now() - new Date(response.createdAt + 'Z').getTime());
|
||||
}, 100);
|
||||
|
||||
@@ -33,7 +33,7 @@ export function HttpStatusTag({ response, className, showReason, short }: Props)
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={classNames(className, 'font-mono', colorClass)}>
|
||||
<span className={classNames(className, 'font-mono min-w-0', colorClass)}>
|
||||
{label} {showReason && 'statusReason' in response ? response.statusReason : null}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -39,6 +39,7 @@ const icons = {
|
||||
code: lucide.CodeIcon,
|
||||
columns_2: lucide.Columns2Icon,
|
||||
command: lucide.CommandIcon,
|
||||
corner_right_up: lucide.CornerRightUpIcon,
|
||||
credit_card: lucide.CreditCardIcon,
|
||||
cookie: lucide.CookieIcon,
|
||||
copy: lucide.CopyIcon,
|
||||
@@ -54,6 +55,7 @@ const icons = {
|
||||
flame: lucide.FlameIcon,
|
||||
flask: lucide.FlaskConicalIcon,
|
||||
folder: lucide.FolderIcon,
|
||||
folder_cog: lucide.FolderCogIcon,
|
||||
folder_code: lucide.FolderCodeIcon,
|
||||
folder_git: lucide.FolderGitIcon,
|
||||
folder_input: lucide.FolderInputIcon,
|
||||
@@ -61,6 +63,7 @@ const icons = {
|
||||
folder_output: lucide.FolderOutputIcon,
|
||||
folder_symlink: lucide.FolderSymlinkIcon,
|
||||
folder_sync: lucide.FolderSyncIcon,
|
||||
folder_up: lucide.FolderUpIcon,
|
||||
git_branch: lucide.GitBranchIcon,
|
||||
git_branch_plus: lucide.GitBranchPlusIcon,
|
||||
git_commit: lucide.GitCommitIcon,
|
||||
@@ -118,7 +121,7 @@ const icons = {
|
||||
x: lucide.XIcon,
|
||||
_unknown: lucide.ShieldAlertIcon,
|
||||
|
||||
empty: (props: HTMLAttributes<HTMLSpanElement>) => <span {...props} />,
|
||||
empty: (props: HTMLAttributes<HTMLSpanElement>) => <div {...props} />,
|
||||
};
|
||||
|
||||
export interface IconProps {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { EditorSelection } from '@codemirror/state';
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
import type { Color } from '@yaakapp-internal/plugins';
|
||||
import classNames from 'classnames';
|
||||
@@ -164,7 +165,7 @@ const BaseInput = forwardRef<EditorView, InputProps>(function InputBase(
|
||||
setFocused(false);
|
||||
// Move selection to the end on blur
|
||||
editorRef.current?.dispatch({
|
||||
selection: { anchor: editorRef.current.state.doc.length },
|
||||
selection: EditorSelection.single(editorRef.current.state.doc.length ),
|
||||
});
|
||||
onBlur?.();
|
||||
}, [onBlur]);
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import classNames from 'classnames';
|
||||
import type { FocusEvent, HTMLAttributes } from 'react';
|
||||
import { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react';
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useRandomKey } from '../../hooks/useRandomKey';
|
||||
import { useStateWithDeps } from '../../hooks/useStateWithDeps';
|
||||
import { IconButton } from './IconButton';
|
||||
import type { InputProps } from './Input';
|
||||
@@ -22,7 +30,7 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
|
||||
className,
|
||||
containerClassName,
|
||||
defaultValue,
|
||||
forceUpdateKey,
|
||||
forceUpdateKey: forceUpdateKeyFromAbove,
|
||||
help,
|
||||
hideLabel,
|
||||
hideObscureToggle,
|
||||
@@ -47,15 +55,21 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
// Track a local key for updates. If the default value is changed when the input is not in focus,
|
||||
// regenerate this to force the field to update.
|
||||
const [focusedUpdateKey, regenerateFocusedUpdateKey] = useRandomKey();
|
||||
const forceUpdateKey = `${forceUpdateKeyFromAbove}::${focusedUpdateKey}`;
|
||||
|
||||
const [obscured, setObscured] = useStateWithDeps(type === 'password', [type]);
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [hasChanged, setHasChanged] = useStateWithDeps<boolean>(false, [forceUpdateKey]);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useImperativeHandle<{ focus: () => void } | null, { focus: () => void } | null>(
|
||||
ref,
|
||||
() => inputRef.current,
|
||||
);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const handleFocus = useCallback(
|
||||
(e: FocusEvent<HTMLInputElement>) => {
|
||||
@@ -75,6 +89,13 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
|
||||
onBlur?.();
|
||||
}, [onBlur]);
|
||||
|
||||
// Force input to update when receiving change and not in focus
|
||||
useLayoutEffect(() => {
|
||||
if (!focused) {
|
||||
regenerateFocusedUpdateKey();
|
||||
}
|
||||
}, [focused, regenerateFocusedUpdateKey, defaultValue]);
|
||||
|
||||
const id = `input-${name}`;
|
||||
const commonClassName = classNames(
|
||||
className,
|
||||
@@ -152,9 +173,9 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
|
||||
)}
|
||||
>
|
||||
<input
|
||||
id={id}
|
||||
ref={inputRef}
|
||||
key={forceUpdateKey}
|
||||
id={id}
|
||||
type={type === 'password' && !obscured ? 'text' : type}
|
||||
defaultValue={defaultValue ?? undefined}
|
||||
autoComplete="off"
|
||||
|
||||
@@ -33,7 +33,15 @@ export function RadioDropdown<T = string | null>({
|
||||
}: RadioDropdownProps<T>) {
|
||||
const dropdownItems = useMemo(
|
||||
() => [
|
||||
...((itemsBefore ? [...itemsBefore, { type: 'separator' }] : []) as DropdownItem[]),
|
||||
...((itemsBefore
|
||||
? [
|
||||
...itemsBefore,
|
||||
{
|
||||
type: 'separator',
|
||||
hidden: itemsBefore[itemsBefore.length - 1]?.type === 'separator',
|
||||
},
|
||||
]
|
||||
: []) as DropdownItem[]),
|
||||
...items.map((item) => {
|
||||
if (item.type === 'separator') {
|
||||
return item;
|
||||
@@ -47,7 +55,9 @@ export function RadioDropdown<T = string | null>({
|
||||
} as DropdownItem;
|
||||
}
|
||||
}),
|
||||
...((itemsAfter ? [{ type: 'separator' }, ...itemsAfter] : []) as DropdownItem[]),
|
||||
...((itemsAfter
|
||||
? [{ type: 'separator', hidden: itemsAfter[0]?.type === 'separator' }, ...itemsAfter]
|
||||
: []) as DropdownItem[]),
|
||||
],
|
||||
[itemsBefore, items, itemsAfter, value, onChange],
|
||||
);
|
||||
|
||||
@@ -88,15 +88,15 @@ export function SplitLayout({
|
||||
|
||||
const unsub = () => {
|
||||
if (moveState.current !== null) {
|
||||
document.documentElement.removeEventListener('mousemove', moveState.current.move);
|
||||
document.documentElement.removeEventListener('mouseup', moveState.current.up);
|
||||
document.documentElement.removeEventListener('pointermove', moveState.current.move);
|
||||
document.documentElement.removeEventListener('pointerup', moveState.current.up);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = useCallback(
|
||||
() => (vertical ? setHeight(defaultRatio) : setWidth(defaultRatio)),
|
||||
[vertical, setHeight, defaultRatio, setWidth],
|
||||
);
|
||||
const handleReset = useCallback(() => {
|
||||
if (vertical) setHeight(defaultRatio);
|
||||
else setWidth(defaultRatio);
|
||||
}, [vertical, setHeight, defaultRatio, setWidth]);
|
||||
|
||||
const handleResizeStart = useCallback(
|
||||
(e: ReactMouseEvent<HTMLDivElement>) => {
|
||||
@@ -112,6 +112,7 @@ export function SplitLayout({
|
||||
|
||||
moveState.current = {
|
||||
move: (e: MouseEvent) => {
|
||||
setIsResizing(true); // Set this here so we don't block double-clicks
|
||||
e.preventDefault(); // Prevent text selection and things
|
||||
if (vertical) {
|
||||
const maxHeightPx = containerRect.height - minHeightPx;
|
||||
@@ -137,9 +138,8 @@ export function SplitLayout({
|
||||
setIsResizing(false);
|
||||
},
|
||||
};
|
||||
document.documentElement.addEventListener('mousemove', moveState.current.move);
|
||||
document.documentElement.addEventListener('mouseup', moveState.current.up);
|
||||
setIsResizing(true);
|
||||
document.documentElement.addEventListener('pointermove', moveState.current.move);
|
||||
document.documentElement.addEventListener('pointerup', moveState.current.up);
|
||||
},
|
||||
[width, height, vertical, minHeightPx, setHeight, minWidthPx, setWidth],
|
||||
);
|
||||
|
||||
@@ -83,13 +83,13 @@ export function Tabs({
|
||||
aria-label={label}
|
||||
className={classNames(
|
||||
tabListClassName,
|
||||
addBorders && '!-ml-1',
|
||||
addBorders && layout === 'vertical' && 'mb-2',
|
||||
addBorders && layout === 'horizontal' && 'pl-3 -ml-1',
|
||||
addBorders && layout === 'vertical' && 'ml-0 mb-2',
|
||||
'flex items-center hide-scrollbars',
|
||||
layout === 'horizontal' && 'h-full overflow-auto p-2 -mr-2',
|
||||
layout === 'vertical' && 'overflow-x-auto overflow-y-visible ',
|
||||
// Give space for button focus states within overflow boundary.
|
||||
layout === 'vertical' && 'py-1 -ml-5 pl-3 pr-1',
|
||||
!addBorders && layout === 'vertical' && 'py-1 pl-3 -ml-5 pr-1',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
@@ -125,6 +125,8 @@ export function Tabs({
|
||||
<RadioDropdown
|
||||
key={t.value}
|
||||
items={t.options.items}
|
||||
itemsAfter={t.options.itemsAfter}
|
||||
itemsBefore={t.options.itemsBefore}
|
||||
value={t.options.value}
|
||||
onChange={t.options.onChange}
|
||||
>
|
||||
|
||||
63
src-web/components/core/tree/AutoScrollWhileDragging.tsx
Normal file
63
src-web/components/core/tree/AutoScrollWhileDragging.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
// AutoScrollWhileDragging.tsx
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useDragLayer } from 'react-dnd';
|
||||
|
||||
type Props = {
|
||||
container: HTMLElement | null | undefined;
|
||||
edgeDistance?: number;
|
||||
maxSpeedPerFrame?: number;
|
||||
};
|
||||
|
||||
export function AutoScrollWhileDragging({
|
||||
container,
|
||||
edgeDistance = 30,
|
||||
maxSpeedPerFrame = 6,
|
||||
}: Props) {
|
||||
const rafId = useRef<number | null>(null);
|
||||
|
||||
const { isDragging, pointer } = useDragLayer((monitor) => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
pointer: monitor.getClientOffset(), // { x, y } | null
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (!container || !isDragging) {
|
||||
if (rafId.current != null) cancelAnimationFrame(rafId.current);
|
||||
rafId.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const tick = () => {
|
||||
if (!container || !isDragging || !pointer) return;
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
const y = pointer.y;
|
||||
|
||||
// Compute vertical speed based on proximity to edges
|
||||
let dy = 0;
|
||||
if (y < rect.top + edgeDistance) {
|
||||
const t = (rect.top + edgeDistance - y) / edgeDistance; // 0..1
|
||||
dy = -Math.min(maxSpeedPerFrame, Math.ceil(t * maxSpeedPerFrame));
|
||||
} else if (y > rect.bottom - edgeDistance) {
|
||||
const t = (y - (rect.bottom - edgeDistance)) / edgeDistance; // 0..1
|
||||
dy = Math.min(maxSpeedPerFrame, Math.ceil(t * maxSpeedPerFrame));
|
||||
}
|
||||
|
||||
if (dy !== 0) {
|
||||
// Only scroll if there’s more content in that direction
|
||||
const prev = container.scrollTop;
|
||||
container.scrollTop = prev + dy;
|
||||
}
|
||||
|
||||
rafId.current = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
rafId.current = requestAnimationFrame(tick);
|
||||
return () => {
|
||||
if (rafId.current != null) cancelAnimationFrame(rafId.current);
|
||||
rafId.current = null;
|
||||
};
|
||||
}, [container, isDragging, pointer, edgeDistance, maxSpeedPerFrame]);
|
||||
|
||||
return null;
|
||||
}
|
||||
557
src-web/components/core/tree/Tree.tsx
Normal file
557
src-web/components/core/tree/Tree.tsx
Normal file
@@ -0,0 +1,557 @@
|
||||
import type { DragEndEvent, DragMoveEvent, DragStartEvent } from '@dnd-kit/core';
|
||||
import {
|
||||
DndContext,
|
||||
PointerSensor,
|
||||
pointerWithin,
|
||||
useDroppable,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import { type } from '@tauri-apps/plugin-os';
|
||||
import classNames from 'classnames';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import type { ComponentType, ReactElement, Ref, RefAttributes } from 'react';
|
||||
import {
|
||||
forwardRef,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useKey, useKeyPressEvent } from 'react-use';
|
||||
import type { HotkeyAction, HotKeyOptions } from '../../../hooks/useHotKey';
|
||||
import { useHotKey } from '../../../hooks/useHotKey';
|
||||
import { sidebarCollapsedAtom } from '../../../hooks/useSidebarItemCollapsed';
|
||||
import { jotaiStore } from '../../../lib/jotai';
|
||||
import type { ContextMenuProps } from '../Dropdown';
|
||||
import { draggingIdsFamily, focusIdsFamily, hoveredParentFamily, selectedIdsFamily } from './atoms';
|
||||
import type { SelectableTreeNode, TreeNode } from './common';
|
||||
import { computeSideForDragMove, equalSubtree, getSelectedItems, hasAncestor } from './common';
|
||||
import { TreeDragOverlay } from './TreeDragOverlay';
|
||||
import type { TreeItemProps } from './TreeItem';
|
||||
import type { TreeItemListProps } from './TreeItemList';
|
||||
import { TreeItemList } from './TreeItemList';
|
||||
|
||||
export interface TreeProps<T extends { id: string }> {
|
||||
root: TreeNode<T>;
|
||||
treeId: string;
|
||||
getItemKey: (item: T) => string;
|
||||
getContextMenu?: (items: T[]) => Promise<ContextMenuProps['items']>;
|
||||
ItemInner: ComponentType<{ treeId: string; item: T }>;
|
||||
ItemLeftSlot?: 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, (items: T[]) => void>> } & HotKeyOptions;
|
||||
getEditOptions?: (item: T) => {
|
||||
defaultValue: string;
|
||||
placeholder?: string;
|
||||
onChange: (item: T, text: string) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TreeHandle {
|
||||
focus: () => void;
|
||||
selectItem: (id: string) => void;
|
||||
}
|
||||
|
||||
function TreeInner<T extends { id: string }>(
|
||||
{
|
||||
className,
|
||||
getContextMenu,
|
||||
getEditOptions,
|
||||
getItemKey,
|
||||
hotkeys,
|
||||
onActivate,
|
||||
onDragEnd,
|
||||
ItemInner,
|
||||
ItemLeftSlot,
|
||||
root,
|
||||
treeId,
|
||||
}: TreeProps<T>,
|
||||
ref: Ref<TreeHandle>,
|
||||
) {
|
||||
const treeRef = useRef<HTMLDivElement>(null);
|
||||
const { treeParentMap, selectableItems } = useTreeParentMap(root, getItemKey);
|
||||
const [isFocused, setIsFocused] = useState<boolean>(false);
|
||||
|
||||
const tryFocus = useCallback(() => {
|
||||
treeRef.current?.querySelector<HTMLButtonElement>('.tree-item button[tabindex="0"]')?.focus();
|
||||
}, []);
|
||||
|
||||
const setSelected = useCallback(
|
||||
function setSelected(ids: string[], focus: boolean) {
|
||||
jotaiStore.set(selectedIdsFamily(treeId), ids);
|
||||
// TODO: Figure out a better way than timeout
|
||||
if (focus) setTimeout(tryFocus, 50);
|
||||
},
|
||||
[treeId, tryFocus],
|
||||
);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
(): TreeHandle => ({
|
||||
focus: tryFocus,
|
||||
selectItem(id) {
|
||||
setSelected([id], false);
|
||||
jotaiStore.set(focusIdsFamily(treeId), { anchorId: id, lastId: id });
|
||||
},
|
||||
}),
|
||||
[setSelected, treeId, tryFocus],
|
||||
);
|
||||
|
||||
const handleGetContextMenu = useMemo(() => {
|
||||
if (getContextMenu == null) return;
|
||||
return (item: T) => {
|
||||
const items = getSelectedItems(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);
|
||||
} else {
|
||||
// If right-clicked an item that was NOT in the multiple-selection, just use that one
|
||||
// Also update the selection with it
|
||||
jotaiStore.set(selectedIdsFamily(treeId), [item.id]);
|
||||
jotaiStore.set(focusIdsFamily(treeId), (prev) => ({ ...prev, lastId: item.id }));
|
||||
return getContextMenu([item]);
|
||||
}
|
||||
};
|
||||
}, [getContextMenu, selectableItems, treeId]);
|
||||
|
||||
const handleSelect = useCallback<NonNullable<TreeItemProps<T>['onClick']>>(
|
||||
(item, { shiftKey, metaKey, ctrlKey }) => {
|
||||
const anchorSelectedId = jotaiStore.get(focusIdsFamily(treeId)).anchorId;
|
||||
const selectedIdsAtom = selectedIdsFamily(treeId);
|
||||
const selectedIds = jotaiStore.get(selectedIdsAtom);
|
||||
|
||||
// Mark item as the last one selected
|
||||
jotaiStore.set(focusIdsFamily(treeId), (prev) => ({ ...prev, lastId: item.id }));
|
||||
|
||||
if (shiftKey) {
|
||||
const anchorIndex = selectableItems.findIndex((i) => i.node.item.id === anchorSelectedId);
|
||||
const currIndex = selectableItems.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);
|
||||
jotaiStore.set(focusIdsFamily(treeId), (prev) => ({ ...prev, anchorId: item.id }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (currIndex > anchorIndex) {
|
||||
// Selecting down
|
||||
const itemsToSelect = selectableItems.slice(anchorIndex, currIndex + 1);
|
||||
setSelected(
|
||||
itemsToSelect.map((v) => v.node.item.id),
|
||||
true,
|
||||
);
|
||||
} else if (currIndex < anchorIndex) {
|
||||
// Selecting up
|
||||
const itemsToSelect = selectableItems.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);
|
||||
jotaiStore.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],
|
||||
);
|
||||
|
||||
useKey(
|
||||
'ArrowUp',
|
||||
(e) => {
|
||||
if (!treeRef.current?.contains(document.activeElement)) return;
|
||||
e.preventDefault();
|
||||
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId;
|
||||
const index = selectableItems.findIndex((i) => i.node.item.id === lastSelectedId);
|
||||
const item = selectableItems[index - 1];
|
||||
if (item != null) handleSelect(item.node.item, e);
|
||||
},
|
||||
undefined,
|
||||
[selectableItems, handleSelect],
|
||||
);
|
||||
|
||||
useKey(
|
||||
'ArrowDown',
|
||||
(e) => {
|
||||
if (!treeRef.current?.contains(document.activeElement)) return;
|
||||
e.preventDefault();
|
||||
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId;
|
||||
const index = selectableItems.findIndex((i) => i.node.item.id === lastSelectedId);
|
||||
const item = selectableItems[index + 1];
|
||||
if (item != null) handleSelect(item.node.item, e);
|
||||
},
|
||||
undefined,
|
||||
[selectableItems, handleSelect],
|
||||
);
|
||||
|
||||
useKeyPressEvent('Escape', async () => {
|
||||
if (!treeRef.current?.contains(document.activeElement)) return;
|
||||
clearDragState();
|
||||
const lastSelectedId = jotaiStore.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
|
||||
jotaiStore.set(hoveredParentFamily(treeId), { parentId: 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) {
|
||||
jotaiStore.set(hoveredParentFamily(treeId), {
|
||||
parentId: root.item.id,
|
||||
index: root.children?.length ?? 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const node = selectableItems.find((i) => i.node.item.id === over.id)?.node ?? null;
|
||||
if (node == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const side = computeSideForDragMove(node, e);
|
||||
|
||||
const item = node.item;
|
||||
let hoveredParent = treeParentMap[item.id] ?? null;
|
||||
const dragIndex = hoveredParent?.children?.findIndex((n) => n.item.id === item.id) ?? -99;
|
||||
const hovered = hoveredParent?.children?.[dragIndex] ?? null;
|
||||
let hoveredIndex = dragIndex + (side === 'above' ? 0 : 1);
|
||||
|
||||
const collapsedMap = jotaiStore.get(jotaiStore.get(sidebarCollapsedAtom));
|
||||
const isHoveredItemCollapsed = hovered != null ? collapsedMap[hovered.item.id] : false;
|
||||
|
||||
if (hovered?.children != null && side === 'below' && !isHoveredItemCollapsed) {
|
||||
// Move into the folder if it's open and we're moving below it
|
||||
hoveredParent = hoveredParent?.children?.find((n) => n.item.id === item.id) ?? null;
|
||||
hoveredIndex = 0;
|
||||
}
|
||||
|
||||
jotaiStore.set(hoveredParentFamily(treeId), {
|
||||
parentId: hoveredParent?.item.id ?? null,
|
||||
index: hoveredIndex,
|
||||
});
|
||||
},
|
||||
[root.children?.length, root.item.id, selectableItems, treeId, treeParentMap],
|
||||
);
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
function handleDragStart(e: DragStartEvent) {
|
||||
const item = selectableItems.find((i) => i.node.item.id === e.active.id)?.node.item ?? null;
|
||||
if (item == null) return;
|
||||
|
||||
const selectedItems = getSelectedItems(treeId, selectableItems);
|
||||
const isDraggingSelectedItem = selectedItems.find((i) => i.id === item.id);
|
||||
if (isDraggingSelectedItem) {
|
||||
jotaiStore.set(
|
||||
draggingIdsFamily(treeId),
|
||||
selectedItems.map((i) => i.id),
|
||||
);
|
||||
} else {
|
||||
jotaiStore.set(draggingIdsFamily(treeId), [item.id]);
|
||||
// Also update selection to just be this one
|
||||
handleSelect(item, { shiftKey: false, metaKey: false, ctrlKey: false });
|
||||
}
|
||||
},
|
||||
[handleSelect, selectableItems, treeId],
|
||||
);
|
||||
|
||||
const clearDragState = useCallback(() => {
|
||||
jotaiStore.set(hoveredParentFamily(treeId), { parentId: null, index: null });
|
||||
// jotaiStore.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 hovered = jotaiStore.get(hoveredParentFamily(treeId));
|
||||
const draggingItems = jotaiStore.get(draggingIdsFamily(treeId));
|
||||
clearDragState();
|
||||
|
||||
// Dropped outside the tree?
|
||||
if (e.over == null) return;
|
||||
|
||||
const hoveredParent =
|
||||
hovered.parentId == root.item.id
|
||||
? root
|
||||
: selectableItems.find((n) => n.node.item.id === hovered.parentId)?.node;
|
||||
|
||||
if (hoveredParent == null || hovered.index == null || !draggingItems?.length) return;
|
||||
|
||||
// Optional tiny guard: don't drop into itself
|
||||
if (draggingItems.some((id) => id === hovered.parentId)) return;
|
||||
|
||||
// Resolve the actual tree nodes for each dragged item (keeps order of draggingItems)
|
||||
const draggedNodes: TreeNode<T>[] = draggingItems
|
||||
.map((id) => {
|
||||
const parent = treeParentMap[id];
|
||||
const idx = parent?.children?.findIndex((n) => n.item.id === id) ?? -1;
|
||||
return idx >= 0 ? parent!.children![idx]! : null;
|
||||
})
|
||||
.filter((n) => n != null)
|
||||
// Filter out invalid drags (dragging into descendant)
|
||||
.filter((n) => !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 = hovered.index;
|
||||
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, root, selectableItems, onDragEnd, treeParentMap],
|
||||
);
|
||||
|
||||
const treeItemListProps: Omit<
|
||||
TreeItemListProps<T>,
|
||||
'node' | 'treeId' | 'activeIdAtom' | 'hoveredParent' | 'hoveredIndex'
|
||||
> = {
|
||||
depth: 0,
|
||||
getItemKey,
|
||||
getContextMenu: handleGetContextMenu,
|
||||
onClick: handleClick,
|
||||
getEditOptions,
|
||||
ItemInner,
|
||||
ItemLeftSlot,
|
||||
};
|
||||
|
||||
const handleFocus = useCallback(function handleFocus() {
|
||||
setIsFocused(true);
|
||||
}, []);
|
||||
|
||||
const handleBlur = useCallback(function handleBlur() {
|
||||
setIsFocused(false);
|
||||
}, []);
|
||||
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
|
||||
|
||||
return (
|
||||
<>
|
||||
<TreeHotKeys treeId={treeId} hotkeys={hotkeys} selectableItems={selectableItems} />
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={pointerWithin}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={clearDragState}
|
||||
onDragAbort={clearDragState}
|
||||
onDragMove={handleDragMove}
|
||||
autoScroll
|
||||
>
|
||||
<div
|
||||
ref={treeRef}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
className={classNames(
|
||||
className,
|
||||
'outline-none h-full',
|
||||
'overflow-y-auto overflow-x-hidden',
|
||||
'grid grid-rows-[auto_1fr]',
|
||||
' [&_.tree-item.selected]:text-text',
|
||||
isFocused
|
||||
? '[&_.tree-item.selected]:bg-surface-active'
|
||||
: '[&_.tree-item.selected]:bg-surface-highlight',
|
||||
)}
|
||||
>
|
||||
<TreeItemList node={root} treeId={treeId} {...treeItemListProps} />
|
||||
{/* Assign root ID so we can reuse our same move/end logic */}
|
||||
<DropRegionAfterList id={root.item.id} />
|
||||
<TreeDragOverlay
|
||||
treeId={treeId}
|
||||
root={root}
|
||||
selectableItems={selectableItems}
|
||||
ItemInner={ItemInner}
|
||||
getItemKey={getItemKey}
|
||||
/>
|
||||
</div>
|
||||
</DndContext>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 }: { id: string }) {
|
||||
const { setNodeRef } = useDroppable({ id });
|
||||
return <div ref={setNodeRef} />;
|
||||
}
|
||||
|
||||
function useTreeParentMap<T extends { id: string }>(
|
||||
root: TreeNode<T>,
|
||||
getItemKey: (item: T) => string,
|
||||
) {
|
||||
const collapsedMap = useAtomValue(useAtomValue(sidebarCollapsedAtom));
|
||||
const [{ treeParentMap, selectableItems }, setData] = useState(() => {
|
||||
return compute(root, collapsedMap);
|
||||
});
|
||||
|
||||
const prevRoot = useRef<TreeNode<T> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const shouldRecompute =
|
||||
root == null || prevRoot.current == null || !equalSubtree(root, prevRoot.current, getItemKey);
|
||||
if (!shouldRecompute) return;
|
||||
setData(compute(root, collapsedMap));
|
||||
prevRoot.current = root;
|
||||
}, [collapsedMap, getItemKey, root]);
|
||||
|
||||
return { treeParentMap, selectableItems };
|
||||
}
|
||||
|
||||
function compute<T extends { id: string }>(
|
||||
root: TreeNode<T>,
|
||||
collapsedMap: Record<string, boolean>,
|
||||
) {
|
||||
const treeParentMap: Record<string, TreeNode<T>> = {};
|
||||
const selectableItems: SelectableTreeNode<T>[] = [];
|
||||
|
||||
// Put requests and folders into a tree structure
|
||||
const next = (node: TreeNode<T>, depth: number = 0) => {
|
||||
const isCollapsed = collapsedMap[node.item.id] === true;
|
||||
// console.log("IS COLLAPSED", node.item.name, isCollapsed);
|
||||
if (node.children == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Recurse to children
|
||||
let selectableIndex = 0;
|
||||
for (const child of node.children) {
|
||||
treeParentMap[child.item.id] = node;
|
||||
if (!isCollapsed) {
|
||||
selectableItems.push({
|
||||
node: child,
|
||||
index: selectableIndex++,
|
||||
depth,
|
||||
});
|
||||
}
|
||||
|
||||
next(child, depth + 1);
|
||||
}
|
||||
};
|
||||
|
||||
next(root);
|
||||
return { treeParentMap, selectableItems };
|
||||
}
|
||||
|
||||
interface TreeHotKeyProps<T extends { id: string }> extends HotKeyOptions {
|
||||
action: HotkeyAction;
|
||||
selectableItems: SelectableTreeNode<T>[];
|
||||
treeId: string;
|
||||
onDone: (items: T[]) => void;
|
||||
}
|
||||
|
||||
function TreeHotKey<T extends { id: string }>({
|
||||
treeId,
|
||||
action,
|
||||
onDone,
|
||||
selectableItems,
|
||||
...options
|
||||
}: TreeHotKeyProps<T>) {
|
||||
useHotKey(
|
||||
action,
|
||||
() => {
|
||||
onDone(getSelectedItems(treeId, selectableItems));
|
||||
},
|
||||
options,
|
||||
);
|
||||
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, onDone]) => (
|
||||
<TreeHotKey
|
||||
key={hotkey}
|
||||
action={hotkey as HotkeyAction}
|
||||
priority={hotkeys.priority}
|
||||
enable={hotkeys.enable}
|
||||
treeId={treeId}
|
||||
onDone={onDone}
|
||||
selectableItems={selectableItems}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
43
src-web/components/core/tree/TreeDragOverlay.tsx
Normal file
43
src-web/components/core/tree/TreeDragOverlay.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { DragOverlay } from '@dnd-kit/core';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { draggingIdsFamily } from './atoms';
|
||||
import type { SelectableTreeNode, TreeNode } from './common';
|
||||
import type { TreeProps } from './Tree';
|
||||
import { TreeItemList } from './TreeItemList';
|
||||
|
||||
export function TreeDragOverlay<T extends { id: string }>({
|
||||
treeId,
|
||||
root,
|
||||
selectableItems,
|
||||
getItemKey,
|
||||
ItemInner,
|
||||
ItemLeftSlot,
|
||||
}: {
|
||||
treeId: string;
|
||||
root: TreeNode<T>;
|
||||
selectableItems: SelectableTreeNode<T>[];
|
||||
} & Pick<TreeProps<T>, 'getItemKey' | 'ItemInner' | 'ItemLeftSlot'>) {
|
||||
const draggingItems = useAtomValue(draggingIdsFamily(treeId));
|
||||
return (
|
||||
<DragOverlay dropAnimation={null}>
|
||||
<TreeItemList
|
||||
treeId={treeId + '.dragging'}
|
||||
node={{
|
||||
item: { ...root.item, id: `${root.item.id}_dragging` },
|
||||
parent: null,
|
||||
children: draggingItems.map((id) => {
|
||||
const child = selectableItems.find((i2) => {
|
||||
return i2.node.item.id === id;
|
||||
})!.node;
|
||||
return { ...child, children: undefined };
|
||||
// Remove children so we don't render them in the drag preview
|
||||
}),
|
||||
}}
|
||||
getItemKey={getItemKey}
|
||||
ItemInner={ItemInner}
|
||||
ItemLeftSlot={ItemLeftSlot}
|
||||
depth={0}
|
||||
/>
|
||||
</DragOverlay>
|
||||
);
|
||||
}
|
||||
273
src-web/components/core/tree/TreeItem.tsx
Normal file
273
src-web/components/core/tree/TreeItem.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
import type { DragMoveEvent } from '@dnd-kit/core';
|
||||
import { useDndMonitor, useDraggable, useDroppable } from '@dnd-kit/core';
|
||||
import classNames from 'classnames';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import type { MouseEvent, PointerEvent } from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { jotaiStore } from '../../../lib/jotai';
|
||||
import type { ContextMenuProps, DropdownItem } from '../Dropdown';
|
||||
import { ContextMenu } from '../Dropdown';
|
||||
import { Icon } from '../Icon';
|
||||
import {
|
||||
isCollapsedFamily,
|
||||
isLastFocusedFamily,
|
||||
isParentHoveredFamily,
|
||||
isSelectedFamily,
|
||||
} from './atoms';
|
||||
import type { TreeNode } from './common';
|
||||
import { computeSideForDragMove } from './common';
|
||||
import type { TreeProps } from './Tree';
|
||||
|
||||
interface OnClickEvent {
|
||||
shiftKey: boolean;
|
||||
ctrlKey: boolean;
|
||||
metaKey: boolean;
|
||||
}
|
||||
|
||||
export type TreeItemProps<T extends { id: string }> = Pick<
|
||||
TreeProps<T>,
|
||||
'ItemInner' | 'ItemLeftSlot' | 'treeId' | 'getEditOptions'
|
||||
> & {
|
||||
node: TreeNode<T>;
|
||||
className?: string;
|
||||
onClick?: (item: T, e: OnClickEvent) => void;
|
||||
getContextMenu?: (item: T) => Promise<ContextMenuProps['items']>;
|
||||
};
|
||||
|
||||
const HOVER_CLOSED_FOLDER_DELAY = 800;
|
||||
|
||||
export function TreeItem<T extends { id: string }>({
|
||||
treeId,
|
||||
node,
|
||||
ItemInner,
|
||||
ItemLeftSlot,
|
||||
getContextMenu,
|
||||
onClick,
|
||||
getEditOptions,
|
||||
className,
|
||||
}: TreeItemProps<T>) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const draggableRef = useRef<HTMLButtonElement>(null);
|
||||
const isSelected = useAtomValue(isSelectedFamily({ treeId, itemId: node.item.id }));
|
||||
const isCollapsed = useAtomValue(isCollapsedFamily({ treeId, itemId: node.item.id }));
|
||||
const isHoveredAsParent = useAtomValue(isParentHoveredFamily({ treeId, parentId: node.item.id }));
|
||||
const isLastSelected = useAtomValue(isLastFocusedFamily({ treeId, itemId: node.item.id }));
|
||||
const [editing, setEditing] = useState<boolean>(false);
|
||||
const [isDropHover, setIsDropHover] = useState<boolean>(false);
|
||||
const startedHoverTimeout = useRef<NodeJS.Timeout>(undefined);
|
||||
|
||||
const [showContextMenu, setShowContextMenu] = useState<{
|
||||
items: DropdownItem[];
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(
|
||||
function scrollIntoViewWhenSelected() {
|
||||
return jotaiStore.sub(isSelectedFamily({ treeId, itemId: node.item.id }), () => {
|
||||
ref.current?.scrollIntoView({ block: 'nearest' });
|
||||
});
|
||||
},
|
||||
[node.item.id, treeId],
|
||||
);
|
||||
|
||||
const handleClick = useCallback(
|
||||
function handleClick(e: MouseEvent<HTMLButtonElement>) {
|
||||
onClick?.(node.item, e);
|
||||
},
|
||||
[node, onClick],
|
||||
);
|
||||
|
||||
const toggleCollapsed = useCallback(
|
||||
function toggleCollapsed() {
|
||||
jotaiStore.set(isCollapsedFamily({ treeId, itemId: node.item.id }), (prev) => !prev);
|
||||
},
|
||||
[node.item.id, treeId],
|
||||
);
|
||||
|
||||
const handleSubmitNameEdit = useCallback(
|
||||
async function submitNameEdit(el: HTMLInputElement) {
|
||||
getEditOptions?.(node.item).onChange(node.item, el.value);
|
||||
// Slight delay for the model to propagate to the local store
|
||||
setTimeout(() => setEditing(false), 200);
|
||||
},
|
||||
[getEditOptions, node.item],
|
||||
);
|
||||
|
||||
const handleEditFocus = useCallback(function handleEditFocus(el: HTMLInputElement | null) {
|
||||
el?.focus();
|
||||
el?.select();
|
||||
}, []);
|
||||
|
||||
const handleEditBlur = useCallback(
|
||||
async function editBlur(e: React.FocusEvent<HTMLInputElement>) {
|
||||
await handleSubmitNameEdit(e.currentTarget);
|
||||
},
|
||||
[handleSubmitNameEdit],
|
||||
);
|
||||
|
||||
const handleEditKeyDown = useCallback(
|
||||
async (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
e.stopPropagation();
|
||||
switch (e.key) {
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
await handleSubmitNameEdit(e.currentTarget);
|
||||
break;
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
setEditing(false);
|
||||
break;
|
||||
}
|
||||
},
|
||||
[handleSubmitNameEdit],
|
||||
);
|
||||
|
||||
const handleDoubleClick = useCallback(() => {
|
||||
const isFolder = node.children != null;
|
||||
if (isFolder) {
|
||||
toggleCollapsed();
|
||||
} else if (getEditOptions != null) {
|
||||
setEditing(true);
|
||||
}
|
||||
}, [getEditOptions, node.children, toggleCollapsed]);
|
||||
|
||||
const clearHoverTimer = () => {
|
||||
if (startedHoverTimeout.current) {
|
||||
setIsDropHover(false); // NEW
|
||||
clearTimeout(startedHoverTimeout.current); // NEW
|
||||
startedHoverTimeout.current = undefined; // NEW
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle auto-expand of folders when hovering over them
|
||||
useDndMonitor({
|
||||
onDragMove(e: DragMoveEvent) {
|
||||
const side = computeSideForDragMove(node, e);
|
||||
const isFolderWithChildren = (node.children?.length ?? 0) > 0;
|
||||
const isCollapsed = jotaiStore.get(isCollapsedFamily({ treeId, itemId: node.item.id }));
|
||||
if (isCollapsed && isFolderWithChildren && side === 'below') {
|
||||
setIsDropHover(true);
|
||||
clearTimeout(startedHoverTimeout.current);
|
||||
startedHoverTimeout.current = setTimeout(() => {
|
||||
jotaiStore.set(isCollapsedFamily({ treeId, itemId: node.item.id }), false);
|
||||
setIsDropHover(false);
|
||||
}, HOVER_CLOSED_FOLDER_DELAY);
|
||||
} else {
|
||||
clearHoverTimer();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const handleContextMenu = useCallback(
|
||||
async (e: MouseEvent<HTMLDivElement>) => {
|
||||
if (getContextMenu == null) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const items = await getContextMenu(node.item);
|
||||
setShowContextMenu({ items, x: e.clientX, y: e.clientY });
|
||||
},
|
||||
[getContextMenu, node.item],
|
||||
);
|
||||
|
||||
const handleCloseContextMenu = useCallback(() => {
|
||||
setShowContextMenu(null);
|
||||
}, []);
|
||||
|
||||
const { attributes, listeners, setNodeRef: setDraggableRef } = useDraggable({ id: node.item.id });
|
||||
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],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
onContextMenu={handleContextMenu}
|
||||
className={classNames(
|
||||
className,
|
||||
'tree-item',
|
||||
isSelected && 'selected',
|
||||
'text-text-subtle',
|
||||
'h-sm grid grid-cols-[auto_minmax(0,1fr)] items-center rounded-md px-1.5',
|
||||
editing && 'ring-1 focus-within:ring-focus',
|
||||
isDropHover && 'relative z-10 ring-2 ring-primary animate-blinkRing',
|
||||
)}
|
||||
>
|
||||
{showContextMenu && (
|
||||
<ContextMenu
|
||||
items={showContextMenu.items}
|
||||
triggerPosition={showContextMenu}
|
||||
onClose={handleCloseContextMenu}
|
||||
/>
|
||||
)}
|
||||
{node.children != null ? (
|
||||
<button
|
||||
tabIndex={-1}
|
||||
className="h-full w-[2.8rem] pr-[0.5rem] -ml-[1rem]"
|
||||
onClick={toggleCollapsed}
|
||||
>
|
||||
<Icon
|
||||
icon="chevron_right"
|
||||
className={classNames(
|
||||
'transition-transform text-text-subtlest',
|
||||
'ml-auto !h-[1rem] !w-[1rem]',
|
||||
node.children.length == 0 && 'opacity-0',
|
||||
!isCollapsed && 'rotate-90',
|
||||
isHoveredAsParent && '!text-text',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<button
|
||||
ref={handleSetDraggableRef}
|
||||
onPointerDown={handlePointerDown}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
disabled={editing}
|
||||
className="focus:outline-none flex items-center gap-2 h-full whitespace-nowrap"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
tabIndex={isLastSelected ? 0 : -1}
|
||||
>
|
||||
{ItemLeftSlot != null && <ItemLeftSlot treeId={treeId} item={node.item} />}
|
||||
{getEditOptions != null && editing ? (
|
||||
(() => {
|
||||
const { defaultValue, placeholder } = getEditOptions(node.item);
|
||||
return (
|
||||
<input
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
131
src-web/components/core/tree/TreeItemList.tsx
Normal file
131
src-web/components/core/tree/TreeItemList.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import classNames from 'classnames';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { Fragment, memo } from 'react';
|
||||
import { DropMarker } from '../../DropMarker';
|
||||
import { isCollapsedFamily, isItemHoveredFamily, isParentHoveredFamily } from './atoms';
|
||||
import type { TreeNode } from './common';
|
||||
import { equalSubtree } from './common';
|
||||
import type { TreeProps } from './Tree';
|
||||
import type { TreeItemProps } from './TreeItem';
|
||||
import { TreeItem } from './TreeItem';
|
||||
|
||||
export type TreeItemListProps<T extends { id: string }> = Pick<
|
||||
TreeProps<T>,
|
||||
'ItemInner' | 'ItemLeftSlot' | 'treeId' | 'getItemKey' | 'getEditOptions'
|
||||
> &
|
||||
Pick<TreeItemProps<T>, 'onClick' | 'getContextMenu'> & {
|
||||
node: TreeNode<T>;
|
||||
depth: number;
|
||||
style?: CSSProperties;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function TreeItemList_<T extends { id: string }>({
|
||||
className,
|
||||
depth,
|
||||
getContextMenu,
|
||||
getEditOptions,
|
||||
getItemKey,
|
||||
node,
|
||||
onClick,
|
||||
ItemInner,
|
||||
ItemLeftSlot,
|
||||
style,
|
||||
treeId,
|
||||
}: TreeItemListProps<T>) {
|
||||
const isHovered = useAtomValue(isParentHoveredFamily({ treeId, parentId: node.item.id }));
|
||||
const isCollapsed = useAtomValue(isCollapsedFamily({ treeId, itemId: node.item.id }));
|
||||
const childList = !isCollapsed && node.children != null && (
|
||||
<ul
|
||||
style={style}
|
||||
className={classNames(
|
||||
className,
|
||||
depth > 0 && 'ml-[calc(1.2rem+0.5px)] pl-[0.7rem] border-l',
|
||||
isHovered ? 'border-l-text-subtle' : 'border-l-border-subtle',
|
||||
)}
|
||||
>
|
||||
{node.children.map(function mapChild(child, i) {
|
||||
return (
|
||||
<Fragment key={getItemKey(child.item)}>
|
||||
<TreeDropMarker treeId={treeId} parent={node} index={i} />
|
||||
<TreeItemList
|
||||
treeId={treeId}
|
||||
node={child}
|
||||
ItemInner={ItemInner}
|
||||
ItemLeftSlot={ItemLeftSlot}
|
||||
onClick={onClick}
|
||||
getEditOptions={getEditOptions}
|
||||
depth={depth + 1}
|
||||
getItemKey={getItemKey}
|
||||
getContextMenu={getContextMenu}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
<TreeDropMarker treeId={treeId} parent={node ?? null} index={node.children?.length ?? 0} />
|
||||
</ul>
|
||||
);
|
||||
|
||||
if (depth === 0) {
|
||||
return childList;
|
||||
}
|
||||
|
||||
return (
|
||||
<li>
|
||||
<TreeItem
|
||||
treeId={treeId}
|
||||
node={node}
|
||||
getContextMenu={getContextMenu}
|
||||
ItemInner={ItemInner}
|
||||
ItemLeftSlot={ItemLeftSlot}
|
||||
onClick={onClick}
|
||||
getEditOptions={getEditOptions}
|
||||
/>
|
||||
{childList}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export const TreeItemList = memo(
|
||||
TreeItemList_,
|
||||
({ 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) {
|
||||
// console.log('TreeItemList: ', nonEqualKeys);
|
||||
return false;
|
||||
}
|
||||
return equalSubtree(prevNode, nextNode, nextProps.getItemKey);
|
||||
},
|
||||
) as typeof TreeItemList_;
|
||||
|
||||
const TreeDropMarker = memo(function TreeDropMarker<T extends { id: string }>({
|
||||
className,
|
||||
treeId,
|
||||
parent,
|
||||
index,
|
||||
}: {
|
||||
treeId: string;
|
||||
parent: TreeNode<T> | null;
|
||||
index: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const isHovered = useAtomValue(isItemHoveredFamily({ treeId, parentId: parent?.item.id, index }));
|
||||
const isLastItem = parent?.children?.length === index;
|
||||
const isLastItemHovered = useAtomValue(
|
||||
isItemHoveredFamily({
|
||||
treeId,
|
||||
parentId: parent?.item.id,
|
||||
index: parent?.children?.length ?? 0,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!isHovered && !(isLastItem && isLastItemHovered)) return null;
|
||||
|
||||
return <DropMarker className={classNames(className)} />;
|
||||
});
|
||||
89
src-web/components/core/tree/atoms.ts
Normal file
89
src-web/components/core/tree/atoms.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { atom } from 'jotai';
|
||||
import { atomFamily, selectAtom } from 'jotai/utils';
|
||||
import { atomWithKVStorage } from '../../../lib/atoms/atomWithKVStorage';
|
||||
|
||||
// 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; parentId: string | null }>({ index: null, parentId: null });
|
||||
});
|
||||
|
||||
export const isParentHoveredFamily = atomFamily(
|
||||
({ treeId, parentId }: { treeId: string; parentId: string | null | undefined }) =>
|
||||
selectAtom(hoveredParentFamily(treeId), (v) => v.parentId === parentId, Object.is),
|
||||
(a, b) => a.treeId === b.treeId && a.parentId === b.parentId,
|
||||
);
|
||||
|
||||
export const isItemHoveredFamily = atomFamily(
|
||||
({
|
||||
treeId,
|
||||
parentId,
|
||||
index,
|
||||
}: {
|
||||
treeId: string;
|
||||
parentId: string | null | undefined;
|
||||
index: number | null;
|
||||
}) =>
|
||||
selectAtom(
|
||||
hoveredParentFamily(treeId),
|
||||
(v) => v.parentId === parentId && v.index === index,
|
||||
Object.is,
|
||||
),
|
||||
(a, b) => a.treeId === b.treeId && a.parentId === b.parentId && a.index === b.index,
|
||||
);
|
||||
|
||||
function kvKey(workspaceId: string | null) {
|
||||
return ['sidebar_collapsed', workspaceId ?? 'n/a'];
|
||||
}
|
||||
|
||||
export const collapsedFamily = atomFamily((workspaceId: string) => {
|
||||
return atomWithKVStorage<Record<string, boolean>>(kvKey(workspaceId), {});
|
||||
});
|
||||
|
||||
export const isCollapsedFamily = atomFamily(
|
||||
({ treeId, itemId }: { treeId: string; itemId: string }) =>
|
||||
atom(
|
||||
// --- getter ---
|
||||
(get) => !!get(collapsedFamily(treeId))[itemId],
|
||||
|
||||
// --- setter ---
|
||||
(get, set, next: boolean | ((prev: boolean) => boolean)) => {
|
||||
const a = collapsedFamily(treeId);
|
||||
const prevMap = get(a);
|
||||
const prevValue = !!prevMap[itemId];
|
||||
const value = typeof next === 'function' ? next(prevValue) : next;
|
||||
|
||||
if (value === prevValue) return; // no-op
|
||||
|
||||
set(a, { ...prevMap, [itemId]: value });
|
||||
},
|
||||
),
|
||||
(a, b) => a.treeId === b.treeId && a.itemId === b.itemId,
|
||||
);
|
||||
70
src-web/components/core/tree/common.ts
Normal file
70
src-web/components/core/tree/common.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { DragMoveEvent } from '@dnd-kit/core';
|
||||
import { jotaiStore } from '../../../lib/jotai';
|
||||
import { selectedIdsFamily } from './atoms';
|
||||
|
||||
export interface TreeNode<T extends { id: string }> {
|
||||
children?: TreeNode<T>[];
|
||||
item: T;
|
||||
parent: TreeNode<T> | null;
|
||||
}
|
||||
|
||||
export interface SelectableTreeNode<T extends { id: string }> {
|
||||
node: TreeNode<T>;
|
||||
depth: number;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export function getSelectedItems<T extends { id: string }>(
|
||||
treeId: string,
|
||||
selectableItems: SelectableTreeNode<T>[],
|
||||
) {
|
||||
const selectedItemIds = jotaiStore.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>,
|
||||
getKey: (t: T) => string,
|
||||
): boolean {
|
||||
if (getKey(a.item) !== getKey(b.item)) 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++) {
|
||||
if (!equalSubtree(ak[i]!, bk[i]!, getKey)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function hasAncestor<T extends { id: string }>(node: TreeNode<T>, ancestorId: string) {
|
||||
// Check parents recursively
|
||||
if (node.parent == null) return false;
|
||||
if (node.parent.item.id === ancestorId) return true;
|
||||
return hasAncestor(node.parent, ancestorId);
|
||||
}
|
||||
|
||||
export function computeSideForDragMove<T extends { id: string }>(
|
||||
node: TreeNode<T>,
|
||||
e: DragMoveEvent,
|
||||
): 'above' | 'below' | null {
|
||||
if (e.over == null || e.over.id !== node.item.id) {
|
||||
return null;
|
||||
}
|
||||
if (e.active.rect.current.initial == null) return null;
|
||||
|
||||
const overRect = e.over.rect;
|
||||
const activeTop =
|
||||
e.active.rect.current.translated?.top ?? e.active.rect.current.initial.top + e.delta.y;
|
||||
const pointerY = activeTop + e.active.rect.current.initial.height / 2;
|
||||
|
||||
const hoverTop = overRect.top;
|
||||
const hoverBottom = overRect.bottom;
|
||||
const hoverMiddleY = (hoverBottom - hoverTop) / 2;
|
||||
const hoverClientY = pointerY - hoverTop;
|
||||
|
||||
return hoverClientY < hoverMiddleY ? 'above' : 'below';
|
||||
}
|
||||
8
src-web/components/core/tree/dnd.ts
Normal file
8
src-web/components/core/tree/dnd.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export enum ItemTypes {
|
||||
TREE_ITEM = 'tree.item',
|
||||
TREE = 'tree',
|
||||
}
|
||||
|
||||
export type DragItem = {
|
||||
id: string;
|
||||
};
|
||||
Reference in New Issue
Block a user