Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

29 changes: 26 additions & 3 deletions apps/desktop/src/components/connection/ConnectionDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,17 @@ import VisibleSchemasDialog from "@/components/sidebar/VisibleSchemasDialog.vue"
import CloudflareD1ConnectionFields from "@/components/connection/CloudflareD1ConnectionFields.vue";
import PluginConnectionFields from "@/components/plugins/PluginConnectionFields.vue";
import PluginIcon from "@/components/plugins/PluginIcon.vue";
import { buildPluginConnectionConfig, createFrontendPluginRegistry, parsePluginConnectionProviderOptionValue, pluginConnectionActionsForDialog, pluginConnectionFormValues, pluginConnectionProviderIcon, pluginConnectionProviderOptionValue } from "@/lib/plugins/frontendPlugin";
import {
buildPluginConnectionConfig,
createFrontendPluginRegistry,
parsePluginConnectionProviderOptionValue,
pluginConnectionActionsForDialog,
pluginConnectionFormValues,
pluginConnectionProviderIcon,
pluginConnectionProviderOptionValue,
pluginFormFieldRequired,
pluginFormFieldVisible,
} from "@/lib/plugins/frontendPlugin";
import type { PluginCenterFocus } from "@/lib/plugins/pluginCenterNavigation";
import { oceanbaseModeConnectionPatch, oceanbaseSubModeFromConfig } from "@/lib/database/oceanbaseConnectionMode";
import { translateBackendError } from "@/i18n/backend-errors";
Expand Down Expand Up @@ -3308,6 +3318,16 @@ function pluginFieldHasValue(field: PluginFormField): boolean {
return typeof value === "string" ? value.trim().length > 0 : value !== undefined;
}

function currentPluginFormValues(): Record<string, PluginFormFieldValue> {
const values = { ...pluginFormValues.value };
const provider = selectedPluginProvider.value?.contribution;
if (!provider) return values;
for (const field of provider.fields) {
if (field.binding === "name") values[field.key] = form.value.name;
}
return values;
}

