From 9893ac87346d0654654f7a1a9fa5b49ecffbe782 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=80=E9=A2=97=E7=BA=A2=E5=BF=83?= Date: Fri, 7 Aug 2026 15:32:16 +0800 Subject: [PATCH] fix(export): use real file name and reveal for background table-export tasks --- .../export/ExportProgressDialog.vue | 14 ++- .../export/ExportProgressPopover.vue | 46 ++++++- .../__tests__/ExportProgressDialog.spec.ts | 82 ++++++++++++ .../__tests__/ExportProgressPopover.spec.ts | 117 ++++++++++++++++++ 4 files changed, 253 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/src/components/export/__tests__/ExportProgressDialog.spec.ts 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"); + }); + }); +});