From e818c349ccab10d0f5326259dbc0e73d144bdd16 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Mon, 5 Jan 2026 14:58:16 -0800 Subject: [PATCH] Add reorderable tabs with global persistence (#347) --- src-web/components/DropMarker.tsx | 15 +- src-web/components/GrpcRequestPane.tsx | 1 + src-web/components/HttpRequestPane.tsx | 1 + src-web/components/HttpResponsePane.tsx | 1 + src-web/components/WebsocketRequestPane.tsx | 1 + src-web/components/core/Dropdown.tsx | 13 +- src-web/components/core/PairEditor.tsx | 2 +- src-web/components/core/Tabs/Tabs.tsx | 459 ++++++++++++++---- src-web/components/core/tree/Tree.tsx | 8 +- .../components/core/tree/TreeDropMarker.tsx | 2 +- src-web/components/core/tree/TreeItem.tsx | 4 +- src-web/lib/dnd.ts | 37 +- 12 files changed, 426 insertions(+), 118 deletions(-) diff --git a/src-web/components/DropMarker.tsx b/src-web/components/DropMarker.tsx index 61c9aab5..18547781 100644 --- a/src-web/components/DropMarker.tsx +++ b/src-web/components/DropMarker.tsx @@ -5,19 +5,28 @@ import { memo } from 'react'; interface Props { className?: string; style?: CSSProperties; + orientation?: 'horizontal' | 'vertical'; } export const DropMarker = memo( - function DropMarker({ className, style }: Props) { + function DropMarker({ className, style, orientation = 'horizontal' }: Props) { return (
-
+
); }, diff --git a/src-web/components/GrpcRequestPane.tsx b/src-web/components/GrpcRequestPane.tsx index cce2e51b..c7a3d922 100644 --- a/src-web/components/GrpcRequestPane.tsx +++ b/src-web/components/GrpcRequestPane.tsx @@ -270,6 +270,7 @@ export function GrpcRequestPane({ onChangeValue={setActiveTab} tabs={tabs} tabListClassName="mt-1 !mb-1.5" + storageKey="grpc_request_tabs_order" > diff --git a/src-web/components/HttpResponsePane.tsx b/src-web/components/HttpResponsePane.tsx index c761ef23..82d1982b 100644 --- a/src-web/components/HttpResponsePane.tsx +++ b/src-web/components/HttpResponsePane.tsx @@ -211,6 +211,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) { label="Response" className="ml-3 mr-3 mb-3 min-h-0 flex-1" tabListClassName="mt-0.5" + storageKey="http_response_tabs_order" > diff --git a/src-web/components/WebsocketRequestPane.tsx b/src-web/components/WebsocketRequestPane.tsx index f9b3cc43..dd454488 100644 --- a/src-web/components/WebsocketRequestPane.tsx +++ b/src-web/components/WebsocketRequestPane.tsx @@ -234,6 +234,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque onChangeValue={setActiveTab} tabs={tabs} tabListClassName="mt-1 !mb-1.5" + storageKey="websocket_request_tabs_order" > diff --git a/src-web/components/core/Dropdown.tsx b/src-web/components/core/Dropdown.tsx index 18260b80..8dbdff26 100644 --- a/src-web/components/core/Dropdown.tsx +++ b/src-web/components/core/Dropdown.tsx @@ -177,18 +177,23 @@ export const Dropdown = forwardRef(function Dropdown const child = useMemo(() => { const existingChild = Children.only(children); + const originalOnClick = existingChild.props?.onClick; const props: HTMLAttributes & { ref: RefObject } = { ...existingChild.props, ref: buttonRef, 'aria-haspopup': 'true', - onClick: - existingChild.props?.onClick ?? - ((e: MouseEvent) => { + onClick: (e: MouseEvent) => { + // Call original onClick first if it exists + originalOnClick?.(e); + + // Only toggle dropdown if event wasn't prevented + if (!e.defaultPrevented) { e.preventDefault(); e.stopPropagation(); handleSetIsOpen((o) => !o); // Toggle dropdown - }), + } + }, }; return cloneElement(existingChild, props); }, [children, handleSetIsOpen]); diff --git a/src-web/components/core/PairEditor.tsx b/src-web/components/core/PairEditor.tsx index 3824c513..49b58202 100644 --- a/src-web/components/core/PairEditor.tsx +++ b/src-web/components/core/PairEditor.tsx @@ -224,7 +224,7 @@ export function PairEditor({ const side = computeSideForDragMove(overPair.id, e); const overIndex = pairs.findIndex((p) => p.id === overId); - const hoveredIndex = overIndex + (side === 'above' ? 0 : 1); + const hoveredIndex = overIndex + (side === 'before' ? 0 : 1); setHoveredIndex(hoveredIndex); }, diff --git a/src-web/components/core/Tabs/Tabs.tsx b/src-web/components/core/Tabs/Tabs.tsx index 0b199e68..75e651a2 100644 --- a/src-web/components/core/Tabs/Tabs.tsx +++ b/src-web/components/core/Tabs/Tabs.tsx @@ -1,6 +1,20 @@ +import type { DragEndEvent, DragMoveEvent, DragStartEvent } from '@dnd-kit/core'; +import { + closestCenter, + DndContext, + DragOverlay, + PointerSensor, + useDraggable, + useDroppable, + useSensor, + useSensors, +} from '@dnd-kit/core'; import classNames from 'classnames'; import type { ReactNode } from 'react'; -import { memo, useEffect, useRef } from 'react'; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useKeyValue } from '../../../hooks/useKeyValue'; +import { computeSideForDragMove } from '../../../lib/dnd'; +import { DropMarker } from '../../DropMarker'; import { ErrorBoundary } from '../../ErrorBoundary'; import type { ButtonProps } from '../Button'; import { Button } from '../Button'; @@ -33,6 +47,7 @@ interface Props { children: ReactNode; addBorders?: boolean; layout?: 'horizontal' | 'vertical'; + storageKey?: string | string[]; } export function Tabs({ @@ -40,13 +55,62 @@ export function Tabs({ onChangeValue, label, children, - tabs, + tabs: originalTabs, className, tabListClassName, addBorders, layout = 'vertical', + storageKey, }: Props) { const ref = useRef(null); + const reorderable = !!storageKey; + + // Use key-value storage for persistence if storageKey is provided + const { value: savedOrder, set: setSavedOrder } = useKeyValue({ + namespace: 'global', + key: storageKey ?? ['tabs_order', 'default'], + fallback: [], + }); + + // State for ordered tabs + const [orderedTabs, setOrderedTabs] = useState(originalTabs); + const [isDragging, setIsDragging] = useState(null); + const [hoveredIndex, setHoveredIndex] = useState(null); + + // Reorder tabs based on saved order when tabs or savedOrder changes + useEffect(() => { + if (!storageKey || savedOrder == null || savedOrder.length === 0) { + setOrderedTabs(originalTabs); + return; + } + + // Create a map of tab values to tab items + const tabMap = new Map(originalTabs.map((tab) => [tab.value, tab])); + + // Reorder based on saved order, adding any new tabs at the end + const reordered: TabItem[] = []; + const seenValues = new Set(); + + // Add tabs in saved order + for (const value of savedOrder) { + const tab = tabMap.get(value); + if (tab) { + reordered.push(tab); + seenValues.add(value); + } + } + + // Add any new tabs that weren't in the saved order + for (const tab of originalTabs) { + if (!seenValues.has(tab.value)) { + reordered.push(tab); + } + } + + setOrderedTabs(reordered); + }, [originalTabs, savedOrder, storageKey]); + + const tabs = storageKey ? orderedTabs : originalTabs; value = value ?? tabs[0]?.value; @@ -70,6 +134,149 @@ export function Tabs({ } }, [value]); + // Drag and drop handlers + const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } })); + + const onDragStart = useCallback( + (e: DragStartEvent) => { + const tab = tabs.find((t) => t.value === e.active.id); + setIsDragging(tab ?? null); + }, + [tabs], + ); + + const onDragMove = useCallback( + (e: DragMoveEvent) => { + const overId = e.over?.id as string | undefined; + if (!overId) return setHoveredIndex(null); + + const overTab = tabs.find((t) => t.value === overId); + if (overTab == null) return setHoveredIndex(null); + + // For vertical layout, tabs are arranged horizontally (side-by-side) + const orientation = layout === 'vertical' ? 'horizontal' : 'vertical'; + const side = computeSideForDragMove(overTab.value, e, orientation); + + // If computeSideForDragMove returns null (shouldn't happen but be safe), default to null + if (side === null) return setHoveredIndex(null); + + const overIndex = tabs.findIndex((t) => t.value === overId); + const hoveredIndex = overIndex + (side === 'before' ? 0 : 1); + + setHoveredIndex(hoveredIndex); + }, + [tabs, layout], + ); + + const onDragCancel = useCallback(() => { + setIsDragging(null); + setHoveredIndex(null); + }, []); + + const onDragEnd = useCallback( + (e: DragEndEvent) => { + setIsDragging(null); + setHoveredIndex(null); + + const activeId = e.active.id as string | undefined; + const overId = e.over?.id as string | undefined; + if (!activeId || !overId || activeId === overId) return; + + const from = tabs.findIndex((t) => t.value === activeId); + const baseTo = tabs.findIndex((t) => t.value === overId); + const to = hoveredIndex ?? (baseTo === -1 ? from : baseTo); + + if (from !== -1 && to !== -1 && from !== to) { + const newTabs = [...tabs]; + const [moved] = newTabs.splice(from, 1); + if (moved === undefined) return; + newTabs.splice(to > from ? to - 1 : to, 0, moved); + + setOrderedTabs(newTabs); + + // Save order to storage + setSavedOrder(newTabs.map((t) => t.value)).catch(console.error); + } + }, + [tabs, hoveredIndex, setSavedOrder], + ); + + const tabButtons = useMemo(() => { + const items: ReactNode[] = []; + tabs.forEach((t, i) => { + if ('hidden' in t && t.hidden) { + return; + } + + const isActive = t.value === value; + const showDropMarkerBefore = hoveredIndex === i; + + if (showDropMarkerBefore) { + items.push( +
+ +
+ ); + } + + items.push( + + ); + }); + return items; + }, [tabs, value, addBorders, layout, reorderable, isDragging, onChangeValue, hoveredIndex]); + + const tabList = ( +
+
+ {tabButtons} + {hoveredIndex === tabs.length && ( +
+ +
+ )} +
+
+ ); + return (
-
-
- {tabs.map((t) => { - if ('hidden' in t && t.hidden) { - return null; - } - - const isActive = t.value === value; - - const btnProps: Partial = { - color: 'custom', - justify: layout === 'horizontal' ? 'start' : 'center', - onClick: isActive ? undefined : () => onChangeValue(t.value), - className: classNames( - 'flex items-center rounded whitespace-nowrap', - '!px-2 ml-[1px]', - 'outline-none', - 'ring-none', - 'focus-visible-or-class:outline-2', - addBorders && 'border focus-visible:bg-surface-highlight', - isActive ? 'text-text' : 'text-text-subtle', - isActive && addBorders - ? 'border-surface-active bg-surface-active' - : layout === 'vertical' - ? 'border-border-subtle' - : 'border-transparent', - layout === 'horizontal' && 'min-w-[10rem]', - ), - }; - - if ('options' in t) { - const option = t.options.items.find( - (i) => 'value' in i && i.value === t.options?.value, - ); - return ( - -
- } - {...btnProps} - > - {option && 'shortLabel' in option && option.shortLabel - ? option.shortLabel - : (option?.label ?? 'Unknown')} - - - ); - } - return ( - - ); - })} -
-
+ {tabList} + + {isDragging && ( + + )} + + + ) : ( + tabList + )} {children}
); } +interface TabButtonProps { + tab: TabItem; + isActive: boolean; + addBorders?: boolean; + layout: 'horizontal' | 'vertical'; + reorderable: boolean; + isDragging: boolean; + onChangeValue: (value: string) => void; + overlay?: boolean; +} + +function TabButton({ + tab, + isActive, + addBorders, + layout, + reorderable, + isDragging, + onChangeValue, + overlay = false, +}: TabButtonProps) { + const { + attributes, + listeners, + setNodeRef: setDraggableRef, + } = useDraggable({ + id: tab.value, + disabled: !reorderable, + }); + const { setNodeRef: setDroppableRef } = useDroppable({ + id: tab.value, + disabled: !reorderable, + }); + + const handleSetWrapperRef = useCallback( + (n: HTMLDivElement | null) => { + if (reorderable) { + setDraggableRef(n); + setDroppableRef(n); + } + }, + [reorderable, setDraggableRef, setDroppableRef], + ); + + const btnProps: Partial = { + color: 'custom', + justify: layout === 'horizontal' ? 'start' : 'center', + onClick: isActive ? undefined : () => onChangeValue(tab.value), + className: classNames( + 'flex items-center rounded whitespace-nowrap', + '!px-2 ml-[1px]', + 'outline-none', + 'ring-none', + 'focus-visible-or-class:outline-2', + addBorders && 'border focus-visible:bg-surface-highlight', + isActive ? 'text-text' : 'text-text-subtle', + isActive && addBorders + ? 'border-surface-active bg-surface-active' + : layout === 'vertical' + ? 'border-border-subtle' + : 'border-transparent', + layout === 'horizontal' && 'min-w-[10rem]', + isDragging && 'opacity-50', + overlay && 'opacity-80', + ), + }; + + const buttonContent = (() => { + if ('options' in tab) { + const option = tab.options.items.find((i) => 'value' in i && i.value === tab.options.value); + return ( + +
+ } + {...btnProps} + > + {option && 'shortLabel' in option && option.shortLabel + ? option.shortLabel + : (option?.label ?? 'Unknown')} + + + ); + } + return ( + + ); + })(); + + // Apply drag handlers to wrapper, not button + const wrapperProps = reorderable && !overlay ? { ...attributes, ...listeners } : {}; + + return ( +
+ {buttonContent} +
+ ); +} + interface TabContentProps { value: string; children: ReactNode; diff --git a/src-web/components/core/tree/Tree.tsx b/src-web/components/core/tree/Tree.tsx index 46d7b5fe..73b9254d 100644 --- a/src-web/components/core/tree/Tree.tsx +++ b/src-web/components/core/tree/Tree.tsx @@ -486,11 +486,11 @@ function TreeInner( let hoveredParent = node.parent; const dragIndex = selectableItems.findIndex((n) => n.node.item.id === item.id) ?? -1; const hovered = selectableItems[dragIndex]?.node ?? null; - const hoveredIndex = dragIndex + (side === 'above' ? 0 : 1); - let hoveredChildIndex = overSelectableItem.index + (side === 'above' ? 0 : 1); + const hoveredIndex = dragIndex + (side === 'before' ? 0 : 1); + let hoveredChildIndex = overSelectableItem.index + (side === 'before' ? 0 : 1); - // Move into the folder if it's open and we're moving below it - if (hovered?.children != null && side === 'below') { + // Move into the folder if it's open and we're moving after it + if (hovered?.children != null && side === 'after') { hoveredParent = hovered; hoveredChildIndex = 0; } diff --git a/src-web/components/core/tree/TreeDropMarker.tsx b/src-web/components/core/tree/TreeDropMarker.tsx index 21b7e9c7..71947940 100644 --- a/src-web/components/core/tree/TreeDropMarker.tsx +++ b/src-web/components/core/tree/TreeDropMarker.tsx @@ -29,7 +29,7 @@ export const TreeDropMarker = memo(function TreeDropMarker +
); diff --git a/src-web/components/core/tree/TreeItem.tsx b/src-web/components/core/tree/TreeItem.tsx index 6cec576c..c6d55849 100644 --- a/src-web/components/core/tree/TreeItem.tsx +++ b/src-web/components/core/tree/TreeItem.tsx @@ -208,7 +208,7 @@ function TreeItem_({ const isFolder = node.children != null; const hasChildren = (node.children?.length ?? 0) > 0; const isCollapsed = jotaiStore.get(isCollapsedFamily({ treeId, itemId: node.item.id })); - if (isCollapsed && isFolder && hasChildren && side === 'below') { + if (isCollapsed && isFolder && hasChildren && side === 'after') { setDropHover('animate'); clearTimeout(startedHoverTimeout.current); startedHoverTimeout.current = setTimeout(() => { @@ -221,7 +221,7 @@ function TreeItem_({ ); }); }, HOVER_CLOSED_FOLDER_DELAY); - } else if (isFolder && !hasChildren && side === 'below') { + } else if (isFolder && !hasChildren && side === 'after') { setDropHover('drop'); } else { clearDropHover(); diff --git a/src-web/lib/dnd.ts b/src-web/lib/dnd.ts index b0e90a14..8beb744f 100644 --- a/src-web/lib/dnd.ts +++ b/src-web/lib/dnd.ts @@ -1,20 +1,39 @@ import type { DragMoveEvent } from '@dnd-kit/core'; -export function computeSideForDragMove(id: string, e: DragMoveEvent): 'above' | 'below' | null { +export function computeSideForDragMove( + id: string, + e: DragMoveEvent, + orientation: 'vertical' | 'horizontal' = 'vertical', +): 'before' | 'after' | null { if (e.over == null || e.over.id !== 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; + if (orientation === 'horizontal') { + // For horizontal layouts (tabs side-by-side), use left/right logic + const activeLeft = + e.active.rect.current.translated?.left ?? e.active.rect.current.initial.left + e.delta.x; + const pointerX = activeLeft + e.active.rect.current.initial.width / 2; - return hoverClientY < hoverMiddleY ? 'above' : 'below'; + const hoverLeft = overRect.left; + const hoverRight = overRect.right; + const hoverMiddleX = hoverLeft + (hoverRight - hoverLeft) / 2; + + return pointerX < hoverMiddleX ? 'before' : 'after'; // 'before' = left, 'after' = right + } else { + // For vertical layouts, use top/bottom logic + 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 ? 'before' : 'after'; + } }