diff --git a/.gitignore b/.gitignore index b1da8cb58d..49e1c96893 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ node_modules .superpowers/ .trellis/ .worktrees/ +.trae/ openspec/ .mcp.json AGENTS.md diff --git a/Cargo.lock b/Cargo.lock index 3f976828ad..216b9d7c0f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -132,6 +132,12 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "ambient-authority" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" + [[package]] name = "android_log-sys" version = "0.3.2" @@ -1266,6 +1272,48 @@ dependencies = [ "serde_core", ] +[[package]] +name = "cap-fs-ext" +version = "4.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78e5a3368ae89b7cb68186411452b4b9fac8b41be9c19bf3f47c2d2c8e36e6b" +dependencies = [ + "cap-primitives", + "cap-std", + "io-lifetimes 3.0.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "cap-primitives" +version = "4.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdadbd7c002d3a484b35243669abdae85a0ebaded5a61117169dc3400f9a7ff0" +dependencies = [ + "ambient-authority", + "fs-set-times", + "io-extras", + "io-lifetimes 3.0.1", + "ipnet", + "maybe-owned", + "rustix", + "rustix-linux-procfs", + "windows-sys 0.61.2", + "winx", +] + +[[package]] +name = "cap-std" +version = "4.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7281235d6e96d3544ca18bba9049be92f4190f8d923e3caef1b5f66cfa752608" +dependencies = [ + "cap-primitives", + "io-extras", + "io-lifetimes 3.0.1", + "rustix", +] + [[package]] name = "cargo-platform" version = "0.1.9" @@ -1989,6 +2037,8 @@ dependencies = [ "axum", "base64 0.22.1", "calamine", + "cap-fs-ext", + "cap-std", "chrono", "csv", "dbx-core", @@ -2982,6 +3032,17 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "fs-set-times" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" +dependencies = [ + "io-lifetimes 2.0.4", + "rustix", + "windows-sys 0.59.0", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -4188,6 +4249,28 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "io-extras" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20fd6de4ccfcc187e38bc21cfa543cb5a302cb86a8b114eb7f0bf0dc9f8ac00f" +dependencies = [ + "io-lifetimes 3.0.1", + "windows-sys 0.60.2", +] + +[[package]] +name = "io-lifetimes" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983" + +[[package]] +name = "io-lifetimes" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0fb0570afe1fed943c5c3d4102d5358592d8625fda6a0007fdbe65a92fba96" + [[package]] name = "ipconfig" version = "0.3.4" @@ -4739,6 +4822,12 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "maybe-owned" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" + [[package]] name = "md-5" version = "0.11.0" @@ -7182,6 +7271,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustix-linux-procfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" +dependencies = [ + "once_cell", + "rustix", +] + [[package]] name = "rustls" version = "0.21.12" @@ -10816,6 +10915,16 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "winx" +version = "0.36.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" +dependencies = [ + "bitflags 2.13.1", + "windows-sys 0.59.0", +] + [[package]] name = "wio" version = "0.2.2" diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 3a53e558dd..72ee2b4b1b 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"; @@ -60,7 +61,8 @@ import { uuid } from "@/lib/common/utils"; import { isMacOS, isWindows } from "@/lib/backend/platform"; import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; import { openQueryResultArchiveFile } from "@/lib/query/queryResultArchiveFile"; -import { rememberExternalSqlFileTarget, resolveExternalSqlFileTarget, unassociatedExternalSqlFileTarget } from "@/lib/sql/externalSqlFileTarget"; +import { rememberExternalSqlFileTarget } from "@/lib/sql/externalSqlFileTarget"; +import { resolveProjectFileTarget, type ProjectLike } from "@/lib/sql/projectFileTarget"; import { externalSqlFileOpenErrorMessage, readBrowserSqlFile, sqlFileTitleFromPath } from "@/lib/sql/sqlFileOpen"; import type { ConnectionConfig, DatabaseType, ObjectSourceKind, QueryTab, TreeNode } from "@/types/database"; import { parseConnectionDeepLink, type ConnectionDeepLinkDraft } from "@/lib/connection/connectionDeepLink"; @@ -156,6 +158,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({ @@ -509,6 +512,7 @@ const { setupTauriListeners, cleanupTauriListeners } = useTauriEvents({ openSqlFilePath, openDbFilePath, openConnectionDeepLink, + openSqlProjectPaths, }); const { showCloseActionPrompt, chooseQuit, chooseMinimize, cancelCloseActionPrompt, performCloseAction, setupCloseActionPromptListener, cleanupCloseActionPromptListener } = useCloseActionPrompt({ requestClose: requestAppClose }); useVisibilityChange(); @@ -1123,9 +1127,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 }); @@ -1439,8 +1453,19 @@ async function saveActiveSqlAsLocalFile() { if (tab) await saveExternalSqlTabAs(tab); } +/** 统一的项目上下文解析选项(连接存在性 + 项目列表)。 */ +function projectFileTargetOptions(projects: ProjectLike[]) { + return { + connectionExists: (connectionId: string) => !!connectionStore.getConfig(connectionId), + getConnection: (connectionId: string) => connectionStore.getConfig(connectionId), + projects, + activeConnectionId: connectionStore.activeConnectionId, + firstConnectionId: connectionStore.connections[0]?.id, + }; +} + function applyExternalSqlFileTarget(tab: QueryTab, path: string) { - const target = resolveExternalSqlFileTarget(path, (savedConnectionId) => !!connectionStore.getConfig(savedConnectionId), unassociatedExternalSqlFileTarget()); + const target = resolveProjectFileTarget(path, projectFileTargetOptions(projectStore.projects)); if (target.connectionId !== tab.connectionId) { queryStore.updateConnection(tab.id, target.connectionId, target.database); } @@ -1448,6 +1473,9 @@ function applyExternalSqlFileTarget(tab: QueryTab, path: string) { if (target.catalog !== undefined || tab.catalog !== undefined) queryStore.updateCatalog(tab.id, target.catalog, target.database); else queryStore.updateDatabase(tab.id, target.database); } + if (target.schema !== undefined && tab.schema !== target.schema) { + queryStore.updateSchema(tab.id, target.schema); + } } async function openSqlFile() { @@ -1517,8 +1545,21 @@ async function openSqlFilePath(path: string) { try { 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 projects: ProjectLike[] = []; + try { + await projectStore.ensureLoaded(); + projects = projectStore.projects; + } catch { + projects = []; + } + const target = resolveProjectFileTarget(path, projectFileTargetOptions(projects)); + queryStore.openExternalSqlFile(target.connectionId, target.database, path, snapshot.content, snapshot.version, { + catalog: target.catalog, + schema: target.schema, + projectId: target.projectId, + fileEncoding: snapshot.encoding, + fileLineEnding: snapshot.lineEnding, + }); } catch (e: any) { toast(t("toolbar.sqlOpenFailed", { message: externalSqlFileOpenErrorMessage(e, (key, params) => t(key, params)) }), 5000); } @@ -1536,6 +1577,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(); @@ -2112,15 +2179,27 @@ function onAiOpenExplainPlan(sql: string) { } async function handleQuickOpenSelect(item: any) { - const connectionStore = useConnectionStore(); const queryStore = useQueryStore(); // Handle SQL file types first — they don't require a database connection if (item.type === "sql_file" && item.filePath) { 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 projects: ProjectLike[] = []; + try { + await projectStore.ensureLoaded(); + projects = projectStore.projects; + } catch { + projects = []; + } + const target = resolveProjectFileTarget(item.filePath, projectFileTargetOptions(projects)); + queryStore.openExternalSqlFile(target.connectionId, target.database, item.filePath, snapshot.content, snapshot.version, { + catalog: target.catalog, + schema: target.schema, + projectId: target.projectId, + fileEncoding: snapshot.encoding, + fileLineEnding: snapshot.lineEnding, + }); } catch (e: any) { toast( externalSqlFileOpenErrorMessage(e, (key, params) => t(key, params)), @@ -2643,6 +2722,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 }); @@ -2681,6 +2764,7 @@ onMounted(async () => { window.addEventListener("blur", handleTabSwitcherWindowBlur); document.addEventListener("visibilitychange", handleTabSwitcherVisibilityChange); 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); @@ -2737,6 +2821,7 @@ onMounted(async () => { void openPendingSqlFiles(); void openPendingDbFiles(); void openPendingConnectionLinks(); + void openPendingSqlProjects(); console.log(`[STARTUP] onMounted sync done: ${(performance.now() - mountStart).toFixed(0)}ms`); }); @@ -2754,6 +2839,7 @@ onUnmounted(() => { document.removeEventListener("visibilitychange", handleTabSwitcherVisibilityChange); tabSwitcherKeyboard.reset(); 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 0ce01d03e6..7a10f01ff4 100644 --- a/apps/desktop/src/components/layout/AppDialogs.vue +++ b/apps/desktop/src/components/layout/AppDialogs.vue @@ -221,7 +221,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..d7bbcbecb9 --- /dev/null +++ b/apps/desktop/src/components/layout/SqlFileHistoryDialog.vue @@ -0,0 +1,183 @@ + + +