feat: add SQL library panel with folder/file management, rename, and sync support
This commit is contained in:
parent
d7a8f5d285
commit
9ffa4c41d9
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: "名称",
|
||||
|
|
|
|||
|
|
@ -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: "名稱",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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" &&
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue