From 930aa48c200c05c67391dadf2b54421e7714aef0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E4=B8=AB=E8=AE=B2=E6=A2=B5?= Date: Wed, 5 Aug 2026 23:58:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(grid):=20=E6=96=B0=E5=A2=9E=E5=8E=9F?= =?UTF-8?q?=E5=A7=8B=E6=95=B0=E6=8D=AE=E5=A4=8D=E5=88=B6=E6=A8=A1=E5=BC=8F?= =?UTF-8?q?=E5=B9=B6=E8=AE=BE=E4=B8=BA=E9=BB=98=E8=AE=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ feat(grid): 新增原始数据复制模式并设为默认 - 原样输出单元格内容,不对引号、制表符和换行进行 DSV 转义 - 支持原始数据复制后的网格粘贴还原 - 补充多语言复制选项文案及前后端提取器实现与测试 * ✨ feat(grid): 优化新增智能复制选项 - 单个单元格复制保留原始单元格文本,多单元格自动使用转义 TSV - 默认复制设置由格式选择升级为复制行为选择,并支持预览 - 限制原始文本格式仅用于单单元格复制 - 更新多语言文案及相关测试 --- .../components/document/DocumentBrowser.vue | 8 +- apps/desktop/src/components/grid/DataGrid.vue | 74 +++++++++-------- .../grid/DataGridExtractorDialog.vue | 35 +++++--- .../src/components/layout/ContentArea.vue | 24 +++--- .../__tests__/useDataGridExport.spec.ts | 81 +++++++++++++++++++ .../__tests__/useDataGridExport.xlsx.spec.ts | 2 + .../src/composables/useDataGridExport.ts | 4 +- .../src/composables/useDataGridExtractor.ts | 42 ++++++++-- apps/desktop/src/i18n/locales/en.ts | 4 +- apps/desktop/src/i18n/locales/es.ts | 4 +- apps/desktop/src/i18n/locales/it.ts | 4 +- apps/desktop/src/i18n/locales/ja.ts | 4 +- apps/desktop/src/i18n/locales/ko.ts | 4 +- apps/desktop/src/i18n/locales/pt-BR.ts | 4 +- apps/desktop/src/i18n/locales/zh-CN.ts | 6 +- apps/desktop/src/i18n/locales/zh-TW.ts | 4 +- .../dataGrid/dataGridCopyExtractor.spec.ts | 10 ++- .../src/lib/dataGrid/dataGridCopyExtractor.ts | 38 +++++++-- .../stores/__tests__/settingsStore.spec.ts | 8 +- apps/desktop/src/stores/settingsStore.ts | 10 +-- .../src/data_grid_extractors/contract.rs | 2 + .../dbx-core/src/data_grid_extractors/mod.rs | 12 +++ .../dbx-core/src/data_grid_extractors/raw.rs | 19 +++++ .../src/data_grid_extractors/tests.rs | 21 +++++ 24 files changed, 333 insertions(+), 91 deletions(-) create mode 100644 crates/dbx-core/src/data_grid_extractors/raw.rs diff --git a/apps/desktop/src/components/document/DocumentBrowser.vue b/apps/desktop/src/components/document/DocumentBrowser.vue index e1e716aceb..6870b35cd4 100644 --- a/apps/desktop/src/components/document/DocumentBrowser.vue +++ b/apps/desktop/src/components/document/DocumentBrowser.vue @@ -1640,10 +1640,10 @@ defineExpose({ focusSearch }); diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index fdae7cebb0..b991f6a7a1 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -189,7 +189,7 @@ import { useDataGridExport, type MongoCopyUpdateTarget } from "@/composables/use import { eventTargetAllowsNativeClipboard, isPlainClipboardShortcut, readTextFromClipboard } from "@/lib/common/clipboard"; import { claimDataGridPaste, claimDataGridSelectAll, clearDataGridClipboardCopy, parseDataGridClipboard, planDataGridPaste } from "@/lib/dataGrid/dataGridClipboard"; import { beginDataGridNativeSelectionBlock, finishDataGridNativeSelectionBlock } from "@/lib/dataGrid/dataGridNativeSelection"; -import { DATA_GRID_COPY_EXTRACTOR_DESCRIPTORS, DATA_GRID_COPY_EXTRACTOR_IDS, extractorUnavailableForDatabase, type DataGridCopyExtractorId } from "@/lib/dataGrid/dataGridCopyExtractor"; +import { DATA_GRID_COPY_EXTRACTOR_DESCRIPTORS, DATA_GRID_COPY_EXTRACTOR_IDS, DATA_GRID_DEFAULT_COPY_PREFERENCES, extractorUnavailableForDatabase, type DataGridCopyExtractorId, type DataGridCopyPreference } from "@/lib/dataGrid/dataGridCopyExtractor"; import { columnNamesForCopy } from "@/lib/dataGrid/dataGridColumnNameCopy"; import { DATA_GRID_ROW_NUM_WIDTH, dataGridRowNumberColumnWidth, resolveDataGridMaxRowNumber, useDataGridColumnResize } from "@/composables/useDataGridColumnResize"; import { createDataGridColumnStructureSignature } from "@/lib/dataGrid/dataGridColumnWidthState"; @@ -5497,7 +5497,8 @@ const { canCopyRow, copyAll, copyWithExtractor, - previewWithExtractor, + copyWithPreference, + previewWithPreference, canCopyWithExtractor, exportCsv, exportCurrentPageCsv, @@ -5566,6 +5567,7 @@ const { function copyExtractorLabel(extractor: DataGridCopyExtractorId): string { const labels: Record = { + raw: t("grid.copyExtractorRaw"), tsv: "TSV", "tsv-with-headers": t("grid.copyExtractorWithHeaders", { format: "TSV" }), csv: "CSV", @@ -5587,28 +5589,34 @@ function copyExtractorLabel(extractor: DataGridCopyExtractorId): string { return labels[extractor]; } -const selectedCopyExtractor = computed(() => settingsStore.editorSettings.dataGridCopyExtractor); -const defaultCopyExtractorLabel = computed(() => { - if (selectedCopyExtractor.value !== "sql-inserts") return copyExtractorLabel(selectedCopyExtractor.value); +function copyPreferenceLabel(preference: DataGridCopyPreference): string { + if (preference === "smart") return t("grid.copyExtractorSmart"); + if (preference !== "sql-inserts") return copyExtractorLabel(preference); return t(settingsStore.editorSettings.dataGridExtractorOptions.sql.excludePrimaryKeysFromInsert ? "grid.copyExtractorSqlInsertsWithoutPrimaryKeys" : "grid.copyExtractorSqlInsertsWithPrimaryKeys"); -}); +} + +const selectedCopyPreference = computed(() => settingsStore.editorSettings.dataGridCopyExtractor); +const defaultCopyPreferenceLabel = computed(() => copyPreferenceLabel(selectedCopyPreference.value)); const extractorConfigOpen = ref(false); -const extractorMenuItems = computed(() => - DATA_GRID_COPY_EXTRACTOR_IDS.map((extractor) => ({ - value: extractor, - label: copyExtractorLabel(extractor), - disabled: extractorUnavailableForDatabase(extractor, props.databaseType) || (extractor === "sql-updates" && !props.tableMeta?.primaryKeys.length), - separatorBefore: DATA_GRID_COPY_EXTRACTOR_DESCRIPTORS[extractor].separatorBefore, - })), +const copyPreferenceMenuItems = computed(() => + DATA_GRID_DEFAULT_COPY_PREFERENCES.map((preference) => { + const extractor = preference === "smart" ? undefined : preference; + return { + value: preference, + label: copyPreferenceLabel(preference), + disabled: extractor ? extractorUnavailableForDatabase(extractor, props.databaseType) || (extractor === "sql-updates" && !props.tableMeta?.primaryKeys.length) : false, + separatorBefore: extractor ? DATA_GRID_COPY_EXTRACTOR_DESCRIPTORS[extractor].separatorBefore : false, + }; + }), ); function openExtractorConfiguration() { extractorConfigOpen.value = true; } -function setDefaultCopyExtractor(value: string) { - const item = extractorMenuItems.value.find((candidate) => candidate.value === value); +function setDefaultCopyPreference(value: string) { + const item = copyPreferenceMenuItems.value.find((candidate) => candidate.value === value); if (!item || item.disabled) return; - settingsStore.updateEditorSettings({ dataGridCopyExtractor: item.value }); + settingsStore.updateEditorSettings({ dataGridCopyExtractor: item.value as DataGridCopyPreference }); } function sqlInsertExtractorOptions(excludePrimaryKeysFromInsert: boolean) { @@ -5619,8 +5627,8 @@ function sqlInsertExtractorOptions(excludePrimaryKeysFromInsert: boolean) { }; } -function saveExtractorConfiguration(value: { extractor: DataGridCopyExtractorId; options: typeof settingsStore.editorSettings.dataGridExtractorOptions }) { - settingsStore.updateEditorSettings({ dataGridCopyExtractor: value.extractor, dataGridExtractorOptions: value.options }); +function saveExtractorConfiguration(value: { preference: DataGridCopyPreference; options: typeof settingsStore.editorSettings.dataGridExtractorOptions }) { + settingsStore.updateEditorSettings({ dataGridCopyExtractor: value.preference, dataGridExtractorOptions: value.options }); } const pageSizeMenuItems = computed(() => @@ -6575,7 +6583,7 @@ async function onGridKeydown(event: KeyboardEvent) { if (clipboardShortcut(event, "c")) { if (!hasCellSelection.value && !hasRowSelection.value) return; event.preventDefault(); - await copyWithExtractor(selectedCopyExtractor.value); + await copyWithPreference(selectedCopyPreference.value); return; } if (clipboardShortcut(event, "a")) { @@ -8187,10 +8195,10 @@ defineExpose({ exportSql, exportXlsx, exportTxt, - defaultCopyExtractor: selectedCopyExtractor, - defaultCopyExtractorLabel, - copyExtractorMenuItems: extractorMenuItems, - setDefaultCopyExtractor, + defaultCopyPreference: selectedCopyPreference, + defaultCopyPreferenceLabel, + copyPreferenceMenuItems, + setDefaultCopyPreference, openExtractorConfiguration, }); @@ -8228,11 +8236,12 @@ function buildExtractorContextItems(): ContextMenuItem[] { const items: ContextMenuItem[] = []; let separatorPending = false; for (const extractor of DATA_GRID_COPY_EXTRACTOR_IDS) { + if (extractor === "raw" && !canCopyWithExtractor(extractor)) continue; const descriptor = DATA_GRID_COPY_EXTRACTOR_DESCRIPTORS[extractor]; if (descriptor.separatorBefore) { separatorPending = items.length > 0; } - const selected = extractor === selectedCopyExtractor.value; + const selected = extractor === selectedCopyPreference.value; const extractorItems: ContextMenuItem[] = []; if (extractor === "sql-inserts") { for (const excludePrimaryKeysFromInsert of [false, true]) { @@ -8265,13 +8274,14 @@ function buildExtractorContextItems(): ContextMenuItem[] { return items; } -function defaultExtractorContextItem(): ContextMenuItem { - const extractor = selectedCopyExtractor.value; - const extractorOptions = extractor === "sql-inserts" ? sqlInsertExtractorOptions(settingsStore.editorSettings.dataGridExtractorOptions.sql.excludePrimaryKeysFromInsert) : undefined; +function defaultCopyPreferenceContextItem(): ContextMenuItem { + const preference = selectedCopyPreference.value; + const extractorOptions = preference === "sql-inserts" ? sqlInsertExtractorOptions(settingsStore.editorSettings.dataGridExtractorOptions.sql.excludePrimaryKeysFromInsert) : undefined; + const disabled = preference === "smart" ? !canCopyWithExtractor("raw", extractorOptions) && !canCopyWithExtractor("tsv", extractorOptions) : !canCopyWithExtractor(preference, extractorOptions); return { - label: defaultCopyExtractorLabel.value, - action: () => void copyWithExtractor(extractor, extractorOptions), - disabled: !canCopyWithExtractor(extractor, extractorOptions), + label: defaultCopyPreferenceLabel.value, + action: () => void copyWithPreference(preference, extractorOptions), + disabled, checked: true, }; } @@ -8283,7 +8293,7 @@ function copySubmenu(): ContextMenuItem { icon: Settings2, action: openExtractorConfiguration, }, - defaultExtractorContextItem(), + defaultCopyPreferenceContextItem(), { label: "", separator: true }, ]; if (contextColumn.value) { @@ -10047,7 +10057,7 @@ const gridContextMenuItems = computed(() => { - + diff --git a/apps/desktop/src/components/grid/DataGridExtractorDialog.vue b/apps/desktop/src/components/grid/DataGridExtractorDialog.vue index 3f23fae78c..e9d1dd17d3 100644 --- a/apps/desktop/src/components/grid/DataGridExtractorDialog.vue +++ b/apps/desktop/src/components/grid/DataGridExtractorDialog.vue @@ -6,23 +6,32 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { DATA_GRID_COPY_EXTRACTOR_DESCRIPTORS, normalizeDataGridExtractorOptions, validateDataGridExtractorOptions, type DataGridCopyExtractorId, type DataGridExtractorOptions, type DataGridExtractPreview, type DataGridExtractWarningCode } from "@/lib/dataGrid/dataGridCopyExtractor"; +import { + DATA_GRID_COPY_EXTRACTOR_DESCRIPTORS, + normalizeDataGridExtractorOptions, + validateDataGridExtractorOptions, + type DataGridCopyExtractorId, + type DataGridCopyPreference, + type DataGridExtractorOptions, + type DataGridExtractPreview, + type DataGridExtractWarningCode, +} from "@/lib/dataGrid/dataGridCopyExtractor"; const props = defineProps<{ open: boolean; - extractor: DataGridCopyExtractorId; + preference: DataGridCopyPreference; options: DataGridExtractorOptions; - items: Array<{ value: DataGridCopyExtractorId; label: string; disabled?: boolean }>; - preview: (extractor: DataGridCopyExtractorId, options: DataGridExtractorOptions) => Promise; + items: Array<{ value: DataGridCopyPreference; label: string; disabled?: boolean }>; + preview: (preference: DataGridCopyPreference, options: DataGridExtractorOptions) => Promise; }>(); const emit = defineEmits<{ "update:open": [value: boolean]; - save: [value: { extractor: DataGridCopyExtractorId; options: DataGridExtractorOptions }]; + save: [value: { preference: DataGridCopyPreference; options: DataGridExtractorOptions }]; }>(); const { t } = useI18n(); -const draftExtractor = ref(props.extractor); +const draftPreference = ref(props.preference); const draftOptions = ref(normalizeDataGridExtractorOptions(props.options)); const previewText = ref(""); const previewError = ref(""); @@ -32,11 +41,13 @@ const previewTruncatedRows = ref(0); let previewSequence = 0; let previewTimer: ReturnType | undefined; -const extractorCategory = computed(() => DATA_GRID_COPY_EXTRACTOR_DESCRIPTORS[draftExtractor.value].category); +const draftExtractor = computed(() => (draftPreference.value === "smart" ? null : draftPreference.value)); +const extractorCategory = computed(() => (draftExtractor.value ? DATA_GRID_COPY_EXTRACTOR_DESCRIPTORS[draftExtractor.value].category : null)); const isDsv = computed(() => extractorCategory.value === "delimited"); const isSql = computed(() => draftExtractor.value === "sql-inserts" || draftExtractor.value === "sql-updates"); const isJson = computed(() => extractorCategory.value === "json"); const optionsError = computed(() => { + if (!draftExtractor.value) return ""; const code = validateDataGridExtractorOptions(draftExtractor.value, draftOptions.value); if (!code) return ""; return t(`grid.copyExtractorValidation.${code}`); @@ -70,7 +81,7 @@ async function refreshPreview() { return; } try { - const result = await props.preview(draftExtractor.value, normalizeDataGridExtractorOptions(draftOptions.value)); + const result = await props.preview(draftPreference.value, normalizeDataGridExtractorOptions(draftOptions.value)); if (sequence === previewSequence) { previewText.value = result.text; previewWarnings.value = (result.warnings ?? []).map((warning) => warningText(warning.code, result.omittedColumns)); @@ -105,14 +116,14 @@ watch( previewLoading.value = false; return; } - draftExtractor.value = props.extractor; + draftPreference.value = props.preference; draftOptions.value = normalizeDataGridExtractorOptions(props.options); schedulePreview(); }, ); watch( - [draftExtractor, draftOptions], + [draftPreference, draftOptions], () => { if (props.open) schedulePreview(); }, @@ -126,7 +137,7 @@ onBeforeUnmount(() => { function save() { if (optionsError.value) return; - emit("save", { extractor: draftExtractor.value, options: normalizeDataGridExtractorOptions(draftOptions.value) }); + emit("save", { preference: draftPreference.value, options: normalizeDataGridExtractorOptions(draftOptions.value) }); emit("update:open", false); } @@ -142,7 +153,7 @@ function save() {
- {{ item.label }} diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index 249591d516..c10e4d14bb 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -108,10 +108,10 @@ type DataGridHandle = DataGridColumnLayoutHandle & { allNullColumnCount: number; canToggleAllNullColumns: boolean; toggleAllNullColumns: () => void; - defaultCopyExtractor: string; - defaultCopyExtractorLabel: string; - copyExtractorMenuItems: Array<{ value: string; label: string; disabled?: boolean; separatorBefore?: boolean }>; - setDefaultCopyExtractor: (value: string) => void; + defaultCopyPreference: string; + defaultCopyPreferenceLabel: string; + copyPreferenceMenuItems: Array<{ value: string; label: string; disabled?: boolean; separatorBefore?: boolean }>; + setDefaultCopyPreference: (value: string) => void; openExtractorConfiguration: () => void; showDdl: boolean; toggleDdl: (tab?: TableInfoTab) => void; @@ -1286,10 +1286,10 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
@@ -1732,10 +1732,10 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
diff --git a/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts index 7f515a3781..ec6e5ccfce 100644 --- a/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts @@ -370,6 +370,87 @@ describe("useDataGridExport prepared row statements", () => { ]); }); + it("uses escaped TSV for a multi-cell smart copy without relying on clipboard metadata", async () => { + const rows = [ + [1, '{"msg":"success"}'], + [2, "inside\ttab\nnext line"], + ]; + const matrix: CellSelectionMatrix = { + rowIndexes: [0, 1], + columnIndexes: [0, 1], + columns: ["id", "name"], + rows, + }; + const text = '1\t"{""msg"":""success""}"\n2\t"inside\ttab\nnext line"'; + vi.mocked(extractDataGridSelection).mockResolvedValueOnce({ text, mimeType: "text/tab-separated-values", fileExtension: "tsv", rowCount: 2, columnCount: 2 }); + const state = createExportState(editableTable, ["id", "name"], matrix, undefined, undefined, rows); + + await expect(state.copyWithPreference("smart")).resolves.toBe(true); + + expect(extractDataGridSelection).toHaveBeenCalledWith(expect.objectContaining({ extractor: "tsv" })); + expect(copyToClipboard).toHaveBeenCalledWith(text); + }); + + it("preserves JSON-column text in a single-cell smart copy", async () => { + const tableMeta: DataGridTableMeta = { + tableName: "events", + primaryKeys: ["id"], + columns: [ + { name: "id", data_type: "int", is_nullable: false, is_primary_key: true }, + { name: "payload", data_type: "json", is_nullable: true }, + ], + }; + const payload = '{ "id": 9007199254740993, "items": [ 1, 2 ] }'; + const matrix: CellSelectionMatrix = { + rowIndexes: [0], + columnIndexes: [1], + columns: ["payload"], + rows: [[payload]], + }; + vi.mocked(extractDataGridSelection).mockResolvedValueOnce({ text: payload, mimeType: "text/plain", fileExtension: "txt", rowCount: 1, columnCount: 1 }); + const state = createExportState(tableMeta, ["id", "payload"], matrix, [7, payload]); + + await expect(state.copyWithPreference("smart")).resolves.toBe(true); + + expect(extractDataGridSelection).toHaveBeenCalledWith(expect.objectContaining({ extractor: "raw", rows: [[payload]] })); + expect(copyToClipboard).toHaveBeenCalledWith(payload); + }); + + it("does not allow raw output for a multi-cell selection", async () => { + const matrix: CellSelectionMatrix = { + rowIndexes: [0, 1], + columnIndexes: [0], + columns: ["id"], + rows: [[1], [2]], + }; + const state = createExportState(editableTable, ["id", "name"], matrix, undefined, undefined, [ + [1, "Ada"], + [2, "Linus"], + ]); + + expect(state.canCopyWithExtractor("raw")).toBe(false); + await expect(state.copyWithExtractor("raw")).resolves.toBe(false); + expect(extractDataGridSelection).not.toHaveBeenCalled(); + }); + + it("uses the same TSV extractor for smart copy previews of multiple cells", async () => { + const matrix: CellSelectionMatrix = { + rowIndexes: [0, 1], + columnIndexes: [0], + columns: ["id"], + rows: [[1], [2]], + }; + vi.mocked(extractDataGridSelection).mockResolvedValueOnce({ text: "1\n2", mimeType: "text/tab-separated-values", fileExtension: "tsv", rowCount: 2, columnCount: 1 }); + const state = createExportState(editableTable, ["id", "name"], matrix, undefined, undefined, [ + [1, "Ada"], + [2, "Linus"], + ]); + + await expect(state.previewWithPreference("smart", DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS)).resolves.toEqual(expect.objectContaining({ text: "1\n2" })); + + expect(extractDataGridSelection).toHaveBeenCalledWith(expect.objectContaining({ extractor: "tsv" })); + }); + it("rejects SQL UPDATE when the selection contains no writable non-key column", () => { const matrix: CellSelectionMatrix = { rowIndexes: [0], diff --git a/apps/desktop/src/composables/__tests__/useDataGridExport.xlsx.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridExport.xlsx.spec.ts index 695ec176dd..4bc936ef4d 100644 --- a/apps/desktop/src/composables/__tests__/useDataGridExport.xlsx.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDataGridExport.xlsx.spec.ts @@ -37,7 +37,9 @@ vi.mock("@/composables/useToast", () => ({ vi.mock("@/composables/useDataGridExtractor", () => ({ useDataGridExtractor: () => ({ copyWithExtractor: vi.fn(), + copyWithPreference: vi.fn(), previewWithExtractor: vi.fn(), + previewWithPreference: vi.fn(), canCopyWithExtractor: vi.fn(), }), })); diff --git a/apps/desktop/src/composables/useDataGridExport.ts b/apps/desktop/src/composables/useDataGridExport.ts index 034999c1fc..a1ff44be2f 100644 --- a/apps/desktop/src/composables/useDataGridExport.ts +++ b/apps/desktop/src/composables/useDataGridExport.ts @@ -558,7 +558,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) { }); } - const { copyWithExtractor, previewWithExtractor, canCopyWithExtractor } = useDataGridExtractor({ + const { copyWithExtractor, copyWithPreference, previewWithExtractor, previewWithPreference, canCopyWithExtractor } = useDataGridExtractor({ columns, displayItems, allColumns, @@ -1291,7 +1291,9 @@ export function useDataGridExport(options: UseDataGridExportOptions) { canCopyRow, copyAll, copyWithExtractor, + copyWithPreference, previewWithExtractor, + previewWithPreference, canCopyWithExtractor, exportCsv, exportCurrentPageCsv, diff --git a/apps/desktop/src/composables/useDataGridExtractor.ts b/apps/desktop/src/composables/useDataGridExtractor.ts index 85b8c0def1..889df046c2 100644 --- a/apps/desktop/src/composables/useDataGridExtractor.ts +++ b/apps/desktop/src/composables/useDataGridExtractor.ts @@ -10,7 +10,9 @@ import { DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, extractorUnavailableForDatabase, normalizeDataGridExtractorOptions, + resolveDataGridCopyPreference, type DataGridCopyExtractorId, + type DataGridCopyPreference, type DataGridExtractPreview, type DataGridExtractRequest, type DataGridExtractorOptions, @@ -133,9 +135,9 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) { sourceName: sourceNames?.[sourceIndex], sourceIndex: compactIndex, })); - const selectedColumnIndexes = selectedSourceIndexes.map((sourceIndex) => compactIndexBySource.get(sourceIndex)).filter((index): index is number => index !== undefined); - const rows = sourceRows.map((row) => requiredSourceIndexes.map((sourceIndex) => normalizeCellValue(row[sourceIndex], columnTypesBySource.get(sourceIndex)))); const descriptor = DATA_GRID_COPY_EXTRACTOR_DESCRIPTORS[extractor]; + const selectedColumnIndexes = selectedSourceIndexes.map((sourceIndex) => compactIndexBySource.get(sourceIndex)).filter((index): index is number => index !== undefined); + const rows = sourceRows.map((row) => requiredSourceIndexes.map((sourceIndex) => (descriptor.category === "json" || descriptor.category === "sql" ? normalizeCellValue(row[sourceIndex], columnTypesBySource.get(sourceIndex)) : row[sourceIndex]))); const tableMeta = descriptor.category === "sql" ? compactTableMeta( @@ -190,9 +192,19 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) { } return canBuildSqlUpdateRequest(); } + if (extractor === "raw") { + const request = buildRequest(extractor, extractorOptions); + return request !== null && request.rows.length * request.selectedColumnIndexes.length === 1; + } return selectionData() !== null; } + function resolveCopyPreference(preference: DataGridCopyPreference, extractorOptions: DataGridExtractorOptions): DataGridCopyExtractorId | null { + const request = buildRequest("raw", extractorOptions); + if (!request) return null; + return resolveDataGridCopyPreference(preference, request.rows.length * request.selectedColumnIndexes.length); + } + async function resolveMongoExtractorResult(extractor: DataGridCopyExtractorId, request: DataGridExtractRequest, rowLimit?: number) { if (options.databaseType.value !== "mongodb") return undefined; if (extractor !== "sql-inserts" && extractor !== "sql-updates") return undefined; @@ -214,11 +226,11 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) { if (!result.text) return false; // Derive the grid paste-back payload from the effective request schema so // hidden support columns, row headers, NULLs, tabs, and newlines keep the - // same shape and values as the rendered TSV. - const isTsv = extractor === "tsv" || extractor === "tsv-with-headers"; - const gridCopy = isTsv + // same shape and values as the rendered raw text or TSV. + const isGridTabularCopy = extractor === "raw" || extractor === "tsv" || extractor === "tsv-with-headers"; + const gridCopy = isGridTabularCopy ? (() => { - const includeRowHeader = request.options.dsv.includeRowHeader; + const includeRowHeader = extractor !== "raw" && request.options.dsv.includeRowHeader; const rows = request.rows.map((row, rowIndex) => { const selectedRow = request.selectedColumnIndexes.map((index) => row[index]); return includeRowHeader ? [rowIndex + 1, ...selectedRow] : selectedRow; @@ -250,6 +262,22 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) { return { ...result, sourceRowCount, truncated: sourceRowCount > result.rowCount }; } + async function copyWithPreference(preference: DataGridCopyPreference, extractorOptions: DataGridExtractorOptions = options.extractorOptions?.value ?? DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS): Promise { + if (hasUnsupportedDiscreteSelection.value) { + toast(t("grid.copyExtractorUnsupportedSelection"), 5000); + return false; + } + const extractor = resolveCopyPreference(preference, extractorOptions); + return extractor ? copyWithExtractor(extractor, extractorOptions) : false; + } + + async function previewWithPreference(preference: DataGridCopyPreference, extractorOptions: DataGridExtractorOptions): Promise { + if (hasUnsupportedDiscreteSelection.value) throw new Error(t("grid.copyExtractorUnsupportedSelection")); + const extractor = resolveCopyPreference(preference, extractorOptions); + if (!extractor) throw new Error(t("grid.copyExtractorEmptySelection")); + return previewWithExtractor(extractor, extractorOptions); + } + function showWarnings(warnings: Array<{ code: DataGridExtractWarningCode; message: string }> | undefined, omittedColumns: string[] | undefined) { if (!warnings?.length) return; for (const warning of warnings) { @@ -258,7 +286,7 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) { } } - return { copyWithExtractor, previewWithExtractor, canCopyWithExtractor }; + return { copyWithExtractor, copyWithPreference, previewWithExtractor, previewWithPreference, canCopyWithExtractor }; } function compactTableMeta(tableMeta: DataGridTableMeta | undefined, requiredColumns: string[]): DataGridTableMeta | undefined { diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 1892296854..1bd3698325 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1123,6 +1123,8 @@ export default { noFilteredRows: "No rows match the filter", noFilteredRowsDescription: "Adjust the search text or row status filter.", copy: "Copy", + copyExtractorSmart: "Smart Copy", + copyExtractorRaw: "Cell Text", copyExtractorWithHeaders: "{format} with Headers", copyExtractorOneRow: "One-row", copyExtractorJsonArray: "JSON Array", @@ -1135,7 +1137,7 @@ export default { copyExtractorPipeSeparated: "Pipe-separated", copyExtractorPretty: "Pretty Table", copyExtractorConfigure: "Configure Data Extractors…", - copyExtractorDefaultFormat: "Default copy format", + copyExtractorDefaultFormat: "Default copy behavior", copyExtractorFormat: "Extractor", copyExtractorColumnSeparator: "Column separator", copyExtractorRowSeparator: "Row separator", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 7f47a7da78..db86e89820 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -1442,6 +1442,8 @@ export default withEnglishFallback({ truncatedHint: "Los resultados se truncaron después de cargar {count} filas. Usa la paginación inferior para explorar los datos cargados; al exportar el resultado completo se vuelve a consultar la base de datos.", filterBuilderSearchColumns: "Buscar columnas...", filterBuilderNoMatchingColumns: "Sin columnas coincidentes", + copyExtractorSmart: "Copia inteligente", + copyExtractorRaw: "Texto de celda", copyExtractorWithHeaders: "{format} (con encabezados)", copyExtractorOneRow: "CSV de una fila", copyExtractorJsonArray: "Array JSON", @@ -1454,7 +1456,7 @@ export default withEnglishFallback({ copyExtractorPipeSeparated: "Separado por barra vertical", copyExtractorPretty: "Tabla formateada", copyExtractorConfigure: "Configurar extractor de datos…", - copyExtractorDefaultFormat: "Formato de copia predeterminado", + copyExtractorDefaultFormat: "Comportamiento de copia predeterminado", copyExtractorFormat: "Extractor", copyExtractorColumnSeparator: "Separador de columnas", copyExtractorRowSeparator: "Separador de filas", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 4dd122226a..fe5ca38b50 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -1440,6 +1440,8 @@ export default withEnglishFallback({ truncatedHint: "I risultati sono stati troncati dopo il caricamento di {count} righe. Usa la paginazione in basso per consultare i dati caricati; l'esportazione del risultato completo riesegue la query sul database.", filterBuilderSearchColumns: "Cerca campi...", filterBuilderNoMatchingColumns: "Nessun campo corrispondente", + copyExtractorSmart: "Copia intelligente", + copyExtractorRaw: "Testo della cella", copyExtractorWithHeaders: "{format} (con intestazione)", copyExtractorOneRow: "CSV a riga singola", copyExtractorJsonArray: "Array JSON", @@ -1452,7 +1454,7 @@ export default withEnglishFallback({ copyExtractorPipeSeparated: "Separato da pipe", copyExtractorPretty: "Testo tabellare", copyExtractorConfigure: "Configura estrattore dati…", - copyExtractorDefaultFormat: "Formato di copia predefinito", + copyExtractorDefaultFormat: "Comportamento di copia predefinito", copyExtractorFormat: "Estrattore", copyExtractorColumnSeparator: "Separatore di colonna", copyExtractorRowSeparator: "Separatore di riga", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 443a840ab2..d0c958edea 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1462,6 +1462,8 @@ export default withEnglishFallback({ redoChange: "変更をやり直す", filterBuilderSearchColumns: "フィールドを検索...", filterBuilderNoMatchingColumns: "一致するフィールドがありません", + copyExtractorSmart: "スマートコピー", + copyExtractorRaw: "セルの原文", copyExtractorWithHeaders: "{format}(ヘッダー付き)", copyExtractorOneRow: "単行CSV", copyExtractorJsonArray: "JSON 配列", @@ -1474,7 +1476,7 @@ export default withEnglishFallback({ copyExtractorPipeSeparated: "パイプ区切り", copyExtractorPretty: "テーブルテキスト", copyExtractorConfigure: "データ抽出の設定…", - copyExtractorDefaultFormat: "デフォルトのコピー形式", + copyExtractorDefaultFormat: "デフォルトのコピー動作", copyExtractorFormat: "抽出器", copyExtractorColumnSeparator: "列区切り文字", copyExtractorRowSeparator: "行区切り文字", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 366a0a5069..4e90701d8b 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -1038,6 +1038,8 @@ export default withEnglishFallback({ noFilteredRows: "필터와 일치하는 행이 없습니다", noFilteredRowsDescription: "검색 텍스트나 행 상태 필터를 조정하세요.", copy: "복사", + copyExtractorSmart: "스마트 복사", + copyExtractorRaw: "셀 원문", copyExtractorWithHeaders: "머리글 포함 {format}", copyExtractorOneRow: "단일 행", copyExtractorJsonArray: "JSON 배열", @@ -1050,7 +1052,7 @@ export default withEnglishFallback({ copyExtractorPipeSeparated: "파이프(|) 구분", copyExtractorPretty: "보기 좋은 표", copyExtractorConfigure: "데이터 추출기 설정…", - copyExtractorDefaultFormat: "기본 복사 형식", + copyExtractorDefaultFormat: "기본 복사 동작", copyExtractorFormat: "추출기", copyExtractorColumnSeparator: "열 구분자", copyExtractorRowSeparator: "행 구분자", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index cb50032ea3..5490851374 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -1442,6 +1442,8 @@ export default withEnglishFallback({ truncatedHint: "Os resultados foram truncados após carregar {count} linhas. Use a paginação no rodapé para navegar pelos dados carregados; exportar o resultado completo executa novamente a consulta no banco de dados.", filterBuilderSearchColumns: "Pesquisar campos...", filterBuilderNoMatchingColumns: "Nenhum campo correspondente", + copyExtractorSmart: "Cópia inteligente", + copyExtractorRaw: "Texto da célula", copyExtractorWithHeaders: "{format} (com cabeçalhos)", copyExtractorOneRow: "CSV de linha única", copyExtractorJsonArray: "Array JSON", @@ -1454,7 +1456,7 @@ export default withEnglishFallback({ copyExtractorPipeSeparated: "Separado por pipe", copyExtractorPretty: "Tabela formatada", copyExtractorConfigure: "Configurar extrator de dados…", - copyExtractorDefaultFormat: "Formato de cópia padrão", + copyExtractorDefaultFormat: "Comportamento de cópia padrão", copyExtractorFormat: "Extrator", copyExtractorColumnSeparator: "Separador de coluna", copyExtractorRowSeparator: "Separador de linha", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 3a6c58c49d..77e77afc62 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1124,6 +1124,8 @@ export default withEnglishFallback({ noFilteredRows: "没有符合条件的行", noFilteredRowsDescription: "调整搜索词或状态筛选试试。", copy: "复制", + copyExtractorSmart: "智能复制", + copyExtractorRaw: "单元格原文", copyExtractorWithHeaders: "{format}(含表头)", copyExtractorOneRow: "单行 CSV", copyExtractorJsonArray: "JSON 数组", @@ -1135,8 +1137,8 @@ export default withEnglishFallback({ copyExtractorWhereClause: "WHERE 条件", copyExtractorPipeSeparated: "竖线分隔", copyExtractorPretty: "表格文本", - copyExtractorConfigure: "设置复制格式…", - copyExtractorDefaultFormat: "默认复制格式", + copyExtractorConfigure: "配置数据提取器…", + copyExtractorDefaultFormat: "默认复制方式", copyExtractorFormat: "提取器", copyExtractorColumnSeparator: "列分隔符", copyExtractorRowSeparator: "行分隔符", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 7fc8445ebe..52602bd713 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1441,6 +1441,8 @@ export default withEnglishFallback({ truncatedHint: "結果已截斷,已載入前 {count} 筆。可透過底部分頁瀏覽已載入資料;匯出完整結果時會重新查詢資料庫。", filterBuilderSearchColumns: "搜尋欄位...", filterBuilderNoMatchingColumns: "沒有匹配的欄位", + copyExtractorSmart: "智慧複製", + copyExtractorRaw: "儲存格原文", copyExtractorWithHeaders: "{format}(含表頭)", copyExtractorOneRow: "單行 CSV", copyExtractorJsonArray: "JSON 陣列", @@ -1453,7 +1455,7 @@ export default withEnglishFallback({ copyExtractorPipeSeparated: "豎線分隔", copyExtractorPretty: "表格文本", copyExtractorConfigure: "配置資料提取器…", - copyExtractorDefaultFormat: "預設複製格式", + copyExtractorDefaultFormat: "預設複製方式", copyExtractorFormat: "提取器", copyExtractorColumnSeparator: "列分隔符", copyExtractorRowSeparator: "行分隔符", diff --git a/apps/desktop/src/lib/__tests__/dataGrid/dataGridCopyExtractor.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/dataGridCopyExtractor.spec.ts index edc58f5373..14a4ce2a3b 100644 --- a/apps/desktop/src/lib/__tests__/dataGrid/dataGridCopyExtractor.spec.ts +++ b/apps/desktop/src/lib/__tests__/dataGrid/dataGridCopyExtractor.spec.ts @@ -1,7 +1,15 @@ import { describe, expect, it } from "vitest"; -import { DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, normalizeDataGridExtractorOptions, validateDataGridExtractorOptions } from "@/lib/dataGrid/dataGridCopyExtractor"; +import { DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, normalizeDataGridCopyPreference, normalizeDataGridExtractorOptions, resolveDataGridCopyPreference, validateDataGridExtractorOptions } from "@/lib/dataGrid/dataGridCopyExtractor"; describe("data-grid extractor options", () => { + it("resolves smart copy to raw for one cell and TSV otherwise", () => { + expect(normalizeDataGridCopyPreference(undefined)).toBe("smart"); + expect(normalizeDataGridCopyPreference("smart")).toBe("smart"); + expect(resolveDataGridCopyPreference("smart", 1)).toBe("raw"); + expect(resolveDataGridCopyPreference("smart", 2)).toBe("tsv"); + expect(resolveDataGridCopyPreference("csv", 1)).toBe("csv"); + }); + it("normalizes persisted values without sharing the default object", () => { const normalized = normalizeDataGridExtractorOptions(DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS); diff --git a/apps/desktop/src/lib/dataGrid/dataGridCopyExtractor.ts b/apps/desktop/src/lib/dataGrid/dataGridCopyExtractor.ts index b3f8bae418..d382a91834 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridCopyExtractor.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridCopyExtractor.ts @@ -1,14 +1,37 @@ import type { DatabaseType } from "@/types/database"; import type { DataGridCopyInsertMode, DataGridTableMeta } from "@/lib/dataGrid/dataGridSql"; -export const DATA_GRID_COPY_EXTRACTOR_IDS = ["tsv", "tsv-with-headers", "csv", "csv-with-headers", "pipe-separated", "dsv", "json", "json-lines", "one-row", "sql-in-list", "sql-inserts", "sql-updates", "where-clause", "markdown", "html", "xml", "pretty"] as const; +export const DATA_GRID_COPY_EXTRACTOR_IDS = ["raw", "tsv", "tsv-with-headers", "csv", "csv-with-headers", "pipe-separated", "dsv", "json", "json-lines", "one-row", "sql-in-list", "sql-inserts", "sql-updates", "where-clause", "markdown", "html", "xml", "pretty"] as const; export type DataGridCopyExtractorId = (typeof DATA_GRID_COPY_EXTRACTOR_IDS)[number]; -export type DataGridExtractorCategory = "delimited" | "json" | "sql" | "document"; +export type DataGridCopyPreference = "smart" | Exclude; +export type DataGridExtractorCategory = "raw" | "delimited" | "json" | "sql" | "document"; + +export const DATA_GRID_DEFAULT_COPY_PREFERENCES: readonly DataGridCopyPreference[] = [ + "smart", + "tsv", + "tsv-with-headers", + "csv", + "csv-with-headers", + "pipe-separated", + "dsv", + "json", + "json-lines", + "one-row", + "sql-in-list", + "sql-inserts", + "sql-updates", + "where-clause", + "markdown", + "html", + "xml", + "pretty", +]; export const DATA_GRID_EXTRACTOR_CONTRACT_VERSION = 1 as const; export const DATA_GRID_COPY_EXTRACTOR_DESCRIPTORS: Record = { + raw: { category: "raw" }, tsv: { category: "delimited" }, "tsv-with-headers": { category: "delimited" }, csv: { category: "delimited" }, @@ -154,10 +177,15 @@ export function normalizeDataGridExtractorOptions(value: unknown): DataGridExtra }; } -const DATA_GRID_COPY_EXTRACTOR_ID_SET = new Set(DATA_GRID_COPY_EXTRACTOR_IDS); +const DATA_GRID_COPY_PREFERENCE_SET = new Set(DATA_GRID_DEFAULT_COPY_PREFERENCES); -export function normalizeDataGridCopyExtractorId(value: unknown): DataGridCopyExtractorId { - return typeof value === "string" && DATA_GRID_COPY_EXTRACTOR_ID_SET.has(value) ? (value as DataGridCopyExtractorId) : "tsv"; +export function normalizeDataGridCopyPreference(value: unknown): DataGridCopyPreference { + return typeof value === "string" && DATA_GRID_COPY_PREFERENCE_SET.has(value) ? (value as DataGridCopyPreference) : "smart"; +} + +export function resolveDataGridCopyPreference(preference: DataGridCopyPreference, selectedCellCount: number): DataGridCopyExtractorId { + if (preference !== "smart") return preference; + return selectedCellCount === 1 ? "raw" : "tsv"; } export function validateDataGridExtractorOptions(extractor: DataGridCopyExtractorId, options: DataGridExtractorOptions): DataGridExtractorOptionsError | null { diff --git a/apps/desktop/src/stores/__tests__/settingsStore.spec.ts b/apps/desktop/src/stores/__tests__/settingsStore.spec.ts index 01b9a73fa9..9a5f01b9cc 100644 --- a/apps/desktop/src/stores/__tests__/settingsStore.spec.ts +++ b/apps/desktop/src/stores/__tests__/settingsStore.spec.ts @@ -131,11 +131,13 @@ describe("normalizeEditorSettings", () => { expect(normalizeEditorSettings({ dataGridSearchMode: "invalid" as any }).dataGridSearchMode).toBe("filter"); }); - it("defaults the global data grid copy extractor and preserves valid choices", () => { - expect(normalizeEditorSettings({}).dataGridCopyExtractor).toBe("tsv"); + it("defaults the global data grid copy preference and preserves valid choices", () => { + expect(normalizeEditorSettings({}).dataGridCopyExtractor).toBe("smart"); + expect(normalizeEditorSettings({ dataGridCopyExtractor: "smart" }).dataGridCopyExtractor).toBe("smart"); + expect(normalizeEditorSettings({ dataGridCopyExtractor: "tsv" }).dataGridCopyExtractor).toBe("tsv"); expect(normalizeEditorSettings({ dataGridCopyExtractor: "sql-updates" }).dataGridCopyExtractor).toBe("sql-updates"); expect(normalizeEditorSettings({ dataGridCopyExtractor: "markdown" }).dataGridCopyExtractor).toBe("markdown"); - expect(normalizeEditorSettings({ dataGridCopyExtractor: "invalid" as any }).dataGridCopyExtractor).toBe("tsv"); + expect(normalizeEditorSettings({ dataGridCopyExtractor: "invalid" as any }).dataGridCopyExtractor).toBe("smart"); }); it("normalizes persistent extractor configuration fail-fast defaults", () => { diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts index dfc4f00855..0c49a32ce3 100644 --- a/apps/desktop/src/stores/settingsStore.ts +++ b/apps/desktop/src/stores/settingsStore.ts @@ -5,7 +5,7 @@ import { generateId, getConfigKey, aiConfigToItem } from "@/lib/ai/aiConfigList" import { normalizeColumnFormatter, normalizeCustomColumnFormatter, normalizeGlobalDateTimePattern, type ColumnFormatterConfig, type CustomColumnFormatterConfig } from "@/lib/dataGrid/columnFormatter"; import { normalizeShortcutSettings, type ShortcutSettings } from "@/lib/editor/shortcutRegistry"; import { normalizeResultPageSize } from "@/lib/dataGrid/paginationPageSize"; -import { DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, normalizeDataGridCopyExtractorId, normalizeDataGridExtractorOptions, type DataGridCopyExtractorId, type DataGridExtractorOptions } from "@/lib/dataGrid/dataGridCopyExtractor"; +import { DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, normalizeDataGridCopyPreference, normalizeDataGridExtractorOptions, type DataGridCopyPreference, type DataGridExtractorOptions } from "@/lib/dataGrid/dataGridCopyExtractor"; import { normalizeSidebarHiddenTablePrefixes } from "@/lib/sidebar/sidebarTableNameDisplay"; import type { ConnectionListSortMode } from "@/lib/sidebar/connectionListSort"; import { DEFAULT_SQL_FORMATTER_SETTINGS, normalizeSqlFormatterSettings, type SqlFormatterSettings } from "@/lib/sql/sqlFormatterConfig"; @@ -474,7 +474,7 @@ export interface EditorSettings { dataGridQuickEntry: boolean; dataGridRenderMode: DataGridRenderMode; dataGridSearchMode: DataGridSearchMode; - dataGridCopyExtractor: DataGridCopyExtractorId; + dataGridCopyExtractor: DataGridCopyPreference; dataGridExtractorOptions: DataGridExtractorOptions; resultRunDisplayMode: ResultRunDisplayMode; dataGridAutoTransposeSingleRow: boolean; @@ -652,7 +652,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = { dataGridQuickEntry: false, dataGridRenderMode: "canvas", dataGridSearchMode: "filter", - dataGridCopyExtractor: "tsv", + dataGridCopyExtractor: "smart", dataGridExtractorOptions: normalizeDataGridExtractorOptions(DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS), resultRunDisplayMode: "tabs", dataGridAutoTransposeSingleRow: false, @@ -961,7 +961,7 @@ export function normalizeEditorSettings(settings: Partial, exist dataGridQuickEntry: settings.dataGridQuickEntry ?? DEFAULT_EDITOR_SETTINGS.dataGridQuickEntry, dataGridRenderMode: normalizeDataGridRenderMode(settings.dataGridRenderMode), dataGridSearchMode: normalizeDataGridSearchMode(settings.dataGridSearchMode), - dataGridCopyExtractor: normalizeDataGridCopyExtractorId(settings.dataGridCopyExtractor), + dataGridCopyExtractor: normalizeDataGridCopyPreference(settings.dataGridCopyExtractor), dataGridExtractorOptions: normalizeDataGridExtractorOptions(settings.dataGridExtractorOptions), resultRunDisplayMode: normalizeResultRunDisplayMode(settings.resultRunDisplayMode), dataGridAutoTransposeSingleRow: settings.dataGridAutoTransposeSingleRow === true, @@ -1454,7 +1454,7 @@ export const useSettingsStore = defineStore("settings", () => { if (partial.dataGridQuickEntry !== undefined) editorSettings.value.dataGridQuickEntry = partial.dataGridQuickEntry; if (partial.dataGridRenderMode !== undefined) editorSettings.value.dataGridRenderMode = normalizeDataGridRenderMode(partial.dataGridRenderMode); if (partial.dataGridSearchMode !== undefined) editorSettings.value.dataGridSearchMode = normalizeDataGridSearchMode(partial.dataGridSearchMode); - if (partial.dataGridCopyExtractor !== undefined) editorSettings.value.dataGridCopyExtractor = normalizeDataGridCopyExtractorId(partial.dataGridCopyExtractor); + if (partial.dataGridCopyExtractor !== undefined) editorSettings.value.dataGridCopyExtractor = normalizeDataGridCopyPreference(partial.dataGridCopyExtractor); if (partial.dataGridExtractorOptions !== undefined) editorSettings.value.dataGridExtractorOptions = normalizeDataGridExtractorOptions(partial.dataGridExtractorOptions); if (partial.resultRunDisplayMode !== undefined) editorSettings.value.resultRunDisplayMode = normalizeResultRunDisplayMode(partial.resultRunDisplayMode); if (partial.dataGridAutoTransposeSingleRow !== undefined) editorSettings.value.dataGridAutoTransposeSingleRow = partial.dataGridAutoTransposeSingleRow === true; diff --git a/crates/dbx-core/src/data_grid_extractors/contract.rs b/crates/dbx-core/src/data_grid_extractors/contract.rs index 6518ad91bf..57b5a91ced 100644 --- a/crates/dbx-core/src/data_grid_extractors/contract.rs +++ b/crates/dbx-core/src/data_grid_extractors/contract.rs @@ -18,6 +18,7 @@ const DEFAULT_JSON_PRETTY: bool = true; #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] #[serde(rename_all = "kebab-case")] pub enum DataGridExtractorId { + Raw, Tsv, TsvWithHeaders, Csv, @@ -196,6 +197,7 @@ pub struct DataGridExtractResult { pub enum DataGridExtractErrorCode { UnsupportedVersion, EmptySelection, + InvalidRawSelection, InvalidColumnIndex, InvalidColumnMapping, MissingTableMetadata, diff --git a/crates/dbx-core/src/data_grid_extractors/mod.rs b/crates/dbx-core/src/data_grid_extractors/mod.rs index 820d01495c..45100512b7 100644 --- a/crates/dbx-core/src/data_grid_extractors/mod.rs +++ b/crates/dbx-core/src/data_grid_extractors/mod.rs @@ -6,12 +6,14 @@ mod contract; mod delimited; mod document; mod json; +mod raw; mod sql; pub use contract::*; use delimited::{write_dsv, write_one_row}; use document::{write_html, write_markdown, write_pretty, write_xml}; use json::{write_json, write_json_lines}; +use raw::write_raw; use sql::{write_sql_in_list, write_sql_inserts, write_sql_updates, write_where_clause}; const DATA_GRID_EXTRACTOR_MAX_OUTPUT_BYTES: usize = 32 * 1024 * 1024; @@ -251,6 +253,16 @@ fn write_extraction( ) -> Result { let extractor = context.request.extractor; match extractor { + DataGridExtractorId::Raw => { + if context.request.rows.len() != 1 || context.selected_source_indexes.len() != 1 { + return Err(DataGridExtractError::new( + DataGridExtractErrorCode::InvalidRawSelection, + "The raw extractor supports exactly one selected cell.", + )); + } + write_raw(context, output)?; + Ok(text_metadata("text/plain", "txt")) + } DataGridExtractorId::Tsv | DataGridExtractorId::TsvWithHeaders => { let options = DataGridDsvOptions { column_separator: "\t".to_string(), diff --git a/crates/dbx-core/src/data_grid_extractors/raw.rs b/crates/dbx-core/src/data_grid_extractors/raw.rs new file mode 100644 index 0000000000..9da079c3c7 --- /dev/null +++ b/crates/dbx-core/src/data_grid_extractors/raw.rs @@ -0,0 +1,19 @@ +use super::{value_text, write_bytes, DataGridExtractError, ExtractContext}; +use std::io::Write; + +/// Writes selected cell values without DSV escaping. Tabs and newlines retain +/// the selection shape, while each value itself remains unchanged. +pub(super) fn write_raw(context: &ExtractContext<'_>, output: &mut dyn Write) -> Result<(), DataGridExtractError> { + for (row_index, row) in context.request.rows.iter().enumerate() { + if row_index > 0 { + write_bytes(output, b"\n")?; + } + for (column_index, source_index) in context.selected_source_indexes.iter().enumerate() { + if column_index > 0 { + write_bytes(output, b"\t")?; + } + write_bytes(output, value_text(&row[*source_index]).as_bytes())?; + } + } + Ok(()) +} diff --git a/crates/dbx-core/src/data_grid_extractors/tests.rs b/crates/dbx-core/src/data_grid_extractors/tests.rs index 5230015573..68630206c4 100644 --- a/crates/dbx-core/src/data_grid_extractors/tests.rs +++ b/crates/dbx-core/src/data_grid_extractors/tests.rs @@ -50,6 +50,27 @@ fn extracts_csv_with_minimal_standard_quoting() { assert_eq!(result.text, "1,Ada\n2,\"Grace, Hopper\""); } +#[test] +fn extracts_raw_values_without_escaping_quotes() { + let mut request = request(DataGridExtractorId::Raw); + request.columns = vec![column("payload", 0)]; + request.selected_column_indexes = vec![0]; + request.rows = vec![vec![json!(r#"{"msg":"success"}"#)]]; + + let result = extract_data_grid_selection(request).expect("raw extraction"); + + assert_eq!(result.text, r#"{"msg":"success"}"#); + assert_eq!(result.mime_type, "text/plain"); +} + +#[test] +fn raw_rejects_multiple_selected_cells() { + let error = + extract_data_grid_selection(request(DataGridExtractorId::Raw)).expect_err("raw must reject multiple cells"); + + assert_eq!(error.code, DataGridExtractErrorCode::InvalidRawSelection); +} + #[test] fn csv_distinguishes_null_from_null_string() { let mut request = request(DataGridExtractorId::Csv);