mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-01-19 07:26:59 +01:00
498 lines
15 KiB
TypeScript
498 lines
15 KiB
TypeScript
import type {
|
|
Folder,
|
|
GrpcRequest,
|
|
HttpRequest,
|
|
WebsocketRequest,
|
|
Workspace,
|
|
} from '@yaakapp-internal/models';
|
|
import {
|
|
duplicateModel,
|
|
foldersAtom,
|
|
getModel,
|
|
grpcConnectionsAtom,
|
|
httpResponsesAtom,
|
|
patchModel,
|
|
websocketConnectionsAtom,
|
|
workspacesAtom,
|
|
} from '@yaakapp-internal/models';
|
|
import classNames from 'classnames';
|
|
import { atom, useAtomValue } from 'jotai';
|
|
import { selectAtom } from 'jotai/utils';
|
|
import React, { memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
|
import { moveToWorkspace } from '../commands/moveToWorkspace';
|
|
import { openFolderSettings } from '../commands/openFolderSettings';
|
|
import { activeCookieJarAtom } from '../hooks/useActiveCookieJar';
|
|
import { activeEnvironmentAtom } from '../hooks/useActiveEnvironment';
|
|
import { activeFolderIdAtom } from '../hooks/useActiveFolderId';
|
|
import { activeRequestIdAtom } from '../hooks/useActiveRequestId';
|
|
import { activeWorkspaceAtom, activeWorkspaceIdAtom } from '../hooks/useActiveWorkspace';
|
|
import { allRequestsAtom } from '../hooks/useAllRequests';
|
|
import { getCreateDropdownItems } from '../hooks/useCreateDropdownItems';
|
|
import { getGrpcRequestActions } from '../hooks/useGrpcRequestActions';
|
|
import { useHotKey } from '../hooks/useHotKey';
|
|
import { getHttpRequestActions } from '../hooks/useHttpRequestActions';
|
|
import { sendAnyHttpRequest } from '../hooks/useSendAnyHttpRequest';
|
|
import { useSidebarHidden } from '../hooks/useSidebarHidden';
|
|
import { deepEqualAtom } from '../lib/atoms';
|
|
import { deleteModelWithConfirm } from '../lib/deleteModelWithConfirm';
|
|
import { jotaiStore } from '../lib/jotai';
|
|
import { renameModelWithPrompt } from '../lib/renameModelWithPrompt';
|
|
import { resolvedModelName } from '../lib/resolvedModelName';
|
|
import { isSidebarFocused } from '../lib/scopes';
|
|
import { navigateToRequestOrFolderOrWorkspace } from '../lib/setWorkspaceSearchParams';
|
|
import { invokeCmd } from '../lib/tauri';
|
|
import type { ContextMenuProps, DropdownItem } from './core/Dropdown';
|
|
import { HttpMethodTag } from './core/HttpMethodTag';
|
|
import { HttpStatusTag } from './core/HttpStatusTag';
|
|
import { Icon } from './core/Icon';
|
|
import { LoadingIcon } from './core/LoadingIcon';
|
|
import { isSelectedFamily } 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 { GitDropdown } from './GitDropdown';
|
|
|
|
type SidebarModel = Workspace | Folder | HttpRequest | GrpcRequest | WebsocketRequest;
|
|
|
|
const OPACITY_SUBTLE = 'opacity-80';
|
|
|
|
function NewSidebar({ className }: { className?: string }) {
|
|
const [hidden, setHidden] = useSidebarHidden();
|
|
const tree = useAtomValue(sidebarTreeAtom);
|
|
const activeWorkspaceId = useAtomValue(activeWorkspaceAtom)?.id;
|
|
const treeId = 'tree.' + (activeWorkspaceId ?? 'unknown');
|
|
const wrapperRef = useRef<HTMLElement>(null);
|
|
const treeRef = useRef<TreeHandle>(null);
|
|
|
|
const focusActiveItem = useCallback(() => {
|
|
treeRef.current?.focus();
|
|
}, []);
|
|
|
|
useHotKey('sidebar.focus', async function focusHotkey() {
|
|
// Hide the sidebar if it's already focused
|
|
if (!hidden && isSidebarFocused()) {
|
|
await setHidden(true);
|
|
return;
|
|
}
|
|
|
|
// Show the sidebar if it's hidden
|
|
if (hidden) {
|
|
await setHidden(false);
|
|
}
|
|
|
|
// Select the 0th index on focus if none selected
|
|
focusActiveItem();
|
|
});
|
|
|
|
const handleDragEnd = useCallback(async function handleDragEnd({
|
|
items,
|
|
parent,
|
|
children,
|
|
insertAt,
|
|
}: {
|
|
items: SidebarModel[];
|
|
parent: SidebarModel;
|
|
children: SidebarModel[];
|
|
insertAt: number;
|
|
}) {
|
|
const prev = children[insertAt - 1] as Exclude<SidebarModel, Workspace>;
|
|
const next = children[insertAt] as Exclude<SidebarModel, Workspace>;
|
|
const folderId = parent.model === 'folder' ? parent.id : null;
|
|
|
|
const beforePriority = prev?.sortPriority ?? 0;
|
|
const afterPriority = next?.sortPriority ?? 0;
|
|
const shouldUpdateAll = afterPriority - beforePriority < 1;
|
|
|
|
try {
|
|
if (shouldUpdateAll) {
|
|
// Add items to children at insertAt
|
|
children.splice(insertAt, 0, ...items);
|
|
await Promise.all(
|
|
children.map((m, i) => patchModel(m, { sortPriority: i * 1000, folderId })),
|
|
);
|
|
} else {
|
|
const range = afterPriority - beforePriority;
|
|
const increment = range / (items.length + 2);
|
|
await Promise.all(
|
|
items.map((m, i) =>
|
|
// Spread item sortPriority out over before/after range
|
|
patchModel(m, { sortPriority: beforePriority + (i + 1) * increment, folderId }),
|
|
),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
}, []);
|
|
|
|
const handleTreeRefInit = useCallback((n: TreeHandle) => {
|
|
treeRef.current = n;
|
|
if (n == null) return;
|
|
const activeId = jotaiStore.get(activeIdAtom);
|
|
if (activeId == null) return;
|
|
n.selectItem(activeId);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
return jotaiStore.sub(activeIdAtom, () => {
|
|
const activeId = jotaiStore.get(activeIdAtom);
|
|
if (activeId == null) return;
|
|
treeRef.current?.selectItem(activeId);
|
|
});
|
|
}, []);
|
|
|
|
if (tree == null || hidden) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<aside
|
|
ref={wrapperRef}
|
|
aria-hidden={hidden ?? undefined}
|
|
className={classNames(className, 'h-full grid grid-rows-[minmax(0,1fr)_auto]')}
|
|
>
|
|
<Tree
|
|
ref={handleTreeRefInit}
|
|
root={tree}
|
|
treeId={treeId}
|
|
hotkeys={hotkeys}
|
|
getItemKey={getItemKey}
|
|
ItemInner={SidebarInnerItem}
|
|
ItemLeftSlot={SidebarLeftSlot}
|
|
getContextMenu={getContextMenu}
|
|
onActivate={handleActivate}
|
|
getEditOptions={getEditOptions}
|
|
className="pl-2 pr-3 pt-2 pb-2"
|
|
onDragEnd={handleDragEnd}
|
|
/>
|
|
<GitDropdown />
|
|
</aside>
|
|
);
|
|
}
|
|
|
|
export default NewSidebar;
|
|
|
|
const activeIdAtom = atom<string | null>((get) => {
|
|
return get(activeRequestIdAtom) || get(activeFolderIdAtom);
|
|
});
|
|
|
|
function getEditOptions(
|
|
item: SidebarModel,
|
|
): ReturnType<NonNullable<TreeItemProps<SidebarModel>['getEditOptions']>> {
|
|
return {
|
|
onChange: handleSubmitEdit,
|
|
defaultValue: resolvedModelName(item),
|
|
placeholder: item.name,
|
|
};
|
|
}
|
|
|
|
async function handleSubmitEdit(item: SidebarModel, text: string) {
|
|
await patchModel(item, { name: text });
|
|
}
|
|
|
|
function handleActivate(item: SidebarModel) {
|
|
// TODO: Add folder layout support
|
|
if (item.model !== 'folder' && item.model !== 'workspace') {
|
|
navigateToRequestOrFolderOrWorkspace(item.id, item.model);
|
|
}
|
|
}
|
|
|
|
const allPotentialChildrenAtom = atom<SidebarModel[]>((get) => {
|
|
const requests = get(allRequestsAtom);
|
|
const folders = get(foldersAtom);
|
|
return [...requests, ...folders];
|
|
});
|
|
|
|
const memoAllPotentialChildrenAtom = deepEqualAtom(allPotentialChildrenAtom);
|
|
|
|
const sidebarTreeAtom = atom((get) => {
|
|
const allModels = get(memoAllPotentialChildrenAtom);
|
|
const activeWorkspace = get(activeWorkspaceAtom);
|
|
|
|
const childrenMap: Record<string, Exclude<SidebarModel, Workspace>[]> = {};
|
|
for (const item of allModels) {
|
|
if ('folderId' in item && item.folderId == null) {
|
|
childrenMap[item.workspaceId] = childrenMap[item.workspaceId] ?? [];
|
|
childrenMap[item.workspaceId]!.push(item);
|
|
} else if ('folderId' in item && item.folderId != null) {
|
|
childrenMap[item.folderId] = childrenMap[item.folderId] ?? [];
|
|
childrenMap[item.folderId]!.push(item);
|
|
}
|
|
}
|
|
|
|
const treeParentMap: Record<string, TreeNode<SidebarModel>> = {};
|
|
|
|
if (activeWorkspace == null) {
|
|
return null;
|
|
}
|
|
|
|
// Put requests and folders into a tree structure
|
|
const next = (node: TreeNode<SidebarModel>, depth: number): TreeNode<SidebarModel> => {
|
|
const childItems = childrenMap[node.item.id] ?? [];
|
|
|
|
// Recurse to children
|
|
childItems.sort((a, b) => a.sortPriority - b.sortPriority);
|
|
if (node.item.model === 'folder' || node.item.model === 'workspace') {
|
|
node.children = node.children ?? [];
|
|
for (const item of childItems) {
|
|
treeParentMap[item.id] = node;
|
|
node.children.push(next({ item, parent: node, depth }, depth + 1));
|
|
}
|
|
}
|
|
|
|
return node;
|
|
};
|
|
|
|
return next(
|
|
{
|
|
item: activeWorkspace,
|
|
children: [],
|
|
parent: null,
|
|
depth: 0,
|
|
},
|
|
1,
|
|
);
|
|
});
|
|
|
|
const actions = {
|
|
'sidebar.selected.delete': {
|
|
enable: isSidebarFocused,
|
|
cb: async function (_: TreeHandle, items: SidebarModel[]) {
|
|
await deleteModelWithConfirm(items);
|
|
},
|
|
},
|
|
'sidebar.selected.rename': {
|
|
enable: isSidebarFocused,
|
|
allowDefault: true,
|
|
cb: async function (tree: TreeHandle, items: SidebarModel[]) {
|
|
const item = items[0];
|
|
if (items.length === 1 && item != null) {
|
|
tree.renameItem(item.id);
|
|
}
|
|
},
|
|
},
|
|
'sidebar.selected.duplicate': {
|
|
priority: 999,
|
|
enable: isSidebarFocused,
|
|
cb: async function (_: TreeHandle, items: SidebarModel[]) {
|
|
if (items.length === 1) {
|
|
const item = items[0]!;
|
|
const newId = await duplicateModel(item);
|
|
navigateToRequestOrFolderOrWorkspace(newId, item.model);
|
|
} else {
|
|
await Promise.all(items.map(duplicateModel));
|
|
}
|
|
},
|
|
},
|
|
'request.send': {
|
|
enable: isSidebarFocused,
|
|
cb: async function (_: TreeHandle, items: SidebarModel[]) {
|
|
await Promise.all(
|
|
items.filter((i) => i.model === 'http_request').map((i) => sendAnyHttpRequest.mutate(i.id)),
|
|
);
|
|
},
|
|
},
|
|
} as const;
|
|
|
|
const hotkeys: TreeProps<SidebarModel>['hotkeys'] = { actions };
|
|
|
|
async function getContextMenu(tree: TreeHandle, items: SidebarModel[]): Promise<DropdownItem[]> {
|
|
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
|
const child = items[0];
|
|
|
|
// No children means we're in the root
|
|
if (child == null) {
|
|
return getCreateDropdownItems({ workspaceId, activeRequest: null, folderId: null });
|
|
}
|
|
|
|
const workspaces = jotaiStore.get(workspacesAtom);
|
|
const onlyHttpRequests = items.every((i) => i.model === 'http_request');
|
|
|
|
const initialItems: ContextMenuProps['items'] = [
|
|
{
|
|
label: 'Folder Settings',
|
|
hidden: !(items.length === 1 && child.model === 'folder'),
|
|
leftSlot: <Icon icon="folder_cog" />,
|
|
onSelect: () => openFolderSettings(child.id),
|
|
},
|
|
{
|
|
label: 'Send All',
|
|
hidden: !(items.length === 1 && child.model === 'folder'),
|
|
leftSlot: <Icon icon="send_horizontal" />,
|
|
onSelect: () => {
|
|
const environment = jotaiStore.get(activeEnvironmentAtom);
|
|
const cookieJar = jotaiStore.get(activeCookieJarAtom);
|
|
invokeCmd('cmd_send_folder', {
|
|
folderId: child.id,
|
|
environmentId: environment?.id,
|
|
cookieJarId: cookieJar?.id,
|
|
});
|
|
},
|
|
},
|
|
{
|
|
label: 'Send',
|
|
hotKeyAction: 'request.send',
|
|
hotKeyLabelOnly: true,
|
|
hidden: !onlyHttpRequests,
|
|
leftSlot: <Icon icon="send_horizontal" />,
|
|
onSelect: () => actions['request.send'].cb(tree, items),
|
|
},
|
|
...(items.length === 1 && child.model === 'http_request'
|
|
? await getHttpRequestActions()
|
|
: []
|
|
).map((a) => ({
|
|
label: a.label,
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
leftSlot: <Icon icon={(a.icon as any) ?? 'empty'} />,
|
|
onSelect: async () => {
|
|
const request = getModel('http_request', child.id);
|
|
if (request != null) await a.call(request);
|
|
},
|
|
})),
|
|
...(items.length === 1 && child.model === 'grpc_request'
|
|
? await getGrpcRequestActions()
|
|
: []
|
|
).map((a) => ({
|
|
label: a.label,
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
leftSlot: <Icon icon={(a.icon as any) ?? 'empty'} />,
|
|
onSelect: async () => {
|
|
const request = getModel('grpc_request', child.id);
|
|
if (request != null) await a.call(request);
|
|
},
|
|
})),
|
|
];
|
|
const modelCreationItems: DropdownItem[] =
|
|
items.length === 1 && child.model === 'folder'
|
|
? [
|
|
{ type: 'separator' },
|
|
...getCreateDropdownItems({ workspaceId, activeRequest: null, folderId: child.id }),
|
|
]
|
|
: [];
|
|
const menuItems: ContextMenuProps['items'] = [
|
|
...initialItems,
|
|
{ type: 'separator', hidden: initialItems.filter((v) => !v.hidden).length === 0 },
|
|
{
|
|
label: 'Rename',
|
|
leftSlot: <Icon icon="pencil" />,
|
|
hidden: items.length > 1,
|
|
hotKeyAction: 'sidebar.selected.rename',
|
|
hotKeyLabelOnly: true,
|
|
onSelect: async () => {
|
|
const request = getModel(
|
|
['folder', 'http_request', 'grpc_request', 'websocket_request'],
|
|
child.id,
|
|
);
|
|
await renameModelWithPrompt(request);
|
|
},
|
|
},
|
|
{
|
|
label: 'Duplicate',
|
|
hotKeyAction: 'model.duplicate',
|
|
hotKeyLabelOnly: true, // Would trigger for every request (bad)
|
|
leftSlot: <Icon icon="copy" />,
|
|
onSelect: () => actions['sidebar.selected.duplicate'].cb(tree, items),
|
|
},
|
|
{
|
|
label: 'Move',
|
|
leftSlot: <Icon icon="arrow_right_circle" />,
|
|
hidden:
|
|
workspaces.length <= 1 ||
|
|
items.length > 1 ||
|
|
child.model === 'folder' ||
|
|
child.model === 'workspace',
|
|
onSelect: () => {
|
|
if (child.model === 'folder' || child.model === 'workspace') return;
|
|
moveToWorkspace.mutate(child);
|
|
},
|
|
},
|
|
{
|
|
color: 'danger',
|
|
label: 'Delete',
|
|
hotKeyAction: 'sidebar.selected.delete',
|
|
hotKeyLabelOnly: true,
|
|
leftSlot: <Icon icon="trash" />,
|
|
onSelect: () => actions['sidebar.selected.delete'].cb(tree, items),
|
|
},
|
|
...modelCreationItems,
|
|
];
|
|
return menuItems;
|
|
}
|
|
|
|
function getItemKey(item: SidebarModel) {
|
|
const responses = jotaiStore.get(httpResponsesAtom);
|
|
const latestResponse = responses.find((r) => r.requestId === item.id) ?? null;
|
|
const url = 'url' in item ? item.url : 'n/a';
|
|
const method = 'method' in item ? item.method : 'n/a';
|
|
return [
|
|
item.id,
|
|
item.name,
|
|
url,
|
|
method,
|
|
latestResponse?.elapsed,
|
|
latestResponse?.id ?? 'n/a',
|
|
].join('::');
|
|
}
|
|
|
|
const SidebarLeftSlot = memo(function SidebarLeftSlot({
|
|
treeId,
|
|
item,
|
|
}: {
|
|
treeId: string;
|
|
item: SidebarModel;
|
|
}) {
|
|
if (item.model === 'folder') {
|
|
return <Icon icon="folder" />;
|
|
} else if (item.model === 'workspace') {
|
|
return null;
|
|
} else {
|
|
const isSelected = jotaiStore.get(isSelectedFamily({ treeId, itemId: item.id }));
|
|
return (
|
|
<HttpMethodTag
|
|
short
|
|
className={classNames('text-xs', !isSelected && OPACITY_SUBTLE)}
|
|
request={item}
|
|
/>
|
|
);
|
|
}
|
|
});
|
|
|
|
const SidebarInnerItem = memo(function SidebarInnerItem({
|
|
item,
|
|
}: {
|
|
treeId: string;
|
|
item: SidebarModel;
|
|
}) {
|
|
const response = useAtomValue(
|
|
useMemo(
|
|
() =>
|
|
selectAtom(
|
|
atom((get) => [
|
|
...get(grpcConnectionsAtom),
|
|
...get(httpResponsesAtom),
|
|
...get(websocketConnectionsAtom),
|
|
]),
|
|
(responses) => responses.find((r) => r.requestId === item.id),
|
|
(a, b) => a?.state === b?.state && a?.id === b?.id, // Only update when the response state changes updated
|
|
),
|
|
[item.id],
|
|
),
|
|
);
|
|
|
|
return (
|
|
<div className="flex items-center gap-2 min-w-0 h-full w-full text-left">
|
|
<div className="truncate">{resolvedModelName(item)}</div>
|
|
{response != null && (
|
|
<div className="ml-auto">
|
|
{response.state !== 'closed' ? (
|
|
<LoadingIcon size="sm" className="text-text-subtlest" />
|
|
) : response.model === 'http_response' ? (
|
|
<HttpStatusTag short className="text-xs" response={response} />
|
|
) : null}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
});
|