feat: add SQL library history query entry points

This commit is contained in:
SuLea-IT authored and GitHub committed 2026-06-11 10:53:54 +08:00
1 parent 634e26e8e7
commit 806d7c758b
16 files changed
+345 -8

No files matched your search

+27
View File
@@ -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"
@@ -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<SavedSqlFolder | SavedSqlFile | "panel" | null>(null);
@@ -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();
</div>
</div>
<div class="rounded-lg border">
<div class="flex items-center justify-between border-b px-4 py-3">
<div class="flex items-center gap-2 text-sm font-medium"><History class="h-4 w-4" /> {{ t("welcome.sqlHistory") }}</div>
</div>
<div class="divide-y">
<button v-for="item in savedSqlHistoryItems" :key="item.id" class="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-muted/40" @click="emit('open-saved-sql', item.id)">
<History class="h-4 w-4 text-muted-foreground" />
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium">{{ item.name }}</div>
<div class="truncate text-xs text-muted-foreground">
<span>{{ item.connectionName }}</span>
<span v-if="item.database"> · {{ item.database }}</span>
<span v-if="item.folderName"> · {{ item.folderName }}</span>
<span v-if="item.openCount"> · {{ t("welcome.sqlHistoryOpenCount", { count: item.openCount }) }}</span>
</div>
</div>
<FilePlus2 class="h-4 w-4 text-muted-foreground" />
</button>
<div v-if="savedSqlHistoryItems.length === 0" class="px-4 py-8 text-sm text-muted-foreground">
{{ t("welcome.sqlHistoryEmpty") }}
</div>
</div>
</div>
<!-- MCP Integration Hint -->
<div class="rounded-lg border bg-muted/10 px-5 py-4">
<div class="flex items-start gap-3">
@@ -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 });
}
+6
View File
@@ -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",
+6
View File
@@ -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",
+6
View File
@@ -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",
+6
View File
@@ -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",
+6
View File
@@ -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: "编辑表结构",
+6
View File
@@ -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: "編輯資料表結構",
+81
View File
@@ -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;
}
+19
View File
@@ -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,
+2
View File
@@ -466,6 +466,8 @@ export interface SavedSqlFile {
schema?: string;
sql: string;
orderIndex?: number;
openCount?: number;
openedAt?: string;
createdAt: string;
updatedAt: string;
}