fix(export): use real file name and reveal for background table-export tasks
This commit is contained in:
parent
8fb7559310
commit
9893ac8734
|
|
@ -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() {
|
|||
</DialogHeader>
|
||||
|
||||
<div class="py-4 space-y-4">
|
||||
<!-- Table name and format info -->
|
||||
<div class="text-sm text-muted-foreground">{{ tableName }} (.{{ format }})</div>
|
||||
<!-- Real saved file name (falls back to table name and format) -->
|
||||
<div class="text-sm text-muted-foreground" :title="filePath || undefined">{{ displayName }}</div>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div class="w-full bg-muted rounded-full h-2 overflow-hidden">
|
||||
|
|
|
|||
|
|
@ -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<string[]>([]);
|
||||
const revealingTaskIds = ref<string[]>([]);
|
||||
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) {
|
|||
<div class="flex-1 min-w-0 flex flex-col gap-1.5">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<component :is="statusIcon(task)" :class="[statusColor(task.status), task.kind === 'table-export' && isActive(task.status) ? 'animate-spin' : '']" class="h-3.5 w-3.5 shrink-0" />
|
||||
<span class="truncate font-medium">{{ taskTitle(task) }}</span>
|
||||
<span class="truncate font-medium" :title="task.filePath || undefined">{{ taskTitle(task) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Progress bar -->
|
||||
|
|
@ -249,8 +284,11 @@ function failureDetailCount(task: ExportTask) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions: stop/cancel for active, delete for finished -->
|
||||
<!-- Actions: reveal folder for finished exports, stop/cancel for active, delete for finished -->
|
||||
<div class="flex shrink-0 pt-4">
|
||||
<button v-if="canRevealTaskFile(task)" class="flex h-6 w-6 items-center justify-center rounded hover:bg-muted disabled:opacity-50" :title="t('exportProgress.openFolder')" :disabled="revealingTaskIds.includes(task.exportId)" @click="revealTaskFile(task)">
|
||||
<FolderOpen class="h-3.5 w-3.5 text-muted-foreground hover:text-foreground" />
|
||||
</button>
|
||||
<button v-if="isActive(task.status)" class="flex h-6 w-6 items-center justify-center rounded hover:bg-muted" :title="t('exportProgress.cancel')" @click="cancelTask(task.exportId)">
|
||||
<X class="h-3.5 w-3.5 text-muted-foreground hover:text-destructive" />
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>) {
|
||||
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)");
|
||||
});
|
||||
});
|
||||
|
|
@ -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<typeof import("@/lib/backend/api")>();
|
||||
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<HTMLButtonElement>('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<HTMLButtonElement>('button[title="Open containing folder"]')?.click();
|
||||
await vi.waitFor(() => {
|
||||
expect(useToast().message.value).toContain("Failed to open folder");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue