From e4bda693fb893b447afe14f72e79782a371c1a59 Mon Sep 17 00:00:00 2001 From: Jinpy Date: Thu, 17 Sep 2026 19:29:28 +0800 Subject: [PATCH] feat(plugins): add interactive prompts and richer connection forms --- .../connection/ConnectionDialog.vue | 2 +- .../plugins/PluginConnectionFields.spec.ts | 174 +++- .../plugins/PluginConnectionFields.vue | 100 +- .../components/ssh/SshHostKeyPromptDialog.vue | 82 +- .../__tests__/SshHostKeyPromptDialog.spec.ts | 118 +++ apps/desktop/src/i18n/locales/az.ts | 7 + apps/desktop/src/i18n/locales/en.ts | 7 + apps/desktop/src/i18n/locales/es.ts | 7 + apps/desktop/src/i18n/locales/it.ts | 7 + apps/desktop/src/i18n/locales/ja.ts | 7 + apps/desktop/src/i18n/locales/ko.ts | 7 + apps/desktop/src/i18n/locales/pt-BR.ts | 7 + apps/desktop/src/i18n/locales/tr.ts | 7 + apps/desktop/src/i18n/locales/zh-CN.ts | 7 + apps/desktop/src/i18n/locales/zh-TW.ts | 7 + .../src/lib/connection/connectionPassword.ts | 2 +- .../src/lib/plugins/frontendPlugin.spec.ts | 87 +- .../desktop/src/lib/plugins/frontendPlugin.ts | 27 +- .../lib/plugins/pluginFieldConditions.spec.ts | 182 +++- .../src/lib/plugins/pluginFieldConditions.ts | 102 +- .../src/lib/plugins/pluginFieldPicker.spec.ts | 86 ++ .../src/lib/plugins/pluginFieldPicker.ts | 69 ++ apps/desktop/src/types/database.ts | 54 +- crates/dbx-core/src/connection_secrets.rs | 56 ++ crates/dbx-core/src/db/sqlite_worker.rs | 4 + crates/dbx-core/src/db/ssh_prompt.rs | 145 ++- crates/dbx-core/src/db/ssh_tunnel.rs | 50 +- crates/dbx-core/src/plugins.rs | 11 +- crates/dbx-core/src/plugins/host.rs | 286 +++++- crates/dbx-core/src/plugins/manifest.rs | 661 ++++++++++++- crates/dbx-core/src/plugins/runtime.rs | 905 +++++++++++++++++- crates/dbx-core/src/storage.rs | 52 + crates/dbx-web/src/ssh_prompt.rs | 4 +- docs/content/docs/plugin-development.cn.mdx | 45 + docs/content/docs/plugin-development.mdx | 45 + plugins/README.md | 82 ++ plugins/manifest.schema.json | 81 +- plugins/sdk/dev-host/sidecar.mjs | 20 + plugins/sdk/dev-host/tests/echo-sidecar.mjs | 14 + plugins/sdk/dev-host/tests/sidecar.test.mjs | 13 + plugins/sdk/rust/dbx-plugin-sdk/src/lib.rs | 489 +++++++++- src-tauri/src/commands/ssh_prompt.rs | 4 +- 42 files changed, 3922 insertions(+), 200 deletions(-) create mode 100644 apps/desktop/src/lib/plugins/pluginFieldPicker.spec.ts create mode 100644 apps/desktop/src/lib/plugins/pluginFieldPicker.ts diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index 46fb68a8a8..4d0e38d1dd 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -3610,7 +3610,7 @@ const connectionLabelSmallPaddedClass = `${connectionLabelClass} pt-2 text-xs`; function pluginFieldValue(field: PluginFormField): PluginFormFieldValue { if (field.binding === "name") return form.value.name; - return pluginFormValues.value[field.key] ?? field.default; + return pluginFormValues.value[field.key] ?? field.default ?? undefined; } function pluginFieldHasValue(field: PluginFormField): boolean { diff --git a/apps/desktop/src/components/plugins/PluginConnectionFields.spec.ts b/apps/desktop/src/components/plugins/PluginConnectionFields.spec.ts index 8e2f87e370..ef243dda82 100644 --- a/apps/desktop/src/components/plugins/PluginConnectionFields.spec.ts +++ b/apps/desktop/src/components/plugins/PluginConnectionFields.spec.ts @@ -4,9 +4,19 @@ import { createApp, defineComponent, h, nextTick, reactive, type App } from "vue 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"; +import type { PluginConnectionProviderContribution, PluginFormField, PluginFormFieldBinding, PluginFormFieldValue } from "@/types/database"; const invokePluginMock = vi.fn(); +const { pickPluginFieldFileMock, tauriRuntime } = vi.hoisted(() => ({ + pickPluginFieldFileMock: vi.fn(), + tauriRuntime: { value: false }, +})); + +vi.mock("@/lib/plugins/pluginFieldPicker", () => ({ + PLUGIN_PICKER_MAX_BYTES: 1_048_576, + pickPluginFieldFile: (...args: unknown[]) => pickPluginFieldFileMock(...args), +})); +vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => tauriRuntime.value })); vi.mock("@/lib/backend/api", () => ({ listLocalSshKeys: vi.fn(async () => [ @@ -56,6 +66,72 @@ async function mountFields(initialValues: Record = return state; } +describe("PluginConnectionFields file picker", () => { + const pickerContribution = (picker: PluginFormField["picker"], contentField = true): PluginConnectionProviderContribution => ({ + type: "connection-provider", + id: "ssh.connection", + label: "SSH", + database_type: "ssh", + fields: [{ key: "private_key_path", label: "Private key path", type: "text", picker }, ...(contentField ? [{ key: "private_key", label: "Private key", type: "textarea" as const, binding: "secret" as const }] : [])], + }); + + it("stores the chosen client path on desktop and drops a stale uploaded copy", async () => { + tauriRuntime.value = true; + pickPluginFieldFileMock.mockReset().mockResolvedValue({ path: "/Users/dev/.ssh/id_rsa", name: "id_rsa" }); + const provider = pickerContribution({ kind: "file", accept: [".pem"], content_field: "private_key" }); + const state = await mountContribution(provider, { private_key: "OLD-UPLOADED-KEY" }); + + const browse = document.querySelector("#ssh-connection-private_key_path-picker"); + expect(browse?.textContent).toContain("Choose file"); + browse?.click(); + + await vi.waitFor(() => expect(state.values.private_key_path).toBe("/Users/dev/.ssh/id_rsa")); + // The plugin prefers content over a path, so picking a path must clear the + // previously uploaded key or the stale one would keep winning. + expect(state.values.private_key).toBeUndefined(); + }); + + it("uploads the file content into the paired field on browser hosts", async () => { + tauriRuntime.value = false; + pickPluginFieldFileMock.mockReset().mockResolvedValue({ content: "UPLOADED-KEY", name: "id_rsa" }); + const provider = pickerContribution({ kind: "file", accept: [".pem"], content_field: "private_key" }); + const state = await mountContribution(provider, { private_key_path: "/Users/dev/.ssh/id_rsa" }); + + const upload = document.querySelector("#ssh-connection-private_key_path-picker"); + expect(upload?.textContent).toContain("Upload file"); + upload?.click(); + + await vi.waitFor(() => expect(state.values.private_key).toBe("UPLOADED-KEY")); + // A browser path would point at the user's machine, not the host's. + expect(state.values.private_key_path).toBeUndefined(); + }); + + it("hides a browser-unsupported picker and keeps the desktop one available", async () => { + const provider = pickerContribution({ kind: "file" }, false); + + tauriRuntime.value = false; + await mountContribution(provider, {}); + expect(document.querySelector("#ssh-connection-private_key_path-picker")).toBeNull(); + + tauriRuntime.value = true; + await mountContribution(provider, {}); + expect(document.querySelector("#ssh-connection-private_key_path-picker")).not.toBeNull(); + }); + + it("surfaces a picker failure without touching the form", async () => { + tauriRuntime.value = false; + pickPluginFieldFileMock.mockReset().mockRejectedValue(new Error("File is larger than 1024 KiB")); + const provider = pickerContribution({ kind: "file", content_field: "private_key" }); + const state = await mountContribution(provider, { private_key: "KEEP-ME" }); + + document.querySelector("#ssh-connection-private_key_path-picker")?.click(); + + await vi.waitFor(() => expect(pickPluginFieldFileMock).toHaveBeenCalled()); + await nextTick(); + expect(state.values).toEqual({ private_key: "KEEP-ME" }); + }); +}); + afterEach(() => { for (const app of mountedApps.splice(0)) app.unmount(); document.body.innerHTML = ""; @@ -111,6 +187,39 @@ describe("PluginConnectionFields", () => { expect(state.values).toEqual({ password: "secret", host: "db.internal" }); }); + it('renders a null value as an empty field instead of the text "null"', async () => { + // Hosts before the manifest serialization fix hydrated untouched fields with + // `null`, and a null reaching an is coerced to the string "null" by + // the DOM — a password field then looked non-empty without any user input. + const provider: PluginConnectionProviderContribution = { + type: "connection-provider", + id: "null.connection", + label: "Nulls", + database_type: "nulls", + fields: [ + { key: "sudo_password", label: "Sudo password", type: "password", binding: "secret" }, + { key: "sudo_command", label: "Sudo command", type: "text", binding: "config" }, + { key: "set_env", label: "Env", type: "textarea", binding: "config" }, + ], + }; + const state = await mountContribution(provider, { + sudo_password: null, + sudo_command: null, + set_env: null, + } as unknown as Record); + + expect(document.querySelector("#null-connection-sudo_password")?.value).toBe(""); + expect(document.querySelector("#null-connection-sudo_command")?.value).toBe(""); + expect(document.querySelector("#null-connection-set_env")?.value).toBe(""); + + // Editing still emits a real value, so a null never survives the dialog. + const command = document.querySelector("#null-connection-sudo_command")!; + command.value = "sudo -n true"; + command.dispatchEvent(new Event("input", { bubbles: true })); + await nextTick(); + expect(state.values.sudo_command).toBe("sudo -n true"); + }); + it("can hide host-owned common bindings", async () => { contribution.fields[0].binding = "host"; await mountFields({}, ["host"]); @@ -222,6 +331,69 @@ describe("PluginConnectionFields", () => { expect(document.querySelector("#kafka-connection-msk_region")).toBeNull(); }); + it("renders a field only while every clause of an all_of condition holds (sudo_source + read_only)", async () => { + // The SSH plugin cannot express "sudo_source = custom AND read_only = + // false" with one clause per field; all_of + a boolean literal covers it. + const ssh: PluginConnectionProviderContribution = { + type: "connection-provider", + id: "ssh.connection", + label: "SSH", + database_type: "ssh", + fields: [ + { + key: "read_only", + label: "Read only", + type: "boolean", + default: false, + }, + { + key: "sudo_source", + label: "Sudo source", + type: "select", + default: "none", + options: [ + { label: "None", value: "none" }, + { label: "Custom", value: "custom" }, + ], + }, + { + key: "sudo_command", + label: "Sudo command", + type: "text", + visible_when: { + all_of: [ + { field: "sudo_source", one_of: ["custom"] }, + { field: "read_only", one_of: [false] }, + ], + }, + required_when: { + all_of: [ + { field: "sudo_source", one_of: ["custom"] }, + { field: "read_only", one_of: [false] }, + ], + }, + }, + ], + }; + const state = await mountContribution(ssh, {}); + + expect(document.querySelector("#ssh-connection-sudo_command")).toBeNull(); + + state.values = { sudo_source: "custom", read_only: false }; + await nextTick(); + expect(document.querySelector("#ssh-connection-sudo_command")).not.toBeNull(); + expect(document.querySelector('label[for="ssh-connection-sudo_command"]')?.textContent).toContain("*"); + + // Read-only sessions ignore the sudo block entirely. + state.values = { sudo_source: "custom", read_only: true }; + await nextTick(); + expect(document.querySelector("#ssh-connection-sudo_command")).toBeNull(); + + state.values = { sudo_source: "none", read_only: false }; + await nextTick(); + expect(document.querySelector("#ssh-connection-sudo_command")).toBeNull(); + }); + it("offers local SSH keys on private_key_path fields and fills the chosen path", async () => { const withKey: PluginConnectionProviderContribution = { type: "connection-provider", diff --git a/apps/desktop/src/components/plugins/PluginConnectionFields.vue b/apps/desktop/src/components/plugins/PluginConnectionFields.vue index 37913d5a62..b7d22d3a55 100644 --- a/apps/desktop/src/components/plugins/PluginConnectionFields.vue +++ b/apps/desktop/src/components/plugins/PluginConnectionFields.vue @@ -1,14 +1,19 @@ @@ -207,7 +291,7 @@ function applyLocalSshKey(field: PluginFormField, event: Event) {
- +
- + @@ -244,6 +328,12 @@ function applyLocalSshKey(field: PluginFormField, event: Event) {
+
+ +
{{ field.description }}
diff --git a/apps/desktop/src/components/ssh/SshHostKeyPromptDialog.vue b/apps/desktop/src/components/ssh/SshHostKeyPromptDialog.vue index 5e93330ab3..341246a3aa 100644 --- a/apps/desktop/src/components/ssh/SshHostKeyPromptDialog.vue +++ b/apps/desktop/src/components/ssh/SshHostKeyPromptDialog.vue @@ -1,5 +1,5 @@