diff --git a/src/App.vue b/src/App.vue index 4caef8ea4d..e35a5c6046 100644 --- a/src/App.vue +++ b/src/App.vue @@ -16,6 +16,7 @@ import LoginPage from "@/components/auth/LoginPage.vue"; import { useConnectionStore } from "@/stores/connectionStore"; import { useQueryStore } from "@/stores/queryStore"; import { useSettingsStore } from "@/stores/settingsStore"; +import { useSavedSqlStore } from "@/stores/savedSqlStore"; import { useToast } from "@/composables/useToast"; import { useTheme } from "@/composables/useTheme"; import { useAppUpdater } from "@/composables/useAppUpdater"; @@ -35,11 +36,16 @@ import { isTauriRuntime } from "@/lib/tauriRuntime"; import { isCloseTabShortcut, isExecuteSqlShortcut } from "@/lib/keyboardShortcuts"; import { isPreviewTab } from "@/lib/tabPresentation"; import { SQL_FILE_UNSUPPORTED_TYPES } from "@/lib/databaseCapabilities"; +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; const { t } = useI18n(); const connectionStore = useConnectionStore(); const queryStore = useQueryStore(); const settingsStore = useSettingsStore(); +const savedSqlStore = useSavedSqlStore(); const { message: toastMessage, visible: toastVisible, toast } = useToast(); const { isDark, applyTheme, toggleTheme } = useTheme(); const { @@ -75,6 +81,10 @@ const selectedSql = ref(""); const cursorPos = ref(0); const formatSqlRequestId = ref(0); const activeOutputView = ref<"result" | "explain" | "chart">("result"); +const showSaveSqlDialog = ref(false); +const saveSqlName = ref(""); +const saveSqlFolderId = ref(""); +const ROOT_SAVED_SQL_FOLDER = "__root__"; const activeTab = computed(() => queryStore.tabs.find((t) => t.id === queryStore.activeTabId)); @@ -114,6 +124,10 @@ const connectionStats = computed(() => ({ types: new Set(connectionStore.connections.map((c) => c.driver_profile || c.db_type)).size, })); const recentConnections = computed(() => connectionStore.connections.slice(0, 5)); +const saveSqlFolders = computed(() => { + const tab = activeTab.value; + return tab ? savedSqlStore.listFolders(tab.connectionId) : []; +}); watch( () => queryStore.activeTabId, @@ -142,32 +156,56 @@ function formatActiveSql() { formatSqlRequestId.value++; } -async function saveSqlToFile() { +function defaultSavedSqlName(title: string) { + const trimmed = title.trim() || "Query"; + return trimmed.endsWith(".sql") ? trimmed : `${trimmed}.sql`; +} + +function openSaveSqlDialog() { const tab = activeTab.value; if (!tab || !tab.sql.trim()) return; + const existing = tab.savedSqlId ? savedSqlStore.getFile(tab.savedSqlId) : undefined; + if (existing) { + const updated = savedSqlStore.saveFile({ + id: existing.id, + connectionId: tab.connectionId, + folderId: existing.folderId, + name: existing.name, + database: tab.database, + schema: tab.schema, + sql: tab.sql, + }); + queryStore.linkSavedSql(tab.id, updated.id, updated.name); + connectionStore.refreshSavedSqlTree(tab.connectionId); + toast(t("savedSql.saved"), 2000); + return; + } + + saveSqlName.value = defaultSavedSqlName(tab.title); + saveSqlFolderId.value = ROOT_SAVED_SQL_FOLDER; + showSaveSqlDialog.value = true; +} + +function confirmSaveSqlToLibrary() { + const tab = activeTab.value; + const name = saveSqlName.value.trim(); + if (!tab || !tab.sql.trim() || !name) return; try { - if (isTauriRuntime()) { - const { save } = await import("@tauri-apps/plugin-dialog"); - const { writeTextFile } = await import("@tauri-apps/plugin-fs"); - const path = await save({ - defaultPath: `${tab.title}.sql`, - filters: [{ name: "SQL", extensions: ["sql"] }], - }); - if (path) { - await writeTextFile(path, tab.sql); - toast(t("toolbar.sqlSaved"), 2000); - } - } else { - const blob = new Blob([tab.sql], { type: "text/plain" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `${tab.title}.sql`; - a.click(); - URL.revokeObjectURL(url); - } + const saved = savedSqlStore.saveFile({ + id: tab.savedSqlId, + connectionId: tab.connectionId, + folderId: saveSqlFolderId.value === ROOT_SAVED_SQL_FOLDER ? undefined : saveSqlFolderId.value, + name: defaultSavedSqlName(name), + database: tab.database, + schema: tab.schema, + 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) { - toast(t("toolbar.sqlSaveFailed", { message: e?.message || String(e) }), 5000); + toast(t("savedSql.saveFailed", { message: e?.message || String(e) }), 5000); } } @@ -330,6 +368,19 @@ function handleKeydown(e: KeyboardEvent) { if (queryStore.activeTabId) queryStore.closeTab(queryStore.activeTabId); return; } + if ( + activeTab.value?.mode === "query" && + !showSaveSqlDialog.value && + !e.isComposing && + !e.altKey && + (e.metaKey || e.ctrlKey) && + e.key.toLowerCase() === "s" + ) { + e.preventDefault(); + e.stopPropagation(); + openSaveSqlDialog(); + return; + } if ( activeTab.value?.mode === "query" && isExecuteSqlShortcut(e) && @@ -480,7 +531,7 @@ onUnmounted(() => { @cancel="cancelActiveExecution()" @explain="tryExplain()" @format-sql="formatActiveSql" - @save-sql="saveSqlToFile" + @save-sql="openSaveSqlDialog" @open-sql="openSqlFile" @change-connection="changeActiveConnection" @change-database="changeActiveDatabase" @@ -608,6 +659,38 @@ onUnmounted(() => { + + + + + {{ t("savedSql.saveToLibrary") }} + +
+
+ + +
+
+ + +
+
+ + + + +
+
diff --git a/src/components/layout/ContentArea.vue b/src/components/layout/ContentArea.vue index 1bcee57967..321d30f3b7 100644 --- a/src/components/layout/ContentArea.vue +++ b/src/components/layout/ContentArea.vue @@ -77,6 +77,8 @@ const editorDialect = computed<"mysql" | "postgres" | "sqlserver">(() => { return "mysql"; }); +const shortcutModifier = computed(() => (navigator.platform.toLowerCase().includes("mac") ? "Cmd" : "Ctrl")); + const hasNumericData = computed(() => { const r = props.activeTab.result; if (!r || r.rows.length === 0) return false; @@ -313,9 +315,10 @@ function onHandleCloseColumnPanel() {
- {{ t("editor.pressToExecute") }} +
{{ t("editor.pressToExecute", { mod: shortcutModifier }) }}
+
{{ t("editor.pressToSaveSql", { mod: shortcutModifier }) }}
diff --git a/src/components/layout/EditorToolbar.vue b/src/components/layout/EditorToolbar.vue index 24711e8636..b0af6cc246 100644 --- a/src/components/layout/EditorToolbar.vue +++ b/src/components/layout/EditorToolbar.vue @@ -91,6 +91,11 @@ function databaseDisplayName(database: string): string { :variant="activeTab.isExecuting ? 'destructive' : 'ghost'" size="icon" class="h-6 w-6" + :class=" + activeTab.isExecuting + ? '' + : 'bg-emerald-500/10 text-emerald-700 hover:bg-emerald-500/20 hover:text-emerald-800 dark:text-emerald-300 dark:hover:text-emerald-200' + " :disabled=" activeTab.isCancelling || activeTab.isExplaining || (!activeTab.isExecuting && !executableSql.trim()) " @@ -111,6 +116,11 @@ function databaseDisplayName(database: string): string { :variant="activeTab.isExplaining ? 'destructive' : 'ghost'" size="icon" class="h-6 w-6" + :class=" + activeTab.isExplaining + ? '' + : 'text-violet-600 hover:bg-violet-500/10 hover:text-violet-700 dark:text-violet-300 dark:hover:text-violet-200' + " :disabled="activeTab.isExecuting || (!activeTab.isExplaining && !executableSql.trim())" @click="activeTab.isExplaining ? emit('cancel') : emit('explain')" > @@ -127,7 +137,7 @@ function databaseDisplayName(database: string): string { diff --git a/src/components/sidebar/TreeItem.vue b/src/components/sidebar/TreeItem.vue index fefbb5c521..bccd316578 100644 --- a/src/components/sidebar/TreeItem.vue +++ b/src/components/sidebar/TreeItem.vue @@ -37,6 +37,7 @@ import { Scissors, CopyPlus, Plus, + FileText, } from "lucide-vue-next"; import { ContextMenu, @@ -50,6 +51,7 @@ import { } from "@/components/ui/context-menu"; import { useConnectionStore } from "@/stores/connectionStore"; import { useQueryStore } from "@/stores/queryStore"; +import { useSavedSqlStore } from "@/stores/savedSqlStore"; import { useToast } from "@/composables/useToast"; import { useDatabaseOptions } from "@/composables/useDatabaseOptions"; import type { DatabaseType, QueryResult, TreeNode, TreeNodeType } from "@/types/database"; @@ -90,6 +92,7 @@ import { Input } from "@/components/ui/input"; const { t } = useI18n(); const connectionStore = useConnectionStore(); const queryStore = useQueryStore(); +const savedSqlStore = useSavedSqlStore(); const { toast } = useToast(); const { getDatabaseOptions } = useDatabaseOptions(); @@ -150,6 +153,12 @@ function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null { return { icon: Link, colorClass: "text-blue-400" }; case "group-triggers": return { icon: Zap, colorClass: "text-orange-400" }; + 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": @@ -167,8 +176,23 @@ function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null { } } -const leafTypes: Set = new Set(["column", "index", "fkey", "trigger", "redis-db", "mongo-collection"]); -const groupTypes: Set = new Set(["group-columns", "group-indexes", "group-fkeys", "group-triggers"]); +const leafTypes: Set = new Set([ + "column", + "index", + "fkey", + "trigger", + "redis-db", + "mongo-collection", + "saved-sql-file", +]); +const groupTypes: Set = new Set([ + "group-columns", + "group-indexes", + "group-fkeys", + "group-triggers", + "saved-sql-root", + "saved-sql-folder", +]); const pinnableTypes: Set = new Set([ "connection-group", "database", @@ -194,10 +218,19 @@ async function toggle() { return; } + if (node.type === "saved-sql-root" || node.type === "saved-sql-folder") { + node.isExpanded = !node.isExpanded; + return; + } + + const showSavedSqlWhileLoading = + node.type === "connection" && !node.isExpanded && node.children?.some((child) => child.type === "saved-sql-root"); + if (node.isExpanded) { node.isExpanded = false; return; } + if (showSavedSqlWhileLoading) node.isExpanded = true; try { if (node.type === "connection" && node.connectionId) { @@ -250,11 +283,22 @@ function onClick() { const action = treeNodeRowAction(node.type, canExpand); if (action === "open-data") { openData(); + } else if (node.type === "saved-sql-file") { + openSavedSqlFile(); } else if (action === "toggle") { toggle(); } } +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") || !node.connectionId || !node.database) return; @@ -1019,6 +1063,9 @@ const hasTypeMenu = computed(() => { t === "table" || t === "view" || t === "column" || + t === "saved-sql-root" || + t === "saved-sql-folder" || + t === "saved-sql-file" || isGroupLabel(props.node) ); }); @@ -1149,6 +1196,70 @@ 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; +} + +function confirmSavedSqlName() { + const name = savedSqlNameInput.value.trim(); + if (!name || !props.node.connectionId || !savedSqlNameMode.value) return; + + if (savedSqlNameMode.value === "folder-create") { + savedSqlStore.createFolder(props.node.connectionId, name); + } else if (savedSqlNameMode.value === "folder-rename" && props.node.savedSqlFolderId) { + savedSqlStore.renameFolder(props.node.savedSqlFolderId, name); + } else if (savedSqlNameMode.value === "file-rename" && props.node.savedSqlId) { + 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; +} + +function confirmDeleteSavedSqlFile() { + if (!props.node.savedSqlId) return; + savedSqlStore.deleteFile(props.node.savedSqlId); + connectionStore.refreshSavedSqlTree(props.node.connectionId); + showDeleteSavedSqlFileConfirm.value = false; +} + +function deleteSavedSqlFolder() { + showDeleteSavedSqlFolderConfirm.value = true; +} + +function confirmDeleteSavedSqlFolder() { + if (!props.node.savedSqlFolderId) return; + savedSqlStore.deleteFolder(props.node.savedSqlFolderId); + connectionStore.refreshSavedSqlTree(props.node.connectionId); + showDeleteSavedSqlFolderConfirm.value = false; +} + // --- Drag and Drop --- import { useDragSort } from "@/composables/useDragSort"; @@ -1498,11 +1609,36 @@ const isDragging = computed(() => dragState.active && dragState.draggedId === pr + +