feat(connection): add editable connection notes

This commit is contained in:
zipg 2026-07-27 17:01:23 +08:00 committed by GitHub
parent 38c2bced92
commit 74c603ee9d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 126 additions and 2 deletions

View File

@ -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>

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",

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",

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",

View File

@ -177,6 +177,8 @@ export default withEnglishFallback({
title: "新しい接続",
name: "名前",
namePlaceholder: "接続名(空の場合は自動生成)",
note: "メモ",
notePlaceholder: "メモにパスワードを平文で保存しないでください",
type: "タイプ",
host: "ホスト",
filePath: "ファイルパス",

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",

View File

@ -178,6 +178,8 @@ export default withEnglishFallback({
title: "新建连接",
name: "名称",
namePlaceholder: "连接名称,留空则自动生成",
note: "备注",
notePlaceholder: "请勿在备注中明文保存密码",
type: "类型",
host: "主机",
filePath: "文件路径",

View File

@ -178,6 +178,8 @@ export default withEnglishFallback({
title: "建立連線",
name: "名稱",
namePlaceholder: "連線名稱,留空則自動產生",
note: "備註",
notePlaceholder: "請勿在備註中以明文儲存密碼",
type: "類型",
host: "主機",
filePath: "檔案路徑",

View File

@ -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", () => {

View File

@ -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");

View File

@ -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;

View File

@ -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);

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;

View File

@ -598,6 +598,7 @@ mod tests {
ConnectionConfig {
id: "conn".to_string(),
name: "Connection".to_string(),
note: String::new(),
db_type,
driver_profile: None,
driver_label: None,

View File

@ -1117,6 +1117,7 @@ mod tests {
ConnectionConfig {
id: id.to_string(),
name: "Postgres".to_string(),
note: String::new(),
db_type: DatabaseType::Postgres,
driver_profile: None,
driver_label: None,
@ -1171,6 +1172,7 @@ mod tests {
ConnectionConfig {
id: id.to_string(),
name: "Nacos".to_string(),
note: String::new(),
db_type: DatabaseType::Nacos,
driver_profile: None,
driver_label: None,
@ -1262,6 +1264,7 @@ mod tests {
let mut config = ConnectionConfig {
id: "id".to_string(),
name: "name".to_string(),
note: String::new(),
db_type: DatabaseType::Postgres,
driver_profile: None,
driver_label: None,

View File

@ -3854,6 +3854,7 @@ mod tests {
ConnectionConfig {
id: "conn".to_string(),
name: "MySQL".to_string(),
note: String::new(),
db_type: DatabaseType::Mysql,
driver_profile: None,
driver_label: None,

View File

@ -737,6 +737,7 @@ mod tests {
ConnectionConfig {
id: id.to_string(),
name: format!("{id} connection"),
note: String::new(),
db_type: DatabaseType::Postgres,
driver_profile: None,
driver_label: None,

View File

@ -4175,6 +4175,7 @@ mod tests {
ConnectionConfig {
id: "redis".to_string(),
name: "Redis".to_string(),
note: String::new(),
db_type: crate::models::connection::DatabaseType::Redis,
driver_profile: None,
driver_label: None,

View File

@ -72,6 +72,8 @@ pub fn database_info_from_protocol_value(value: &Value) -> Option<DatabaseConnec
pub struct ConnectionConfig {
pub id: String,
pub name: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub note: String,
pub db_type: DatabaseType,
#[serde(default)]
pub driver_profile: Option<String>,
@ -537,6 +539,8 @@ pub enum DatabaseType {
struct ConnectionConfigData {
pub id: String,
pub name: String,
#[serde(default)]
pub note: String,
pub db_type: DatabaseType,
#[serde(default)]
pub driver_profile: Option<String>,
@ -632,6 +636,7 @@ impl From<ConnectionConfigData> for ConnectionConfig {
Self {
id: data.id,
name: data.name,
note: data.note,
db_type: data.db_type,
driver_profile: data.driver_profile,
driver_label: data.driver_label,
@ -2176,6 +2181,7 @@ mod tests {
ConnectionConfig {
id: "id".to_string(),
name: "name".to_string(),
note: String::new(),
db_type: DatabaseType::Mysql,
driver_profile: None,
driver_label: None,
@ -2244,6 +2250,20 @@ mod tests {
assert!(!legacy.read_only);
}
#[test]
fn connection_note_is_optional_and_round_trips_when_present() {
let config = mysql_config("root", "secret", Some("app"));
let value = serde_json::to_value(&config).unwrap();
assert!(value.get("note").is_none());
assert!(serde_json::from_value::<ConnectionConfig>(value).unwrap().note.is_empty());
let mut config = config;
config.note = "Production reporting".to_string();
let value = serde_json::to_value(&config).unwrap();
assert_eq!(value["note"], "Production reporting");
assert_eq!(serde_json::from_value::<ConnectionConfig>(value).unwrap().note, config.note);
}
#[test]
fn database_identifier_whitespace_is_preserved_and_percent_encoded() {
let mut config = mysql_config("root", "secret", Some(" analytics "));

View File

@ -104,6 +104,7 @@ mod tests {
let mut cfg = ConnectionConfig {
id: "c1".to_string(),
name: "mq".to_string(),
note: String::new(),
db_type: crate::models::connection::DatabaseType::MessageQueue,
driver_profile: None,
driver_label: None,

View File

@ -857,6 +857,7 @@ mod tests {
ConnectionConfig {
id: "readonly-mq".to_string(),
name: "Read only MQ".to_string(),
note: String::new(),
db_type: DatabaseType::MessageQueue,
driver_profile: Some("pulsar".to_string()),
driver_label: Some("Apache Pulsar".to_string()),

View File

@ -286,6 +286,7 @@ mod tests {
ConnectionConfig {
id: "nacos-1".to_string(),
name: "Nacos".to_string(),
note: String::new(),
db_type: DatabaseType::Nacos,
driver_profile: None,
driver_label: None,

View File

@ -218,6 +218,7 @@ mod tests {
let mut cfg = crate::models::connection::ConnectionConfig {
id: "nacos-1".to_string(),
name: "Nacos".to_string(),
note: String::new(),
db_type: DatabaseType::Nacos,
driver_profile: None,
driver_label: None,
@ -287,6 +288,7 @@ mod tests {
let cfg = crate::models::connection::ConnectionConfig {
id: "nacos-rollback".to_string(),
name: "Nacos".to_string(),
note: String::new(),
db_type: DatabaseType::Nacos,
driver_profile: None,
driver_label: None,

View File

@ -640,6 +640,7 @@ mod tests {
ConnectionConfig {
id: "conn".to_string(),
name: "test".to_string(),
note: String::new(),
db_type: DatabaseType::Mysql,
driver_profile: None,
driver_label: None,

View File

@ -3579,6 +3579,7 @@ mod tests {
ConnectionConfig {
id: "conn-1".to_string(),
name: "Connection".to_string(),
note: String::new(),
db_type,
driver_profile: None,
driver_label: None,
@ -4666,6 +4667,7 @@ mod tests {
let config = ConnectionConfig {
id: "jdbc-1".to_string(),
name: "JDBC".to_string(),
note: String::new(),
db_type: DatabaseType::Jdbc,
driver_profile: None,
driver_label: None,

View File

@ -2887,6 +2887,7 @@ mod tests {
ConnectionConfig {
id: "test".to_string(),
name: "test".to_string(),
note: String::new(),
db_type,
driver_profile: None,
driver_label: None,

View File

@ -3720,6 +3720,7 @@ mod tests {
ConnectionConfig {
id: id.to_string(),
name: "Pulsar".to_string(),
note: String::new(),
db_type: DatabaseType::MessageQueue,
driver_profile: Some("pulsar".to_string()),
driver_label: Some("Apache Pulsar".to_string()),
@ -3781,6 +3782,7 @@ mod tests {
ConnectionConfig {
id: id.to_string(),
name: "Nacos".to_string(),
note: String::new(),
db_type: DatabaseType::Nacos,
driver_profile: None,
driver_label: None,

View File

@ -4921,6 +4921,7 @@ mod tests {
crate::models::connection::ConnectionConfig {
id: id.to_string(),
name: id.to_string(),
note: String::new(),
db_type: DatabaseType::DuckDb,
driver_profile: None,
driver_label: None,

View File

@ -87,6 +87,7 @@ fn postgres_test_config(id: &str, port: u16) -> ConnectionConfig {
ConnectionConfig {
id: id.to_string(),
name: id.to_string(),
note: String::new(),
db_type: DatabaseType::Postgres,
driver_profile: None,
driver_label: None,

View File

@ -20,6 +20,7 @@ fn live_postgres_config(
ConnectionConfig {
id: id.to_string(),
name: id.to_string(),
note: String::new(),
db_type: DatabaseType::Postgres,
driver_profile: None,
driver_label: None,

View File

@ -12,6 +12,7 @@ fn postgres_test_config(id: &str, database: &str) -> ConnectionConfig {
ConnectionConfig {
id: id.to_string(),
name: id.to_string(),
note: String::new(),
db_type: DatabaseType::Postgres,
driver_profile: None,
driver_label: None,

View File

@ -15,6 +15,7 @@ fn live_sqlserver_config(id: &str, database: &str) -> dbx_core::models::connecti
dbx_core::models::connection::ConnectionConfig {
id: id.to_string(),
name: id.to_string(),
note: String::new(),
db_type: DatabaseType::SqlServer,
driver_profile: None,
driver_label: None,

View File

@ -10,6 +10,7 @@ fn live_sqlserver_config(id: &str, database: &str) -> dbx_core::models::connecti
dbx_core::models::connection::ConnectionConfig {
id: id.to_string(),
name: id.to_string(),
note: String::new(),
db_type: DatabaseType::SqlServer,
driver_profile: None,
driver_label: None,

View File

@ -417,6 +417,7 @@ mod tests {
ConnectionConfig {
id: id.to_string(),
name: "SQLite".to_string(),
note: String::new(),
db_type: DatabaseType::Sqlite,
driver_profile: None,
driver_label: None,

View File

@ -176,6 +176,7 @@ mod tests {
ConnectionConfig {
id: "mongo".to_string(),
name: "MongoDB".to_string(),
note: String::new(),
db_type: DatabaseType::MongoDb,
driver_profile: Some("mongodb".to_string()),
driver_label: Some("MongoDB".to_string()),