fix(editor): protect external SQL files from conflicting changes
This commit is contained in:
parent
575d87c022
commit
89a35d6e02
|
|
@ -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<boolean> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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"
|
||||
/>
|
||||
<ExternalSqlFileChangeDialog :prompt="externalSqlFilePrompt" @decide="externalSqlFileChanges.resolvePrompt" />
|
||||
<CloseActionPromptDialog v-if="isDesktop && showCloseActionPrompt" :open="showCloseActionPrompt" @update:open="handleCloseActionPromptOpenChange" @quit="chooseQuit" @minimize="chooseMinimize" />
|
||||
<QuickOpenDialog :open="showQuickOpen" @update:open="showQuickOpen = $event" @select="handleQuickOpenSelect" />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { AlertTriangle, FileWarning } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import type { ExternalSqlFilePrompt, ExternalSqlFilePromptDecision } from "@/composables/useExternalSqlFileChanges";
|
||||
|
||||
const props = defineProps<{
|
||||
prompt: ExternalSqlFilePrompt | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
decide: [decision: ExternalSqlFilePromptDecision];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const isOpen = computed(() => props.prompt !== null);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="isOpen">
|
||||
<DialogContent :show-close-button="false" class="sm:max-w-[560px]" @interact-outside.prevent @escape-key-down.prevent>
|
||||
<template v-if="prompt">
|
||||
<DialogHeader>
|
||||
<div class="flex items-start gap-3 pr-2">
|
||||
<div class="mt-0.5 rounded-md bg-amber-500/12 p-2 text-amber-600 dark:text-amber-400">
|
||||
<FileWarning v-if="prompt.kind === 'deleted'" class="h-5 w-5" />
|
||||
<AlertTriangle v-else class="h-5 w-5" />
|
||||
</div>
|
||||
<div class="min-w-0 space-y-1 text-left">
|
||||
<DialogTitle>{{ t(prompt.kind === "modified" ? "externalSqlFile.modifiedTitle" : "externalSqlFile.deletedTitle") }}</DialogTitle>
|
||||
<DialogDescription class="break-all">
|
||||
{{ t(prompt.kind === "modified" ? "externalSqlFile.modifiedDescription" : "externalSqlFile.deletedDescription", { path: prompt.path }) }}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<p v-if="prompt.dirty" class="rounded-md border border-amber-500/30 bg-amber-500/8 px-3 py-2 text-xs text-amber-800 dark:text-amber-200">
|
||||
{{ t(prompt.kind === "deleted" ? "externalSqlFile.deletedUnsavedWarning" : "externalSqlFile.unsavedWarning") }}
|
||||
</p>
|
||||
|
||||
<DialogFooter v-if="prompt.kind === 'modified' && prompt.context === 'reload'" class="gap-2 sm:gap-2">
|
||||
<Button variant="outline" @click="emit('decide', 'keep')">{{ t("externalSqlFile.keepEditor") }}</Button>
|
||||
<Button @click="emit('decide', 'load')">{{ t("externalSqlFile.loadLatest") }}</Button>
|
||||
</DialogFooter>
|
||||
|
||||
<DialogFooter v-else-if="prompt.kind === 'modified'" class="flex-wrap gap-2 sm:gap-2">
|
||||
<Button variant="outline" @click="emit('decide', 'cancel')">{{ t("externalSqlFile.cancelSave") }}</Button>
|
||||
<Button variant="outline" @click="emit('decide', 'load')">{{ t("externalSqlFile.loadExternal") }}</Button>
|
||||
<Button variant="destructive" @click="emit('decide', 'overwrite')">{{ t("externalSqlFile.overwriteSave") }}</Button>
|
||||
</DialogFooter>
|
||||
|
||||
<DialogFooter v-else class="flex-wrap gap-2 sm:gap-2">
|
||||
<Button variant="ghost" @click="emit('decide', 'close')">{{ t("externalSqlFile.closeTab") }}</Button>
|
||||
<Button variant="outline" @click="emit('decide', 'keep')">{{ t("externalSqlFile.keepEditing") }}</Button>
|
||||
<Button variant="outline" @click="emit('decide', 'saveAs')">{{ t("externalSqlFile.saveAs") }}</Button>
|
||||
<Button @click="emit('decide', 'recreate')">{{ t("externalSqlFile.recreate") }}</Button>
|
||||
</DialogFooter>
|
||||
</template>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
|
@ -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)"
|
||||
>
|
||||
<span class="shrink-0" :class="tabIconClass(tab)">
|
||||
<Table2 v-if="tab.mode === 'data' || tab.mode === 'mongo' || tab.mode === 'redis' || tab.mode === 'hbase'" class="h-3.5 w-3.5" />
|
||||
<AlertTriangle v-if="tab.externalSqlFileMissing" class="h-3.5 w-3.5" />
|
||||
<Table2 v-else-if="tab.mode === 'data' || tab.mode === 'mongo' || tab.mode === 'redis' || tab.mode === 'hbase'" class="h-3.5 w-3.5" />
|
||||
<DatabaseIcon v-else-if="tab.mode === 'mq'" :db-type="tabDatabaseIconType(tab)" class="h-3.5 w-3.5" />
|
||||
<TableProperties v-else-if="tab.mode === 'vector'" class="h-3.5 w-3.5" />
|
||||
<KeyRound v-else-if="tab.mode === 'etcd' || tab.mode === 'zookeeper'" class="h-3.5 w-3.5" />
|
||||
|
|
@ -858,7 +861,8 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul
|
|||
@mouseleave="tabDrag.clearTarget(tab.id)"
|
||||
>
|
||||
<span class="shrink-0" :class="tabIconClass(tab)">
|
||||
<Table2 v-if="tab.mode === 'data' || tab.mode === 'mongo' || tab.mode === 'redis' || tab.mode === 'hbase'" class="h-3.5 w-3.5" />
|
||||
<AlertTriangle v-if="tab.externalSqlFileMissing" class="h-3.5 w-3.5" />
|
||||
<Table2 v-else-if="tab.mode === 'data' || tab.mode === 'mongo' || tab.mode === 'redis' || tab.mode === 'hbase'" class="h-3.5 w-3.5" />
|
||||
<DatabaseIcon v-else-if="tab.mode === 'mq'" :db-type="tabDatabaseIconType(tab)" class="h-3.5 w-3.5" />
|
||||
<TableProperties v-else-if="tab.mode === 'vector'" class="h-3.5 w-3.5" />
|
||||
<KeyRound v-else-if="tab.mode === 'etcd' || tab.mode === 'zookeeper'" class="h-3.5 w-3.5" />
|
||||
|
|
|
|||
|
|
@ -208,12 +208,12 @@ function collectDirPaths(entries: SqlFileEntry[], into: Set<string>) {
|
|||
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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:",
|
||||
|
|
|
|||
|
|
@ -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:",
|
||||
|
|
|
|||
|
|
@ -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:",
|
||||
|
|
|
|||
|
|
@ -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: "データベース:",
|
||||
|
|
|
|||
|
|
@ -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: "데이터베이스:",
|
||||
|
|
|
|||
|
|
@ -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:",
|
||||
|
|
|
|||
|
|
@ -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: "数据库:",
|
||||
|
|
|
|||
|
|
@ -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: "資料庫:",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 } : {}),
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -1864,11 +1864,19 @@ export async function readExternalSqlFile(_path: string): Promise<string> {
|
|||
throw new Error("Opening external SQL file paths is only available in the desktop app");
|
||||
}
|
||||
|
||||
export async function writeExternalSqlFile(_path: string, _content: string): Promise<void> {
|
||||
export async function readExternalSqlFileSnapshot(_path: string): Promise<import("@/lib/backend/tauri").ExternalSqlFileSnapshot> {
|
||||
throw new Error("Opening external SQL file paths is only available in the desktop app");
|
||||
}
|
||||
|
||||
export async function inspectExternalSqlFile(_path: string): Promise<import("@/lib/backend/tauri").ExternalSqlFileStatus> {
|
||||
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<import("@/lib/backend/tauri").ExternalSqlFileWriteResult> {
|
||||
throw new Error("Saving external SQL file paths is only available in the desktop app");
|
||||
}
|
||||
|
||||
export async function saveExternalSqlFile(_defaultFileName: string, _content: string): Promise<string | null> {
|
||||
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");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string[]> {
|
|||
return invoke("pending_open_connection_links");
|
||||
}
|
||||
|
||||
export async function readExternalSqlFile(path: string): Promise<string> {
|
||||
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<ExternalSqlFileSnapshot> {
|
||||
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<void> {
|
||||
return invoke("write_external_sql_file", { path, content });
|
||||
export async function readExternalSqlFile(path: string): Promise<string> {
|
||||
return (await readExternalSqlFileSnapshot(path)).content;
|
||||
}
|
||||
|
||||
export async function saveExternalSqlFile(defaultFileName: string, content: string): Promise<string | null> {
|
||||
export async function inspectExternalSqlFile(path: string): Promise<ExternalSqlFileStatus> {
|
||||
return invoke("inspect_external_sql_file", { path });
|
||||
}
|
||||
|
||||
export async function writeExternalSqlFile(path: string, content: string, options: { expectedContentHash?: string; expectedMissing?: boolean } = {}): Promise<ExternalSqlFileWriteResult> {
|
||||
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 });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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<QueryTab["externalSqlFileVersion"]>) {
|
||||
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<QueryTab["externalSqlFileVersion"]>) {
|
||||
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<QueryTab["externalSqlFileVersion"]>) {
|
||||
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<QueryTab["externalSqlFileVersion"]>) {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 })"));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<ExternalSqlFileReadR
|
|||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn write_external_sql_file(path: String, content: String) -> Result<(), String> {
|
||||
write_external_sql_file_content_async(PathBuf::from(path), content).await
|
||||
pub async fn inspect_external_sql_file(path: String) -> Result<ExternalSqlFileStatus, String> {
|
||||
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<String>,
|
||||
expected_missing: bool,
|
||||
) -> Result<ExternalSqlFileWriteResult, String> {
|
||||
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<Option<String>, String> {
|
||||
) -> Result<Option<ExternalSqlFileSaveResult>, 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<ExternalSqlFileReadResult, String> {
|
||||
if !is_sql_file_path(path) {
|
||||
|
|
@ -117,7 +182,8 @@ fn read_external_sql_file_content(path: &Path) -> Result<ExternalSqlFileReadResu
|
|||
});
|
||||
}
|
||||
let bytes = std::fs::read(path).map_err(|e| format!("Failed to read SQL file: {e}"))?;
|
||||
decode_sql_file_bytes(&bytes).map(|content| ExternalSqlFileReadResult::Content { content })
|
||||
let version = external_sql_file_version(&metadata, &bytes);
|
||||
decode_sql_file_bytes(&bytes).map(|content| ExternalSqlFileReadResult::Content { content, version })
|
||||
}
|
||||
|
||||
async fn read_external_sql_file_content_async(path: PathBuf) -> Result<ExternalSqlFileReadResult, String> {
|
||||
|
|
@ -143,7 +209,21 @@ async fn read_external_sql_file_content_async(path: PathBuf) -> Result<ExternalS
|
|||
max_size_bytes: MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES,
|
||||
});
|
||||
}
|
||||
decode_sql_file_bytes(&bytes).map(|content| ExternalSqlFileReadResult::Content { content })
|
||||
let version = external_sql_file_version(&metadata, &bytes);
|
||||
decode_sql_file_bytes(&bytes).map(|content| ExternalSqlFileReadResult::Content { content, version })
|
||||
}
|
||||
|
||||
async fn inspect_external_sql_file_async(path: PathBuf) -> Result<ExternalSqlFileStatus, String> {
|
||||
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<Option<ExternalSqlFileVersion>, 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<String>,
|
||||
expected_missing: bool,
|
||||
force: bool,
|
||||
) -> Result<ExternalSqlFileWriteResult, String> {
|
||||
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<PathBuf>,
|
||||
content: String,
|
||||
) -> Result<Option<String>, String> {
|
||||
) -> Result<Option<ExternalSqlFileSaveResult>, 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<Option<String>, String> {
|
||||
fn save_external_sql_file_content(
|
||||
path: Option<&Path>,
|
||||
content: &str,
|
||||
) -> Result<Option<ExternalSqlFileSaveResult>, 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<String>) -> Vec<String> {
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue