Merge pull request #49 from SuLea-IT/codex/sql-file-execution

Add SQL file execution workflow
This commit is contained in:
skyler 2026-05-02 09:47:33 +08:00 committed by GitHub
commit 8b153bef0d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 1914 additions and 22 deletions

View File

@ -7,5 +7,6 @@ pub mod query;
pub mod query_cancel;
pub mod redis_cmd;
pub mod schema;
pub mod sql_file;
pub mod transfer;
pub mod update;

View File

@ -90,6 +90,17 @@ async fn wait_for_query<F>(
cancel_token: Option<CancellationToken>,
future: F,
) -> Result<db::QueryResult, String>
where
F: Future<Output = Result<db::QueryResult, String>>,
{
wait_for_query_with_timeout(cancel_token, QUERY_TIMEOUT, future).await
}
async fn wait_for_query_with_timeout<F>(
cancel_token: Option<CancellationToken>,
timeout_duration: Duration,
future: F,
) -> Result<db::QueryResult, String>
where
F: Future<Output = Result<db::QueryResult, String>>,
{
@ -97,10 +108,10 @@ where
tokio::select! {
biased;
_ = token.cancelled() => Err(canceled_error()),
result = timeout(QUERY_TIMEOUT, future) => result.map_err(|_| timeout_error())?,
result = timeout(timeout_duration, future) => result.map_err(|_| timeout_error())?,
}
} else {
timeout(QUERY_TIMEOUT, future)
timeout(timeout_duration, future)
.await
.map_err(|_| timeout_error())?
}
@ -194,6 +205,35 @@ async fn do_execute(
}
}
pub(super) async fn execute_sql_statement(
state: &AppState,
connection_id: &str,
database: &str,
sql: &str,
cancel_token: Option<CancellationToken>,
) -> Result<db::QueryResult, String> {
let pool_key = if database.is_empty() {
connection_id.to_string()
} else {
state.get_or_create_pool(connection_id, Some(database)).await?
};
if is_canceled(&cancel_token) {
return Err(canceled_error());
}
let result = do_execute(state, &pool_key, sql, cancel_token.clone()).await;
match &result {
Err(e) if is_connection_error(e) && !is_canceled(&cancel_token) => {
let db_opt = if database.is_empty() { None } else { Some(database) };
let new_key = state.reconnect_pool(connection_id, db_opt).await?;
do_execute(state, &new_key, sql, cancel_token).await
}
_ => result,
}
}
#[tauri::command]
pub async fn execute_query(
state: State<'_, Arc<AppState>>,
@ -208,26 +248,16 @@ pub async fn execute_query(
.map(|id| state.running_queries.register(id.clone()));
let cancel_token = registered_query.as_ref().map(|query| query.token());
let pool_key = if database.is_empty() {
connection_id.clone()
} else {
state.get_or_create_pool(&connection_id, Some(&database)).await?
};
let result = execute_sql_statement(
&state,
&connection_id,
&database,
&sql,
cancel_token,
)
.await;
if is_canceled(&cancel_token) {
return Err(canceled_error());
}
let result = do_execute(&state, &pool_key, &sql, cancel_token.clone()).await;
match &result {
Err(e) if is_connection_error(e) && !is_canceled(&cancel_token) => {
let db_opt = if database.is_empty() { None } else { Some(database.as_str()) };
let new_key = state.reconnect_pool(&connection_id, db_opt).await?;
do_execute(&state, &new_key, &sql, cancel_token).await
}
_ => result,
}
result
}
#[tauri::command]
@ -304,4 +334,21 @@ mod tests {
assert_eq!(result.unwrap_err(), QUERY_CANCELED);
}
#[tokio::test]
async fn wait_for_query_without_token_still_times_out() {
let result = wait_for_query_with_timeout(None, Duration::from_millis(10), async {
tokio::time::sleep(Duration::from_secs(1)).await;
Ok(db::QueryResult {
columns: vec![],
rows: vec![],
affected_rows: 0,
execution_time_ms: 0,
truncated: false,
})
})
.await;
assert_eq!(result.unwrap_err(), timeout_error());
}
}

File diff suppressed because it is too large Load Diff

View File

@ -61,6 +61,9 @@ pub fn run() {
commands::query::execute_query,
commands::query::cancel_query,
commands::query::execute_batch,
commands::sql_file::preview_sql_file,
commands::sql_file::execute_sql_file,
commands::sql_file::cancel_sql_file_execution,
commands::redis_cmd::redis_list_databases,
commands::redis_cmd::redis_scan_keys,
commands::redis_cmd::redis_get_value,

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, nextTick, type Ref } from "vue";
import { useI18n } from "vue-i18n";
import { DatabaseZap, FilePlus2, Play, Loader2, Square, X, Globe, Moon, Sun, Upload, Download, Plus, History, Server, Table2, Database, Search, ShieldCheck, Bot, Pin, AlignLeft, CloudDownload, ArrowLeftRight } from "lucide-vue-next";
import { DatabaseZap, FilePlus2, Play, Loader2, Square, X, Globe, Moon, Sun, Upload, Download, Plus, History, Server, Table2, Database, Search, ShieldCheck, Bot, Pin, AlignLeft, CloudDownload, ArrowLeftRight, FileCode } from "lucide-vue-next";
import { Splitpanes, Pane } from "splitpanes";
import "splitpanes/dist/splitpanes.css";
import { Button } from "@/components/ui/button";
@ -35,6 +35,7 @@ import QueryHistory from "@/components/editor/QueryHistory.vue";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import DataTransferDialog from "@/components/transfer/DataTransferDialog.vue";
import SchemaDiffDialog from "@/components/diff/SchemaDiffDialog.vue";
import SqlFileExecutionDialog from "@/components/sql-file/SqlFileExecutionDialog.vue";
import type { ConnectionConfig } from "@/types/database";
import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
@ -115,10 +116,13 @@ const formatSqlRequestId = ref(0);
const showDangerDialog = ref(false);
const showTransferDialog = ref(false);
const showSchemaDiffDialog = ref(false);
const showSqlFileDialog = ref(false);
const transferPrefillConnectionId = ref("");
const transferPrefillDatabase = ref("");
const schemaDiffPrefillConnectionId = ref("");
const schemaDiffPrefillDatabase = ref("");
const sqlFilePrefillConnectionId = ref("");
const sqlFilePrefillDatabase = ref("");
const databaseOptions = ref<Record<string, string[]>>({});
const loadingDatabaseOptions = ref<Record<string, boolean>>({});
const checkingUpdates = ref(false);
@ -126,6 +130,11 @@ const updateInfo = ref<api.UpdateInfo | null>(null);
const updateCheckMessage = ref("");
const appVersion = ref("");
const latestReleaseUrl = "https://github.com/t8y2/dbx/releases/latest";
const sqlFileUnsupportedTypes = new Set(["redis", "mongodb", "elasticsearch"]);
const hasSqlFileConnections = computed(() =>
connectionStore.connections.some((connection) => !sqlFileUnsupportedTypes.has(connection.db_type))
);
const editConfig = computed(() => {
const id = connectionStore.editingConnectionId;
@ -159,6 +168,15 @@ watch(() => connectionStore.schemaDiffSource, (v) => {
}
});
watch(() => connectionStore.sqlFileSource, (v) => {
if (v) {
sqlFilePrefillConnectionId.value = v.connectionId;
sqlFilePrefillDatabase.value = v.database;
showSqlFileDialog.value = true;
connectionStore.sqlFileSource = null;
}
});
function onConnectionConnectStarted(name: string) {
toast(t("connection.connecting", { name }), 30000);
}
@ -705,6 +723,11 @@ async function setupFileDrop() {
{{ t('transfer.dataTransfer') }}
</Button>
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs gap-1" @click="showSqlFileDialog = true" :disabled="!hasSqlFileConnections">
<FileCode class="h-3.5 w-3.5" />
{{ t('sqlFile.title') }}
</Button>
<div class="flex-1" />
<Tooltip>
@ -1182,6 +1205,11 @@ async function setupFileDrop() {
:prefill-connection-id="schemaDiffPrefillConnectionId"
:prefill-database="schemaDiffPrefillDatabase"
/>
<SqlFileExecutionDialog
v-model:open="showSqlFileDialog"
:prefill-connection-id="sqlFilePrefillConnectionId"
:prefill-database="sqlFilePrefillDatabase"
/>
<Dialog v-model:open="showUpdateDialog">
<DialogContent class="sm:max-w-[520px]">
<DialogHeader>

View File

@ -33,6 +33,8 @@ const props = defineProps<{
depth: number;
}>();
const sqlFileUnsupportedTypes = new Set(["redis", "mongodb", "elasticsearch"]);
function quoteIdent(name: string): string {
const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined;
return config?.db_type === "mysql"
@ -337,8 +339,21 @@ function openSchemaDiff() {
}
}
function openSqlFileExecution() {
if (props.node.connectionId) {
connectionStore.sqlFileSource = {
connectionId: props.node.connectionId,
database: props.node.database ?? "",
};
}
}
const canExpand = !leafTypes.has(props.node.type);
const canPin = computed(() => pinnableTypes.has(props.node.type));
const canOpenSqlFileExecution = computed(() => {
const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined;
return !!config && !sqlFileUnsupportedTypes.has(config.db_type);
});
const isPinned = computed(() => props.node.pinned || connectionStore.isTreeNodePinned(props.node.id));
const hasTypeMenu = computed(() => {
const t = props.node.type;
@ -448,6 +463,9 @@ async function showMore() {
<ContextMenuItem @click="newQuery">
<TerminalSquare class="w-4 h-4" /> {{ t('contextMenu.newQuery') }}
</ContextMenuItem>
<ContextMenuItem v-if="canOpenSqlFileExecution" @click="openSqlFileExecution">
<FileCode class="w-4 h-4" /> {{ t('sqlFile.title') }}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem @click="refresh">
<RefreshCw class="w-4 h-4" /> {{ t('contextMenu.refreshChildren') }}
@ -465,6 +483,9 @@ async function showMore() {
<ContextMenuItem @click="newQuery">
<TerminalSquare class="w-4 h-4" /> {{ t('contextMenu.newQuery') }}
</ContextMenuItem>
<ContextMenuItem v-if="canOpenSqlFileExecution" @click="openSqlFileExecution">
<FileCode class="w-4 h-4" /> {{ t('sqlFile.title') }}
</ContextMenuItem>
<ContextMenuItem @click="refresh">
<RefreshCw class="w-4 h-4" /> {{ t('contextMenu.refreshChildren') }}
</ContextMenuItem>

View File

@ -0,0 +1,520 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { open as openDialog } from "@tauri-apps/plugin-dialog";
import { useI18n } from "vue-i18n";
import {
Dialog, DialogFooter, DialogHeader, DialogScrollContent, DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import { useToast } from "@/composables/useToast";
import { useConnectionStore } from "@/stores/connectionStore";
import {
cancelSqlFileExecution,
executeSqlFile,
listenSqlFileProgress,
listDatabases,
previewSqlFile,
type SqlFilePreview,
type SqlFileProgress,
type SqlFileStatus,
} from "@/lib/tauri";
import { Check, CheckSquare, FileCode, FolderOpen, Loader2, Play, Square, X } from "lucide-vue-next";
const { t } = useI18n();
const { toast } = useToast();
const open = defineModel<boolean>("open", { default: false });
const props = defineProps<{
prefillConnectionId?: string;
prefillDatabase?: string;
}>();
const store = useConnectionStore();
const filePath = ref("");
const preview = ref<SqlFilePreview | null>(null);
const selectingFile = ref(false);
const loadingPreview = ref(false);
const connectionId = ref("");
const database = ref("");
const databaseOptions = ref<string[]>([]);
const loadingDatabases = ref(false);
const continueOnError = ref(false);
const running = ref(false);
const cancelling = ref(false);
const cancelRequested = ref(false);
const executionStarted = ref(false);
const executionId = ref("");
const progress = ref<SqlFileProgress | null>(null);
const terminalStatus = ref<SqlFileStatus | "idle">("idle");
const terminalError = ref("");
const sqlConnections = computed(() =>
store.connections.filter((c) =>
!["redis", "mongodb", "elasticsearch"].includes(c.db_type),
),
);
const selectedConnection = computed(() =>
sqlConnections.value.find((c) => c.id === connectionId.value),
);
const canStart = computed(() =>
Boolean(preview.value && selectedConnection.value && database.value.trim() && !running.value && !loadingPreview.value && !loadingDatabases.value),
);
const statusTone = computed(() => {
if (terminalStatus.value === "done") return "text-green-600";
if (terminalStatus.value === "error") return "text-destructive";
if (terminalStatus.value === "cancelled") return "text-yellow-600";
if (running.value) return "text-primary";
return "text-muted-foreground";
});
const statusIcon = computed(() => {
if (running.value) return Loader2;
if (terminalStatus.value === "done") return Check;
if (terminalStatus.value === "error" || terminalStatus.value === "cancelled") return X;
return FileCode;
});
const progressPercent = computed(() => {
if (!progress.value) return 0;
if (terminalStatus.value === "done") return 100;
const attempted = progress.value.successCount + progress.value.failureCount;
const current = Math.max(progress.value.statementIndex, attempted);
if (current <= 0) return running.value ? 8 : 0;
return Math.min(95, Math.max(8, Math.round((attempted / current) * 100)));
});
function connectionIconType(id: string) {
const config = store.getConfig(id);
return config?.driver_profile || config?.db_type || "mysql";
}
function formatBytes(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
const units = ["KB", "MB", "GB"];
let value = bytes / 1024;
let unit = units[0];
for (let i = 1; i < units.length && value >= 1024; i += 1) {
value /= 1024;
unit = units[i];
}
return `${value >= 10 ? value.toFixed(1) : value.toFixed(2)} ${unit}`;
}
function formatElapsed(ms: number) {
if (ms < 1000) return `${ms} ms`;
const seconds = ms / 1000;
if (seconds < 60) return `${seconds.toFixed(1)} s`;
const minutes = Math.floor(seconds / 60);
return `${minutes}m ${Math.round(seconds % 60)}s`;
}
function statusLabel(status: SqlFileStatus | "idle") {
return t(`sqlFile.status.${status}`);
}
function isTerminalStatus(status: SqlFileStatus | "idle") {
return status === "done" || status === "error" || status === "cancelled";
}
function resolveInitialConnectionId() {
if (props.prefillConnectionId && sqlConnections.value.some((c) => c.id === props.prefillConnectionId)) {
return props.prefillConnectionId;
}
return sqlConnections.value[0]?.id ?? "";
}
function chooseDatabase(names: string[], id: string) {
const configDatabase = store.getConfig(id)?.database ?? "";
if (names.length > 0) {
if (props.prefillDatabase && names.includes(props.prefillDatabase)) return props.prefillDatabase;
if (configDatabase && names.includes(configDatabase)) return configDatabase;
return names.length === 1 ? names[0] : "";
}
return props.prefillDatabase ?? configDatabase;
}
function resetExecution() {
running.value = false;
cancelling.value = false;
cancelRequested.value = false;
executionStarted.value = false;
executionId.value = "";
progress.value = null;
terminalStatus.value = "idle";
terminalError.value = "";
}
function resetState() {
filePath.value = "";
preview.value = null;
selectingFile.value = false;
loadingPreview.value = false;
connectionId.value = resolveInitialConnectionId();
database.value = "";
databaseOptions.value = [];
loadingDatabases.value = false;
continueOnError.value = false;
resetExecution();
}
let databaseLoadToken = 0;
async function loadDatabasesForConnection(id: string) {
const token = databaseLoadToken + 1;
databaseLoadToken = token;
databaseOptions.value = [];
if (!sqlConnections.value.some((c) => c.id === id)) {
database.value = "";
return;
}
loadingDatabases.value = true;
try {
await store.ensureConnected(id);
const names = (await listDatabases(id)).map((db) => db.name);
if (token !== databaseLoadToken) return;
databaseOptions.value = names;
database.value = chooseDatabase(names, id);
} catch {
if (token !== databaseLoadToken) return;
databaseOptions.value = [];
database.value = chooseDatabase([], id);
} finally {
if (token === databaseLoadToken) {
loadingDatabases.value = false;
}
}
}
async function loadPreview(path: string) {
loadingPreview.value = true;
preview.value = null;
try {
preview.value = await previewSqlFile(path);
filePath.value = preview.value.filePath;
resetExecution();
} catch (e: any) {
toast(e?.message || String(e), 5000);
} finally {
loadingPreview.value = false;
}
}
async function selectFile() {
if (running.value) return;
selectingFile.value = true;
try {
const selected = await openDialog({
multiple: false,
filters: [{ name: "SQL", extensions: ["sql"] }],
});
if (typeof selected === "string") {
await loadPreview(selected);
}
} catch (e: any) {
toast(e?.message || String(e), 5000);
} finally {
selectingFile.value = false;
}
}
async function startExecution() {
if (!canStart.value || !preview.value) return;
const id = crypto.randomUUID();
executionId.value = id;
running.value = true;
cancelling.value = false;
cancelRequested.value = false;
executionStarted.value = false;
terminalStatus.value = "running";
terminalError.value = "";
progress.value = null;
let unlisten: (() => void) | undefined;
try {
await store.ensureConnected(connectionId.value);
if (cancelRequested.value) {
terminalStatus.value = "cancelled";
return;
}
unlisten = await listenSqlFileProgress((next) => {
if (next.executionId !== id) return;
progress.value = next;
terminalStatus.value = next.status;
terminalError.value = next.error ?? terminalError.value;
if (isTerminalStatus(next.status)) {
running.value = false;
cancelling.value = false;
}
});
if (cancelRequested.value) {
terminalStatus.value = "cancelled";
return;
}
executionStarted.value = true;
await executeSqlFile({
executionId: id,
connectionId: connectionId.value,
database: database.value.trim(),
filePath: preview.value.filePath,
continueOnError: continueOnError.value,
});
if (!isTerminalStatus(terminalStatus.value)) {
terminalStatus.value = cancelRequested.value ? "cancelled" : "done";
}
} catch (e: any) {
terminalStatus.value = cancelRequested.value ? "cancelled" : "error";
terminalError.value = e?.message || String(e);
if (!cancelRequested.value) {
toast(terminalError.value, 5000);
}
} finally {
unlisten?.();
running.value = false;
cancelling.value = false;
executionStarted.value = false;
}
}
async function cancelExecution() {
if (!executionId.value || !running.value || cancelling.value) return;
cancelRequested.value = true;
cancelling.value = true;
if (!executionStarted.value) return;
try {
const cancelled = await cancelSqlFileExecution(executionId.value);
if (!cancelled) {
throw new Error("Cancel request was not accepted");
}
} catch (e: any) {
cancelRequested.value = false;
cancelling.value = false;
toast(e?.message || String(e), 5000);
}
}
function handleOpenChange(nextOpen: boolean) {
if (!nextOpen && running.value) return;
open.value = nextOpen;
}
watch(connectionId, (id) => {
loadDatabasesForConnection(id);
});
watch(sqlConnections, () => {
if (!open.value || running.value || selectedConnection.value) return;
connectionId.value = resolveInitialConnectionId();
});
watch(open, (value) => {
if (!value) return;
resetState();
if (connectionId.value) {
loadDatabasesForConnection(connectionId.value);
}
});
</script>
<template>
<Dialog :open="open" @update:open="handleOpenChange">
<DialogScrollContent class="sm:max-w-[620px]" :trap-focus="false" @interact-outside.prevent>
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<FileCode class="w-4 h-4" />
{{ t('sqlFile.title') }}
</DialogTitle>
</DialogHeader>
<div class="grid gap-4 py-3">
<div class="space-y-3">
<div class="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{{ t('sqlFile.file') }}
</div>
<div class="flex items-center gap-2">
<Input
:model-value="filePath"
readonly
class="h-8 text-xs font-mono"
:placeholder="t('sqlFile.selectSqlFile')"
/>
<Button variant="outline" size="sm" class="h-8 shrink-0" :disabled="running || selectingFile" @click="selectFile">
<Loader2 v-if="selectingFile || loadingPreview" class="w-3.5 h-3.5 mr-1.5 animate-spin" />
<FolderOpen v-else class="w-3.5 h-3.5 mr-1.5" />
{{ t('sqlFile.browse') }}
</Button>
</div>
<div v-if="preview" class="border rounded-md overflow-hidden">
<div class="flex items-center justify-between gap-3 px-3 py-2 text-xs border-b bg-muted/40">
<div class="min-w-0 flex items-center gap-2">
<FileCode class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
<span class="font-medium truncate">{{ preview.fileName }}</span>
</div>
<span class="text-muted-foreground shrink-0">{{ formatBytes(preview.sizeBytes) }}</span>
</div>
<pre class="max-h-40 overflow-auto p-3 text-xs font-mono whitespace-pre-wrap bg-muted/15">{{ preview.preview }}</pre>
</div>
</div>
<div class="space-y-3">
<div class="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{{ t('sqlFile.target') }}
</div>
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1.5">
<Label class="text-xs">{{ t('sqlFile.connection') }}</Label>
<Select v-model="connectionId" :disabled="running">
<SelectTrigger class="h-8 text-xs">
<div v-if="connectionId" class="flex items-center gap-1.5 min-w-0">
<DatabaseIcon :db-type="connectionIconType(connectionId)" class="w-3.5 h-3.5 shrink-0" />
<span class="truncate">{{ selectedConnection?.name ?? connectionId }}</span>
</div>
<SelectValue v-else :placeholder="t('sqlFile.selectConnection')" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="c in sqlConnections" :key="c.id" :value="c.id">
<div class="flex items-center gap-1.5">
<DatabaseIcon :db-type="c.driver_profile || c.db_type" class="w-3.5 h-3.5" />
{{ c.name }}
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-1.5">
<Label class="text-xs">{{ t('sqlFile.database') }}</Label>
<Select v-if="databaseOptions.length" v-model="database" :disabled="running || loadingDatabases">
<SelectTrigger class="h-8 text-xs">
<SelectValue :placeholder="t('sqlFile.selectDatabase')" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="db in databaseOptions" :key="db" :value="db">{{ db }}</SelectItem>
</SelectContent>
</Select>
<div v-else class="relative">
<Input
v-model="database"
class="h-8 text-xs"
:disabled="running || loadingDatabases"
:placeholder="t('sqlFile.databasePlaceholder')"
/>
<Loader2
v-if="loadingDatabases"
class="absolute right-2 top-2 w-3.5 h-3.5 animate-spin text-muted-foreground"
/>
</div>
</div>
</div>
</div>
<div class="space-y-2.5">
<div class="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{{ t('sqlFile.options') }}
</div>
<button
type="button"
class="flex items-center gap-2 text-xs text-left"
:disabled="running"
@click="continueOnError = !continueOnError"
>
<CheckSquare v-if="continueOnError" class="w-3.5 h-3.5 text-primary shrink-0" />
<Square v-else class="w-3.5 h-3.5 text-muted-foreground/40 shrink-0" />
{{ t('sqlFile.continueOnError') }}
</button>
</div>
<div v-if="running || terminalStatus !== 'idle' || progress" class="space-y-3">
<div class="flex items-center justify-between gap-3 text-xs">
<div class="flex items-center gap-1.5 min-w-0" :class="statusTone">
<component :is="statusIcon" class="w-3.5 h-3.5 shrink-0" :class="{ 'animate-spin': running }" />
<span class="font-medium truncate">
{{ cancelling ? t('sqlFile.cancelling') : statusLabel(terminalStatus) }}
</span>
</div>
<span v-if="progress" class="text-muted-foreground shrink-0">
{{ formatElapsed(progress.elapsedMs) }}
</span>
</div>
<div class="w-full bg-muted rounded-full h-2 overflow-hidden">
<div
class="h-full rounded-full transition-all duration-300"
:class="terminalStatus === 'error' ? 'bg-destructive' : terminalStatus === 'cancelled' ? 'bg-yellow-500' : 'bg-primary'"
:style="{ width: `${progressPercent}%` }"
/>
</div>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs">
<div class="border rounded-md px-2 py-1.5 min-w-0">
<div class="text-muted-foreground truncate">{{ t('sqlFile.statement') }}</div>
<div class="font-medium truncate">{{ progress?.statementIndex ?? 0 }}</div>
</div>
<div class="border rounded-md px-2 py-1.5 min-w-0">
<div class="text-muted-foreground truncate">{{ t('sqlFile.succeeded') }}</div>
<div class="font-medium text-green-600 truncate">{{ progress?.successCount ?? 0 }}</div>
</div>
<div class="border rounded-md px-2 py-1.5 min-w-0">
<div class="text-muted-foreground truncate">{{ t('sqlFile.failed') }}</div>
<div class="font-medium text-destructive truncate">{{ progress?.failureCount ?? 0 }}</div>
</div>
<div class="border rounded-md px-2 py-1.5 min-w-0">
<div class="text-muted-foreground truncate">{{ t('sqlFile.affectedRows') }}</div>
<div class="font-medium truncate">{{ (progress?.affectedRows ?? 0).toLocaleString() }}</div>
</div>
</div>
<div v-if="progress?.statementSummary" class="space-y-1">
<Label class="text-xs">{{ t('sqlFile.currentStatement') }}</Label>
<div class="border rounded-md p-2 text-xs font-mono bg-muted/15 max-h-20 overflow-auto whitespace-pre-wrap">
{{ progress.statementSummary }}
</div>
</div>
<div v-if="progress?.error || terminalError" class="border rounded-md p-2 text-xs text-destructive bg-destructive/5">
{{ progress?.error || terminalError }}
</div>
</div>
</div>
<DialogFooter>
<template v-if="running">
<Button variant="destructive" size="sm" :disabled="cancelling" @click="cancelExecution">
<Loader2 v-if="cancelling" class="w-3.5 h-3.5 mr-1.5 animate-spin" />
<X v-else class="w-3.5 h-3.5 mr-1.5" />
{{ cancelling ? t('sqlFile.cancelling') : t('sqlFile.cancel') }}
</Button>
</template>
<template v-else>
<Button variant="outline" size="sm" @click="open = false">
{{ t('common.close') }}
</Button>
<Button size="sm" :disabled="!canStart" @click="startExecution">
<Play class="w-3.5 h-3.5 mr-1.5" />
{{ t('sqlFile.execute') }}
</Button>
</template>
</DialogFooter>
</DialogScrollContent>
</Dialog>
</template>

View File

@ -304,6 +304,38 @@ export default {
overallProgress: "Overall progress",
dataTransfer: "Data Transfer",
},
sqlFile: {
title: "Execute SQL File",
file: "File",
selectSqlFile: "Select SQL file",
browse: "Browse",
target: "Target",
connection: "Connection",
selectConnection: "Select connection",
database: "Database",
selectDatabase: "Select database",
databasePlaceholder: "Database name",
options: "Options",
continueOnError: "Continue on error",
cancelling: "Cancelling...",
cancel: "Cancel",
execute: "Execute",
statement: "Statement",
succeeded: "Succeeded",
failed: "Failed",
affectedRows: "Affected rows",
currentStatement: "Current statement",
status: {
idle: "Idle",
started: "Started",
running: "Running",
statementDone: "Statement done",
statementFailed: "Statement failed",
done: "Done",
error: "Error",
cancelled: "Cancelled",
},
},
diff: {
title: "Compare Databases",
source: "Source",

View File

@ -304,6 +304,38 @@ export default {
overallProgress: "整体进度",
dataTransfer: "数据传输",
},
sqlFile: {
title: "执行 SQL 文件",
file: "文件",
selectSqlFile: "选择 SQL 文件",
browse: "浏览",
target: "目标",
connection: "连接",
selectConnection: "选择连接",
database: "数据库",
selectDatabase: "选择数据库",
databasePlaceholder: "数据库名称",
options: "选项",
continueOnError: "出错后继续",
cancelling: "正在取消...",
cancel: "取消",
execute: "执行",
statement: "语句",
succeeded: "成功",
failed: "失败",
affectedRows: "影响行数",
currentStatement: "当前语句",
status: {
idle: "空闲",
started: "已开始",
running: "执行中",
statementDone: "语句完成",
statementFailed: "语句失败",
done: "完成",
error: "错误",
cancelled: "已取消",
},
},
diff: {
title: "比较数据库",
source: "源数据库",

View File

@ -303,6 +303,61 @@ export async function deleteHistoryEntry(id: string): Promise<void> {
return invoke("delete_history_entry", { id });
}
// --- SQL File Execution ---
export type SqlFileStatus =
| "started"
| "running"
| "statementDone"
| "statementFailed"
| "done"
| "error"
| "cancelled";
export interface SqlFileRequest {
executionId: string;
connectionId: string;
database: string;
filePath: string;
continueOnError: boolean;
}
export interface SqlFilePreview {
fileName: string;
filePath: string;
sizeBytes: number;
preview: string;
}
export interface SqlFileProgress {
executionId: string;
status: SqlFileStatus;
statementIndex: number;
successCount: number;
failureCount: number;
affectedRows: number;
elapsedMs: number;
statementSummary: string;
error?: string | null;
}
export async function previewSqlFile(filePath: string): Promise<SqlFilePreview> {
return invoke("preview_sql_file", { filePath });
}
export async function executeSqlFile(request: SqlFileRequest): Promise<void> {
return invoke("execute_sql_file", { request });
}
export async function cancelSqlFileExecution(executionId: string): Promise<boolean> {
return invoke("cancel_sql_file_execution", { executionId });
}
export async function listenSqlFileProgress(
handler: (progress: SqlFileProgress) => void,
): Promise<UnlistenFn> {
return listen<SqlFileProgress>("sql-file-progress", (event) => handler(event.payload));
}
// --- Data Transfer ---
export interface TransferRequest {
transferId: string;

View File

@ -18,6 +18,7 @@ export const useConnectionStore = defineStore("connection", () => {
const completionColumnsCache = ref<Record<string, ColumnInfo[]>>({});
const transferSource = ref<{ connectionId: string; database: string } | null>(null);
const schemaDiffSource = ref<{ connectionId: string; database: string } | null>(null);
const sqlFileSource = ref<{ connectionId: string; database: string } | null>(null);
function startEditing(id: string) {
editingConnectionId.value = id;
@ -653,5 +654,6 @@ export const useConnectionStore = defineStore("connection", () => {
importConnectionsFromFile,
transferSource,
schemaDiffSource,
sqlFileSource,
};
});