From 5518830440fe387762f34533c6a8e04b8f9e7072 Mon Sep 17 00:00:00 2001 From: efeire Date: Tue, 20 Oct 2026 20:10:31 +0800 Subject: [PATCH] feat: expand data-grid copy formats and metadata (#12777) * feat: extend data grid copy formats * fix(data-grid): address copy parity review findings * test(sidebar): cover qualified multi-copy names * fix(grid): retain row header option for TSV * fix(i18n): remove unused extractor import * fix(grid): satisfy clippy for unsigned types * fix(grid): address copy parity review findings * fix(grid): preserve exact PHP decimal values * fix(grid): pass dialect into PHP extraction * fix(grid): require known dialect for PHP floats * style(grid): format metadata copy test * Avoid lint warning in metadata formula guard * Preserve standalone at-sign metadata values * Align PHP date option with extractor wire contract * Distinguish binary floats from binary data * Preserve exact decimal precision across query results * Cover CLI exact decimal formatting * docs: clarify data grid copy entry points * fix: satisfy Rust clippy for decimal serializer * fix: preserve PHP single-quoted line breaks * docs: add verified data grid copy example * test: cover qualified sidebar object namespaces * feat(grid): add temporary advanced copy workflow * fix(grid): guard advanced copy preview lifecycle * docs(grid): clarify copy format settings without stale preview * fix(copy): preserve identifier and null-marker semantics * test(copy): assert null-disabled marker collisions are allowed * style(copy): match CI rustfmt ordering --- apps/desktop/src/components/grid/DataGrid.vue | 154 +++- .../grid/DataGridExtractorDialog.vue | 499 ++++++++--- .../DataGridContextExtractor.spec.ts | 241 ++++- .../__tests__/DataGridExtractorDialog.spec.ts | 246 +++++- .../sidebar/SidebarTreeRuntimeHost.vue | 38 +- ...untimeHost.tableTriggerContextMenu.spec.ts | 93 +- .../__tests__/useDataGridExport.spec.ts | 31 +- ...aGridExtractor.includeDatabaseName.spec.ts | 141 ++- .../src/composables/useDataGridExport.ts | 11 +- .../src/composables/useDataGridExtractor.ts | 87 +- .../gridCopyExtractorSavedParity.spec.ts | 19 + apps/desktop/src/i18n/locales/az.ts | 13 + apps/desktop/src/i18n/locales/en.ts | 63 ++ apps/desktop/src/i18n/locales/es.ts | 63 ++ apps/desktop/src/i18n/locales/id.ts | 13 + apps/desktop/src/i18n/locales/it.ts | 63 ++ apps/desktop/src/i18n/locales/ja.ts | 63 ++ apps/desktop/src/i18n/locales/ko.ts | 63 ++ apps/desktop/src/i18n/locales/pl.ts | 13 + apps/desktop/src/i18n/locales/pt-BR.ts | 63 ++ apps/desktop/src/i18n/locales/ru.ts | 63 ++ apps/desktop/src/i18n/locales/th.ts | 63 ++ apps/desktop/src/i18n/locales/tr.ts | 13 + apps/desktop/src/i18n/locales/zh-CN.ts | 63 ++ apps/desktop/src/i18n/locales/zh-TW.ts | 63 ++ .../__tests__/common/spreadsheetTsv.spec.ts | 31 + .../dataGrid/dataGridCellCoercion.spec.ts | 15 +- .../dataGridColumnFilter.values.spec.ts | 6 + .../dataGridColumnMetadataCopy.spec.ts | 31 + .../dataGrid/dataGridContextMenu.spec.ts | 8 + .../dataGrid/dataGridCopyExtractor.spec.ts | 48 +- .../__tests__/dataGrid/dataGridSort.spec.ts | 6 + .../fixtures/exactDecimalQueryResult.json | 1 + .../sidebar/sidebarTableNameCopy.spec.ts | 72 +- .../dataGrid/dataGridColumnMetadataCopy.ts | 14 + .../src/lib/dataGrid/dataGridContextMenu.ts | 5 +- .../src/lib/dataGrid/dataGridCopyExtractor.ts | 73 +- .../src/lib/sidebar/sidebarTableNameCopy.ts | 75 +- .../stores/__tests__/settingsStore.spec.ts | 7 +- apps/desktop/src/stores/settingsStore.ts | 15 +- crates/dbx-cli/src/main.rs | 28 + .../dbx-core/src/data/grid_clipboard_guard.rs | 31 +- .../src/data_grid_extractors/contract.rs | 149 ++++ .../src/data_grid_extractors/copy_formats.rs | 823 ++++++++++++++++++ .../src/data_grid_extractors/delimited.rs | 203 ++++- .../src/data_grid_extractors/mod.rs | 46 +- .../src/data_grid_extractors/tests.rs | 197 ++++- crates/dbx-types/src/types.rs | 260 +++++- docs/data-grid-copy-formats.md | 27 + docs/images/data-grid-copy-php-native.jpg | Bin 0 -> 29245 bytes 50 files changed, 4190 insertions(+), 223 deletions(-) create mode 100644 apps/desktop/src/lib/__tests__/common/spreadsheetTsv.spec.ts create mode 100644 apps/desktop/src/lib/__tests__/dataGrid/dataGridColumnMetadataCopy.spec.ts create mode 100644 apps/desktop/src/lib/__tests__/dataGrid/fixtures/exactDecimalQueryResult.json create mode 100644 apps/desktop/src/lib/dataGrid/dataGridColumnMetadataCopy.ts create mode 100644 crates/dbx-sql-data/src/data_grid_extractors/copy_formats.rs create mode 100644 docs/data-grid-copy-formats.md create mode 100644 docs/images/data-grid-copy-php-native.jpg diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 61bc47548f..f7c6a2059a 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -90,6 +90,7 @@ import DataGridToolbar from "@/components/grid/DataGridToolbar.vue"; import DataGridVirtualRowIdentifier from "@/components/grid/DataGridVirtualRowIdentifier.vue"; import DataGridExtractorDialog from "@/components/grid/DataGridExtractorDialog.vue"; import DataGridColumnHeader from "@/components/grid/DataGridColumnHeader.vue"; +import { formatDataGridColumnMetadataForCopy } from "@/lib/dataGrid/dataGridColumnMetadataCopy"; import DataGridQueryControls from "@/components/grid/DataGridQueryControls.vue"; import DataGridFilterBuilder from "@/components/grid/DataGridFilterBuilder.vue"; import DataGridFilterModeOrderDialog from "@/components/grid/DataGridFilterModeOrderDialog.vue"; @@ -9155,6 +9156,7 @@ const { columnComments: visibleColumnComments, allColumnComments, displayValue: (value, index) => formatCell(value, index, undefined, false), + editValue: (value, index) => cellEditorTextForValue(value, index), hasColumnFormatter: (index: number) => Boolean(resolvedColumnFormatters.value[index]), applyColumnFormattersOnExport: computed(() => settingsStore.editorSettings.applyColumnFormattersOnExport), cellClipboardText: (value) => (usesMongoDocumentGridValues.value ? mongoDocumentGridClipboardText(value) : undefined), @@ -9223,6 +9225,8 @@ function copyExtractorLabel(extractor: DataGridCopyExtractorId): string { html: "HTML", xml: "XML", pretty: t("grid.copyExtractorPretty"), + dbunit: "DbUnit", + php: "PHP", }; return labels[extractor]; } @@ -9242,6 +9246,86 @@ function sqlInsertModeLabel(mode: DataGridPrimaryKeyMode): string { const selectedCopyPreference = computed(() => settingsStore.editorSettings.dataGridCopyExtractor); const defaultCopyPreferenceLabel = computed(() => copyPreferenceLabel(selectedCopyPreference.value)); const extractorConfigOpen = ref(false); +const advancedCopyOpen = ref(false); +let advancedCopySession = 0; +const advancedCopyGeneration = ref(0); +let advancedCopyBinding: ReturnType | undefined; + +const advancedCopySelectionKey = computed(() => + JSON.stringify({ + rows: [...selectedRowIds.value].sort((a, b) => a - b), + columns: [...selectedColumnIndexes.value].sort((a, b) => a - b), + cells: [...selectedCellKeys.value].sort(), + range: selectedRange.value, + anchor: selectionAnchor.value, + focus: selectionFocus.value, + }), +); + +watch( + [() => props.result, advancedCopySelectionKey], + () => { + advancedCopyGeneration.value += 1; + }, + { flush: "sync" }, +); + +function advancedCopySnapshot() { + return { + result: props.result, + rows: props.result.rows, + columns: props.result.columns, + selection: advancedCopySelectionKey.value, + generation: advancedCopyGeneration.value, + }; +} + +function isAdvancedCopySessionValid(session: number, binding: ReturnType): boolean { + const current = advancedCopySnapshot(); + return advancedCopyOpen.value && session === advancedCopySession && current.result === binding.result && current.rows === binding.rows && current.columns === binding.columns && current.selection === binding.selection && current.generation === binding.generation; +} + +function openAdvancedCopy() { + advancedCopySession += 1; + advancedCopyBinding = advancedCopySnapshot(); + advancedCopyOpen.value = true; +} + +watch(advancedCopyOpen, (open) => { + if (open) return; + advancedCopySession += 1; + advancedCopyBinding = undefined; +}); +onUnmounted(() => { + advancedCopySession += 1; + advancedCopyOpen.value = false; +}); + +function canCopyAdvancedFormat(preference: DataGridCopyPreference): boolean { + if (preference === "smart") return canCopyWithExtractor("raw") || canCopyWithExtractor("tsv"); + return canCopyWithExtractor(preference); +} + +async function copyAdvancedFormat(preference: DataGridCopyPreference, extractorOptions: typeof settingsStore.editorSettings.dataGridExtractorOptions): Promise { + const session = advancedCopySession; + const binding = advancedCopyBinding; + const isValid = () => !!binding && isAdvancedCopySessionValid(session, binding); + if (!isValid()) throw new Error(t("grid.copyExtractorSelectionChanged")); + const copied = await copyWithPreference(preference, extractorOptions, isValid); + if (!copied && !isValid()) throw new Error(t("grid.copyExtractorSelectionChanged")); + return copied; +} + +async function previewAdvancedCopy(preference: DataGridCopyPreference, extractorOptions: typeof settingsStore.editorSettings.dataGridExtractorOptions) { + const session = advancedCopySession; + const binding = advancedCopyBinding; + const isValid = () => !!binding && isAdvancedCopySessionValid(session, binding); + if (!isValid()) throw new Error(t("grid.copyExtractorSelectionChanged")); + const preview = await previewWithPreference(preference, extractorOptions); + if (!isValid()) throw new Error(t("grid.copyExtractorSelectionChanged")); + return preview; +} + const copyPreferenceMenuItems = computed(() => DATA_GRID_DEFAULT_COPY_PREFERENCES.map((preference) => { const extractor = preference === "smart" ? undefined : preference; @@ -9253,6 +9337,13 @@ const copyPreferenceMenuItems = computed(() => }; }), ); +const visibleCopyExtractorItems = computed(() => + DATA_GRID_COPY_EXTRACTOR_IDS.map((extractor) => ({ + value: extractor, + label: copyExtractorLabel(extractor), + disabled: extractorUnavailableForDatabase(extractor, props.databaseType) || ((extractor === "sql-updates" || extractor === "sql-deletes-inserts") && !props.tableMeta?.primaryKeys.length), + })), +); function openExtractorConfiguration() { extractorConfigOpen.value = true; } @@ -9273,10 +9364,11 @@ function sqlInsertExtractorOptions(primaryKeyMode: DataGridPrimaryKeyMode) { }; } -function saveExtractorConfiguration(value: { preference: DataGridCopyPreference; options: typeof settingsStore.editorSettings.dataGridExtractorOptions }) { +function saveExtractorConfiguration(value: { preference: DataGridCopyPreference; options: typeof settingsStore.editorSettings.dataGridExtractorOptions; visibleCopyExtractors: typeof settingsStore.editorSettings.dataGridVisibleCopyExtractors }) { settingsStore.updateEditorSettings({ dataGridCopyExtractor: value.preference, dataGridExtractorOptions: value.options, + dataGridVisibleCopyExtractors: value.visibleCopyExtractors, }); // The dialog closes on save; without this the write is invisible and users // report the save button as doing nothing (#9872). @@ -12169,6 +12261,29 @@ function copyHeaderColumnOrSelected() { void copyHeaderColumn(); } +function copyContextColumnMetadata() { + const visibleIndexes = + contextHeaderVisibleColIdx.value !== null && selectedColumnIndexes.value.has(contextHeaderVisibleColIdx.value) && selectedColumnIndexes.value.size > 1 ? [...selectedColumnIndexes.value].sort((a, b) => a - b) : contextHeaderVisibleColIdx.value !== null ? [contextHeaderVisibleColIdx.value] : []; + const rows = visibleIndexes + .map((visibleIndex) => { + const actualIndex = visibleColumnIndexes.value[visibleIndex]; + const name = visibleColumns.value[visibleIndex]; + if (actualIndex === undefined || name === undefined) return undefined; + const info = tableColumnForGridColumn(actualIndex); + const sourceName = props.sourceColumns?.[actualIndex] ?? info?.name; + return { + name, + type: allColumnTypes.value[actualIndex], + source: sourceName, + nullable: info ? info.is_nullable : undefined, + comment: resolvedColumnComment(name, actualIndex) || undefined, + }; + }) + .filter((row): row is NonNullable => row !== undefined); + if (rows.length === 0) return; + void copyText(formatDataGridColumnMetadataForCopy(rows)); +} + // 「复制列注释」的目标列:显式多选时按选中列,否则用右键命中的那列。 function contextCommentActualColumnIndexes(): number[] { if (contextHeaderColumnIndex.value === null) return []; @@ -13914,6 +14029,7 @@ function buildExtractorContextItems(destination: "copy" | "export" = "copy"): Co const items: ContextMenuItem[] = []; let separatorPending = false; for (const extractor of DATA_GRID_COPY_EXTRACTOR_IDS) { + if (destination === "copy" && !settingsStore.editorSettings.dataGridVisibleCopyExtractors.includes(extractor)) continue; if (extractor === "raw" && !canCopyWithExtractor(extractor)) continue; const descriptor = DATA_GRID_COPY_EXTRACTOR_DESCRIPTORS[extractor]; if (descriptor.separatorBefore) { @@ -13971,6 +14087,12 @@ function copySubmenu(): ContextMenuItem { icon: Settings2, action: openExtractorConfiguration, }, + { + label: t("grid.copyExtractorAdvancedCopy"), + icon: Copy, + action: openAdvancedCopy, + disabled: !copyPreferenceMenuItems.value.some((item) => !item.disabled && canCopyAdvancedFormat(item.value)), + }, defaultCopyPreferenceContextItem(), { label: "", separator: true }, ]; @@ -14149,6 +14271,7 @@ const gridContextMenuItems = computed(() => { headerColumn: !!contextHeaderColumn.value, contextColumn: !!contextColumn.value, canCopyAlterSql: canCopyAlterColumnSql.value, + canCopyMetadata: true, canCopyComment: contextCopyableColumnComments.value.length > 0, canFilter: canUseWhereSearch.value, hasSort: !!sortCol.value, @@ -14168,6 +14291,7 @@ const gridContextMenuItems = computed(() => { count: selectedColumnNamesForCopy.value.length, }) : t("grid.copyColumnName"), + copyMetadata: t("grid.copyColumnMetadata"), copyComment: t("grid.copyColumnComment"), copyNames: t("grid.copyColumnNames"), details: t("grid.openColumnDetailsDialog"), @@ -14201,6 +14325,7 @@ const gridContextMenuItems = computed(() => { copyNames: openCopyAllColumnNamesDialog, details: openContextColumnDetailDialog, copyAlterSql: copyAlterColumnSql, + copyMetadata: copyContextColumnMetadata, sort: applyContextSort, freezeToColumn: () => { const idx = contextHeaderVisibleColIdx.value; @@ -16609,7 +16734,32 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val - + + diff --git a/apps/desktop/src/components/grid/DataGridExtractorDialog.vue b/apps/desktop/src/components/grid/DataGridExtractorDialog.vue index aafd0ca087..5504ec6dc2 100644 --- a/apps/desktop/src/components/grid/DataGridExtractorDialog.vue +++ b/apps/desktop/src/components/grid/DataGridExtractorDialog.vue @@ -1,5 +1,5 @@