From de72675b67ac465c062d6fe1b81570c2d6c6b07e Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Thu, 13 Aug 2026 11:55:01 +0000 Subject: [PATCH] feat(welcome): show recently opened connections Closes #6015 --- apps/desktop/src/App.vue | 16 +++- .../src/lib/connection/recentConnections.ts | 62 +++++++++++++ packages/app-tests/recentConnections.test.ts | 92 +++++++++++++++++++ 3 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/lib/connection/recentConnections.ts create mode 100644 packages/app-tests/recentConnections.test.ts diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index ce8528e3e3..99a7e980b5 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -46,6 +46,7 @@ import { translateBackendError } from "@/i18n/backend-errors"; import * as api from "@/lib/backend/api"; import { connectionRedactedNameLabel } from "@/lib/connection/connectionPresentation"; import { quickConnectionOpenTarget } from "@/lib/connection/connectionOpenTarget"; +import { parseRecentConnectionIds, rankRecentConnections, RECENT_CONNECTION_IDS_STORAGE_KEY, recordRecentConnection } from "@/lib/connection/recentConnections"; import { resolveDefaultDatabase } from "@/lib/database/defaultDatabase"; import { normalizeSqliteNamespace } from "@/lib/database/sqliteNamespace"; import { findTreeNodeById, resolveNewQueryTarget, resolveNewQueryInitialSql } from "@/lib/sql/newQueryContext"; @@ -143,6 +144,7 @@ const queryStore = useQueryStore(); const settingsStore = useSettingsStore(); const savedSqlStore = useSavedSqlStore(); const promptTemplateStore = usePromptTemplateStore(); +const recentConnectionIds = ref(parseRecentConnectionIds(safeLocalStorageGet(RECENT_CONNECTION_IDS_STORAGE_KEY))); connectionStore.setBeforeConnectHandler(async (config) => { await ensureJdbcxRuntimeDrivers(config, api); const jdbcProductRuntimeBefore = JSON.stringify({ @@ -581,7 +583,18 @@ const connectionStats = computed(() => ({ connected: connectionStore.connectedIds.size, types: new Set(connectionStore.connections.map((c) => c.driver_profile || c.db_type)).size, })); -const recentConnections = computed(() => connectionStore.connections.slice(0, 5)); +const recentConnections = computed(() => rankRecentConnections(connectionStore.connections, recentConnectionIds.value)); + +function rememberRecentConnection(connectionId: string | null) { + if (!connectionId) return; + const nextIds = recordRecentConnection(recentConnectionIds.value, connectionId); + if (nextIds === recentConnectionIds.value) return; + recentConnectionIds.value = nextIds; + safeLocalStorageSet(RECENT_CONNECTION_IDS_STORAGE_KEY, JSON.stringify(nextIds)); +} + +watch(() => connectionStore.activeConnectionId, rememberRecentConnection); + const savedSqlHistoryItems = computed(() => { const folderById = new Map(savedSqlStore.allFolders.map((folder) => [folder.id, folder])); const folderPath = (folderId?: string): string | undefined => { @@ -1602,6 +1615,7 @@ async function newQuery() { async function openConnectionQuery(connectionId: string) { const connection = connectionStore.getConfig(connectionId); if (!connection) return; + rememberRecentConnection(connectionId); connectionStore.activeConnectionId = connectionId; const initialTarget = quickConnectionOpenTarget(connection); if (initialTarget.kind === "mq-admin") { diff --git a/apps/desktop/src/lib/connection/recentConnections.ts b/apps/desktop/src/lib/connection/recentConnections.ts new file mode 100644 index 0000000000..5ad61d90d2 --- /dev/null +++ b/apps/desktop/src/lib/connection/recentConnections.ts @@ -0,0 +1,62 @@ +export const RECENT_CONNECTION_IDS_STORAGE_KEY = "dbx-recent-connection-ids-v1"; +export const MAX_RECENT_CONNECTION_IDS = 5; + +function normalizeRecentConnectionIds(ids: readonly unknown[]): string[] { + const normalized: string[] = []; + const seen = new Set(); + + for (const value of ids) { + if (typeof value !== "string") continue; + const id = value.trim(); + if (!id || seen.has(id)) continue; + seen.add(id); + normalized.push(id); + if (normalized.length === MAX_RECENT_CONNECTION_IDS) break; + } + + return normalized; +} + +export function parseRecentConnectionIds(raw: string | null): string[] { + if (!raw) return []; + + try { + const parsed: unknown = JSON.parse(raw); + return Array.isArray(parsed) ? normalizeRecentConnectionIds(parsed) : []; + } catch { + return []; + } +} + +export function recordRecentConnection(history: readonly string[], connectionId: string): readonly string[] { + const id = connectionId.trim(); + const nextHistory = normalizeRecentConnectionIds(id ? [id, ...history] : history); + const unchanged = nextHistory.length === history.length && nextHistory.every((value, index) => value === history[index]); + return unchanged ? history : nextHistory; +} + +export function rankRecentConnections(connections: readonly T[], history: readonly string[], limit = MAX_RECENT_CONNECTION_IDS): T[] { + const cappedLimit = Number.isFinite(limit) ? Math.max(0, Math.min(Math.floor(limit), MAX_RECENT_CONNECTION_IDS)) : MAX_RECENT_CONNECTION_IDS; + if (cappedLimit === 0) return []; + + const connectionById = new Map(connections.map((connection) => [connection.id, connection])); + const ranked: T[] = []; + const selectedIds = new Set(); + + for (const id of normalizeRecentConnectionIds(history)) { + const connection = connectionById.get(id); + if (!connection) continue; + ranked.push(connection); + selectedIds.add(id); + if (ranked.length === cappedLimit) return ranked; + } + + for (const connection of connections) { + if (selectedIds.has(connection.id)) continue; + ranked.push(connection); + selectedIds.add(connection.id); + if (ranked.length === cappedLimit) break; + } + + return ranked; +} diff --git a/packages/app-tests/recentConnections.test.ts b/packages/app-tests/recentConnections.test.ts new file mode 100644 index 0000000000..4fb3f4754f --- /dev/null +++ b/packages/app-tests/recentConnections.test.ts @@ -0,0 +1,92 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "vitest"; +import { MAX_RECENT_CONNECTION_IDS, parseRecentConnectionIds, rankRecentConnections, recordRecentConnection } from "../../apps/desktop/src/lib/connection/recentConnections.ts"; + +interface Connection { + id: string; + name: string; +} + +function connections(...ids: string[]): Connection[] { + return ids.map((id) => ({ id, name: `Connection ${id}` })); +} + +test("recent connections rank MRU IDs first, backfill saved order, and cap the card", () => { + const saved = connections("one", "two", "three", "four", "five", "six"); + + assert.deepEqual( + rankRecentConnections(saved, ["six", "three"]).map((connection) => connection.id), + ["six", "three", "one", "two", "four"], + ); +}); + +test("recording a connection moves it to the front without duplicates", () => { + assert.deepEqual(recordRecentConnection(["three", "two", "one"], "two"), ["two", "three", "one"]); + + const history = ["six", "five", "four", "three", "two"]; + assert.deepEqual(recordRecentConnection(history, "one"), ["one", "six", "five", "four", "three"]); + assert.equal(recordRecentConnection(history, "").length, history.length); + assert.equal( + recordRecentConnection( + connections(...history).map((connection) => connection.id), + "one", + ).length, + MAX_RECENT_CONNECTION_IDS, + ); +}); + +test("recording the current MRU reuses the history so persistence can skip duplicate writes", () => { + const history = ["three", "two", "one"]; + + assert.equal(recordRecentConnection(history, "three"), history); + assert.equal(recordRecentConnection(history, ""), history); + assert.notEqual(recordRecentConnection(history, "two"), history); +}); + +test("persisted MRU order survives serialization and ignores malformed history", () => { + const recorded = recordRecentConnection(recordRecentConnection([], "two"), "four"); + const restored = parseRecentConnectionIds(JSON.stringify(recorded)); + + assert.deepEqual(restored, ["four", "two"]); + assert.deepEqual( + rankRecentConnections(connections("one", "two", "three", "four"), restored).map((connection) => connection.id), + ["four", "two", "one", "three"], + ); + assert.deepEqual(parseRecentConnectionIds("not-json"), []); + assert.deepEqual(parseRecentConnectionIds(JSON.stringify({ connectionId: "one" })), []); +}); + +test("stale and duplicate IDs are ignored before saved-order backfill", () => { + const restored = parseRecentConnectionIds(JSON.stringify(["missing", "two", "two", 42, " one "])); + const saved = connections("one", "two", "three", "four", "five", "six"); + + assert.deepEqual(restored, ["missing", "two", "one"]); + assert.deepEqual( + rankRecentConnections(saved, restored).map((connection) => connection.id), + ["two", "one", "three", "four", "five"], + ); +}); + +test("empty history preserves saved order and recency follows stable IDs after rename", () => { + const saved = connections("one", "two", "three"); + assert.deepEqual( + rankRecentConnections(saved, []).map((connection) => connection.id), + ["one", "two", "three"], + ); + + const renamed = saved.map((connection) => (connection.id === "two" ? { ...connection, name: "Renamed" } : connection)); + assert.deepEqual( + rankRecentConnections(renamed, ["two"]).map((connection) => connection.name), + ["Renamed", "Connection one", "Connection three"], + ); +}); + +test("App wires active connection changes and Quick Start opens into the MRU history", () => { + const source = readFileSync("apps/desktop/src/App.vue", "utf8"); + + assert.ok(source.includes("rankRecentConnections(connectionStore.connections, recentConnectionIds.value)")); + assert.ok(source.includes("if (nextIds === recentConnectionIds.value) return;")); + assert.match(source, /watch\(\s*\(\) => connectionStore\.activeConnectionId,\s*rememberRecentConnection,?\s*\);/); + assert.match(source, /async function openConnectionQuery\(connectionId: string\)[\s\S]*?rememberRecentConnection\(connectionId\);/); +});