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 { useSettingsStore } from "@/stores/settingsStore"; import { externalSqlEditorMaxBytes } from "@/lib/sql/sqlFileOpen"; 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 settingsStore = useSettingsStore(); 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) { const maxSizeBytes = externalSqlEditorMaxBytes(settingsStore.editorSettings.externalSqlEditorMaxMb); try { return await api.readExternalSqlFileSnapshot(path, maxSizeBytes); } catch (firstError) { await delay(MISSING_FILE_RECHECK_DELAY_MS); try { return await api.readExternalSqlFileSnapshot(path, maxSizeBytes); } 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, }; }