function pluginActionLabel(action: PluginConnectionAction): string {
if (action.label) return action.label;
if (action.kind === "test") return t("connection.test");
Expand Down Expand Up @@ -3361,7 +3381,9 @@ function applyPluginActionFieldValues(values: Record<string, PluginFormFieldValu
const hasRequiredConnectionTarget = computed(() => {
if (isPluginConnection.value) {
const entry = selectedPluginProvider.value;
return !!entry && entry.contribution.fields.every((field) => !field.required || pluginFieldHasValue(field));
if (!entry) return false;
const values = currentPluginFormValues();
return entry.contribution.fields.every((field) => !pluginFormFieldRequired(entry.contribution, field, values) || pluginFieldHasValue(field));
}
if (form.value.db_type === "mq") {
if (mqSystemKind.value === "kafka") return mqKafkaConnectionSource.value === "zookeeper" ? !!mqKafkaZooKeeperServers.value.trim() : !!mqKafkaBootstrapServers.value.trim();
Expand Down Expand Up @@ -3672,7 +3694,8 @@ function connectionConfigForSubmit(id: string, generatedName = "", validatePlugi
const entry = selectedPluginProvider.value;
if (!entry) throw new Error(pluginLoadError.value || t("connection.pluginProviderUnavailable"));
if (validatePluginRequired) {
const missingField = entry.contribution.fields.find((field) => field.required && !pluginFieldHasValue(field));
const values = currentPluginFormValues();
const missingField = entry.contribution.fields.find((field) => pluginFormFieldVisible(entry.contribution, field, values) && pluginFormFieldRequired(entry.contribution, field, values) && !pluginFieldHasValue(field));
if (missingField) throw new Error(t("connection.pluginRequiredField", { field: missingField.label }));
}
const values = { ...pluginFormValues.value };
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/components/layout/AppDialogs.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const FieldLineageDialog = defineAsyncComponent(() => import("@/components/linea
const ConfigPassphraseDialog = defineAsyncComponent(() => import("@/components/config/ConfigPassphraseDialog.vue"));
const DatabaseSearchDialog = defineAsyncComponent(() => import("@/components/search/DatabaseSearchDialog.vue"));
const SshHostKeyPromptDialog = defineAsyncComponent(() => import("@/components/ssh/SshHostKeyPromptDialog.vue"));
const PluginConnectionChallengeDialog = defineAsyncComponent(() => import("@/components/plugins/PluginConnectionChallengeDialog.vue"));
const DatabaseExportDialog = defineAsyncComponent(() => import("@/components/export/DatabaseExportDialog.vue"));
const DataGenerateDialog = defineAsyncComponent(() => import("@/components/generate/DataGenerateDialog.vue"));
import { useConnectionStore } from "@/stores/connectionStore";
Expand Down Expand Up @@ -280,4 +281,5 @@ watch(
</DialogContent>
</Dialog>
<SshHostKeyPromptDialog />
<PluginConnectionChallengeDialog />
</template>
10 changes: 9 additions & 1 deletion apps/desktop/src/components/layout/ContentArea.vue
Original file line number Diff line number Diff line change
Expand Up @@ -1983,7 +1983,15 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand

<template v-else-if="activeTab.mode === 'plugin-workbench' && activeTab.pluginWorkbench">
<div class="flex-1 min-h-0">
<PluginWorkbenchTab :key="activeTab.id" :plugin-id="activeTab.pluginWorkbench.pluginId" :contribution-id="activeTab.pluginWorkbench.contributionId" :context="activeTab.pluginWorkbench.context" />
<PluginWorkbenchTab
:key="activeTab.id"
:tab-id="activeTab.id"
:plugin-id="activeTab.pluginWorkbench.pluginId"
:contribution-id="activeTab.pluginWorkbench.contributionId"
:context="activeTab.pluginWorkbench.context"
:state="activeTab.pluginWorkbench.state"
:restored="activeTab.pluginWorkbench.restored"
/>
</div>
</template>
<template v-else-if="activeTab.mode === 'plugin-filesystem' && activeTab.pluginFilesystem">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import * as api from "@/lib/backend/api";
import type { PluginEvent } from "@/types/database";
import {
parsePluginConnectionChallenge,
pluginConnectionChallengeKey,
PLUGIN_CONNECTION_CHALLENGE_RESOLVE_METHOD,
type PluginConnectionChallenge,
} from "@/lib/plugins/pluginConnectionChallenge";

const { t } = useI18n();
const queue = ref<PluginConnectionChallenge[]>([]);
const current = computed(() => queue.value[0] ?? null);
const visible = computed({
get: () => current.value !== null,
set: (open) => {
if (!open) void resolve(false);
},
});
const remember = ref(true);
const resolving = ref(false);
const seen = new Set<string>();
let unsubscribe: (() => void) | undefined;
let mounted = true;

onMounted(async () => {
const stop = await api.subscribePluginEvents((event: PluginEvent) => {
const challenge = parsePluginConnectionChallenge(event);
if (!challenge) return;
const key = pluginConnectionChallengeKey(challenge);
if (seen.has(key)) return;
seen.add(key);
queue.value.push(challenge);
});
if (!mounted) stop();
else unsubscribe = stop;
});

onBeforeUnmount(() => {
mounted = false;
unsubscribe?.();
});

async function resolve(accept: boolean) {
const challenge = current.value;
if (!challenge || resolving.value) return;
resolving.value = true;
try {
await api.invokePlugin(challenge.pluginId, PLUGIN_CONNECTION_CHALLENGE_RESOLVE_METHOD, {
operationId: challenge.operationId,
challengeId: challenge.challengeId,
accept,
remember: accept && remember.value,
});
} catch (error) {
console.error("[DBX] failed to resolve plugin connection challenge:", error);
} finally {
seen.delete(pluginConnectionChallengeKey(challenge));
if (queue.value[0] === challenge) queue.value.shift();
else queue.value = queue.value.filter((item) => item !== challenge);
remember.value = true;
resolving.value = false;
}
}
</script>

<template>
<Dialog v-model:open="visible">
<DialogContent class="sm:max-w-[460px]" :show-close-button="false" @interact-outside.prevent @escape-key-down.prevent>
<DialogHeader>
<DialogTitle>{{ current?.title || t("connection.sshHostKeyVerifyTitle") }}</DialogTitle>
<DialogDescription>{{ current?.message || t("connection.sshHostKeyVerifyMessage", { host: current?.host || "", port: current?.port || "" }) }}</DialogDescription>
</DialogHeader>
<div v-if="current" class="space-y-3 py-1">
<div class="rounded-md border border-border bg-muted/40 p-3 text-sm">
<div v-if="current.host" class="flex items-center justify-between gap-3">
<span class="text-muted-foreground">{{ current.host }}{{ current.port ? `:${current.port}` : "" }}</span>
</div>
<div class="mt-2 flex items-center justify-between gap-3">
<span class="text-muted-foreground">{{ t("connection.sshHostKeyVerifyKeyType") }}</span>
<span class="font-medium">{{ current.keyType || "—" }}</span>
</div>
<div class="mt-2 flex items-start justify-between gap-3">
<span class="shrink-0 text-muted-foreground">{{ t("connection.sshHostKeyVerifyFingerprint") }}</span>
<span class="break-all text-right font-mono text-xs font-medium">{{ current.fingerprint }}</span>
</div>
</div>
<label class="flex items-center gap-2 text-sm">
<input v-model="remember" type="checkbox" class="h-4 w-4 rounded border-border accent-primary" />
<span>{{ t("connection.sshHostKeyVerifyRemember") }}</span>
</label>
</div>
<DialogFooter>
<Button variant="outline" :disabled="resolving" @click="resolve(false)">{{ t("connection.sshHostKeyVerifyReject") }}</Button>
<Button :disabled="resolving" @click="resolve(true)">{{ t("connection.sshHostKeyVerifyAccept") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
32 changes: 30 additions & 2 deletions apps/desktop/src/components/plugins/PluginConnectionFields.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ 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 { isTauriRuntime } from "@/lib/backend/tauriRuntime";

const props = defineProps<{
contribution: PluginConnectionProviderContribution;
Expand All @@ -19,7 +20,15 @@ const emit = defineEmits<{
}>();

const formValues = computed(() => props.modelValue);
const visibleFields = computed(() => props.contribution.fields.filter((field) => !field.binding || !props.hiddenBindings?.includes(field.binding)));
const visibleFields = computed(() =>
props.contribution.fields.filter((field) => {
if (field.binding && props.hiddenBindings?.includes(field.binding)) return false;
if (!field.visible_when) return true;
const source = props.contribution.fields.find((candidate) => candidate.key === field.visible_when?.field);
const value = formValues.value[field.visible_when.field] ?? source?.default;
return field.visible_when.one_of.some((candidate) => candidate === value);
}),
);
const isConnectionDialogLayout = computed(() => props.layout === "connection-dialog");

function fieldValue(field: PluginFormField): PluginFormFieldValue {
Expand Down Expand Up @@ -59,6 +68,21 @@ function defaultValueFor(field: PluginFormField): PluginFormFieldValue {
if (field.type === "number") return undefined;
return "";
}

function fieldRequired(field: PluginFormField): boolean {
if (field.required) return true;
if (!field.required_when) return false;
const source = props.contribution.fields.find((candidate) => candidate.key === field.required_when?.field);
const value = formValues.value[field.required_when.field] ?? source?.default;
return field.required_when.one_of.some((candidate) => candidate === value);
}

async function browsePath(field: PluginFormField) {
if (!isTauriRuntime()) return;
const { open } = await import("@tauri-apps/plugin-dialog");
const selected = await open({ multiple: false });
if (typeof selected === "string") updateField(field, selected);
}
</script>

<template>
Expand All @@ -75,10 +99,14 @@ 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)" />
<div v-else-if="field.type === 'path'" class="flex items-center gap-1">
<Input :id="fieldId(field)" class="min-w-0 flex-1" :model-value="String(fieldValue(field) ?? '')" :placeholder="field.placeholder" @update:model-value="updateField(field, String($event))" />
<button v-if="isTauriRuntime()" type="button" class="inline-flex h-9 shrink-0 items-center rounded-md border border-input bg-background px-3 text-xs hover:bg-accent" @click="browsePath(field)">…</button>
</div>
<PasswordInput v-else-if="field.type === 'password'" :id="fieldId(field)" :model-value="String(fieldValue(field) ?? '')" :placeholder="field.placeholder" @update:model-value="updateField(field, $event)" />
<textarea
v-else-if="field.type === 'textarea'"
Expand Down
Loading