feat(table): open copy dialog from clone action

This commit is contained in:
t8y2
2026-10-15 12:29:21 +08:00
parent 7815319a7a
commit 5d341d3afd
5 changed files with 74 additions and 18 deletions
@@ -436,6 +436,7 @@ const batchEmptyPlan = ref<BatchTableEmptyPlanItem<ObjectBrowserRow>[]>([]);
const showPasteDialog = ref(false);
const pasteTableMode = ref<PasteTableMode>("structure-and-data");
const pasteTableEntries = ref<{ sourceName: string; targetName: string; schema?: string; tableComment?: string | null }[]>([]);
const pasteTableFromClipboard = ref(false);
const pasteTableDataCopySupported = computed(() => supportsWholeRowTableDataCopy(effectiveDatabaseType.value));
const objectColumnWidths = ref<Record<ObjectBrowserColumnKey, number>>({
select: 34,
@@ -3094,10 +3095,18 @@ async function exportTableData(row: ObjectBrowserRow, format: "csv" | "xlsx" | "
}
}
function requestDuplicateStructure(row: ObjectBrowserRow) {
duplicateTarget.value = row;
duplicateTableName.value = `${row.name}_copy`;
showDuplicateDialog.value = true;
function requestCopyAsNewTable(row: ObjectBrowserRow) {
pasteTableFromClipboard.value = false;
pasteTableMode.value = defaultPasteTableMode(effectiveDatabaseType.value);
pasteTableEntries.value = [
{
sourceName: row.name,
targetName: `${row.name}_copy`,
schema: normalizeObjectBrowserTableClipboardSchema(row.schema || selectedSchema.value),
tableComment: row.comment,
},
];
showPasteDialog.value = true;
}
function captureTableCopyContext() {
@@ -3300,6 +3309,7 @@ function openPasteTableDialog() {
toast(t("contextMenu.noTableToPaste"), 2000);
return;
}
pasteTableFromClipboard.value = true;
pasteTableMode.value = defaultPasteTableMode(effectiveDatabaseType.value);
pasteTableEntries.value = clipboard.tables.map((entry) => ({
sourceName: entry.tableName,
@@ -3335,7 +3345,9 @@ async function confirmPasteTableImpl() {
const entries = pasteTableEntries.value.filter((entry) => entry.targetName.trim()).map((entry) => ({ ...entry }));
if (entries.length === 0) return;
const context = captureTableCopyContext();
const clipboardAtPasteStart = connectionStore.treeClipboard;
const shouldConsumeTableClipboard = pasteTableFromClipboard.value;
const clipboardAtPasteStart = shouldConsumeTableClipboard ? connectionStore.treeClipboard : null;
pasteTableFromClipboard.value = false;
const mode = pasteTableMode.value;
const copyData = pasteTableModeCopiesData(mode) && pasteTableDataCopySupported.value;
showPasteDialog.value = false;
@@ -3444,7 +3456,7 @@ async function confirmPasteTableImpl() {
}
const pasteFeedback = tablePasteFeedback(successCount, pasteFailCount, firstPasteError);
if (pasteFailCount === 0) {
if (connectionStore.treeClipboard === clipboardAtPasteStart) {
if (shouldConsumeTableClipboard && connectionStore.treeClipboard === clipboardAtPasteStart) {
connectionStore.treeClipboard = null;
}
toast(t("contextMenu.batchPasteSuccess", { count: successCount }), 3000);
@@ -4375,7 +4387,7 @@ function getTableMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
{ sidebarActionId: "contextMenu.exportStructure", label: t("contextMenu.exportStructure"), action: () => exportStructure(item), icon: FileCode },
...(canOpenDataDictionary.value ? [{ sidebarActionId: "dataDictionary.title", label: t("dataDictionary.title"), action: () => openDataDictionary(item), icon: FileText }] : []),
{ label: "", separator: true },
{ sidebarActionId: "contextMenu.duplicateStructure", label: t("contextMenu.duplicateStructure"), action: () => requestDuplicateStructure(item), icon: CopyPlus },
{ sidebarActionId: "contextMenu.duplicateStructure", label: t("contextMenu.duplicateStructure"), action: () => requestCopyAsNewTable(item), icon: CopyPlus },
...tableClipboardMenuItems(item),
{ label: "", separator: true },
{ sidebarActionId: "common.more", label: t("common.more"), icon: ListTree, children: moreActions },
@@ -5457,7 +5469,7 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
<Dialog v-model:open="showPasteDialog">
<DialogContent class="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>{{ pasteTableEntries.length > 1 ? t("contextMenu.batchPasteTitle") : t("contextMenu.pasteTableConfirmTitle") }}</DialogTitle>
<DialogTitle>{{ pasteTableFromClipboard ? (pasteTableEntries.length > 1 ? t("contextMenu.batchPasteTitle") : t("contextMenu.pasteTableConfirmTitle")) : t("contextMenu.duplicateNameTitle") }}</DialogTitle>
</DialogHeader>
<div class="space-y-4">
<div class="flex gap-2">
@@ -90,6 +90,7 @@ const {
showPasteDialog,
pasteTableEntries,
pasteTableMode,
pasteTableFromClipboard,
pasteTableDataCopySupported,
confirmPasteTable,
showCreateDatabaseDialog,
@@ -608,7 +609,7 @@ watch(
<Dialog v-model:open="showPasteDialog">
<DialogContent class="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>{{ pasteTableEntries.length > 1 ? t("contextMenu.batchPasteTitle") : t("contextMenu.pasteTableConfirmTitle") }}</DialogTitle>
<DialogTitle>{{ pasteTableFromClipboard ? (pasteTableEntries.length > 1 ? t("contextMenu.batchPasteTitle") : t("contextMenu.pasteTableConfirmTitle")) : t("contextMenu.duplicateNameTitle") }}</DialogTitle>
</DialogHeader>
<div class="space-y-4">
<div class="flex gap-2">
@@ -333,6 +333,7 @@ import {
showPasteDialog,
pasteTableMode,
pasteTableEntries,
pasteTableFromClipboard,
showCreateDatabaseDialog,
createDatabaseName,
createDatabaseCharset,
@@ -1515,6 +1516,7 @@ function requestPasteTreeClipboard(): boolean {
if (clipboard?.kind !== "table-copy") return false;
if (canTransferTreeClipboardToCurrentNode()) return openTransferFromTreeClipboard();
if (!canPasteTreeClipboardToCurrentNode()) return false;
pasteTableFromClipboard.value = true;
pasteTableMode.value = defaultPasteTableMode(currentDatabaseType());
pasteTableEntries.value = clipboard.tables.map((entry) => ({
sourceName: entry.tableName,
@@ -5247,12 +5249,22 @@ async function confirmDropSchema() {
}
}
function duplicateStructure(source: TreeNode = activeNode.value) {
function copyAsNewTable(source: TreeNode = activeNode.value) {
if (!isDuplicateStructureSource(source)) return;
if (databaseTypeForNode(source) === "victoriametrics") return;
duplicateStructureSource.value = source;
duplicateTableName.value = `${source.label}_copy`;
showDuplicateDialog.value = true;
pasteTableFromClipboard.value = false;
pasteTableMode.value = defaultPasteTableMode(databaseTypeForNode(source));
pasteTableEntries.value = [
{
sourceName: source.label,
targetName: `${source.label}_copy`,
connectionId: source.connectionId,
database: source.database,
schema: normalizeTreeClipboardSchema(source.connectionId, source.database, source.schema),
tableComment: source.comment,
},
];
showPasteDialog.value = true;
}
function isDuplicateStructureSource(node: TreeNode): node is DuplicateStructureSource {
@@ -5324,7 +5336,9 @@ async function confirmPasteTableImpl() {
.filter((entry) => entry.targetName.trim())
.map((entry) => ({ ...entry, databaseType: entry.connectionId ? effectiveDatabaseTypeForConnection(connectionStore.getConfig(entry.connectionId)) : undefined, identifierQuote: connectionStore.connectionIdentifierQuote?.(entry.connectionId) }));
if (entries.length === 0) return;
const clipboardAtPasteStart = connectionStore.treeClipboard;
const shouldConsumeTableClipboard = pasteTableFromClipboard.value;
const clipboardAtPasteStart = shouldConsumeTableClipboard ? connectionStore.treeClipboard : null;
pasteTableFromClipboard.value = false;
const mode = pasteTableMode.value;
const copyData = pasteTableModeCopiesData(mode) && pasteTableDataCopySupported.value;
showPasteDialog.value = false;
@@ -5491,7 +5505,7 @@ async function confirmPasteTableImpl() {
}
const pasteFeedback = tablePasteFeedback(successCount, pasteFailCount, firstPasteError);
if (pasteFailCount === 0) {
if (connectionStore.treeClipboard === clipboardAtPasteStart) {
if (shouldConsumeTableClipboard && connectionStore.treeClipboard === clipboardAtPasteStart) {
connectionStore.treeClipboard = null;
}
toast(t("contextMenu.batchPasteSuccess", { count: successCount }), 3000);
@@ -5518,6 +5532,7 @@ function openPasteTableDialog() {
toast(t("contextMenu.noTableToPaste"), 2000);
return;
}
pasteTableFromClipboard.value = true;
pasteTableMode.value = defaultPasteTableMode(currentDatabaseType());
pasteTableEntries.value = clipboard.tables.map((entry) => ({
sourceName: entry.tableName,
@@ -6228,6 +6243,7 @@ function objectDialogCapabilities() {
showPasteDialog,
pasteTableEntries,
pasteTableMode,
pasteTableFromClipboard,
pasteTableDataCopySupported: pasteTableDataCopySupported.value,
confirmPasteTable,
};
@@ -7355,7 +7371,7 @@ function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean {
items.push(copyStructureAsSubmenu());
if (isTableNotView.value) {
items.push({ label: "", separator: true });
items.push({ sidebarActionId: "contextMenu.duplicateStructure", label: t("contextMenu.duplicateStructure"), action: duplicateStructure, icon: CopyPlus });
items.push({ sidebarActionId: "contextMenu.duplicateStructure", label: t("contextMenu.duplicateStructure"), action: copyAsNewTable, icon: CopyPlus });
// Keep menu copy aligned with keyboard copy so frozen multi-selection and single-row fallback stay compatible.
items.push(...treeTableClipboardMenuItems(node));
if (supportsVacuum.value) {
@@ -89,6 +89,7 @@ export const duplicateStructureSource = ref<DuplicateStructureSource | null>(nul
export const showPasteDialog = ref(false);
export const pasteTableMode = ref<PasteTableMode>("structure-and-data");
export const pasteTableEntries = ref<Array<{ sourceName: string; targetName: string; connectionId: string; database: string; schema?: string; tableComment?: string | null }>>([]);
export const pasteTableFromClipboard = ref(false);
export const showCreateDatabaseDialog = ref(false);
export const createDatabaseName = ref("");
export const createDatabaseCharset = ref("utf8mb4");
+28 -2
View File
@@ -66,12 +66,38 @@ test("successful tree table paste consumes only the clipboard used to start it",
const runtimeHost = readFileSync("apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue", "utf8");
const confirmPasteTableBody = functionBody(runtimeHost, "confirmPasteTableImpl");
assert.match(confirmPasteTableBody, /const clipboardAtPasteStart = connectionStore\.treeClipboard/);
assert.match(confirmPasteTableBody, /const shouldConsumeTableClipboard = pasteTableFromClipboard\.value/);
assert.match(confirmPasteTableBody, /const clipboardAtPasteStart = shouldConsumeTableClipboard \? connectionStore\.treeClipboard : null/);
assert.match(confirmPasteTableBody, /if \(pasteFailCount === 0\)/);
assert.match(confirmPasteTableBody, /connectionStore\.treeClipboard === clipboardAtPasteStart/);
assert.match(confirmPasteTableBody, /shouldConsumeTableClipboard && connectionStore\.treeClipboard === clipboardAtPasteStart/);
assert.match(confirmPasteTableBody, /connectionStore\.treeClipboard = null/);
});
test("tree table clone opens the full copy dialog without replacing the clipboard", () => {
const runtimeHost = readFileSync("apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue", "utf8");
const copyAsNewTableBody = functionBody(runtimeHost, "copyAsNewTable");
assert.match(copyAsNewTableBody, /pasteTableFromClipboard\.value = false/);
assert.match(copyAsNewTableBody, /pasteTableMode\.value = defaultPasteTableMode/);
assert.match(copyAsNewTableBody, /targetName: `\$\{source\.label\}_copy`/);
assert.match(copyAsNewTableBody, /showPasteDialog\.value = true/);
assert.doesNotMatch(copyAsNewTableBody, /treeClipboard\s*=/);
assert.match(runtimeHost, /sidebarActionId: "contextMenu\.duplicateStructure"[\s\S]*?action: copyAsNewTable/);
});
test("object browser table clone opens the full copy dialog without consuming the clipboard", () => {
const objectBrowser = readFileSync("apps/desktop/src/components/objects/ObjectBrowser.vue", "utf8");
const copyAsNewTableBody = functionBody(objectBrowser, "requestCopyAsNewTable");
const confirmPasteTableBody = functionBody(objectBrowser, "confirmPasteTableImpl");
assert.match(copyAsNewTableBody, /pasteTableFromClipboard\.value = false/);
assert.match(copyAsNewTableBody, /targetName: `\$\{row\.name\}_copy`/);
assert.match(copyAsNewTableBody, /showPasteDialog\.value = true/);
assert.doesNotMatch(copyAsNewTableBody, /treeClipboard\s*=/);
assert.match(confirmPasteTableBody, /shouldConsumeTableClipboard && connectionStore\.treeClipboard === clipboardAtPasteStart/);
assert.match(objectBrowser, /sidebarActionId: "contextMenu\.duplicateStructure"[\s\S]*?requestCopyAsNewTable\(item\)/);
});
test("tree table paste keeps the clipboard when production confirmation is cancelled", () => {
const runtimeHost = readFileSync("apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue", "utf8");
const confirmPasteTableBody = functionBody(runtimeHost, "confirmPasteTableImpl");