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

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

View File

@ -70,6 +70,7 @@ import type { AiAction } from "@/lib/ai";
const AiAssistant = defineAsyncComponent(() => import("@/components/editor/AiAssistant.vue"));
const QueryHistory = defineAsyncComponent(() => import("@/components/editor/QueryHistory.vue"));
const SqlLibraryPanel = defineAsyncComponent(() => import("@/components/layout/SqlLibraryPanel.vue"));
const DriverStorePage = defineAsyncComponent(() => import("@/components/config/DriverStoreDialog.vue"));
const UpdateDialog = defineAsyncComponent(() => import("@/components/layout/UpdateDialog.vue"));
const LoginPage = defineAsyncComponent(() => import("@/components/auth/LoginPage.vue"));
@ -116,10 +117,19 @@ const showDriverStore = ref(false);
const agentDriverUpdateCount = ref(0);
const showHistory = ref(false);
const showAiPanel = ref(safeLocalStorageGet("dbx-ai-panel-open") === "true");
const showSqlLibraryPanel = ref(safeLocalStorageGet("dbx-sql-library-open") === "true");
const sidebarOpen = ref(safeLocalStorageGet("dbx-sidebar-open") !== "false");
const aiPanelReady = ref(false);
const { sidebarWidth, aiPanelWidth, historyWidth, startSidebarResize, startAiPanelResize, startHistoryResize } =
usePanelResize();
const {
sidebarWidth,
aiPanelWidth,
historyWidth,
sqlLibraryWidth,
startSidebarResize,
startAiPanelResize,
startHistoryResize,
startSqlLibraryResize,
} = usePanelResize();
const aiAssistantRef = ref<AiAssistantHandle | null>(null);
const appSidebarRef = ref<InstanceType<typeof AppSidebar> | null>(null);
const contentAreaRef = ref<InstanceType<typeof ContentArea> | null>(null);
@ -245,8 +255,7 @@ const connectionStats = computed(() => ({
}));
const recentConnections = computed(() => connectionStore.connections.slice(0, 5));
const saveSqlFolders = computed(() => {
const tab = activeTab.value;
return tab ? savedSqlStore.listFolders(tab.connectionId) : [];
return savedSqlStore.allFolders;
});
async function applyUiScale(scale: number) {
@ -325,6 +334,11 @@ function toggleAiPanel() {
safeLocalStorageSet("dbx-ai-panel-open", String(showAiPanel.value));
}
function toggleSqlLibrary() {
showSqlLibraryPanel.value = !showSqlLibraryPanel.value;
safeLocalStorageSet("dbx-sql-library-open", String(showSqlLibraryPanel.value));
}
function fixWithAi(errorMessage: string) {
if (!showAiPanel.value) {
showAiPanel.value = true;
@ -371,8 +385,9 @@ function formatActiveSql() {
}
function defaultSavedSqlName(title: string) {
const trimmed = title.trim() || "Query";
return trimmed.endsWith(".sql") ? trimmed : `${trimmed}.sql`;
const trimmed = title.trim() || "query";
const normalized = trimmed.replace(/\s+/g, "_");
return normalized.endsWith(".sql") ? normalized : `${normalized}.sql`;
}
async function openSaveSqlDialog() {
@ -394,7 +409,6 @@ async function openSaveSqlDialog() {
sql: tab.sql,
});
queryStore.linkSavedSql(tab.id, updated.id, updated.name);
connectionStore.refreshSavedSqlTree(tab.connectionId);
toast(t("savedSql.saved"), 2000);
return;
}
@ -445,7 +459,6 @@ async function confirmSaveSqlToLibrary() {
sql: tab.sql,
});
queryStore.linkSavedSql(tab.id, saved.id, saved.name);
connectionStore.refreshSavedSqlTree(tab.connectionId);
showSaveSqlDialog.value = false;
toast(t("savedSql.saved"), 2000);
} catch (e: any) {
@ -868,9 +881,10 @@ function onLoginSuccess() {
function initApp() {
const t0 = performance.now();
console.log("[STARTUP] initApp begin");
settingsStore.initDesktopSettings().catch(() => {});
savedSqlStore
.initFromStorage()
settingsStore
.initDesktopSettings()
.catch(() => {})
.then(() => savedSqlStore.initFromStorage())
.then(() => {
console.log(`[STARTUP] savedSqlStore.initFromStorage: ${(performance.now() - t0).toFixed(0)}ms`);
return connectionStore.initFromDisk();
@ -1010,6 +1024,7 @@ onUnmounted(() => {
:theme-mode="themeMode"
:show-ai-panel="showAiPanel"
:show-history="showHistory"
:show-sql-library="showSqlLibraryPanel"
:show-driver-store="showDriverStore"
:checking-updates="checkingUpdates"
:has-update-available="toolbarHasUpdateAvailable"
@ -1021,6 +1036,7 @@ onUnmounted(() => {
@set-theme-mode="setThemeMode"
@toggle-ai="toggleAiPanel"
@toggle-history="showHistory = !showHistory"
@toggle-sql-library="toggleSqlLibrary"
@open-github="openGitHub"
@open-settings="showSettingsDialog = true"
@open-driver-store="showDriverStore = !showDriverStore"
@ -1235,6 +1251,21 @@ onUnmounted(() => {
@close="showHistory = false"
/>
</div>
<div
v-if="showSqlLibraryPanel"
:class="
isClassicLayout
? 'h-full shrink-0 relative z-30 isolate bg-background'
: 'h-full shrink-0 relative z-30 isolate rounded-md border border-border/80 bg-background'
"
:style="{ width: sqlLibraryWidth + 'px' }"
>
<div class="panel-resize-handle panel-resize-handle--left" @mousedown="startSqlLibraryResize" />
<div class="h-full min-h-0 overflow-hidden">
<SqlLibraryPanel @close="showSqlLibraryPanel = false" />
</div>
</div>
</div>
<AppDialogs

View File

@ -2,7 +2,7 @@
import { ref, computed, onMounted } from "vue";
import { useI18n } from "vue-i18n";
import { useSqlHighlighter } from "@/composables/useSqlHighlighter";
import { Clock, Copy, Database, RotateCcw, Search, Sparkles, Trash2, X } from "@lucide/vue";
import { Copy, Database, RotateCcw, Search, Sparkles, Trash2, X } from "@lucide/vue";
import { RecycleScroller } from "vue-virtual-scroller";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
@ -206,7 +206,6 @@ onMounted(() => store.load());
<template>
<div class="h-full flex flex-col overflow-hidden border-l">
<div class="h-9 flex items-center gap-1 px-2 border-b shrink-0 bg-muted/20">
<Clock class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
<span class="text-xs font-medium">{{ t("history.title") }}</span>
<span class="flex-1" />
<Button v-if="store.entries.length > 0" variant="ghost" size="icon" class="h-5 w-5" @click="confirmClearHistory">
@ -218,18 +217,7 @@ onMounted(() => store.load());
</div>
<div class="border-b shrink-0">
<div class="flex items-center gap-1 px-2 py-1">
<Search class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
<input
v-model="searchText"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
class="flex-1 h-5 text-xs bg-transparent outline-none placeholder:text-muted-foreground"
:placeholder="t('history.search')"
/>
</div>
<div class="flex gap-1 overflow-x-auto px-2 pb-2">
<div class="flex gap-1 overflow-x-auto px-2 pt-2">
<button
v-for="filter in filters"
:key="filter"
@ -241,6 +229,17 @@ onMounted(() => store.load());
{{ filterLabel(filter) }}
</button>
</div>
<div class="relative flex items-center px-2 py-1">
<Search class="absolute left-3 w-3 h-3 text-muted-foreground pointer-events-none" />
<input
v-model="searchText"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
class="flex-1 h-5 text-xs bg-transparent border rounded pl-5 pr-1 outline-none placeholder:text-muted-foreground"
:placeholder="t('history.search')"
/>
</div>
</div>
<div class="min-h-0 flex-1">

View File

@ -13,6 +13,7 @@ import {
Bot,
ArrowLeftRight,
FileCode,
FileStack,
GitCompareArrows,
TableProperties,
Settings,
@ -34,6 +35,7 @@ const props = defineProps<{
themeMode: AppThemeMode;
showAiPanel: boolean;
showHistory: boolean;
showSqlLibrary: boolean;
showDriverStore: boolean;
checkingUpdates: boolean;
hasUpdateAvailable: boolean;
@ -48,6 +50,7 @@ const emit = defineEmits<{
"set-theme-mode": [mode: AppThemeMode];
"toggle-ai": [];
"toggle-history": [];
"toggle-sql-library": [];
"open-github": [];
"open-settings": [];
"open-driver-store": [];
@ -196,6 +199,21 @@ function onToolbarDblClick(e: MouseEvent) {
<ExportProgressPopover />
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
:class="{ 'bg-accent': showSqlLibrary }"
@click="emit('toggle-sql-library')"
>
<FileStack class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("sqlLibrary.title") }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button

View File

@ -40,7 +40,6 @@ import {
Scissors,
CopyPlus,
Plus,
FileText,
ScrollText,
Braces,
Code2,
@ -52,7 +51,6 @@ import {
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
import { useSavedSqlStore } from "@/stores/savedSqlStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { useToast } from "@/composables/useToast";
import { useDatabaseOptions } from "@/composables/useDatabaseOptions";
@ -164,7 +162,6 @@ function isLabelTruncated(): boolean {
}
const connectionStore = useConnectionStore();
const queryStore = useQueryStore();
const savedSqlStore = useSavedSqlStore();
const settingsStore = useSettingsStore();
const { toast } = useToast();
const { highlight } = useSqlHighlighter();
@ -246,12 +243,6 @@ function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null {
return { icon: TableProperties, colorClass: "text-primary" };
case "user-admin":
return { icon: UsersRound, colorClass: "text-primary" };
case "saved-sql-root":
return { icon: FolderOpen, colorClass: "text-blue-500" };
case "saved-sql-folder":
return { icon: node.isExpanded ? FolderOpen : FolderClosed, colorClass: "text-blue-400" };
case "saved-sql-file":
return { icon: FileText, colorClass: "text-blue-300" };
case "index":
return { icon: Key, colorClass: "text-amber-400" };
case "fkey":
@ -307,8 +298,6 @@ const groupTypes: Set<TreeNodeType> = new Set([
"group-sequences",
"group-packages",
"group-partitions",
"saved-sql-root",
"saved-sql-folder",
]);
const pinnableTypes: Set<TreeNodeType> = new Set([
"connection-group",
@ -356,12 +345,6 @@ async function toggle() {
return;
}
if (node.type === "saved-sql-root" || node.type === "saved-sql-folder") {
node.isExpanded = !node.isExpanded;
emit("node-toggled", node, wasExpanded);
return;
}
if (node.type === "group-partitions") {
node.isExpanded = !node.isExpanded;
emit("node-toggled", node, wasExpanded);
@ -484,8 +467,6 @@ function runRowClickAction() {
node.type === "package-body"
) {
void viewObjectSource();
} else if (node.type === "saved-sql-file") {
openSavedSqlFile();
} else if (action === "toggle") {
toggle();
}
@ -650,9 +631,7 @@ function canRefreshTreeNodeShortcut(): boolean {
if (type === "connection" || type === "database" || type === "schema" || type === "table" || type === "view") {
return true;
}
return (
isGroupLabel(props.node) && type !== "saved-sql-root" && type !== "saved-sql-folder" && type !== "group-partitions"
);
return isGroupLabel(props.node) && type !== "group-partitions";
}
function requestRenameSelectedNode(): boolean {
@ -666,14 +645,6 @@ function requestRenameSelectedNode(): boolean {
startRenameGroup();
return true;
}
if (props.node.type === "saved-sql-folder") {
openRenameSavedSqlFolder();
return true;
}
if (props.node.type === "saved-sql-file") {
openRenameSavedSqlFile();
return true;
}
return false;
}
@ -687,14 +658,6 @@ function requestDeleteSelectedNode(): boolean {
deleteConnectionGroup();
return true;
}
if (props.node.type === "saved-sql-file") {
deleteSavedSqlFile();
return true;
}
if (props.node.type === "saved-sql-folder") {
deleteSavedSqlFolder();
return true;
}
if (canDropDatabase.value) {
dropDatabase();
return true;
@ -722,8 +685,6 @@ function onDoubleClick() {
openData();
} else if (action === "open-source") {
void viewObjectSource();
} else if (action === "open-saved-sql") {
openSavedSqlFile();
} else if (action === "toggle") {
toggle();
}
@ -773,15 +734,6 @@ async function openUserAdmin() {
}
}
function openSavedSqlFile() {
const id = props.node.savedSqlId;
if (!id) return;
const file = savedSqlStore.getFile(id);
if (!file) return;
queryStore.openSavedSql(file);
connectionStore.activeConnectionId = file.connectionId;
}
async function openData() {
const node = props.node;
if (!(node.type === "table" || node.type === "view") || !hasNodeDatabaseContext(node)) return;
@ -2518,9 +2470,6 @@ const hasTypeMenu = computed(() => {
t === "function" ||
t === "package" ||
t === "package-body" ||
t === "saved-sql-root" ||
t === "saved-sql-folder" ||
t === "saved-sql-file" ||
isGroupLabel(props.node)
);
});
@ -2688,70 +2637,6 @@ const currentGroupId = computed(() => {
return null;
});
// --- Saved SQL Library ---
const showSavedSqlNameDialog = ref(false);
const savedSqlNameMode = ref<"folder-create" | "folder-rename" | "file-rename" | null>(null);
const savedSqlNameInput = ref("");
const showDeleteSavedSqlFileConfirm = ref(false);
const showDeleteSavedSqlFolderConfirm = ref(false);
function openCreateSavedSqlFolder() {
savedSqlNameMode.value = "folder-create";
savedSqlNameInput.value = t("savedSql.newFolderDefault");
showSavedSqlNameDialog.value = true;
}
function openRenameSavedSqlFolder() {
savedSqlNameMode.value = "folder-rename";
savedSqlNameInput.value = props.node.label;
showSavedSqlNameDialog.value = true;
}
function openRenameSavedSqlFile() {
savedSqlNameMode.value = "file-rename";
savedSqlNameInput.value = props.node.label.replace(/\.sql$/i, "");
showSavedSqlNameDialog.value = true;
}
async function confirmSavedSqlName() {
const name = savedSqlNameInput.value.trim();
if (!name || !props.node.connectionId || !savedSqlNameMode.value) return;
if (savedSqlNameMode.value === "folder-create") {
await savedSqlStore.createFolder(props.node.connectionId, name);
} else if (savedSqlNameMode.value === "folder-rename" && props.node.savedSqlFolderId) {
await savedSqlStore.renameFolder(props.node.savedSqlFolderId, name);
} else if (savedSqlNameMode.value === "file-rename" && props.node.savedSqlId) {
await savedSqlStore.renameFile(props.node.savedSqlId, name.endsWith(".sql") ? name : `${name}.sql`);
}
connectionStore.refreshSavedSqlTree(props.node.connectionId);
showSavedSqlNameDialog.value = false;
savedSqlNameMode.value = null;
}
function deleteSavedSqlFile() {
showDeleteSavedSqlFileConfirm.value = true;
}
async function confirmDeleteSavedSqlFile() {
if (!props.node.savedSqlId) return;
await savedSqlStore.deleteFile(props.node.savedSqlId);
connectionStore.refreshSavedSqlTree(props.node.connectionId);
showDeleteSavedSqlFileConfirm.value = false;
}
function deleteSavedSqlFolder() {
showDeleteSavedSqlFolderConfirm.value = true;
}
async function confirmDeleteSavedSqlFolder() {
if (!props.node.savedSqlFolderId) return;
await savedSqlStore.deleteFolder(props.node.savedSqlFolderId);
connectionStore.refreshSavedSqlTree(props.node.connectionId);
showDeleteSavedSqlFolderConfirm.value = false;
}
// --- Drag and Drop ---
import { useDragSort } from "@/composables/useDragSort";
@ -3272,27 +3157,8 @@ function treeItemMenuItems(): ContextMenuItem[] {
return items;
}
// 9. Group Labels (saved-sql-root, saved-sql-folder, group-columns, etc.)
// 9. Group Labels (group-columns, group-tables, etc.)
if (isGroupLabel(node)) {
if (node.type === "saved-sql-root") {
items.push({ label: t("savedSql.newFolder"), action: openCreateSavedSqlFolder, icon: FolderPlus });
}
if (node.type === "saved-sql-folder") {
items.push({
label: t("savedSql.renameFolder"),
action: openRenameSavedSqlFolder,
icon: Pencil,
shortcut: shortcutRename,
});
items.push({
label: t("savedSql.deleteFolder"),
action: deleteSavedSqlFolder,
icon: Trash2,
shortcut: shortcutDelete,
variant: "destructive" as const,
});
items.push({ label: "", separator: true });
}
const hasGroupCreateAction =
(node.type === "group-tables" && canCreateTable.value) ||
(node.type === "group-views" && !!node.connectionId && !!node.database);
@ -3305,7 +3171,7 @@ function treeItemMenuItems(): ContextMenuItem[] {
if (hasGroupCreateAction) {
items.push({ label: "", separator: true });
}
if (node.type !== "saved-sql-root" && node.type !== "saved-sql-folder" && node.type !== "group-partitions") {
if (node.type !== "group-partitions") {
items.push({
label: t("contextMenu.refreshChildren"),
action: refresh,
@ -3316,27 +3182,7 @@ function treeItemMenuItems(): ContextMenuItem[] {
return items;
}
// 10. Saved SQL File
if (node.type === "saved-sql-file") {
items.push({ label: t("savedSql.open"), action: openSavedSqlFile, icon: FileText });
items.push({
label: t("savedSql.renameFile"),
action: openRenameSavedSqlFile,
icon: Pencil,
shortcut: shortcutRename,
});
items.push({ label: "", separator: true });
items.push({
label: t("savedSql.deleteFile"),
action: deleteSavedSqlFile,
icon: Trash2,
shortcut: shortcutDelete,
variant: "destructive" as const,
});
return items;
}
// 11. Universal Copy Name (for all types except connection)
// 10. Universal Copy Name (for all types except connection)
if (hasTypeMenu.value) {
items.push({ label: "", separator: true });
items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value });
@ -3352,7 +3198,7 @@ function treeItemMenuItems(): ContextMenuItem[] {
<LightTooltip :text="displayLabel(node)" :disabled="isTooltipDisabled" side="right" :side-offset="8" :delay="0">
<div
ref="rowRef"
class="group flex items-center gap-1.5 py-1 px-2 cursor-pointer hover:bg-accent transition-colors relative outline-none"
class="group flex items-center gap-1.5 py-1 px-2 cursor-pointer hover:bg-accent relative outline-none"
style="contain: layout style"
:class="[
rowWidthClass,
@ -3360,8 +3206,7 @@ function treeItemMenuItems(): ContextMenuItem[] {
'ring-1 ring-primary/50 bg-primary/5': showDropInside,
'opacity-50': isDragging,
'tree-item-connection-tint': connectionColor,
'rounded-none': connectionColor && !isSelected && !isMultiSelected,
'rounded-sm': !connectionColor && !isSelected && !isMultiSelected,
rounded: !isSelected && !isMultiSelected,
'tree-item-active rounded-none': connectionColor && (isSelected || isMultiSelected),
'tree-item-active rounded-md': !connectionColor && (isSelected || isMultiSelected),
'tree-item-highlight': highlighted,
@ -3406,7 +3251,7 @@ function treeItemMenuItems(): ContextMenuItem[] {
<component
v-else
:is="getIconInfo(node)?.icon || Database"
class="w-3.5 h-3.5 shrink-0 transition-colors"
class="w-3.5 h-3.5 shrink-0"
:class="nodeIconClass"
/>
<input
@ -3533,29 +3378,6 @@ function treeItemMenuItems(): ContextMenuItem[] {
</DialogContent>
</Dialog>
<Dialog v-model:open="showSavedSqlNameDialog">
<DialogContent class="sm:max-w-[380px]">
<DialogHeader>
<DialogTitle>
{{
savedSqlNameMode === "folder-create"
? t("savedSql.newFolder")
: savedSqlNameMode === "folder-rename"
? t("savedSql.renameFolder")
: t("savedSql.renameFile")
}}
</DialogTitle>
</DialogHeader>
<Input v-model="savedSqlNameInput" @keydown.enter.prevent="confirmSavedSqlName" />
<DialogFooter>
<Button variant="outline" @click="showSavedSqlNameDialog = false">{{ t("dangerDialog.cancel") }}</Button>
<Button :disabled="!savedSqlNameInput.trim()" @click="confirmSavedSqlName">{{
t("dangerDialog.confirm")
}}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog v-model:open="showRenameObjectDialog">
<DialogContent class="sm:max-w-[420px]">
<DialogHeader>
@ -3645,38 +3467,6 @@ function treeItemMenuItems(): ContextMenuItem[] {
</DialogContent>
</Dialog>
<Dialog v-model:open="showDeleteSavedSqlFileConfirm">
<DialogContent class="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>{{ t("savedSql.deleteFile") }}</DialogTitle>
</DialogHeader>
<p class="text-sm text-muted-foreground">
{{ t("savedSql.deleteFileConfirm", { name: node.label }) }}
</p>
<DialogFooter>
<Button variant="outline" @click="showDeleteSavedSqlFileConfirm = false">{{ t("dangerDialog.cancel") }}</Button>
<Button variant="destructive" @click="confirmDeleteSavedSqlFile">{{ t("savedSql.deleteFile") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog v-model:open="showDeleteSavedSqlFolderConfirm">
<DialogContent class="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>{{ t("savedSql.deleteFolder") }}</DialogTitle>
</DialogHeader>
<p class="text-sm text-muted-foreground">
{{ t("savedSql.deleteFolderConfirm", { name: node.label }) }}
</p>
<DialogFooter>
<Button variant="outline" @click="showDeleteSavedSqlFolderConfirm = false">{{
t("dangerDialog.cancel")
}}</Button>
<Button variant="destructive" @click="confirmDeleteSavedSqlFolder">{{ t("savedSql.deleteFolder") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<DangerConfirmDialog
v-model:open="showDropTableConfirm"
:title="t('contextMenu.confirmDropTableTitle')"

View File

@ -5,6 +5,7 @@ export function usePanelResize() {
const sidebarWidth = ref(Number(safeLocalStorageGet("dbx-sidebar-width")) || 260);
const aiPanelWidth = ref(Number(safeLocalStorageGet("dbx-ai-panel-width")) || 360);
const historyWidth = ref(Number(safeLocalStorageGet("dbx-history-width")) || 288);
const sqlLibraryWidth = ref(Number(safeLocalStorageGet("dbx-sql-library-width")) || 288);
function startPanelResize(widthRef: Ref<number>, storageKey: string, direction: "left" | "right") {
return (e: MouseEvent) => {
@ -31,13 +32,16 @@ export function usePanelResize() {
const startSidebarResize = startPanelResize(sidebarWidth, "dbx-sidebar-width", "right");
const startAiPanelResize = startPanelResize(aiPanelWidth, "dbx-ai-panel-width", "left");
const startHistoryResize = startPanelResize(historyWidth, "dbx-history-width", "left");
const startSqlLibraryResize = startPanelResize(sqlLibraryWidth, "dbx-sql-library-width", "left");
return {
sidebarWidth,
aiPanelWidth,
historyWidth,
sqlLibraryWidth,
startSidebarResize,
startAiPanelResize,
startHistoryResize,
startSqlLibraryResize,
};
}

View File

@ -105,6 +105,32 @@ export default {
deleteFile: "Delete SQL",
deleteFileConfirm: "Delete “{name}”?",
},
sqlLibrary: {
title: "SQL Library",
empty: "No saved queries yet",
emptyFolder: "Empty folder",
unfiled: "Unfiled",
exportFile: "Export SQL",
exportFolder: "Export Folder",
exportLibrary: "Export SQL Library",
importDirectory: "Import Local Folder",
importIntoFolder: "Import Into Folder",
openStorageDirectory: "Open SQL Storage Directory",
chooseSyncDirectory: "Set Local Sync Directory",
disableSyncDirectory: "Disable Local Sync Directory",
syncDirectorySaved: "SQL local sync directory saved",
syncDirectoryDisabled: "SQL local sync disabled",
syncDirectoryFailed: "Failed to set sync directory: {message}",
openDirectoryFailed: "Failed to open directory: {message}",
exported: "Exported",
exportFailed: "Export failed: {message}",
importFailed: "Import failed: {message}",
importNone: "No .sql files found in the selected folder",
imported: "Imported {count} SQL files",
desktopOnly: "This feature is only available on desktop",
noConnection: "No connection is available for import",
},
connection: {
title: "New Connection",
name: "Name",

View File

@ -105,6 +105,32 @@ export default {
deleteFile: "Eliminar SQL",
deleteFileConfirm: "¿Eliminar “{name}”?",
},
sqlLibrary: {
title: "Biblioteca SQL",
empty: "Sin consultas guardadas",
emptyFolder: "Carpeta vacía",
unfiled: "Sin categoría",
exportFile: "Exportar SQL",
exportFolder: "Exportar carpeta",
exportLibrary: "Exportar biblioteca SQL",
importDirectory: "Importar carpeta local",
importIntoFolder: "Importar a esta carpeta",
openStorageDirectory: "Abrir directorio de SQL",
chooseSyncDirectory: "Definir directorio de sincronización local",
disableSyncDirectory: "Desactivar sincronización local",
syncDirectorySaved: "Directorio de sincronización local de SQL guardado",
syncDirectoryDisabled: "Sincronización local de SQL desactivada",
syncDirectoryFailed: "Error al definir el directorio de sincronización: {message}",
openDirectoryFailed: "Error al abrir el directorio: {message}",
exported: "Exportado",
exportFailed: "Error al exportar: {message}",
importFailed: "Error al importar: {message}",
importNone: "No se encontraron archivos .sql en la carpeta seleccionada",
imported: "Se importaron {count} archivos SQL",
desktopOnly: "Esta función solo está disponible en escritorio",
noConnection: "No hay conexión disponible para importar",
},
connection: {
title: "Nueva conexión",
name: "Nombre",

View File

@ -105,6 +105,32 @@ export default {
deleteFile: "Elimina SQL",
deleteFileConfirm: "Eliminare “{name}”?",
},
sqlLibrary: {
title: "Libreria SQL",
empty: "Nessuna query salvata",
emptyFolder: "Cartella vuota",
unfiled: "Non categorizzati",
exportFile: "Esporta SQL",
exportFolder: "Esporta cartella",
exportLibrary: "Esporta libreria SQL",
importDirectory: "Importa cartella locale",
importIntoFolder: "Importa in questa cartella",
openStorageDirectory: "Apri directory SQL",
chooseSyncDirectory: "Imposta directory di sincronizzazione locale",
disableSyncDirectory: "Disattiva sincronizzazione locale",
syncDirectorySaved: "Directory di sincronizzazione locale SQL salvata",
syncDirectoryDisabled: "Sincronizzazione locale SQL disattivata",
syncDirectoryFailed: "Impossibile impostare la directory di sincronizzazione: {message}",
openDirectoryFailed: "Impossibile aprire la directory: {message}",
exported: "Esportato",
exportFailed: "Esportazione non riuscita: {message}",
importFailed: "Importazione non riuscita: {message}",
importNone: "Nessun file .sql trovato nella cartella selezionata",
imported: "Importati {count} file SQL",
desktopOnly: "Questa funzione è disponibile solo su desktop",
noConnection: "Nessuna connessione disponibile per l'importazione",
},
connection: {
title: "Nuova Connessione",
name: "Nome",

View File

@ -105,6 +105,32 @@ export default {
deleteFile: "Excluir SQL",
deleteFileConfirm: "Excluir “{name}”?",
},
sqlLibrary: {
title: "Biblioteca SQL",
empty: "Nenhuma consulta salva",
emptyFolder: "Pasta vazia",
unfiled: "Sem categoria",
exportFile: "Exportar SQL",
exportFolder: "Exportar pasta",
exportLibrary: "Exportar biblioteca SQL",
importDirectory: "Importar pasta local",
importIntoFolder: "Importar para esta pasta",
openStorageDirectory: "Abrir diretório de SQL",
chooseSyncDirectory: "Definir diretório de sincronização local",
disableSyncDirectory: "Desativar sincronização local",
syncDirectorySaved: "Diretório de sincronização local de SQL salvo",
syncDirectoryDisabled: "Sincronização local de SQL desativada",
syncDirectoryFailed: "Falha ao definir diretório de sincronização: {message}",
openDirectoryFailed: "Falha ao abrir diretório: {message}",
exported: "Exportado",
exportFailed: "Falha na exportação: {message}",
importFailed: "Falha na importação: {message}",
importNone: "Nenhum arquivo .sql encontrado na pasta selecionada",
imported: "Importados {count} arquivos SQL",
desktopOnly: "Este recurso está disponível apenas no desktop",
noConnection: "Nenhuma conexão disponível para importar",
},
connection: {
title: "Nova Conexão",
name: "Nome",

View File

@ -104,6 +104,33 @@ export default {
deleteFile: "删除 SQL",
deleteFileConfirm: "确定删除“{name}”吗?",
},
sqlLibrary: {
title: "SQL 库",
empty: "暂无已保存的查询",
emptyFolder: "空文件夹",
unfiled: "未分类",
exportFile: "导出 SQL",
exportFolder: "导出文件夹",
exportLibrary: "导出 SQL 库",
importDirectory: "导入本地文件夹",
importIntoFolder: "导入到当前文件夹",
openStorageDirectory: "打开 SQL 存储目录",
chooseSyncDirectory: "设置本地同步目录",
disableSyncDirectory: "关闭本地同步目录",
syncDirectorySaved: "SQL 本地同步目录已设置",
syncDirectoryDisabled: "SQL 本地同步已关闭",
syncDirectoryFailed: "设置同步目录失败:{message}",
openDirectoryFailed: "打开目录失败:{message}",
exported: "导出完成",
exportedCount: "已导入 {count} 个 SQL 文件",
exportFailed: "导出失败:{message}",
importFailed: "导入失败:{message}",
importNone: "所选文件夹中没有 .sql 文件",
imported: "已导入 {count} 个 SQL 文件",
desktopOnly: "该功能仅桌面端可用",
noConnection: "当前没有可用于导入的连接",
},
connection: {
title: "新建连接",
name: "名称",

View File

@ -104,6 +104,32 @@ export default {
deleteFile: "刪除 SQL",
deleteFileConfirm: "確定刪除「{name}」嗎?",
},
sqlLibrary: {
title: "SQL 庫",
empty: "暫無已儲存的查詢",
emptyFolder: "空資料夾",
unfiled: "未分類",
exportFile: "匯出 SQL",
exportFolder: "匯出資料夾",
exportLibrary: "匯出 SQL 庫",
importDirectory: "匯入本機資料夾",
importIntoFolder: "匯入到目前資料夾",
openStorageDirectory: "開啟 SQL 儲存目錄",
chooseSyncDirectory: "設定本機同步目錄",
disableSyncDirectory: "關閉本機同步目錄",
syncDirectorySaved: "SQL 本機同步目錄已設定",
syncDirectoryDisabled: "SQL 本機同步已關閉",
syncDirectoryFailed: "設定同步目錄失敗:{message}",
openDirectoryFailed: "開啟目錄失敗:{message}",
exported: "匯出完成",
exportFailed: "匯出失敗:{message}",
importFailed: "匯入失敗:{message}",
importNone: "所選資料夾中沒有 .sql 檔案",
imported: "已匯入 {count} 個 SQL 檔案",
desktopOnly: "此功能僅桌面版可用",
noConnection: "目前沒有可用於匯入的連線",
},
connection: {
title: "建立連線",
name: "名稱",

View File

@ -91,6 +91,9 @@ export const saveSavedSqlFolder = forward("saveSavedSqlFolder");
export const deleteSavedSqlFolder = forward("deleteSavedSqlFolder");
export const saveSavedSqlFile = forward("saveSavedSqlFile");
export const deleteSavedSqlFile = forward("deleteSavedSqlFile");
export const savedSqlStorageDir = forward("savedSqlStorageDir");
export const openSavedSqlStorageDir = forward("openSavedSqlStorageDir");
export const syncSavedSqlDirectory = forward("syncSavedSqlDirectory");
// Schema
export const listDatabases = forward("listDatabases");

View File

@ -33,6 +33,7 @@ import type {
UpgradeAllAgentDriversResult,
AgentUpdateBlocker,
DesktopSettings,
SavedSqlSyncRequest,
DriverInstallProgress,
JavaRuntimeConfig,
UpdateInfo,
@ -374,6 +375,18 @@ export async function deleteSavedSqlFile(id: string): Promise<void> {
return del(`/api/saved-sql/${encodeURIComponent(id)}`);
}
export async function savedSqlStorageDir(): Promise<string> {
return "";
}
export async function openSavedSqlStorageDir(_dir?: string | null): Promise<void> {
throw new Error("SQL storage directory is only available in the desktop app.");
}
export async function syncSavedSqlDirectory(_request: SavedSqlSyncRequest): Promise<void> {
throw new Error("SQL directory sync is only available in the desktop app.");
}
// ---------------------------------------------------------------------------
// Schema
// ---------------------------------------------------------------------------
@ -882,7 +895,7 @@ export async function loadAiConfig(): Promise<AiConfig | null> {
}
export async function loadDesktopSettings(): Promise<DesktopSettings> {
return { show_tray_icon: true, icon_theme: "default", debug_logging_enabled: false };
return { show_tray_icon: true, icon_theme: "default", debug_logging_enabled: false, saved_sql_sync_dir: null };
}
export async function saveDesktopSettings(_settings: DesktopSettings): Promise<void> {

View File

@ -19,10 +19,6 @@ export type ActiveTabSidebarTarget =
type: "etcd-root";
connectionId: string;
}
| {
type: "saved-sql-file";
savedSqlId: string;
}
| {
type: "query-context";
connectionId: string;
@ -33,10 +29,6 @@ export type ActiveTabSidebarTarget =
export function activeTabSidebarTarget(tab: QueryTab | undefined | null): ActiveTabSidebarTarget | null {
if (!tab) return null;
if (tab.savedSqlId) {
return { type: "saved-sql-file", savedSqlId: tab.savedSqlId };
}
if (tab.mode === "data") {
const tableName = tab.tableMeta?.tableName || tab.title;
if (!tableName) return null;
@ -83,10 +75,6 @@ function schemaMatches(node: TreeNode, schema: string | undefined): boolean {
}
export function matchesTarget(node: TreeNode, target: ActiveTabSidebarTarget): boolean {
if (target.type === "saved-sql-file") {
return node.type === "saved-sql-file" && node.savedSqlId === target.savedSqlId;
}
if (target.type === "mongo-collection") {
return (
node.type === "mongo-collection" &&

View File

@ -31,12 +31,9 @@ export function sortSidebarTreeChildrenForParent(
}
if (parent.type === "connection") {
const savedSqlRoots = normalized.filter((child) => child.type === "saved-sql-root");
const userAdminNodes = normalized.filter((child) => child.type === "user-admin");
const regularChildren = normalized.filter(
(child) => child.type !== "saved-sql-root" && child.type !== "user-admin",
);
const withConnectionUtilityOrder = (children: TreeNode[]) => [...savedSqlRoots, ...children, ...userAdminNodes];
const regularChildren = normalized.filter((child) => child.type !== "user-admin");
const withConnectionUtilityOrder = (children: TreeNode[]) => [...children, ...userAdminNodes];
if (databaseType === "mongodb" || databaseType === "elasticsearch") {
return withConnectionUtilityOrder(sortByLabel(regularChildren));

View File

@ -13,7 +13,6 @@ const leafTypes: Set<TreeNodeType> = new Set([
"redis-db",
"mongo-collection",
"user-admin",
"saved-sql-file",
]);
const fullWidthLabelTypes: Set<TreeNodeType> = new Set(["table", "view", "mongo-collection"]);
@ -30,10 +29,13 @@ export function canTreeNodeExpand(type: TreeNodeType): boolean {
return !leafTypes.has(type);
}
export function canTreeNodeShowExpander({ type, childCount }: { type: TreeNodeType; childCount?: number }): boolean {
export function canTreeNodeShowExpander({
type,
childCount: _childCount,
}: {
type: TreeNodeType;
childCount?: number;
}): boolean {
if (!canTreeNodeExpand(type)) return false;
if (type === "saved-sql-root" || type === "saved-sql-folder") {
return (childCount ?? 0) > 0;
}
return true;
}

View File

@ -146,6 +146,18 @@ export interface DesktopSettings {
show_tray_icon: boolean;
icon_theme: "default" | "black";
debug_logging_enabled: boolean;
saved_sql_sync_dir?: string | null;
}
export interface SavedSqlSyncEntry {
folderName?: string;
fileName: string;
sql: string;
}
export interface SavedSqlSyncRequest {
targetDir: string;
entries: SavedSqlSyncEntry[];
}
export interface WebDavConfig {
@ -1021,6 +1033,18 @@ export async function deleteSavedSqlFile(id: string): Promise<void> {
return invoke("delete_saved_sql_file", { id });
}
export async function savedSqlStorageDir(): Promise<string> {
return invoke("saved_sql_storage_dir");
}
export async function openSavedSqlStorageDir(dir?: string | null): Promise<void> {
return invoke("open_saved_sql_storage_dir", { dir });
}
export async function syncSavedSqlDirectory(request: SavedSqlSyncRequest): Promise<void> {
return invoke("sync_saved_sql_directory", { request });
}
export async function saveSidebarLayout(layout: import("@/types/database").SidebarLayout): Promise<void> {
return invoke("save_sidebar_layout", { layout });
}

View File

@ -64,7 +64,6 @@ export function treeNodeRowDoubleClickAction(
if (activation === "double") {
if (dataNodeTypes.has(type)) return "open-data";
if (sourceNodeTypes.has(type)) return "open-source";
if (type === "saved-sql-file") return "open-saved-sql";
if (toggleLeafNodeTypes.has(type)) return "toggle";
if (canOpenObjectBrowser && objectBrowserNodeTypes.has(type) && canExpand) return "open-object-browser-and-expand";
if (canOpenObjectBrowser && objectBrowserNodeTypes.has(type)) return "open-object-browser";

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,

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,

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,
};
});

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,
};
}

View File

@ -460,6 +460,7 @@ export interface SavedSqlFolder {
id: string;
connectionId: string;
name: string;
orderIndex?: number;
createdAt: string;
updatedAt: string;
}
@ -472,6 +473,7 @@ export interface SavedSqlFile {
database: string;
schema?: string;
sql: string;
orderIndex?: number;
createdAt: string;
updatedAt: string;
}

View File

@ -6,6 +6,8 @@ pub struct SavedSqlFolder {
pub id: String,
pub connection_id: String,
pub name: String,
#[serde(default)]
pub order_index: i64,
pub created_at: String,
pub updated_at: String,
}
@ -20,6 +22,8 @@ pub struct SavedSqlFile {
pub database: String,
pub schema: Option<String>,
pub sql: String,
#[serde(default)]
pub order_index: i64,
pub created_at: String,
pub updated_at: String,
}

View File

@ -1,9 +1,38 @@
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tauri::State;
use dbx_core::connection::AppState;
use dbx_core::saved_sql::{SavedSqlFile, SavedSqlFolder, SavedSqlLibrary};
#[derive(Clone)]
pub struct SavedSqlStorageState {
pub data_dir: PathBuf,
}
const SYNC_MANIFEST_FILE: &str = ".dbx-sql-library-sync.json";
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SavedSqlSyncEntry {
pub folder_name: Option<String>,
pub file_name: String,
pub sql: String,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SavedSqlSyncRequest {
pub target_dir: String,
pub entries: Vec<SavedSqlSyncEntry>,
}
#[derive(serde::Serialize, serde::Deserialize, Default)]
#[serde(rename_all = "camelCase")]
struct SavedSqlSyncManifest {
files: Vec<String>,
}
#[tauri::command]
pub async fn load_saved_sql_library(state: State<'_, Arc<AppState>>) -> Result<SavedSqlLibrary, String> {
state.storage.load_saved_sql_library().await
@ -33,3 +62,157 @@ pub async fn save_saved_sql_file(state: State<'_, Arc<AppState>>, file: SavedSql
pub async fn delete_saved_sql_file(state: State<'_, Arc<AppState>>, id: String) -> Result<(), String> {
state.storage.delete_saved_sql_file(&id).await
}
#[tauri::command]
pub async fn saved_sql_storage_dir(state: State<'_, SavedSqlStorageState>) -> Result<String, String> {
Ok(state.data_dir.to_string_lossy().to_string())
}
#[tauri::command]
pub async fn open_saved_sql_storage_dir(
state: State<'_, SavedSqlStorageState>,
dir: Option<String>,
) -> Result<(), String> {
let target_dir = dir
.as_deref()
.map(str::trim)
.filter(|path| !path.is_empty())
.map(PathBuf::from)
.unwrap_or_else(|| state.data_dir.clone());
std::fs::create_dir_all(&target_dir).map_err(|e| e.to_string())?;
open_path(&target_dir)
}
#[tauri::command]
pub async fn sync_saved_sql_directory(request: SavedSqlSyncRequest) -> Result<(), String> {
let target_dir = PathBuf::from(request.target_dir.trim());
if target_dir.as_os_str().is_empty() {
return Err("Target directory is empty".to_string());
}
tokio::task::spawn_blocking(move || sync_saved_sql_directory_blocking(&target_dir, &request.entries))
.await
.map_err(|e| e.to_string())?
}
fn sync_saved_sql_directory_blocking(target_dir: &Path, entries: &[SavedSqlSyncEntry]) -> Result<(), String> {
std::fs::create_dir_all(target_dir).map_err(|e| e.to_string())?;
remove_previous_sync_files(target_dir)?;
let mut written_files = Vec::new();
for entry in entries {
let mut file_dir = target_dir.to_path_buf();
if let Some(folder_name) = entry.folder_name.as_deref().map(str::trim).filter(|name| !name.is_empty()) {
file_dir.push(sanitize_file_segment(folder_name));
}
std::fs::create_dir_all(&file_dir).map_err(|e| e.to_string())?;
let file_name = ensure_sql_extension(&sanitize_file_segment(&entry.file_name));
let file_path = unique_file_path(&file_dir, &file_name);
std::fs::write(&file_path, &entry.sql).map_err(|e| e.to_string())?;
if let Ok(relative) = file_path.strip_prefix(target_dir) {
written_files.push(relative.to_string_lossy().replace('\\', "/"));
}
}
let manifest = SavedSqlSyncManifest { files: written_files };
let manifest_json = serde_json::to_string_pretty(&manifest).map_err(|e| e.to_string())?;
std::fs::write(target_dir.join(SYNC_MANIFEST_FILE), manifest_json).map_err(|e| e.to_string())?;
Ok(())
}
fn remove_previous_sync_files(target_dir: &Path) -> Result<(), String> {
let manifest_path = target_dir.join(SYNC_MANIFEST_FILE);
let Ok(raw) = std::fs::read_to_string(&manifest_path) else {
return Ok(());
};
let manifest = serde_json::from_str::<SavedSqlSyncManifest>(&raw).unwrap_or_default();
for relative in manifest.files {
let file_path = target_dir.join(relative);
if file_path.is_file() {
std::fs::remove_file(&file_path).map_err(|e| e.to_string())?;
remove_empty_parent_dirs(target_dir, file_path.parent());
}
}
Ok(())
}
fn remove_empty_parent_dirs(root: &Path, parent: Option<&Path>) {
let Some(dir) = parent else {
return;
};
if dir == root {
return;
}
if std::fs::remove_dir(dir).is_ok() {
remove_empty_parent_dirs(root, dir.parent());
}
}
fn sanitize_file_segment(value: &str) -> String {
let sanitized = value
.chars()
.map(|ch| match ch {
'<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '_',
ch if ch.is_control() => '_',
ch => ch,
})
.collect::<String>()
.trim()
.trim_matches('.')
.to_string();
if sanitized.is_empty() {
"untitled".to_string()
} else {
sanitized
}
}
fn ensure_sql_extension(name: &str) -> String {
if name.to_lowercase().ends_with(".sql") {
name.to_string()
} else {
format!("{name}.sql")
}
}
fn unique_file_path(dir: &Path, file_name: &str) -> PathBuf {
let mut candidate = dir.join(file_name);
if !candidate.exists() {
return candidate;
}
let base = file_name.strip_suffix(".sql").unwrap_or(file_name);
let mut counter = 2;
loop {
candidate = dir.join(format!("{base} ({counter}).sql"));
if !candidate.exists() {
return candidate;
}
counter += 1;
}
}
fn open_path(path: &Path) -> Result<(), String> {
#[cfg(target_os = "macos")]
let mut command = {
let mut command = std::process::Command::new("open");
command.arg(path);
command
};
#[cfg(target_os = "windows")]
let mut command = {
let mut command = std::process::Command::new("explorer");
command.arg(path);
command
};
#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
let mut command = {
let mut command = std::process::Command::new("xdg-open");
command.arg(path);
command
};
command.spawn().map(|_| ()).map_err(|e| e.to_string())
}

View File

@ -276,6 +276,7 @@ pub fn run() {
))
};
app.manage(state.clone());
app.manage(commands::saved_sql::SavedSqlStorageState { data_dir: data_dir.clone() });
app.manage(commands::external_sql::ExternalSqlOpenState::default());
app.manage(commands::external_db::ExternalDbOpenState::default());
app.manage(commands::deep_link::DeepLinkOpenState::default());
@ -469,6 +470,9 @@ pub fn run() {
commands::saved_sql::delete_saved_sql_folder,
commands::saved_sql::save_saved_sql_file,
commands::saved_sql::delete_saved_sql_file,
commands::saved_sql::saved_sql_storage_dir,
commands::saved_sql::open_saved_sql_storage_dir,
commands::saved_sql::sync_saved_sql_directory,
commands::mongo_cmd::mongo_list_databases,
commands::mongo_cmd::mongo_list_collections,
commands::mongo_cmd::mongo_find_documents,