mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-21 08:59:07 +01:00
Decouple Tree from client app's hotkey system by adding getSelectedItems() to TreeHandle and having callers register hotkeys externally. Extract shared action callbacks to eliminate duplication between hotkey and context menu handlers. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
869 lines
28 KiB
TypeScript
869 lines
28 KiB
TypeScript
import type { Extension } from '@codemirror/state';
|
|
import { Compartment } from '@codemirror/state';
|
|
import { debounce } from '@yaakapp-internal/lib';
|
|
import type {
|
|
AnyModel,
|
|
Folder,
|
|
GrpcRequest,
|
|
HttpRequest,
|
|
ModelPayload,
|
|
WebsocketRequest,
|
|
Workspace,
|
|
} from '@yaakapp-internal/models';
|
|
import {
|
|
duplicateModel,
|
|
foldersAtom,
|
|
getAnyModel,
|
|
getModel,
|
|
grpcConnectionsAtom,
|
|
httpResponsesAtom,
|
|
patchModel,
|
|
websocketConnectionsAtom,
|
|
workspacesAtom,
|
|
} from '@yaakapp-internal/models';
|
|
import classNames from 'classnames';
|
|
import { atom, useAtomValue } from 'jotai';
|
|
import { atomFamily, selectAtom } from 'jotai/utils';
|
|
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
|
import { moveToWorkspace } from '../commands/moveToWorkspace';
|
|
import { openFolderSettings } from '../commands/openFolderSettings';
|
|
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 { getFolderActions } from '../hooks/useFolderActions';
|
|
import { getGrpcRequestActions } from '../hooks/useGrpcRequestActions';
|
|
import { useHotKey } from '../hooks/useHotKey';
|
|
import { getHttpRequestActions } from '../hooks/useHttpRequestActions';
|
|
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
|
|
import { getModelAncestors } from '../hooks/useModelAncestors';
|
|
import { sendAnyHttpRequest } from '../hooks/useSendAnyHttpRequest';
|
|
import { useSidebarHidden } from '../hooks/useSidebarHidden';
|
|
import { getWebsocketRequestActions } from '../hooks/useWebsocketRequestActions';
|
|
import { deepEqualAtom } from '../lib/atoms';
|
|
import { deleteModelWithConfirm } from '../lib/deleteModelWithConfirm';
|
|
import { jotaiStore } from '../lib/jotai';
|
|
import { resolvedModelName } from '../lib/resolvedModelName';
|
|
import { isSidebarFocused } from '../lib/scopes';
|
|
import { navigateToRequestOrFolderOrWorkspace } from '../lib/setWorkspaceSearchParams';
|
|
import type { ContextMenuProps, DropdownItem } from './core/Dropdown';
|
|
import { ContextMenu, Dropdown } from './core/Dropdown';
|
|
import type { FieldDef } from './core/Editor/filter/extension';
|
|
import { filter } from './core/Editor/filter/extension';
|
|
import { evaluate, parseQuery } from './core/Editor/filter/query';
|
|
import { HttpMethodTag } from './core/HttpMethodTag';
|
|
import { HttpStatusTag } from './core/HttpStatusTag';
|
|
import {
|
|
Icon,
|
|
LoadingIcon,
|
|
Tree,
|
|
isSelectedFamily,
|
|
selectedIdsFamily,
|
|
} from '@yaakapp-internal/ui';
|
|
import type { TreeNode, TreeHandle, TreeProps, TreeItemProps } from '@yaakapp-internal/ui';
|
|
import { IconButton } from './core/IconButton';
|
|
import { InlineCode } from './core/InlineCode';
|
|
import type { InputHandle } from './core/Input';
|
|
import { Input } from './core/Input';
|
|
import { atomWithKVStorage } from '../lib/atoms/atomWithKVStorage';
|
|
import { GitDropdown } from './git/GitDropdown';
|
|
|
|
const collapsedFamily = atomFamily((treeId: string) => {
|
|
const key = ['sidebar_collapsed', treeId ?? 'n/a'];
|
|
return atomWithKVStorage<Record<string, boolean>>(key, {});
|
|
});
|
|
|
|
type SidebarModel = Workspace | Folder | HttpRequest | GrpcRequest | WebsocketRequest;
|
|
function isSidebarLeafModel(m: AnyModel): boolean {
|
|
const modelMap: Record<Exclude<SidebarModel['model'], 'workspace'>, null> = {
|
|
http_request: null,
|
|
grpc_request: null,
|
|
websocket_request: null,
|
|
folder: null,
|
|
};
|
|
return m.model in modelMap;
|
|
}
|
|
|
|
const OPACITY_SUBTLE = 'opacity-80';
|
|
|
|
function Sidebar({ className }: { className?: string }) {
|
|
const [hidden, setHidden] = useSidebarHidden();
|
|
const activeWorkspaceId = useAtomValue(activeWorkspaceAtom)?.id;
|
|
const treeId = `tree.${activeWorkspaceId ?? 'unknown'}`;
|
|
const filterText = useAtomValue(sidebarFilterAtom);
|
|
const [tree, allFields] = useAtomValue(sidebarTreeAtom) ?? [];
|
|
const wrapperRef = useRef<HTMLElement>(null);
|
|
const treeRef = useRef<TreeHandle>(null);
|
|
const filterRef = useRef<InputHandle>(null);
|
|
const setFilterRef = useCallback((h: InputHandle | null) => {
|
|
filterRef.current = h;
|
|
}, []);
|
|
const allHidden = useMemo(() => {
|
|
if (tree?.children?.length === 0) return false;
|
|
if (filterText) return tree?.children?.every((c) => c.hidden);
|
|
return true;
|
|
}, [filterText, tree?.children]);
|
|
|
|
const focusActiveItem = useCallback(() => {
|
|
const didFocus = treeRef.current?.focus();
|
|
// If we weren't able to focus any items, focus the filter bar
|
|
if (!didFocus) filterRef.current?.focus();
|
|
}, []);
|
|
|
|
// Focus any new sidebar models when created
|
|
useListenToTauriEvent<ModelPayload>('model_write', ({ payload }) => {
|
|
if (!isSidebarLeafModel(payload.model)) return;
|
|
if (!(payload.change.type === 'upsert' && payload.change.created)) return;
|
|
treeRef.current?.selectItem(payload.model.id, true);
|
|
});
|
|
|
|
useEffect(() => {
|
|
return jotaiStore.sub(activeIdAtom, () => {
|
|
const activeId = jotaiStore.get(activeIdAtom);
|
|
if (activeId) {
|
|
treeRef.current?.selectItem(activeId, true);
|
|
}
|
|
});
|
|
}, []);
|
|
|
|
useHotKey(
|
|
'sidebar.filter',
|
|
() => {
|
|
filterRef.current?.focus();
|
|
},
|
|
{
|
|
enable: isSidebarFocused,
|
|
},
|
|
);
|
|
|
|
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
|
|
setTimeout(focusActiveItem, 100);
|
|
});
|
|
|
|
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;
|
|
const selectedIds = jotaiStore.get(selectedIdsFamily(treeId));
|
|
if (selectedIds.length > 0) return;
|
|
n.selectItem(activeId);
|
|
},
|
|
[treeId],
|
|
);
|
|
|
|
const clearFilterText = useCallback(() => {
|
|
jotaiStore.set(sidebarFilterAtom, { text: '', key: `${Math.random()}` });
|
|
requestAnimationFrame(() => {
|
|
filterRef.current?.focus();
|
|
});
|
|
}, []);
|
|
|
|
const handleFilterKeyDown = useCallback(
|
|
(e: KeyboardEvent) => {
|
|
e.stopPropagation(); // Don't trigger tree navigation hotkeys
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
clearFilterText();
|
|
}
|
|
},
|
|
[clearFilterText],
|
|
);
|
|
|
|
const handleFilterChange = useMemo(
|
|
() =>
|
|
debounce((text: string) => {
|
|
jotaiStore.set(sidebarFilterAtom, (prev) => ({ ...prev, text }));
|
|
}, 0),
|
|
[],
|
|
);
|
|
|
|
const treeHasFocus = useCallback(() => treeRef.current?.hasFocus() ?? false, []);
|
|
|
|
const getSelectedTreeModels = useCallback(
|
|
() => treeRef.current?.getSelectedItems() as SidebarModel[] | undefined,
|
|
[],
|
|
);
|
|
|
|
const handleRenameSelected = useCallback((items: SidebarModel[]) => {
|
|
if (items.length === 1 && items[0] != null) {
|
|
treeRef.current?.renameItem(items[0].id);
|
|
}
|
|
}, []);
|
|
|
|
const handleDeleteSelected = useCallback(
|
|
async (items: SidebarModel[]) => { await deleteModelWithConfirm(items); },
|
|
[],
|
|
);
|
|
|
|
const handleDuplicateSelected = useCallback(async (items: SidebarModel[]) => {
|
|
if (items.length === 1 && items[0]) {
|
|
const newId = await duplicateModel(items[0]);
|
|
navigateToRequestOrFolderOrWorkspace(newId, items[0].model);
|
|
} else {
|
|
await Promise.all(items.map(duplicateModel));
|
|
}
|
|
}, []);
|
|
|
|
const handleMoveSelected = useCallback((items: SidebarModel[]) => {
|
|
const requests = items.filter(
|
|
(i): i is HttpRequest | GrpcRequest | WebsocketRequest =>
|
|
i.model === 'http_request' || i.model === 'grpc_request' || i.model === 'websocket_request'
|
|
);
|
|
if (requests.length > 0) {
|
|
moveToWorkspace.mutate(requests);
|
|
}
|
|
}, []);
|
|
|
|
const handleSendSelected = useCallback(async (items: SidebarModel[]) => {
|
|
await Promise.all(
|
|
items
|
|
.filter((i) => i.model === 'http_request')
|
|
.map((i) => sendAnyHttpRequest.mutate(i.id)),
|
|
);
|
|
}, []);
|
|
|
|
useHotKey('sidebar.context_menu', useCallback(() => {
|
|
treeRef.current?.showContextMenu();
|
|
}, []), { enable: treeHasFocus });
|
|
|
|
useHotKey('sidebar.expand_all', useCallback(() => {
|
|
jotaiStore.set(collapsedFamily(treeId), {});
|
|
}, [treeId]), { enable: isSidebarFocused });
|
|
|
|
useHotKey('sidebar.collapse_all', useCallback(() => {
|
|
if (tree == null) return;
|
|
const next = (node: TreeNode<SidebarModel>, collapsed: Record<string, boolean>) => {
|
|
let newCollapsed = { ...collapsed };
|
|
for (const n of node.children ?? []) {
|
|
if (n.item.model !== 'folder') continue;
|
|
newCollapsed[n.item.id] = true;
|
|
newCollapsed = next(n, newCollapsed);
|
|
}
|
|
return newCollapsed;
|
|
};
|
|
const collapsed = next(tree, {});
|
|
jotaiStore.set(collapsedFamily(treeId), collapsed);
|
|
}, [tree, treeId]), { enable: isSidebarFocused });
|
|
|
|
useHotKey('sidebar.selected.delete', useCallback(() => {
|
|
const items = getSelectedTreeModels();
|
|
if (items) handleDeleteSelected(items);
|
|
}, [getSelectedTreeModels, handleDeleteSelected]), { enable: treeHasFocus });
|
|
|
|
useHotKey('sidebar.selected.rename', useCallback(() => {
|
|
const items = getSelectedTreeModels();
|
|
if (items) handleRenameSelected(items);
|
|
}, [getSelectedTreeModels, handleRenameSelected]), { enable: treeHasFocus, allowDefault: true });
|
|
|
|
useHotKey('sidebar.selected.duplicate', useCallback(async () => {
|
|
const items = getSelectedTreeModels();
|
|
if (items) await handleDuplicateSelected(items);
|
|
}, [getSelectedTreeModels, handleDuplicateSelected]), { priority: 10, enable: treeHasFocus });
|
|
|
|
useHotKey('sidebar.selected.move', useCallback(() => {
|
|
const items = getSelectedTreeModels();
|
|
if (items) handleMoveSelected(items);
|
|
}, [getSelectedTreeModels, handleMoveSelected]), { enable: treeHasFocus });
|
|
|
|
useHotKey('request.send', useCallback(async () => {
|
|
const items = getSelectedTreeModels();
|
|
if (items) await handleSendSelected(items);
|
|
}, [getSelectedTreeModels, handleSendSelected]), { enable: treeHasFocus });
|
|
|
|
const getContextMenu = useCallback<(items: SidebarModel[]) => Promise<DropdownItem[]>>(
|
|
async (items) => {
|
|
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 requestItems = items.filter(
|
|
(i) =>
|
|
i.model === 'http_request' || i.model === 'grpc_request' || i.model === 'websocket_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',
|
|
hotKeyAction: 'request.send',
|
|
hotKeyLabelOnly: true,
|
|
hidden: !onlyHttpRequests,
|
|
leftSlot: <Icon icon="send_horizontal" />,
|
|
onSelect: () => handleSendSelected(items),
|
|
},
|
|
...(items.length === 1 && child.model === 'http_request'
|
|
? await getHttpRequestActions()
|
|
: []
|
|
).map((a) => ({
|
|
label: a.label,
|
|
leftSlot: <Icon icon={a.icon ?? '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 ?? 'empty'} />,
|
|
onSelect: async () => {
|
|
const request = getModel('grpc_request', child.id);
|
|
if (request != null) await a.call(request);
|
|
},
|
|
})),
|
|
...(items.length === 1 && child.model === 'websocket_request'
|
|
? await getWebsocketRequestActions()
|
|
: []
|
|
).map((a) => ({
|
|
label: a.label,
|
|
leftSlot: <Icon icon={a.icon ?? 'empty'} />,
|
|
onSelect: async () => {
|
|
const request = getModel('websocket_request', child.id);
|
|
if (request != null) await a.call(request);
|
|
},
|
|
})),
|
|
...(items.length === 1 && child.model === 'folder' ? await getFolderActions() : []).map(
|
|
(a) => ({
|
|
label: a.label,
|
|
leftSlot: <Icon icon={a.icon ?? 'empty'} />,
|
|
onSelect: async () => {
|
|
const model = getModel('folder', child.id);
|
|
if (model != null) await a.call(model);
|
|
},
|
|
}),
|
|
),
|
|
];
|
|
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: () => handleRenameSelected(items),
|
|
},
|
|
{
|
|
label: 'Duplicate',
|
|
hotKeyAction: 'model.duplicate',
|
|
hotKeyLabelOnly: true, // Would trigger for every request (bad)
|
|
leftSlot: <Icon icon="copy" />,
|
|
onSelect: () => handleDuplicateSelected(items),
|
|
},
|
|
{
|
|
label: items.length <= 1 ? 'Move' : `Move ${requestItems.length} Requests`,
|
|
hotKeyAction: 'sidebar.selected.move',
|
|
hotKeyLabelOnly: true,
|
|
leftSlot: <Icon icon="arrow_right_circle" />,
|
|
hidden: workspaces.length <= 1 || requestItems.length === 0 || requestItems.length !== items.length,
|
|
onSelect: () => handleMoveSelected(items),
|
|
},
|
|
{
|
|
color: 'danger',
|
|
label: 'Delete',
|
|
hotKeyAction: 'sidebar.selected.delete',
|
|
hotKeyLabelOnly: true,
|
|
leftSlot: <Icon icon="trash" />,
|
|
onSelect: () => handleDeleteSelected(items),
|
|
},
|
|
...modelCreationItems,
|
|
];
|
|
return menuItems;
|
|
},
|
|
[],
|
|
);
|
|
|
|
const renderContextMenuFn = useCallback<NonNullable<TreeProps<SidebarModel>['renderContextMenu']>>(
|
|
({ items, position, onClose }) => (
|
|
<ContextMenu items={items as DropdownItem[]} triggerPosition={position} onClose={onClose} />
|
|
),
|
|
[],
|
|
);
|
|
|
|
// Use a language compartment for the filter so we can reconfigure it when the autocompletion changes
|
|
const filterLanguageCompartmentRef = useRef(new Compartment());
|
|
const filterCompartmentMountExtRef = useRef<Extension | null>(null);
|
|
if (filterCompartmentMountExtRef.current == null) {
|
|
filterCompartmentMountExtRef.current = filterLanguageCompartmentRef.current.of(
|
|
filter({ fields: allFields ?? [] }),
|
|
);
|
|
}
|
|
|
|
useEffect(() => {
|
|
const view = filterRef.current;
|
|
if (!view) return;
|
|
const ext = filter({ fields: allFields ?? [] });
|
|
view.dispatch({
|
|
effects: filterLanguageCompartmentRef.current.reconfigure(ext),
|
|
});
|
|
}, [allFields]);
|
|
|
|
if (tree == null || hidden) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<aside
|
|
ref={wrapperRef}
|
|
aria-hidden={hidden ?? undefined}
|
|
className={classNames(className, 'h-full grid grid-rows-[auto_minmax(0,1fr)_auto]')}
|
|
>
|
|
<div className="w-full pl-3 pr-0.5 pt-3 grid grid-cols-[minmax(0,1fr)_auto] items-center">
|
|
{(tree.children?.length ?? 0) > 0 && (
|
|
<>
|
|
<Input
|
|
hideLabel
|
|
setRef={setFilterRef}
|
|
size="sm"
|
|
label="filter"
|
|
language={null} // Explicitly disable
|
|
placeholder="Search"
|
|
onChange={handleFilterChange}
|
|
defaultValue={filterText.text}
|
|
forceUpdateKey={filterText.key}
|
|
onKeyDown={handleFilterKeyDown}
|
|
stateKey={null}
|
|
wrapLines={false}
|
|
extraExtensions={filterCompartmentMountExtRef.current ?? undefined}
|
|
rightSlot={
|
|
filterText.text && (
|
|
<IconButton
|
|
className="!bg-transparent !h-auto min-h-full opacity-50 hover:opacity-100 -mr-1"
|
|
icon="x"
|
|
title="Clear filter"
|
|
onClick={clearFilterText}
|
|
/>
|
|
)
|
|
}
|
|
/>
|
|
<Dropdown
|
|
items={[
|
|
{
|
|
label: 'Focus Active Request',
|
|
leftSlot: <Icon icon="crosshair" />,
|
|
onSelect: () => {
|
|
const activeId = jotaiStore.get(activeIdAtom);
|
|
if (activeId == null) return;
|
|
|
|
const folders = jotaiStore.get(foldersAtom);
|
|
const workspaces = jotaiStore.get(workspacesAtom);
|
|
const currentModel = getAnyModel(activeId);
|
|
const ancestors = getModelAncestors(folders, workspaces, currentModel);
|
|
jotaiStore.set(collapsedFamily(treeId), (prev) => {
|
|
const n = { ...prev };
|
|
for (const ancestor of ancestors) {
|
|
if (ancestor.model === 'folder') {
|
|
delete n[ancestor.id];
|
|
}
|
|
}
|
|
return n;
|
|
});
|
|
treeRef.current?.selectItem(activeId, false);
|
|
treeRef.current?.focus();
|
|
},
|
|
},
|
|
{
|
|
label: 'Expand All Folders',
|
|
leftSlot: <Icon icon="chevrons_up_down" />,
|
|
onSelect: () => jotaiStore.set(collapsedFamily(treeId), {}),
|
|
hotKeyAction: 'sidebar.expand_all',
|
|
hotKeyLabelOnly: true,
|
|
},
|
|
{
|
|
label: 'Collapse All Folders',
|
|
leftSlot: <Icon icon="chevrons_down_up" />,
|
|
onSelect: () => {
|
|
if (tree == null) return;
|
|
const next = (node: TreeNode<SidebarModel>, collapsed: Record<string, boolean>) => {
|
|
let newCollapsed = { ...collapsed };
|
|
for (const n of node.children ?? []) {
|
|
if (n.item.model !== 'folder') continue;
|
|
newCollapsed[n.item.id] = true;
|
|
newCollapsed = next(n, newCollapsed);
|
|
}
|
|
return newCollapsed;
|
|
};
|
|
jotaiStore.set(collapsedFamily(treeId), next(tree, {}));
|
|
},
|
|
hotKeyAction: 'sidebar.collapse_all',
|
|
hotKeyLabelOnly: true,
|
|
},
|
|
]}
|
|
>
|
|
<IconButton
|
|
size="xs"
|
|
className="ml-0.5 text-text-subtle hover:text-text"
|
|
icon="ellipsis_vertical"
|
|
title="Show sidebar actions menu"
|
|
/>
|
|
</Dropdown>
|
|
</>
|
|
)}
|
|
</div>
|
|
{allHidden ? (
|
|
<div className="italic text-text-subtle p-3 text-sm text-center">
|
|
No results for <InlineCode>{filterText.text}</InlineCode>
|
|
</div>
|
|
) : (
|
|
<Tree
|
|
ref={handleTreeRefInit}
|
|
root={tree}
|
|
treeId={treeId}
|
|
collapsedAtom={collapsedFamily(treeId)}
|
|
getItemKey={getItemKey}
|
|
ItemInner={SidebarInnerItem}
|
|
ItemLeftSlotInner={SidebarLeftSlot}
|
|
getContextMenu={getContextMenu}
|
|
renderContextMenu={renderContextMenuFn}
|
|
onActivate={handleActivate}
|
|
getEditOptions={getEditOptions}
|
|
className="pl-2 pr-3 pt-2 pb-2"
|
|
onDragEnd={handleDragEnd}
|
|
/>
|
|
)}
|
|
<GitDropdown />
|
|
</aside>
|
|
);
|
|
}
|
|
|
|
export default Sidebar;
|
|
|
|
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 sidebarFilterAtom = atom<{ text: string; key: string }>({
|
|
text: '',
|
|
key: '',
|
|
});
|
|
|
|
const sidebarTreeAtom = atom<[TreeNode<SidebarModel>, FieldDef[]] | null>((get) => {
|
|
const allModels = get(memoAllPotentialChildrenAtom);
|
|
const activeWorkspace = get(activeWorkspaceAtom);
|
|
const filter = get(sidebarFilterAtom);
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
if (activeWorkspace == null) {
|
|
return null;
|
|
}
|
|
|
|
const queryAst = parseQuery(filter.text);
|
|
|
|
// returns true if this node OR any child matches the filter
|
|
const allFields: Record<string, Set<string>> = {};
|
|
const build = (node: TreeNode<SidebarModel>, depth: number): boolean => {
|
|
const childItems = childrenMap[node.item.id] ?? [];
|
|
let matchesSelf = true;
|
|
const fields = getItemFields(node);
|
|
const model = node.item.model;
|
|
const isLeafNode = !(model === 'folder' || model === 'workspace');
|
|
|
|
for (const [field, value] of Object.entries(fields)) {
|
|
if (!value) continue;
|
|
allFields[field] = allFields[field] ?? new Set();
|
|
allFields[field].add(value);
|
|
}
|
|
|
|
if (queryAst != null) {
|
|
matchesSelf = isLeafNode && evaluate(queryAst, { text: getItemText(node.item), fields });
|
|
}
|
|
|
|
let matchesChild = false;
|
|
|
|
// Recurse to children
|
|
node.children = !isLeafNode ? [] : undefined;
|
|
|
|
if (node.children != null) {
|
|
childItems.sort((a, b) => {
|
|
if (a.sortPriority === b.sortPriority) {
|
|
return a.updatedAt > b.updatedAt ? 1 : -1;
|
|
}
|
|
return a.sortPriority - b.sortPriority;
|
|
});
|
|
|
|
for (const item of childItems) {
|
|
const childNode = { item, parent: node, depth };
|
|
const childMatches = build(childNode, depth + 1);
|
|
if (childMatches) {
|
|
matchesChild = true;
|
|
}
|
|
node.children.push(childNode);
|
|
}
|
|
}
|
|
|
|
// hide node IFF nothing in its subtree matches
|
|
const anyMatch = matchesSelf || matchesChild;
|
|
node.hidden = !anyMatch;
|
|
|
|
return anyMatch;
|
|
};
|
|
|
|
const root: TreeNode<SidebarModel> = {
|
|
item: activeWorkspace,
|
|
parent: null,
|
|
children: [],
|
|
depth: 0,
|
|
};
|
|
|
|
// Build tree and mark visibility in one pass
|
|
build(root, 1);
|
|
|
|
const fields: FieldDef[] = [];
|
|
for (const [name, values] of Object.entries(allFields)) {
|
|
fields.push({
|
|
name,
|
|
values: Array.from(values).filter((v) => v.length < 20),
|
|
});
|
|
}
|
|
return [root, fields] as const;
|
|
});
|
|
|
|
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';
|
|
const service = 'service' in item ? item.service : 'n/a';
|
|
return [
|
|
item.id,
|
|
item.name,
|
|
url,
|
|
method,
|
|
service,
|
|
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" />;
|
|
}
|
|
if (item.model === 'workspace') {
|
|
return null;
|
|
}
|
|
const isSelected = jotaiStore.get(isSelectedFamily({ treeId, itemId: item.id }));
|
|
return (
|
|
<HttpMethodTag
|
|
short
|
|
className={classNames('text-xs pl-1.5', !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>
|
|
);
|
|
});
|
|
|
|
function getItemFields(node: TreeNode<SidebarModel>): Record<string, string> {
|
|
const item = node.item;
|
|
|
|
if (item.model === 'workspace') return {};
|
|
|
|
const fields: Record<string, string> = {};
|
|
if (item.model === 'http_request') {
|
|
fields.method = item.method.toUpperCase();
|
|
}
|
|
|
|
if (item.model === 'grpc_request') {
|
|
fields.grpc_method = item.method ?? '';
|
|
fields.grpc_service = item.service ?? '';
|
|
}
|
|
|
|
if ('url' in item) fields.url = item.url;
|
|
fields.name = resolvedModelName(item);
|
|
|
|
fields.type = 'http';
|
|
if (item.model === 'grpc_request') fields.type = 'grpc';
|
|
else if (item.model === 'websocket_request') fields.type = 'ws';
|
|
|
|
if (node.parent?.item.model === 'folder') {
|
|
fields.folder = node.parent.item.name;
|
|
}
|
|
|
|
return fields;
|
|
}
|
|
|
|
function getItemText(item: SidebarModel): string {
|
|
const segments = [];
|
|
if (item.model === 'http_request') {
|
|
segments.push(item.method);
|
|
}
|
|
|
|
segments.push(resolvedModelName(item));
|
|
|
|
return segments.join(' ');
|
|
}
|