fix(editor): protect external SQL files from conflicting changes
This commit is contained in:
29 files changed
+1125
-65
No files matched your search
@@ -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<QueryTab | undefined>;
|
||||
recreateFile: (tab: QueryTab) => Promise<boolean>;
|
||||
saveAsFile: (tab: QueryTab) => Promise<boolean>;
|
||||
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<void>((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export function useExternalSqlFileChanges(options: UseExternalSqlFileChangesOptions) {
|
||||
const { t } = useI18n();
|
||||
const queryStore = useQueryStore();
|
||||
const pendingPrompt = ref<ExternalSqlFilePrompt | null>(null);
|
||||
const checkingTabIds = new Set<string>();
|
||||
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<DetectedExternalSqlFileChange | null> {
|
||||
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<ExternalSqlFilePromptDecision> {
|
||||
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<ExternalSqlFileSavePreparation> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user