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

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

No files matched your search

@@ -2,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')"