feat: add SQL library panel with folder/file management, rename, and sync support

This commit is contained in:
t8y2 committed 2026-06-10 02:46:10 +08:00
1 parent d7a8f5d285
commit 9ffa4c41d9
26 files changed
+711 -352

No files matched your search

+2 -78
View File
@@ -67,7 +67,6 @@ import {
import { decodeSchemaTreeCache, encodeSchemaTreeCache } from "@/lib/schemaTreeCache";
import { sortSidebarTreeChildrenForParent } from "@/lib/sidebarNodeOrdering";
import { prunePinnedTreeNodeIdsForConnection } from "@/lib/pinnedTreeNodeIds";
import { useSavedSqlStore } from "@/stores/savedSqlStore";
import { supportsDatabaseUserAdmin } from "@/lib/databaseUserAdmin";
import { useSettingsStore } from "@/stores/settingsStore";
@@ -382,56 +381,6 @@ export const useConnectionStore = defineStore("connection", () => {
if (treeSelectionAnchorId.value === nodeId) treeSelectionAnchorId.value = null;
}
function buildSavedSqlRootNode(connectionId: string, existingRoot?: TreeNode): TreeNode | undefined {
const savedSqlStore = useSavedSqlStore();
const folders = savedSqlStore.listFolders(connectionId);
const files = savedSqlStore.listFiles(connectionId);
if (folders.length === 0 && files.length === 0) return undefined;
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 = folders.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, ...files.map(fileNode)],
};
}
function buildUserAdminNode(connectionId: string, existingConnectionNode?: TreeNode): TreeNode | undefined {
const config = getConfig(connectionId);
if (!supportsDatabaseUserAdmin(effectiveDatabaseTypeForConnection(config))) return undefined;
@@ -451,35 +400,15 @@ export const useConnectionStore = defineStore("connection", () => {
children: TreeNode[],
existingConnectionNode?: TreeNode,
): TreeNode[] {
const existingRoot = existingConnectionNode?.children?.find((child) => child.type === "saved-sql-root");
const nonUtilityChildren = children.filter(
(child) => child.type !== "saved-sql-root" && child.type !== "user-admin",
);
const nonUtilityChildren = children.filter((child) => child.type !== "user-admin");
const userAdminNode = buildUserAdminNode(connectionId, existingConnectionNode);
const savedSqlRoot = buildSavedSqlRootNode(connectionId, existingRoot);
return [savedSqlRoot, ...nonUtilityChildren, userAdminNode].filter(Boolean) as TreeNode[];
return [...nonUtilityChildren, userAdminNode].filter(Boolean) as TreeNode[];
}
function withSavedSqlRoot(connectionId: string, children: TreeNode[], existingConnectionNode?: TreeNode): TreeNode[] {
return withConnectionUtilityNodes(connectionId, children, existingConnectionNode);
}
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" && child.type !== "user-admin"),
node,
);
}
if (node.children) refresh(node.children);
}
};
refresh(treeNodes.value);
}
function schemaCacheKey(...parts: string[]): string {
return parts.map((part) => encodeURIComponent(part)).join(":");
}
@@ -597,10 +526,6 @@ export const useConnectionStore = defineStore("connection", () => {
const parent = findParentNode(treeNodes.value, id);
if (parent?.children) {
parent.children = orderPinnedFirst(parent.children, (child) => !!child.pinned);
const sqlRootIdx = parent.children.findIndex((c) => c.type === "saved-sql-root");
if (sqlRootIdx > 0) {
parent.children.unshift(...parent.children.splice(sqlRootIdx, 1));
}
}
}
}
@@ -2650,7 +2575,6 @@ export const useConnectionStore = defineStore("connection", () => {
treeNodes,
removeTreeNode,
refreshAllTree,
refreshSavedSqlTree,
refreshTreeNode,
refreshDatabaseTreeNode,
refreshObjectListTreeNode,
+1 -1
View File
@@ -305,7 +305,7 @@ export const useQueryStore = defineStore("query", () => {
const id = uuid();
const tab: QueryTab = {
id,
title: title || `Query ${tabs.value.length + 1}`,
title: title || `query_${tabs.value.length + 1}`,
customTitle: mode === "query" && !!title ? true : undefined,
connectionId,
database,
+221 -7
View File
@@ -1,7 +1,9 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { computed, ref } from "vue";
import { uuid } from "@/lib/utils";
import * as api from "@/lib/api";
import { isTauriRuntime } from "@/lib/tauriRuntime";
import { useSettingsStore } from "@/stores/settingsStore";
import type { SavedSqlFile, SavedSqlFolder, SavedSqlLibrary } from "@/types/database";
const LEGACY_STORAGE_KEY = "dbx-saved-sql-library";
@@ -15,6 +17,38 @@ function nowIso() {
return new Date().toISOString();
}
function sortFoldersByOrder(items: SavedSqlFolder[]) {
return [...items].sort((a, b) => {
const orderDiff = (a.orderIndex ?? 0) - (b.orderIndex ?? 0);
if (orderDiff !== 0) return orderDiff;
return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" });
});
}
function sortFilesByOrder(items: SavedSqlFile[]) {
return [...items].sort((a, b) => {
const orderDiff = (a.orderIndex ?? 0) - (b.orderIndex ?? 0);
if (orderDiff !== 0) return orderDiff;
return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" });
});
}
function reindexFolders(items: SavedSqlFolder[]) {
return items.map((folder, index) => ({ ...folder, orderIndex: index }));
}
function reindexFiles(items: SavedSqlFile[], folderId?: string) {
return items.map((file, index) => ({
...file,
folderId,
orderIndex: index,
}));
}
function maxOrderIndex(values: Array<{ orderIndex?: number }>) {
return values.reduce((max, item) => Math.max(max, item.orderIndex ?? -1), -1);
}
function loadLegacyState(): SavedSqlState {
try {
const raw = localStorage.getItem(LEGACY_STORAGE_KEY);
@@ -33,6 +67,7 @@ export const useSavedSqlStore = defineStore("savedSql", () => {
const folders = ref<SavedSqlFolder[]>([]);
const files = ref<SavedSqlFile[]>([]);
const isLoaded = ref(false);
let pendingSync: Promise<void> | null = null;
const version = ref(0);
function bumpVersion() {
@@ -62,18 +97,17 @@ export const useSavedSqlStore = defineStore("savedSql", () => {
await migrateLegacyLocalStorage();
applyLibrary(await api.loadSavedSqlLibrary());
isLoaded.value = true;
await syncToLocalDirectory();
}
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" }));
return sortFoldersByOrder(folders.value.filter((folder) => folder.connectionId === connectionId));
}
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" }));
return sortFilesByOrder(
files.value.filter((file) => file.connectionId === connectionId && (file.folderId || "") === (folderId || "")),
);
}
function getFile(id: string) {
@@ -86,12 +120,14 @@ export const useSavedSqlStore = defineStore("savedSql", () => {
id: uuid(),
connectionId,
name,
orderIndex: maxOrderIndex(folders.value) + 1,
createdAt: timestamp,
updatedAt: timestamp,
};
const saved = await api.saveSavedSqlFolder(folder);
folders.value = [...folders.value.filter((item) => item.id !== saved.id), saved];
bumpVersion();
await syncToLocalDirectory();
return saved;
}
@@ -101,6 +137,7 @@ export const useSavedSqlStore = defineStore("savedSql", () => {
const saved = await api.saveSavedSqlFolder({ ...existing, name, updatedAt: nowIso() });
folders.value = folders.value.map((folder) => (folder.id === id ? saved : folder));
bumpVersion();
await syncToLocalDirectory();
}
async function deleteFolder(id: string) {
@@ -108,6 +145,7 @@ export const useSavedSqlStore = defineStore("savedSql", () => {
folders.value = folders.value.filter((folder) => folder.id !== id);
files.value = files.value.filter((file) => file.folderId !== id);
bumpVersion();
await syncToLocalDirectory();
}
async function saveFile(input: {
@@ -139,12 +177,21 @@ export const useSavedSqlStore = defineStore("savedSql", () => {
database: input.database,
schema: input.schema,
sql: input.sql,
orderIndex:
maxOrderIndex(
files.value.filter(
(file) =>
file.connectionId === input.connectionId &&
(file.folderId || "") === (input.folderId || undefined || ""),
),
) + 1,
createdAt: timestamp,
updatedAt: timestamp,
};
const saved = await api.saveSavedSqlFile(file);
files.value = [...files.value.filter((item) => item.id !== saved.id), saved];
bumpVersion();
await syncToLocalDirectory();
return saved;
}
@@ -154,12 +201,170 @@ export const useSavedSqlStore = defineStore("savedSql", () => {
const saved = await api.saveSavedSqlFile({ ...existing, name, updatedAt: nowIso() });
files.value = files.value.map((file) => (file.id === id ? saved : file));
bumpVersion();
await syncToLocalDirectory();
}
async function deleteFile(id: string) {
await api.deleteSavedSqlFile(id);
files.value = files.value.filter((file) => file.id !== id);
bumpVersion();
await syncToLocalDirectory();
}
async function persistFolders(nextFolders: SavedSqlFolder[]) {
const reindexed = reindexFolders(nextFolders).map((folder) => ({ ...folder, updatedAt: nowIso() }));
await Promise.all(reindexed.map((folder) => api.saveSavedSqlFolder(folder)));
folders.value = reindexed;
bumpVersion();
await syncToLocalDirectory();
}
async function persistFiles(nextFiles: SavedSqlFile[]) {
await Promise.all(nextFiles.map((file) => api.saveSavedSqlFile(file)));
files.value = nextFiles;
bumpVersion();
await syncToLocalDirectory();
}
function syncEntries() {
const folderById = new Map(folders.value.map((folder) => [folder.id, folder]));
return sortFilesByOrder(files.value).map((file) => ({
folderName: file.folderId ? folderById.get(file.folderId)?.name : undefined,
fileName: file.name,
sql: file.sql,
}));
}
async function syncToLocalDirectory() {
if (!isTauriRuntime()) return;
const settingsStore = useSettingsStore();
const targetDir = settingsStore.desktopSettings.saved_sql_sync_dir?.trim();
if (!targetDir) return;
const syncPromise =
pendingSync?.catch(() => {}).then(() => api.syncSavedSqlDirectory({ targetDir, entries: syncEntries() })) ??
api.syncSavedSqlDirectory({ targetDir, entries: syncEntries() });
pendingSync = syncPromise;
try {
await syncPromise;
} catch (error) {
console.warn("[DBX][saved-sql:sync:error]", error);
} finally {
if (pendingSync === syncPromise) {
pendingSync = null;
}
}
}
async function reorderFolders(draggedId: string, targetId: string, position: "before" | "after") {
const ordered = sortFoldersByOrder(folders.value);
const dragged = ordered.find((folder) => folder.id === draggedId);
const target = ordered.find((folder) => folder.id === targetId);
if (!dragged || !target || dragged.id === target.id) return;
const remaining = ordered.filter((folder) => folder.id !== draggedId);
const targetIndex = remaining.findIndex((folder) => folder.id === targetId);
const insertIndex = position === "before" ? targetIndex : targetIndex + 1;
remaining.splice(insertIndex, 0, dragged);
await persistFolders(remaining);
}
async function moveFileToFolder(fileId: string, folderId?: string) {
const target = files.value.find((file) => file.id === fileId);
if (!target) return;
const targetFolderId = folderId || undefined;
if ((target.folderId || undefined) === targetFolderId) return;
const timestamp = nowIso();
const sourceGroup = sortFilesByOrder(
files.value.filter((file) => (file.folderId || "") === (target.folderId || "")),
).filter((file) => file.id !== fileId);
const destinationGroup = sortFilesByOrder(
files.value.filter((file) => file.id !== fileId && (file.folderId || "") === (targetFolderId || "")),
);
const movedFile: SavedSqlFile = {
...target,
folderId: targetFolderId,
updatedAt: timestamp,
};
const nextSource = reindexFiles(sourceGroup, target.folderId || undefined).map((file) => ({
...file,
updatedAt: timestamp,
}));
const nextDestination = reindexFiles([...destinationGroup, movedFile], targetFolderId).map((file) => ({
...file,
updatedAt: timestamp,
}));
const untouched = files.value.filter(
(file) =>
file.id !== fileId &&
(file.folderId || "") !== (target.folderId || "") &&
(file.folderId || "") !== (targetFolderId || ""),
);
await persistFiles([...untouched, ...nextSource, ...nextDestination]);
}
async function reorderFiles(draggedId: string, targetId: string, position: "before" | "after") {
const dragged = files.value.find((file) => file.id === draggedId);
const target = files.value.find((file) => file.id === targetId);
if (!dragged || !target || dragged.id === target.id) return;
const targetFolderId = target.folderId || undefined;
const groupFiles = sortFilesByOrder(files.value.filter((file) => (file.folderId || "") === (targetFolderId || "")));
const remainingGroup = groupFiles.filter((file) => file.id !== draggedId);
const draggedNext: SavedSqlFile = {
...dragged,
folderId: targetFolderId,
updatedAt: nowIso(),
};
const targetIndex = remainingGroup.findIndex((file) => file.id === targetId);
const insertIndex = position === "before" ? targetIndex : targetIndex + 1;
remainingGroup.splice(insertIndex, 0, draggedNext);
const updatedGroup = reindexFiles(remainingGroup, targetFolderId).map((file) => ({
...file,
updatedAt: draggedNext.updatedAt,
}));
const previousGroupId = dragged.folderId || undefined;
const sourceGroup =
previousGroupId === targetFolderId
? []
: reindexFiles(
sortFilesByOrder(
files.value.filter((file) => file.id !== draggedId && (file.folderId || "") === (previousGroupId || "")),
),
previousGroupId,
).map((file) => ({ ...file, updatedAt: draggedNext.updatedAt }));
const untouched = files.value.filter(
(file) =>
file.id !== draggedId &&
(file.folderId || "") !== (targetFolderId || "") &&
(file.folderId || "") !== (previousGroupId || ""),
);
await persistFiles([...untouched, ...sourceGroup, ...updatedGroup]);
}
const allFolders = computed(() => sortFoldersByOrder(folders.value));
const allFiles = computed(() => sortFilesByOrder(files.value));
function filesInFolder(folderId: string) {
return allFiles.value.filter((f) => f.folderId === folderId);
}
function filesWithoutFolder() {
return allFiles.value.filter((f) => !f.folderId);
}
function orphanedFileIds(activeConnectionIds: Set<string>) {
return new Set(files.value.filter((f) => !activeConnectionIds.has(f.connectionId)).map((f) => f.id));
}
return {
@@ -177,5 +382,14 @@ export const useSavedSqlStore = defineStore("savedSql", () => {
saveFile,
renameFile,
deleteFile,
reorderFolders,
reorderFiles,
moveFileToFolder,
syncToLocalDirectory,
allFolders,
allFiles,
filesInFolder,
filesWithoutFolder,
orphanedFileIds,
};
});
+3
View File
@@ -46,6 +46,7 @@ export interface DesktopSettings {
show_tray_icon: boolean;
icon_theme: DesktopIconTheme;
debug_logging_enabled: boolean;
saved_sql_sync_dir?: string | null;
}
export type DesktopIconTheme = "default" | "black";
@@ -54,6 +55,7 @@ export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
show_tray_icon: true,
icon_theme: "default",
debug_logging_enabled: false,
saved_sql_sync_dir: null,
};
function normalizeDesktopSettings(settings: Partial<DesktopSettings> | null | undefined): DesktopSettings {
@@ -62,6 +64,7 @@ function normalizeDesktopSettings(settings: Partial<DesktopSettings> | null | un
show_tray_icon: settings?.show_tray_icon ?? DEFAULT_DESKTOP_SETTINGS.show_tray_icon,
icon_theme: iconTheme,
debug_logging_enabled: settings?.debug_logging_enabled ?? DEFAULT_DESKTOP_SETTINGS.debug_logging_enabled,
saved_sql_sync_dir: settings?.saved_sql_sync_dir?.trim() || DEFAULT_DESKTOP_SETTINGS.saved_sql_sync_dir,
};
}