| @@ -1,4 +1,4 @@ |
| 1 | import { useDeferredValue, useEffect, useMemo, useState, type Dispatch, type SetStateAction } from "react"; | 1 | import { useDeferredValue, useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from "react"; |
| 2 | import { useQuery as useTanstackQuery } from "@tanstack/react-query"; | 2 | import { useQuery as useTanstackQuery } from "@tanstack/react-query"; |
| 3 | import { useForm, useWatch } from "react-hook-form"; | 3 | import { useForm, useWatch } from "react-hook-form"; |
| 4 | import { fetchAndInspectOpenApiSpec, type InferredSpecAuth } from "@/lib/openapi/spec-inspector"; | 4 | import { fetchAndInspectOpenApiSpec, type InferredSpecAuth } from "@/lib/openapi/spec-inspector"; |
| @@ -117,6 +117,11 @@ export function useAddSourceFormState({ · 112 unmodified lines |
| 117 | const values: SourceFormValues = { ...createDefaultFormValues(), ...watched }; | 117 | const values: SourceFormValues = { ...createDefaultFormValues(), ...watched }; |
| 118 | const [ui, setUi] = useState<AddSourceUiState>(() => createDefaultUiState(sourceToEdit ? "custom" : "catalog")); | 118 | const [ui, setUi] = useState<AddSourceUiState>(() => createDefaultUiState(sourceToEdit ? "custom" : "catalog")); |
| 119 | | 119 | |
| | 120 | // Refs to let effects read current values without adding reactive deps that cause loops. |
| | 121 | const authManuallyEditedRef = useRef(ui.authManuallyEdited); |
| | 122 | authManuallyEditedRef.current = ui.authManuallyEdited; |
| | 123 | const appliedDetectionRef = useRef<{ endpoint: string; type: SourceType } | null>(null); |
| | 124 | |
| 120 | const visibleCatalogItems = useMemo( | 125 | const visibleCatalogItems = useMemo( |
| 121 | () => getVisibleCatalogItems(ui.catalogQuery, ui.catalogSort), | 126 | () => getVisibleCatalogItems(ui.catalogQuery, ui.catalogSort), |
| 122 | [ui.catalogQuery, ui.catalogSort], | 127 | [ui.catalogQuery, ui.catalogSort], |
| @@ -160,6 +165,9 @@ export function useAddSourceFormState({ · 37 unmodified lines |
| 160 | return; | 165 | return; |
| 161 | } | 166 | } |
| 162 | | 167 | |
| | 168 | // Clear applied detection tracking when dialog opens/resets |
| | 169 | appliedDetectionRef.current = null; |
| | 170 | |
| 163 | if (sourceToEdit) { | 171 | if (sourceToEdit) { |
| 164 | const editValues = sourceToFormValues(sourceToEdit); | 172 | const editValues = sourceToFormValues(sourceToEdit); |
| 165 | form.reset(editValues); | 173 | form.reset(editValues); |
| @@ -396,60 +404,60 @@ export function useAddSourceFormState({ · 230 unmodified lines |
| 396 | ? "error" | 404 | ? "error" |
| 397 | : "idle"; | 405 | : "idle"; |
| 398 | | 406 | |
| 399 | // Auto-set the type when detection succeeds | 407 | // Auto-set the type when detection succeeds. |
| | 408 | // Uses a ref to track what we last applied, avoiding deps on values the effect itself mutates. |
| 400 | useEffect(() => { | 409 | useEffect(() => { |
| 401 | if (!typeDetectionEnabled || !typeDetectionQuery.data) { | 410 | if (!typeDetectionEnabled || !typeDetectionQuery.data) { |
| 402 | return; | 411 | return; |
| 403 | } | 412 | } |
| 404 | const detected = typeDetectionQuery.data.type; | 413 | const detected = typeDetectionQuery.data.type; |
| 405 | if (values.type !== detected) { | 414 | |
| 406 | form.setValue("type", detected, { shouldDirty: false, shouldTouch: false }); | 415 | // Skip if we already applied this exact detection result for this endpoint |
| 407 | | 416 | if ( |
| 408 | // Trigger the same side-effects as handleTypeChange but without marking as explicitly set | 417 | appliedDetectionRef.current |
| 409 | if (detected === "openapi") { | 418 | && appliedDetectionRef.current.endpoint === detectionEndpoint |
| 410 | const inferredBaseUrl = deriveBaseUrlFromEndpoint(values.endpoint); | 419 | && appliedDetectionRef.current.type === detected |
| 411 | patchUi(setUi, { | 420 | ) { |
| 412 | openApiBaseUrlOptions: inferredBaseUrl ? [inferredBaseUrl] : [], | 421 | return; |
| 413 | authManuallyEdited: false, | 422 | } |
| 414 | mcpOAuthLinkedEndpoint: null, | 423 | |
| 415 | }); | 424 | appliedDetectionRef.current = { endpoint: detectionEndpoint, type: detected }; |
| 416 | if (!baseUrlManuallyEdited) { | 425 | form.setValue("type", detected, { shouldDirty: false, shouldTouch: false }); |
| 417 | form.setValue("baseUrl", inferredBaseUrl, { shouldDirty: false, shouldTouch: false }); | 426 | |
| 418 | } | 427 | // Trigger the same side-effects as handleTypeChange but without marking as explicitly set. |
| 419 | } else { | 428 | // Uses functional setUi to read current state without depending on it reactively. |
| 420 | patchUi(setUi, { | 429 | if (detected === "openapi") { |
| 421 | openApiBaseUrlOptions: [], | 430 | const inferredBaseUrl = deriveBaseUrlFromEndpoint(detectionEndpoint); |
| 422 | mcpOAuthLinkedEndpoint: null, | 431 | setUi((current) => ({ |
| 423 | // Preserve authManuallyEdited — user may have set credentials before detection | 432 | ...current, |
| 424 | }); | 433 | openApiBaseUrlOptions: inferredBaseUrl ? [inferredBaseUrl] : [], |
| 425 | // Only reset auth fields if the user hasn't manually edited them | 434 | mcpOAuthLinkedEndpoint: null, |
| 426 | if (!ui.authManuallyEdited) { | 435 | })); |
| 427 | patchUi(setUi, { authManuallyEdited: false }); | 436 | if (!baseUrlManuallyEdited) { |
| 428 | form.setValue("authType", "none", { shouldDirty: false, shouldTouch: false }); | 437 | form.setValue("baseUrl", inferredBaseUrl, { shouldDirty: false, shouldTouch: false }); |
| 429 | form.setValue("authScope", "workspace", { shouldDirty: false, shouldTouch: false }); | 438 | } |
| 430 | } | 439 | } else { |
| | 440 | setUi((current) => ({ |
| | 441 | ...current, |
| | 442 | openApiBaseUrlOptions: [], |
| | 443 | mcpOAuthLinkedEndpoint: null, |
| | 444 | })); |
| | 445 | // Only reset auth form values if the user hasn't manually set credentials. |
| | 446 | // Read from ref to avoid adding a reactive dep that causes render loops. |
| | 447 | if (!authManuallyEditedRef.current) { |
| | 448 | form.setValue("authType", "none", { shouldDirty: false, shouldTouch: false }); |
| | 449 | form.setValue("authScope", "workspace", { shouldDirty: false, shouldTouch: false }); |
| 431 | } | 450 | } |
| 432 | } | 451 | } |
| 433 | | 452 | |
| 434 | // Also infer name from URL if not already set | 453 | // Also infer name from URL if not already set |
| 435 | if (!nameManuallyEdited) { | 454 | if (!nameManuallyEdited) { |
| 436 | const inferredName = inferNameFromUrl(detectionEndpoint); | 455 | const inferredName = inferNameFromUrl(detectionEndpoint); |
| 437 | if (inferredName && values.name !== inferredName) { | 456 | if (inferredName) { |
| 438 | form.setValue("name", inferredName, { shouldDirty: false, shouldTouch: false }); | 457 | form.setValue("name", inferredName, { shouldDirty: false, shouldTouch: false }); |
| 439 | } | 458 | } |
| 440 | } | 459 | } |
| 441 | }, [ | 460 | }, [typeDetectionEnabled, typeDetectionQuery.data, detectionEndpoint, form, baseUrlManuallyEdited, nameManuallyEdited]); |
| 442 | typeDetectionEnabled, | | |
| 443 | typeDetectionQuery.data, | | |
| 444 | baseUrlManuallyEdited, | | |
| 445 | detectionEndpoint, | | |
| 446 | form, | | |
| 447 | nameManuallyEdited, | | |
| 448 | ui.authManuallyEdited, | | |
| 449 | values.endpoint, | | |
| 450 | values.name, | | |
| 451 | values.type, | | |
| 452 | ]); | | |
| 453 | | 461 | |
| 454 | // Type is "resolved" when explicitly set by the user/catalog, or auto-detected from the URL. | 462 | // Type is "resolved" when explicitly set by the user/catalog, or auto-detected from the URL. |
| 455 | // Type-specific queries (spec inspection, MCP OAuth) should only fire after resolution, | 463 | // Type-specific queries (spec inspection, MCP OAuth) should only fire after resolution, |