diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index dd2a297d5..0ff914f81 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -31,6 +31,7 @@ import { useDataGridActions } from "@/composables/useDataGridActions"; import { useTauriEvents } from "@/composables/useTauriEvents"; import { useCloseActionPrompt, type AppCloseAction, type AppCloseRequestOptions } from "@/composables/useCloseActionPrompt"; import { useVisibilityChange } from "@/composables/useVisibilityChange"; +import { useExternalSqlFileChanges } from "@/composables/useExternalSqlFileChanges"; import { useWebDavAutoUpload } from "@/composables/useWebDavAutoUpload"; import { useScheduledDatabaseBackups } from "@/composables/useScheduledDatabaseBackups"; import { shouldDrawDesktopWindowFrame } from "@/composables/useWindowControls"; @@ -109,6 +110,7 @@ import { Input } from "@/components/ui/input"; import { SearchableSelect } from "@/components/ui/searchable-select"; import type { HistoryEntry } from "@/lib/backend/tauri"; import type { AiAction } from "@/lib/ai/ai"; +import ExternalSqlFileChangeDialog from "@/components/editor/ExternalSqlFileChangeDialog.vue"; const AiAssistant = defineAsyncComponent(() => import("@/components/editor/AiAssistant.vue")); const QueryHistory = defineAsyncComponent(() => import("@/components/editor/QueryHistory.vue")); @@ -245,6 +247,19 @@ const pendingCloseActionChoice = ref(false); const activeTab = computed(() => queryStore.tabs.find((t) => t.id === queryStore.activeTabId)); +const externalSqlFileChanges = useExternalSqlFileChanges({ + activeTab, + recreateFile: async (tab) => { + const result = await writeExternalSqlTab(tab, { expectedMissing: true }); + if (result === "retry") toast(t("externalSqlFile.changedAgain"), 5000); + return result === "saved"; + }, + saveAsFile: (tab) => saveExternalSqlTabAs(tab), + closeTab: (tab) => queryStore.closeTab(tab.id), + reportError: (message) => toast(message, 5000), +}); +const externalSqlFilePrompt = externalSqlFileChanges.pendingPrompt; + const activeConnection = computed(() => { const tab = activeTab.value; return tab ? connectionStore.getConfig(tab.connectionId) : undefined; @@ -833,21 +848,46 @@ function handleCloseActionPromptOpenChange(open: boolean) { } } -async function saveExternalSqlPath(tab: QueryTab, options: { closeAfterSave?: boolean } = {}): Promise { - if (!tab.externalSqlPath || !isTauriRuntime()) return false; +async function writeExternalSqlTab(tab: QueryTab, options: { closeAfterSave?: boolean; expectedContentHash?: string; expectedMissing?: boolean } = {}): Promise<"saved" | "retry" | "failed"> { + if (!tab.externalSqlPath || !isTauriRuntime()) return "failed"; try { - await api.writeExternalSqlFile(tab.externalSqlPath, tab.sql); + const result = await api.writeExternalSqlFile(tab.externalSqlPath, tab.sql, { + expectedContentHash: options.expectedContentHash, + expectedMissing: options.expectedMissing, + }); + if (result.kind !== "written") return "retry"; rememberExternalSqlFileTarget(tab.externalSqlPath, { connectionId: tab.connectionId, database: tab.database }); - queryStore.markTabClean(tab); + queryStore.markExternalSqlFileSaved(tab.id, result.version); toast(t("savedSql.saved"), 2000); if (options.closeAfterSave) queryStore.closeTab(tab.id, { force: true }); - return true; + return "saved"; } catch (e: any) { toast(t("toolbar.sqlSaveFailed", { message: e?.message || String(e) }), 5000); - return true; + return "failed"; } } +async function saveExternalSqlPath(tab: QueryTab, options: { closeAfterSave?: boolean } = {}): Promise { + if (!tab.externalSqlPath || !isTauriRuntime()) return false; + for (let attempt = 0; attempt < 2; attempt += 1) { + const preparation = await externalSqlFileChanges.prepareSave(tab); + if (!preparation.proceed) { + if (options.closeAfterSave && queryStore.tabs.some((candidate) => candidate.id === tab.id) && !queryStore.isTabDirty(tab)) { + queryStore.closeTab(tab.id, { force: true }); + } + return true; + } + const result = await writeExternalSqlTab(tab, { + closeAfterSave: options.closeAfterSave, + expectedContentHash: preparation.expectedContentHash, + expectedMissing: preparation.expectedMissing, + }); + if (result !== "retry") return true; + } + toast(t("externalSqlFile.checkFailed", { message: t("externalSqlFile.changedAgain") }), 5000); + return true; +} + function savedSqlTargetForSave(tab: QueryTab) { return savedSqlDefaultTargetForWrite({ connectionId: tab.connectionId, @@ -1090,23 +1130,29 @@ async function confirmSaveSqlToLibrary() { } } -async function saveActiveSqlAsLocalFile() { - const tab = activeTab.value; - if (!tab || !canSaveSqlTab(tab) || !isTauriRuntime()) return; +async function saveExternalSqlTabAs(tab: QueryTab): Promise { + if (!canSaveSqlTab(tab) || !isTauriRuntime()) return false; try { - const path = await api.saveExternalSqlFile(defaultSavedSqlName(tab.title), tab.sql); - if (!path) return; - queryStore.linkExternalSqlPath(tab.id, path, sqlFileTitleFromPath(path)); - rememberExternalSqlFileTarget(path, { connectionId: tab.connectionId, database: tab.database }); + const saved = await api.saveExternalSqlFile(defaultSavedSqlName(tab.title), tab.sql); + if (!saved) return false; + queryStore.linkExternalSqlPath(tab.id, saved.path, sqlFileTitleFromPath(saved.path), saved.version); + rememberExternalSqlFileTarget(saved.path, { connectionId: tab.connectionId, database: tab.database }); invalidateSaveSqlFolderSelection(); showSaveSqlDialog.value = false; closePendingSavedTab(); toast(t("savedSql.saved"), 2000); + return true; } catch (e: any) { toast(t("toolbar.sqlSaveFailed", { message: e?.message || String(e) }), 5000); + return false; } } +async function saveActiveSqlAsLocalFile() { + const tab = activeTab.value; + if (tab) await saveExternalSqlTabAs(tab); +} + function applyExternalSqlFileTarget(tab: QueryTab, path: string) { const target = resolveExternalSqlFileTarget(path, (savedConnectionId) => !!connectionStore.getConfig(savedConnectionId), { connectionId: tab.connectionId, @@ -1131,9 +1177,9 @@ async function openSqlFile() { }); if (path) { const sqlPath = path as string; - const content = await api.readExternalSqlFile(sqlPath); - queryStore.updateSql(tab.id, content); - queryStore.linkExternalSqlPath(tab.id, sqlPath, sqlFileTitleFromPath(sqlPath)); + const snapshot = await api.readExternalSqlFileSnapshot(sqlPath); + queryStore.updateSql(tab.id, snapshot.content); + queryStore.linkExternalSqlPath(tab.id, sqlPath, sqlFileTitleFromPath(sqlPath), snapshot.version); applyExternalSqlFileTarget(tab, sqlPath); } } else { @@ -1185,12 +1231,12 @@ async function openSqlFilePath(path: string) { if (!isTauriRuntime()) return; try { await desktopOpenTabsRestorationBarrier?.settled; - const content = await api.readExternalSqlFile(path); + const snapshot = await api.readExternalSqlFileSnapshot(path); const connectionId = connectionStore.activeConnectionId || activeTab.value?.connectionId || connectionStore.connections[0]?.id || ""; const connection = connectionId ? connectionStore.getConfig(connectionId) : undefined; const database = activeTab.value?.database || (connection ? resolveDefaultDatabase(connection, []) : ""); const target = resolveExternalSqlFileTarget(path, (savedConnectionId) => !!connectionStore.getConfig(savedConnectionId), { connectionId, database }); - queryStore.openExternalSqlFile(target.connectionId, target.database, path, content); + queryStore.openExternalSqlFile(target.connectionId, target.database, path, snapshot.content, snapshot.version); } catch (e: any) { toast(t("toolbar.sqlOpenFailed", { message: externalSqlFileOpenErrorMessage(e, (key, params) => t(key, params)) }), 5000); } @@ -1779,11 +1825,11 @@ async function handleQuickOpenSelect(item: any) { // Handle SQL file types first — they don't require a database connection if (item.type === "sql_file" && item.filePath) { try { - const content = await api.readExternalSqlFile(item.filePath); + const snapshot = await api.readExternalSqlFileSnapshot(item.filePath); const connectionId = connectionStore.activeConnectionId || connectionStore.connections[0]?.id || ""; const connection = connectionId ? connectionStore.getConfig(connectionId) : undefined; const database = connection ? resolveDefaultDatabase(connection, []) : ""; - queryStore.openExternalSqlFile(connectionId, database, item.filePath, content); + queryStore.openExternalSqlFile(connectionId, database, item.filePath, snapshot.content, snapshot.version); } catch (e: any) { toast( externalSqlFileOpenErrorMessage(e, (key, params) => t(key, params)), @@ -2639,6 +2685,7 @@ onUnmounted(() => { @install-downloaded="installDownloadedUpdate" @restart="restartApp" /> + diff --git a/apps/desktop/src/components/editor/ExternalSqlFileChangeDialog.vue b/apps/desktop/src/components/editor/ExternalSqlFileChangeDialog.vue new file mode 100644 index 000000000..92d020aee --- /dev/null +++ b/apps/desktop/src/components/editor/ExternalSqlFileChangeDialog.vue @@ -0,0 +1,64 @@ + + + diff --git a/apps/desktop/src/components/layout/AppTabBar.vue b/apps/desktop/src/components/layout/AppTabBar.vue index 851c41e29..d89d857d3 100644 --- a/apps/desktop/src/components/layout/AppTabBar.vue +++ b/apps/desktop/src/components/layout/AppTabBar.vue @@ -490,6 +490,7 @@ function tabColorStyle(tab: QueryTab) { } function tabIconClass(tab: QueryTab) { + if (tab.externalSqlFileMissing) return "text-amber-600 dark:text-amber-400"; if (tab.mode === "mq") return ""; if (tab.mode === "objects") return "text-amber-500 dark:text-amber-400"; if (tab.mode === "data" || tab.mode === "mongo" || tab.mode === "vector" || tab.mode === "redis" || tab.mode === "hbase" || tab.mode === "structure") return "text-emerald-600 dark:text-emerald-400"; @@ -519,6 +520,7 @@ const tabBarClass = computed(() => [isClassicLayout.value ? "bg-muted" : "border const regularTabRowClass = computed(() => [isClassicLayout.value ? "h-9 items-stretch" : "h-10 items-center px-2", isClassicLayout.value && !hasFixedTabs.value ? "border-b" : ""]); function tabMenuIcon(tab: QueryTab) { + if (tab.externalSqlFileMissing) return AlertTriangle; if (tab.mode === "data" || tab.mode === "mongo" || tab.mode === "redis" || tab.mode === "hbase") return Table2; if (tab.mode === "vector") return TableProperties; if (tab.mode === "etcd" || tab.mode === "zookeeper") return KeyRound; @@ -663,7 +665,8 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul @mouseleave="tabDrag.clearTarget(tab.id)" > - + + @@ -858,7 +861,8 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul @mouseleave="tabDrag.clearTarget(tab.id)" > - + + diff --git a/apps/desktop/src/components/layout/SqlFilePanel.vue b/apps/desktop/src/components/layout/SqlFilePanel.vue index d3af6ee4b..bf3ef768f 100644 --- a/apps/desktop/src/components/layout/SqlFilePanel.vue +++ b/apps/desktop/src/components/layout/SqlFilePanel.vue @@ -208,12 +208,12 @@ function collectDirPaths(entries: SqlFileEntry[], into: Set) { async function openFile(path: string) { if (!isTauriRuntime()) return; try { - const content = await api.readExternalSqlFile(path); + const snapshot = await api.readExternalSqlFileSnapshot(path); const connectionId = connectionStore.activeConnectionId || connectionStore.connections[0]?.id || ""; const connection = connectionId ? connectionStore.getConfig(connectionId) : undefined; const database = connection ? resolveDefaultDatabase(connection, []) : ""; const target = resolveExternalSqlFileTarget(path, (savedConnectionId) => !!connectionStore.getConfig(savedConnectionId), { connectionId, database }); - queryStore.openExternalSqlFile(target.connectionId, target.database, path, content); + queryStore.openExternalSqlFile(target.connectionId, target.database, path, snapshot.content, snapshot.version); } catch (e: any) { if (isExternalSqlFileTooLargeError(e)) { executeFile(path); diff --git a/apps/desktop/src/composables/useExternalSqlFileChanges.ts b/apps/desktop/src/composables/useExternalSqlFileChanges.ts new file mode 100644 index 000000000..27020fa48 --- /dev/null +++ b/apps/desktop/src/composables/useExternalSqlFileChanges.ts @@ -0,0 +1,263 @@ +import { computed, onScopeDispose, ref, watch, type ComputedRef } from "vue"; +import { useI18n } from "vue-i18n"; +import * as api from "@/lib/backend/api"; +import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; +import { externalSqlFileContentMatchesBaseline, externalSqlFileMetadataMatches, externalSqlFileVersionWasIgnored } from "@/lib/sql/externalSqlFileChanges"; +import { useQueryStore } from "@/stores/queryStore"; +import type { ExternalSqlFileSnapshot } from "@/lib/backend/tauri"; +import type { QueryTab } from "@/types/database"; + +export type ExternalSqlFilePromptDecision = "load" | "keep" | "overwrite" | "recreate" | "saveAs" | "close" | "cancel"; + +export type ExternalSqlFilePrompt = + | { + kind: "modified"; + context: "reload" | "save"; + tabId: string; + path: string; + dirty: boolean; + snapshot: ExternalSqlFileSnapshot; + } + | { + kind: "deleted"; + context: "reload" | "save"; + tabId: string; + path: string; + dirty: boolean; + }; + +type DetectedExternalSqlFileChange = + | { + kind: "modified"; + tabId: string; + path: string; + dirty: boolean; + snapshot: ExternalSqlFileSnapshot; + } + | { + kind: "deleted"; + tabId: string; + path: string; + dirty: boolean; + }; + +interface UseExternalSqlFileChangesOptions { + activeTab: ComputedRef; + recreateFile: (tab: QueryTab) => Promise; + saveAsFile: (tab: QueryTab) => Promise; + closeTab: (tab: QueryTab) => void; + reportError: (message: string) => void; +} + +export interface ExternalSqlFileSavePreparation { + proceed: boolean; + expectedContentHash?: string; + expectedMissing?: boolean; +} + +const MISSING_FILE_RECHECK_DELAY_MS = 180; + +function delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export function useExternalSqlFileChanges(options: UseExternalSqlFileChangesOptions) { + const { t } = useI18n(); + const queryStore = useQueryStore(); + const pendingPrompt = ref(null); + const checkingTabIds = new Set(); + let promptResolver: ((decision: ExternalSqlFilePromptDecision) => void) | null = null; + let checksSuspended = 0; + let disposed = false; + let unlistenWindowFocus: (() => void) | undefined; + + const promptOpen = computed(() => pendingPrompt.value !== null); + + async function inspectStableFile(path: string) { + let status = await api.inspectExternalSqlFile(path); + if (status.kind === "missing") { + await delay(MISSING_FILE_RECHECK_DELAY_MS); + status = await api.inspectExternalSqlFile(path); + } + return status; + } + + async function readStableSnapshot(path: string) { + try { + return await api.readExternalSqlFileSnapshot(path); + } catch (firstError) { + await delay(MISSING_FILE_RECHECK_DELAY_MS); + try { + return await api.readExternalSqlFileSnapshot(path); + } catch { + throw firstError; + } + } + } + + async function detectChange(tab: QueryTab, forceContentCheck = false): Promise { + if (!tab.externalSqlPath || !isTauriRuntime() || checkingTabIds.has(tab.id)) return null; + checkingTabIds.add(tab.id); + try { + const status = await inspectStableFile(tab.externalSqlPath); + if (status.kind === "missing") { + if (!forceContentCheck && tab.externalSqlFileMissing) return null; + return { + kind: "deleted", + tabId: tab.id, + path: tab.externalSqlPath, + dirty: queryStore.isTabDirty(tab), + }; + } + + if (!forceContentCheck && !tab.externalSqlFileMissing && externalSqlFileMetadataMatches(tab.externalSqlFileVersion, status)) return null; + + const snapshot = await readStableSnapshot(tab.externalSqlPath); + if (externalSqlFileContentMatchesBaseline(tab, snapshot) || (!tab.externalSqlFileVersion && tab.sql === snapshot.content)) { + queryStore.updateExternalSqlFileVersion(tab.id, snapshot.version); + return null; + } + if (!forceContentCheck && externalSqlFileVersionWasIgnored(tab, snapshot)) return null; + + return { + kind: "modified", + tabId: tab.id, + path: tab.externalSqlPath, + dirty: queryStore.isTabDirty(tab), + snapshot, + }; + } finally { + checkingTabIds.delete(tab.id); + } + } + + function requestDecision(change: DetectedExternalSqlFileChange, context: ExternalSqlFilePrompt["context"]): Promise { + if (promptResolver) return Promise.resolve("cancel"); + pendingPrompt.value = { ...change, context } as ExternalSqlFilePrompt; + return new Promise((resolve) => { + promptResolver = resolve; + }); + } + + function resolvePrompt(decision: ExternalSqlFilePromptDecision) { + const resolve = promptResolver; + promptResolver = null; + pendingPrompt.value = null; + resolve?.(decision); + } + + async function loadLatest(tab: QueryTab) { + if (!tab.externalSqlPath) return false; + try { + const snapshot = await readStableSnapshot(tab.externalSqlPath); + queryStore.applyExternalSqlFileSnapshot(tab.id, snapshot.content, snapshot.version); + return true; + } catch (error: any) { + options.reportError(t("externalSqlFile.loadFailed", { message: error?.message || String(error) })); + return false; + } + } + + async function handleDeletedDecision(tab: QueryTab, decision: ExternalSqlFilePromptDecision) { + checksSuspended += 1; + try { + if (decision === "recreate") await options.recreateFile(tab); + else if (decision === "saveAs") await options.saveAsFile(tab); + else if (decision === "close") options.closeTab(tab); + else if (decision === "keep") queryStore.acknowledgeExternalSqlFileMissing(tab.id); + } finally { + checksSuspended -= 1; + } + } + + async function checkActiveFile() { + if (checksSuspended || pendingPrompt.value) return; + const tab = options.activeTab.value; + if (!tab?.externalSqlPath) return; + try { + const change = await detectChange(tab); + if (!change || !queryStore.tabs.some((candidate) => candidate.id === tab.id)) return; + const decision = await requestDecision(change, "reload"); + if (change.kind === "modified") { + if (decision === "load") await loadLatest(tab); + else if (decision === "keep") queryStore.ignoreExternalSqlFileVersion(tab.id, change.snapshot.version); + } else { + await handleDeletedDecision(tab, decision); + } + } catch (error: any) { + options.reportError(t("externalSqlFile.checkFailed", { message: error?.message || String(error) })); + } + } + + async function prepareSave(tab: QueryTab): Promise { + if (!tab.externalSqlPath) return { proceed: true }; + checksSuspended += 1; + try { + const change = await detectChange(tab, true); + if (!change) { + return { + proceed: true, + expectedContentHash: tab.externalSqlFileVersion?.contentHash, + }; + } + + const decision = await requestDecision(change, "save"); + if (change.kind === "modified") { + if (decision === "overwrite") { + return { + proceed: true, + expectedContentHash: change.snapshot.version.contentHash, + }; + } + if (decision === "load") await loadLatest(tab); + return { proceed: false }; + } + + if (decision === "recreate") return { proceed: true, expectedMissing: true }; + await handleDeletedDecision(tab, decision); + return { proceed: false }; + } catch (error: any) { + options.reportError(t("externalSqlFile.checkFailed", { message: error?.message || String(error) })); + return { proceed: false }; + } finally { + checksSuspended -= 1; + } + } + + watch( + () => options.activeTab.value?.id, + () => { + if (!disposed) void checkActiveFile(); + }, + { flush: "post" }, + ); + + if (isTauriRuntime()) { + void import("@tauri-apps/api/window") + .then(({ getCurrentWindow }) => + getCurrentWindow().onFocusChanged(({ payload }) => { + if (payload && !disposed) void checkActiveFile(); + }), + ) + .then((unlisten) => { + if (disposed) unlisten(); + else unlistenWindowFocus = unlisten; + }) + .catch(() => {}); + } + + onScopeDispose(() => { + disposed = true; + unlistenWindowFocus?.(); + promptResolver?.("cancel"); + promptResolver = null; + }); + + return { + pendingPrompt, + promptOpen, + resolvePrompt, + checkActiveFile, + prepareSave, + }; +} diff --git a/apps/desktop/src/composables/useFileDrop.ts b/apps/desktop/src/composables/useFileDrop.ts index 952b8fa38..4b61290c1 100644 --- a/apps/desktop/src/composables/useFileDrop.ts +++ b/apps/desktop/src/composables/useFileDrop.ts @@ -5,7 +5,7 @@ import { useConnectionStore } from "@/stores/connectionStore"; import { useQueryStore } from "@/stores/queryStore"; import { useToast } from "@/composables/useToast"; import * as api from "@/lib/backend/api"; -import type { ConnectionConfig } from "@/types/database"; +import type { ConnectionConfig, ExternalSqlFileVersion } from "@/types/database"; import { detectDatabaseFileType } from "@/lib/database/databaseFileDetection"; import { externalSqlFileOpenErrorMessage, readBrowserSqlFile } from "@/lib/sql/sqlFileOpen"; @@ -23,12 +23,12 @@ export function useFileDrop() { const queryStore = useQueryStore(); const { toast } = useToast(); - async function openDroppedSqlFile(name: string, content: string, path?: string) { + async function openDroppedSqlFile(name: string, content: string, path?: string, version?: ExternalSqlFileVersion) { const connectionId = connectionStore.activeConnectionId || connectionStore.connections[0]?.id || ""; const connection = connectionId ? connectionStore.getConfig(connectionId) : undefined; const database = connection?.database || ""; if (path) { - queryStore.openExternalSqlFile(connectionId, database, path, content); + queryStore.openExternalSqlFile(connectionId, database, path, content, version); } else { const tabId = queryStore.createTab(connectionId, database, name, "query"); queryStore.updateSql(tabId, content); @@ -70,8 +70,8 @@ export function useFileDrop() { if (isSqlFilePath(path)) { try { - const content = await api.readExternalSqlFile(path); - await openDroppedSqlFile(name, content, path); + const snapshot = await api.readExternalSqlFileSnapshot(path); + await openDroppedSqlFile(name, snapshot.content, path, snapshot.version); } catch (e: any) { toast(t("toolbar.sqlOpenFailed", { message: externalSqlFileOpenErrorMessage(e, (key, params) => t(key, params)) }), 5000); } diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index a1d781d31..ba4023a0e 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -65,6 +65,26 @@ export default { mcpUpdateAvailable: "MCP server update available", blockDangerousRedisCommands: "Block dangerous commands", }, + externalSqlFile: { + modifiedTitle: "File changed outside DBX", + modifiedDescription: "{path} has been modified by another program. Choose which version to keep.", + deletedTitle: "File no longer exists", + deletedDescription: "{path} was deleted or moved outside DBX. The editor content is still available.", + unsavedWarning: "This editor also contains unsaved changes. Loading the external version will discard them.", + deletedUnsavedWarning: "This editor contains unsaved changes. Closing the tab will discard them.", + keepEditor: "Keep editor content", + loadLatest: "Load latest", + cancelSave: "Cancel save", + loadExternal: "Load external version", + overwriteSave: "Overwrite file", + keepEditing: "Keep editing", + saveAs: "Save as...", + recreate: "Recreate file", + closeTab: "Close tab", + loadFailed: "Failed to load the latest file content: {message}", + checkFailed: "Failed to check the external SQL file: {message}", + changedAgain: "The file changed again while it was being saved. Try again.", + }, updates: { title: "Updates", check: "Check for updates", @@ -1009,6 +1029,8 @@ export default { executionSummary: "Summary", tooltipTitle: "Title:", tooltipFilePath: "File Path:", + tooltipFileStatus: "File Status:", + externalFileMissing: "Deleted or moved outside DBX", tooltipConnection: "Connection:", tooltipGroup: "Group:", tooltipDatabase: "Database:", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index a55cd56ad..fafbd2cf6 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -67,6 +67,26 @@ export default withEnglishFallback({ mcpUpdateAvailable: "Actualización del servidor MCP disponible", blockDangerousRedisCommands: "Bloquear comandos peligrosos", }, + externalSqlFile: { + modifiedTitle: "El archivo cambió fuera de DBX", + modifiedDescription: "{path} fue modificado por otro programa. Elige qué versión conservar.", + deletedTitle: "El archivo ya no existe", + deletedDescription: "{path} fue eliminado o movido fuera de DBX. El contenido del editor sigue disponible.", + unsavedWarning: "El editor también contiene cambios sin guardar. Cargar la versión externa los descartará.", + deletedUnsavedWarning: "El editor contiene cambios sin guardar. Cerrar la pestaña los descartará.", + keepEditor: "Conservar contenido del editor", + loadLatest: "Cargar versión más reciente", + cancelSave: "Cancelar guardado", + loadExternal: "Cargar versión externa", + overwriteSave: "Sobrescribir archivo", + keepEditing: "Seguir editando", + saveAs: "Guardar como...", + recreate: "Volver a crear el archivo", + closeTab: "Cerrar pestaña", + loadFailed: "No se pudo cargar el contenido más reciente del archivo: {message}", + checkFailed: "No se pudo comprobar el archivo SQL externo: {message}", + changedAgain: "El archivo volvió a cambiar durante el guardado. Inténtalo de nuevo.", + }, updates: { title: "Actualizaciones", check: "Buscar actualizaciones", @@ -987,6 +1007,8 @@ export default withEnglishFallback({ mongo: "Mongo", objects: "Objetos", tooltipTitle: "Título:", + tooltipFileStatus: "Estado del archivo:", + externalFileMissing: "Eliminado o movido fuera de DBX", tooltipConnection: "Conexión:", tooltipGroup: "Grupo:", tooltipDatabase: "Base de datos:", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 85d5b86eb..a677e2059 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -66,6 +66,26 @@ export default withEnglishFallback({ mcpUpdateAvailable: "Aggiornamento server MCP disponibile", blockDangerousRedisCommands: "Blocca comandi pericolosi", }, + externalSqlFile: { + modifiedTitle: "File modificato esternamente a DBX", + modifiedDescription: "{path} è stato modificato da un altro programma. Scegli quale versione mantenere.", + deletedTitle: "Il file non esiste più", + deletedDescription: "{path} è stato eliminato o spostato fuori da DBX. Il contenuto dell'editor è ancora disponibile.", + unsavedWarning: "L'editor contiene anche modifiche non salvate. Il caricamento della versione esterna le eliminerà.", + deletedUnsavedWarning: "L'editor contiene modifiche non salvate. La chiusura della scheda le eliminerà.", + keepEditor: "Mantieni contenuto dell'editor", + loadLatest: "Carica versione più recente", + cancelSave: "Annulla salvataggio", + loadExternal: "Carica versione esterna", + overwriteSave: "Sovrascrivi file", + keepEditing: "Continua a modificare", + saveAs: "Salva con nome...", + recreate: "Ricrea file", + closeTab: "Chiudi scheda", + loadFailed: "Impossibile caricare il contenuto più recente del file: {message}", + checkFailed: "Impossibile controllare il file SQL esterno: {message}", + changedAgain: "Il file è cambiato di nuovo durante il salvataggio. Riprova.", + }, updates: { title: "Aggiornamenti", check: "Verifica aggiornamenti", @@ -985,6 +1005,8 @@ export default withEnglishFallback({ users: "Utenti e Privilegi", executionSummary: "Riepilogo", tooltipTitle: "Titolo:", + tooltipFileStatus: "Stato file:", + externalFileMissing: "Eliminato o spostato fuori da DBX", tooltipConnection: "Connessione:", tooltipGroup: "Gruppo:", tooltipDatabase: "Database:", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 594c405d7..958ac2ab7 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -67,6 +67,26 @@ export default withEnglishFallback({ mcpUpdateAvailable: "MCPサーバーの更新があります", blockDangerousRedisCommands: "危険なコマンドをブロック", }, + externalSqlFile: { + modifiedTitle: "ファイルがDBXの外部で変更されました", + modifiedDescription: "{path} は別のプログラムによって変更されました。保持するバージョンを選択してください。", + deletedTitle: "ファイルが存在しません", + deletedDescription: "{path} はDBXの外部で削除または移動されました。エディターの内容は保持されています。", + unsavedWarning: "エディターにも未保存の変更があります。外部バージョンを読み込むと変更は破棄されます。", + deletedUnsavedWarning: "エディターに未保存の変更があります。タブを閉じると変更は破棄されます。", + keepEditor: "エディターの内容を保持", + loadLatest: "最新版を読み込む", + cancelSave: "保存をキャンセル", + loadExternal: "外部バージョンを読み込む", + overwriteSave: "ファイルを上書き", + keepEditing: "編集を続ける", + saveAs: "名前を付けて保存...", + recreate: "ファイルを再作成", + closeTab: "タブを閉じる", + loadFailed: "ファイルの最新内容を読み込めませんでした: {message}", + checkFailed: "外部SQLファイルを確認できませんでした: {message}", + changedAgain: "保存中にファイルが再度変更されました。もう一度お試しください。", + }, updates: { title: "アップデート", check: "アップデートを確認", @@ -1005,6 +1025,8 @@ export default withEnglishFallback({ executionSummary: "サマリー", tooltipTitle: "タイトル:", tooltipFilePath: "ファイルパス:", + tooltipFileStatus: "ファイルの状態:", + externalFileMissing: "DBXの外部で削除または移動されました", tooltipConnection: "接続:", tooltipGroup: "グループ:", tooltipDatabase: "データベース:", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 9e5ea8377..7967e19e0 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -67,6 +67,26 @@ export default withEnglishFallback({ mcpUpdateAvailable: "MCP 서버 업데이트 가능", blockDangerousRedisCommands: "위험한 명령 차단", }, + externalSqlFile: { + modifiedTitle: "DBX 외부에서 파일이 변경됨", + modifiedDescription: "{path} 파일이 다른 프로그램에서 변경되었습니다. 유지할 버전을 선택하세요.", + deletedTitle: "파일이 더 이상 존재하지 않음", + deletedDescription: "{path} 파일이 DBX 외부에서 삭제되거나 이동되었습니다. 편집기 내용은 유지됩니다.", + unsavedWarning: "편집기에도 저장되지 않은 변경 사항이 있습니다. 외부 버전을 불러오면 해당 변경 사항이 사라집니다.", + deletedUnsavedWarning: "편집기에 저장되지 않은 변경 사항이 있습니다. 탭을 닫으면 해당 변경 사항이 사라집니다.", + keepEditor: "편집기 내용 유지", + loadLatest: "최신 버전 불러오기", + cancelSave: "저장 취소", + loadExternal: "외부 버전 불러오기", + overwriteSave: "파일 덮어쓰기", + keepEditing: "계속 편집", + saveAs: "다른 이름으로 저장...", + recreate: "파일 다시 만들기", + closeTab: "탭 닫기", + loadFailed: "파일의 최신 내용을 불러오지 못했습니다: {message}", + checkFailed: "외부 SQL 파일을 확인하지 못했습니다: {message}", + changedAgain: "저장하는 동안 파일이 다시 변경되었습니다. 다시 시도하세요.", + }, updates: { title: "업데이트", check: "업데이트 확인", @@ -924,6 +944,8 @@ export default withEnglishFallback({ executionSummary: "요약", tooltipTitle: "제목:", tooltipFilePath: "파일 경로:", + tooltipFileStatus: "파일 상태:", + externalFileMissing: "DBX 외부에서 삭제되거나 이동됨", tooltipConnection: "연결:", tooltipGroup: "그룹:", tooltipDatabase: "데이터베이스:", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 55a383e72..a70906665 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -67,6 +67,26 @@ export default withEnglishFallback({ mcpUpdateAvailable: "Atualização do servidor MCP disponível", blockDangerousRedisCommands: "Bloquear comandos perigosos", }, + externalSqlFile: { + modifiedTitle: "Arquivo alterado fora do DBX", + modifiedDescription: "{path} foi alterado por outro programa. Escolha qual versão manter.", + deletedTitle: "O arquivo não existe mais", + deletedDescription: "{path} foi excluído ou movido fora do DBX. O conteúdo do editor continua disponível.", + unsavedWarning: "O editor também contém alterações não salvas. Carregar a versão externa irá descartá-las.", + deletedUnsavedWarning: "O editor contém alterações não salvas. Fechar a aba irá descartá-las.", + keepEditor: "Manter conteúdo do editor", + loadLatest: "Carregar versão mais recente", + cancelSave: "Cancelar salvamento", + loadExternal: "Carregar versão externa", + overwriteSave: "Sobrescrever arquivo", + keepEditing: "Continuar editando", + saveAs: "Salvar como...", + recreate: "Recriar arquivo", + closeTab: "Fechar aba", + loadFailed: "Não foi possível carregar o conteúdo mais recente do arquivo: {message}", + checkFailed: "Não foi possível verificar o arquivo SQL externo: {message}", + changedAgain: "O arquivo mudou novamente durante o salvamento. Tente outra vez.", + }, updates: { title: "Atualizações", check: "Verificar atualizações", @@ -987,6 +1007,8 @@ export default withEnglishFallback({ executionSummary: "Resumo", tooltipTitle: "Título:", tooltipFilePath: "Caminho do arquivo:", + tooltipFileStatus: "Status do arquivo:", + externalFileMissing: "Excluído ou movido fora do DBX", tooltipConnection: "Conexão:", tooltipGroup: "Grupo:", tooltipDatabase: "Banco de dados:", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 61856c1c0..4fa656ea5 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -67,6 +67,26 @@ export default withEnglishFallback({ mcpUpdateAvailable: "MCP 服务有可用更新", blockDangerousRedisCommands: "拦截危险命令", }, + externalSqlFile: { + modifiedTitle: "文件已在外部发生修改", + modifiedDescription: "{path} 已被其他程序修改,请选择需要保留的版本。", + deletedTitle: "文件已不存在", + deletedDescription: "{path} 已在 DBX 外部被删除或移动,编辑器中的内容仍然保留。", + unsavedWarning: "当前编辑器也有未保存的修改,加载外部版本将丢弃这些修改。", + deletedUnsavedWarning: "当前编辑器有未保存的修改,关闭标签将丢弃这些修改。", + keepEditor: "保留编辑器内容", + loadLatest: "加载最新内容", + cancelSave: "取消保存", + loadExternal: "加载外部版本", + overwriteSave: "覆盖文件", + keepEditing: "保留编辑", + saveAs: "另存为...", + recreate: "重新创建文件", + closeTab: "关闭标签", + loadFailed: "加载文件最新内容失败:{message}", + checkFailed: "检查外部 SQL 文件失败:{message}", + changedAgain: "保存过程中该文件再次发生变化,请重试。", + }, updates: { title: "更新", check: "检查更新", @@ -1010,6 +1030,8 @@ export default withEnglishFallback({ executionSummary: "摘要", tooltipTitle: "标题:", tooltipFilePath: "文件路径:", + tooltipFileStatus: "文件状态:", + externalFileMissing: "已在 DBX 外部删除或移动", tooltipConnection: "连接:", tooltipGroup: "分组:", tooltipDatabase: "数据库:", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 6b07ad911..8aec5ccb2 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -67,6 +67,26 @@ export default withEnglishFallback({ mcpUpdateAvailable: "MCP 服務有可用更新", blockDangerousRedisCommands: "攔截危險命令", }, + externalSqlFile: { + modifiedTitle: "檔案已在 DBX 外部變更", + modifiedDescription: "{path} 已被其他程式修改,請選擇要保留的版本。", + deletedTitle: "檔案已不存在", + deletedDescription: "{path} 已在 DBX 外部刪除或移動,編輯器中的內容仍然保留。", + unsavedWarning: "目前編輯器也有尚未儲存的變更,載入外部版本將捨棄這些變更。", + deletedUnsavedWarning: "目前編輯器有尚未儲存的變更,關閉分頁將捨棄這些變更。", + keepEditor: "保留編輯器內容", + loadLatest: "載入最新內容", + cancelSave: "取消儲存", + loadExternal: "載入外部版本", + overwriteSave: "覆寫檔案", + keepEditing: "保留編輯", + saveAs: "另存新檔...", + recreate: "重新建立檔案", + closeTab: "關閉分頁", + loadFailed: "載入檔案最新內容失敗:{message}", + checkFailed: "檢查外部 SQL 檔案失敗:{message}", + changedAgain: "儲存期間檔案再次發生變更,請重試。", + }, updates: { title: "更新", check: "檢查更新", @@ -986,6 +1006,8 @@ export default withEnglishFallback({ executionSummary: "摘要", tooltipTitle: "標題:", tooltipFilePath: "檔案路徑:", + tooltipFileStatus: "檔案狀態:", + externalFileMissing: "已在 DBX 外部刪除或移動", tooltipConnection: "連線:", tooltipGroup: "群組:", tooltipDatabase: "資料庫:", diff --git a/apps/desktop/src/lib/__tests__/app/openTabsPersistence.spec.ts b/apps/desktop/src/lib/__tests__/app/openTabsPersistence.spec.ts index 33c4589fe..d6d5ec79e 100644 --- a/apps/desktop/src/lib/__tests__/app/openTabsPersistence.spec.ts +++ b/apps/desktop/src/lib/__tests__/app/openTabsPersistence.spec.ts @@ -57,4 +57,56 @@ describe("openTabsPersistence originalSql round-trip", () => { expect(restored.database).toBe("dbx_catalog_completion"); expect(restored.catalog).toBe("dbx_mysql_catalog"); }); + + it("preserves external file versions and acknowledged state across tab restore", () => { + const version = { sizeBytes: 9, modifiedNs: "100", contentHash: "original" }; + const ignoredVersion = { sizeBytes: 9, modifiedNs: "200", contentHash: "changed" }; + const [restored] = roundTrip([ + queryTab({ + sql: "SELECT 1", + originalSql: "SELECT 1", + externalSqlPath: "/tmp/query.sql", + externalSqlFileVersion: version, + externalSqlIgnoredFileVersion: ignoredVersion, + externalSqlFileMissing: true, + }), + ]); + + expect(restored.externalSqlFileVersion).toEqual(version); + expect(restored.externalSqlIgnoredFileVersion).toEqual(ignoredVersion); + expect(restored.externalSqlFileMissing).toBe(true); + }); + + it("preserves the disk baseline for a dirty external file after an ignored change", () => { + const version = { sizeBytes: 9, modifiedNs: "100", contentHash: "original" }; + const ignoredVersion = { sizeBytes: 9, modifiedNs: "200", contentHash: "changed" }; + const [restored] = roundTrip([ + queryTab({ + sql: "SELECT 2", + originalSql: "SELECT 1", + externalSqlPath: "/tmp/query.sql", + externalSqlFileVersion: version, + externalSqlIgnoredFileVersion: ignoredVersion, + }), + ]); + + expect(restored.sql).toBe("SELECT 2"); + expect(restored.originalSql).toBe("SELECT 1"); + expect(restored.externalSqlIgnoredFileVersion).toEqual(ignoredVersion); + }); + + it("preserves the disk baseline for a dirty external file acknowledged as missing", () => { + const [restored] = roundTrip([ + queryTab({ + sql: "SELECT 2", + originalSql: "SELECT 1", + externalSqlPath: "/tmp/query.sql", + externalSqlFileMissing: true, + }), + ]); + + expect(restored.sql).toBe("SELECT 2"); + expect(restored.originalSql).toBe("SELECT 1"); + expect(restored.externalSqlFileMissing).toBe(true); + }); }); diff --git a/apps/desktop/src/lib/__tests__/sql/externalSqlFileChanges.spec.ts b/apps/desktop/src/lib/__tests__/sql/externalSqlFileChanges.spec.ts new file mode 100644 index 000000000..3b7b84216 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/sql/externalSqlFileChanges.spec.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { externalSqlFileContentMatchesBaseline, externalSqlFileMetadataMatches, externalSqlFileVersionWasIgnored } from "@/lib/sql/externalSqlFileChanges"; +import type { ExternalSqlFileSnapshot } from "@/lib/backend/tauri"; +import type { QueryTab } from "@/types/database"; + +const originalVersion = { + sizeBytes: 9, + modifiedNs: "100", + contentHash: "original", +}; + +const changedSnapshot: ExternalSqlFileSnapshot = { + content: "select 2;", + version: { + sizeBytes: 9, + modifiedNs: "200", + contentHash: "changed", + }, +}; + +function tab(overrides: Partial = {}): QueryTab { + return { + id: "tab-1", + title: "demo.sql", + connectionId: "", + database: "", + sql: "select 1;", + originalSql: "select 1;", + externalSqlPath: "/tmp/demo.sql", + externalSqlFileVersion: originalVersion, + isExecuting: false, + isCancelling: false, + isExplaining: false, + mode: "query", + ...overrides, + }; +} + +describe("external SQL file change detection", () => { + it("uses size and precise modification time for the metadata fast path", () => { + expect(externalSqlFileMetadataMatches(originalVersion, { kind: "present", sizeBytes: 9, modifiedNs: "100" })).toBe(true); + expect(externalSqlFileMetadataMatches(originalVersion, { kind: "present", sizeBytes: 9, modifiedNs: "101" })).toBe(false); + expect(externalSqlFileMetadataMatches(originalVersion, { kind: "missing" })).toBe(false); + }); + + it("treats identical raw content or identical decoded baseline text as unchanged", () => { + expect(externalSqlFileContentMatchesBaseline(tab(), { ...changedSnapshot, content: "select 1;" })).toBe(true); + expect(externalSqlFileContentMatchesBaseline(tab(), { ...changedSnapshot, version: originalVersion })).toBe(true); + expect(externalSqlFileContentMatchesBaseline(tab(), changedSnapshot)).toBe(false); + }); + + it("suppresses only the exact external version the user kept", () => { + expect(externalSqlFileVersionWasIgnored(tab({ externalSqlIgnoredFileVersion: changedSnapshot.version }), changedSnapshot)).toBe(true); + expect(externalSqlFileVersionWasIgnored(tab({ externalSqlIgnoredFileVersion: originalVersion }), changedSnapshot)).toBe(false); + }); +}); diff --git a/apps/desktop/src/lib/app/openTabsPersistence.ts b/apps/desktop/src/lib/app/openTabsPersistence.ts index 6e697df4e..1602bff1d 100644 --- a/apps/desktop/src/lib/app/openTabsPersistence.ts +++ b/apps/desktop/src/lib/app/openTabsPersistence.ts @@ -26,6 +26,9 @@ export interface SavedOpenTab { originalSql?: string; savedSqlId?: string; externalSqlPath?: string; + externalSqlFileVersion?: QueryTab["externalSqlFileVersion"]; + externalSqlIgnoredFileVersion?: QueryTab["externalSqlIgnoredFileVersion"]; + externalSqlFileMissing?: boolean; lastExecutedSql?: string; resultBaseSql?: string; resultSortedSql?: string; @@ -74,7 +77,7 @@ function shouldPersistTabSql(tab: QueryTab) { function restoredOriginalSql(tab: SavedOpenTab, mode: QueryTab["mode"], sql: string) { if (mode !== "query") return undefined; - if (tab.externalSqlPath) return sql; + if (tab.externalSqlPath) return tab.originalSql ?? sql; if (tab.savedSqlId) return sql ? "" : undefined; // Prefer the persisted originalSql so a clean prefilled query tab (sql === originalSql) // restores clean instead of being marked dirty. Older saved state without this field @@ -93,12 +96,14 @@ export function serializeOpenTabs(tabs: QueryTab[]): SavedOpenTab[] { ...(tab.catalog !== undefined ? { catalog: tab.catalog } : {}), schema: tab.schema, sql: shouldPersistTabSql(tab) ? tab.sql : "", - // Only round-trip originalSql for plain query tabs (no savedSqlId / externalSqlPath): - // saved-SQL and external-file tabs re-derive it on restore, and persisting it here - // would duplicate their (potentially large) SQL text in the open-tabs state. - ...(tab.originalSql !== undefined && !tab.savedSqlId && !tab.externalSqlPath ? { originalSql: tab.originalSql } : {}), + // Plain query tabs always round-trip originalSql. External-file tabs only persist it + // while dirty so their disk baseline survives restart without duplicating clean SQL. + ...(tab.originalSql !== undefined && !tab.savedSqlId && (!tab.externalSqlPath || tab.sql !== tab.originalSql) ? { originalSql: tab.originalSql } : {}), savedSqlId: tab.savedSqlId, externalSqlPath: tab.externalSqlPath, + ...(tab.externalSqlFileVersion ? { externalSqlFileVersion: tab.externalSqlFileVersion } : {}), + ...(tab.externalSqlIgnoredFileVersion ? { externalSqlIgnoredFileVersion: tab.externalSqlIgnoredFileVersion } : {}), + ...(tab.externalSqlFileMissing ? { externalSqlFileMissing: true } : {}), ...(tab.lastExecutedSql !== undefined ? { lastExecutedSql: tab.lastExecutedSql } : {}), ...(tab.resultBaseSql !== undefined ? { resultBaseSql: tab.resultBaseSql } : {}), ...(tab.resultSortedSql !== undefined ? { resultSortedSql: tab.resultSortedSql } : {}), diff --git a/apps/desktop/src/lib/backend/__tests__/externalSqlFileApi.spec.ts b/apps/desktop/src/lib/backend/__tests__/externalSqlFileApi.spec.ts index 4771732c4..34521739d 100644 --- a/apps/desktop/src/lib/backend/__tests__/externalSqlFileApi.spec.ts +++ b/apps/desktop/src/lib/backend/__tests__/externalSqlFileApi.spec.ts @@ -9,7 +9,13 @@ vi.mock("@tauri-apps/api/core", () => ({ invoke: mocks.invoke, })); -import { readExternalSqlFile } from "@/lib/backend/tauri"; +import { inspectExternalSqlFile, readExternalSqlFile, readExternalSqlFileSnapshot, writeExternalSqlFile } from "@/lib/backend/tauri"; + +const version = { + sizeBytes: 9, + modifiedNs: "123000000", + contentHash: "abc123", +}; describe("external SQL file API", () => { beforeEach(() => { @@ -17,12 +23,49 @@ describe("external SQL file API", () => { }); it("returns editor content from the structured backend response", async () => { - mocks.invoke.mockResolvedValue({ kind: "content", content: "select 1;" }); + mocks.invoke.mockResolvedValue({ kind: "content", content: "select 1;", version }); await expect(readExternalSqlFile("/tmp/demo.sql")).resolves.toBe("select 1;"); expect(mocks.invoke).toHaveBeenCalledWith("read_external_sql_file", { path: "/tmp/demo.sql" }); }); + it("returns the disk version with an editor snapshot", async () => { + mocks.invoke.mockResolvedValue({ kind: "content", content: "select 1;", version }); + + await expect(readExternalSqlFileSnapshot("/tmp/demo.sql")).resolves.toEqual({ content: "select 1;", version }); + }); + + it("inspects metadata without reading editor content", async () => { + mocks.invoke.mockResolvedValue({ kind: "present", sizeBytes: 9, modifiedNs: "123000000" }); + + await expect(inspectExternalSqlFile("/tmp/demo.sql")).resolves.toEqual({ kind: "present", sizeBytes: 9, modifiedNs: "123000000" }); + expect(mocks.invoke).toHaveBeenCalledWith("inspect_external_sql_file", { path: "/tmp/demo.sql" }); + }); + + it("passes the expected version to checked writes", async () => { + mocks.invoke.mockResolvedValue({ kind: "written", version }); + + await expect(writeExternalSqlFile("/tmp/demo.sql", "select 2;", { expectedContentHash: "abc123" })).resolves.toEqual({ kind: "written", version }); + expect(mocks.invoke).toHaveBeenCalledWith("write_external_sql_file", { + path: "/tmp/demo.sql", + content: "select 2;", + expectedContentHash: "abc123", + expectedMissing: false, + }); + }); + + it("passes an expected-missing precondition to recreate writes", async () => { + mocks.invoke.mockResolvedValue({ kind: "written", version }); + + await expect(writeExternalSqlFile("/tmp/demo.sql", "select 2;", { expectedMissing: true })).resolves.toEqual({ kind: "written", version }); + expect(mocks.invoke).toHaveBeenCalledWith("write_external_sql_file", { + path: "/tmp/demo.sql", + content: "select 2;", + expectedContentHash: null, + expectedMissing: true, + }); + }); + it("maps oversized responses to a typed frontend error", async () => { mocks.invoke.mockResolvedValue({ kind: "tooLarge", sizeBytes: 50 * 1024 ** 3, maxSizeBytes: 64 * 1024 ** 2 }); diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts index 4e54ca317..6be6ff635 100644 --- a/apps/desktop/src/lib/backend/api.ts +++ b/apps/desktop/src/lib/backend/api.ts @@ -333,6 +333,8 @@ export const pendingOpenSqlFiles = forward("pendingOpenSqlFiles"); export const pendingOpenDbFiles = forward("pendingOpenDbFiles"); export const pendingOpenConnectionLinks = forward("pendingOpenConnectionLinks"); export const readExternalSqlFile = forward("readExternalSqlFile"); +export const readExternalSqlFileSnapshot = forward("readExternalSqlFileSnapshot"); +export const inspectExternalSqlFile = forward("inspectExternalSqlFile"); export const writeExternalSqlFile = forward("writeExternalSqlFile"); export const saveExternalSqlFile = forward("saveExternalSqlFile"); export const listSqlFilesInFolder = forward("listSqlFilesInFolder"); diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index 7cfe1e195..83281617e 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -1864,11 +1864,19 @@ export async function readExternalSqlFile(_path: string): Promise { throw new Error("Opening external SQL file paths is only available in the desktop app"); } -export async function writeExternalSqlFile(_path: string, _content: string): Promise { +export async function readExternalSqlFileSnapshot(_path: string): Promise { + throw new Error("Opening external SQL file paths is only available in the desktop app"); +} + +export async function inspectExternalSqlFile(_path: string): Promise { + throw new Error("Inspecting external SQL file paths is only available in the desktop app"); +} + +export async function writeExternalSqlFile(_path: string, _content: string, _options: { expectedContentHash?: string; expectedMissing?: boolean } = {}): Promise { throw new Error("Saving external SQL file paths is only available in the desktop app"); } -export async function saveExternalSqlFile(_defaultFileName: string, _content: string): Promise { +export async function saveExternalSqlFile(_defaultFileName: string, _content: string): Promise<{ path: string; version: import("@/types/database").ExternalSqlFileVersion } | null> { throw new Error("Saving SQL files locally is only available in the desktop app"); } diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index f7f7e7ee5..d8e87ebfd 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -56,6 +56,7 @@ import type { SshConfigHostEntry, TunnelProfile, TransactionLog, + ExternalSqlFileVersion, } from "@/types/database"; import { isTauriCommandUnavailable, normalizeConnectionTestResult } from "@/lib/connection/connectionDatabaseInfo"; import type { CollectionInfo } from "@/types/database"; @@ -754,19 +755,41 @@ export async function pendingOpenConnectionLinks(): Promise { return invoke("pending_open_connection_links"); } -export async function readExternalSqlFile(path: string): Promise { - const result = await invoke<{ kind: "content"; content: string } | { kind: "tooLarge"; sizeBytes: number; maxSizeBytes: number }>("read_external_sql_file", { path }); +export interface ExternalSqlFileSnapshot { + content: string; + version: ExternalSqlFileVersion; +} + +export type ExternalSqlFileStatus = { kind: "present"; sizeBytes: number; modifiedNs: string } | { kind: "missing" }; + +export type ExternalSqlFileWriteResult = { kind: "written"; version: ExternalSqlFileVersion } | { kind: "conflict"; currentVersion: ExternalSqlFileVersion } | { kind: "missing" }; + +export async function readExternalSqlFileSnapshot(path: string): Promise { + const result = await invoke<{ kind: "content"; content: string; version: ExternalSqlFileVersion } | { kind: "tooLarge"; sizeBytes: number; maxSizeBytes: number }>("read_external_sql_file", { path }); if (result.kind === "tooLarge") { throw new ExternalSqlFileTooLargeError(result.sizeBytes, result.maxSizeBytes); } - return result.content; + return { content: result.content, version: result.version }; } -export async function writeExternalSqlFile(path: string, content: string): Promise { - return invoke("write_external_sql_file", { path, content }); +export async function readExternalSqlFile(path: string): Promise { + return (await readExternalSqlFileSnapshot(path)).content; } -export async function saveExternalSqlFile(defaultFileName: string, content: string): Promise { +export async function inspectExternalSqlFile(path: string): Promise { + return invoke("inspect_external_sql_file", { path }); +} + +export async function writeExternalSqlFile(path: string, content: string, options: { expectedContentHash?: string; expectedMissing?: boolean } = {}): Promise { + return invoke("write_external_sql_file", { + path, + content, + expectedContentHash: options.expectedContentHash ?? null, + expectedMissing: options.expectedMissing ?? false, + }); +} + +export async function saveExternalSqlFile(defaultFileName: string, content: string): Promise<{ path: string; version: ExternalSqlFileVersion } | null> { return invoke("save_external_sql_file", { defaultFileName, content }); } diff --git a/apps/desktop/src/lib/sql/externalSqlFileChanges.ts b/apps/desktop/src/lib/sql/externalSqlFileChanges.ts new file mode 100644 index 000000000..03417ac43 --- /dev/null +++ b/apps/desktop/src/lib/sql/externalSqlFileChanges.ts @@ -0,0 +1,14 @@ +import type { ExternalSqlFileStatus, ExternalSqlFileSnapshot } from "@/lib/backend/tauri"; +import type { ExternalSqlFileVersion, QueryTab } from "@/types/database"; + +export function externalSqlFileMetadataMatches(version: ExternalSqlFileVersion | undefined, status: ExternalSqlFileStatus): boolean { + return !!version && status.kind === "present" && version.sizeBytes === status.sizeBytes && version.modifiedNs === status.modifiedNs; +} + +export function externalSqlFileContentMatchesBaseline(tab: QueryTab, snapshot: ExternalSqlFileSnapshot): boolean { + return tab.externalSqlFileVersion?.contentHash === snapshot.version.contentHash || tab.originalSql === snapshot.content; +} + +export function externalSqlFileVersionWasIgnored(tab: QueryTab, snapshot: ExternalSqlFileSnapshot): boolean { + return tab.externalSqlIgnoredFileVersion?.contentHash === snapshot.version.contentHash; +} diff --git a/apps/desktop/src/lib/tabs/tabPresentation.ts b/apps/desktop/src/lib/tabs/tabPresentation.ts index 1192df877..47d2b7b6c 100644 --- a/apps/desktop/src/lib/tabs/tabPresentation.ts +++ b/apps/desktop/src/lib/tabs/tabPresentation.ts @@ -141,6 +141,7 @@ export function tabTooltipLines(tab: QueryTab, t: Translate): { label: string; v } if (tab.mode === "query" && tab.externalSqlPath) { lines.push({ label: t("tabs.tooltipFilePath"), value: tab.externalSqlPath }); + if (tab.externalSqlFileMissing) lines.push({ label: t("tabs.tooltipFileStatus"), value: t("tabs.externalFileMissing") }); } if (tab.mode === "data" && tab.tableMeta?.tableName) { lines.push({ label: t("tabs.tooltipTable"), value: tab.tableMeta.tableName }); diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 075e39cd3..c548541d4 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -1284,6 +1284,9 @@ export const useQueryStore = defineStore("query", () => { sql: t.sql, savedSqlId: t.savedSqlId, externalSqlPath: t.externalSqlPath, + externalSqlFileVersion: t.externalSqlFileVersion, + externalSqlIgnoredFileVersion: t.externalSqlIgnoredFileVersion, + externalSqlFileMissing: t.externalSqlFileMissing, lastExecutedSql: t.lastExecutedSql, resultBaseSql: t.resultBaseSql, resultSortedSql: t.resultSortedSql, @@ -1404,7 +1407,7 @@ export const useQueryStore = defineStore("query", () => { }); } - function openExternalSqlFile(connectionId: string, database: string, path: string, sql: string) { + function openExternalSqlFile(connectionId: string, database: string, path: string, sql: string, version?: QueryTab["externalSqlFileVersion"]) { const normalizedPath = normalizeExternalSqlPath(path); const existing = tabs.value.find((tab) => tab.mode === "query" && tab.externalSqlPath && normalizeExternalSqlPath(tab.externalSqlPath) === normalizedPath); if (existing) { @@ -1424,6 +1427,7 @@ export const useQueryStore = defineStore("query", () => { sql, originalSql: sql, externalSqlPath: path, + externalSqlFileVersion: version, isExecuting: false, isCancelling: false, isExplaining: false, @@ -1858,6 +1862,46 @@ export const useQueryStore = defineStore("query", () => { if (tab) tab.originalSql = tab.sql; } + function applyExternalSqlFileSnapshot(id: string, sql: string, version: NonNullable) { + const tab = tabs.value.find((candidate) => candidate.id === id); + if (!tab?.externalSqlPath) return; + tab.sql = sql; + tab.originalSql = sql; + tab.externalSqlFileVersion = version; + tab.externalSqlIgnoredFileVersion = undefined; + tab.externalSqlFileMissing = undefined; + } + + function markExternalSqlFileSaved(id: string, version: NonNullable) { + const tab = tabs.value.find((candidate) => candidate.id === id); + if (!tab?.externalSqlPath) return; + tab.originalSql = tab.sql; + tab.externalSqlFileVersion = version; + tab.externalSqlIgnoredFileVersion = undefined; + tab.externalSqlFileMissing = undefined; + } + + function updateExternalSqlFileVersion(id: string, version: NonNullable) { + const tab = tabs.value.find((candidate) => candidate.id === id); + if (!tab?.externalSqlPath) return; + tab.externalSqlFileVersion = version; + tab.externalSqlIgnoredFileVersion = undefined; + tab.externalSqlFileMissing = undefined; + } + + function ignoreExternalSqlFileVersion(id: string, version: NonNullable) { + const tab = tabs.value.find((candidate) => candidate.id === id); + if (!tab?.externalSqlPath) return; + tab.externalSqlIgnoredFileVersion = version; + tab.externalSqlFileMissing = undefined; + } + + function acknowledgeExternalSqlFileMissing(id: string) { + const tab = tabs.value.find((candidate) => candidate.id === id); + if (!tab?.externalSqlPath) return; + tab.externalSqlFileMissing = true; + } + function persistSavedSqlEditorPosition(tab: QueryTab | undefined) { if (!tab?.savedSqlId || tab.mode !== "query") return; const pending = savedSqlEditorPositionTimers.get(tab.savedSqlId); @@ -2545,16 +2589,22 @@ export const useQueryStore = defineStore("query", () => { if (!tab) return; tab.savedSqlId = savedSqlId; tab.externalSqlPath = undefined; + tab.externalSqlFileVersion = undefined; + tab.externalSqlIgnoredFileVersion = undefined; + tab.externalSqlFileMissing = undefined; if (title) { tab.title = title; tab.customTitle = true; } } - function linkExternalSqlPath(id: string, path: string, title?: string) { + function linkExternalSqlPath(id: string, path: string, title?: string, version?: QueryTab["externalSqlFileVersion"]) { const tab = tabs.value.find((t) => t.id === id); if (!tab) return; tab.externalSqlPath = path; + tab.externalSqlFileVersion = version; + tab.externalSqlIgnoredFileVersion = undefined; + tab.externalSqlFileMissing = undefined; tab.savedSqlId = undefined; if (title) { tab.title = title; @@ -5355,6 +5405,11 @@ export const useQueryStore = defineStore("query", () => { completePendingCloseAfterSaveAll, isTabDirty, markTabClean, + applyExternalSqlFileSnapshot, + markExternalSqlFileSaved, + updateExternalSqlFileVersion, + ignoreExternalSqlFileVersion, + acknowledgeExternalSqlFileMissing, discardTabChanges, requestAppCloseConfirmation, closeOtherTabs, diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 7e017dc23..53484f2b9 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -925,6 +925,12 @@ export interface ObjectBrowserViewport { viewMode: ObjectBrowserViewMode; } +export interface ExternalSqlFileVersion { + sizeBytes: number; + modifiedNs: string; + contentHash: string; +} + export interface QueryTab { id: string; title: string; @@ -938,6 +944,9 @@ export interface QueryTab { sql: string; savedSqlId?: string; externalSqlPath?: string; + externalSqlFileVersion?: ExternalSqlFileVersion; + externalSqlIgnoredFileVersion?: ExternalSqlFileVersion; + externalSqlFileMissing?: boolean; originalSql?: string; lastExecutedSql?: string; resultBaseSql?: string; diff --git a/packages/app-tests/externalSqlFileStartup.test.ts b/packages/app-tests/externalSqlFileStartup.test.ts index f5603dd92..61777cd6d 100644 --- a/packages/app-tests/externalSqlFileStartup.test.ts +++ b/packages/app-tests/externalSqlFileStartup.test.ts @@ -61,7 +61,7 @@ test("cold-start SQL files wait for restored tabs before opening", () => { const openPathSource = appSource.slice(openPathStart, openPathEnd); const initializationWait = openPathSource.indexOf("await desktopOpenTabsRestorationBarrier?.settled"); - const fileRead = openPathSource.indexOf("api.readExternalSqlFile(path)"); + const fileRead = openPathSource.indexOf("api.readExternalSqlFileSnapshot(path)"); const tabOpen = openPathSource.indexOf("queryStore.openExternalSqlFile"); assert.ok(initializationWait >= 0); assert.ok(initializationWait < fileRead); diff --git a/packages/app-tests/externalSqlFileTargetPersistence.test.ts b/packages/app-tests/externalSqlFileTargetPersistence.test.ts index 6f7ce60fb..1596ac06e 100644 --- a/packages/app-tests/externalSqlFileTargetPersistence.test.ts +++ b/packages/app-tests/externalSqlFileTargetPersistence.test.ts @@ -4,6 +4,7 @@ import { test } from "vitest"; const appSource = readFileSync("apps/desktop/src/App.vue", "utf8"); const sqlFilePanelSource = readFileSync("apps/desktop/src/components/layout/SqlFilePanel.vue", "utf8"); +const externalSqlFileChangesSource = readFileSync("apps/desktop/src/composables/useExternalSqlFileChanges.ts", "utf8"); function functionSource(source: string, startMarker: string, endMarker: string) { const start = source.indexOf(startMarker); @@ -13,7 +14,7 @@ function functionSource(source: string, startMarker: string, endMarker: string) } test("external SQL saves persist their selected data source before closing", () => { - const source = functionSource(appSource, "async function saveExternalSqlPath", "function savedSqlTargetForSave"); + const source = functionSource(appSource, "async function writeExternalSqlTab", "async function saveExternalSqlPath"); const write = source.indexOf("api.writeExternalSqlFile"); const remember = source.indexOf("rememberExternalSqlFileTarget"); const close = source.indexOf("queryStore.closeTab"); @@ -39,3 +40,13 @@ test("external SQL open entry points restore a saved data source", () => { const pickerOpen = functionSource(appSource, "async function openSqlFile()", "async function importResultArchive"); assert.ok(pickerOpen.includes("applyExternalSqlFileTarget(tab, sqlPath)")); }); + +test("external SQL overwrite and recreate actions keep checked-write preconditions", () => { + const prepareSave = functionSource(externalSqlFileChangesSource, "async function prepareSave", "\n watch("); + assert.ok(prepareSave.includes("expectedContentHash: change.snapshot.version.contentHash")); + assert.ok(prepareSave.includes("expectedMissing: true")); + assert.equal(prepareSave.includes("force: true"), false); + + const setup = appSource.slice(appSource.indexOf("const externalSqlFileChanges = useExternalSqlFileChanges"), appSource.indexOf("const externalSqlFilePrompt")); + assert.ok(setup.includes("writeExternalSqlTab(tab, { expectedMissing: true })")); +}); diff --git a/src-tauri/src/commands/external_sql.rs b/src-tauri/src/commands/external_sql.rs index 822a67fd1..50720ab83 100644 --- a/src-tauri/src/commands/external_sql.rs +++ b/src-tauri/src/commands/external_sql.rs @@ -1,8 +1,10 @@ use std::path::{Path, PathBuf}; use std::sync::Mutex; +use std::time::UNIX_EPOCH; use dbx_core::sql::decode_sql_file_bytes; use serde::Serialize; +use sha2::{Digest, Sha256}; use tokio::io::AsyncReadExt; const MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES: u64 = 64 * 1024 * 1024; @@ -14,9 +16,39 @@ fn exceeds_external_sql_editor_limit(size_bytes: u64) -> bool { #[derive(Debug, Serialize, PartialEq, Eq)] #[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")] pub enum ExternalSqlFileReadResult { - Content { content: String }, + Content { content: String, version: ExternalSqlFileVersion }, TooLarge { size_bytes: u64, max_size_bytes: u64 }, } + +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ExternalSqlFileVersion { + pub size_bytes: u64, + pub modified_ns: String, + pub content_hash: String, +} + +#[derive(Debug, Serialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")] +pub enum ExternalSqlFileStatus { + Present { size_bytes: u64, modified_ns: String }, + Missing, +} + +#[derive(Debug, Serialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")] +pub enum ExternalSqlFileWriteResult { + Written { version: ExternalSqlFileVersion }, + Conflict { current_version: ExternalSqlFileVersion }, + Missing, +} + +#[derive(Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ExternalSqlFileSaveResult { + pub path: String, + pub version: ExternalSqlFileVersion, +} use tauri_plugin_dialog::DialogExt; #[tauri::command] @@ -33,8 +65,19 @@ pub async fn read_external_sql_file(path: String) -> Result Result<(), String> { - write_external_sql_file_content_async(PathBuf::from(path), content).await +pub async fn inspect_external_sql_file(path: String) -> Result { + inspect_external_sql_file_async(PathBuf::from(path)).await +} + +#[tauri::command] +pub async fn write_external_sql_file( + path: String, + content: String, + expected_content_hash: Option, + expected_missing: bool, +) -> Result { + write_external_sql_file_checked_async(PathBuf::from(path), content, expected_content_hash, expected_missing, false) + .await } #[tauri::command] @@ -42,7 +85,7 @@ pub async fn save_external_sql_file( window: tauri::Window, default_file_name: String, content: String, -) -> Result, String> { +) -> Result, String> { let (sender, receiver) = tokio::sync::oneshot::channel(); window.dialog().file().set_file_name(default_file_name).add_filter("SQL", &["sql"]).save_file(move |file_path| { let _ = sender.send(file_path); @@ -104,6 +147,28 @@ pub fn is_sql_file_path(path: &Path) -> bool { path.extension().and_then(|ext| ext.to_str()).map(|ext| ext.eq_ignore_ascii_case("sql")).unwrap_or(false) } +fn modified_ns(metadata: &std::fs::Metadata) -> String { + metadata + .modified() + .ok() + .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok()) + .map(|duration| duration.as_nanos()) + .unwrap_or_default() + .to_string() +} + +fn content_hash(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +fn external_sql_file_version(metadata: &std::fs::Metadata, bytes: &[u8]) -> ExternalSqlFileVersion { + ExternalSqlFileVersion { + size_bytes: metadata.len(), + modified_ns: modified_ns(metadata), + content_hash: content_hash(bytes), + } +} + #[cfg(test)] fn read_external_sql_file_content(path: &Path) -> Result { if !is_sql_file_path(path) { @@ -117,7 +182,8 @@ fn read_external_sql_file_content(path: &Path) -> Result Result { @@ -143,7 +209,21 @@ async fn read_external_sql_file_content_async(path: PathBuf) -> Result Result { + if !is_sql_file_path(&path) { + return Err("Only .sql files can be inspected this way".to_string()); + } + match tokio::fs::metadata(&path).await { + Ok(metadata) => { + Ok(ExternalSqlFileStatus::Present { size_bytes: metadata.len(), modified_ns: modified_ns(&metadata) }) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(ExternalSqlFileStatus::Missing), + Err(error) => Err(format!("Failed to inspect SQL file: {error}")), + } } #[cfg(test)] @@ -154,31 +234,98 @@ fn write_external_sql_file_content(path: &Path, content: &str) -> Result<(), Str std::fs::write(path, content).map_err(|e| format!("Failed to save SQL file: {e}")) } -async fn write_external_sql_file_content_async(path: PathBuf, content: String) -> Result<(), String> { +async fn external_sql_file_version_async(path: &Path) -> Result, String> { + let mut file = match tokio::fs::File::open(path).await { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(format!("Failed to read SQL file before saving: {error}")), + }; + let metadata = + file.metadata().await.map_err(|error| format!("Failed to inspect SQL file before saving: {error}"))?; + let mut digest = Sha256::new(); + let mut buffer = vec![0_u8; 64 * 1024]; + loop { + let read = + file.read(&mut buffer).await.map_err(|error| format!("Failed to read SQL file before saving: {error}"))?; + if read == 0 { + break; + } + digest.update(&buffer[..read]); + } + Ok(Some(ExternalSqlFileVersion { + size_bytes: metadata.len(), + modified_ns: modified_ns(&metadata), + content_hash: format!("{:x}", digest.finalize()), + })) +} + +async fn write_external_sql_file_checked_async( + path: PathBuf, + content: String, + expected_content_hash: Option, + expected_missing: bool, + force: bool, +) -> Result { if !is_sql_file_path(&path) { return Err("Only .sql files can be saved this way".to_string()); } - tokio::fs::write(&path, content).await.map_err(|e| format!("Failed to save SQL file: {e}")) + + if !force { + match external_sql_file_version_async(&path).await? { + Some(current_version) => { + if expected_missing + || expected_content_hash.as_deref().is_some_and(|expected| expected != current_version.content_hash) + { + return Ok(ExternalSqlFileWriteResult::Conflict { current_version }); + } + } + None if expected_content_hash.is_some() => return Ok(ExternalSqlFileWriteResult::Missing), + None => {} + } + } + + let hash = content_hash(content.as_bytes()); + tokio::fs::write(&path, content).await.map_err(|error| format!("Failed to save SQL file: {error}"))?; + let metadata = + tokio::fs::metadata(&path).await.map_err(|error| format!("Failed to inspect saved SQL file: {error}"))?; + Ok(ExternalSqlFileWriteResult::Written { + version: ExternalSqlFileVersion { + size_bytes: metadata.len(), + modified_ns: modified_ns(&metadata), + content_hash: hash, + }, + }) } async fn save_external_sql_file_content_async( path: Option, content: String, -) -> Result, String> { +) -> Result, String> { let Some(path) = path else { return Ok(None); }; - write_external_sql_file_content_async(path.clone(), content).await?; - Ok(Some(path.to_string_lossy().into_owned())) + let result = write_external_sql_file_checked_async(path.clone(), content, None, false, true).await?; + let ExternalSqlFileWriteResult::Written { version } = result else { + return Err("Failed to save SQL file".to_string()); + }; + Ok(Some(ExternalSqlFileSaveResult { path: path.to_string_lossy().into_owned(), version })) } #[cfg(test)] -fn save_external_sql_file_content(path: Option<&Path>, content: &str) -> Result, String> { +fn save_external_sql_file_content( + path: Option<&Path>, + content: &str, +) -> Result, String> { let Some(path) = path else { return Ok(None); }; write_external_sql_file_content(path, content)?; - Ok(Some(path.to_string_lossy().into_owned())) + let bytes = std::fs::read(path).map_err(|error| format!("Failed to read saved SQL file: {error}"))?; + let metadata = std::fs::metadata(path).map_err(|error| format!("Failed to inspect saved SQL file: {error}"))?; + Ok(Some(ExternalSqlFileSaveResult { + path: path.to_string_lossy().into_owned(), + version: external_sql_file_version(&metadata, &bytes), + })) } fn dedupe_paths(paths: Vec) -> Vec { @@ -226,7 +373,12 @@ mod tests { let result = read_external_sql_file_content(&path); let _ = std::fs::remove_file(&path); - assert_eq!(result.unwrap(), ExternalSqlFileReadResult::Content { content: "select 1;".to_string() }); + let ExternalSqlFileReadResult::Content { content, version } = result.unwrap() else { + panic!("expected SQL file content"); + }; + assert_eq!(content, "select 1;"); + assert_eq!(version.size_bytes, 9); + assert_eq!(version.content_hash, content_hash(b"select 1;")); } #[test] @@ -237,7 +389,11 @@ mod tests { let result = read_external_sql_file_content(&path); let _ = std::fs::remove_file(&path); - assert_eq!(result.unwrap(), ExternalSqlFileReadResult::Content { content: "select '中文';".to_string() }); + let ExternalSqlFileReadResult::Content { content, version } = result.unwrap() else { + panic!("expected SQL file content"); + }; + assert_eq!(content, "select '中文';"); + assert_eq!(version.content_hash, content_hash(b"select '\xD6\xD0\xCE\xC4';")); } #[test] @@ -317,6 +473,76 @@ mod tests { assert_eq!(content, "select 2;"); } + #[tokio::test] + async fn inspects_present_and_missing_external_sql_files() { + let path = std::env::temp_dir().join(format!("dbx-test-{}.sql", uuid::Uuid::new_v4())); + std::fs::write(&path, "select 1;").unwrap(); + + let present = inspect_external_sql_file_async(path.clone()).await.unwrap(); + assert!(matches!(present, ExternalSqlFileStatus::Present { size_bytes: 9, .. })); + + std::fs::remove_file(&path).unwrap(); + assert_eq!(inspect_external_sql_file_async(path).await.unwrap(), ExternalSqlFileStatus::Missing); + } + + #[tokio::test] + async fn checked_write_rejects_external_content_conflicts() { + let path = std::env::temp_dir().join(format!("dbx-test-{}.sql", uuid::Uuid::new_v4())); + std::fs::write(&path, "select 2;").unwrap(); + + let result = write_external_sql_file_checked_async( + path.clone(), + "select 3;".to_string(), + Some(content_hash(b"select 1;")), + false, + false, + ) + .await + .unwrap(); + + assert!(matches!(result, ExternalSqlFileWriteResult::Conflict { .. })); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "select 2;"); + let _ = std::fs::remove_file(&path); + } + + #[tokio::test] + async fn checked_write_reports_missing_and_can_recreate_file() { + let path = std::env::temp_dir().join(format!("dbx-test-{}.sql", uuid::Uuid::new_v4())); + + let missing = write_external_sql_file_checked_async( + path.clone(), + "select 2;".to_string(), + Some(content_hash(b"select 1;")), + false, + false, + ) + .await + .unwrap(); + assert_eq!(missing, ExternalSqlFileWriteResult::Missing); + + let recreated = write_external_sql_file_checked_async(path.clone(), "select 2;".to_string(), None, true, false) + .await + .unwrap(); + assert!(matches!(recreated, ExternalSqlFileWriteResult::Written { .. })); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "select 2;"); + let _ = std::fs::remove_file(&path); + } + + #[tokio::test] + async fn checked_write_does_not_overwrite_a_file_recreated_after_confirmation() { + let path = std::env::temp_dir().join(format!("dbx-test-{}.sql", uuid::Uuid::new_v4())); + std::fs::write(&path, "select external;").unwrap(); + + let result = + write_external_sql_file_checked_async(path.clone(), "select editor;".to_string(), None, true, false) + .await + .unwrap(); + + assert!(matches!(result, ExternalSqlFileWriteResult::Conflict { .. })); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "select external;"); + let _ = std::fs::remove_file(&path); + } + #[test] fn saves_external_sql_file_with_unicode_name() { let path = std::env::temp_dir().join(format!("查询-{}.sql", uuid::Uuid::new_v4())); @@ -325,7 +551,7 @@ mod tests { let content = std::fs::read_to_string(&path).unwrap(); let _ = std::fs::remove_file(&path); - assert_eq!(result.unwrap(), Some(path.to_string_lossy().into_owned())); + assert_eq!(result.unwrap().unwrap().path, path.to_string_lossy()); assert_eq!(content, "select 3;"); } @@ -336,7 +562,7 @@ mod tests { let result = save_external_sql_file_content(Some(&path), "select 4;"); let _ = std::fs::remove_file(&path); - assert_eq!(result.unwrap(), Some(path.to_string_lossy().into_owned())); + assert_eq!(result.unwrap().unwrap().path, path.to_string_lossy()); } #[test] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0e376deb9..9045512e2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1675,6 +1675,7 @@ pub fn run() { commands::sql_file::cancel_sql_file_execution, commands::external_sql::pending_open_sql_files, commands::external_sql::read_external_sql_file, + commands::external_sql::inspect_external_sql_file, commands::external_sql::write_external_sql_file, commands::external_sql::save_external_sql_file, commands::list_sql_files::list_sql_files_in_folder,