feat: add SQL library panel with folder/file management, rename, and sync support
This commit is contained in:
1 parent
d7a8f5d285
commit
9ffa4c41d9
26 files changed
+711
-352
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user