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

View File

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

View File

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

View File

@@ -1,8 +1,7 @@
import type { HttpResponse } from '@yaakapp-internal/models'; import type { HttpResponse } from '@yaakapp-internal/models';
import classNames from 'classnames'; import classNames from 'classnames';
import type { ComponentType, CSSProperties } from 'react'; import type { ComponentType, CSSProperties } from 'react';
import { lazy, Suspense, useCallback, useMemo } from 'react'; import { lazy, Suspense, useMemo } from 'react';
import { useLocalStorage } from 'react-use';
import { useCancelHttpResponse } from '../hooks/useCancelHttpResponse'; import { useCancelHttpResponse } from '../hooks/useCancelHttpResponse';
import { useHttpResponseEvents } from '../hooks/useHttpResponseEvents'; import { useHttpResponseEvents } from '../hooks/useHttpResponseEvents';
import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse'; import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse';
@@ -58,10 +57,6 @@ const TAB_TIMELINE = 'timeline';
export function HttpResponsePane({ style, className, activeRequestId }: Props) { export function HttpResponsePane({ style, className, activeRequestId }: Props) {
const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId); const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId);
const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId); const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId);
const [activeTabs, setActiveTabs] = useLocalStorage<Record<string, string>>(
'responsePaneActiveTabs',
{},
);
const contentType = getContentTypeFromHeaders(activeResponse?.headers ?? null); const contentType = getContentTypeFromHeaders(activeResponse?.headers ?? null);
const mimeType = contentType == null ? null : getMimeTypeFromContentType(contentType).essence; const mimeType = contentType == null ? null : getMimeTypeFromContentType(contentType).essence;
@@ -129,13 +124,6 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
viewMode, viewMode,
], ],
); );
const activeTab = activeTabs?.[activeRequestId];
const setActiveTab = useCallback(
(tab: string) => {
setActiveTabs((r) => ({ ...r, [activeRequestId]: tab }));
},
[activeRequestId, setActiveTabs],
);
const cancel = useCancelHttpResponse(activeResponse?.id ?? null); 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 */} {/* Show tabs if we have any data (headers, body, etc.) even if there's an error */}
<Tabs <Tabs
key={activeRequestId} // Freshen tabs on request change
value={activeTab}
onChangeValue={setActiveTab}
tabs={tabs} tabs={tabs}
label="Response" label="Response"
className="ml-3 mr-3 mb-3 min-h-0 flex-1" className="ml-3 mr-3 mb-3 min-h-0 flex-1"
tabListClassName="mt-0.5 -mb-1.5" tabListClassName="mt-0.5 -mb-1.5"
storageKey="http_response_tabs_order" storageKey="http_response_tabs"
activeTabKey={activeRequestId}
> >
<TabContent value={TAB_BODY}> <TabContent value={TAB_BODY}>
<ErrorBoundary name="Http Response Viewer"> <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 { pluginsAtom, settingsAtom } from '@yaakapp-internal/models';
import classNames from 'classnames'; import classNames from 'classnames';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import { useState } from 'react';
import { useKeyPressEvent } from 'react-use'; import { useKeyPressEvent } from 'react-use';
import { appInfo } from '../../lib/appInfo'; import { appInfo } from '../../lib/appInfo';
import { capitalize } from '../../lib/capitalize'; import { capitalize } from '../../lib/capitalize';
@@ -51,7 +50,6 @@ export default function Settings({ hide }: Props) {
const { tab: tabFromQuery } = useSearch({ from: '/workspaces/$workspaceId/settings' }); const { tab: tabFromQuery } = useSearch({ from: '/workspaces/$workspaceId/settings' });
// Parse tab and subtab (e.g., "plugins:installed") // Parse tab and subtab (e.g., "plugins:installed")
const [mainTab, subtab] = tabFromQuery?.split(':') ?? []; const [mainTab, subtab] = tabFromQuery?.split(':') ?? [];
const [tab, setTab] = useState<string | undefined>(mainTab || tabFromQuery);
const settings = useAtomValue(settingsAtom); const settings = useAtomValue(settingsAtom);
const plugins = useAtomValue(pluginsAtom); const plugins = useAtomValue(pluginsAtom);
const licenseCheck = useLicense(); const licenseCheck = useLicense();
@@ -91,11 +89,10 @@ export default function Settings({ hide }: Props) {
)} )}
<Tabs <Tabs
layout="horizontal" layout="horizontal"
value={tab} defaultValue={mainTab || tabFromQuery}
addBorders addBorders
tabListClassName="min-w-[10rem] bg-surface x-theme-sidebar border-r border-border pl-3" tabListClassName="min-w-[10rem] bg-surface x-theme-sidebar border-r border-border pl-3"
label="Settings" label="Settings"
onChangeValue={setTab}
tabs={tabs.map( tabs={tabs.map(
(value): TabItem => ({ (value): TabItem => ({
value, value,
@@ -145,7 +142,7 @@ export default function Settings({ hide }: Props) {
<SettingsHotkeys /> <SettingsHotkeys />
</TabContent> </TabContent>
<TabContent value={TAB_PLUGINS} className="h-full grid grid-rows-1 px-6 !py-4"> <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>
<TabContent value={TAB_PROXY} className="overflow-y-auto h-full px-6 !py-4"> <TabContent value={TAB_PROXY} className="overflow-y-auto h-full px-6 !py-4">
<SettingsProxy /> <SettingsProxy />

View File

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

View File

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

View File

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

View File

@@ -10,8 +10,8 @@ import {
useSensors, useSensors,
} from '@dnd-kit/core'; } from '@dnd-kit/core';
import classNames from 'classnames'; import classNames from 'classnames';
import type { ReactNode } from 'react'; import type { ReactNode, Ref } from 'react';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
import { useKeyValue } from '../../../hooks/useKeyValue'; import { useKeyValue } from '../../../hooks/useKeyValue';
import { computeSideForDragMove } from '../../../lib/dnd'; import { computeSideForDragMove } from '../../../lib/dnd';
import { DropMarker } from '../../DropMarker'; import { DropMarker } from '../../DropMarker';
@@ -37,22 +37,37 @@ export type TabItem =
rightSlot?: ReactNode; rightSlot?: ReactNode;
}; };
interface TabsStorage {
order: string[];
activeTabs: Record<string, string>;
}
export interface TabsRef {
/** Programmatically set the active tab */
setActiveTab: (value: string) => void;
}
interface Props { interface Props {
label: string; label: string;
value?: string; /** Default tab value. If not provided, defaults to first tab. */
onChangeValue: (value: string) => void; defaultValue?: string;
/** Called when active tab changes */
onChangeValue?: (value: string) => void;
tabs: TabItem[]; tabs: TabItem[];
tabListClassName?: string; tabListClassName?: string;
className?: string; className?: string;
children: ReactNode; children: ReactNode;
addBorders?: boolean; addBorders?: boolean;
layout?: 'horizontal' | 'vertical'; 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[]; 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({ export const Tabs = forwardRef<TabsRef, Props>(function Tabs({
value, defaultValue,
onChangeValue, onChangeValue: onChangeValueProp,
label, label,
children, children,
tabs: originalTabs, tabs: originalTabs,
@@ -61,17 +76,74 @@ export function Tabs({
addBorders, addBorders,
layout = 'vertical', layout = 'vertical',
storageKey, storageKey,
}: Props) { activeTabKey,
}: Props, forwardedRef: Ref<TabsRef>) {
const ref = useRef<HTMLDivElement | null>(null); const ref = useRef<HTMLDivElement | null>(null);
const reorderable = !!storageKey; const reorderable = !!storageKey;
// Use key-value storage for persistence if storageKey is provided // Use key-value storage for persistence if storageKey is provided
const { value: savedOrder, set: setSavedOrder } = useKeyValue<string[]>({ // Handle migration from old format (string[]) to new format (TabsStorage)
namespace: 'global', const { value: rawStorage, set: setStorage } = useKeyValue<TabsStorage | string[]>({
key: storageKey ?? ['tabs_order', 'default'], namespace: 'no_sync',
fallback: [], 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 // State for ordered tabs
const [orderedTabs, setOrderedTabs] = useState<TabItem[]>(originalTabs); const [orderedTabs, setOrderedTabs] = useState<TabItem[]>(originalTabs);
const [isDragging, setIsDragging] = useState<TabItem | null>(null); const [isDragging, setIsDragging] = useState<TabItem | null>(null);
@@ -112,8 +184,6 @@ export function Tabs({
const tabs = storageKey ? orderedTabs : originalTabs; const tabs = storageKey ? orderedTabs : originalTabs;
value = value ?? tabs[0]?.value;
// Update tabs when value changes // Update tabs when value changes
useEffect(() => { useEffect(() => {
const tabs = ref.current?.querySelectorAll<HTMLDivElement>('[data-tab]'); const tabs = ref.current?.querySelectorAll<HTMLDivElement>('[data-tab]');
@@ -320,7 +390,7 @@ export function Tabs({
{children} {children}
</div> </div>
); );
} });
interface TabButtonProps { interface TabButtonProps {
tab: TabItem; tab: TabItem;
@@ -329,7 +399,7 @@ interface TabButtonProps {
layout: 'horizontal' | 'vertical'; layout: 'horizontal' | 'vertical';
reorderable: boolean; reorderable: boolean;
isDragging: boolean; isDragging: boolean;
onChangeValue: (value: string) => void; onChangeValue?: (value: string) => void;
overlay?: boolean; overlay?: boolean;
} }
@@ -373,7 +443,7 @@ function TabButton({
? undefined ? undefined
: (e: React.MouseEvent) => { : (e: React.MouseEvent) => {
e.preventDefault(); // Prevent dropdown from opening on first click e.preventDefault(); // Prevent dropdown from opening on first click
onChangeValue(tab.value); onChangeValue?.(tab.value);
}, },
className: classNames( className: classNames(
'flex items-center rounded whitespace-nowrap', 'flex items-center rounded whitespace-nowrap',
@@ -478,3 +548,32 @@ export const TabContent = memo(function TabContent({
</ErrorBoundary> </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 { 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 { languageFromContentType } from '../../lib/contentType';
import { Banner } from '../core/Banner'; import { Banner } from '../core/Banner';
import { Icon } from '../core/Icon'; import { Icon } from '../core/Icon';
@@ -22,8 +22,6 @@ interface Props {
} }
export function MultipartViewer({ data, boundary, idPrefix = 'multipart' }: Props) { export function MultipartViewer({ data, boundary, idPrefix = 'multipart' }: Props) {
const [tab, setTab] = useState<string>();
const parseResult = useMemo(() => { const parseResult = useMemo(() => {
try { try {
const maxFileSize = 1024 * 1024 * 10; // 10MB const maxFileSize = 1024 * 1024 * 10; // 10MB
@@ -55,12 +53,10 @@ export function MultipartViewer({ data, boundary, idPrefix = 'multipart' }: Prop
return ( return (
<Tabs <Tabs
value={tab}
addBorders addBorders
label="Multipart" label="Multipart"
layout="horizontal" layout="horizontal"
tabListClassName="border-r border-r-border" tabListClassName="border-r border-r-border"
onChangeValue={setTab}
tabs={parts.map((part) => ({ tabs={parts.map((part) => ({
label: part.name ?? '', label: part.name ?? '',
value: part.name ?? '', value: part.name ?? '',

View File

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