feat(sql): add saved SQL library

This commit is contained in:
t8y2
2026-05-10 00:33:25 +08:00
parent d3919a5c20
commit cac383e6bf
11 changed files with 694 additions and 54 deletions
+107 -19
View File
@@ -23,6 +23,7 @@ import { isTauriRuntime } from "@/lib/tauriRuntime";
import { isSchemaAware, TREE_SCHEMA_TYPES } from "@/lib/databaseCapabilities";
import { buildDatabaseTreeNodes } from "@/lib/databaseTree";
import { buildSqlServerDatabaseTreeNodes, SQLSERVER_DEFAULT_SCHEMA } from "@/lib/sqlServerTree";
import { useSavedSqlStore } from "@/stores/savedSqlStore";
const PINNED_TREE_NODES_STORAGE_KEY = "dbx-pinned-tree-nodes";
@@ -204,6 +205,73 @@ export const useConnectionStore = defineStore("connection", () => {
loadedTreeNodeChildrenIds.value.add(parent.id);
}
function buildSavedSqlRootNode(connectionId: string, existingRoot?: TreeNode): TreeNode {
const savedSqlStore = useSavedSqlStore();
const existingById = new Map<string, TreeNode>();
const collectExisting = (node?: TreeNode) => {
if (!node) return;
existingById.set(node.id, node);
node.children?.forEach(collectExisting);
};
collectExisting(existingRoot);
const fileNode = (file: ReturnType<typeof savedSqlStore.listFiles>[number]): TreeNode => ({
id: `${connectionId}:__saved_sql:file:${file.id}`,
label: file.name,
type: "saved-sql-file",
connectionId,
database: file.database,
schema: file.schema,
savedSqlId: file.id,
});
const folderNodes = savedSqlStore.listFolders(connectionId).map((folder) => {
const id = `${connectionId}:__saved_sql:folder:${folder.id}`;
const existing = existingById.get(id);
return {
id,
label: folder.name,
type: "saved-sql-folder" as const,
connectionId,
savedSqlFolderId: folder.id,
isExpanded: existing?.isExpanded ?? true,
children: savedSqlStore.listFiles(connectionId, folder.id).map(fileNode),
};
});
const rootId = `${connectionId}:__saved_sql`;
return {
id: rootId,
label: "tree.savedSql",
type: "saved-sql-root",
connectionId,
isExpanded: existingRoot?.isExpanded ?? true,
children: [...folderNodes, ...savedSqlStore.listFiles(connectionId).map(fileNode)],
};
}
function withSavedSqlRoot(connectionId: string, children: TreeNode[], existingConnectionNode?: TreeNode): TreeNode[] {
const existingRoot = existingConnectionNode?.children?.find((child) => child.type === "saved-sql-root");
const nonSavedChildren = children.filter((child) => child.type !== "saved-sql-root");
return [buildSavedSqlRootNode(connectionId, existingRoot), ...nonSavedChildren];
}
function refreshSavedSqlTree(connectionId?: string) {
const refresh = (nodes: TreeNode[]) => {
for (const node of nodes) {
if (node.type === "connection" && node.connectionId && (!connectionId || node.connectionId === connectionId)) {
node.children = withSavedSqlRoot(
node.connectionId,
(node.children || []).filter((child) => child.type !== "saved-sql-root"),
node,
);
}
if (node.children) refresh(node.children);
}
};
refresh(treeNodes.value);
}
function schemaCacheKey(...parts: string[]): string {
return parts.map((part) => encodeURIComponent(part)).join(":");
}
@@ -211,7 +279,10 @@ export const useConnectionStore = defineStore("connection", () => {
async function loadPersistedTreeChildren(node: TreeNode, cacheKey: string): Promise<boolean> {
const children = await api.loadSchemaCache<TreeNode[]>(cacheKey).catch(() => null);
if (!children) return false;
setChildren(node, children);
setChildren(
node,
node.type === "connection" && node.connectionId ? withSavedSqlRoot(node.connectionId, children, node) : children,
);
node.isExpanded = true;
return true;
}
@@ -427,7 +498,7 @@ export const useConnectionStore = defineStore("connection", () => {
try {
await ensureConnected(connectionId);
const databases = await api.listDatabases(connectionId);
const children = buildDatabaseTreeNodes(connectionId, databases);
const children = withSavedSqlRoot(connectionId, buildDatabaseTreeNodes(connectionId, databases), node);
setChildren(node, children);
await savePersistedTreeChildren(cacheKey, children);
node.isExpanded = true;
@@ -446,15 +517,19 @@ export const useConnectionStore = defineStore("connection", () => {
const dbs = await api.redisListDatabases(connectionId);
setChildren(
node,
dbs.map((db) => ({
id: `${connectionId}:db${db}`,
label: `db${db}`,
type: "redis-db" as const,
withSavedSqlRoot(
connectionId,
database: String(db),
isExpanded: false,
children: [],
})),
dbs.map((db) => ({
id: `${connectionId}:db${db}`,
label: `db${db}`,
type: "redis-db" as const,
connectionId,
database: String(db),
isExpanded: false,
children: [],
})),
node,
),
);
node.isExpanded = true;
} finally {
@@ -472,15 +547,19 @@ export const useConnectionStore = defineStore("connection", () => {
const dbs = await api.mongoListDatabases(connectionId);
setChildren(
node,
dbs.map((db) => ({
id: `${connectionId}:${db}`,
label: db,
type: "mongo-db" as const,
withSavedSqlRoot(
connectionId,
database: db,
isExpanded: false,
children: [],
})),
dbs.map((db) => ({
id: `${connectionId}:${db}`,
label: db,
type: "mongo-db" as const,
connectionId,
database: db,
isExpanded: false,
children: [],
})),
node,
),
);
node.isExpanded = true;
} finally {
@@ -936,7 +1015,15 @@ export const useConnectionStore = defineStore("connection", () => {
return { ...node, children: mergeState(node.children || []) };
}
if (existing && node.type === "connection") {
return { ...existing, label: node.label, pinned: node.pinned };
return {
...existing,
label: node.label,
pinned: node.pinned,
children: withSavedSqlRoot(node.connectionId!, existing.children || [], existing),
};
}
if (node.type === "connection" && node.connectionId) {
return { ...node, children: withSavedSqlRoot(node.connectionId, node.children || []) };
}
return node;
});
@@ -1113,6 +1200,7 @@ export const useConnectionStore = defineStore("connection", () => {
activeConnectionId,
treeNodes,
refreshAllTree,
refreshSavedSqlTree,
refreshTreeNode,
connectedIds,
connectionErrors,
+36
View File
@@ -11,6 +11,7 @@ import { restoreOpenTabsState, serializeOpenTabs } from "@/lib/openTabsPersisten
import * as api from "@/lib/api";
import { useConnectionStore } from "@/stores/connectionStore";
import { isTauriRuntime } from "@/lib/tauriRuntime";
import type { SavedSqlFile } from "@/stores/savedSqlStore";
const STORAGE_KEY = "dbx-open-tabs";
const ACTIVE_TAB_KEY = "dbx-active-tab";
@@ -111,6 +112,39 @@ export const useQueryStore = defineStore("query", () => {
}
}
function linkSavedSql(id: string, savedSqlId: string, title?: string) {
const tab = tabs.value.find((t) => t.id === id);
if (!tab) return;
tab.savedSqlId = savedSqlId;
if (title) tab.title = title;
}
function openSavedSql(file: SavedSqlFile) {
const existing = tabs.value.find((tab) => tab.savedSqlId === file.id);
if (existing) {
activeTabId.value = existing.id;
return existing.id;
}
const id = uuid();
const tab: QueryTab = {
id,
title: file.name,
connectionId: file.connectionId,
database: file.database,
schema: file.schema,
sql: file.sql,
savedSqlId: file.id,
isExecuting: false,
isCancelling: false,
isExplaining: false,
mode: "query",
};
tabs.value.push(tab);
activeTabId.value = id;
return id;
}
function togglePinnedTab(id: string) {
const tab = tabs.value.find((t) => t.id === id);
if (!tab) return;
@@ -426,6 +460,8 @@ export const useQueryStore = defineStore("query", () => {
closeOtherTabs,
closeAllTabs,
updateSql,
linkSavedSql,
openSavedSql,
togglePinnedTab,
updateDatabase,
updateSchema,
+176
View File
@@ -0,0 +1,176 @@
import { defineStore } from "pinia";
import { computed, ref } from "vue";
import { uuid } from "@/lib/utils";
const STORAGE_KEY = "dbx-saved-sql-library";
export interface SavedSqlFolder {
id: string;
connectionId: string;
name: string;
createdAt: string;
updatedAt: string;
}
export interface SavedSqlFile {
id: string;
connectionId: string;
folderId?: string;
name: string;
database: string;
schema?: string;
sql: string;
createdAt: string;
updatedAt: string;
}
interface SavedSqlState {
folders: SavedSqlFolder[];
files: SavedSqlFile[];
}
function nowIso() {
return new Date().toISOString();
}
function loadState(): SavedSqlState {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return { folders: [], files: [] };
const parsed = JSON.parse(raw) as Partial<SavedSqlState>;
return {
folders: Array.isArray(parsed.folders) ? parsed.folders.filter((item) => item?.id && item?.connectionId) : [],
files: Array.isArray(parsed.files) ? parsed.files.filter((item) => item?.id && item?.connectionId) : [],
};
} catch {
return { folders: [], files: [] };
}
}
export const useSavedSqlStore = defineStore("savedSql", () => {
const initial = loadState();
const folders = ref<SavedSqlFolder[]>(initial.folders);
const files = ref<SavedSqlFile[]>(initial.files);
const version = computed(
() => `${folders.value.length}:${files.value.length}:${files.value.map((f) => f.updatedAt).join("|")}`,
);
function persist() {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ folders: folders.value, files: files.value }));
}
function listFolders(connectionId: string) {
return folders.value
.filter((folder) => folder.connectionId === connectionId)
.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }));
}
function listFiles(connectionId: string, folderId?: string) {
return files.value
.filter((file) => file.connectionId === connectionId && (file.folderId || "") === (folderId || ""))
.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }));
}
function getFile(id: string) {
return files.value.find((file) => file.id === id);
}
function createFolder(connectionId: string, name: string) {
const timestamp = nowIso();
const folder: SavedSqlFolder = {
id: uuid(),
connectionId,
name,
createdAt: timestamp,
updatedAt: timestamp,
};
folders.value = [...folders.value, folder];
persist();
return folder;
}
function renameFolder(id: string, name: string) {
const timestamp = nowIso();
folders.value = folders.value.map((folder) =>
folder.id === id ? { ...folder, name, updatedAt: timestamp } : folder,
);
persist();
}
function deleteFolder(id: string) {
folders.value = folders.value.filter((folder) => folder.id !== id);
files.value = files.value.filter((file) => file.folderId !== id);
persist();
}
function saveFile(input: {
id?: string;
connectionId: string;
folderId?: string;
name: string;
database: string;
schema?: string;
sql: string;
}) {
const timestamp = nowIso();
if (input.id) {
const existing = getFile(input.id);
if (existing) {
const updated: SavedSqlFile = {
...existing,
folderId: input.folderId || undefined,
name: input.name,
database: input.database,
schema: input.schema,
sql: input.sql,
updatedAt: timestamp,
};
files.value = files.value.map((file) => (file.id === input.id ? updated : file));
persist();
return updated;
}
}
const file: SavedSqlFile = {
id: uuid(),
connectionId: input.connectionId,
folderId: input.folderId || undefined,
name: input.name,
database: input.database,
schema: input.schema,
sql: input.sql,
createdAt: timestamp,
updatedAt: timestamp,
};
files.value = [...files.value, file];
persist();
return file;
}
function renameFile(id: string, name: string) {
const timestamp = nowIso();
files.value = files.value.map((file) => (file.id === id ? { ...file, name, updatedAt: timestamp } : file));
persist();
}
function deleteFile(id: string) {
files.value = files.value.filter((file) => file.id !== id);
persist();
}
return {
folders,
files,
version,
listFolders,
listFiles,
getFile,
createFolder,
renameFolder,
deleteFolder,
saveFile,
renameFile,
deleteFile,
};
});