diff --git a/src-web/components/FolderSettingsDialog.tsx b/src-web/components/FolderSettingsDialog.tsx index 290fc106..40b6526a 100644 --- a/src-web/components/FolderSettingsDialog.tsx +++ b/src-web/components/FolderSettingsDialog.tsx @@ -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(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 ( >({ - namespace: 'no_sync', - key: 'grpcRequestActiveTabs', - fallback: {}, - }); const forceUpdateKey = useRequestUpdateKey(activeRequest.id ?? null); const urlContainerEl = useRef(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({ { 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>({ - namespace: 'no_sync', - key: 'httpRequestActiveTabs', - fallback: {}, - }); + const tabsRef = useRef(null); const [forceUpdateHeaderEditorKey, setForceUpdateHeaderEditorKey] = useState(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> = {}) => { 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'} /> diff --git a/src-web/components/HttpResponsePane.tsx b/src-web/components/HttpResponsePane.tsx index ac747c47..8d525356 100644 --- a/src-web/components/HttpResponsePane.tsx +++ b/src-web/components/HttpResponsePane.tsx @@ -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>( - '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 */} diff --git a/src-web/components/Settings/Settings.tsx b/src-web/components/Settings/Settings.tsx index cbfa9a98..0ba895cc 100644 --- a/src-web/components/Settings/Settings.tsx +++ b/src-web/components/Settings/Settings.tsx @@ -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(mainTab || tabFromQuery); const settings = useAtomValue(settingsAtom); const plugins = useAtomValue(pluginsAtom); const licenseCheck = useLicense(); @@ -91,11 +89,10 @@ export default function Settings({ hide }: Props) { )} ({ value, @@ -145,7 +142,7 @@ export default function Settings({ hide }: Props) { - + diff --git a/src-web/components/Settings/SettingsPlugins.tsx b/src-web/components/Settings/SettingsPlugins.tsx index 33195513..300a70de 100644 --- a/src-web/components/Settings/SettingsPlugins.tsx +++ b/src-web/components/Settings/SettingsPlugins.tsx @@ -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(defaultSubtab); return (
{ 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>({ - namespace: 'no_sync', - key: 'websocketRequestActiveTabs', - fallback: {}, - }); + const tabsRef = useRef(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 />
diff --git a/src-web/components/WorkspaceSettingsDialog.tsx b/src-web/components/WorkspaceSettingsDialog.tsx index 2e7ae2b1..2d825c05 100644 --- a/src-web/components/WorkspaceSettingsDialog.tsx +++ b/src-web/components/WorkspaceSettingsDialog.tsx @@ -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(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 ( diff --git a/src-web/components/core/Tabs/Tabs.tsx b/src-web/components/core/Tabs/Tabs.tsx index 86a76544..05720a7a 100644 --- a/src-web/components/core/Tabs/Tabs.tsx +++ b/src-web/components/core/Tabs/Tabs.tsx @@ -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; +} + +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(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) { 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: [], + // Handle migration from old format (string[]) to new format (TabsStorage) + const { value: rawStorage, set: setStorage } = useKeyValue({ + 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(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(originalTabs); const [isDragging, setIsDragging] = useState(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('[data-tab]'); @@ -320,7 +390,7 @@ export function Tabs({ {children} ); -} +}); 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({ ); }); + +/** + * 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 { + const { getKeyValue, setKeyValue } = await import('../../../lib/keyValueStore'); + const current = getKeyValue({ + namespace: 'no_sync', + key: storageKey, + fallback: { order: [], activeTabs: {} }, + }); + await setKeyValue({ + namespace: 'no_sync', + key: storageKey, + value: { + ...current, + activeTabs: { ...current.activeTabs, [activeTabKey]: value }, + }, + }); +} diff --git a/src-web/components/responseViewers/MultipartViewer.tsx b/src-web/components/responseViewers/MultipartViewer.tsx index c21b2a71..354d1a62 100644 --- a/src-web/components/responseViewers/MultipartViewer.tsx +++ b/src-web/components/responseViewers/MultipartViewer.tsx @@ -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(); - const parseResult = useMemo(() => { try { const maxFileSize = 1024 * 1024 * 10; // 10MB @@ -55,12 +53,10 @@ export function MultipartViewer({ data, boundary, idPrefix = 'multipart' }: Prop return ( ({ label: part.name ?? '', value: part.name ?? '', diff --git a/src-web/hooks/useRequestEditor.tsx b/src-web/hooks/useRequestEditor.tsx index 467f93c3..4da71d85 100644 --- a/src-web/hooks/useRequestEditor.tsx +++ b/src-web/hooks/useRequestEditor.tsx @@ -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], );