Files
NetMesh/application/state/usePluginConnectionSectionState.ts
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

261 lines
9.9 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import type { Dispatch, SetStateAction } from "react";
import type { Host, Identity, SSHKey } from "../../types";
import {
isPluginCredentialCatalogEntryAvailable,
isPluginHostProtocol,
pluginProtocolForProvider,
sanitizePluginConnection,
} from "../../domain/pluginConnection";
import { pluginConfigurationMatchesSchema } from "../../domain/pluginConfigurationSchema";
import { pluginExtensionBridge } from "./pluginExtensionBridge";
type Translation = (key: string, params?: Record<string, unknown>) => string;
export type PluginConnectionSectionStateOptions = {
form: Host;
setForm: Dispatch<SetStateAction<Host>>;
t: Translation;
onValidityChange: (valid: boolean) => void;
identities: ReadonlyArray<Identity>;
keys: ReadonlyArray<SSHKey>;
};
type SelectOption = {
value: string;
label: string;
sublabel?: string;
};
const localizeProviderLabel = (
value: unknown,
fallback: string,
locale = typeof navigator === "undefined" ? "en" : navigator.language,
): string => {
if (typeof value === "string") return value;
if (!value || typeof value !== "object" || Array.isArray(value)) return fallback;
const labels = value as Record<string, unknown>;
const candidate = labels[locale] ?? labels[locale.split("-")[0]] ?? labels.en;
return typeof candidate === "string" ? candidate : fallback;
};
export function usePluginConnectionSectionState({
form,
setForm,
t,
onValidityChange,
identities,
keys,
}: PluginConnectionSectionStateOptions) {
const [providers, setProviders] = useState<ReadonlyArray<NetcattyExtensionProviderContribution>>([]);
const [authenticationProviders, setAuthenticationProviders] = useState<ReadonlyArray<NetcattyExtensionProviderContribution>>([]);
const [configurationText, setConfigurationText] = useState(() => JSON.stringify(
form.pluginConnection?.configuration === undefined ? {} : form.pluginConnection.configuration,
null,
2,
));
const configurationTextRef = useRef(configurationText);
const [configurationError, setConfigurationError] = useState<string | null>(null);
const onValidityChangeRef = useRef(onValidityChange);
onValidityChangeRef.current = onValidityChange;
const credentialCatalogIds = useSyncExternalStore(
pluginExtensionBridge.subscribeCredentialCatalog,
pluginExtensionBridge.getCredentialCatalogIds,
pluginExtensionBridge.getCredentialCatalogIds,
);
const credentialCatalogIdSet = useMemo(() => new Set(credentialCatalogIds), [credentialCatalogIds]);
const active = isPluginHostProtocol(form.protocol);
const providerId = form.pluginConnection?.providerId ?? (active ? form.protocol.slice(7) : "");
const selectedProvider = providers.find((entry) => entry.provider.id === providerId);
const installed = providers.some((entry) => entry.provider.id === providerId);
useEffect(() => {
let disposed = false;
let requestId = 0;
const refreshProviders = () => {
const currentRequestId = ++requestId;
void Promise.all([
pluginExtensionBridge.listProviders("connection"),
pluginExtensionBridge.listProviders("authentication"),
]).then(([connections, authentications]) => {
if (disposed || currentRequestId !== requestId) return;
setProviders(connections);
setAuthenticationProviders(authentications);
}).catch(() => {
if (!disposed && currentRequestId === requestId) {
setProviders([]);
setAuthenticationProviders([]);
}
});
};
refreshProviders();
const unsubscribe = pluginExtensionBridge.onContributionsChanged(() => { refreshProviders(); });
return () => {
disposed = true;
requestId += 1;
unsubscribe();
};
}, []);
useEffect(() => {
const configuration = form.pluginConnection?.configuration === undefined
? {}
: form.pluginConnection.configuration;
let localConfigurationMatches = false;
try {
localConfigurationMatches = JSON.stringify(JSON.parse(configurationTextRef.current)) === JSON.stringify(configuration);
} catch {
// Preserve an in-progress invalid edit until the selected host or provider changes.
}
if (!localConfigurationMatches) {
const nextText = JSON.stringify(configuration, null, 2);
configurationTextRef.current = nextText;
setConfigurationText(nextText);
}
const structurallyValid = !active || Boolean(sanitizePluginConnection(form.pluginConnection, form.protocol));
const schemaValid = selectedProvider?.provider.configurationSchema === undefined
|| pluginConfigurationMatchesSchema(selectedProvider.provider.configurationSchema, configuration);
setConfigurationError(schemaValid ? null : t("hostDetails.plugin.configuration.schemaInvalid"));
onValidityChangeRef.current(structurallyValid && schemaValid);
}, [active, form.id, form.pluginConnection, form.protocol, selectedProvider, t]);
const providerOptions = useMemo<ReadonlyArray<SelectOption>>(() => providers.map((entry) => ({
value: entry.provider.id,
label: localizeProviderLabel(entry.provider.label, entry.provider.id),
})), [providers]);
const authenticationOptions = useMemo<ReadonlyArray<SelectOption>>(() => [
{ value: "", label: t("hostDetails.plugin.authentication.none") },
...authenticationProviders.map((entry) => ({
value: entry.provider.id,
label: localizeProviderLabel(entry.provider.label, entry.provider.id),
})),
], [authenticationProviders, t]);
const credentialOptions = useMemo<ReadonlyArray<SelectOption>>(() => {
const options = [
{ value: "", label: t("hostDetails.plugin.credential.none") },
...identities.flatMap((identity) => isPluginCredentialCatalogEntryAvailable(
identity.id,
identity.password,
credentialCatalogIdSet,
)
? [{
value: identity.id,
label: identity.label,
sublabel: t("hostDetails.plugin.credential.password"),
}]
: []),
...keys.flatMap((key) => isPluginCredentialCatalogEntryAvailable(
key.id,
key.privateKey,
credentialCatalogIdSet,
)
? [{
value: key.id,
label: key.label,
sublabel: t("hostDetails.plugin.credential.privateKey"),
}]
: []),
];
const selected = form.pluginConnection?.credentialId;
if (selected && !options.some((option) => option.value === selected)) {
options.push({
value: selected,
label: t("hostDetails.plugin.credential.unavailable"),
sublabel: selected,
});
}
return options;
}, [credentialCatalogIdSet, form.pluginConnection?.credentialId, identities, keys, t]);
const updateConfiguration = useCallback((text: string) => {
configurationTextRef.current = text;
setConfigurationText(text);
try {
const configuration = JSON.parse(text) as unknown;
if (configuration === undefined) throw new Error("empty");
const nextConnection = form.pluginConnection ? { ...form.pluginConnection, configuration } : undefined;
if (!sanitizePluginConnection(nextConnection, form.protocol)) throw new Error("unsafe");
if (selectedProvider?.provider.configurationSchema !== undefined
&& !pluginConfigurationMatchesSchema(selectedProvider.provider.configurationSchema, configuration)) {
setConfigurationError(t("hostDetails.plugin.configuration.schemaInvalid"));
onValidityChange(false);
return;
}
setConfigurationError(null);
onValidityChange(true);
setForm((previous) => previous.pluginConnection ? ({
...previous,
pluginConnection: { ...previous.pluginConnection, configuration },
}) : previous);
} catch {
setConfigurationError(t("hostDetails.plugin.configuration.invalid"));
onValidityChange(false);
}
}, [form.pluginConnection, form.protocol, onValidityChange, selectedProvider, setForm, t]);
const selectProvider = useCallback((nextProviderId: string) => {
const configuration = {};
const nextProvider = providers.find((entry) => entry.provider.id === nextProviderId);
const schemaValid = nextProvider?.provider.configurationSchema === undefined
|| pluginConfigurationMatchesSchema(nextProvider.provider.configurationSchema, configuration);
setForm((previous) => ({
...previous,
protocol: pluginProtocolForProvider(nextProviderId),
pluginConnection: { providerId: nextProviderId, configuration },
}));
setConfigurationText("{}");
configurationTextRef.current = "{}";
setConfigurationError(schemaValid ? null : t("hostDetails.plugin.configuration.schemaInvalid"));
onValidityChange(schemaValid);
}, [onValidityChange, providers, setForm, t]);
const selectAuthenticationProvider = useCallback((authenticationProviderId: string) => {
setForm((previous) => previous.pluginConnection ? ({
...previous,
pluginConnection: {
...previous.pluginConnection,
...(authenticationProviderId ? { authenticationProviderId } : { authenticationProviderId: undefined }),
},
}) : previous);
}, [setForm]);
const selectCredential = useCallback((credentialId: string) => {
setForm((previous) => previous.pluginConnection ? ({
...previous,
pluginConnection: {
...previous.pluginConnection,
...(credentialId ? { credentialId } : { credentialId: undefined }),
},
}) : previous);
}, [setForm]);
const resetToSsh = useCallback(() => {
setForm((previous) => ({
...previous,
protocol: "ssh",
pluginConnection: undefined,
}));
onValidityChange(true);
}, [onValidityChange, setForm]);
return {
active,
installed,
providerId,
providerCount: providers.length,
providerOptions,
authenticationOptions,
credentialOptions,
configurationText,
configurationError,
updateConfiguration,
selectProvider,
selectAuthenticationProvider,
selectCredential,
resetToSsh,
};
}