import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useConsoleWorkspace } from "../context.js"; import { createCapabilityClient, type CapabilityClient, } from "../domain/capability-client.js"; import type { CapabilityDetail, CapabilitySummary, } from "../domain/capability-models.js"; import type { ConsoleReadExecutor } from "../domain/read-executor.js"; const PAGE_LIMIT = 50; export type CapabilityDiscoveryController = { readonly phase: "disconnected" | "loading" | "ready" | "error"; readonly query: string; readonly sourceId: string; readonly items: ReadonlyArray; readonly selected: CapabilityDetail | null; readonly nextCursor: string | null; readonly message: string | null; readonly setQuery: (value: string) => void; readonly setSourceId: (value: string) => void; readonly search: () => void; readonly loadMore: () => void; readonly inspect: (qualifiedName: string) => void; }; type DiscoveryState = Omit; type CapabilityFilters = { readonly query: string; readonly sourceId: string; }; type DiscoveryStateWithAppliedFilters = DiscoveryState & { readonly appliedQuery: string; readonly appliedSourceId: string; }; type ConnectionProvenance = { readonly readExecutor: ConsoleReadExecutor; readonly connectedTarget: string; }; const initialState: DiscoveryStateWithAppliedFilters = { phase: "disconnected", query: "", sourceId: "", appliedQuery: "", appliedSourceId: "", items: [], selected: null, nextCursor: null, message: null, }; const errorMessage = (error: unknown): string => error instanceof Error ? error.message : String(error); const requestParams = ( query: string, sourceId: string, cursor?: string, ): { readonly query?: string; readonly sourceId?: string; readonly cursor?: string; readonly limit: number } => ({ ...(query ? { query } : {}), ...(sourceId ? { sourceId } : {}), ...(cursor ? { cursor } : {}), limit: PAGE_LIMIT, }); const appendUnique = ( existing: ReadonlyArray, additions: ReadonlyArray, ): ReadonlyArray => { const names = new Set(existing.map((item) => item.name)); const result = [...existing]; for (const item of additions) { if (names.has(item.name)) continue; names.add(item.name); result.push(item); } return result; }; const isSameConnection = ( left: ConnectionProvenance | null, right: ConnectionProvenance | null, ): boolean => { if (left === null || right === null) return left === right; return left.readExecutor === right.readExecutor && left.connectedTarget === right.connectedTarget; }; export const useCapabilityDiscovery = (): CapabilityDiscoveryController => { const { connectedTarget, readExecutor } = useConsoleWorkspace(); const client = useMemo( () => (readExecutor ? createCapabilityClient(readExecutor) : null), [readExecutor], ); const [state, setState] = useState(initialState); const listGenerationRef = useRef(0); const inspectGenerationRef = useRef(0); const committedProvenanceRef = useRef(null); const listProvenanceRef = useRef(null); const selectedProvenanceRef = useRef(null); const currentProvenance = useMemo( () => readExecutor !== null && connectedTarget !== null ? { readExecutor, connectedTarget } : null, [connectedTarget, readExecutor], ); const runList = useCallback( (filters: CapabilityFilters, cursor: string | undefined, append: boolean): void => { if (!client || currentProvenance === null) return; const requestProvenance = currentProvenance; const generation = ++listGenerationRef.current; if (append === false) inspectGenerationRef.current++; listProvenanceRef.current = requestProvenance; setState((current) => ({ ...current, appliedQuery: filters.query, appliedSourceId: filters.sourceId, phase: "loading", items: append ? current.items : [], selected: append ? current.selected : null, nextCursor: append ? current.nextCursor : null, message: null, })); void client .list(requestParams(filters.query, filters.sourceId, cursor)) .then((page) => { if ( generation !== listGenerationRef.current || !isSameConnection(requestProvenance, committedProvenanceRef.current) ) return; setState((current) => ({ ...current, phase: "ready", items: appendUnique(append ? current.items : [], page.capabilities), nextCursor: page.nextCursor, message: null, })); }) .catch((error: unknown) => { if ( generation !== listGenerationRef.current || !isSameConnection(requestProvenance, committedProvenanceRef.current) ) return; setState((current) => ({ ...current, phase: "error", message: errorMessage(error), })); }); }, [client, currentProvenance], ); useEffect(() => { committedProvenanceRef.current = currentProvenance; if (!client || !connectedTarget) { listGenerationRef.current++; inspectGenerationRef.current++; listProvenanceRef.current = null; selectedProvenanceRef.current = null; setState((current) => ({ ...current, phase: "disconnected", items: [], selected: null, nextCursor: null, message: null, })); return; } runList( { query: state.appliedQuery, sourceId: state.appliedSourceId }, undefined, false, ); // The executor identity changes with the connected target. Query and source // filters are intentionally retained so reconnecting preserves the submitted view. // eslint-disable-next-line react-hooks/exhaustive-deps }, [client, connectedTarget, currentProvenance, readExecutor, runList]); const setQuery = useCallback((query: string) => { setState((current) => ({ ...current, query })); }, []); const setSourceId = useCallback( (sourceId: string) => { setState((current) => ({ ...current, sourceId })); }, [], ); const search = useCallback(() => { runList({ query: state.query, sourceId: state.sourceId }, undefined, false); }, [runList, state.query, state.sourceId]); const loadMore = useCallback(() => { if (!state.nextCursor || state.phase === "loading") return; runList( { query: state.appliedQuery, sourceId: state.appliedSourceId }, state.nextCursor, true, ); }, [ runList, state.appliedQuery, state.appliedSourceId, state.nextCursor, state.phase, ]); const inspect = useCallback( (qualifiedName: string) => { if (!client) return; if (currentProvenance === null) return; const generation = ++inspectGenerationRef.current; const requestProvenance = currentProvenance; selectedProvenanceRef.current = null; setState((current) => ({ ...current, phase: "loading", selected: null, message: null })); void client .inspect(qualifiedName) .then((detail) => { if ( generation !== inspectGenerationRef.current || !isSameConnection(requestProvenance, committedProvenanceRef.current) ) return; selectedProvenanceRef.current = requestProvenance; setState((current) => ({ ...current, phase: "ready", selected: detail, message: null })); }) .catch((error: unknown) => { if ( generation !== inspectGenerationRef.current || !isSameConnection(requestProvenance, committedProvenanceRef.current) ) return; setState((current) => ({ ...current, phase: "error", message: errorMessage(error) })); }); }, [client, currentProvenance], ); const hasCurrentList = isSameConnection( listProvenanceRef.current, currentProvenance, ); const hasCurrentSelection = isSameConnection( selectedProvenanceRef.current, currentProvenance, ); const visiblePhase = currentProvenance === null ? "disconnected" : hasCurrentList ? state.phase : "loading"; return { ...state, phase: visiblePhase, items: hasCurrentList ? state.items : [], selected: hasCurrentSelection ? state.selected : null, nextCursor: hasCurrentList ? state.nextCursor : null, message: hasCurrentList ? state.message : null, setQuery, setSourceId, search, loadMore, inspect, }; };