feat: add SQL library history query entry points
This commit is contained in:
parent
634e26e8e7
commit
806d7c758b
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: "编辑表结构",
|
||||
|
|
|
|||
|
|
@ -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: "編輯資料表結構",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -466,6 +466,8 @@ export interface SavedSqlFile {
|
|||
schema?: string;
|
||||
sql: string;
|
||||
orderIndex?: number;
|
||||
openCount?: number;
|
||||
openedAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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<SavedSqlFile> & Pick<SavedSqlFile, "id" | "name" | "connectionId" | "database">): 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"],
|
||||
);
|
||||
});
|
||||
Loading…
Reference in New Issue