diff --git a/apps/desktop/src/components/export/ExportProgressDialog.vue b/apps/desktop/src/components/export/ExportProgressDialog.vue
index 5464a9a71..cada6aa06 100644
--- a/apps/desktop/src/components/export/ExportProgressDialog.vue
+++ b/apps/desktop/src/components/export/ExportProgressDialog.vue
@@ -47,6 +47,16 @@ const emit = defineEmits<{
}>();
const translatedErrorMessage = computed(() => (props.errorMessage ? translateBackendError(t, props.errorMessage) : ""));
+// Prefer the real saved file name from the save dialog path; the synthetic
+// "Query Result" style label is only a fallback when no path is available.
+const displayName = computed(() => {
+ const filePath = props.filePath?.trim() ?? "";
+ if (filePath) {
+ const segments = filePath.split(/[\\/]+/).filter((segment) => segment.length > 0);
+ if (segments.length > 0) return segments[segments.length - 1];
+ }
+ return `${props.tableName} (.${props.format})`;
+});
const isActive = computed(() => props.status === "Running" || props.status === "Writing");
const isFinished = computed(() => props.status === "Done" || props.status === "Error" || props.status === "Cancelled");
const canRevealFile = computed(() => props.status === "Done" && !!props.filePath && isTauriRuntime());
@@ -96,8 +106,8 @@ async function revealExportFile() {
-
-
{{ tableName }} (.{{ format }})
+
+
{{ displayName }}
diff --git a/apps/desktop/src/components/export/ExportProgressPopover.vue b/apps/desktop/src/components/export/ExportProgressPopover.vue
index 5896aeabf..cea51ee36 100644
--- a/apps/desktop/src/components/export/ExportProgressPopover.vue
+++ b/apps/desktop/src/components/export/ExportProgressPopover.vue
@@ -3,15 +3,20 @@ import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
-import { Loader2, CheckCircle2, XCircle, AlertCircle, X, FileDown, DatabaseBackup, FileCode2, ArrowRightLeft, ChevronRight } from "@lucide/vue";
+import { Loader2, CheckCircle2, XCircle, AlertCircle, X, FileDown, DatabaseBackup, FileCode2, ArrowRightLeft, ChevronRight, FolderOpen } from "@lucide/vue";
import { formatDataTransferDuration, useExportTracker, type ExportTask } from "@/composables/useExportTracker";
import { translateBackendError } from "@/i18n/backend-errors";
+import { useToast } from "@/composables/useToast";
+import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
+import * as api from "@/lib/backend/api";
const { t } = useI18n();
+const { toast } = useToast();
const { tasks, activeCount, hasActive, clearFinished, cancelTask, removeTask } = useExportTracker();
const open = ref(false);
const showAll = ref(false);
const expandedFailureTaskIds = ref
([]);
+const revealingTaskIds = ref([]);
const currentTime = ref(Date.now());
const MAX_VISIBLE = 5;
@@ -67,14 +72,27 @@ const progressValue = (task: ExportTask) => {
return progressPercent(task.totalRows, task.rowsExported);
};
+// The save dialog path is what the user recognizes the export by, so the real
+// saved file name beats the synthetic "Query Result.xlsx" style label.
+const taskFileName = (task: ExportTask) => {
+ const filePath = task.filePath.trim();
+ if (!filePath) return "";
+ const segments = filePath.split(/[\\/]+/).filter((segment) => segment.length > 0);
+ return segments.length > 0 ? segments[segments.length - 1] : "";
+};
+
const taskTitle = (task: ExportTask) => {
+ // database-export and sql-file keep their task labels on purpose: the former
+ // names the database (filePath may be a directory), the latter already names
+ // the executed script. Only table-export synthesizes a misleading
+ // "Query Result.xlsx" style name, so it prefers the real saved file name.
if (task.kind === "database-export") {
const key = task.databaseExportSource === "scheduled" ? "exportProgress.databaseBackupTitle" : "exportProgress.databaseExportTitle";
return t(key, { name: task.tableName });
}
if (task.kind === "sql-file") return t("exportProgress.sqlFileTitle", { name: task.tableName });
if (task.kind === "data-transfer") return t("exportProgress.dataTransferTitle", { name: task.tableName });
- return `${task.tableName}.${task.format}`;
+ return taskFileName(task) || `${task.tableName}.${task.format}`;
};
const rowsText = (task: ExportTask) => {
@@ -169,6 +187,23 @@ function toggleShowAll() {
showAll.value = !showAll.value;
}
+// Reveal is offered only for tasks that produce one local output file.
+// sql-file filePath can be a "; "-joined list of input scripts (not an
+// output), and data-transfer has no local file at all.
+const canRevealTaskFile = (task: ExportTask) => (task.kind === "table-export" || task.kind === "database-export") && task.status === "Done" && !!task.filePath && isTauriRuntime();
+
+async function revealTaskFile(task: ExportTask) {
+ if (!canRevealTaskFile(task) || revealingTaskIds.value.includes(task.exportId)) return;
+ revealingTaskIds.value = [...revealingTaskIds.value, task.exportId];
+ try {
+ await api.revealPathInFileManager(task.filePath);
+ } catch (error) {
+ toast(t("exportProgress.openFolderFailed", { message: translateBackendError(t, error) }), 5000);
+ } finally {
+ revealingTaskIds.value = revealingTaskIds.value.filter((id) => id !== task.exportId);
+ }
+}
+
function failureDetailsExpanded(exportId: string) {
return expandedFailureTaskIds.value.includes(exportId);
}
@@ -204,7 +239,7 @@ function failureDetailCount(task: ExportTask) {
- {{ taskTitle(task) }}
+ {{ taskTitle(task) }}
@@ -249,8 +284,11 @@ function failureDetailCount(task: ExportTask) {
-
+
+
diff --git a/apps/desktop/src/components/export/__tests__/ExportProgressDialog.spec.ts b/apps/desktop/src/components/export/__tests__/ExportProgressDialog.spec.ts
new file mode 100644
index 000000000..ff52c6f1b
--- /dev/null
+++ b/apps/desktop/src/components/export/__tests__/ExportProgressDialog.spec.ts
@@ -0,0 +1,82 @@
+// @vitest-environment happy-dom
+
+import { createApp, defineComponent, h, nextTick, type App } from "vue";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import i18n from "@/i18n";
+
+vi.mock("@/components/ui/dialog", async () => {
+ const { defineComponent, h } = await import("vue");
+ const passthrough = defineComponent({
+ setup(_props, { slots }) {
+ return () => h("div", slots.default?.());
+ },
+ });
+ return { Dialog: passthrough, DialogContent: passthrough, DialogHeader: passthrough, DialogTitle: passthrough, DialogFooter: passthrough };
+});
+
+vi.mock("@/components/ui/button", async () => {
+ const { defineComponent, h } = await import("vue");
+ return {
+ Button: defineComponent({
+ setup(_props, { slots }) {
+ return () => h("button", slots.default?.());
+ },
+ }),
+ };
+});
+
+import ExportProgressDialog from "@/components/export/ExportProgressDialog.vue";
+
+const mountedApps: App[] = [];
+
+afterEach(() => {
+ for (const app of mountedApps.splice(0)) app.unmount();
+ document.body.innerHTML = "";
+ vi.restoreAllMocks();
+});
+
+async function mountDialog(props: Record) {
+ const container = document.createElement("div");
+ document.body.append(container);
+ const app = createApp(
+ defineComponent({
+ setup() {
+ return () => h(ExportProgressDialog, props);
+ },
+ }),
+ );
+ mountedApps.push(app);
+ app.use(i18n);
+ app.mount(container);
+ await nextTick();
+}
+
+const baseProps = {
+ open: true,
+ title: "Export Table Data",
+ tableName: "Query Result",
+ format: "sql",
+ rowsExported: 10,
+ totalRows: 10,
+ status: "Done",
+ errorMessage: null,
+};
+
+describe("ExportProgressDialog display name", () => {
+ it("shows the real saved file name instead of the synthetic table label", async () => {
+ i18n.global.locale.value = "en";
+ await mountDialog({ ...baseProps, filePath: "C:\\exports\\自定义.sql" });
+
+ expect(document.body.textContent).toContain("自定义.sql");
+ expect(document.body.textContent).not.toContain("Query Result (.sql)");
+ const titleHolder = [...document.body.querySelectorAll("[title]")].find((el) => el.getAttribute("title") === "C:\\exports\\自定义.sql");
+ expect(titleHolder).toBeTruthy();
+ });
+
+ it("falls back to the table label when no file path is available", async () => {
+ i18n.global.locale.value = "en";
+ await mountDialog({ ...baseProps, tableName: "audit_log", format: "csv", filePath: null });
+
+ expect(document.body.textContent).toContain("audit_log (.csv)");
+ });
+});
diff --git a/apps/desktop/src/components/export/__tests__/ExportProgressPopover.spec.ts b/apps/desktop/src/components/export/__tests__/ExportProgressPopover.spec.ts
index fb794e79c..8c5db7d0a 100644
--- a/apps/desktop/src/components/export/__tests__/ExportProgressPopover.spec.ts
+++ b/apps/desktop/src/components/export/__tests__/ExportProgressPopover.spec.ts
@@ -25,8 +25,17 @@ vi.mock("@/components/ui/button", async () => {
};
});
+vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => true }));
+
+vi.mock("@/lib/backend/api", async (importOriginal) => {
+ const original = await importOriginal();
+ return { ...original, revealPathInFileManager: vi.fn() };
+});
+
import ExportProgressPopover from "@/components/export/ExportProgressPopover.vue";
import { useExportTracker } from "@/composables/useExportTracker";
+import { useToast } from "@/composables/useToast";
+import * as api from "@/lib/backend/api";
const mountedApps: App[] = [];
let now = 0;
@@ -250,3 +259,111 @@ describe("ExportProgressPopover task duration", () => {
expect(document.body.textContent).not.toContain("failure 100");
});
});
+
+describe("ExportProgressPopover file name and reveal action", () => {
+ it("shows the saved file name instead of the synthetic query-result label", async () => {
+ const tracker = useExportTracker();
+ tracker.addTask("Query Result", "sql", "C:\\exports\\自定义.sql");
+ tracker.addTask("Query Result", "xlsx", "/home/dbx/downloads/report-final.xlsx");
+
+ await mountPopover();
+
+ expect(document.body.textContent).toContain("自定义.sql");
+ expect(document.body.textContent).toContain("report-final.xlsx");
+ expect(document.body.textContent).not.toContain("Query Result.sql");
+ expect(document.body.textContent).not.toContain("Query Result.xlsx");
+ });
+
+ it("falls back to the table label when the task has no file path", async () => {
+ const tracker = useExportTracker();
+ tracker.addTask("audit_log", "csv", "");
+
+ await mountPopover();
+
+ expect(document.body.textContent).toContain("audit_log.csv");
+ });
+
+ it("reveals the containing folder for a finished export from the task row", async () => {
+ const revealMock = vi.mocked(api.revealPathInFileManager);
+ revealMock.mockReset();
+ revealMock.mockResolvedValue(undefined);
+ const tracker = useExportTracker();
+ const task = tracker.addTask("Query Result", "sql", "C:\\exports\\自定义.sql");
+ tracker.updateTableExportTask(task.exportId, {
+ exportId: task.exportId,
+ tableName: "Query Result",
+ rowsExported: 10,
+ totalRows: 10,
+ status: "Done",
+ errorMessage: undefined,
+ });
+
+ await mountPopover();
+
+ const revealButton = document.body.querySelector('button[title="Open containing folder"]');
+ expect(revealButton).not.toBeNull();
+ revealButton?.click();
+ await nextTick();
+ await vi.waitFor(() => expect(revealMock).toHaveBeenCalledWith("C:\\exports\\自定义.sql"));
+ });
+
+ it("hides the reveal button for active tasks and tasks without a file path", async () => {
+ const tracker = useExportTracker();
+ tracker.addTask("running_export", "csv", "C:\\exports\\running.csv");
+ const doneTask = tracker.addTask("finished_export", "csv", "");
+ tracker.updateTableExportTask(doneTask.exportId, {
+ exportId: doneTask.exportId,
+ tableName: "finished_export",
+ rowsExported: 1,
+ totalRows: 1,
+ status: "Done",
+ errorMessage: undefined,
+ });
+
+ await mountPopover();
+
+ expect(document.body.querySelector('button[title="Open containing folder"]')).toBeNull();
+ });
+
+ it("hides the reveal button for sql-file tasks whose path is a joined list of input scripts", async () => {
+ const tracker = useExportTracker();
+ const task = tracker.addSqlFileTask("sql-batch", "a.sql (+1)", "C:\\scripts\\a.sql; C:\\scripts\\b.sql");
+ tracker.updateSqlFileTask(task.exportId, {
+ executionId: task.exportId,
+ status: "done",
+ statementIndex: 2,
+ successCount: 2,
+ failureCount: 0,
+ affectedRows: 0,
+ elapsedMs: 10,
+ statementSummary: "",
+ });
+
+ await mountPopover();
+
+ expect(document.body.querySelector('button[title="Open containing folder"]')).toBeNull();
+ });
+
+ it("shows a toast when revealing the folder fails", async () => {
+ const revealMock = vi.mocked(api.revealPathInFileManager);
+ revealMock.mockReset();
+ revealMock.mockRejectedValue(new Error("file does not exist: C:\\exports\\gone.sql"));
+ const tracker = useExportTracker();
+ const task = tracker.addTask("Query Result", "sql", "C:\\exports\\gone.sql");
+ tracker.updateTableExportTask(task.exportId, {
+ exportId: task.exportId,
+ tableName: "Query Result",
+ rowsExported: 10,
+ totalRows: 10,
+ status: "Done",
+ errorMessage: undefined,
+ });
+
+ await mountPopover();
+
+ document.body.querySelector('button[title="Open containing folder"]')?.click();
+ await vi.waitFor(() => {
+ expect(useToast().message.value).toContain("Failed to open folder");
+ });
+ });
+});