Merge pull request #49 from SuLea-IT/codex/sql-file-execution
Add SQL file execution workflow
This commit is contained in:
commit
8b153bef0d
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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,
|
||||
|
|
|
|||
30
src/App.vue
30
src/App.vue
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: "源数据库",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue