feat(grid): 新增原始数据复制模式并设为默认

*  feat(grid): 新增原始数据复制模式并设为默认

- 原样输出单元格内容,不对引号、制表符和换行进行 DSV 转义
- 支持原始数据复制后的网格粘贴还原
- 补充多语言复制选项文案及前后端提取器实现与测试

*  feat(grid): 优化新增智能复制选项

- 单个单元格复制保留原始单元格文本,多单元格自动使用转义 TSV
- 默认复制设置由格式选择升级为复制行为选择,并支持预览
- 限制原始文本格式仅用于单单元格复制
- 更新多语言文案及相关测试
This commit is contained in:
二丫讲梵 2026-08-05 23:58:01 +08:00 committed by GitHub
parent e749bdd42c
commit 930aa48c20
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 333 additions and 91 deletions

View File

@ -1640,10 +1640,10 @@ defineExpose({ focusSearch });
</span>
</label>
<DataGridCopyFormatControl
:current-label="dataGridRef?.defaultCopyExtractorLabel ?? '-'"
:current-value="dataGridRef?.defaultCopyExtractor ?? ''"
:items="dataGridRef?.copyExtractorMenuItems ?? []"
@select="dataGridRef?.setDefaultCopyExtractor($event)"
:current-label="dataGridRef?.defaultCopyPreferenceLabel ?? '-'"
:current-value="dataGridRef?.defaultCopyPreference ?? ''"
:items="dataGridRef?.copyPreferenceMenuItems ?? []"
@select="dataGridRef?.setDefaultCopyPreference($event)"
@configure="openDataGridExtractorConfiguration"
/>
</PopoverContent>

View File

@ -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<DataGridCopyExtractorId, string> = {
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<ContextMenuItem[]>(() => {
<DataGridBulkEditDialog v-if="bulkEditDialogMounted" v-model:open="bulkEditDialogOpen" v-model:value="bulkEditValue" :selected-cell-count="selectedCellCount" @apply="applyBulkEditValue" />
<DataGridInsertRowsDialog v-if="insertRowsDialogMounted" v-model:open="insertRowsDialogOpen" :can-place-at-selection="canPlaceInsertAtSelection" :initial-position="insertPosition" @insert="insertRows" />
<DataGridExtractorDialog v-model:open="extractorConfigOpen" :extractor="selectedCopyExtractor" :options="settingsStore.editorSettings.dataGridExtractorOptions" :items="extractorMenuItems" :preview="previewWithExtractor" @save="saveExtractorConfiguration" />
<DataGridExtractorDialog v-model:open="extractorConfigOpen" :preference="selectedCopyPreference" :options="settingsStore.editorSettings.dataGridExtractorOptions" :items="copyPreferenceMenuItems" :preview="previewWithPreference" @save="saveExtractorConfiguration" />
<DataGridCopyColumnNamesDialog v-if="copyColumnNamesDialogMounted" v-model:open="copyColumnNamesDialogOpen" :column-names="copyColumnNamesDialogColumns" :database-type="resolvedDatabaseType" :column-comments="columnCommentMap" @copy="copyText" />
<Dialog v-model:open="generateIncrementDialogOpen">

View File

@ -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<DataGridExtractPreview>;
items: Array<{ value: DataGridCopyPreference; label: string; disabled?: boolean }>;
preview: (preference: DataGridCopyPreference, options: DataGridExtractorOptions) => Promise<DataGridExtractPreview>;
}>();
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<DataGridCopyExtractorId>(props.extractor);
const draftPreference = ref<DataGridCopyPreference>(props.preference);
const draftOptions = ref<DataGridExtractorOptions>(normalizeDataGridExtractorOptions(props.options));
const previewText = ref("");
const previewError = ref("");
@ -32,11 +41,13 @@ const previewTruncatedRows = ref(0);
let previewSequence = 0;
let previewTimer: ReturnType<typeof setTimeout> | undefined;
const extractorCategory = computed(() => DATA_GRID_COPY_EXTRACTOR_DESCRIPTORS[draftExtractor.value].category);
const draftExtractor = computed<DataGridCopyExtractorId | null>(() => (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);
}
</script>
@ -142,7 +153,7 @@ function save() {
<div class="space-y-3">
<div class="space-y-1.5">
<Label>{{ t("grid.copyExtractorDefaultFormat") }}</Label>
<Select v-model="draftExtractor">
<Select v-model="draftPreference">
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem v-for="item in items" :key="item.value" :value="item.value" :disabled="item.disabled">{{ item.label }}</SelectItem>

View File

@ -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
<Switch size="sm" :model-value="!!dataGridRef?.nullColumnsHidden" :disabled="!dataGridRef?.canToggleAllNullColumns" :aria-label="t('grid.hideNullColumns')" @update:model-value="dataGridRef?.toggleAllNullColumns()" />
</div>
<DataGridCopyFormatControl
:current-label="dataGridRef?.defaultCopyExtractorLabel ?? '-'"
:current-value="dataGridRef?.defaultCopyExtractor ?? ''"
:items="dataGridRef?.copyExtractorMenuItems ?? []"
@select="dataGridRef?.setDefaultCopyExtractor($event)"
:current-label="dataGridRef?.defaultCopyPreferenceLabel ?? '-'"
:current-value="dataGridRef?.defaultCopyPreference ?? ''"
:items="dataGridRef?.copyPreferenceMenuItems ?? []"
@select="dataGridRef?.setDefaultCopyPreference($event)"
@configure="openDataGridExtractorConfiguration"
/>
</PopoverContent>
@ -1732,10 +1732,10 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
<Switch size="sm" :model-value="!!dataGridRef?.nullColumnsHidden" :disabled="!dataGridRef?.canToggleAllNullColumns" :aria-label="t('grid.hideNullColumns')" @update:model-value="dataGridRef?.toggleAllNullColumns()" />
</div>
<DataGridCopyFormatControl
:current-label="dataGridRef?.defaultCopyExtractorLabel ?? '-'"
:current-value="dataGridRef?.defaultCopyExtractor ?? ''"
:items="dataGridRef?.copyExtractorMenuItems ?? []"
@select="dataGridRef?.setDefaultCopyExtractor($event)"
:current-label="dataGridRef?.defaultCopyPreferenceLabel ?? '-'"
:current-value="dataGridRef?.defaultCopyPreference ?? ''"
:items="dataGridRef?.copyPreferenceMenuItems ?? []"
@select="dataGridRef?.setDefaultCopyPreference($event)"
@configure="openDataGridExtractorConfiguration"
/>
</PopoverContent>

View File

@ -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],

View File

@ -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(),
}),
}));

View File

@ -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,

View File

@ -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<boolean> {
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<DataGridExtractPreview> {
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 {

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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: "行区切り文字",

View File

@ -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: "행 구분자",

View File

@ -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",

View File

@ -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: "行分隔符",

View File

@ -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: "行分隔符",

View File

@ -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);

View File

@ -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<DataGridCopyExtractorId, "raw">;
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<DataGridCopyExtractorId, { category: DataGridExtractorCategory; separatorBefore?: boolean }> = {
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<string>(DATA_GRID_COPY_EXTRACTOR_IDS);
const DATA_GRID_COPY_PREFERENCE_SET = new Set<string>(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 {

View File

@ -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", () => {

View File

@ -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<EditorSettings>, 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;

View File

@ -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,

View File

@ -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<WriteMetadata, DataGridExtractError> {
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(),

View File

@ -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(())
}

View File

@ -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);