Consolidate tab persistence logic into Tabs component

- Move active tab persistence into Tabs component with storageKey + activeTabKey props
- Change value prop to defaultValue so callers don't manage tab state
- Add TabsRef with setActiveTab method for programmatic tab switching
- Restore request_pane.focus_tab listener for :param placeholder clicks
- Update all Tab consumers to use new pattern

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Gregory Schier
2026-01-14 10:32:10 -08:00
parent 3eb29ff2fe
commit 99209e088f
11 changed files with 170 additions and 135 deletions

View File

@@ -1,6 +1,6 @@
import { createWorkspaceModel, foldersAtom, patchModel } from '@yaakapp-internal/models';
import { useAtomValue } from 'jotai';
import { useMemo, useState } from 'react';
import { useMemo } from 'react';
import { useAuthTab } from '../hooks/useAuthTab';
import { useEnvironmentsBreakdown } from '../hooks/useEnvironmentsBreakdown';
import { useHeadersTab } from '../hooks/useHeadersTab';
@@ -37,7 +37,6 @@ export type FolderSettingsTab =
export function FolderSettingsDialog({ folderId, tab }: Props) {
const folders = useAtomValue(foldersAtom);
const folder = folders.find((f) => f.id === folderId) ?? null;
const [activeTab, setActiveTab] = useState<string>(tab ?? TAB_GENERAL);
const authTab = useAuthTab(TAB_AUTH, folder);
const headersTab = useHeadersTab(TAB_HEADERS, folder);
const inheritedHeaders = useInheritedHeaders(folder);
@@ -69,8 +68,7 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
return (
<Tabs
value={activeTab}
onChangeValue={setActiveTab}
defaultValue={tab ?? TAB_GENERAL}
label="Folder Settings"
className="pt-2 pb-2 pl-3 pr-1"
layout="horizontal"

View File

@@ -7,7 +7,6 @@ import { useContainerSize } from '../hooks/useContainerQuery';
import type { ReflectResponseService } from '../hooks/useGrpc';
import { useHeadersTab } from '../hooks/useHeadersTab';
import { useInheritedHeaders } from '../hooks/useInheritedHeaders';
import { useKeyValue } from '../hooks/useKeyValue';
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
import { resolvedModelName } from '../lib/resolvedModelName';
import { Button } from './core/Button';
@@ -69,11 +68,6 @@ export function GrpcRequestPane({
const authTab = useAuthTab(TAB_AUTH, activeRequest);
const metadataTab = useHeadersTab(TAB_METADATA, activeRequest, 'Metadata');
const inheritedHeaders = useInheritedHeaders(activeRequest);
const { value: activeTabs, set: setActiveTabs } = useKeyValue<Record<string, string>>({
namespace: 'no_sync',
key: 'grpcRequestActiveTabs',
fallback: {},
});
const forceUpdateKey = useRequestUpdateKey(activeRequest.id ?? null);
const urlContainerEl = useRef<HTMLDivElement>(null);
@@ -145,14 +139,6 @@ export function GrpcRequestPane({
[activeRequest.description, authTab, metadataTab],
);
const activeTab = activeTabs?.[activeRequest.id];
const setActiveTab = useCallback(
async (tab: string) => {
await setActiveTabs((r) => ({ ...r, [activeRequest.id]: tab }));
},
[activeRequest.id, setActiveTabs],
);
const handleMetadataChange = useCallback(
(metadata: HttpRequestHeader[]) => patchModel(activeRequest, { metadata }),
[activeRequest],
@@ -265,12 +251,11 @@ export function GrpcRequestPane({
</HStack>
</div>
<Tabs
value={activeTab}
label="Request"
onChangeValue={setActiveTab}
tabs={tabs}
tabListClassName="mt-1 !mb-1.5"
storageKey="grpc_request_tabs_order"
storageKey="grpc_request_tabs"
activeTabKey={activeRequest.id}
>
<TabContent value="message">
<GrpcEditor

View File

@@ -4,7 +4,7 @@ import type { GenericCompletionOption } from '@yaakapp-internal/plugins';
import classNames from 'classnames';
import { atom, useAtomValue } from 'jotai';
import type { CSSProperties } from 'react';
import { lazy, Suspense, useCallback, useMemo, useState } from 'react';
import { lazy, Suspense, useCallback, useMemo, useRef, useState } from 'react';
import { activeRequestIdAtom } from '../hooks/useActiveRequestId';
import { allRequestsAtom } from '../hooks/useAllRequests';
import { useAuthTab } from '../hooks/useAuthTab';
@@ -12,7 +12,6 @@ import { useCancelHttpResponse } from '../hooks/useCancelHttpResponse';
import { useHeadersTab } from '../hooks/useHeadersTab';
import { useImportCurl } from '../hooks/useImportCurl';
import { useInheritedHeaders } from '../hooks/useInheritedHeaders';
import { useKeyValue } from '../hooks/useKeyValue';
import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse';
import { useRequestEditor, useRequestEditorEvent } from '../hooks/useRequestEditor';
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
@@ -42,8 +41,8 @@ import { Editor } from './core/Editor/LazyEditor';
import { InlineCode } from './core/InlineCode';
import type { Pair } from './core/PairEditor';
import { PlainInput } from './core/PlainInput';
import type { TabItem } from './core/Tabs/Tabs';
import { TabContent, Tabs } from './core/Tabs/Tabs';
import type { TabItem, TabsRef } from './core/Tabs/Tabs';
import { setActiveTab, TabContent, Tabs } from './core/Tabs/Tabs';
import { EmptyStateText } from './EmptyStateText';
import { FormMultipartEditor } from './FormMultipartEditor';
import { FormUrlencodedEditor } from './FormUrlencodedEditor';
@@ -70,6 +69,7 @@ const TAB_PARAMS = 'params';
const TAB_HEADERS = 'headers';
const TAB_AUTH = 'auth';
const TAB_DESCRIPTION = 'description';
const TABS_STORAGE_KEY = 'http_request_tabs';
const nonActiveRequestUrlsAtom = atom((get) => {
const activeRequestId = get(activeRequestIdAtom);
@@ -83,19 +83,20 @@ const memoNotActiveRequestUrlsAtom = deepEqualAtom(nonActiveRequestUrlsAtom);
export function HttpRequestPane({ style, fullHeight, className, activeRequest }: Props) {
const activeRequestId = activeRequest.id;
const { value: activeTabs, set: setActiveTabs } = useKeyValue<Record<string, string>>({
namespace: 'no_sync',
key: 'httpRequestActiveTabs',
fallback: {},
});
const tabsRef = useRef<TabsRef>(null);
const [forceUpdateHeaderEditorKey, setForceUpdateHeaderEditorKey] = useState<number>(0);
const forceUpdateKey = useRequestUpdateKey(activeRequest.id ?? null);
const [{ urlKey }, { focusParamsTab, forceUrlRefresh, forceParamsRefresh }] = useRequestEditor();
const [{ urlKey }, { forceUrlRefresh, forceParamsRefresh }] = useRequestEditor();
const contentType = getContentTypeFromHeaders(activeRequest.headers);
const authTab = useAuthTab(TAB_AUTH, activeRequest);
const headersTab = useHeadersTab(TAB_HEADERS, activeRequest);
const inheritedHeaders = useInheritedHeaders(activeRequest);
// Listen for event to focus the params tab (e.g., when clicking a :param in the URL)
useRequestEditorEvent('request_pane.focus_tab', () => {
tabsRef.current?.setActiveTab(TAB_PARAMS);
}, []);
const handleContentTypeChange = useCallback(
async (contentType: string | null, patch: Partial<Omit<HttpRequest, 'headers'>> = {}) => {
if (activeRequest == null) {
@@ -260,18 +261,6 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
[activeRequest],
);
const activeTab = activeTabs?.[activeRequestId];
const setActiveTab = useCallback(
async (tab: string) => {
await setActiveTabs((r) => ({ ...r, [activeRequest.id]: tab }));
},
[activeRequest.id, setActiveTabs],
);
useRequestEditorEvent('request_pane.focus_tab', async () => {
await setActiveTab(TAB_PARAMS);
});
const autocompleteUrls = useAtomValue(memoNotActiveRequestUrlsAtom);
const autocomplete: GenericCompletionConfig = useMemo(
@@ -298,7 +287,11 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
e.preventDefault(); // Prevent input onChange
await patchModel(activeRequest, patch);
focusParamsTab();
await setActiveTab({
storageKey: TABS_STORAGE_KEY,
activeTabKey: activeRequestId,
value: TAB_PARAMS,
});
// Wait for request to update, then refresh the UI
// TODO: Somehow make this deterministic
@@ -309,14 +302,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
}
}
},
[
activeRequest,
activeRequestId,
focusParamsTab,
forceParamsRefresh,
forceUrlRefresh,
importCurl,
],
[activeRequest, activeRequestId, forceParamsRefresh, forceUrlRefresh, importCurl],
);
const handleSend = useCallback(
() => sendRequest(activeRequest.id ?? null),
@@ -354,12 +340,12 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
isLoading={activeResponse != null && activeResponse.state !== 'closed'}
/>
<Tabs
value={activeTab}
ref={tabsRef}
label="Request"
onChangeValue={setActiveTab}
tabs={tabs}
tabListClassName="mt-1 -mb-1.5"
storageKey="http_request_tabs_order"
storageKey={TABS_STORAGE_KEY}
activeTabKey={activeRequestId}
>
<TabContent value={TAB_AUTH}>
<HttpAuthenticationEditor model={activeRequest} />

View File

@@ -1,8 +1,7 @@
import type { HttpResponse } from '@yaakapp-internal/models';
import classNames from 'classnames';
import type { ComponentType, CSSProperties } from 'react';
import { lazy, Suspense, useCallback, useMemo } from 'react';
import { useLocalStorage } from 'react-use';
import { lazy, Suspense, useMemo } from 'react';
import { useCancelHttpResponse } from '../hooks/useCancelHttpResponse';
import { useHttpResponseEvents } from '../hooks/useHttpResponseEvents';
import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse';
@@ -58,10 +57,6 @@ const TAB_TIMELINE = 'timeline';
export function HttpResponsePane({ style, className, activeRequestId }: Props) {
const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId);
const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId);
const [activeTabs, setActiveTabs] = useLocalStorage<Record<string, string>>(
'responsePaneActiveTabs',
{},
);
const contentType = getContentTypeFromHeaders(activeResponse?.headers ?? null);
const mimeType = contentType == null ? null : getMimeTypeFromContentType(contentType).essence;
@@ -129,13 +124,6 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
viewMode,
],
);
const activeTab = activeTabs?.[activeRequestId];
const setActiveTab = useCallback(
(tab: string) => {
setActiveTabs((r) => ({ ...r, [activeRequestId]: tab }));
},
[activeRequestId, setActiveTabs],
);
const cancel = useCancelHttpResponse(activeResponse?.id ?? null);
@@ -199,14 +187,12 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
)}
{/* Show tabs if we have any data (headers, body, etc.) even if there's an error */}
<Tabs
key={activeRequestId} // Freshen tabs on request change
value={activeTab}
onChangeValue={setActiveTab}
tabs={tabs}
label="Response"
className="ml-3 mr-3 mb-3 min-h-0 flex-1"
tabListClassName="mt-0.5 -mb-1.5"
storageKey="http_response_tabs_order"
storageKey="http_response_tabs"
activeTabKey={activeRequestId}
>
<TabContent value={TAB_BODY}>
<ErrorBoundary name="Http Response Viewer">

View File

@@ -5,7 +5,6 @@ import { useLicense } from '@yaakapp-internal/license';
import { pluginsAtom, settingsAtom } from '@yaakapp-internal/models';
import classNames from 'classnames';
import { useAtomValue } from 'jotai';
import { useState } from 'react';
import { useKeyPressEvent } from 'react-use';
import { appInfo } from '../../lib/appInfo';
import { capitalize } from '../../lib/capitalize';
@@ -51,7 +50,6 @@ export default function Settings({ hide }: Props) {
const { tab: tabFromQuery } = useSearch({ from: '/workspaces/$workspaceId/settings' });
// Parse tab and subtab (e.g., "plugins:installed")
const [mainTab, subtab] = tabFromQuery?.split(':') ?? [];
const [tab, setTab] = useState<string | undefined>(mainTab || tabFromQuery);
const settings = useAtomValue(settingsAtom);
const plugins = useAtomValue(pluginsAtom);
const licenseCheck = useLicense();
@@ -91,11 +89,10 @@ export default function Settings({ hide }: Props) {
)}
<Tabs
layout="horizontal"
value={tab}
defaultValue={mainTab || tabFromQuery}
addBorders
tabListClassName="min-w-[10rem] bg-surface x-theme-sidebar border-r border-border pl-3"
label="Settings"
onChangeValue={setTab}
tabs={tabs.map(
(value): TabItem => ({
value,
@@ -145,7 +142,7 @@ export default function Settings({ hide }: Props) {
<SettingsHotkeys />
</TabContent>
<TabContent value={TAB_PLUGINS} className="h-full grid grid-rows-1 px-6 !py-4">
<SettingsPlugins defaultSubtab={tab === TAB_PLUGINS ? subtab : undefined} />
<SettingsPlugins defaultSubtab={mainTab === TAB_PLUGINS ? subtab : undefined} />
</TabContent>
<TabContent value={TAB_PROXY} className="overflow-y-auto h-full px-6 !py-4">
<SettingsProxy />

View File

@@ -54,13 +54,11 @@ export function SettingsPlugins({ defaultSubtab }: SettingsPluginsProps) {
const installedPlugins = plugins.filter((p) => !isPluginBundled(p, appInfo.vendoredPluginDir));
const createPlugin = useInstallPlugin();
const refreshPlugins = useRefreshPlugins();
const [tab, setTab] = useState<string | undefined>(defaultSubtab);
return (
<div className="h-full">
<Tabs
value={tab}
defaultValue={defaultSubtab}
label="Plugins"
onChangeValue={setTab}
addBorders
tabs={[
{ label: 'Discover', value: 'search' },

View File

@@ -5,7 +5,7 @@ import { closeWebsocket, connectWebsocket, sendWebsocket } from '@yaakapp-intern
import classNames from 'classnames';
import { atom, useAtomValue } from 'jotai';
import type { CSSProperties } from 'react';
import { useCallback, useMemo } from 'react';
import { useCallback, useMemo, useRef } from 'react';
import { getActiveCookieJar } from '../hooks/useActiveCookieJar';
import { getActiveEnvironment } from '../hooks/useActiveEnvironment';
import { activeRequestIdAtom } from '../hooks/useActiveRequestId';
@@ -14,7 +14,6 @@ import { useAuthTab } from '../hooks/useAuthTab';
import { useCancelHttpResponse } from '../hooks/useCancelHttpResponse';
import { useHeadersTab } from '../hooks/useHeadersTab';
import { useInheritedHeaders } from '../hooks/useInheritedHeaders';
import { useKeyValue } from '../hooks/useKeyValue';
import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse';
import { activeWebsocketConnectionAtom } from '../hooks/usePinnedWebsocketConnection';
import { useRequestEditor, useRequestEditorEvent } from '../hooks/useRequestEditor';
@@ -30,8 +29,8 @@ import { Editor } from './core/Editor/LazyEditor';
import { IconButton } from './core/IconButton';
import type { Pair } from './core/PairEditor';
import { PlainInput } from './core/PlainInput';
import type { TabItem } from './core/Tabs/Tabs';
import { TabContent, Tabs } from './core/Tabs/Tabs';
import type { TabItem, TabsRef } from './core/Tabs/Tabs';
import { setActiveTab, TabContent, Tabs } from './core/Tabs/Tabs';
import { HeadersEditor } from './HeadersEditor';
import { HttpAuthenticationEditor } from './HttpAuthenticationEditor';
import { MarkdownEditor } from './MarkdownEditor';
@@ -50,6 +49,7 @@ const TAB_PARAMS = 'params';
const TAB_HEADERS = 'headers';
const TAB_AUTH = 'auth';
const TAB_DESCRIPTION = 'description';
const TABS_STORAGE_KEY = 'websocket_request_tabs';
const nonActiveRequestUrlsAtom = atom((get) => {
const activeRequestId = get(activeRequestIdAtom);
@@ -63,17 +63,18 @@ const memoNotActiveRequestUrlsAtom = deepEqualAtom(nonActiveRequestUrlsAtom);
export function WebsocketRequestPane({ style, fullHeight, className, activeRequest }: Props) {
const activeRequestId = activeRequest.id;
const { value: activeTabs, set: setActiveTabs } = useKeyValue<Record<string, string>>({
namespace: 'no_sync',
key: 'websocketRequestActiveTabs',
fallback: {},
});
const tabsRef = useRef<TabsRef>(null);
const forceUpdateKey = useRequestUpdateKey(activeRequest.id);
const [{ urlKey }, { focusParamsTab, forceUrlRefresh, forceParamsRefresh }] = useRequestEditor();
const [{ urlKey }, { forceUrlRefresh, forceParamsRefresh }] = useRequestEditor();
const authTab = useAuthTab(TAB_AUTH, activeRequest);
const headersTab = useHeadersTab(TAB_HEADERS, activeRequest);
const inheritedHeaders = useInheritedHeaders(activeRequest);
// Listen for event to focus the params tab (e.g., when clicking a :param in the URL)
useRequestEditorEvent('request_pane.focus_tab', () => {
tabsRef.current?.setActiveTab(TAB_PARAMS);
}, []);
const { urlParameterPairs, urlParametersKey } = useMemo(() => {
const placeholderNames = Array.from(activeRequest.url.matchAll(/\/(:[^/]+)/g)).map(
(m) => m[1] ?? '',
@@ -115,18 +116,6 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null);
const connection = useAtomValue(activeWebsocketConnectionAtom);
const activeTab = activeTabs?.[activeRequestId];
const setActiveTab = useCallback(
async (tab: string) => {
await setActiveTabs((r) => ({ ...r, [activeRequest.id]: tab }));
},
[activeRequest.id, setActiveTabs],
);
useRequestEditorEvent('request_pane.focus_tab', async () => {
await setActiveTab(TAB_PARAMS);
});
const autocompleteUrls = useAtomValue(memoNotActiveRequestUrlsAtom);
const autocomplete: GenericCompletionConfig = useMemo(
@@ -176,7 +165,11 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
e.preventDefault(); // Prevent input onChange
await patchModel(activeRequest, patch);
focusParamsTab();
await setActiveTab({
storageKey: TABS_STORAGE_KEY,
activeTabKey: activeRequestId,
value: TAB_PARAMS,
});
// Wait for request to update, then refresh the UI
// TODO: Somehow make this deterministic
@@ -186,7 +179,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
}, 100);
}
},
[activeRequest, focusParamsTab, forceParamsRefresh, forceUrlRefresh],
[activeRequest, activeRequestId, forceParamsRefresh, forceUrlRefresh],
);
const messageLanguage = languageFromContentType(null, activeRequest.message);
@@ -229,12 +222,12 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
/>
</div>
<Tabs
value={activeTab}
ref={tabsRef}
label="Request"
onChangeValue={setActiveTab}
tabs={tabs}
tabListClassName="mt-1 !mb-1.5"
storageKey="websocket_request_tabs_order"
storageKey={TABS_STORAGE_KEY}
activeTabKey={activeRequestId}
>
<TabContent value={TAB_AUTH}>
<HttpAuthenticationEditor model={activeRequest} />

View File

@@ -1,6 +1,5 @@
import { patchModel, workspaceMetasAtom, workspacesAtom } from '@yaakapp-internal/models';
import { useAtomValue } from 'jotai';
import { useState } from 'react';
import { useAuthTab } from '../hooks/useAuthTab';
import { useHeadersTab } from '../hooks/useHeadersTab';
import { useInheritedHeaders } from '../hooks/useInheritedHeaders';
@@ -45,7 +44,6 @@ const DEFAULT_TAB: WorkspaceSettingsTab = TAB_GENERAL;
export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
const workspace = useAtomValue(workspacesAtom).find((w) => w.id === workspaceId);
const workspaceMeta = useAtomValue(workspaceMetasAtom).find((m) => m.workspaceId === workspaceId);
const [activeTab, setActiveTab] = useState<string>(tab ?? DEFAULT_TAB);
const authTab = useAuthTab(TAB_AUTH, workspace ?? null);
const headersTab = useHeadersTab(TAB_HEADERS, workspace ?? null);
const inheritedHeaders = useInheritedHeaders(workspace ?? null);
@@ -67,8 +65,7 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
return (
<Tabs
value={activeTab}
onChangeValue={setActiveTab}
defaultValue={tab ?? DEFAULT_TAB}
label="Folder Settings"
className="pt-4 pb-2 px-3"
tabListClassName="pl-4"
@@ -90,7 +87,7 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
) : null,
},
]}
storageKey="workspace_settings_tabs_order"
storageKey="workspace_settings_tabs"
>
<TabContent value={TAB_AUTH} className="overflow-y-auto h-full px-4">
<HttpAuthenticationEditor model={workspace} />

View File

@@ -10,8 +10,8 @@ import {
useSensors,
} from '@dnd-kit/core';
import classNames from 'classnames';
import type { ReactNode } from 'react';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { ReactNode, Ref } from 'react';
import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
import { useKeyValue } from '../../../hooks/useKeyValue';
import { computeSideForDragMove } from '../../../lib/dnd';
import { DropMarker } from '../../DropMarker';
@@ -37,22 +37,37 @@ export type TabItem =
rightSlot?: ReactNode;
};
interface TabsStorage {
order: string[];
activeTabs: Record<string, string>;
}
export interface TabsRef {
/** Programmatically set the active tab */
setActiveTab: (value: string) => void;
}
interface Props {
label: string;
value?: string;
onChangeValue: (value: string) => void;
/** Default tab value. If not provided, defaults to first tab. */
defaultValue?: string;
/** Called when active tab changes */
onChangeValue?: (value: string) => void;
tabs: TabItem[];
tabListClassName?: string;
className?: string;
children: ReactNode;
addBorders?: boolean;
layout?: 'horizontal' | 'vertical';
/** Storage key for persisting tab order and active tab. When provided, enables drag-to-reorder and active tab persistence. */
storageKey?: string | string[];
/** Key to identify which context this tab belongs to (e.g., request ID). Used for per-context active tab persistence. */
activeTabKey?: string;
}
export function Tabs({
value,
onChangeValue,
export const Tabs = forwardRef<TabsRef, Props>(function Tabs({
defaultValue,
onChangeValue: onChangeValueProp,
label,
children,
tabs: originalTabs,
@@ -61,17 +76,74 @@ export function Tabs({
addBorders,
layout = 'vertical',
storageKey,
}: Props) {
activeTabKey,
}: Props, forwardedRef: Ref<TabsRef>) {
const ref = useRef<HTMLDivElement | null>(null);
const reorderable = !!storageKey;
// Use key-value storage for persistence if storageKey is provided
const { value: savedOrder, set: setSavedOrder } = useKeyValue<string[]>({
namespace: 'global',
key: storageKey ?? ['tabs_order', 'default'],
fallback: [],
// Handle migration from old format (string[]) to new format (TabsStorage)
const { value: rawStorage, set: setStorage } = useKeyValue<TabsStorage | string[]>({
namespace: 'no_sync',
key: storageKey ?? ['tabs', 'default'],
fallback: { order: [], activeTabs: {} },
});
// Migrate old format (string[]) to new format (TabsStorage)
const storage: TabsStorage = Array.isArray(rawStorage)
? { order: rawStorage, activeTabs: {} }
: rawStorage ?? { order: [], activeTabs: {} };
const savedOrder = storage.order;
// Get the active tab value - prefer storage (if activeTabKey), then defaultValue, then first tab
const storedActiveTab = activeTabKey ? storage?.activeTabs?.[activeTabKey] : undefined;
const [internalValue, setInternalValue] = useState<string | undefined>(undefined);
const value = storedActiveTab ?? internalValue ?? defaultValue ?? originalTabs[0]?.value;
// Helper to normalize storage (handle migration from old format)
const normalizeStorage = useCallback(
(s: TabsStorage | string[]): TabsStorage =>
Array.isArray(s) ? { order: s, activeTabs: {} } : s,
[],
);
// Handle tab change - update internal state, storage if we have a key, and call prop callback
const onChangeValue = useCallback(
async (newValue: string) => {
setInternalValue(newValue);
if (storageKey && activeTabKey) {
await setStorage((s) => {
const normalized = normalizeStorage(s);
return {
...normalized,
activeTabs: { ...normalized.activeTabs, [activeTabKey]: newValue },
};
});
}
onChangeValueProp?.(newValue);
},
[storageKey, activeTabKey, setStorage, onChangeValueProp, normalizeStorage],
);
// Expose imperative methods via ref
useImperativeHandle(forwardedRef, () => ({
setActiveTab: (value: string) => {
onChangeValue(value);
},
}), [onChangeValue]);
// Helper to save order
const setSavedOrder = useCallback(
async (order: string[]) => {
await setStorage((s) => {
const normalized = normalizeStorage(s);
return { ...normalized, order };
});
},
[setStorage, normalizeStorage],
);
// State for ordered tabs
const [orderedTabs, setOrderedTabs] = useState<TabItem[]>(originalTabs);
const [isDragging, setIsDragging] = useState<TabItem | null>(null);
@@ -112,8 +184,6 @@ export function Tabs({
const tabs = storageKey ? orderedTabs : originalTabs;
value = value ?? tabs[0]?.value;
// Update tabs when value changes
useEffect(() => {
const tabs = ref.current?.querySelectorAll<HTMLDivElement>('[data-tab]');
@@ -320,7 +390,7 @@ export function Tabs({
{children}
</div>
);
}
});
interface TabButtonProps {
tab: TabItem;
@@ -329,7 +399,7 @@ interface TabButtonProps {
layout: 'horizontal' | 'vertical';
reorderable: boolean;
isDragging: boolean;
onChangeValue: (value: string) => void;
onChangeValue?: (value: string) => void;
overlay?: boolean;
}
@@ -373,7 +443,7 @@ function TabButton({
? undefined
: (e: React.MouseEvent) => {
e.preventDefault(); // Prevent dropdown from opening on first click
onChangeValue(tab.value);
onChangeValue?.(tab.value);
},
className: classNames(
'flex items-center rounded whitespace-nowrap',
@@ -478,3 +548,32 @@ export const TabContent = memo(function TabContent({
</ErrorBoundary>
);
});
/**
* Programmatically set the active tab for a Tabs component that uses storageKey + activeTabKey.
* This is useful when you need to change the tab from outside the component (e.g., in response to an event).
*/
export async function setActiveTab({
storageKey,
activeTabKey,
value,
}: {
storageKey: string;
activeTabKey: string;
value: string;
}): Promise<void> {
const { getKeyValue, setKeyValue } = await import('../../../lib/keyValueStore');
const current = getKeyValue<TabsStorage>({
namespace: 'no_sync',
key: storageKey,
fallback: { order: [], activeTabs: {} },
});
await setKeyValue({
namespace: 'no_sync',
key: storageKey,
value: {
...current,
activeTabs: { ...current.activeTabs, [activeTabKey]: value },
},
});
}

View File

@@ -1,5 +1,5 @@
import { type MultipartPart, parseMultipart } from '@mjackson/multipart-parser';
import { lazy, Suspense, useMemo, useState } from 'react';
import { lazy, Suspense, useMemo } from 'react';
import { languageFromContentType } from '../../lib/contentType';
import { Banner } from '../core/Banner';
import { Icon } from '../core/Icon';
@@ -22,8 +22,6 @@ interface Props {
}
export function MultipartViewer({ data, boundary, idPrefix = 'multipart' }: Props) {
const [tab, setTab] = useState<string>();
const parseResult = useMemo(() => {
try {
const maxFileSize = 1024 * 1024 * 10; // 10MB
@@ -55,12 +53,10 @@ export function MultipartViewer({ data, boundary, idPrefix = 'multipart' }: Prop
return (
<Tabs
value={tab}
addBorders
label="Multipart"
layout="horizontal"
tabListClassName="border-r border-r-border"
onChangeValue={setTab}
tabs={parts.map((part) => ({
label: part.name ?? '',
value: part.name ?? '',

View File

@@ -34,7 +34,7 @@ export function useRequestEditor() {
const focusParamValue = useCallback(
(name: string) => {
focusParamsTab();
setTimeout(() => emitter.emit('request_params.focus_value', name), 50);
requestAnimationFrame(() => emitter.emit('request_params.focus_value', name));
},
[focusParamsTab],
);