fix(plugins): stabilize plugin integration
This commit is contained in:
1 parent
b70d6da227
commit
3d1b84831f
76 files changed
+3846
-431
No files matched your search
+235
-18
@@ -1,11 +1,21 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, defineComponent, h, nextTick, reactive, type App } from "vue";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "@/i18n";
|
||||
import PluginConnectionFields from "./PluginConnectionFields.vue";
|
||||
import type { PluginConnectionProviderContribution, PluginFormFieldBinding, PluginFormFieldValue } from "@/types/database";
|
||||
|
||||
const invokePluginMock = vi.fn();
|
||||
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
listLocalSshKeys: vi.fn(async () => [
|
||||
{ path: "/home/dev/.ssh/id_ed25519", algorithm: "ssh-ed25519", fingerprint: "SHA256:abc", hasPassphrase: false },
|
||||
{ path: "/home/dev/.ssh/id_rsa", algorithm: "ssh-rsa", fingerprint: "", hasPassphrase: true },
|
||||
]),
|
||||
invokePlugin: (...args: unknown[]) => invokePluginMock(...args),
|
||||
}));
|
||||
|
||||
const mountedApps: App[] = [];
|
||||
|
||||
const contribution: PluginConnectionProviderContribution = {
|
||||
@@ -16,16 +26,6 @@ const contribution: PluginConnectionProviderContribution = {
|
||||
fields: [
|
||||
{ key: "host", label: "Host", type: "text", required: true, placeholder: "localhost" },
|
||||
{ key: "password", label: "Password", type: "password" },
|
||||
{
|
||||
key: "protocol",
|
||||
label: "Protocol",
|
||||
type: "radio",
|
||||
default: "https",
|
||||
options: [
|
||||
{ label: "HTTPS", value: "https" },
|
||||
{ label: "HTTP", value: "http" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -62,6 +62,39 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("PluginConnectionFields", () => {
|
||||
it("preserves multiline secret input and masks it again after its branch is reopened", async () => {
|
||||
const provider: PluginConnectionProviderContribution = {
|
||||
type: "connection-provider",
|
||||
id: "pem.connection",
|
||||
label: "PEM",
|
||||
database_type: "example",
|
||||
fields: [
|
||||
{ key: "mode", label: "Mode", type: "text", default: "tls" },
|
||||
{ key: "key", label: "Private key", type: "textarea", binding: "secret", visible_when: { field: "mode", one_of: ["tls"] } },
|
||||
],
|
||||
};
|
||||
const state = await mountContribution(provider, {});
|
||||
const textarea = document.querySelector<HTMLTextAreaElement>("#pem-connection-key")!;
|
||||
const pem = "-----BEGIN PRIVATE KEY-----\nexample\n-----END PRIVATE KEY-----\n";
|
||||
expect(textarea.classList.contains("secret-masked")).toBe(true);
|
||||
textarea.value = pem;
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await nextTick();
|
||||
expect(state.values.key).toBe(pem);
|
||||
textarea.parentElement!.querySelector<HTMLButtonElement>("button")!.click();
|
||||
await nextTick();
|
||||
expect(textarea.classList.contains("secret-masked")).toBe(false);
|
||||
expect(textarea.value).toBe(pem);
|
||||
state.values = { ...state.values, mode: "plain" };
|
||||
await nextTick();
|
||||
expect(document.querySelector("#pem-connection-key")).toBeNull();
|
||||
state.values = { ...state.values, mode: "tls" };
|
||||
await nextTick();
|
||||
const reopened = document.querySelector<HTMLTextAreaElement>("#pem-connection-key")!;
|
||||
expect(reopened.value).toBe(pem);
|
||||
expect(reopened.classList.contains("secret-masked")).toBe(true);
|
||||
});
|
||||
|
||||
it("renders declared fields and emits immutable model updates", async () => {
|
||||
const state = await mountFields({ password: "secret" });
|
||||
const hostInput = document.querySelector<HTMLInputElement>("#example-connection-host");
|
||||
@@ -99,15 +132,199 @@ describe("PluginConnectionFields", () => {
|
||||
expect(document.querySelector(".rounded-lg.border")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders radio fields and emits the selected option", async () => {
|
||||
const state = await mountFields();
|
||||
const radios = Array.from(document.querySelectorAll<HTMLInputElement>("input[type='radio']"));
|
||||
it("hides conditional fields until visible_when matches and marks required_when", async () => {
|
||||
const conditional: PluginConnectionProviderContribution = {
|
||||
type: "connection-provider",
|
||||
id: "cond.connection",
|
||||
label: "Conditional",
|
||||
database_type: "cond",
|
||||
fields: [
|
||||
{ key: "mode", label: "Mode", type: "text", default: "password" },
|
||||
{ key: "private_key_path", label: "Private key", type: "text", visible_when: { field: "mode", one_of: ["key"] }, required_when: { field: "mode", one_of: ["key"] } },
|
||||
],
|
||||
};
|
||||
const state = await mountContribution(conditional, {});
|
||||
|
||||
expect(radios).toHaveLength(2);
|
||||
expect(radios[0]?.checked).toBe(true);
|
||||
radios[1]?.click();
|
||||
expect(document.querySelector("#cond-connection-private_key_path")).toBeNull();
|
||||
|
||||
state.values = { mode: "key" };
|
||||
await nextTick();
|
||||
|
||||
expect(state.values.protocol).toBe("http");
|
||||
const keyInput = document.querySelector<HTMLInputElement>("#cond-connection-private_key_path");
|
||||
expect(keyInput).not.toBeNull();
|
||||
const label = document.querySelector('label[for="cond-connection-private_key_path"]');
|
||||
expect(label?.textContent).toContain("*");
|
||||
|
||||
state.values = { mode: "password" };
|
||||
await nextTick();
|
||||
expect(document.querySelector("#cond-connection-private_key_path")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps grandchild fields hidden while an intermediate default-only value would match (kafka msk_region regression)", async () => {
|
||||
// sasl_mechanism (PLAIN default, hidden without SASL) → oauth_token_source
|
||||
// (msk_iam default) → msk_region: the nested default must not surface an
|
||||
// always-required AWS region on a plain PLAINTEXT/SCRAM connection.
|
||||
const kafka: PluginConnectionProviderContribution = {
|
||||
type: "connection-provider",
|
||||
id: "kafka.connection",
|
||||
label: "Kafka",
|
||||
database_type: "kafka",
|
||||
fields: [
|
||||
{ key: "bootstrap_servers", label: "Bootstrap servers", type: "textarea", required: true },
|
||||
{
|
||||
key: "security_protocol",
|
||||
label: "Security protocol",
|
||||
type: "select",
|
||||
default: "PLAINTEXT",
|
||||
options: [
|
||||
{ label: "PLAINTEXT", value: "PLAINTEXT" },
|
||||
{ label: "SASL_SSL", value: "SASL_SSL" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "sasl_mechanism",
|
||||
label: "SASL mechanism",
|
||||
type: "select",
|
||||
default: "PLAIN",
|
||||
options: [
|
||||
{ label: "PLAIN", value: "PLAIN" },
|
||||
{ label: "OAUTHBEARER", value: "OAUTHBEARER" },
|
||||
],
|
||||
visible_when: { field: "security_protocol", one_of: ["SASL_PLAINTEXT", "SASL_SSL"] },
|
||||
},
|
||||
{
|
||||
key: "oauth_token_source",
|
||||
label: "OAuth token source",
|
||||
type: "select",
|
||||
default: "msk_iam",
|
||||
options: [
|
||||
{ label: "MSK IAM", value: "msk_iam" },
|
||||
{ label: "Static token", value: "static_token" },
|
||||
],
|
||||
visible_when: { field: "sasl_mechanism", one_of: ["OAUTHBEARER"] },
|
||||
},
|
||||
{ key: "msk_region", label: "MSK AWS region", type: "text", visible_when: { field: "oauth_token_source", one_of: ["msk_iam"] }, required_when: { field: "oauth_token_source", one_of: ["msk_iam"] } },
|
||||
],
|
||||
};
|
||||
const state = await mountContribution(kafka, {}, "io.dbx.kafka");
|
||||
|
||||
expect(document.querySelector("#kafka-connection-msk_region")).toBeNull();
|
||||
|
||||
state.values = { security_protocol: "SASL_SSL", sasl_mechanism: "OAUTHBEARER" };
|
||||
await nextTick();
|
||||
expect(document.querySelector("#kafka-connection-msk_region")).not.toBeNull();
|
||||
const label = document.querySelector('label[for="kafka-connection-msk_region"]');
|
||||
expect(label?.textContent).toContain("*");
|
||||
|
||||
// Selecting the static token source hides the region field again.
|
||||
state.values = { sasl_mechanism: "OAUTHBEARER", oauth_token_source: "static_token" };
|
||||
await nextTick();
|
||||
expect(document.querySelector("#kafka-connection-msk_region")).toBeNull();
|
||||
});
|
||||
|
||||
it("offers local SSH keys on private_key_path fields and fills the chosen path", async () => {
|
||||
const withKey: PluginConnectionProviderContribution = {
|
||||
type: "connection-provider",
|
||||
id: "keys.connection",
|
||||
label: "Keys",
|
||||
database_type: "keys",
|
||||
fields: [{ key: "private_key_path", label: "Private key", type: "text" }],
|
||||
};
|
||||
const state = await mountContribution(withKey, {});
|
||||
await flushAsync();
|
||||
|
||||
const select = document.querySelector<HTMLSelectElement>("select");
|
||||
expect(select).not.toBeNull();
|
||||
expect(select?.disabled).toBe(false);
|
||||
const encryptedOption = select?.querySelector<HTMLOptionElement>('option[value="/home/dev/.ssh/id_rsa"]');
|
||||
expect(encryptedOption?.textContent).toContain("id_rsa");
|
||||
expect(encryptedOption?.textContent).toContain("ssh-rsa");
|
||||
expect(encryptedOption?.textContent).toMatch(/encrypted|已加密/);
|
||||
|
||||
select!.value = "/home/dev/.ssh/id_rsa";
|
||||
select!.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await nextTick();
|
||||
|
||||
expect(state.values.private_key_path).toBe("/home/dev/.ssh/id_rsa");
|
||||
});
|
||||
|
||||
it("renders options_action fields as a dynamic select and keeps a stored value visible", async () => {
|
||||
const dynamic: PluginConnectionProviderContribution = {
|
||||
type: "connection-provider",
|
||||
id: "dyn.connection",
|
||||
label: "Dyn",
|
||||
database_type: "dyn",
|
||||
fields: [{ key: "sudo_profile", label: "Profile", type: "text", options_action: "sudo/profiles/options" }],
|
||||
};
|
||||
invokePluginMock.mockResolvedValueOnce({
|
||||
options: [
|
||||
{ value: "p1", label: "Ops" },
|
||||
{ value: "p2", label: "QA" },
|
||||
],
|
||||
});
|
||||
// "ghost" is stored but no longer offered: the dropdown must keep it visible.
|
||||
await mountContribution(dynamic, { sudo_profile: "ghost" }, "io.dbx.ssh");
|
||||
await flushAsync();
|
||||
|
||||
expect(invokePluginMock).toHaveBeenCalledWith("io.dbx.ssh", "sudo/profiles/options");
|
||||
expect(document.querySelector("input#dyn-connection-sudo_profile")).toBeNull();
|
||||
const trigger = document.querySelector<HTMLButtonElement>('#dyn-connection-sudo_profile, button[role="combobox"]');
|
||||
expect(trigger).not.toBeNull();
|
||||
expect(trigger?.textContent).toContain("ghost");
|
||||
});
|
||||
|
||||
it("falls back to the text input when the options_action call fails or returns nothing", async () => {
|
||||
const dynamic: PluginConnectionProviderContribution = {
|
||||
type: "connection-provider",
|
||||
id: "degrade.connection",
|
||||
label: "Degrade",
|
||||
database_type: "degrade",
|
||||
fields: [{ key: "sudo_profile", label: "Profile", type: "text", options_action: "sudo/profiles/options" }],
|
||||
};
|
||||
invokePluginMock.mockRejectedValueOnce(new Error("method not registered"));
|
||||
await mountContribution(dynamic, {}, "io.dbx.ssh");
|
||||
await flushAsync();
|
||||
|
||||
const input = document.querySelector<HTMLInputElement>("input#degrade-connection-sudo_profile");
|
||||
expect(input).not.toBeNull();
|
||||
expect(input?.value).toBe("");
|
||||
|
||||
// An empty option list degrades the same way.
|
||||
invokePluginMock.mockResolvedValueOnce({ options: [] });
|
||||
const empty = { ...dynamic, id: "empty.connection", label: "Empty" };
|
||||
await mountContribution(empty, {}, "io.dbx.ssh");
|
||||
await flushAsync();
|
||||
expect(document.querySelector("input#empty-connection-sudo_profile")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
async function flushAsync() {
|
||||
await nextTick();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
async function mountContribution(contribution: PluginConnectionProviderContribution, initialValues: Record<string, PluginFormFieldValue>, pluginId?: string) {
|
||||
const state = reactive({ values: initialValues });
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(PluginConnectionFields, {
|
||||
contribution,
|
||||
modelValue: state.values,
|
||||
pluginId,
|
||||
"onUpdate:modelValue": (value: Record<string, PluginFormFieldValue>) => {
|
||||
state.values = value;
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
mountedApps.push(app);
|
||||
app.use(i18n);
|
||||
app.mount(container);
|
||||
await nextTick();
|
||||
return state;
|
||||
}
|
||||
@@ -1,25 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import PasswordInput from "@/components/ui/PasswordInput.vue";
|
||||
import PasswordTextarea from "@/components/ui/PasswordTextarea.vue";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import type { PluginConnectionProviderContribution, PluginFormField, PluginFormFieldBinding, PluginFormFieldValue } from "@/types/database";
|
||||
import { pluginFieldIsRequired, pluginFieldIsVisible, type PluginFieldResolver } from "@/lib/plugins/pluginFieldConditions";
|
||||
import { invokePlugin, listLocalSshKeys } from "@/lib/backend/api";
|
||||
import type { LocalSshKey, PluginConnectionProviderContribution, PluginFormField, PluginFormFieldBinding, PluginFormFieldOption, PluginFormFieldValue } from "@/types/database";
|
||||
|
||||
const props = defineProps<{
|
||||
contribution: PluginConnectionProviderContribution;
|
||||
modelValue: Record<string, PluginFormFieldValue>;
|
||||
hiddenBindings?: PluginFormFieldBinding[];
|
||||
layout?: "stacked" | "connection-dialog";
|
||||
pluginId?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: Record<string, PluginFormFieldValue>];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const formValues = computed(() => props.modelValue);
|
||||
const visibleFields = computed(() => props.contribution.fields.filter((field) => !field.binding || !props.hiddenBindings?.includes(field.binding)));
|
||||
/** Value of any sibling field by key (condition evaluation reads current form values). */
|
||||
function readFieldValue(key: string): PluginFormFieldValue {
|
||||
const field = props.contribution.fields.find((candidate) => candidate.key === key);
|
||||
return field ? (formValues.value[key] ?? field.default) : undefined;
|
||||
}
|
||||
const resolveSiblingField: PluginFieldResolver = (key) => props.contribution.fields.find((candidate) => candidate.key === key);
|
||||
function fieldVisible(field: PluginFormField): boolean {
|
||||
if (field.binding && props.hiddenBindings?.includes(field.binding)) return false;
|
||||
return pluginFieldIsVisible(field, readFieldValue, resolveSiblingField);
|
||||
}
|
||||
function fieldRequired(field: PluginFormField): boolean {
|
||||
return pluginFieldIsRequired(field, readFieldValue);
|
||||
}
|
||||
const visibleFields = computed(() => props.contribution.fields.filter((field) => fieldVisible(field)));
|
||||
const isConnectionDialogLayout = computed(() => props.layout === "connection-dialog");
|
||||
|
||||
function fieldValue(field: PluginFormField): PluginFormFieldValue {
|
||||
@@ -59,6 +79,103 @@ function defaultValueFor(field: PluginFormField): PluginFormFieldValue {
|
||||
if (field.type === "number") return undefined;
|
||||
return "";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dynamic option lists (fields declaring `options_action`)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// A field may point `options_action` at a plugin method returning
|
||||
// `{ options: [{ value, label }] }`. The host fetches it once per dialog and
|
||||
// renders the field as a select; a missing plugin id, a failed call, or an
|
||||
// empty result falls back to the declared text input (older hosts without
|
||||
// this extension never fetch and simply keep the text field).
|
||||
const dynamicOptions = ref<Record<string, PluginFormFieldOption[]>>({});
|
||||
const dynamicOptionsRequested = ref(new Set<string>());
|
||||
|
||||
const optionsActionFields = computed(() => visibleFields.value.filter((field) => field.options_action));
|
||||
|
||||
watch(
|
||||
optionsActionFields,
|
||||
(fields) => {
|
||||
for (const field of fields) {
|
||||
const action = field.options_action;
|
||||
if (!action || !props.pluginId || dynamicOptionsRequested.value.has(field.key)) continue;
|
||||
dynamicOptionsRequested.value.add(field.key);
|
||||
invokePlugin<{ options?: PluginFormFieldOption[] }>(props.pluginId, action)
|
||||
.then((result) => {
|
||||
const options = Array.isArray(result?.options) ? result.options.filter((option) => option && option.value !== undefined) : [];
|
||||
dynamicOptions.value = { ...dynamicOptions.value, [field.key]: options };
|
||||
})
|
||||
.catch(() => {
|
||||
dynamicOptions.value = { ...dynamicOptions.value, [field.key]: [] };
|
||||
});
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function selectOptionsFor(field: PluginFormField): PluginFormFieldOption[] | null {
|
||||
if (!field.options_action) return null;
|
||||
const options = dynamicOptions.value[field.key];
|
||||
if (!options || options.length === 0) return null;
|
||||
// Keep a stored value visible even when its profile disappeared so the
|
||||
// dialog does not silently look "unset" on reopen.
|
||||
const current = fieldValue(field);
|
||||
if (current !== undefined && current !== "" && !options.some((option) => String(option.value) === String(current))) {
|
||||
return [{ value: String(current), label: String(current) }, ...options];
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Local SSH key suggestions (Host API: private_key_path convenience picker)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const LOCAL_SSH_KEY_FIELD = "private_key_path";
|
||||
|
||||
function isLocalSshKeyField(field: PluginFormField): boolean {
|
||||
return field.key === LOCAL_SSH_KEY_FIELD && (field.type === "text" || field.type === "password");
|
||||
}
|
||||
|
||||
const hasLocalSshKeyField = computed(() => visibleFields.value.some((field) => isLocalSshKeyField(field)));
|
||||
|
||||
const localSshKeys = ref<LocalSshKey[]>([]);
|
||||
const localSshKeysRequested = ref(false);
|
||||
|
||||
async function loadLocalSshKeys() {
|
||||
if (localSshKeysRequested.value) return;
|
||||
localSshKeysRequested.value = true;
|
||||
try {
|
||||
localSshKeys.value = await listLocalSshKeys();
|
||||
} catch {
|
||||
localSshKeys.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
hasLocalSshKeyField,
|
||||
(present) => {
|
||||
if (present) void loadLocalSshKeys();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function localSshKeyFile(path: string): string {
|
||||
return path.replaceAll("\\", "/").split("/").pop() || path;
|
||||
}
|
||||
|
||||
function localSshKeyLabel(key: LocalSshKey): string {
|
||||
const algorithm = key.algorithm || "?";
|
||||
return key.hasPassphrase ? `${localSshKeyFile(key.path)} · ${algorithm} · ${t("connection.pluginSshKeyEncrypted")}` : `${localSshKeyFile(key.path)} · ${algorithm}`;
|
||||
}
|
||||
|
||||
function applyLocalSshKey(field: PluginFormField, event: Event) {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
const selected = localSshKeys.value.find((key) => key.path === select.value);
|
||||
select.value = "";
|
||||
if (!selected) return;
|
||||
updateField(field, selected.path);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -75,11 +192,37 @@ function defaultValueFor(field: PluginFormField): PluginFormFieldValue {
|
||||
<div v-for="field in visibleFields" :key="field.key" :class="isConnectionDialogLayout ? 'col-span-full grid grid-cols-4 items-start gap-4' : 'space-y-1.5'">
|
||||
<Label :for="fieldId(field)" :class="isConnectionDialogLayout ? 'justify-self-start pt-2 text-left' : 'text-xs'">
|
||||
{{ field.label }}
|
||||
<span v-if="field.required" class="text-destructive">*</span>
|
||||
<span v-if="fieldRequired(field)" class="text-destructive">*</span>
|
||||
</Label>
|
||||
<div :class="isConnectionDialogLayout ? 'col-span-3 min-w-0 space-y-1.5' : ''">
|
||||
<Input v-if="field.type === 'text' || field.type === 'number'" :id="fieldId(field)" :type="field.type === 'number' ? 'number' : 'text'" :model-value="fieldValue(field) as string | number | undefined" :placeholder="field.placeholder" @update:model-value="updateTextField(field, $event)" />
|
||||
<template v-if="field.type === 'text' || field.type === 'number'">
|
||||
<Select v-if="field.type === 'text' && selectOptionsFor(field)" :model-value="String(fieldValue(field) ?? '')" @update:model-value="updateSelectField(field, $event)">
|
||||
<SelectTrigger :id="fieldId(field)" :class="isConnectionDialogLayout ? 'h-9' : 'h-8 text-xs'">
|
||||
<SelectValue :placeholder="field.placeholder" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="option in selectOptionsFor(field)!" :key="String(option.value)" :value="String(option.value)">
|
||||
{{ option.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div v-else-if="isLocalSshKeyField(field)" class="flex items-center gap-1.5">
|
||||
<Input :id="fieldId(field)" type="text" :model-value="fieldValue(field) as string | number | undefined" :placeholder="field.placeholder" class="min-w-0 flex-1" @update:model-value="updateTextField(field, $event)" />
|
||||
<select
|
||||
class="h-9 w-40 shrink-0 rounded-md border border-input bg-transparent px-2 text-xs outline-none transition-colors focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="localSshKeys.length === 0"
|
||||
:title="t('connection.pluginSshKeyPickerPlaceholder')"
|
||||
:aria-label="t('connection.pluginSshKeyPickerPlaceholder')"
|
||||
@change="applyLocalSshKey(field, $event)"
|
||||
>
|
||||
<option value="" disabled selected>{{ localSshKeys.length ? t("connection.pluginSshKeyPickerPlaceholder") : t("connection.pluginSshKeyPickerEmpty") }}</option>
|
||||
<option v-for="key in localSshKeys" :key="key.path" :value="key.path" :title="key.path">{{ localSshKeyLabel(key) }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<Input v-else :id="fieldId(field)" :type="field.type === 'number' ? 'number' : 'text'" :model-value="fieldValue(field) as string | number | undefined" :placeholder="field.placeholder" @update:model-value="updateTextField(field, $event)" />
|
||||
</template>
|
||||
<PasswordInput v-else-if="field.type === 'password'" :id="fieldId(field)" :model-value="String(fieldValue(field) ?? '')" :placeholder="field.placeholder" @update:model-value="updateField(field, $event)" />
|
||||
<PasswordTextarea v-else-if="field.type === 'textarea' && field.binding === 'secret'" :id="fieldId(field)" :model-value="String(fieldValue(field) ?? '')" :placeholder="field.placeholder" @update:model-value="updateField(field, $event)" />
|
||||
<textarea
|
||||
v-else-if="field.type === 'textarea'"
|
||||
:id="fieldId(field)"
|
||||
@@ -98,12 +241,6 @@ function defaultValueFor(field: PluginFormField): PluginFormFieldValue {
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div v-else-if="field.type === 'radio'" :id="fieldId(field)" class="flex min-h-9 flex-wrap items-center gap-x-4 gap-y-2" role="radiogroup" :aria-label="field.label">
|
||||
<label v-for="option in field.options || []" :key="option.value" class="inline-flex cursor-pointer items-center gap-2 text-sm">
|
||||
<input type="radio" :name="fieldId(field)" :value="option.value" :checked="String(fieldValue(field) ?? '') === option.value" class="size-4 accent-primary" @change="updateField(field, option.value)" />
|
||||
<span>{{ option.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div v-else-if="field.type === 'boolean'" class="flex h-9 items-center">
|
||||
<Switch :id="fieldId(field)" :model-value="Boolean(fieldValue(field))" size="sm" @update:model-value="updateBooleanField(field, $event)" />
|
||||
</div>
|
||||
|
||||
@@ -25,10 +25,14 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const { t, locale: appLocale } = useI18n();
|
||||
const { isDark, activeCustomUiColors } = useTheme();
|
||||
const { isDark, themeRevision } = useTheme();
|
||||
const iframe = ref<HTMLIFrameElement>();
|
||||
const source = ref("");
|
||||
const loading = ref(true);
|
||||
// Stays false until the iframe's load event: WKWebView paints a white canvas
|
||||
// for a freshly inserted iframe before the sandbox document's first styled
|
||||
// frame, so the themed overlay must keep covering the frame area until then.
|
||||
const frameReady = ref(false);
|
||||
const error = ref("");
|
||||
let bridge: PluginHostBridge | undefined;
|
||||
let unsubscribeEvents: (() => void) | undefined;
|
||||
@@ -116,6 +120,7 @@ async function loadWorkbench() {
|
||||
const generation = ++loadGeneration;
|
||||
bridge = undefined;
|
||||
loading.value = true;
|
||||
frameReady.value = false;
|
||||
error.value = "";
|
||||
try {
|
||||
if (!props.plugin.compatibility.compatible) throw new Error((props.plugin.compatibility.errors || []).join("; ") || t("pluginPlatform.pluginIncompatible"));
|
||||
@@ -132,7 +137,8 @@ async function loadWorkbench() {
|
||||
if (disposed || generation !== loadGeneration) return;
|
||||
error.value = cause instanceof Error ? cause.message : String(cause);
|
||||
emit("error", error.value);
|
||||
loading.value = false;
|
||||
} finally {
|
||||
if (!disposed && generation === loadGeneration) loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,9 +147,18 @@ function onMessage(event: MessageEvent) {
|
||||
}
|
||||
|
||||
function onFrameLoad() {
|
||||
// The load event can precede the webview's first actual paint (notably on
|
||||
// WKWebView); reveal after two animation frames, with a timer fallback
|
||||
// because rAF stalls in occluded/background webviews. Guarded by generation
|
||||
// so a stale callback from a rebuilt iframe can't lift the new overlay.
|
||||
const generation = loadGeneration;
|
||||
const reveal = () => {
|
||||
if (!disposed && generation === loadGeneration) frameReady.value = true;
|
||||
};
|
||||
requestAnimationFrame(() => requestAnimationFrame(reveal));
|
||||
setTimeout(reveal, 400);
|
||||
bridge?.sendInit();
|
||||
emit("ready");
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -172,7 +187,10 @@ watch(
|
||||
{ deep: true },
|
||||
);
|
||||
watch(appLocale, (locale) => bridge?.updateLocale(locale));
|
||||
watch([isDark, activeCustomUiColors], () => bridge?.updateTheme(currentBridgeTheme()), { deep: true });
|
||||
// Keyed on the theme revision (bumped by applyTheme) so every theme change
|
||||
// path — dark/light, palette switch, custom colors — re-pushes the resolved
|
||||
// tokens; watching isDark/custom colors alone misses palette-only switches.
|
||||
watch(themeRevision, () => bridge?.updateTheme(currentBridgeTheme()));
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true;
|
||||
@@ -185,14 +203,25 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template>
|
||||
<div class="relative flex size-full min-h-40 overflow-hidden bg-background">
|
||||
<div v-if="error" class="m-auto flex max-w-lg items-start gap-3 rounded-lg border border-destructive/40 bg-destructive/5 p-4 text-sm text-destructive">
|
||||
<AlertTriangle class="mt-0.5 size-4 shrink-0" />
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
<iframe v-else-if="source" ref="iframe" :title="title" :srcdoc="source" sandbox="allow-scripts" referrerpolicy="no-referrer" class="size-full border-0 bg-transparent" @load="onFrameLoad" />
|
||||
<div v-if="loading && !error" class="absolute inset-0 z-10 flex items-center justify-center bg-background/80 text-sm text-muted-foreground backdrop-blur-sm">
|
||||
<div v-if="loading" class="absolute inset-0 z-10 flex items-center justify-center bg-background text-sm text-muted-foreground">
|
||||
<Loader2 class="mr-2 size-4 animate-spin" />
|
||||
{{ t("pluginPlatform.loadingTitle", { title }) }}
|
||||
</div>
|
||||
<div v-else-if="error" class="m-auto flex max-w-lg items-start gap-3 rounded-lg border border-destructive/40 bg-destructive/5 p-4 text-sm text-destructive">
|
||||
<AlertTriangle class="mt-0.5 size-4 shrink-0" />
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
<template v-else>
|
||||
<iframe ref="iframe" :title="title" :srcdoc="source" sandbox="allow-scripts" referrerpolicy="no-referrer" class="size-full border-0 bg-transparent" @load="onFrameLoad" />
|
||||
<!-- Cover until the frame has actually painted: the iframe stays mounted
|
||||
underneath so its load event can fire (v-else on the overlay would
|
||||
deadlock), it just isn't visible yet. Fully opaque so the covered
|
||||
phase is visually identical to the host background, and faded out
|
||||
instead of removed so the reveal is never a hard swap. -->
|
||||
<div class="absolute inset-0 z-10 flex items-center justify-center bg-background text-sm text-muted-foreground transition-opacity duration-150 ease-out" :class="frameReady ? 'pointer-events-none opacity-0' : 'opacity-100'">
|
||||
<Loader2 class="mr-2 size-4 animate-spin" />
|
||||
{{ t("pluginPlatform.loadingTitle", { title }) }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
Reference in new issue
Block a user