From 72bb880ee7425f7db36b657b292a1ecc72b987bc Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Sun, 23 Aug 2026 16:17:21 +0000 Subject: [PATCH] fix(table-paste): surface first batch paste error Closes #4832 --- .../src/components/objects/ObjectBrowser.vue | 24 +++++++++++++++---- .../objects/ObjectBrowserClipboard.spec.ts | 10 +++++++- .../sidebar/SidebarTreeRuntimeHost.vue | 20 +++++++++++++--- .../__tests__/crossDatabaseTablePaste.spec.ts | 6 +++++ .../table/__tests__/tableClipboard.spec.ts | 9 ++++++- apps/desktop/src/lib/table/tableClipboard.ts | 15 ++++++++++++ 6 files changed, 74 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index 411faf605f..3388a31dc3 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -87,7 +87,18 @@ import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; import { generateDatabaseExportId } from "@/lib/export/databaseExport"; import { buildXlsxHeaderOverrides, hasXlsxHeaderComments, type XlsxHeaderMode } from "@/lib/export/xlsxHeader"; import { copyToClipboard, eventTargetAllowsAppClipboardShortcut } from "@/lib/common/clipboard"; -import { defaultPasteTableMode, pasteTableModeCopiesData, supportsWholeRowTableDataCopy, tableClipboardMatchesTarget, tableClipboardMenuState, tableClipboardSourceContext, tableDataCopyColumnOptions, type PasteTableMode, type TableClipboardContext } from "@/lib/table/tableClipboard"; +import { + defaultPasteTableMode, + pasteTableModeCopiesData, + supportsWholeRowTableDataCopy, + tableClipboardMatchesTarget, + tableClipboardMenuState, + tableClipboardSourceContext, + tableDataCopyColumnOptions, + tablePasteFeedback, + type PasteTableMode, + type TableClipboardContext, +} from "@/lib/table/tableClipboard"; import { buildSingleDdlExportFileContent } from "@/lib/export/ddlExport"; import { fetchTableDataForExport } from "@/lib/table/tableDataExport"; import { useConnectionStore } from "@/stores/connectionStore"; @@ -2285,7 +2296,8 @@ async function confirmPasteTable() { const copyData = pasteTableModeCopiesData(mode) && pasteTableDataCopySupported.value; showPasteDialog.value = false; let successCount = 0; - let failCount = 0; + let pasteFailCount = 0; + let firstPasteError: unknown; let pasteCancelled = false; let hasMutatedTable = false; for (const entry of entries) { @@ -2327,7 +2339,8 @@ async function confirmPasteTable() { } successCount++; } catch (e: any) { - failCount++; + pasteFailCount++; + firstPasteError ??= e; console.error(`Failed to paste table "${entry.sourceName}" -> "${targetName}":`, e); } } @@ -2343,13 +2356,14 @@ async function confirmPasteTable() { } return; } - if (failCount === 0) { + const pasteFeedback = tablePasteFeedback(successCount, pasteFailCount, firstPasteError); + if (pasteFeedback.failedCount === 0) { if (connectionStore.treeClipboard === clipboardAtPasteStart) { connectionStore.treeClipboard = null; } toast(t("contextMenu.batchPasteSuccess", { count: successCount }), 3000); } else { - toast(t("contextMenu.batchPastePartialFail", { success: successCount, failed: failCount }), 5000); + toast(`${t("contextMenu.batchPastePartialFail", { success: pasteFeedback.successCount, failed: pasteFeedback.failedCount })}\n${t("contextMenu.tableOperationFailed", { message: translateBackendError(t, pasteFeedback.firstError) })}`, 5000); } await reload(); await connectionStore.refreshObjectListTreeNode(props.connection.id, props.database, selectedSchema.value); diff --git a/apps/desktop/src/components/objects/ObjectBrowserClipboard.spec.ts b/apps/desktop/src/components/objects/ObjectBrowserClipboard.spec.ts index 328387f896..cc9287b050 100644 --- a/apps/desktop/src/components/objects/ObjectBrowserClipboard.spec.ts +++ b/apps/desktop/src/components/objects/ObjectBrowserClipboard.spec.ts @@ -22,7 +22,15 @@ describe("ObjectBrowser table clipboard context menu", () => { }); it("consumes only the clipboard used by a fully successful paste", () => { - expect(objectBrowserSource).toMatch(/async function confirmPasteTable\(\)[\s\S]*?const clipboardAtPasteStart = connectionStore\.treeClipboard[\s\S]*?if \(failCount === 0\)[\s\S]*?connectionStore\.treeClipboard === clipboardAtPasteStart[\s\S]*?connectionStore\.treeClipboard = null/); + expect(objectBrowserSource).toMatch( + /async function confirmPasteTable\(\)[\s\S]*?const clipboardAtPasteStart = connectionStore\.treeClipboard[\s\S]*?const pasteFeedback = tablePasteFeedback\(successCount, pasteFailCount, firstPasteError\)[\s\S]*?if \(pasteFeedback\.failedCount === 0\)[\s\S]*?connectionStore\.treeClipboard === clipboardAtPasteStart[\s\S]*?connectionStore\.treeClipboard = null/, + ); + }); + + it("shows the first paste failure reason without concatenating every error", () => { + expect(objectBrowserSource).toMatch(/pasteFailCount\+\+/); + expect(objectBrowserSource).toMatch(/firstPasteError \?\?= e/); + expect(objectBrowserSource).toMatch(/batchPastePartialFail[\s\S]*?tableOperationFailed[\s\S]*?translateBackendError\(t, pasteFeedback\.firstError\)/); }); it("refreshes created tables and retains the clipboard when a later paste step is cancelled", () => { diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index e6edb52fb5..ca9d9b62b1 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -149,7 +149,18 @@ import { formatSqlForDisplay, sqlFormatDialectForDbType } from "@/lib/sql/sqlFor import { getTableStructureCapabilities } from "@/lib/table/tableStructureCapabilities"; import { connectionObjectTreeNodeSchema, connectionObjectTreeQuerySchema, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection, tableStructureDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; import { hasTreeNodeDatabaseContext } from "@/lib/sidebar/treeNodeContext"; -import { defaultPasteTableMode, pasteTableModeCopiesData, supportsWholeRowTableDataCopy, tableClipboardMatchesTarget, tableClipboardMenuState, tableClipboardSourceContext, tableDataCopyColumnOptions, type TableClipboardContext, type TableClipboardTableContext } from "@/lib/table/tableClipboard"; +import { + defaultPasteTableMode, + pasteTableModeCopiesData, + supportsWholeRowTableDataCopy, + tableClipboardMatchesTarget, + tableClipboardMenuState, + tableClipboardSourceContext, + tableDataCopyColumnOptions, + tablePasteFeedback, + type TableClipboardContext, + type TableClipboardTableContext, +} from "@/lib/table/tableClipboard"; import { selectedSidebarBatchTargets, selectedTreeNodesInVisibleOrder as orderSelectedTreeNodes } from "@/lib/sidebar/sidebarTreeSelection"; import { connectionPasteTargetGroupId, selectedConnectionClipboardTargets, selectedConnectionEditTarget, selectedConnectionMoveTargets } from "@/lib/sidebar/sidebarConnectionSelection"; import { connectionSupportsDatabaseUserAdmin, resolveDatabaseUserAdminProviderForConnection, type DatabaseUserIdentity } from "@/lib/database/databaseUserAdmin"; @@ -3831,6 +3842,7 @@ async function confirmPasteTable() { showPasteDialog.value = false; let successCount = 0; let pasteFailCount = 0; + let firstPasteError: unknown; let refreshFailCount = 0; let refreshError: unknown; let pasteCancelled = false; @@ -3902,6 +3914,7 @@ async function confirmPasteTable() { successCount++; } catch (e: any) { pasteFailCount++; + firstPasteError ??= e; console.error(`Failed to paste table "${entry.sourceName}" -> "${targetName}":`, e); } } @@ -3924,13 +3937,14 @@ async function confirmPasteTable() { } return; } - if (pasteFailCount === 0) { + const pasteFeedback = tablePasteFeedback(successCount, pasteFailCount, firstPasteError); + if (pasteFeedback.failedCount === 0) { if (connectionStore.treeClipboard === clipboardAtPasteStart) { connectionStore.treeClipboard = null; } toast(t("contextMenu.batchPasteSuccess", { count: successCount }), 3000); } else { - toast(t("contextMenu.batchPastePartialFail", { success: successCount, failed: pasteFailCount }), 5000); + toast(`${t("contextMenu.batchPastePartialFail", { success: pasteFeedback.successCount, failed: pasteFeedback.failedCount })}\n${t("contextMenu.tableOperationFailed", { message: translateBackendError(t, pasteFeedback.firstError) })}`, 5000); } if (refreshFailCount > 0) { const refreshMessage = refreshError instanceof Error ? refreshError.message : String(refreshError); diff --git a/apps/desktop/src/components/sidebar/__tests__/crossDatabaseTablePaste.spec.ts b/apps/desktop/src/components/sidebar/__tests__/crossDatabaseTablePaste.spec.ts index 738ab81e5d..423bfd4db4 100644 --- a/apps/desktop/src/components/sidebar/__tests__/crossDatabaseTablePaste.spec.ts +++ b/apps/desktop/src/components/sidebar/__tests__/crossDatabaseTablePaste.spec.ts @@ -41,4 +41,10 @@ describe("cross-database table paste", () => { expect(runtimeSource).toMatch(/buildDuplicateTableStructurePlan\(\{[\s\S]*?connectionId: entry\.connectionId[\s\S]*?sourceName: entry\.sourceName[\s\S]*?tableComment: entry\.tableComment/); expect(runtimeSource).toContain("sourceColumns = plan.sourceColumns"); }); + + it("shows the first paste failure reason without concatenating every error", () => { + expect(runtimeSource).toMatch(/pasteFailCount\+\+/); + expect(runtimeSource).toMatch(/firstPasteError \?\?= e/); + expect(runtimeSource).toMatch(/batchPastePartialFail[\s\S]*?tableOperationFailed[\s\S]*?translateBackendError\(t, pasteFeedback\.firstError\)/); + }); }); diff --git a/apps/desktop/src/lib/table/__tests__/tableClipboard.spec.ts b/apps/desktop/src/lib/table/__tests__/tableClipboard.spec.ts index dc13df3c06..b396a716a3 100644 --- a/apps/desktop/src/lib/table/__tests__/tableClipboard.spec.ts +++ b/apps/desktop/src/lib/table/__tests__/tableClipboard.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { tableClipboardMatchesTarget, tableClipboardMenuState, tableClipboardSourceContext } from "@/lib/table/tableClipboard"; +import { tableClipboardMatchesTarget, tableClipboardMenuState, tableClipboardSourceContext, tablePasteFeedback } from "@/lib/table/tableClipboard"; describe("table clipboard contexts", () => { const source = { connectionId: "source", database: "app", schema: "public" }; @@ -23,4 +23,11 @@ describe("table clipboard contexts", () => { expect(tableClipboardMenuState([sourceTable], targetTable)).toBe("copy"); expect(tableClipboardMenuState([sourceTable], targetTable, true)).toBe("copy-and-paste"); }); + + it("keeps batch feedback bounded to the first paste error", () => { + const firstError = { code: "permission-denied" }; + const feedback = tablePasteFeedback(0, 3, firstError); + + expect(feedback).toEqual({ successCount: 0, failedCount: 3, firstError }); + }); }); diff --git a/apps/desktop/src/lib/table/tableClipboard.ts b/apps/desktop/src/lib/table/tableClipboard.ts index 3b625bd84c..a4ac1ddc94 100644 --- a/apps/desktop/src/lib/table/tableClipboard.ts +++ b/apps/desktop/src/lib/table/tableClipboard.ts @@ -20,6 +20,21 @@ export interface TableDataCopyColumnOptions { sqlserverIdentityInsert: boolean; } +export interface TablePasteFeedback { + successCount: number; + failedCount: number; + firstError: unknown; +} + +/** Keep batch paste feedback bounded while preserving the first actionable error. */ +export function tablePasteFeedback(successCount: number, failedCount: number, firstError: unknown): TablePasteFeedback { + return { + successCount, + failedCount, + firstError, + }; +} + function normalizeSchema(schema: string | null | undefined): string { return schema?.trim() ?? ""; }