[Init] Initial commit - NetMesh terminal manager
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
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
This commit is contained in:
260
application/state/usePluginConnectionSectionState.ts
Normal file
260
application/state/usePluginConnectionSectionState.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user