279 lines
8.7 KiB
TypeScript
279 lines
8.7 KiB
TypeScript
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<CapabilitySummary>;
|
|
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<CapabilityDiscoveryController, "setQuery" | "setSourceId" | "search" | "loadMore" | "inspect">;
|
|
|
|
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<CapabilitySummary>,
|
|
additions: ReadonlyArray<CapabilitySummary>,
|
|
): ReadonlyArray<CapabilitySummary> => {
|
|
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<CapabilityClient | null>(
|
|
() => (readExecutor ? createCapabilityClient(readExecutor) : null),
|
|
[readExecutor],
|
|
);
|
|
const [state, setState] = useState<DiscoveryStateWithAppliedFilters>(initialState);
|
|
const listGenerationRef = useRef(0);
|
|
const inspectGenerationRef = useRef(0);
|
|
const committedProvenanceRef = useRef<ConnectionProvenance | null>(null);
|
|
const listProvenanceRef = useRef<ConnectionProvenance | null>(null);
|
|
const selectedProvenanceRef = useRef<ConnectionProvenance | null>(null);
|
|
const currentProvenance = useMemo<ConnectionProvenance | null>(
|
|
() =>
|
|
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,
|
|
};
|
|
};
|