feat(connection): add editable connection notes

This commit is contained in:
zipg authored and GitHub committed 2026-07-27 17:01:23 +08:00
1 parent 38c2bced92
commit 74c603ee9d
35 files changed
+126 -2

No files matched your search

@@ -213,6 +213,7 @@ function initialConfigTab(): ConfigTab {
const defaultForm = (): ConnectionForm => ({
name: "",
note: "",
db_type: "mysql",
driver_profile: "mysql",
driver_label: "MySQL",
@@ -444,6 +445,23 @@ function sshLayersForConfig(config: LegacyConnectionConfig): SshTunnelConfig[] {
}
const form = ref(defaultForm());
const noteTextareaRef = ref<HTMLTextAreaElement | null>(null);
function resizeNoteTextarea() {
const textarea = noteTextareaRef.value;
if (!textarea) return;
const style = window.getComputedStyle(textarea);
const lineHeight = Number.parseFloat(style.lineHeight) || 20;
const paddingHeight = (Number.parseFloat(style.paddingTop) || 0) + (Number.parseFloat(style.paddingBottom) || 0);
const borderHeight = (Number.parseFloat(style.borderTopWidth) || 0) + (Number.parseFloat(style.borderBottomWidth) || 0);
const maxContentHeight = lineHeight * 3 + paddingHeight;
textarea.style.height = "auto";
textarea.style.height = `${Math.min(textarea.scrollHeight, maxContentHeight) + borderHeight}px`;
textarea.style.overflowY = textarea.scrollHeight > maxContentHeight ? "auto" : "hidden";
}
const showJdbcDependencyDriverManagerAction = computed(() => form.value.db_type === "jdbc" && isJdbcMissingRuntimeDependencyError(connectionErrorDetail.value));
function externalConfigRecord(value: unknown): Record<string, unknown> {
@@ -517,6 +535,9 @@ const dbPickerView = ref<DbPickerView>(loadConnectionPickerView());
const dbSearchQuery = ref("");
const selectedDbCategory = ref<DbCategoryKey>("sql");
const configTab = ref<ConfigTab>("connection");
watch([() => form.value.note, configTab, dialogStep, open], () => {
void nextTick(resizeNoteTextarea);
});
const MQ_KAFKA_SECURITY_PROTOCOL_AUTO = "__auto";
const mqAdminUrl = ref("http://127.0.0.1:8080");
const mqSystemKind = ref<MqSystemKind>("pulsar");
@@ -1922,6 +1943,7 @@ watch(
const profileConfig = driverProfiles[profile];
form.value = {
name: config.name,
note: config.note || "",
db_type: oceanbasePatch?.db_type || profileConfig?.type || config.db_type,
driver_profile: oceanbasePatch?.driver_profile || profile,
driver_label: config.driver_label || oceanbasePatch?.driver_label || driverProfiles[profile]?.label || config.db_type,
@@ -3035,6 +3057,7 @@ function generateConnectionName(): string {
function connectionConfigForSubmit(id: string, generatedName = ""): ConnectionConfig {
const config = { ...formValueForSubmit(), id } as LegacyConnectionConfig;
config.database_info = undefined;
config.note = config.note?.trim() || undefined;
if (selectedType.value === "oceanbase" && (config.driver_profile === "oceanbase" || config.driver_profile === "oceanbase-oracle")) {
Object.assign(config, oceanbaseModeConnectionPatch(oceanbaseSubMode.value));
}
@@ -4744,7 +4767,7 @@ function openExternalUrl(url: string) {
</div>
<TabsContent value="connection" class="m-0 min-h-0 flex-1 overflow-hidden">
<div class="connection-form-body grid h-full min-h-0 gap-4 overflow-y-auto pt-4 pr-2">
<div class="connection-form-body grid h-full min-h-0 gap-4 overflow-y-auto pt-4 pr-2 pb-2">
<div v-if="!isJdbcConnection && form.db_type !== 'nacos'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.connectionUrlOptional") }}</Label>
<div class="col-span-3 flex items-center gap-1">
@@ -6148,6 +6171,18 @@ function openExternalUrl(url: string) {
</PopoverContent>
</Popover>
</div>
<div class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelTopClass">{{ t("connection.note") }}</Label>
<textarea
ref="noteTextareaRef"
v-model="form.note"
rows="1"
class="col-span-3 min-h-8 w-full min-w-0 resize-none overflow-y-hidden rounded-md border border-input bg-transparent px-2.5 py-1 text-base leading-5 transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 dark:bg-input/30 md:text-sm"
:placeholder="t('connection.notePlaceholder')"
@input="resizeNoteTextarea"
/>
</div>
</div>
</TabsContent>
+2
View File
@@ -176,6 +176,8 @@ export default {
title: "New Connection",
name: "Name",
namePlaceholder: "Connection name, auto-generated if empty",
note: "Notes",
notePlaceholder: "Do not store passwords in plaintext in notes",
type: "Type",
host: "Host",
filePath: "File Path",
+2
View File
@@ -178,6 +178,8 @@ export default withEnglishFallback({
title: "Nueva conexión",
name: "Nombre",
namePlaceholder: "Nombre de conexión, se genera automáticamente si está vacío",
note: "Notas",
notePlaceholder: "No guardes contraseñas en texto plano en las notas",
type: "Tipo",
host: "Host",
filePath: "Ruta del archivo",
+2
View File
@@ -177,6 +177,8 @@ export default withEnglishFallback({
title: "Nuova Connessione",
name: "Nome",
namePlaceholder: "Nome connessione, generato automaticamente se vuoto",
note: "Note",
notePlaceholder: "Non salvare password in chiaro nelle note",
type: "Tipo",
host: "Host",
filePath: "Percorso File",
+2
View File
@@ -177,6 +177,8 @@ export default withEnglishFallback({
title: "新しい接続",
name: "名前",
namePlaceholder: "接続名(空の場合は自動生成)",
note: "メモ",
notePlaceholder: "メモにパスワードを平文で保存しないでください",
type: "タイプ",
host: "ホスト",
filePath: "ファイルパス",
+2
View File
@@ -178,6 +178,8 @@ export default withEnglishFallback({
title: "Nova Conexão",
name: "Nome",
namePlaceholder: "Nome da conexão, gerado automaticamente se vazio",
note: "Observações",
notePlaceholder: "Não salve senhas em texto simples nas observações",
type: "Tipo",
host: "Host",
filePath: "Caminho do Arquivo",
+2
View File
@@ -178,6 +178,8 @@ export default withEnglishFallback({
title: "新建连接",
name: "名称",
namePlaceholder: "连接名称,留空则自动生成",
note: "备注",
notePlaceholder: "请勿在备注中明文保存密码",
type: "类型",
host: "主机",
filePath: "文件路径",
+2
View File
@@ -178,6 +178,8 @@ export default withEnglishFallback({
title: "建立連線",
name: "名稱",
namePlaceholder: "連線名稱,留空則自動產生",
note: "備註",
notePlaceholder: "請勿在備註中以明文儲存密碼",
type: "類型",
host: "主機",
filePath: "檔案路徑",
@@ -65,6 +65,7 @@ describe("connectionDatabaseInfo", () => {
expect(connectionConfigFingerprint({ ...original, transport_layers: [{ ...original.transport_layers![0], host: "other-jump" }] })).not.toBe(connectionConfigFingerprint(original));
expect(connectionConfigFingerprint(original, "")).not.toBe(connectionConfigFingerprint(original, original.name));
expect(connectionConfigFingerprint({ ...original, database_info: { productName: "MySQL", productVersion: "8.4.0" } })).toBe(connectionConfigFingerprint(original));
expect(connectionConfigFingerprint({ ...original, note: "Production reporting" })).toBe(connectionConfigFingerprint(original));
});
it("formats only database metadata for rows and copied text", () => {
@@ -86,7 +86,7 @@ function fnv1a(value: string, seed: number): number {
}
export function connectionConfigFingerprint(config: ConnectionConfig, sourceName = config.name): string {
const { database_info: _databaseInfo, ...submittedConfig } = config;
const { database_info: _databaseInfo, note: _note, ...submittedConfig } = config;
const serialized = JSON.stringify(stableValue({ config: submittedConfig, sourceName }));
const first = fnv1a(serialized, 0x811c9dc5).toString(16).padStart(8, "0");
const second = fnv1a(serialized, 0x9e3779b9).toString(16).padStart(8, "0");
@@ -101,6 +101,31 @@ describe("connectionStore database info", () => {
expect(store.connectedIds.has(config.id)).toBe(true);
});
it("keeps a live connection when only its note changes", async () => {
const config = mysqlConnection();
const saveConnections = vi.fn().mockResolvedValue(undefined);
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
connectDb: vi.fn().mockResolvedValue(config.id),
connectionDatabaseInfo: vi.fn().mockRejectedValue(new Error("metadata unavailable")),
saveConnections,
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
connectionIdentifierQuote: vi.fn().mockResolvedValue(undefined),
}));
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
await store.addConnection(config);
await store.connect(config);
await store.updateConnection({ ...config, note: "Production reporting" });
expect(saveConnections).toHaveBeenLastCalledWith([expect.objectContaining({ id: config.id, note: "Production reporting" })]);
expect(store.getConfig(config.id)?.note).toBe("Production reporting");
expect(store.connectedIds.has(config.id)).toBe(true);
});
it("does not delay connection success while optional metadata is loading", async () => {
const config = mysqlConnection();
let resolveDatabaseInfo!: (value: { productName: string; productVersion: string }) => void;
@@ -2119,11 +2119,13 @@ export const useConnectionStore = defineStore("connection", () => {
config = normalizeConnection(config);
const idx = connections.value.findIndex((c) => c.id === config.id);
if (idx < 0) return;
const runtimeConfigChanged = connectionConfigFingerprint(connections.value[idx]) !== connectionConfigFingerprint(config);
const nextConnections = [...connections.value];
nextConnections[idx] = config;
await persistConnections(nextConnections);
connections.value = nextConnections;
rebuildTreeNodes();
if (!runtimeConfigChanged) return;
connectedIds.value.delete(config.id);
clearConnectionIdentifierQuote(config.id);
clearConnectionHealthCheck(config.id);
+1
View File
@@ -116,6 +116,7 @@ export interface CompletionAssistantResponse {
export interface ConnectionConfig {
id: string;
name: string;
note?: string;
db_type: DatabaseType;
driver_profile?: string;
driver_label?: string;