mirror of
https://github.com/t8y2/dbx.git
synced 2026-11-01 08:26:35 +00:00
feat(sql): add saved SQL library
This commit is contained in:
+107
-19
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user