Files
yaak/src-web/components/Sidebar.tsx
2025-01-04 07:01:31 -08:00

368 lines
12 KiB
TypeScript

import { useNavigate } from '@tanstack/react-router';
import type { AnyModel, Folder, GrpcRequest, HttpRequest } from '@yaakapp-internal/models';
import classNames from 'classnames';
import { useAtom, useAtomValue } from 'jotai';
import React, { useCallback, useRef, useState } from 'react';
import { useKey, useKeyPressEvent } from 'react-use';
import { getActiveRequest } from '../hooks/useActiveRequest';
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
import { useCreateDropdownItems } from '../hooks/useCreateDropdownItems';
import { useGrpcConnections } from '../hooks/useGrpcConnections';
import { useHotKey } from '../hooks/useHotKey';
import { useHttpResponses } from '../hooks/useHttpResponses';
import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { getSidebarCollapsedMap } from '../hooks/useSidebarItemCollapsed';
import { useUpdateAnyFolder } from '../hooks/useUpdateAnyFolder';
import { useUpdateAnyGrpcRequest } from '../hooks/useUpdateAnyGrpcRequest';
import { useUpdateAnyHttpRequest } from '../hooks/useUpdateAnyHttpRequest';
import { ContextMenu } from './core/Dropdown';
import { sidebarSelectedIdAtom, sidebarTreeAtom } from './SidebarAtoms';
import type { SidebarItemProps } from './SidebarItem';
import { SidebarItems } from './SidebarItems';
interface Props {
className?: string;
}
export interface SidebarTreeNode {
id: string;
name: string;
model: AnyModel['model'];
sortPriority?: number;
workspaceId?: string;
folderId?: string | null;
children: SidebarTreeNode[];
depth: number;
}
export type SidebarModel = Folder | GrpcRequest | HttpRequest;
export function Sidebar({ className }: Props) {
const [hidden, setHidden] = useSidebarHidden();
const sidebarRef = useRef<HTMLElement>(null);
const activeWorkspace = useActiveWorkspace();
const httpResponses = useHttpResponses();
const grpcConnections = useGrpcConnections();
const [hasFocus, setHasFocus] = useState<boolean>(false);
const [selectedId, setSelectedId] = useAtom(sidebarSelectedIdAtom);
const [selectedTree, setSelectedTree] = useState<SidebarTreeNode | null>(null);
const { mutateAsync: updateAnyHttpRequest } = useUpdateAnyHttpRequest();
const { mutateAsync: updateAnyGrpcRequest } = useUpdateAnyGrpcRequest();
const { mutateAsync: updateAnyFolder } = useUpdateAnyFolder();
const [draggingId, setDraggingId] = useState<string | null>(null);
const [hoveredTree, setHoveredTree] = useState<SidebarTreeNode | null>(null);
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const navigate = useNavigate();
const { tree, treeParentMap, selectableRequests } = useAtomValue(sidebarTreeAtom);
const focusActiveRequest = useCallback(
(
args: {
forced?: {
id: string;
tree: SidebarTreeNode;
};
noFocusSidebar?: boolean;
} = {},
) => {
const activeRequest = getActiveRequest();
const { forced, noFocusSidebar } = args;
const tree = forced?.tree ?? treeParentMap[activeRequest?.id ?? 'n/a'] ?? null;
const children = tree?.children ?? [];
const id = forced?.id ?? children.find((m) => m.id === activeRequest?.id)?.id ?? null;
setHasFocus(true);
setSelectedId(id);
setSelectedTree(tree);
if (id == null) {
return;
}
if (!noFocusSidebar) {
sidebarRef.current?.focus();
}
},
[setHasFocus, setSelectedId, treeParentMap],
);
const handleSelect = useCallback(
async (id: string) => {
const tree = treeParentMap[id ?? 'n/a'] ?? null;
const children = tree?.children ?? [];
const node = children.find((m) => m.id === id) ?? null;
if (node == null || tree == null || node.model === 'workspace') {
return;
}
if (node.model === 'http_request' || node.model === 'grpc_request') {
await navigate({
to: '/workspaces/$workspaceId/requests/$requestId',
params: {
requestId: id,
workspaceId: node.workspaceId ?? 'n/a',
},
search: (prev) => ({ ...prev }),
});
setHasFocus(true);
setSelectedId(id);
setSelectedTree(tree);
}
},
[treeParentMap, navigate, setSelectedId],
);
const handleClearSelected = useCallback(() => {
setSelectedId(null);
setSelectedTree(null);
}, [setSelectedId]);
const handleFocus = useCallback(() => {
if (hasFocus) return;
focusActiveRequest({ noFocusSidebar: true });
}, [focusActiveRequest, hasFocus]);
const handleBlur = useCallback(() => setHasFocus(false), [setHasFocus]);
useHotKey('sidebar.focus', async () => {
// Hide the sidebar if it's already focused
if (!hidden && hasFocus) {
await setHidden(true);
return;
}
// Show the sidebar if it's hidden
if (hidden) {
await setHidden(false);
}
// Select 0th index on focus if none selected
focusActiveRequest(
selectedTree != null && selectedId != null
? { forced: { id: selectedId, tree: selectedTree } }
: undefined,
);
});
useKeyPressEvent('Enter', async (e) => {
if (!hasFocus) return;
const selected = selectableRequests.find((r) => r.id === selectedId);
if (!selected || activeWorkspace == null) {
return;
}
e.preventDefault();
await navigate({
to: '/workspaces/$workspaceId/requests/$requestId',
params: {
requestId: selected.id,
workspaceId: activeWorkspace?.id ?? null,
},
search: (prev) => ({ ...prev }),
});
});
useKey(
'ArrowUp',
(e) => {
if (!hasFocus) return;
e.preventDefault();
const i = selectableRequests.findIndex((r) => r.id === selectedId);
const newSelectable = selectableRequests[i - 1];
if (newSelectable == null) {
return;
}
setSelectedId(newSelectable.id);
setSelectedTree(newSelectable.tree);
},
undefined,
[hasFocus, selectableRequests, selectedId, setSelectedId, setSelectedTree],
);
useKey(
'ArrowDown',
(e) => {
if (!hasFocus) return;
e.preventDefault();
const i = selectableRequests.findIndex((r) => r.id === selectedId);
const newSelectable = selectableRequests[i + 1];
if (newSelectable == null) {
return;
}
setSelectedId(newSelectable.id);
setSelectedTree(newSelectable.tree);
},
undefined,
[hasFocus, selectableRequests, selectedId, setSelectedId, setSelectedTree],
);
const handleMove = useCallback<SidebarItemProps['onMove']>(
async (id, side) => {
let hoveredTree = treeParentMap[id] ?? null;
const dragIndex = hoveredTree?.children.findIndex((n) => n.id === id) ?? -99;
const hoveredItem = hoveredTree?.children[dragIndex] ?? null;
let hoveredIndex = dragIndex + (side === 'above' ? 0 : 1);
const isHoveredItemCollapsed =
hoveredItem != null ? getSidebarCollapsedMap()[hoveredItem.id] : false;
if (hoveredItem?.model === 'folder' && side === 'below' && !isHoveredItemCollapsed) {
// Move into the folder if it's open and we're moving below it
hoveredTree = hoveredTree?.children.find((n) => n.id === id) ?? null;
hoveredIndex = 0;
}
setHoveredTree(hoveredTree);
setHoveredIndex(hoveredIndex);
},
[treeParentMap],
);
const handleDragStart = useCallback<SidebarItemProps['onDragStart']>((id: string) => {
setDraggingId(id);
}, []);
const handleEnd = useCallback<SidebarItemProps['onEnd']>(
async (itemId) => {
setHoveredTree(null);
handleClearSelected();
if (hoveredTree == null || hoveredIndex == null) {
return;
}
// Block dragging folder into itself
if (hoveredTree.id === itemId) {
return;
}
const parentTree = treeParentMap[itemId] ?? null;
const index = parentTree?.children.findIndex((n) => n.id === itemId) ?? -1;
const child = parentTree?.children[index ?? -1];
if (child == null || parentTree == null) return;
const movedToDifferentTree = hoveredTree.id !== parentTree.id;
const movedUpInSameTree = !movedToDifferentTree && hoveredIndex < index;
const newChildren = hoveredTree.children.filter((c) => c.id !== itemId);
if (movedToDifferentTree || movedUpInSameTree) {
// Moving up or into a new tree is simply inserting before the hovered item
newChildren.splice(hoveredIndex, 0, child);
} else {
// Moving down has to account for the fact that the original item will be removed
newChildren.splice(hoveredIndex - 1, 0, child);
}
const insertedIndex = newChildren.findIndex((c) => c.id === child.id);
const prev = newChildren[insertedIndex - 1];
const next = newChildren[insertedIndex + 1];
const beforePriority = prev?.sortPriority ?? 0;
const afterPriority = next?.sortPriority ?? 0;
const folderId = hoveredTree.model === 'folder' ? hoveredTree.id : null;
const shouldUpdateAll = afterPriority - beforePriority < 1;
if (shouldUpdateAll) {
await Promise.all(
newChildren.map((child, i) => {
const sortPriority = i * 1000;
if (child.model === 'folder') {
const updateFolder = (f: Folder) => ({ ...f, sortPriority, folderId });
return updateAnyFolder({ id: child.id, update: updateFolder });
} else if (child.model === 'grpc_request') {
const updateRequest = (r: GrpcRequest) => ({ ...r, sortPriority, folderId });
return updateAnyGrpcRequest({ id: child.id, update: updateRequest });
} else if (child.model === 'http_request') {
const updateRequest = (r: HttpRequest) => ({ ...r, sortPriority, folderId });
return updateAnyHttpRequest({ id: child.id, update: updateRequest });
}
}),
);
} else {
const sortPriority = afterPriority - (afterPriority - beforePriority) / 2;
if (child.model === 'folder') {
const updateFolder = (f: Folder) => ({ ...f, sortPriority, folderId });
await updateAnyFolder({ id: child.id, update: updateFolder });
} else if (child.model === 'grpc_request') {
const updateRequest = (r: GrpcRequest) => ({ ...r, sortPriority, folderId });
await updateAnyGrpcRequest({ id: child.id, update: updateRequest });
} else if (child.model === 'http_request') {
const updateRequest = (r: HttpRequest) => ({ ...r, sortPriority, folderId });
await updateAnyHttpRequest({ id: child.id, update: updateRequest });
}
}
setDraggingId(null);
},
[
handleClearSelected,
hoveredTree,
hoveredIndex,
treeParentMap,
updateAnyFolder,
updateAnyGrpcRequest,
updateAnyHttpRequest,
],
);
const [showMainContextMenu, setShowMainContextMenu] = useState<{
x: number;
y: number;
} | null>(null);
const handleMainContextMenu = useCallback((e: React.MouseEvent<HTMLElement>) => {
e.preventDefault();
e.stopPropagation();
setShowMainContextMenu({ x: e.clientX, y: e.clientY });
}, []);
const mainContextMenuItems = useCreateDropdownItems({ folderId: null });
// Not ready to render yet
if (tree == null) {
return null;
}
return (
<aside
aria-hidden={hidden ?? undefined}
ref={sidebarRef}
onFocus={handleFocus}
onBlur={handleBlur}
tabIndex={hidden ? -1 : 0}
onContextMenu={handleMainContextMenu}
data-focused={hasFocus}
className={classNames(
className,
// Style item selection color here, because it's very hard to do in an efficient
// way in the item itself (selection ID makes it hard)
hasFocus && '[&_[data-selected=true]]:bg-surface-active',
'h-full grid grid-rows-[minmax(0,1fr)_auto]',
)}
>
<div className="pb-3 overflow-x-visible overflow-y-scroll pt-2">
<ContextMenu
triggerPosition={showMainContextMenu}
items={mainContextMenuItems}
onClose={() => setShowMainContextMenu(null)}
/>
<SidebarItems
treeParentMap={treeParentMap}
selectedTree={selectedTree}
httpResponses={httpResponses}
grpcConnections={grpcConnections}
tree={tree}
draggingId={draggingId}
onSelect={handleSelect}
hoveredIndex={hoveredIndex}
hoveredTree={hoveredTree}
handleMove={handleMove}
handleEnd={handleEnd}
handleDragStart={handleDragStart}
/>
</div>
</aside>
);
}