feat(plugins): add interactive prompts and richer connection forms

This commit is contained in:
Jinpy authored and GitHub committed 2026-09-17 19:29:28 +08:00
1 parent fac270fc8a
commit e4bda693fb
42 files changed
+3922 -200

No files matched your search

+86 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import type { InstalledPlugin, PluginConnectionProviderContribution } from "@/types/database";
import type { InstalledPlugin, PluginConnectionProviderContribution, PluginFormFieldValue } from "@/types/database";
import { buildPluginConnectionConfig, createFrontendPluginRegistry, initialPluginFormValues, parsePluginConnectionProviderOptionValue, pluginConnectionActionsForDialog, pluginConnectionFormValues, pluginConnectionProviderIcon, pluginConnectionProviderOptionValue } from "./frontendPlugin";
function installedPlugin(id: string, contributions: InstalledPlugin["manifest"]["contributions"] = []): InstalledPlugin {
@@ -324,6 +324,91 @@ describe("FrontendPluginRegistry", () => {
expect(values).not.toHaveProperty("host");
});
it("treats a host-serialized null default as unset instead of a value", () => {
// Hosts before the manifest serialization fix sent `"default": null` for
// every field that declares no default, so an untouched password field
// turned into the four-character string "null" once it was saved.
const provider = connectionProvider({
fields: [
{ key: "sudo_password", label: "Sudo password", type: "password", binding: "secret", default: null },
{ key: "mode", label: "Mode", type: "text", binding: "config", default: null },
{ key: "read_only", label: "Read only", type: "boolean", binding: "config", default: false },
],
});
expect(initialPluginFormValues(provider)).toEqual({ read_only: false });
const config = buildPluginConnectionConfig("io.dbx.ssh", provider, {
sudo_password: null,
mode: null,
read_only: false,
} as unknown as Record<string, string>);
expect(config.connection_secrets?.sudo_password).toBeUndefined();
expect(config.external_config).toEqual({ read_only: false });
});
it("preserves opaque 'null' credentials when reopening and saving connections", () => {
const provider = connectionProvider({
fields: [
{ key: "sudo_password", label: "Sudo password", type: "password", binding: "secret" },
{ key: "totp_secret", label: "TOTP secret", type: "textarea", binding: "secret" },
{ key: "sudo_source", label: "Sudo source", type: "text", binding: "config" },
],
});
const existing = buildPluginConnectionConfig("io.dbx.ssh", provider, {});
existing.connection_secrets = { sudo_password: "null", totp_secret: "JBSWY3DPEHPK3PXP" };
existing.external_config = { sudo_source: "custom", stale: null };
const values = pluginConnectionFormValues(provider, existing);
expect(values.sudo_password).toBe("null");
expect(values.totp_secret).toBe("JBSWY3DPEHPK3PXP");
expect(values.stale).toBeUndefined();
expect(values.sudo_source).toBe("custom");
const saved = buildPluginConnectionConfig("io.dbx.ssh", provider, values, existing);
expect(saved.connection_secrets?.sudo_password).toBe("null");
expect(saved.connection_secrets?.totp_secret).toBe("JBSWY3DPEHPK3PXP");
});
it.each([undefined, "secret", "password"] as const)("round-trips explicit 'null' credentials with binding %s", (binding) => {
const provider = connectionProvider({ fields: [{ key: "credential", label: "Credential", type: "password", binding }] });
const saved = buildPluginConnectionConfig("example.plugin", provider, { credential: "null" });
expect(binding === "password" ? saved.password : saved.connection_secrets?.credential).toBe("null");
expect(pluginConnectionFormValues(provider, saved)).toEqual({ credential: "null" });
const reopened = buildPluginConnectionConfig("example.plugin", provider, pluginConnectionFormValues(provider, saved), saved);
expect(pluginConnectionFormValues(provider, reopened)).toEqual({ credential: "null" });
});
it("migrates an opaque 'null' credential from config to secret storage without losing it", () => {
const provider = connectionProvider({ fields: [{ key: "credential", label: "Credential", type: "password" }] });
const existing = buildPluginConnectionConfig("example.plugin", provider, {});
existing.external_config = { credential: "null" };
const values = pluginConnectionFormValues(provider, existing);
expect(values).toEqual({ credential: "null" });
const saved = buildPluginConnectionConfig("example.plugin", provider, values, existing);
expect(saved.connection_secrets).toEqual({ credential: "null" });
expect(saved.external_config).toEqual({});
});
it.each([null, undefined, ""])("keeps an unset credential %s absent without a default", (credential) => {
const provider = connectionProvider({ fields: [{ key: "credential", label: "Credential", type: "password" }] });
const existing = buildPluginConnectionConfig("example.plugin", provider, {});
existing.connection_secrets = { credential: "old-secret" };
const saved = buildPluginConnectionConfig("example.plugin", provider, { credential } as unknown as Record<string, PluginFormFieldValue>, existing);
expect(saved.connection_secrets).toEqual({});
expect(pluginConnectionFormValues(provider, saved)).toEqual({});
});
it("uses an explicit string default only for missing input, not a cleared input", () => {
const provider = connectionProvider({ fields: [{ key: "credential", label: "Credential", type: "password", default: "null" }] });
expect(initialPluginFormValues(provider)).toEqual({ credential: "null" });
expect(buildPluginConnectionConfig("example.plugin", provider, {}).connection_secrets).toEqual({ credential: "null" });
expect(buildPluginConnectionConfig("example.plugin", provider, { credential: null } as unknown as Record<string, PluginFormFieldValue>).connection_secrets).toEqual({});
expect(buildPluginConnectionConfig("example.plugin", provider, { credential: "" }).connection_secrets).toEqual({});
});
it("maps provider fields into common, config, and secret storage", () => {
const provider: PluginConnectionProviderContribution = {
type: "connection-provider",
+10 -17
View File
@@ -115,7 +115,9 @@ export function pluginConnectionActionsForDialog(contribution: PluginConnectionP
}
export function initialPluginFormValues(contribution: PluginConnectionProviderContribution): Record<string, PluginFormFieldValue> {
return Object.fromEntries(contribution.fields.filter((field) => field.default !== undefined).map((field) => [field.key, field.default])) as Record<string, PluginFormFieldValue>;
// `null` means "no default" (older hosts serialized the absent case that way),
// so it must not seed the form with a value the user never typed.
return Object.fromEntries(contribution.fields.filter((field) => field.default !== undefined && field.default !== null).map((field) => [field.key, field.default])) as Record<string, PluginFormFieldValue>;
}
export function pluginConnectionFormValues(contribution: PluginConnectionProviderContribution, config?: ConnectionConfig): Record<string, PluginFormFieldValue> {
@@ -125,22 +127,9 @@ export function pluginConnectionFormValues(contribution: PluginConnectionProvide
const secrets = config.connection_secrets || {};
for (const field of contribution.fields) {
const binding = effectiveFieldBinding(field);
const secret = secrets[field.key] ?? externalConfig[field.key];
const value =
binding === "name"
? config.name
: binding === "host"
? config.host
: binding === "port"
? config.port
: binding === "username"
? config.username
: binding === "password"
? config.password
: binding === "database"
? config.database
: binding === "secret"
? (secrets[field.key] ?? externalConfig[field.key])
: externalConfig[field.key];
binding === "name" ? config.name : binding === "host" ? config.host : binding === "port" ? config.port : binding === "username" ? config.username : binding === "password" ? config.password : binding === "database" ? config.database : binding === "secret" ? secret : externalConfig[field.key];
// Port 0 is the stored representation of an optional, automatic port.
// Keep that input empty on reopen so its protocol-default hint remains visible.
if (binding === "port" && value === 0 && field.default === undefined) delete values[field.key];
@@ -180,7 +169,11 @@ export function buildPluginConnectionConfig(pluginId: string, contribution: Plug
production_databases: existing?.production_databases || [],
};
for (const field of contribution.fields) {
const value = values[field.key] ?? field.default;
// A `null` coming from the form (or from a host that hydrated absent
// defaults as `null`) means "unset": fall back to the declared default and
// otherwise clear the stored value instead of persisting a null.
const raw = values[field.key];
const value = raw === null ? undefined : (raw ?? field.default ?? undefined);
const binding = effectiveFieldBinding(field);
if (binding === "config") {
if (value === undefined) delete externalConfig[field.key];
+173 -9
View File
@@ -1,22 +1,84 @@
import { describe, expect, it } from "vitest";
import { pluginFieldConditionMatches, pluginFieldIsRequired, pluginFieldIsVisible } from "./pluginFieldConditions";
import type { PluginFormField, PluginFormFieldValue } from "@/types/database";
import { pluginConditionValueText, pluginFieldConditionMatches, pluginFieldConditionReferencedFields, pluginFieldIsRequired, pluginFieldIsVisible } from "./pluginFieldConditions";
import type { PluginFieldCondition, PluginFormField, PluginFormFieldValue } from "@/types/database";
function field(overrides: Partial<PluginFormField>): PluginFormField {
return { key: "demo", label: "Demo", type: "text", ...overrides } as PluginFormField;
}
const readValue = (key: string) => (key === "mode" ? "custom" : undefined);
const readValue = (key: string): PluginFormFieldValue => (key === "mode" ? "custom" : undefined);
/** Reads a fixed map, so a condition can be evaluated against known values. */
function reader(values: Record<string, PluginFormFieldValue>): (key: string) => PluginFormFieldValue {
return (key) => values[key];
}
describe("pluginFieldConditionMatches", () => {
it("matches a missing condition and a listed value", () => {
expect(pluginFieldConditionMatches(undefined, undefined)).toBe(true);
expect(pluginFieldConditionMatches({ field: "mode", one_of: ["custom"] }, "custom")).toBe(true);
expect(pluginFieldConditionMatches(undefined, () => undefined)).toBe(true);
expect(pluginFieldConditionMatches({ field: "mode", one_of: ["custom"] }, reader({ mode: "custom" }))).toBe(true);
});
it("rejects an unlisted or empty value", () => {
expect(pluginFieldConditionMatches({ field: "mode", one_of: ["custom"] }, "direct")).toBe(false);
expect(pluginFieldConditionMatches({ field: "mode", one_of: ["custom"] }, undefined)).toBe(false);
expect(pluginFieldConditionMatches({ field: "mode", one_of: ["custom"] }, reader({ mode: "direct" }))).toBe(false);
expect(pluginFieldConditionMatches({ field: "mode", one_of: ["custom"] }, reader({}))).toBe(false);
expect(pluginFieldConditionMatches({ field: "mode", one_of: ["custom"] }, reader({ mode: " " }))).toBe(false);
});
it("compares boolean and number literals by canonical string form", () => {
// `read_only = false` is the composite-condition driver the SSH plugin needs.
const readOnly = { field: "read_only", one_of: [false] } as PluginFieldCondition;
expect(pluginFieldConditionMatches(readOnly, reader({ read_only: false }))).toBe(true);
expect(pluginFieldConditionMatches(readOnly, reader({ read_only: true }))).toBe(false);
expect(pluginFieldConditionMatches({ field: "read_only", one_of: ["false"] }, reader({ read_only: false }))).toBe(true);
expect(pluginFieldConditionMatches({ field: "port", one_of: [2222] }, reader({ port: 2222 }))).toBe(true);
expect(pluginFieldConditionMatches({ field: "port", one_of: [2222.0] }, reader({ port: 2222 }))).toBe(true);
expect(pluginConditionValueText(false)).toBe("false");
expect(pluginConditionValueText(22)).toBe("22");
});
it("composes clauses with all_of, any_of and not", () => {
const allOf = {
all_of: [
{ field: "sudo_source", one_of: ["custom"] },
{ field: "read_only", one_of: [false] },
],
} as PluginFieldCondition;
expect(pluginFieldConditionMatches(allOf, reader({ sudo_source: "custom", read_only: false }))).toBe(true);
expect(pluginFieldConditionMatches(allOf, reader({ sudo_source: "custom", read_only: true }))).toBe(false);
expect(pluginFieldConditionMatches(allOf, reader({ sudo_source: "default", read_only: false }))).toBe(false);
const anyOf = {
any_of: [
{ field: "authentication", one_of: ["password"] },
{ field: "authentication", one_of: ["keyboard-interactive"] },
],
} as PluginFieldCondition;
expect(pluginFieldConditionMatches(anyOf, reader({ authentication: "keyboard-interactive" }))).toBe(true);
expect(pluginFieldConditionMatches(anyOf, reader({ authentication: "agent" }))).toBe(false);
const not = { not: { field: "read_only", one_of: [true] } } as PluginFieldCondition;
expect(pluginFieldConditionMatches(not, reader({ read_only: false }))).toBe(true);
expect(pluginFieldConditionMatches(not, reader({ read_only: true }))).toBe(false);
const nested = { all_of: [anyOf, not, { any_of: [{ all_of: [{ field: "read_only", one_of: [false] }] }] }] } as PluginFieldCondition;
expect(pluginFieldConditionMatches(nested, reader({ authentication: "password", read_only: false }))).toBe(true);
expect(pluginFieldConditionMatches(nested, reader({ authentication: "password", read_only: true }))).toBe(false);
});
it("lists every referenced field of a nested expression", () => {
expect(
pluginFieldConditionReferencedFields({
all_of: [
{
any_of: [
{ field: "a", one_of: ["1"] },
{ field: "b", one_of: ["1"] },
],
},
{ not: { field: "c", one_of: ["1"] } },
],
}),
).toEqual(["a", "b", "c"]);
});
});
@@ -35,6 +97,19 @@ describe("pluginFieldIsRequired", () => {
expect(pluginFieldIsRequired(conditional, () => "direct")).toBe(false);
expect(pluginFieldIsRequired(conditional, () => undefined)).toBe(false);
});
it("requires a field only while every clause of an all_of required_when matches", () => {
const conditional = field({
required_when: {
all_of: [
{ field: "sudo_source", one_of: ["custom"] },
{ field: "read_only", one_of: [false] },
],
},
});
expect(pluginFieldIsRequired(conditional, reader({ sudo_source: "custom", read_only: false }))).toBe(true);
expect(pluginFieldIsRequired(conditional, reader({ sudo_source: "custom", read_only: true }))).toBe(false);
});
});
describe("pluginFieldIsVisible", () => {
@@ -45,6 +120,62 @@ describe("pluginFieldIsVisible", () => {
expect(pluginFieldIsVisible(conditional, () => "direct")).toBe(false);
});
it("renders a field only while every clause of an all_of visible_when matches", () => {
const conditional = field({
visible_when: {
all_of: [
{ field: "sudo_source", one_of: ["custom"] },
{ field: "read_only", one_of: [false] },
],
},
});
expect(pluginFieldIsVisible(conditional, reader({ sudo_source: "custom", read_only: false }))).toBe(true);
expect(pluginFieldIsVisible(conditional, reader({ sudo_source: "custom", read_only: true }))).toBe(false);
expect(pluginFieldIsVisible(conditional, reader({ read_only: false }))).toBe(false);
});
it("keeps an unused any_of branch from hiding a matching field", () => {
// `visible_when.any_of` matches through the first clause; the second clause
// references a sibling that is itself hidden, and that must not matter.
const fields: Record<string, PluginFormField> = {
source: field({ key: "source", default: "custom" }),
hidden_controller: field({ key: "hidden_controller", default: "on", visible_when: { field: "source", one_of: ["remote"] } }),
target: field({
key: "target",
visible_when: {
any_of: [
{ field: "source", one_of: ["custom"] },
{ field: "hidden_controller", one_of: ["on"] },
],
},
}),
};
const read: (key: string) => PluginFormFieldValue = (key) => fields[key]?.default ?? undefined;
const resolve: (key: string) => PluginFormField | undefined = (key) => fields[key];
expect(pluginFieldIsVisible(fields.target, read, resolve)).toBe(true);
// ...while a match that *depends* on the hidden controller stays dormant.
const onlyHidden = field({
key: "only_hidden",
visible_when: { any_of: [{ field: "hidden_controller", one_of: ["on"] }] },
});
expect(pluginFieldIsVisible(onlyHidden, read, resolve)).toBe(false);
});
it("treats a hidden operand of `not` as dormant instead of lighting the field up", () => {
const fields: Record<string, PluginFormField> = {
mode: field({ key: "mode", default: "remote" }),
read_only: field({ key: "read_only", default: false, visible_when: { field: "mode", one_of: ["local"] } }),
writable_options: field({ key: "writable_options", visible_when: { not: { field: "read_only", one_of: [true] } } }),
};
const read: (key: string) => PluginFormFieldValue = (key) => fields[key]?.default ?? undefined;
const resolve: (key: string) => PluginFormField | undefined = (key) => fields[key];
expect(pluginFieldIsVisible(fields.writable_options, read, resolve)).toBe(false);
const localRead = reader({ mode: "local", read_only: false });
expect(pluginFieldIsVisible(fields.writable_options, localRead, resolve)).toBe(true);
});
it("keeps a condition dormant while its referenced sibling is hidden, even when the sibling default would match", () => {
// kafka-shaped chain: sasl_mechanism (default PLAIN, hidden) →
// oauth_token_source (visible only for OAUTHBEARER, defaults msk_iam) →
@@ -56,16 +187,49 @@ describe("pluginFieldIsVisible", () => {
oauth_token_source: field({ key: "oauth_token_source", default: "msk_iam", visible_when: { field: "sasl_mechanism", one_of: ["OAUTHBEARER"] } }),
msk_region: field({ key: "msk_region", visible_when: { field: "oauth_token_source", one_of: ["msk_iam"] } }),
};
const read: (key: string) => PluginFormFieldValue = (key) => fields[key]?.default;
const read: (key: string) => PluginFormFieldValue = (key) => fields[key]?.default ?? undefined;
const resolve: (key: string) => PluginFormField | undefined = (key) => fields[key];
expect(pluginFieldIsVisible(fields.msk_region, read, resolve)).toBe(false);
// Turning OAUTHBEARER on makes the whole chain visible again.
const readOauth: (key: string) => PluginFormFieldValue = (key) => (key === "sasl_mechanism" ? "OAUTHBEARER" : key === "security_protocol" ? "SASL_SSL" : fields[key]?.default);
const readOauth: (key: string) => PluginFormFieldValue = (key) => (key === "sasl_mechanism" ? "OAUTHBEARER" : key === "security_protocol" ? "SASL_SSL" : (fields[key]?.default ?? undefined));
expect(pluginFieldIsVisible(fields.oauth_token_source, readOauth, resolve)).toBe(true);
expect(pluginFieldIsVisible(fields.msk_region, readOauth, resolve)).toBe(true);
});
it.each([false, true])("keeps sibling branches path-local with reverse order %s", (reverse) => {
const fields: Record<string, PluginFormField> = {
mode: field({ key: "mode" }),
auth: field({ key: "auth", visible_when: { field: "mode", one_of: ["enabled"] } }),
};
const clause: PluginFieldCondition = { field: "auth", one_of: ["password"] };
const branches: PluginFieldCondition[] = [{ all_of: [clause, { field: "flag", one_of: [true] }] }, { all_of: [{ field: "flag", one_of: [false] }, clause] }];
if (reverse) branches.reverse();
const resolve = (key: string) => fields[key];
for (const flag of [false, true]) {
const hidden = reader({ mode: "disabled", auth: "password", flag });
const visible = reader({ mode: "enabled", auth: "password", flag });
for (const condition of [clause, { any_of: [clause, clause] }, { any_of: branches }, { all_of: [clause, clause] }] satisfies PluginFieldCondition[]) {
const target = field({ key: "target", visible_when: condition });
expect(pluginFieldIsVisible(target, hidden, resolve)).toBe(false);
expect(pluginFieldIsVisible(target, visible, resolve)).toBe(true);
}
const negated = field({ visible_when: { not: { any_of: [clause, clause] } } });
expect(pluginFieldIsVisible(negated, hidden, resolve)).toBe(false);
expect(pluginFieldIsVisible(negated, reader({ mode: "enabled", auth: "token", flag }), resolve)).toBe(true);
}
});
it("preserves true mutual-cycle compatibility without bypassing value checks", () => {
const fields: Record<string, PluginFormField> = {
first: field({ key: "first", visible_when: { field: "second", one_of: ["on"] } }),
second: field({ key: "second", visible_when: { field: "first", one_of: ["on"] } }),
};
const resolve = (key: string) => fields[key];
expect(pluginFieldIsVisible(fields.first, reader({ first: "on", second: "on" }), resolve)).toBe(true);
expect(pluginFieldIsVisible(fields.first, reader({ first: "off", second: "on" }), resolve)).toBe(false);
});
it("ignores unknown condition fields and survives self-referencing cycles", () => {
const unknown = field({ visible_when: { field: "missing", one_of: ["x"] } });
expect(
@@ -1,14 +1,67 @@
import type { PluginFieldCondition, PluginFormField, PluginFormFieldValue } from "@/types/database";
/**
* Evaluates a plugin manifest field condition (`visible_when` / `required_when`,
* Host API 1.1). A condition matches when the current value of the referenced
* sibling field is listed in `one_of`. Missing conditions always match.
* Canonical string form of a form value, matching the Rust host
* (`condition_value_text` in `crates/dbx-core/src/plugins/manifest.rs`).
* `false` renders as `"false"` and `22.0` as `"22"`.
*/
export function pluginFieldConditionMatches(condition: PluginFieldCondition | undefined, value: PluginFormFieldValue): boolean {
if (!condition) return true;
export function pluginConditionValueText(value: PluginFormFieldValue): string {
if (value === undefined || value === null) return "";
return String(value);
}
/** Reads the current value of a sibling form field by key. */
export type PluginFieldValueReader = (key: string) => PluginFormFieldValue;
/** A leaf clause matches when the sibling value equals one of its literals. */
function conditionClauseMatches(clause: { field: string; one_of: readonly (string | number | boolean)[] }, value: PluginFormFieldValue): boolean {
if (value === undefined || value === null || (typeof value === "string" && value.trim().length === 0)) return false;
return condition.one_of.includes(String(value));
const text = pluginConditionValueText(value);
return clause.one_of.some((literal) => pluginConditionValueText(literal) === text);
}
/**
* Evaluates a plugin manifest field condition (`visible_when` / `required_when`,
* Host API 1.1+). A leaf matches when the current value of the referenced
* sibling field is listed in `one_of`; `all_of` / `any_of` / `not` compose
* clauses, so a manifest can express `sudo_source = custom AND read_only =
* false`. Missing conditions always match; a missing or empty sibling value
* never matches.
*/
export function pluginFieldConditionMatches(condition: PluginFieldCondition | undefined, readValue: PluginFieldValueReader): boolean {
if (!condition) return true;
return conditionExpressionMatches(condition, readValue);
}
function conditionExpressionMatches(condition: PluginFieldCondition, readValue: PluginFieldValueReader): boolean {
if (isConditionClause(condition)) return conditionClauseMatches(condition, readValue(condition.field));
if (isConditionAllOf(condition)) {
return condition.all_of.every((child) => conditionExpressionMatches(child, readValue));
}
if (isConditionAnyOf(condition)) {
return condition.any_of.some((child) => conditionExpressionMatches(child, readValue));
}
return !conditionExpressionMatches(condition.not, readValue);
}
/** Field keys the expression reads, in evaluation order (duplicates kept). */
export function pluginFieldConditionReferencedFields(condition: PluginFieldCondition): string[] {
if (isConditionClause(condition)) return [condition.field];
if (isConditionAllOf(condition)) return condition.all_of.flatMap(pluginFieldConditionReferencedFields);
if (isConditionAnyOf(condition)) return condition.any_of.flatMap(pluginFieldConditionReferencedFields);
return pluginFieldConditionReferencedFields(condition.not);
}
function isConditionClause(condition: PluginFieldCondition): condition is { field: string; one_of: (string | number | boolean)[] } {
return typeof (condition as { field?: unknown }).field === "string";
}
function isConditionAllOf(condition: PluginFieldCondition): condition is { all_of: PluginFieldCondition[] } {
return Array.isArray((condition as { all_of?: unknown }).all_of);
}
function isConditionAnyOf(condition: PluginFieldCondition): condition is { any_of: PluginFieldCondition[] } {
return Array.isArray((condition as { any_of?: unknown }).any_of);
}
/** Looks a sibling field up by key; returns undefined when the key is unknown. */
@@ -28,11 +81,40 @@ export function pluginFieldIsVisible(field: PluginFormField, readValue: (key: st
function pluginFieldIsVisibleCached(field: PluginFormField, readValue: (key: string) => PluginFormFieldValue, resolveField: PluginFieldResolver | undefined, seen: Set<string>): boolean {
const condition = field.visible_when;
if (!condition) return true;
if (!pluginFieldConditionMatches(condition, readValue(condition.field))) return false;
const target = resolveField?.(condition.field);
return conditionExpressionVisible(condition, readValue, resolveField, seen);
}
/**
* Visibility-aware evaluation of one condition expression.
*
* A clause only counts while the sibling it reads is visible *and* its own
* `visible_when` chain is satisfied, so a hidden controller's stored default
* cannot light a field up. `not` additionally requires every operand to be
* visible: an inverted hidden value is not a usable answer.
*/
function conditionExpressionVisible(condition: PluginFieldCondition, readValue: PluginFieldValueReader, resolveField: PluginFieldResolver | undefined, seen: Set<string>): boolean {
if (isConditionClause(condition)) {
if (!conditionClauseMatches(condition, readValue(condition.field))) return false;
return conditionOperandVisible(condition.field, readValue, resolveField, seen);
}
if (isConditionAllOf(condition)) {
return condition.all_of.every((child) => conditionExpressionVisible(child, readValue, resolveField, seen));
}
if (isConditionAnyOf(condition)) {
return condition.any_of.some((child) => conditionExpressionVisible(child, readValue, resolveField, seen));
}
const operandsVisible = pluginFieldConditionReferencedFields(condition.not).every((key) => conditionOperandVisible(key, readValue, resolveField, seen));
return operandsVisible && !conditionExpressionMatches(condition.not, readValue);
}
/** Whether the sibling behind one clause is itself rendered. Cycles count as visible. */
function conditionOperandVisible(key: string, readValue: PluginFieldValueReader, resolveField: PluginFieldResolver | undefined, seen: Set<string>): boolean {
const target = resolveField?.(key);
if (!target || seen.has(target.key)) return true;
seen.add(target.key);
return pluginFieldIsVisibleCached(target, readValue, resolveField, seen);
const visible = pluginFieldIsVisibleCached(target, readValue, resolveField, seen);
seen.delete(target.key);
return visible;
}
/** Effective required = static `required` OR a matching `required_when`. */
@@ -43,5 +125,5 @@ export function pluginFieldIsRequired(field: PluginFormField, readValue: (key: s
// for visibility), so it must not be consulted here — that used to mark
// every plugin field required and dead-locked the connection footer.
if (!field.required_when) return false;
return pluginFieldConditionMatches(field.required_when, readValue(field.required_when.field));
return pluginFieldConditionMatches(field.required_when, readValue);
}
+86
View File
@@ -0,0 +1,86 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from "vitest";
const { tauriRuntime, openMock } = vi.hoisted(() => ({ tauriRuntime: { value: false }, openMock: vi.fn() }));
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => tauriRuntime.value }));
vi.mock("@tauri-apps/plugin-dialog", () => ({ open: openMock }));
import { PLUGIN_PICKER_MAX_BYTES, pickPluginFieldFile } from "./pluginFieldPicker";
/** Captures the hidden `<input type="file">` the browser path creates. */
function stubFileSelection(files: File[]) {
const original = document.createElement.bind(document);
const created: HTMLInputElement[] = [];
const spy = vi.spyOn(document, "createElement").mockImplementation(((tag: string, options?: ElementCreationOptions) => {
const element = original(tag, options as never);
if (tag === "input") {
created.push(element as HTMLInputElement);
setTimeout(() => {
Object.defineProperty(element, "files", { value: files, configurable: true });
element.dispatchEvent(new Event("change"));
}, 0);
}
return element;
}) as typeof document.createElement);
return { created, restore: () => spy.mockRestore() };
}
beforeEach(() => {
tauriRuntime.value = false;
openMock.mockReset();
});
describe("pickPluginFieldFile", () => {
it("returns the client path on desktop so the plugin can read the file itself", async () => {
tauriRuntime.value = true;
openMock.mockResolvedValue("/Users/dev/.ssh/id_rsa");
const picked = await pickPluginFieldFile({ kind: "file", accept: [".pem", ".key"] });
expect(picked).toEqual({ path: "/Users/dev/.ssh/id_rsa", name: "id_rsa" });
expect(openMock).toHaveBeenCalledWith({
directory: false,
multiple: false,
filters: [{ name: "Files", extensions: ["pem", "key"] }],
});
});
it("offers a folder dialog for directory pickers and ignores a dismissal", async () => {
tauriRuntime.value = true;
openMock.mockResolvedValue(null);
expect(await pickPluginFieldFile({ kind: "directory" })).toBeNull();
expect(openMock).toHaveBeenCalledWith({ directory: true, multiple: false, filters: undefined });
});
it("reads the file content on browser hosts, which cannot hand over a path", async () => {
const files = [new File(["-----BEGIN PRIVATE KEY-----\nkey\n"], "id_rsa", { type: "text/plain" })];
const selection = stubFileSelection(files);
try {
const picked = await pickPluginFieldFile({ kind: "file", accept: [".pem", "text/plain"] });
expect(picked).toEqual({ content: "-----BEGIN PRIVATE KEY-----\nkey\n", name: "id_rsa" });
expect(selection.created[0]?.accept).toBe(".pem,text/plain");
expect(selection.created[0]?.multiple).toBe(false);
} finally {
selection.restore();
}
});
it("has no browser equivalent for a folder picker", async () => {
expect(await pickPluginFieldFile({ kind: "directory" })).toBeNull();
});
it("rejects an oversized upload instead of loading it", async () => {
const oversized = new File(["x"], "big.key", { type: "text/plain" });
Object.defineProperty(oversized, "size", { value: PLUGIN_PICKER_MAX_BYTES + 1 });
const selection = stubFileSelection([oversized]);
try {
await expect(pickPluginFieldFile({ kind: "file" })).rejects.toThrow(/larger than 1024 KiB/);
} finally {
selection.restore();
}
});
});
@@ -0,0 +1,69 @@
import type { PluginFormFieldPicker } from "@/types/database";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
/** Uploads larger than this are rejected instead of being held in memory. */
export const PLUGIN_PICKER_MAX_BYTES = 1_048_576;
export interface PluginPickedFile {
/** Absolute path of the selected file; desktop hosts only. */
path?: string;
/** File content; browser hosts only (they cannot produce a usable path). */
content?: string;
/** File name, for messages. */
name: string;
}
/**
* Runs the manifest's picker action for one field.
*
* Desktop hosts (Tauri) open the native dialog and return the absolute path, so
* the plugin — which runs on the same machine — can read the file itself.
* Browser hosts have no client filesystem: they read the selected file and hand
* back its content, which the form stores in the declared `content_field`
* (the browser build cannot hand the server a path to a client file).
*
* Returns `null` when the user cancels.
*/
export async function pickPluginFieldFile(picker: PluginFormFieldPicker): Promise<PluginPickedFile | null> {
const accepted = (picker.accept || []).slice();
if (isTauriRuntime()) {
const { open } = await import("@tauri-apps/plugin-dialog");
const extensions = accepted.filter((entry) => entry.startsWith(".")).map((entry) => entry.replace(/^\./, ""));
const selected = await open({
directory: picker.kind === "directory",
multiple: false,
filters: extensions.length ? [{ name: picker.kind === "directory" ? "Folders" : "Files", extensions }] : undefined,
});
if (typeof selected !== "string" || !selected) return null;
return { path: selected, name: selected.replaceAll("\\", "/").split("/").pop() || selected };
}
if (picker.kind === "directory") return null;
const file = await selectBrowserFile(accepted);
if (!file) return null;
if (file.size > PLUGIN_PICKER_MAX_BYTES) {
throw new Error(`File is larger than ${Math.round(PLUGIN_PICKER_MAX_BYTES / 1024)} KiB`);
}
return { content: await file.text(), name: file.name };
}
function selectBrowserFile(accept: string[]): Promise<File | null> {
return new Promise((resolve) => {
const input = document.createElement("input");
input.type = "file";
input.multiple = false;
if (accept.length) input.accept = accept.join(",");
// Chrome fires `cancel` when the dialog is dismissed; older engines only
// fire `change` without files, which the handler below already treats as a
// cancellation.
input.addEventListener("cancel", () => resolve(null), { once: true });
input.addEventListener(
"change",
() => {
resolve(input.files?.[0] ?? null);
},
{ once: true },
);
input.click();
});
}