From aaf95591ae75be9f6c45205044de01cc42f1e4db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B0=E4=B8=80=E5=B3=B0?= <1025961800@qq.com> Date: Thu, 13 Aug 2026 17:33:46 +0800 Subject: [PATCH 1/9] feat(sql-project): add SQL project management wave 2 (merged with origin/main) --- .gitignore | 1 + Cargo.lock | 69 ++ apps/desktop/src/App.vue | 74 +- .../src/components/layout/AppDialogs.vue | 8 +- .../layout/ProjectSettingsDialog.vue | 96 ++ .../layout/SqlFileHistoryDialog.vue | 139 +++ .../src/components/layout/SqlFilePanel.vue | 954 +++++++++++++++--- .../sql-file/SqlFileExecutionDialog.vue | 5 +- .../__tests__/useQuickOpen.spec.ts | 6 + .../src/composables/useDialogSources.ts | 8 +- apps/desktop/src/composables/useFileDrop.ts | 43 +- apps/desktop/src/composables/useQuickOpen.ts | 19 +- .../desktop/src/composables/useTauriEvents.ts | 17 +- apps/desktop/src/i18n/locales/en.ts | 41 +- apps/desktop/src/i18n/locales/es.ts | 41 +- apps/desktop/src/i18n/locales/it.ts | 41 +- apps/desktop/src/i18n/locales/ja.ts | 41 +- apps/desktop/src/i18n/locales/ko.ts | 41 +- apps/desktop/src/i18n/locales/pt-BR.ts | 41 +- apps/desktop/src/i18n/locales/zh-CN.ts | 41 +- apps/desktop/src/i18n/locales/zh-TW.ts | 41 +- .../src/lib/app/openTabsPersistence.ts | 6 + .../__tests__/externalSqlFileApi.spec.ts | 4 + apps/desktop/src/lib/backend/api.ts | 14 + apps/desktop/src/lib/backend/http.ts | 60 +- apps/desktop/src/lib/backend/tauri.ts | 113 ++- apps/desktop/src/stores/connectionStore.ts | 2 +- apps/desktop/src/stores/projectStore.ts | 252 +++++ apps/desktop/src/stores/queryStore.ts | 75 +- apps/desktop/src/types/database.ts | 6 + crates/dbx-core/src/lib.rs | 1 + crates/dbx-core/src/sql.rs | 109 +- crates/dbx-core/src/sql_project.rs | 37 + crates/dbx-core/src/storage.rs | 178 ++++ docs/sql-project-wave2-design.md | 166 +++ src-tauri/Cargo.toml | 1 + src-tauri/capabilities/default.json | 2 + src-tauri/src/commands/external_sql.rs | 168 ++- src-tauri/src/commands/list_sql_files.rs | 136 ++- src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/sql_project.rs | 440 ++++++++ src-tauri/src/lib.rs | 40 + 42 files changed, 3378 insertions(+), 200 deletions(-) create mode 100644 apps/desktop/src/components/layout/ProjectSettingsDialog.vue create mode 100644 apps/desktop/src/components/layout/SqlFileHistoryDialog.vue create mode 100644 apps/desktop/src/stores/projectStore.ts create mode 100644 crates/dbx-core/src/sql_project.rs create mode 100644 docs/sql-project-wave2-design.md create mode 100644 src-tauri/src/commands/sql_project.rs diff --git a/.gitignore b/.gitignore index dfd2dcce45..70789cf78e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ node_modules .superpowers/ .trellis/ .worktrees/ +.trae/ openspec/ .mcp.json AGENTS.md diff --git a/Cargo.lock b/Cargo.lock index 194da1dce7..dbada611b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1697,6 +1697,7 @@ dependencies = [ "tokio", "tokio-postgres", "tokio-util", + "trash", "uuid", "windows-sys 0.61.2", "zip", @@ -9000,6 +9001,24 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "trash" +version = "5.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7602e0c7d66ec2d92a8c917219fbc7894039efa2063b9064260110828a356f46" +dependencies = [ + "chrono", + "libc", + "log", + "objc2", + "objc2-foundation", + "once_cell", + "percent-encoding", + "scopeguard", + "urlencoding", + "windows 0.56.0", +] + [[package]] name = "tray-icon" version = "0.24.2" @@ -9274,6 +9293,12 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "urlpattern" version = "0.3.0" @@ -9792,6 +9817,16 @@ dependencies = [ "windows-version", ] +[[package]] +name = "windows" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1de69df01bdf1ead2f4ac895dc77c9351aefff65b2f3db429a343f9cbf05e132" +dependencies = [ + "windows-core 0.56.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.57.0" @@ -9845,6 +9880,18 @@ dependencies = [ "windows-core 0.62.2", ] +[[package]] +name = "windows-core" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4698e52ed2d08f8658ab0c39512a7c00ee5fe2688c65f8c0a4f06750d729f2a6" +dependencies = [ + "windows-implement 0.56.0", + "windows-interface 0.56.0", + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.57.0" @@ -9905,6 +9952,17 @@ dependencies = [ "windows-threading 0.2.1", ] +[[package]] +name = "windows-implement" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6fc35f58ecd95a9b71c4f2329b911016e6bec66b3f2e6a4aad86bd2e99e2f9b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-implement" version = "0.57.0" @@ -9927,6 +9985,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "windows-interface" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08990546bf4edef8f431fa6326e032865f27138718c587dc21bc0265bbcb57cc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-interface" version = "0.57.0" diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index a4b58c11cc..f9f3974c47 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -18,6 +18,7 @@ import { useProductionSafetyStore } from "@/stores/productionSafetyStore"; import { enforceRightSidebarPanelExclusivity, RIGHT_SIDEBAR_PANEL_IDS, transitionRightSidebarPanels, useSettingsStore, type RightSidebarPanelId, type RightSidebarPanelState } from "@/stores/settingsStore"; import { useSavedSqlStore } from "@/stores/savedSqlStore"; import { usePromptTemplateStore } from "@/stores/promptTemplateStore"; +import { useProjectStore } from "@/stores/projectStore"; import { useToast } from "@/composables/useToast"; import { useTheme } from "@/composables/useTheme"; import { useAppUpdater } from "@/composables/useAppUpdater"; @@ -145,6 +146,7 @@ const settingsStore = useSettingsStore(); const savedSqlStore = useSavedSqlStore(); const promptTemplateStore = usePromptTemplateStore(); const recentConnectionIds = ref(parseRecentConnectionIds(safeLocalStorageGet(RECENT_CONNECTION_IDS_STORAGE_KEY))); +const projectStore = useProjectStore(); connectionStore.setBeforeConnectHandler(async (config) => { await ensureJdbcxRuntimeDrivers(config, api); const jdbcProductRuntimeBefore = JSON.stringify({ @@ -493,6 +495,7 @@ const { setupTauriListeners, cleanupTauriListeners } = useTauriEvents({ openSqlFilePath, openDbFilePath, openConnectionDeepLink, + openSqlProjectPaths, }); const { showCloseActionPrompt, chooseQuit, chooseMinimize, cancelCloseActionPrompt, performCloseAction, setupCloseActionPromptListener, cleanupCloseActionPromptListener } = useCloseActionPrompt({ requestClose: requestAppClose }); useVisibilityChange(); @@ -1024,9 +1027,19 @@ function handleCloseActionPromptOpenChange(open: boolean) { async function writeExternalSqlTab(tab: QueryTab, options: { closeAfterSave?: boolean; expectedContentHash?: string; expectedMissing?: boolean } = {}): Promise<"saved" | "retry" | "failed"> { if (!tab.externalSqlPath || !isTauriRuntime()) return "failed"; try { + // Local History 保底:写回前把磁盘当前内容记入项目快照(仅项目内文件)。 + if (tab.projectId) { + try { + await api.snapshotSqlFileBeforeSave(tab.projectId, tab.externalSqlPath); + } catch { + // 快照失败不阻断保存(快照仅为保底,非关键路径) + } + } const result = await api.writeExternalSqlFile(tab.externalSqlPath, tab.sql, { expectedContentHash: options.expectedContentHash, expectedMissing: options.expectedMissing, + encoding: tab.fileEncoding, + lineEnding: tab.fileLineEnding, }); if (result.kind !== "written") return "retry"; rememberExternalSqlFileTarget(tab.externalSqlPath, { connectionId: tab.connectionId, database: tab.database, catalog: tab.catalog }); @@ -1419,7 +1432,19 @@ async function openSqlFilePath(path: string) { await desktopOpenTabsRestorationBarrier?.settled; const snapshot = await api.readExternalSqlFileSnapshot(path); const target = resolveExternalSqlFileTarget(path, (savedConnectionId) => !!connectionStore.getConfig(savedConnectionId), unassociatedExternalSqlFileTarget()); - queryStore.openExternalSqlFile(target.connectionId, target.database, path, snapshot.content, snapshot.version, target.catalog); + let projectId: string | undefined; + try { + await projectStore.ensureLoaded(); + projectId = projectStore.projectForFilePath(path)?.id ?? undefined; + } catch { + projectId = undefined; + } + queryStore.openExternalSqlFile(target.connectionId, target.database, path, snapshot.content, snapshot.version, { + catalog: target.catalog, + projectId, + fileEncoding: snapshot.encoding, + fileLineEnding: snapshot.lineEnding, + }); } catch (e: any) { toast(t("toolbar.sqlOpenFailed", { message: externalSqlFileOpenErrorMessage(e, (key, params) => t(key, params)) }), 5000); } @@ -1437,6 +1462,32 @@ async function openPendingSqlFiles() { } } +async function openSqlProjectPaths(paths: string[]) { + if (!isTauriRuntime() || paths.length === 0) return; + try { + const opened = await projectStore.openProjects(paths); + if (opened.length === 0) return; + openRightSidebarPanel("sqlFile"); + if (opened.length === 1) { + toast(t("toolbar.sqlProjectOpened", { name: opened[0].name })); + } else { + toast(t("toolbar.sqlProjectsOpened", { name: opened[0].name, count: opened.length - 1 })); + } + } catch (e: any) { + toast(t("toolbar.sqlOpenFailed", { message: e?.message || String(e) }), 5000); + } +} + +async function openPendingSqlProjects() { + if (!isTauriRuntime()) return; + try { + const paths = await api.pendingOpenSqlProjects(); + await openSqlProjectPaths(paths); + } catch { + /* ignore startup project-open probing errors */ + } +} + async function openDbFilePath(path: string) { if (!isTauriRuntime()) return; await connectionStore.initFromDisk(); @@ -2021,7 +2072,19 @@ async function handleQuickOpenSelect(item: any) { try { const snapshot = await api.readExternalSqlFileSnapshot(item.filePath); const target = resolveExternalSqlFileTarget(item.filePath, (savedConnectionId) => !!connectionStore.getConfig(savedConnectionId), unassociatedExternalSqlFileTarget()); - queryStore.openExternalSqlFile(target.connectionId, target.database, item.filePath, snapshot.content, snapshot.version, target.catalog); + let projectId: string | undefined; + try { + await projectStore.ensureLoaded(); + projectId = projectStore.projectForFilePath(item.filePath)?.id ?? undefined; + } catch { + projectId = undefined; + } + queryStore.openExternalSqlFile(target.connectionId, target.database, item.filePath, snapshot.content, snapshot.version, { + catalog: target.catalog, + projectId, + fileEncoding: snapshot.encoding, + fileLineEnding: snapshot.lineEnding, + }); } catch (e: any) { toast( externalSqlFileOpenErrorMessage(e, (key, params) => t(key, params)), @@ -2455,6 +2518,10 @@ function openDriverStoreFromEvent(event: Event) { openDriverStorePage(((event as CustomEvent).detail as DriverStoreFocus | undefined) ?? null); } +function showSqlFilePanelFromEvent() { + openRightSidebarPanel("sqlFile"); +} + function runUpdateNotificationChecks() { if (!updateNotificationsEnabled.value) return; checkUpdates({ silent: true }); @@ -2489,6 +2556,7 @@ onMounted(async () => { window.addEventListener("keydown", handleNativeSelectAll, true); window.addEventListener("keydown", handleKeydown); window.addEventListener("dbx-open-driver-store", openDriverStoreFromEvent); + window.addEventListener("dbx-show-sql-file-panel", showSqlFilePanelFromEvent); window.addEventListener("dbx-mcp-status-changed", handleMcpStatusChanged); if (isDesktop) { document.addEventListener("contextmenu", handleContextMenu); @@ -2545,6 +2613,7 @@ onMounted(async () => { void openPendingSqlFiles(); void openPendingDbFiles(); void openPendingConnectionLinks(); + void openPendingSqlProjects(); console.log(`[STARTUP] onMounted sync done: ${(performance.now() - mountStart).toFixed(0)}ms`); }); @@ -2557,6 +2626,7 @@ onUnmounted(() => { window.removeEventListener("keydown", handleNativeSelectAll, true); window.removeEventListener("keydown", handleKeydown); window.removeEventListener("dbx-open-driver-store", openDriverStoreFromEvent); + window.removeEventListener("dbx-show-sql-file-panel", showSqlFilePanelFromEvent); window.removeEventListener("dbx-mcp-status-changed", handleMcpStatusChanged); document.removeEventListener("contextmenu", handleContextMenu); window.clearTimeout(sqlLibraryFlyAnimationTimer); diff --git a/apps/desktop/src/components/layout/AppDialogs.vue b/apps/desktop/src/components/layout/AppDialogs.vue index 6fa9fe68a8..938db0bfc1 100644 --- a/apps/desktop/src/components/layout/AppDialogs.vue +++ b/apps/desktop/src/components/layout/AppDialogs.vue @@ -220,7 +220,13 @@ watch( :prefill-schema="dialogs.dataComparePrefillSchema.value" :prefill-table="dialogs.dataComparePrefillTable.value" /> - + +import { computed, ref, watch } from "vue"; +import { useI18n } from "vue-i18n"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { useConnectionStore } from "@/stores/connectionStore"; +import type { SqlProject } from "@/lib/backend/tauri"; + +const NONE_CONNECTION_VALUE = "__none__"; + +const props = defineProps<{ + project: SqlProject | null; +}>(); + +const emit = defineEmits<{ + save: [project: SqlProject]; +}>(); + +const open = defineModel("open", { default: false }); + +const { t } = useI18n(); +const connectionStore = useConnectionStore(); + +const name = ref(""); +const connectionId = ref(NONE_CONNECTION_VALUE); +const defaultSchema = ref(""); + +const sqlConnections = computed(() => connectionStore.connections.filter((connection) => !["redis", "mongodb", "elasticsearch", "easysearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos"].includes(connection.db_type))); + +watch( + open, + (value) => { + if (!value || !props.project) return; + name.value = props.project.name; + connectionId.value = props.project.connectionId || NONE_CONNECTION_VALUE; + defaultSchema.value = props.project.defaultSchema || ""; + }, + { immediate: true }, +); + +function connectionLabel(id: string): string { + return connectionStore.connections.find((connection) => connection.id === id)?.name || id; +} + +function handleSave() { + if (!props.project) return; + emit("save", { + ...props.project, + name: name.value.trim() || props.project.name, + connectionId: connectionId.value === NONE_CONNECTION_VALUE ? null : connectionId.value, + defaultSchema: defaultSchema.value.trim() ? defaultSchema.value.trim() : null, + }); + open.value = false; +} + + + diff --git a/apps/desktop/src/components/layout/SqlFileHistoryDialog.vue b/apps/desktop/src/components/layout/SqlFileHistoryDialog.vue new file mode 100644 index 0000000000..8dc258ffcc --- /dev/null +++ b/apps/desktop/src/components/layout/SqlFileHistoryDialog.vue @@ -0,0 +1,139 @@ + + +