From 806d7c758bc5687d42e4963929049ecf4b677155 Mon Sep 17 00:00:00 2001 From: SuLea-IT <105108570+SuLea-IT@users.noreply.github.com> Date: Thu, 11 Jun 2026 10:53:54 +0800 Subject: [PATCH] feat: add SQL library history query entry points --- apps/desktop/src/App.vue | 27 +++++++ .../src/components/layout/SqlLibraryPanel.vue | 1 + .../src/components/layout/WelcomeScreen.vue | 35 ++++++++ .../src/components/sidebar/TreeItem.vue | 63 +++++++++++++++ apps/desktop/src/i18n/locales/en.ts | 6 ++ apps/desktop/src/i18n/locales/es.ts | 6 ++ apps/desktop/src/i18n/locales/it.ts | 6 ++ apps/desktop/src/i18n/locales/pt-BR.ts | 6 ++ apps/desktop/src/i18n/locales/zh-CN.ts | 6 ++ apps/desktop/src/i18n/locales/zh-TW.ts | 6 ++ apps/desktop/src/lib/savedSqlHistory.ts | 81 +++++++++++++++++++ apps/desktop/src/stores/savedSqlStore.ts | 19 +++++ apps/desktop/src/types/database.ts | 2 + crates/dbx-core/src/saved_sql.rs | 3 + crates/dbx-core/src/storage.rs | 30 +++++-- packages/app-tests/savedSqlHistory.test.ts | 56 +++++++++++++ 16 files changed, 345 insertions(+), 8 deletions(-) create mode 100644 apps/desktop/src/lib/savedSqlHistory.ts create mode 100644 packages/app-tests/savedSqlHistory.test.ts diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index be2b52061..36c0c086a 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -30,6 +30,7 @@ import { useVisibilityChange } from "@/composables/useVisibilityChange"; import "@/i18n"; import { translateBackendError } from "@/i18n/backend-errors"; import * as api from "@/lib/api"; +import { connectionRedactedNameLabel } from "@/lib/connectionPresentation"; import { resolveDefaultDatabase } from "@/lib/defaultDatabase"; import { findTreeNodeById, resolveNewQueryTarget } from "@/lib/newQueryContext"; import { buildExecutableObjectSourceStatements, objectSourceSaveExecutionMode } from "@/lib/objectSourceEditor"; @@ -61,6 +62,7 @@ import { classifyAiSqlExecution } from "@/lib/aiSqlExecutionPolicy"; import { buildHistoryAiAnalysisPrompt } from "@/lib/historyAiAnalysis"; import { countAvailableAgentDriverUpdates, type AgentDriverUpdateBadgeState } from "@/lib/agentDriverUpdateBadge"; import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/safeStorage"; +import { rankSavedSqlHistory } from "@/lib/savedSqlHistory"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -215,6 +217,20 @@ const connectionStats = computed(() => ({ types: new Set(connectionStore.connections.map((c) => c.driver_profile || c.db_type)).size, })); const recentConnections = computed(() => connectionStore.connections.slice(0, 5)); +const savedSqlHistoryItems = computed(() => { + const folderById = new Map(savedSqlStore.allFolders.map((folder) => [folder.id, folder.name])); + return rankSavedSqlHistory(savedSqlStore.allFiles, { limit: 6 }).map((file) => { + const connection = connectionStore.getConfig(file.connectionId); + return { + id: file.id, + name: file.name, + connectionName: connection ? connectionRedactedNameLabel(connection) : t("welcome.unknownConnection"), + database: file.database, + folderName: file.folderId ? folderById.get(file.folderId) : undefined, + openCount: file.openCount ?? 0, + }; + }); +}); const saveSqlFolders = computed(() => { return savedSqlStore.allFolders; }); @@ -619,6 +635,15 @@ async function openConnectionQuery(connectionId: string) { } } +function openSavedSqlFromWelcome(fileId: string) { + const file = savedSqlStore.getFile(fileId); + if (!file) return; + queryStore.openSavedSql(file); + connectionStore.activeConnectionId = file.connectionId; + void savedSqlStore.recordFileUsage(file.id); + toast(t("welcome.fileOpened", { name: file.name }), 2000); +} + async function onClickTable(tableName: string) { const tab = activeTab.value; if (!tab) return; @@ -1088,9 +1113,11 @@ onUnmounted(() => { v-else :connection-stats="connectionStats" :recent-connections="recentConnections" + :saved-sql-history-items="savedSqlHistoryItems" :app-version="appVersion" :has-connections="connectionStore.connections.length > 0" @open-connection-query="openConnectionQuery" + @open-saved-sql="openSavedSqlFromWelcome" @new-connection="showConnectionDialog = true" @new-query="newQuery" @show-history="showHistory = true" diff --git a/apps/desktop/src/components/layout/SqlLibraryPanel.vue b/apps/desktop/src/components/layout/SqlLibraryPanel.vue index 74aaf2d56..8392ef559 100644 --- a/apps/desktop/src/components/layout/SqlLibraryPanel.vue +++ b/apps/desktop/src/components/layout/SqlLibraryPanel.vue @@ -439,6 +439,7 @@ function openFile(file: SavedSqlFile) { if (suppressNextRowClick.value) return; queryStore.openSavedSql(file); connectionStore.activeConnectionId = file.connectionId; + void savedSqlStore.recordFileUsage(file.id); } const contextTarget = ref(null); diff --git a/apps/desktop/src/components/layout/WelcomeScreen.vue b/apps/desktop/src/components/layout/WelcomeScreen.vue index 47c53a1e0..4b0f38790 100644 --- a/apps/desktop/src/components/layout/WelcomeScreen.vue +++ b/apps/desktop/src/components/layout/WelcomeScreen.vue @@ -5,15 +5,26 @@ import DatabaseIcon from "@/components/icons/DatabaseIcon.vue"; import { connectionDriverLabel, connectionIconType, connectionRedactedNameLabel, connectionRedactedOptionSubtitle } from "@/lib/connectionPresentation"; import type { ConnectionConfig } from "@/types/database"; +export interface WelcomeSavedSqlHistoryItem { + id: string; + name: string; + connectionName: string; + database?: string; + folderName?: string; + openCount?: number; +} + defineProps<{ connectionStats: { total: number; connected: number; types: number }; recentConnections: ConnectionConfig[]; + savedSqlHistoryItems: WelcomeSavedSqlHistoryItem[]; appVersion: string; hasConnections: boolean; }>(); const emit = defineEmits<{ "open-connection-query": [connectionId: string]; + "open-saved-sql": [fileId: string]; "new-connection": []; "new-query": []; "show-history": []; @@ -83,6 +94,30 @@ const { t } = useI18n(); +
+
+
{{ t("welcome.sqlHistory") }}
+
+
+ +
+ {{ t("welcome.sqlHistoryEmpty") }} +
+
+
+
diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index 98b6c6905..94e2fd8bd 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -53,6 +53,7 @@ import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomC import { useConnectionStore } from "@/stores/connectionStore"; import { useQueryStore } from "@/stores/queryStore"; import { useSettingsStore } from "@/stores/settingsStore"; +import { useSavedSqlStore } from "@/stores/savedSqlStore"; import { useToast } from "@/composables/useToast"; import { useDatabaseOptions } from "@/composables/useDatabaseOptions"; import type { ColumnInfo, DatabaseType, TreeNode, TreeNodeType } from "@/types/database"; @@ -103,6 +104,7 @@ import { isTauriRuntime } from "@/lib/tauriRuntime"; import { copyToClipboard } from "@/lib/clipboard"; import { hasEnabledTransportLayers } from "@/lib/connectionTransport"; import { formatShortcut } from "@/lib/shortcutRegistry"; +import { rankSavedSqlHistory, type SavedSqlHistoryScope } from "@/lib/savedSqlHistory"; import DatabaseIcon from "@/components/icons/DatabaseIcon.vue"; import ConnectionErrorIndicator from "@/components/connection/ConnectionErrorIndicator.vue"; import VisibleDatabasesDialog from "@/components/sidebar/VisibleDatabasesDialog.vue"; @@ -126,6 +128,7 @@ function isLabelTruncated(): boolean { const connectionStore = useConnectionStore(); const queryStore = useQueryStore(); const settingsStore = useSettingsStore(); +const savedSqlStore = useSavedSqlStore(); const { toast } = useToast(); const { highlight } = useSqlHighlighter(); @@ -2529,6 +2532,60 @@ function copyStructureAsSubmenu(): ContextMenuItem { }; } +function savedSqlHistoryScopeForNode(node: TreeNode): SavedSqlHistoryScope | null { + if (!node.connectionId) return null; + if (node.type === "connection") { + return { connectionId: node.connectionId }; + } + if ((node.type === "database" || node.type === "schema") && hasTreeNodeDatabaseContext(node)) { + return { + connectionId: node.connectionId, + database: node.database, + schema: node.type === "schema" ? node.schema : undefined, + }; + } + if ((node.type === "table" || node.type === "view") && hasTreeNodeDatabaseContext(node)) { + return { + connectionId: node.connectionId, + database: node.database, + schema: node.schema, + tableName: node.label, + }; + } + return null; +} + +function openSavedSqlHistoryFile(fileId: string) { + const file = savedSqlStore.getFile(fileId); + if (!file) return; + queryStore.openSavedSql(file); + connectionStore.activeConnectionId = file.connectionId; + void savedSqlStore.recordFileUsage(file.id); +} + +function savedSqlHistorySubmenu(): ContextMenuItem | null { + const scope = savedSqlHistoryScopeForNode(props.node); + if (!scope) return null; + const files = rankSavedSqlHistory(savedSqlStore.allFiles, { ...scope, limit: 10 }); + return { + label: t("contextMenu.sqlHistory"), + icon: ScrollText, + children: + files.length > 0 + ? files.map((file) => ({ + label: file.name, + action: () => openSavedSqlHistoryFile(file.id), + icon: FileCode, + })) + : [ + { + label: t("contextMenu.noSqlHistory"), + disabled: true, + }, + ], + }; +} + function treeItemMenuItems(): ContextMenuItem[] { const node = props.node; const items: ContextMenuItem[] = []; @@ -2554,6 +2611,8 @@ function treeItemMenuItems(): ContextMenuItem[] { items.push({ label: t("contextMenu.closeConnection"), action: disconnectConnection, icon: Unplug }); } items.push({ label: t("contextMenu.newQuery"), action: newQuery, icon: TerminalSquare }); + const sqlHistoryMenu = savedSqlHistorySubmenu(); + if (sqlHistoryMenu) items.push(sqlHistoryMenu); if (supportsDatabaseUserAdmin(currentDatabaseType())) { items.push({ label: t("contextMenu.userAdmin"), action: openUserAdmin, icon: UsersRound }); } @@ -2641,6 +2700,8 @@ function treeItemMenuItems(): ContextMenuItem[] { items.push({ label: t("contextMenu.openObjectBrowser"), action: openObjectBrowser, icon: TableProperties }); } items.push({ label: t("contextMenu.newQuery"), action: newQuery, icon: TerminalSquare }); + const sqlHistoryMenu = savedSqlHistorySubmenu(); + if (sqlHistoryMenu) items.push(sqlHistoryMenu); if (node.type === "database") { if (!isNodeDefaultDatabase.value) { items.push({ label: t("contextMenu.setDefaultDatabase"), action: setNodeAsDefaultDatabase, icon: Database }); @@ -2758,6 +2819,8 @@ function treeItemMenuItems(): ContextMenuItem[] { }); } items.push({ label: t("contextMenu.newQuery"), action: newQuery, icon: TerminalSquare }); + const sqlHistoryMenu = savedSqlHistorySubmenu(); + if (sqlHistoryMenu) items.push(sqlHistoryMenu); if (canOpenDiagram.value) { items.push({ label: t("diagram.open"), action: openDiagram, icon: Network }); } diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 6db37ece8..207048fce 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -761,6 +761,10 @@ quickConnectionsHint: "Click a connection to open a query tab.", shortcuts: "Shortcuts", shortcutsHint: "Start the next action from here.", + sqlHistory: "Query History", + sqlHistoryEmpty: "No SQL Library records yet", + sqlHistoryOpenCount: "Opened {count} times", + unknownConnection: "Unknown connection", tip: "You can also expand connections on the left and right-click databases or tables for more actions.", tipSidebar: "Click a table on the left to view data", tipExecute: "to execute query", @@ -1002,6 +1006,8 @@ openUserAdmin: "Open Users & Privileges", duplicateConnection: "Duplicate Connection", newQuery: "New Query", + sqlHistory: "Query History", + noSqlHistory: "No related SQL", openObjectBrowser: "Browse Objects", viewData: "View Data", editStructure: "Edit Structure", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 87c5501a1..bf1c55a33 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -669,6 +669,10 @@ quickConnectionsHint: "Haz clic en una conexión para abrir una pestaña de consulta.", shortcuts: "Atajos", shortcutsHint: "Inicia la siguiente acción desde aquí.", + sqlHistory: "Historial de consultas", + sqlHistoryEmpty: "Aún no hay registros de la biblioteca SQL", + sqlHistoryOpenCount: "Abierto {count} veces", + unknownConnection: "Conexión desconocida", tip: "También puedes expandir las conexiones a la izquierda y hacer clic derecho en bases de datos o tablas para más opciones.", tipSidebar: "Haz clic en una tabla a la izquierda para ver los datos", tipExecute: "para ejecutar la consulta", @@ -865,6 +869,8 @@ selectVisibleDatabases: "Seleccionar bases visibles", duplicateConnection: "Duplicar conexión", newQuery: "Nueva consulta", + sqlHistory: "Historial de consultas", + noSqlHistory: "Sin SQL relacionado", openObjectBrowser: "Explorar objetos", viewData: "Ver datos", editStructure: "Editar estructura", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 4a56afb4b..6edfa27db 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -729,6 +729,10 @@ quickConnectionsHint: "Fai clic su una connessione per aprire una scheda query.", shortcuts: "Scorciatoie", shortcutsHint: "Avvia la prossima azione da qui.", + sqlHistory: "Cronologia Query", + sqlHistoryEmpty: "Nessun record nella Libreria SQL", + sqlHistoryOpenCount: "Aperto {count} volte", + unknownConnection: "Connessione sconosciuta", tip: "Puoi anche espandere le connessioni a sinistra e fare clic con il tasto destro su database o tabelle per altre azioni.", tipSidebar: "Fai clic su una tabella a sinistra per visualizzarne i dati", tipExecute: "per eseguire la query", @@ -966,6 +970,8 @@ selectVisibleDatabases: "Seleziona Database Visibili", duplicateConnection: "Duplica Connessione", newQuery: "Nuova Query", + sqlHistory: "Cronologia Query", + noSqlHistory: "Nessun SQL correlato", openObjectBrowser: "Esplora Oggetti", viewData: "Visualizza Dati", editStructure: "Modifica Struttura", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index b13ac2bbe..530d9abb5 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -729,6 +729,10 @@ quickConnectionsHint: "Clique em uma conexão para abrir uma aba de consulta.", shortcuts: "Atalhos", shortcutsHint: "Inicie a próxima ação a partir daqui.", + sqlHistory: "Histórico de Consultas", + sqlHistoryEmpty: "Nenhum registro da Biblioteca SQL ainda", + sqlHistoryOpenCount: "Aberto {count} vezes", + unknownConnection: "Conexão desconhecida", tip: "Você também pode expandir as conexões à esquerda e clicar com o botão direito em bancos de dados ou tabelas para mais ações.", tipSidebar: "Clique em uma tabela à esquerda para visualizar os dados", tipExecute: "para executar a consulta", @@ -966,6 +970,8 @@ selectVisibleDatabases: "Selecionar Bancos de Dados Visíveis", duplicateConnection: "Duplicar Conexão", newQuery: "Nova Consulta", + sqlHistory: "Histórico de Consultas", + noSqlHistory: "Nenhum SQL relacionado", openObjectBrowser: "Explorar Objetos", viewData: "Ver Dados", editStructure: "Editar Estrutura", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index cad899b6b..86e35003b 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -762,6 +762,10 @@ quickConnectionsHint: "点击连接即可新建查询标签页。", shortcuts: "常用操作", shortcutsHint: "从这里开始你的下一步。", + sqlHistory: "历史查询", + sqlHistoryEmpty: "暂无 SQL 库记录", + sqlHistoryOpenCount: "打开 {count} 次", + unknownConnection: "未知连接", tip: "也可以在左侧展开连接,右键数据库或表查看更多操作。", fileOpened: "已打开 {name}", mcpTitle: "AI 编程助手集成", @@ -1001,6 +1005,8 @@ userAdmin: "用户与权限", openUserAdmin: "打开用户与权限", newQuery: "新建查询", + sqlHistory: "历史查询", + noSqlHistory: "暂无关联 SQL", openObjectBrowser: "浏览对象", viewData: "查看数据", editStructure: "编辑表结构", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index c14ff8743..776dbf106 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -708,6 +708,10 @@ quickConnectionsHint: "點選連線以開啟查詢分頁。", shortcuts: "常用操作", shortcutsHint: "從這裡開始你的下一步。", + sqlHistory: "歷史查詢", + sqlHistoryEmpty: "暫無 SQL 庫記錄", + sqlHistoryOpenCount: "開啟 {count} 次", + unknownConnection: "未知連線", tip: "也可以在左側展開連線,並在資料庫或資料表上按右鍵查看更多操作。", tipSidebar: "點選左側資料表以檢視資料", tipExecute: "執行查詢", @@ -945,6 +949,8 @@ selectVisibleDatabases: "選擇顯示資料庫", duplicateConnection: "複製連線", newQuery: "建立查詢", + sqlHistory: "歷史查詢", + noSqlHistory: "暫無關聯 SQL", openObjectBrowser: "瀏覽物件", viewData: "檢視資料", editStructure: "編輯資料表結構", diff --git a/apps/desktop/src/lib/savedSqlHistory.ts b/apps/desktop/src/lib/savedSqlHistory.ts new file mode 100644 index 000000000..66339bf92 --- /dev/null +++ b/apps/desktop/src/lib/savedSqlHistory.ts @@ -0,0 +1,81 @@ +import type { SavedSqlFile } from "@/types/database"; + +const DAY_MS = 24 * 60 * 60 * 1000; +const RECENCY_WINDOW_MS = 30 * DAY_MS; + +export interface SavedSqlHistoryScope { + connectionId?: string; + database?: string; + schema?: string; + tableName?: string; + limit?: number; + now?: number; +} + +function timestamp(value?: string) { + if (!value) return 0; + const time = Date.parse(value); + return Number.isFinite(time) ? time : 0; +} + +function normalize(value?: string) { + return (value || "").trim().toLowerCase(); +} + +function containsToken(text: string | undefined, token: string) { + const normalizedText = normalize(text); + const normalizedToken = normalize(token); + if (!normalizedText || !normalizedToken) return false; + return normalizedText.includes(normalizedToken); +} + +function scopeRelevance(file: SavedSqlFile, scope: SavedSqlHistoryScope) { + let score = 0; + if (scope.database != null && file.database === scope.database) score += 100; + if (scope.schema && file.schema === scope.schema) score += 30; + if (scope.tableName) { + const schemaTable = scope.schema ? `${scope.schema}.${scope.tableName}` : ""; + if (containsToken(file.name, scope.tableName) || containsToken(file.sql, scope.tableName)) score += 80; + if (schemaTable && (containsToken(file.name, schemaTable) || containsToken(file.sql, schemaTable))) score += 40; + } + return score; +} + +export function savedSqlHistoryScore(file: SavedSqlFile, now = Date.now()) { + const openCount = Math.max(0, file.openCount ?? 0); + const openedAt = timestamp(file.openedAt); + const age = openedAt > 0 ? Math.max(0, now - openedAt) : RECENCY_WINDOW_MS; + const recency = openedAt > 0 ? Math.max(0, 1 - age / RECENCY_WINDOW_MS) : 0; + return openCount * 1000 + recency * 999; +} + +export function savedSqlMatchesHistoryScope(file: SavedSqlFile, scope: SavedSqlHistoryScope) { + if (scope.connectionId && file.connectionId !== scope.connectionId) return false; + if (scope.database != null && file.database !== scope.database) return false; + if (scope.schema && file.schema && file.schema !== scope.schema) return false; + return true; +} + +export function rankSavedSqlHistory(files: SavedSqlFile[], scope: SavedSqlHistoryScope = {}) { + const now = scope.now ?? Date.now(); + const ranked = files + .filter((file) => savedSqlMatchesHistoryScope(file, scope)) + .map((file) => ({ + file, + score: scopeRelevance(file, scope) + savedSqlHistoryScore(file, now), + openedAt: timestamp(file.openedAt), + updatedAt: timestamp(file.updatedAt), + })) + .sort((a, b) => { + const scoreDiff = b.score - a.score; + if (scoreDiff !== 0) return scoreDiff; + const openedDiff = b.openedAt - a.openedAt; + if (openedDiff !== 0) return openedDiff; + const updatedDiff = b.updatedAt - a.updatedAt; + if (updatedDiff !== 0) return updatedDiff; + return a.file.name.localeCompare(b.file.name, undefined, { numeric: true, sensitivity: "base" }); + }) + .map((item) => item.file); + + return typeof scope.limit === "number" ? ranked.slice(0, Math.max(0, scope.limit)) : ranked; +} diff --git a/apps/desktop/src/stores/savedSqlStore.ts b/apps/desktop/src/stores/savedSqlStore.ts index 63e9fd7e8..c90e25f31 100644 --- a/apps/desktop/src/stores/savedSqlStore.ts +++ b/apps/desktop/src/stores/savedSqlStore.ts @@ -187,6 +187,24 @@ export const useSavedSqlStore = defineStore("savedSql", () => { await syncToLocalDirectory(); } + async function recordFileUsage(id: string) { + const existing = getFile(id); + if (!existing) return; + try { + const saved = await api.saveSavedSqlFile({ + ...existing, + openCount: (existing.openCount ?? 0) + 1, + openedAt: nowIso(), + }); + files.value = files.value.map((file) => (file.id === id ? saved : file)); + bumpVersion(); + return saved; + } catch (error) { + console.warn("[DBX][saved-sql:usage:error]", error); + return existing; + } + } + async function deleteFile(id: string) { await api.deleteSavedSqlFile(id); files.value = files.value.filter((file) => file.id !== id); @@ -340,6 +358,7 @@ export const useSavedSqlStore = defineStore("savedSql", () => { deleteFolder, saveFile, renameFile, + recordFileUsage, deleteFile, reorderFolders, reorderFiles, diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 06feeab9a..895db871d 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -466,6 +466,8 @@ export interface SavedSqlFile { schema?: string; sql: string; orderIndex?: number; + openCount?: number; + openedAt?: string; createdAt: string; updatedAt: string; } diff --git a/crates/dbx-core/src/saved_sql.rs b/crates/dbx-core/src/saved_sql.rs index 9541b0e89..f8385d29a 100644 --- a/crates/dbx-core/src/saved_sql.rs +++ b/crates/dbx-core/src/saved_sql.rs @@ -24,6 +24,9 @@ pub struct SavedSqlFile { pub sql: String, #[serde(default)] pub order_index: i64, + #[serde(default)] + pub open_count: i64, + pub opened_at: Option, pub created_at: String, pub updated_at: String, } diff --git a/crates/dbx-core/src/storage.rs b/crates/dbx-core/src/storage.rs index aeb79d330..a57295f01 100644 --- a/crates/dbx-core/src/storage.rs +++ b/crates/dbx-core/src/storage.rs @@ -144,6 +144,8 @@ const SCHEMA_STATEMENTS: &[&str] = &[ schema_name TEXT, sql_text TEXT NOT NULL DEFAULT '', order_index INTEGER NOT NULL DEFAULT 0, + open_count INTEGER NOT NULL DEFAULT 0, + opened_at TEXT, created_at TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL DEFAULT '' )", @@ -208,7 +210,11 @@ fn ensure_history_columns_sync(conn: &Connection) -> Result<(), String> { fn ensure_saved_sql_columns_sync(conn: &Connection) -> Result<(), String> { const FOLDER_COLUMNS: &[(&str, &str)] = &[("order_index", "INTEGER NOT NULL DEFAULT 0")]; - const FILE_COLUMNS: &[(&str, &str)] = &[("order_index", "INTEGER NOT NULL DEFAULT 0")]; + const FILE_COLUMNS: &[(&str, &str)] = &[ + ("order_index", "INTEGER NOT NULL DEFAULT 0"), + ("open_count", "INTEGER NOT NULL DEFAULT 0"), + ("opened_at", "TEXT"), + ]; ensure_table_columns(conn, "saved_sql_folders", FOLDER_COLUMNS)?; ensure_table_columns(conn, "saved_sql_files", FILE_COLUMNS)?; @@ -871,8 +877,8 @@ impl Storage { for file in &library.files { tx.execute( "INSERT INTO saved_sql_files \ - (id, connection_id, folder_id, name, database_name, schema_name, sql_text, order_index, created_at, updated_at) \ - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + (id, connection_id, folder_id, name, database_name, schema_name, sql_text, order_index, open_count, opened_at, created_at, updated_at) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", params![ file.id, file.connection_id, @@ -882,6 +888,8 @@ impl Storage { file.schema, file.sql, file.order_index, + file.open_count, + file.opened_at, file.created_at, file.updated_at ], @@ -919,7 +927,7 @@ impl Storage { let mut file_stmt = conn .prepare( - "SELECT id, connection_id, folder_id, name, database_name, schema_name, sql_text, order_index, created_at, updated_at \ + "SELECT id, connection_id, folder_id, name, database_name, schema_name, sql_text, order_index, open_count, opened_at, created_at, updated_at \ FROM saved_sql_files ORDER BY COALESCE(folder_id, ''), order_index, connection_id, name COLLATE NOCASE", ) .map_err(|e| e.to_string())?; @@ -934,8 +942,10 @@ impl Storage { schema: row.get(5)?, sql: row.get(6)?, order_index: row.get(7)?, - created_at: row.get(8)?, - updated_at: row.get(9)?, + open_count: row.get(8)?, + opened_at: row.get(9)?, + created_at: row.get(10)?, + updated_at: row.get(11)?, }) }) .map_err(|e| e.to_string())? @@ -989,8 +999,8 @@ impl Storage { self.with_conn(move |conn| { conn.execute( "INSERT INTO saved_sql_files \ - (id, connection_id, folder_id, name, database_name, schema_name, sql_text, order_index, created_at, updated_at) \ - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \ + (id, connection_id, folder_id, name, database_name, schema_name, sql_text, order_index, open_count, opened_at, created_at, updated_at) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \ ON CONFLICT(id) DO UPDATE SET \ connection_id = excluded.connection_id, \ folder_id = excluded.folder_id, \ @@ -999,6 +1009,8 @@ impl Storage { schema_name = excluded.schema_name, \ sql_text = excluded.sql_text, \ order_index = excluded.order_index, \ + open_count = excluded.open_count, \ + opened_at = excluded.opened_at, \ updated_at = excluded.updated_at", params![ file.id, @@ -1009,6 +1021,8 @@ impl Storage { file.schema, file.sql, file.order_index, + file.open_count, + file.opened_at, file.created_at, file.updated_at ], diff --git a/packages/app-tests/savedSqlHistory.test.ts b/packages/app-tests/savedSqlHistory.test.ts new file mode 100644 index 000000000..f9d1f79ed --- /dev/null +++ b/packages/app-tests/savedSqlHistory.test.ts @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; +import { rankSavedSqlHistory, savedSqlHistoryScore, savedSqlMatchesHistoryScope } from "../../apps/desktop/src/lib/savedSqlHistory.ts"; +import type { SavedSqlFile } from "../../apps/desktop/src/types/database.ts"; + +function file(input: Partial & Pick): SavedSqlFile { + return { + folderId: undefined, + schema: undefined, + sql: "select 1", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + ...input, + }; +} + +test("SQL 库历史按连接和数据库过滤", () => { + const files = [file({ id: "a", name: "a.sql", connectionId: "conn-1", database: "app" }), file({ id: "b", name: "b.sql", connectionId: "conn-1", database: "analytics" }), file({ id: "c", name: "c.sql", connectionId: "conn-2", database: "app" })]; + + assert.deepEqual( + rankSavedSqlHistory(files, { + connectionId: "conn-1", + database: "app", + }).map((item) => item.id), + ["a"], + ); + assert.equal(savedSqlMatchesHistoryScope(files[2], { connectionId: "conn-1" }), false); +}); + +test("SQL 库历史综合打开次数和最近打开时间排序", () => { + const now = Date.parse("2026-06-11T12:00:00.000Z"); + const files = [ + file({ id: "recent", name: "recent.sql", connectionId: "conn", database: "app", openCount: 2, openedAt: "2026-06-11T11:00:00.000Z" }), + file({ id: "frequent", name: "frequent.sql", connectionId: "conn", database: "app", openCount: 7, openedAt: "2026-04-01T00:00:00.000Z" }), + file({ id: "unused", name: "unused.sql", connectionId: "conn", database: "app", openCount: 0 }), + ]; + + assert.deepEqual( + rankSavedSqlHistory(files, { now }).map((item) => item.id), + ["frequent", "recent", "unused"], + ); + assert.ok(savedSqlHistoryScore(files[0], now) > savedSqlHistoryScore(files[2], now)); +}); + +test("表级入口会优先展示命中表名的 SQL", () => { + const now = Date.parse("2026-06-11T12:00:00.000Z"); + const files = [ + file({ id: "generic", name: "daily.sql", connectionId: "conn", database: "app", openCount: 1, openedAt: "2026-06-11T11:00:00.000Z" }), + file({ id: "orders", name: "orders-report.sql", connectionId: "conn", database: "app", sql: "select * from public.orders", openCount: 1, openedAt: "2026-06-11T10:00:00.000Z" }), + ]; + + assert.deepEqual( + rankSavedSqlHistory(files, { connectionId: "conn", database: "app", schema: "public", tableName: "orders", now }).map((item) => item.id), + ["orders", "generic"], + ); +});