Add back creation items to context menu

This commit is contained in:
Gregory Schier
2025-10-19 08:52:03 -07:00
parent 07b90c6ae3
commit b11c72fde4
4 changed files with 229 additions and 152 deletions

View File

@@ -25,8 +25,9 @@ import { activeCookieJarAtom } from '../hooks/useActiveCookieJar';
import { activeEnvironmentAtom } from '../hooks/useActiveEnvironment';
import { activeFolderIdAtom } from '../hooks/useActiveFolderId';
import { activeRequestIdAtom } from '../hooks/useActiveRequestId';
import { activeWorkspaceAtom } from '../hooks/useActiveWorkspace';
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';
@@ -52,80 +53,9 @@ import { Tree } from './core/tree/Tree';
import type { TreeItemProps } from './core/tree/TreeItem';
import { GitDropdown } from './GitDropdown';
type Model = Workspace | Folder | HttpRequest | GrpcRequest | WebsocketRequest;
type SidebarModel = Workspace | Folder | HttpRequest | GrpcRequest | WebsocketRequest;
const opacitySubtle = 'opacity-80';
function getItemKey(item: Model) {
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: Model;
}) {
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 && opacitySubtle)}
request={item}
/>
);
}
});
const SidebarInnerItem = memo(function SidebarInnerItem({ item }: { treeId: string; item: Model }) {
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>
);
});
const OPACITY_SUBTLE = 'opacity-80';
function NewSidebar({ className }: { className?: string }) {
const [hidden, setHidden] = useSidebarHidden();
@@ -161,13 +91,13 @@ function NewSidebar({ className }: { className?: string }) {
children,
insertAt,
}: {
items: Model[];
parent: Model;
children: Model[];
items: SidebarModel[];
parent: SidebarModel;
children: SidebarModel[];
insertAt: number;
}) {
const prev = children[insertAt - 1] as Exclude<Model, Workspace>;
const next = children[insertAt] as Exclude<Model, Workspace>;
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;
@@ -248,8 +178,8 @@ const activeIdAtom = atom<string | null>((get) => {
});
function getEditOptions(
item: Model,
): ReturnType<NonNullable<TreeItemProps<Model>['getEditOptions']>> {
item: SidebarModel,
): ReturnType<NonNullable<TreeItemProps<SidebarModel>['getEditOptions']>> {
return {
onChange: handleSubmitEdit,
defaultValue: resolvedModelName(item),
@@ -257,18 +187,18 @@ function getEditOptions(
};
}
async function handleSubmitEdit(item: Model, text: string) {
async function handleSubmitEdit(item: SidebarModel, text: string) {
await patchModel(item, { name: text });
}
function handleActivate(item: Model) {
function handleActivate(item: SidebarModel) {
// TODO: Add folder layout support
if (item.model !== 'folder' && item.model !== 'workspace') {
navigateToRequestOrFolderOrWorkspace(item.id, item.model);
}
}
const allPotentialChildrenAtom = atom<Model[]>((get) => {
const allPotentialChildrenAtom = atom<SidebarModel[]>((get) => {
const requests = get(allRequestsAtom);
const folders = get(foldersAtom);
return [...requests, ...folders];
@@ -280,7 +210,7 @@ const sidebarTreeAtom = atom((get) => {
const allModels = get(memoAllPotentialChildrenAtom);
const activeWorkspace = get(activeWorkspaceAtom);
const childrenMap: Record<string, Exclude<Model, Workspace>[]> = {};
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] ?? [];
@@ -291,14 +221,14 @@ const sidebarTreeAtom = atom((get) => {
}
}
const treeParentMap: Record<string, TreeNode<Model>> = {};
const treeParentMap: Record<string, TreeNode<SidebarModel>> = {};
if (activeWorkspace == null) {
return null;
}
// Put requests and folders into a tree structure
const next = (node: TreeNode<Model>, depth: number): TreeNode<Model> => {
const next = (node: TreeNode<SidebarModel>, depth: number): TreeNode<SidebarModel> => {
const childItems = childrenMap[node.item.id] ?? [];
// Recurse to children
@@ -326,10 +256,10 @@ const sidebarTreeAtom = atom((get) => {
});
const actions = {
'sidebar.delete_selected_item': async function (items: Model[]) {
'sidebar.delete_selected_item': async function (items: SidebarModel[]) {
await deleteModelWithConfirm(items);
},
'model.duplicate': async function (items: Model[]) {
'model.duplicate': async function (items: SidebarModel[]) {
if (items.length === 1) {
const item = items[0]!;
const newId = await duplicateModel(item);
@@ -338,22 +268,29 @@ const actions = {
await Promise.all(items.map(duplicateModel));
}
},
'request.send': async function (items: Model[]) {
'request.send': async function (items: SidebarModel[]) {
await Promise.all(
items.filter((i) => i.model === 'http_request').map((i) => sendAnyHttpRequest.mutate(i.id)),
);
},
} as const;
const hotkeys: TreeProps<Model>['hotkeys'] = {
const hotkeys: TreeProps<SidebarModel>['hotkeys'] = {
priority: 10, // So these ones take precedence over global hotkeys when the sidebar is focused
actions,
enable: () => isSidebarFocused(),
};
async function getContextMenu(items: Model[]): Promise<DropdownItem[]> {
async function getContextMenu(items: SidebarModel[]): Promise<DropdownItem[]> {
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
const child = items[0];
if (child == null) return [];
// No children means we're in the root
if (child == null) {
console.log('HELLO', child);
return getCreateDropdownItems({ workspaceId, activeRequest: null, folderId: null });
}
const workspaces = jotaiStore.get(workspacesAtom);
const onlyHttpRequests = items.every((i) => i.model === 'http_request');
@@ -411,7 +348,13 @@ async function getContextMenu(items: Model[]): Promise<DropdownItem[]> {
},
})),
];
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 },
@@ -455,6 +398,83 @@ async function getContextMenu(items: Model[]): Promise<DropdownItem[]> {
leftSlot: <Icon icon="trash" />,
onSelect: () => actions['sidebar.delete_selected_item'](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>
);
});

View File

@@ -9,14 +9,23 @@ import {
} from '@dnd-kit/core';
import { type } from '@tauri-apps/plugin-os';
import classNames from 'classnames';
import type { ComponentType, ReactElement, Ref, RefAttributes } from 'react';
import { forwardRef, memo, useCallback, useImperativeHandle, useMemo, useRef } from 'react';
import type { ComponentType, MouseEvent, ReactElement, Ref, RefAttributes } from 'react';
import React, {
forwardRef,
memo,
useCallback,
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 { computeSideForDragMove } from '../../../lib/dnd';
import { jotaiStore } from '../../../lib/jotai';
import type { ContextMenuProps } from '../Dropdown';
import type { ContextMenuProps, DropdownItem } from '../Dropdown';
import { ContextMenu } from '../Dropdown';
import {
collapsedFamily,
draggingIdsFamily,
@@ -73,6 +82,15 @@ function TreeInner<T extends { id: string }>(
) {
const treeRef = useRef<HTMLDivElement>(null);
const selectableItems = useSelectableItems(root);
const [showContextMenu, setShowContextMenu] = useState<{
items: DropdownItem[];
x: number;
y: number;
} | null>(null);
const handleCloseContextMenu = useCallback(() => {
setShowContextMenu(null);
}, []);
const tryFocus = useCallback(() => {
treeRef.current?.querySelector<HTMLButtonElement>('.tree-item button[tabindex="0"]')?.focus();
@@ -403,11 +421,30 @@ function TreeInner<T extends { id: string }>(
ItemLeftSlot,
};
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 (
<>
<TreeHotKeys treeId={treeId} hotkeys={hotkeys} selectableItems={selectableItems} />
{showContextMenu && (
<ContextMenu
items={showContextMenu.items}
triggerPosition={showContextMenu}
onClose={handleCloseContextMenu}
/>
)}
<DndContext
sensors={sensors}
collisionDetection={pointerWithin}
@@ -429,8 +466,6 @@ function TreeInner<T extends { id: string }>(
>
<div
className={classNames(
'[&_.tree-item-inner]:bg-surface',
'[&_.tree-item-selectable.selected]:text-text',
'[&:focus-within]:[&_.tree-item.selected]:bg-surface-active',
'[&:not(:focus-within)]:[&_.tree-item.selected]:bg-surface-highlight',
@@ -446,7 +481,7 @@ function TreeInner<T extends { id: string }>(
<TreeItemList nodes={selectableItems} treeId={treeId} {...treeItemListProps} />
</div>
{/* Assign root ID so we can reuse our same move/end logic */}
<DropRegionAfterList id={root.item.id} />
<DropRegionAfterList id={root.item.id} onContextMenu={handleContextMenu} />
</div>
<TreeDragOverlay
treeId={treeId}
@@ -476,9 +511,15 @@ export const Tree = memo(
},
) as typeof Tree_;
function DropRegionAfterList({ id }: { id: string }) {
function DropRegionAfterList({
id,
onContextMenu,
}: {
id: string;
onContextMenu?: (e: MouseEvent<HTMLDivElement>) => void;
}) {
const { setNodeRef } = useDroppable({ id });
return <div ref={setNodeRef} />;
return <div ref={setNodeRef} onContextMenu={onContextMenu} />;
}
interface TreeHotKeyProps<T extends { id: string }> extends HotKeyOptions {

View File

@@ -242,7 +242,6 @@ function TreeItem_<T extends { id: string }>({
<TreeIndentGuide treeId={treeId} depth={depth} />
<div
className={classNames(
'tree-item-selectable',
'text-text-subtle',
'grid grid-cols-[auto_minmax(0,1fr)] items-center rounded-md',
)}

View File

@@ -1,3 +1,5 @@
import type { HttpRequest, WebsocketRequest } from '@yaakapp-internal/models';
import type { GrpcRequest } from '@yaakapp-internal/sync';
import { useAtomValue } from 'jotai';
import { useMemo } from 'react';
import { createFolder } from '../commands/commands';
@@ -5,7 +7,6 @@ import type { DropdownItem } from '../components/core/Dropdown';
import { Icon } from '../components/core/Icon';
import { createRequestAndNavigate } from '../lib/createRequestAndNavigate';
import { generateId } from '../lib/generateId';
import { jotaiStore } from '../lib/jotai';
import { BODY_TYPE_GRAPHQL } from '../lib/model_util';
import { activeRequestAtom } from './useActiveRequest';
import { activeWorkspaceIdAtom } from './useActiveWorkspace';
@@ -13,62 +14,78 @@ import { activeWorkspaceIdAtom } from './useActiveWorkspace';
export function useCreateDropdownItems({
hideFolder,
hideIcons,
folderId: folderIdOption,
folderId,
}: {
hideFolder?: boolean;
hideIcons?: boolean;
folderId?: string | null | 'active-folder';
} = {}): DropdownItem[] {
const workspaceId = useAtomValue(activeWorkspaceIdAtom);
const activeRequest = useAtomValue(activeRequestAtom);
const items = useMemo((): DropdownItem[] => {
const activeRequest = jotaiStore.get(activeRequestAtom);
const folderId =
(folderIdOption === 'active-folder' ? activeRequest?.folderId : folderIdOption) ?? null;
if (workspaceId == null) return [];
return [
{
label: 'HTTP',
leftSlot: hideIcons ? undefined : <Icon icon="plus" />,
onSelect: () => createRequestAndNavigate({ model: 'http_request', workspaceId, folderId }),
},
{
label: 'GraphQL',
leftSlot: hideIcons ? undefined : <Icon icon="plus" />,
onSelect: () =>
createRequestAndNavigate({
model: 'http_request',
workspaceId,
folderId,
bodyType: BODY_TYPE_GRAPHQL,
method: 'POST',
headers: [{ name: 'Content-Type', value: 'application/json', id: generateId() }],
}),
},
{
label: 'gRPC',
leftSlot: hideIcons ? undefined : <Icon icon="plus" />,
onSelect: () => createRequestAndNavigate({ model: 'grpc_request', workspaceId, folderId }),
},
{
label: 'WebSocket',
leftSlot: hideIcons ? undefined : <Icon icon="plus" />,
onSelect: () =>
createRequestAndNavigate({ model: 'websocket_request', workspaceId, folderId }),
},
...((hideFolder
? []
: [
{ type: 'separator' },
{
label: 'Folder',
leftSlot: hideIcons ? undefined : <Icon icon="plus" />,
onSelect: () => createFolder.mutate({ folderId }),
},
]) as DropdownItem[]),
];
}, [folderIdOption, hideFolder, hideIcons, workspaceId]);
return getCreateDropdownItems({ hideFolder, hideIcons, folderId, activeRequest, workspaceId });
}, [activeRequest, folderId, hideFolder, hideIcons, workspaceId]);
return items;
}
export function getCreateDropdownItems({
hideFolder,
hideIcons,
folderId: folderIdOption,
workspaceId,
activeRequest,
}: {
hideFolder?: boolean;
hideIcons?: boolean;
folderId?: string | null | 'active-folder';
workspaceId: string | null;
activeRequest: HttpRequest | GrpcRequest | WebsocketRequest | null;
}): DropdownItem[] {
const folderId =
(folderIdOption === 'active-folder' ? activeRequest?.folderId : folderIdOption) ?? null;
if (workspaceId == null) return [];
return [
{
label: 'HTTP',
leftSlot: hideIcons ? undefined : <Icon icon="plus" />,
onSelect: () => createRequestAndNavigate({ model: 'http_request', workspaceId, folderId }),
},
{
label: 'GraphQL',
leftSlot: hideIcons ? undefined : <Icon icon="plus" />,
onSelect: () =>
createRequestAndNavigate({
model: 'http_request',
workspaceId,
folderId,
bodyType: BODY_TYPE_GRAPHQL,
method: 'POST',
headers: [{ name: 'Content-Type', value: 'application/json', id: generateId() }],
}),
},
{
label: 'gRPC',
leftSlot: hideIcons ? undefined : <Icon icon="plus" />,
onSelect: () => createRequestAndNavigate({ model: 'grpc_request', workspaceId, folderId }),
},
{
label: 'WebSocket',
leftSlot: hideIcons ? undefined : <Icon icon="plus" />,
onSelect: () =>
createRequestAndNavigate({ model: 'websocket_request', workspaceId, folderId }),
},
...((hideFolder
? []
: [
{ type: 'separator' },
{
label: 'Folder',
leftSlot: hideIcons ? undefined : <Icon icon="plus" />,
onSelect: () => createFolder.mutate({ folderId }),
},
]) as DropdownItem[]),
];
}