fix(editor): protect external SQL files from conflicting changes

This commit is contained in:
zipg authored and GitHub committed 2026-08-06 20:13:59 +08:00
1 parent 575d87c022
commit 89a35d6e02
29 files changed
+1125 -65

No files matched your search

@@ -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);