feat(data-grid): add multi-format copy extractors

This commit is contained in:
soddygo authored and GitHub committed 2026-07-27 16:11:53 +08:00
1 parent 166027600f
commit 0b7a886cfd
60 files changed
+4615 -1766

No files matched your search

+213 -155
View File
@@ -58,6 +58,7 @@ import DataGridCellDetailPanel from "@/components/grid/DataGridCellDetailPanel.v
import DataGridPagination from "@/components/grid/DataGridPagination.vue";
import DataGridSearchBar from "@/components/grid/DataGridSearchBar.vue";
import DataGridToolbar from "@/components/grid/DataGridToolbar.vue";
import DataGridExtractorDialog from "@/components/grid/DataGridExtractorDialog.vue";
import DataGridColumnHeader from "@/components/grid/DataGridColumnHeader.vue";
import DataGridQueryControls from "@/components/grid/DataGridQueryControls.vue";
import TemporalCellEditor from "@/components/grid/TemporalCellEditor.vue";
@@ -70,6 +71,7 @@ import { tableInfoTabForDrawerToggle } from "@/lib/table/tableInfoTabPreference"
import * as api from "@/lib/backend/api";
import { formatElapsedSeconds } from "@/lib/common/elapsedTime";
import { dataGridCellDisplayText, dataGridCellEditorText } from "@/lib/dataGrid/dataGridCellCoercion";
import { dataGridActiveRowBackground } from "@/lib/dataGrid/dataGridPaintTheme";
import { createColumnDrafts } from "@/lib/table/tableStructureEditorState";
import type { BuildSingleColumnAlterSqlOptions } from "@/lib/table/tableStructureEditorSql";
import { buildTableSelectSql, quoteTableDataIdentifier } from "@/lib/table/tableSelectSql";
@@ -180,6 +182,7 @@ import { useToast } from "@/composables/useToast";
import { useDataGridExport, type MongoCopyUpdateTarget } from "@/composables/useDataGridExport";
import { eventTargetAllowsNativeClipboard, isPlainClipboardShortcut, readTextFromClipboard } from "@/lib/common/clipboard";
import { claimDataGridPaste, clearDataGridClipboardCopy, parseDataGridClipboard, planDataGridPaste } from "@/lib/dataGrid/dataGridClipboard";
import { DATA_GRID_COPY_EXTRACTOR_DESCRIPTORS, DATA_GRID_COPY_EXTRACTOR_IDS, extractorUnavailableForDatabase, type DataGridCopyExtractorId } from "@/lib/dataGrid/dataGridCopyExtractor";
import { columnNamesForCopy } from "@/lib/dataGrid/dataGridColumnNameCopy";
import { DATA_GRID_ROW_NUM_WIDTH, useDataGridColumnResize } from "@/composables/useDataGridColumnResize";
import { createDataGridColumnStructureSignature } from "@/lib/dataGrid/dataGridColumnWidthState";
@@ -204,7 +207,7 @@ import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
import { useSettingsStore } from "@/stores/settingsStore";
import type { DataGridSortDirection, DataGridSortMode } from "@/lib/dataGrid/dataGridSort";
import { DATA_GRID_COMPACT_TOPBAR_WIDTH, type DataGridReloadIntent, type DataGridToolbarActionCapability, type DataGridToolbarAutoRefreshCapability, type DataGridToolbarSaveCapability } from "@/lib/dataGrid/dataGridToolbar";
import { DATA_GRID_COMPACT_TOPBAR_WIDTH, type DataGridReloadIntent, type DataGridToolbarActionCapability, type DataGridToolbarAutoRefreshCapability, type DataGridToolbarCopyCapability, type DataGridToolbarSaveCapability } from "@/lib/dataGrid/dataGridToolbar";
import { getTableMetadataCapabilities } from "@/lib/table/tableMetadataCapabilities";
import { getTableStructureCapabilities } from "@/lib/table/tableStructureCapabilities";
import { filterObjectBrowserTableColumns } from "@/lib/table/objectBrowserTableInfo";
@@ -550,9 +553,19 @@ function typeColorClass(t: string): string {
return "text-muted-foreground";
}
const contextCell = ref<{ rowId: number; rowIndex: number; col: number } | null>(null);
// True when onCellContext created a synthetic single-cell selection (the cell
// was not previously selected). Lets buildRequest distinguish a genuine 1×1
// selection (user Ctrl+click) from a synthetic one (right-click on unselected).
const contextSelectionIsSynthetic = ref(false);
const contextHeaderColumn = ref<string | null>(null);
const contextHeaderColumnIndex = ref<number | null>(null);
const contextHeaderVisibleColIdx = ref<number | null>(null);
function invalidateSyntheticContextSelection() {
contextSelectionIsSynthetic.value = false;
contextCell.value = null;
}
const bulkEditDialogOpen = ref(false);
const bulkEditValue = ref("");
const copyColumnNamesDialogOpen = ref(false);
@@ -3433,6 +3446,8 @@ const selection = useDataGridSelection({
gridRef,
getScrollElement: dataGridSelectionScroller,
cellFromClientPoint: dataGridCellFromClientPoint,
rowFromClientPoint: dataGridRowFromClientPoint,
onUserCellSelection: invalidateSyntheticContextSelection,
runtimeScope: dataGridRuntimeScope,
});
@@ -3461,6 +3476,7 @@ const {
hasColumnSelection,
clearRowSelection,
handleRowClick,
beginRowSelection,
handleDataCellMousedown,
isRowSelected,
} = selection;
@@ -4359,28 +4375,31 @@ function rowNumberStatusClass(item: RowItem): string {
}
function rowCellsUseSelectionVisual(rowId: number): boolean {
return hasRowSelection.value && isRowSelected(rowId) && !hasCellSelection.value;
return hasRowSelection.value && isRowSelected(rowId);
}
function dataGridRowStyle(item: RowItem): CSSProperties {
const dark = isDark.value || (typeof document !== "undefined" && document.documentElement.classList.contains("dark"));
const activeRowBg = dataGridActiveRowBackground(dark);
const rowBg = item.isDeleted
? dark
? "rgb(55, 31, 32)"
: "rgb(255, 244, 244)"
: item.isNew
? dark
? "rgb(51, 51, 55)"
: "rgb(243, 243, 243)"
: item.isDraft
: isRowActive(item.displayIndex)
? activeRowBg
: item.isNew
? dark
? "rgb(51, 51, 55)"
: "rgb(243, 243, 243)"
: item.displayIndex % 2 === 1
? `var(--data-grid-row-muted-bg, ${dark ? DATA_GRID_DARK_STRIPED_ROW_BG : DATA_GRID_LIGHT_STRIPED_ROW_BG})`
: dark
? "rgb(19, 20, 22)"
: "rgb(255, 255, 255)";
: item.isDraft
? dark
? "rgb(51, 51, 55)"
: "rgb(243, 243, 243)"
: item.displayIndex % 2 === 1
? `var(--data-grid-row-muted-bg, ${dark ? DATA_GRID_DARK_STRIPED_ROW_BG : DATA_GRID_LIGHT_STRIPED_ROW_BG})`
: dark
? "rgb(19, 20, 22)"
: "rgb(255, 255, 255)";
const rowNumberBg =
item.status === "new"
? dark
@@ -4395,20 +4414,19 @@ function dataGridRowStyle(item: RowItem): CSSProperties {
? "rgb(55, 31, 32)"
: "rgb(255, 244, 244)"
: isRowActive(item.displayIndex) && !item.isDeleted
? dark
? "rgb(66, 67, 70)"
: "rgb(226, 226, 226)"
? activeRowBg
: dark
? "rgb(35, 37, 42)"
: "rgb(255, 255, 255)";
return {
"--data-grid-cell-bg": rowBg,
"--data-grid-row-number-bg": rowNumberBg,
"--data-grid-cell-selected-bg": dark ? "rgb(66, 67, 70)" : "rgb(226, 226, 226)",
"--data-grid-cell-selected-dirty-bg": dark ? "rgb(94, 75, 26)" : "rgb(244, 229, 186)",
"--data-grid-cell-selected-border": dark ? "rgb(170, 170, 175)" : "rgb(90, 90, 90)",
"--data-grid-row-number-active-bg": dark ? "rgb(66, 67, 70)" : "rgb(232, 232, 232)",
"--data-grid-row-number-selected-bg": dark ? "rgb(66, 67, 70)" : "rgb(226, 226, 226)",
"--data-grid-cell-selected-bg": dark ? "rgb(20, 40, 60)" : "rgb(239, 246, 255)",
"--data-grid-cell-selected-single-bg": dark ? "rgb(30, 64, 96)" : "rgb(191, 219, 254)",
"--data-grid-cell-selected-dirty-bg": dark ? "rgb(76, 66, 38)" : "rgb(235, 224, 184)",
"--data-grid-cell-selected-border": dark ? "rgb(96, 165, 250)" : "rgb(59, 130, 246)",
"--data-grid-row-number-active-bg": activeRowBg,
"--data-grid-row-number-selected-bg": dark ? "rgb(30, 64, 96)" : "rgb(191, 219, 254)",
} as CSSProperties;
}
@@ -4490,6 +4508,24 @@ function dataGridCellFromClientPoint(clientX: number, clientY: number): { rowInd
return { rowIndex, colIndex };
}
function dataGridRowFromClientPoint(_clientX: number, clientY: number): number | null {
const scroller = dataGridSelectionScroller();
if (!scroller) return null;
const rect = scroller.getBoundingClientRect();
const clampedY = Math.min(rect.bottom - 1, Math.max(rect.top + 1, clientY));
if (useCanvasGridRows.value) {
const rowIndex = Math.floor((scroller.scrollTop + clampedY - rect.top) / CANVAS_DATA_GRID_ROW_HEIGHT);
const item = displayItemAt(rowIndex);
return item?.displayIndex ?? null;
}
const target = document.elementFromPoint(rect.left + Math.min(DATA_GRID_ROW_NUM_WIDTH / 2, rect.width / 2), clampedY);
const row = target instanceof Element ? target.closest<HTMLElement>("[data-row-index]") : null;
const rowIndex = Number(row?.dataset.rowIndex);
return Number.isInteger(rowIndex) ? rowIndex : null;
}
function syncCanvasViewport() {
if (!dataGridIsActive) return;
const scroller = canvasScrollerElement();
@@ -4755,7 +4791,7 @@ function onCanvasMouseDown(event: MouseEvent) {
commitHiddenCanvasEditBeforeCellInteraction();
if (!item) return;
if (hit.rowNumber) {
handleRowClick(item.displayIndex, item.id, event);
beginRowSelection(item.displayIndex, item.id, event);
} else {
handleDataCellMousedown(item.displayIndex, hit.visibleColIdx, item.id, event);
}
@@ -5080,22 +5116,10 @@ const {
copyText,
copyCell,
copyRow,
copyRowAsInsert,
copyRowAsInsertWithoutPrimaryKeys,
canCopyRowAsInsert,
copyRowAsUpdate,
canCopyRowAsInsertWithoutPrimaryKeys,
canCopyRowAsUpdate,
copyAll,
copySelectionTsv,
copySelectionTsvWithHeaders,
copySelectionCsv,
copySelectionJson,
copySelectionSqlInList,
copySelectionAsInsert,
canCopySelectionAsInsert,
copySelectedRowsTsv,
copySelectedRowsTsvWithHeaders,
copyWithExtractor,
previewWithExtractor,
canCopyWithExtractor,
exportCsv,
exportCurrentPageCsv,
exportJson,
@@ -5116,6 +5140,11 @@ const {
} = useDataGridExport({
columns: visibleColumns,
displayItems: visibleDisplayItems,
allColumns: computed(() => props.result.columns),
allDisplayItems: displayItems,
allSourceColumns: computed(() => props.sourceColumns),
visibleColumnIndexes,
extractorOptions: computed(() => settingsStore.editorSettings.dataGridExtractorOptions),
sql: computed(() => props.sql),
exportSql: computed(() => props.exportSql),
tableMeta: computed(() => (props.tableMeta ? { ...props.tableMeta } : undefined)),
@@ -5132,10 +5161,12 @@ const {
orderBy: computed(() => currentOrderBy()),
exportBatchSize: computed(() => settingsStore.editorSettings.exportBatchSize),
hasCellSelection,
hasColumnSelection,
selectedCells,
selectedCellMatrix,
selectedRange,
contextCell: exportContextCell,
contextSelectionIsSynthetic,
getRowItem: (rowId: number) => visibleDisplayItems.value.find((item) => item.id === rowId),
selectedRowIds,
hasRowSelection,
@@ -5151,6 +5182,61 @@ const {
exportCancelHandler,
});
function copyExtractorLabel(extractor: DataGridCopyExtractorId): string {
const labels: Record<DataGridCopyExtractorId, string> = {
tsv: "TSV",
"tsv-with-headers": t("grid.copyExtractorWithHeaders", { format: "TSV" }),
csv: "CSV",
"csv-with-headers": t("grid.copyExtractorWithHeaders", { format: "CSV" }),
"pipe-separated": t("grid.copyExtractorPipeSeparated"),
dsv: "DSV",
json: "JSON",
"json-lines": "JSON Lines",
"one-row": t("grid.copyExtractorOneRow"),
"sql-in-list": t("grid.copyExtractorSqlInList"),
"sql-inserts": t("grid.copyExtractorSqlInserts"),
"sql-updates": t("grid.copyExtractorSqlUpdates"),
"where-clause": t("grid.copyExtractorWhereClause"),
markdown: "Markdown",
html: "HTML",
xml: "XML",
pretty: t("grid.copyExtractorPretty"),
};
return labels[extractor];
}
const selectedCopyExtractor = computed(() => settingsStore.editorSettings.dataGridCopyExtractor);
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 copyToolbarCapability = computed<DataGridToolbarCopyCapability>(() => ({
label: copyExtractorLabel(selectedCopyExtractor.value),
tooltip: `${t("grid.copy")} (${shortcutMod}+C)`,
currentValue: selectedCopyExtractor.value,
disabled: !canCopyWithExtractor(selectedCopyExtractor.value),
items: [...extractorMenuItems.value, { value: "__configure__", label: t("grid.copyExtractorConfigure"), separatorBefore: true }],
onCopy: async () => {
await copyWithExtractor(selectedCopyExtractor.value);
},
onSelect: (value) => {
if (value === "__configure__") {
extractorConfigOpen.value = true;
return;
}
settingsStore.updateEditorSettings({ dataGridCopyExtractor: value as DataGridCopyExtractorId });
},
}));
function saveExtractorConfiguration(value: { extractor: DataGridCopyExtractorId; options: typeof settingsStore.editorSettings.dataGridExtractorOptions }) {
settingsStore.updateEditorSettings({ dataGridCopyExtractor: value.extractor, dataGridExtractorOptions: value.options });
}
const pageSizeMenuItems = computed(() =>
pageSizeOptions.value.map((size) => ({
value: String(size),
@@ -5252,6 +5338,7 @@ function showCellDetails(rowIndex: number, colIndex: number) {
function showCellDetailsForVisibleCell(rowIndex: number, visibleColIdx: number, actualColIdx: number) {
clearRowSelection();
invalidateSyntheticContextSelection();
selectSingleCell(rowIndex, visibleColIdx);
showCellDetails(rowIndex, actualColIdx);
}
@@ -5339,6 +5426,7 @@ function selectTransposeCell(rowIndex: number, actualColIdx: number, event: Mous
contextHeaderColumnIndex.value = null;
contextHeaderVisibleColIdx.value = null;
clearRowSelection();
invalidateSyntheticContextSelection();
if (event.shiftKey || event.metaKey || event.ctrlKey) {
extendCellSelectionTo(rowIndex, visibleColIdx);
} else {
@@ -5355,6 +5443,7 @@ function showTransposeCellDetails(rowIndex: number, actualColIdx: number) {
contextHeaderColumnIndex.value = null;
contextHeaderVisibleColIdx.value = null;
clearRowSelection();
invalidateSyntheticContextSelection();
selectSingleCell(rowIndex, visibleColIdx);
transposeRowIndex.value = rowIndex;
showCellDetails(rowIndex, actualColIdx);
@@ -5647,7 +5736,7 @@ function generateSelectionMenuItems(disabled: boolean): ContextMenuItem[] {
function cutSelection() {
if (!props.editable || !selectedRange.value) return;
copySelectionTsv();
void copyWithExtractor("tsv");
const range = selectedRange.value;
const allowDraftSelectionValue = selectedRangeTargetsOnlyDraftRow();
beginBatch();
@@ -5780,6 +5869,7 @@ function moveSelectedCell(rowDelta: number, colDelta: number): boolean {
visibleColumnCount: visibleColumnIndexes.value.length,
});
if (!nextPosition) return false;
invalidateSyntheticContextSelection();
selectSingleCell(nextPosition.rowIndex, nextPosition.colIndex);
clearRowSelection();
if (showTranspose.value) transposeRowIndex.value = nextPosition.rowIndex;
@@ -5996,15 +6086,7 @@ async function onGridKeydown(event: KeyboardEvent) {
if (clipboardShortcut(event, "c")) {
if (!hasCellSelection.value && !hasRowSelection.value) return;
event.preventDefault();
if (isTransposeMode.value && hasRowSelection.value) {
copyRow();
return;
}
if (hasCellSelection.value) {
copySelectionTsv();
} else {
copySelectedRowsTsv();
}
await copyWithExtractor(selectedCopyExtractor.value);
return;
}
if (clipboardShortcut(event, "a")) {
@@ -6597,11 +6679,15 @@ function onCellContext(rowId: number, rowIndex: number, colIdx: number, visibleC
contextHeaderVisibleColIdx.value = null;
contextCell.value = { rowId, rowIndex, col: colIdx };
if (hasRowSelection.value && isRowSelected(rowId)) {
contextSelectionIsSynthetic.value = false;
return;
}
clearRowSelection();
if (!cellIsSelected(rowIndex, visibleColIdx)) {
selectSingleCell(rowIndex, visibleColIdx);
contextSelectionIsSynthetic.value = true;
} else {
contextSelectionIsSynthetic.value = false;
}
}
@@ -7409,20 +7495,6 @@ function rowActionLabels() {
};
}
function copyRowLabels() {
const count = multiRowCount.value;
return {
row: isMultiRow.value ? t("grid.copyRows", { count }) : t("grid.copyRow"),
insert: isMultiRow.value ? t("grid.copyRowsInsert", { count }) : t("grid.copyRowInsert"),
insertMerged: t("grid.copyRowsInsertMerged", { count }),
insertRowByRow: t("grid.copyRowsInsertRowByRow", { count }),
insertNoPk: isMultiRow.value ? t("grid.copyRowsInsertWithoutPrimaryKeys", { count }) : t("grid.copyRowInsertWithoutPrimaryKeys"),
insertNoPkMerged: t("grid.copyRowsInsertWithoutPrimaryKeysMerged", { count }),
insertNoPkRowByRow: t("grid.copyRowsInsertWithoutPrimaryKeysRowByRow", { count }),
update: isMultiRow.value ? t("grid.copyRowsUpdate", { count }) : t("grid.copyRowUpdate"),
};
}
function filterSubmenu(): ContextMenuItem {
return createDataGridFilterSubmenu({
label: t("grid.filter"),
@@ -7443,73 +7515,48 @@ function filterSubmenu(): ContextMenuItem {
});
}
function buildExtractorContextItems(): ContextMenuItem[] {
const items: ContextMenuItem[] = [];
for (const extractor of DATA_GRID_COPY_EXTRACTOR_IDS) {
const descriptor = DATA_GRID_COPY_EXTRACTOR_DESCRIPTORS[extractor];
if (descriptor.separatorBefore) {
items.push({ label: "", separator: true });
}
const selected = extractor === selectedCopyExtractor.value;
items.push({
label: `${selected ? "✓ " : ""}${copyExtractorLabel(extractor)}`,
action: () => {
// One-off copy as the chosen format; do NOT persist it as the default —
// the saved default stays controlled by the toolbar/settings dialog.
void copyWithExtractor(extractor);
},
disabled: !canCopyWithExtractor(extractor),
});
}
items.push({ label: "", separator: true });
items.push({
label: t("grid.copyExtractorConfigure"),
action: () => {
extractorConfigOpen.value = true;
},
});
return items;
}
function copySubmenu(): ContextMenuItem {
const labels = copyRowLabels();
const items: ContextMenuItem[] = [];
if (contextColumn.value) {
items.push({ label: t("grid.copyCell"), action: copyCell });
}
items.push({ label: labels.row, action: copyRow });
if (hasRowSelection.value && selectedRowCount.value > 0) {
const singleRowSelected = selectedRowCount.value === 1;
items.push({ label: singleRowSelected ? t("grid.copySelectedRowTsv") : t("grid.copySelectedRowsTsv", { count: selectedRowCount.value }), action: copySelectedRowsTsv });
items.push({ label: singleRowSelected ? t("grid.copySelectedRowTsvWithHeaders") : t("grid.copySelectedRowsTsvWithHeaders", { count: selectedRowCount.value }), action: copySelectedRowsTsvWithHeaders });
}
if (isMultiRow.value) {
items.push({ label: labels.insertMerged, action: () => copyRowAsInsert("merged"), disabled: !canCopyRowAsInsert.value });
items.push({ label: labels.insertRowByRow, action: () => copyRowAsInsert("row-by-row"), disabled: !canCopyRowAsInsert.value });
} else {
items.push({ label: labels.insert, action: () => copyRowAsInsert(), disabled: !canCopyRowAsInsert.value });
}
if (canCopyRowAsInsertWithoutPrimaryKeys.value) {
if (isMultiRow.value) {
items.push({
label: labels.insertNoPkMerged,
action: () => copyRowAsInsertWithoutPrimaryKeys("merged"),
});
items.push({
label: labels.insertNoPkRowByRow,
action: () => copyRowAsInsertWithoutPrimaryKeys("row-by-row"),
});
} else {
items.push({
label: labels.insertNoPk,
action: () => copyRowAsInsertWithoutPrimaryKeys(),
});
}
}
if (canCopyRowAsUpdate.value) {
items.push({ label: labels.update, action: copyRowAsUpdate });
}
items.push({ label: isMultiRow.value ? t("grid.copyRows", { count: multiRowCount.value }) : t("grid.copyRow"), action: copyRow });
items.push({ label: "", separator: true });
items.push(...buildExtractorContextItems());
items.push({ label: "", separator: true });
items.push({ label: t("grid.copyAll"), action: copyAll });
items.push({ label: t("grid.copyColumnNames"), action: openCopyAllColumnNamesDialog });
return { label: t("grid.copy"), icon: Copy, children: items };
}
function selectionSubmenu(): ContextMenuItem {
const insertItems: ContextMenuItem[] =
selectedCells.value.rows.length > 1
? [
{ label: t("grid.copySelectionInsertMerged"), action: () => void copySelectionAsInsert("merged"), disabled: () => !canCopySelectionAsInsert.value },
{ label: t("grid.copySelectionInsertRowByRow"), action: () => void copySelectionAsInsert("row-by-row"), disabled: () => !canCopySelectionAsInsert.value },
]
: [{ label: t("grid.copySelectionInsert"), action: () => void copySelectionAsInsert(), disabled: () => !canCopySelectionAsInsert.value }];
return {
label: t("grid.selection"),
icon: SquareDashed,
children: [
{ label: t("grid.copySelectionTsv"), action: copySelectionTsv },
{ label: t("grid.copySelectionTsvWithHeaders"), action: copySelectionTsvWithHeaders },
{ label: t("grid.copySelectionCsv"), action: copySelectionCsv },
{ label: t("grid.copySelectionJson"), action: copySelectionJson },
{ label: t("grid.copySelectionSql"), action: copySelectionSqlInList },
...insertItems,
{ label: "", separator: true },
{ label: t("grid.clearSelection"), action: clearCellSelection },
],
};
}
function exportSubmenu(): ContextMenuItem {
const items: ContextMenuItem[] = [
{ label: t("grid.exportCsv"), action: exportCsv },
@@ -7629,7 +7676,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
actions: { cellDetails: openContextCellDetailDialog, columnDetails: openContextColumnDetailDialog, rowDetails: openContextRowDetailDialog, setNull: setSelectionNull, bulkEdit: openBulkEditDialog, transpose: openContextTranspose },
downloadItem: binaryDownloadSubmenu(contextCellDetail.value),
copySubmenu: copySubmenu(),
selectionSubmenu: selectionSubmenu(),
clearSelectionItem: { label: t("grid.clearSelection"), action: clearCellSelection, icon: SquareDashed },
generateSubmenu: {
label: t("grid.generateValue"),
icon: WandSparkles,
@@ -7737,6 +7784,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
:compact="compactDataGridToolbar"
:refresh="refreshToolbarCapability"
:auto-refresh="autoRefreshToolbarCapability"
:copy-data="copyToolbarCapability"
:add-row="addRowToolbarCapability"
:preview="previewToolbarCapability"
:save="saveToolbarCapability"
@@ -8566,8 +8614,8 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
>
<div
class="data-grid-row-number w-(--row-num-w) shrink-0 px-2 py-1 border-r text-center select-none cursor-default sticky left-0 z-10"
:class="rowNumberStatusClass(item)"
@click="handleRowClick(item.displayIndex, item.id, $event)"
:class="[rowNumberStatusClass(item), { 'data-grid-row-number--selected': isRowSelected(item.id) }]"
@mousedown="beginRowSelection(item.displayIndex, item.id, $event)"
@dblclick.stop="toggleTranspose(item.displayIndex)"
@contextmenu="onRowContext(item.id, item.displayIndex)"
>
@@ -9181,6 +9229,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
<DataGridBulkEditDialog v-if="bulkEditDialogMounted" v-model:open="bulkEditDialogOpen" v-model:value="bulkEditValue" :selected-cell-count="selectedCellCount" @apply="applyBulkEditValue" />
<DataGridExtractorDialog v-model:open="extractorConfigOpen" :extractor="selectedCopyExtractor" :options="settingsStore.editorSettings.dataGridExtractorOptions" :items="extractorMenuItems" :preview="previewWithExtractor" @save="saveExtractorConfiguration" />
<DataGridCopyColumnNamesDialog v-if="copyColumnNamesDialogMounted" v-model:open="copyColumnNamesDialogOpen" :column-names="copyColumnNamesDialogColumns" :database-type="resolvedDatabaseType" @copy="copyText" />
<Dialog v-model:open="generateIncrementDialogOpen">
@@ -9247,11 +9296,12 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
--data-grid-row-muted-bg: rgb(240, 240, 240);
--data-grid-row-new-bg: rgb(243, 243, 243);
--data-grid-row-deleted-bg: rgb(255, 244, 244);
--data-grid-cell-active-bg: rgb(232, 232, 232);
--data-grid-cell-active-bg: rgb(244, 248, 255);
--data-grid-cell-dirty-bg: rgb(255, 248, 230);
--data-grid-cell-selected-bg: rgb(226, 226, 226);
--data-grid-cell-selected-dirty-bg: rgb(244, 229, 186);
--data-grid-cell-selected-border: rgb(90, 90, 90);
--data-grid-cell-selected-bg: rgb(239, 246, 255);
--data-grid-cell-selected-single-bg: rgb(191, 219, 254);
--data-grid-cell-selected-dirty-bg: rgb(235, 224, 184);
--data-grid-cell-selected-border: rgb(59, 130, 246);
--data-grid-cell-hover-bg: rgb(245, 245, 245);
--data-grid-cell-search-bg: rgb(253, 245, 184);
--data-grid-cell-current-search-bg: rgba(253, 224, 71, 0.52);
@@ -9260,8 +9310,8 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
--data-grid-row-number-new-bg: rgb(219, 244, 233);
--data-grid-row-number-edited-bg: rgb(253, 241, 219);
--data-grid-row-number-deleted-bg: rgb(255, 244, 244);
--data-grid-row-number-active-bg: rgb(232, 232, 232);
--data-grid-row-number-selected-bg: rgb(226, 226, 226);
--data-grid-row-number-active-bg: rgb(244, 248, 255);
--data-grid-row-number-selected-bg: rgb(191, 219, 254);
--data-grid-scrollbar-thumb: color-mix(in oklch, var(--foreground) 30%, transparent);
--data-grid-scrollbar-thumb-hover: color-mix(in oklch, var(--foreground) 48%, transparent);
--data-grid-scrollbar-track: transparent;
@@ -9273,11 +9323,12 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
--data-grid-row-muted-bg: rgb(40, 40, 43);
--data-grid-row-new-bg: rgb(51, 51, 55);
--data-grid-row-deleted-bg: rgb(55, 31, 32);
--data-grid-cell-active-bg: rgb(64, 64, 64);
--data-grid-cell-active-bg: rgb(25, 34, 46);
--data-grid-cell-dirty-bg: rgb(94, 75, 26);
--data-grid-cell-selected-bg: rgb(66, 67, 70);
--data-grid-cell-selected-dirty-bg: rgb(94, 75, 26);
--data-grid-cell-selected-border: rgb(170, 170, 175);
--data-grid-cell-selected-bg: rgb(20, 40, 60);
--data-grid-cell-selected-single-bg: rgb(30, 64, 96);
--data-grid-cell-selected-dirty-bg: rgb(76, 66, 38);
--data-grid-cell-selected-border: rgb(96, 165, 250);
--data-grid-cell-hover-bg: rgb(46, 47, 51);
--data-grid-cell-search-bg: rgb(72, 57, 8);
--data-grid-cell-current-search-bg: rgb(116, 87, 0);
@@ -9286,8 +9337,8 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
--data-grid-row-number-new-bg: rgb(33, 45, 40);
--data-grid-row-number-edited-bg: rgb(48, 41, 28);
--data-grid-row-number-deleted-bg: rgb(55, 31, 32);
--data-grid-row-number-active-bg: rgb(64, 64, 64);
--data-grid-row-number-selected-bg: rgb(66, 67, 70);
--data-grid-row-number-active-bg: rgb(25, 34, 46);
--data-grid-row-number-selected-bg: rgb(30, 64, 96);
--data-grid-scrollbar-thumb: rgb(82, 82, 91);
--data-grid-scrollbar-thumb-hover: rgb(113, 113, 122);
--data-grid-scrollbar-track: rgb(24, 24, 27);
@@ -9299,17 +9350,16 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
--data-grid-row-muted-bg: color-mix(in oklab, var(--muted) 99%, var(--foreground));
--data-grid-row-new-bg: color-mix(in oklab, var(--primary) 5%, transparent);
--data-grid-row-deleted-bg: color-mix(in oklab, var(--destructive) 5%, transparent);
--data-grid-cell-active-bg: color-mix(in oklab, var(--primary) 15%, transparent);
--data-grid-cell-dirty-bg: color-mix(in oklab, rgb(240 177 0) 10%, transparent);
--data-grid-cell-selected-bg: color-mix(in oklab, var(--primary) 25%, transparent);
--data-grid-cell-selected-dirty-bg: color-mix(in oklab, rgb(234 181 50) 30%, color-mix(in oklab, var(--primary) 18%, transparent));
--data-grid-cell-selected-border: color-mix(in oklab, var(--primary) 70%, transparent);
--data-grid-cell-selected-bg: color-mix(in oklab, rgb(59 130 246) 12%, var(--background));
--data-grid-cell-selected-single-bg: color-mix(in oklab, rgb(59 130 246) 30%, var(--background));
--data-grid-cell-selected-dirty-bg: color-mix(in oklab, rgb(234 181 50) 30%, color-mix(in oklab, rgb(59 130 246) 18%, var(--background)));
--data-grid-cell-selected-border: color-mix(in oklab, rgb(59 130 246) 75%, transparent);
--data-grid-cell-hover-bg: color-mix(in oklab, var(--accent) 50%, transparent);
--data-grid-row-number-new-bg: color-mix(in oklab, rgb(16 185 129) 15%, var(--background));
--data-grid-row-number-edited-bg: color-mix(in oklab, rgb(245 158 11) 15%, var(--background));
--data-grid-row-number-deleted-bg: color-mix(in oklab, var(--destructive) 15%, var(--background));
--data-grid-row-number-active-bg: color-mix(in oklab, var(--primary) 15%, var(--background));
--data-grid-row-number-selected-bg: color-mix(in oklab, var(--primary) 25%, var(--background));
--data-grid-row-number-selected-bg: color-mix(in oklab, rgb(59 130 246) 30%, var(--background));
}
}
@@ -9339,14 +9389,15 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
}
.data-grid-header-cell--selected {
background-color: rgb(209, 213, 219) !important;
background-color: var(--data-grid-cell-selected-single-bg) !important;
}
:global(.dark) [data-grid-root] {
--data-grid-cell-selected-bg: rgb(66, 67, 70);
--data-grid-cell-selected-dirty-bg: rgb(94, 75, 26);
--data-grid-cell-selected-border: rgb(170, 170, 175);
--data-grid-row-number-selected-bg: rgb(66, 67, 70);
--data-grid-cell-selected-bg: rgb(20, 40, 60);
--data-grid-cell-selected-single-bg: rgb(30, 64, 96);
--data-grid-cell-selected-dirty-bg: rgb(76, 66, 38);
--data-grid-cell-selected-border: rgb(96, 165, 250);
--data-grid-row-number-selected-bg: rgb(30, 64, 96);
}
[data-grid-root].data-grid--dark .data-grid-header-cell--selected,
@@ -9355,7 +9406,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
:global(.dark) [data-grid-root] .data-grid-header-cell--selected,
:global(.dark) [data-grid-root] .transpose-record-header-selected,
:global(.dark) [data-grid-root] .transpose-record-header-active {
background-color: rgb(66, 67, 70) !important;
background-color: var(--data-grid-cell-selected-single-bg) !important;
color: rgb(244, 244, 245) !important;
}
@@ -9792,8 +9843,6 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
.row-cell-selected {
background-color: var(--data-grid-cell-selected-bg) !important;
outline: 1px solid var(--data-grid-cell-selected-border);
outline-offset: -1px;
}
.cell-dirty {
@@ -9827,8 +9876,6 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
.row-cell-selected-dirty {
background-color: var(--data-grid-cell-selected-dirty-bg) !important;
outline: 1px solid var(--data-grid-cell-selected-border);
outline-offset: -1px;
}
.data-grid-row-number.bg-emerald-500\/15 {
@@ -9847,9 +9894,15 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
background-color: var(--data-grid-row-number-active-bg) !important;
}
.data-grid-row-number--selected {
background-color: var(--data-grid-row-number-selected-bg) !important;
color: var(--foreground) !important;
box-shadow: inset 3px 0 0 var(--data-grid-cell-selected-border);
}
.cell-selected {
color: hsl(var(--foreground));
background-color: var(--data-grid-cell-selected-bg) !important;
background-color: var(--data-grid-cell-selected-single-bg) !important;
}
.cell-selected {
@@ -9859,19 +9912,24 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
:global(.dark) [data-grid-root] .cell-selected,
:global(.dark) [data-grid-root] .row-cell-selected {
color: rgb(244, 244, 245) !important;
background-color: rgb(66, 67, 70) !important;
}
:global(.dark) [data-grid-root] .cell-selected {
background-color: var(--data-grid-cell-selected-single-bg) !important;
}
:global(.dark) [data-grid-root] .row-cell-selected {
background-color: var(--data-grid-cell-selected-bg) !important;
}
:global(.dark) [data-grid-root] .cell-selected-dirty,
:global(.dark) [data-grid-root] .row-cell-selected-dirty {
background-color: rgb(94, 75, 26) !important;
background-color: var(--data-grid-cell-selected-dirty-bg) !important;
}
:global(.dark) [data-grid-root] .cell-selected,
:global(.dark) [data-grid-root] .row-cell-selected,
:global(.dark) [data-grid-root] .cell-selected-dirty,
:global(.dark) [data-grid-root] .row-cell-selected-dirty {
outline-color: rgb(170, 170, 175) !important;
:global(.dark) [data-grid-root] .cell-selected-dirty {
outline-color: var(--data-grid-cell-selected-border) !important;
}
.ddl-code :deep(.ddl-kw) {
@@ -107,11 +107,11 @@ const emit = defineEmits<{
}
.data-grid-header-cell--selected {
background-color: rgb(209, 213, 219) !important;
background-color: var(--data-grid-cell-selected-single-bg, rgb(191, 219, 254)) !important;
}
.data-grid-header-cell--dark.data-grid-header-cell--selected {
background-color: rgb(66, 67, 70) !important;
background-color: var(--data-grid-cell-selected-single-bg, rgb(30, 64, 96)) !important;
color: rgb(244, 244, 245) !important;
}
@@ -0,0 +1,229 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
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";
const props = defineProps<{
open: boolean;
extractor: DataGridCopyExtractorId;
options: DataGridExtractorOptions;
items: Array<{ value: DataGridCopyExtractorId; label: string; disabled?: boolean }>;
preview: (extractor: DataGridCopyExtractorId, options: DataGridExtractorOptions) => Promise<DataGridExtractPreview>;
}>();
const emit = defineEmits<{
"update:open": [value: boolean];
save: [value: { extractor: DataGridCopyExtractorId; options: DataGridExtractorOptions }];
}>();
const { t } = useI18n();
const draftExtractor = ref<DataGridCopyExtractorId>(props.extractor);
const draftOptions = ref<DataGridExtractorOptions>(normalizeDataGridExtractorOptions(props.options));
const previewText = ref("");
const previewError = ref("");
const previewWarnings = ref<string[]>([]);
const previewLoading = ref(false);
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 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(() => {
const code = validateDataGridExtractorOptions(draftExtractor.value, draftOptions.value);
if (!code) return "";
return t(`grid.copyExtractorValidation.${code}`);
});
function visibleSeparator(value: string): string {
return value.replace(/\t/g, "\\t").replace(/\r/g, "\\r").replace(/\n/g, "\\n");
}
function separatorValue(value: string): string {
return value.replace(/\\t/g, "\t").replace(/\\r/g, "\r").replace(/\\n/g, "\n");
}
function updateDsv<K extends keyof DataGridExtractorOptions["dsv"]>(key: K, value: DataGridExtractorOptions["dsv"][K]) {
draftOptions.value = { ...draftOptions.value, dsv: { ...draftOptions.value.dsv, [key]: value } };
}
function updateSql<K extends keyof DataGridExtractorOptions["sql"]>(key: K, value: DataGridExtractorOptions["sql"][K]) {
draftOptions.value = { ...draftOptions.value, sql: { ...draftOptions.value.sql, [key]: value } };
}
async function refreshPreview() {
const sequence = ++previewSequence;
previewLoading.value = true;
previewError.value = "";
previewWarnings.value = [];
previewTruncatedRows.value = 0;
if (optionsError.value) {
previewError.value = optionsError.value;
previewLoading.value = false;
return;
}
try {
const result = await props.preview(draftExtractor.value, normalizeDataGridExtractorOptions(draftOptions.value));
if (sequence === previewSequence) {
previewText.value = result.text;
previewWarnings.value = (result.warnings ?? []).map((warning) => warningText(warning.code, result.omittedColumns));
previewTruncatedRows.value = result.truncated ? result.sourceRowCount : 0;
}
} catch (error: unknown) {
if (sequence === previewSequence) {
previewText.value = "";
previewError.value = error instanceof Error ? error.message : String(error);
}
} finally {
if (sequence === previewSequence) previewLoading.value = false;
}
}
function warningText(code: DataGridExtractWarningCode, omittedColumns: string[] | undefined): string {
return code === "omitted-columns" ? t("grid.copyExtractorWarningOmittedColumns", { columns: omittedColumns?.join(", ") || "-" }) : t("grid.copyExtractorWarningDuplicateJsonColumns");
}
function schedulePreview() {
if (previewTimer) clearTimeout(previewTimer);
previewTimer = setTimeout(() => void refreshPreview(), 180);
}
watch(
() => props.open,
(open) => {
if (!open) {
if (previewTimer) clearTimeout(previewTimer);
previewTimer = undefined;
previewSequence += 1;
previewLoading.value = false;
return;
}
draftExtractor.value = props.extractor;
draftOptions.value = normalizeDataGridExtractorOptions(props.options);
schedulePreview();
},
);
watch(
[draftExtractor, draftOptions],
() => {
if (props.open) schedulePreview();
},
{ deep: true },
);
onBeforeUnmount(() => {
if (previewTimer) clearTimeout(previewTimer);
previewSequence += 1;
});
function save() {
if (optionsError.value) return;
emit("save", { extractor: draftExtractor.value, options: normalizeDataGridExtractorOptions(draftOptions.value) });
emit("update:open", false);
}
</script>
<template>
<Dialog :open="open" @update:open="emit('update:open', $event)">
<DialogContent class="max-h-[85vh] overflow-y-auto sm:max-w-[760px]">
<DialogHeader
><DialogTitle>{{ t("grid.copyExtractorConfigure") }}</DialogTitle></DialogHeader
>
<div class="grid gap-4 md:grid-cols-[260px_minmax(0,1fr)]">
<div class="space-y-3">
<div class="space-y-1.5">
<Label>{{ t("grid.copyExtractorFormat") }}</Label>
<Select v-model="draftExtractor">
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem v-for="item in items" :key="item.value" :value="item.value" :disabled="item.disabled">{{ item.label }}</SelectItem>
</SelectContent>
</Select>
</div>
<template v-if="isDsv">
<div v-if="draftExtractor === 'dsv'" class="space-y-1.5">
<Label>{{ t("grid.copyExtractorColumnSeparator") }}</Label>
<Input :model-value="visibleSeparator(draftOptions.dsv.columnSeparator)" @update:model-value="updateDsv('columnSeparator', separatorValue(String($event)))" />
</div>
<div v-if="draftExtractor !== 'one-row'" class="space-y-1.5">
<Label>{{ t("grid.copyExtractorRowSeparator") }}</Label>
<Input :model-value="visibleSeparator(draftOptions.dsv.rowSeparator)" @update:model-value="updateDsv('rowSeparator', separatorValue(String($event)))" />
</div>
<div class="space-y-1.5">
<Label>{{ t("grid.copyExtractorNullText") }}</Label>
<Input :model-value="draftOptions.dsv.nullText" @update:model-value="updateDsv('nullText', String($event))" />
</div>
<div class="grid grid-cols-2 gap-2">
<div class="space-y-1.5">
<Label>{{ t("grid.copyExtractorQuote") }}</Label>
<Input maxlength="1" :model-value="draftOptions.dsv.quote" @update:model-value="updateDsv('quote', String($event))" />
</div>
<div class="space-y-1.5">
<Label>{{ t("grid.copyExtractorQuotePolicy") }}</Label>
<Select :model-value="draftOptions.dsv.quotePolicy" @update:model-value="updateDsv('quotePolicy', $event as DataGridExtractorOptions['dsv']['quotePolicy'])">
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="minimal">{{ t("grid.copyExtractorQuoteMinimal") }}</SelectItem>
<SelectItem value="always">{{ t("grid.copyExtractorQuoteAlways") }}</SelectItem>
<SelectItem value="never">{{ t("grid.copyExtractorQuoteNever") }}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<label v-if="draftExtractor === 'dsv'" class="flex items-center gap-2 text-sm"
><input type="checkbox" :checked="draftOptions.dsv.includeColumnHeader" @change="updateDsv('includeColumnHeader', ($event.target as HTMLInputElement).checked)" />{{ t("grid.copyExtractorIncludeHeader") }}</label
>
<label v-if="draftExtractor !== 'one-row'" class="flex items-center gap-2 text-sm"
><input type="checkbox" :checked="draftOptions.dsv.includeRowHeader" @change="updateDsv('includeRowHeader', ($event.target as HTMLInputElement).checked)" />{{ t("grid.copyExtractorIncludeRowHeader") }}</label
>
</template>
<template v-if="isSql">
<label class="flex items-center gap-2 text-sm"><input type="checkbox" :checked="draftOptions.sql.skipComputedColumns" @change="updateSql('skipComputedColumns', ($event.target as HTMLInputElement).checked)" />{{ t("grid.copyExtractorSkipComputed") }}</label>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" :checked="draftOptions.sql.skipGeneratedColumns" @change="updateSql('skipGeneratedColumns', ($event.target as HTMLInputElement).checked)" />{{ t("grid.copyExtractorSkipGenerated") }}</label>
<label v-if="draftExtractor === 'sql-inserts'" class="flex items-center gap-2 text-sm"
><input type="checkbox" :checked="draftOptions.sql.excludePrimaryKeysFromInsert" @change="updateSql('excludePrimaryKeysFromInsert', ($event.target as HTMLInputElement).checked)" />{{ t("grid.copyExtractorExcludePrimaryKeys") }}</label
>
<div v-if="draftExtractor === 'sql-inserts'" class="space-y-1.5">
<Label>{{ t("grid.copyExtractorInsertMode") }}</Label>
<Select :model-value="draftOptions.sql.insertMode" @update:model-value="updateSql('insertMode', $event as DataGridExtractorOptions['sql']['insertMode'])">
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="merged">{{ t("grid.copyExtractorInsertMerged") }}</SelectItem>
<SelectItem value="row-by-row">{{ t("grid.copyExtractorInsertRowByRow") }}</SelectItem>
</SelectContent>
</Select>
</div>
</template>
<label v-if="isJson" class="flex items-center gap-2 text-sm"><input type="checkbox" :checked="draftOptions.json.pretty" @change="draftOptions = { ...draftOptions, json: { pretty: ($event.target as HTMLInputElement).checked } }" />{{ t("grid.copyExtractorPrettyJson") }}</label>
</div>
<div class="min-w-0 space-y-1.5">
<Label>{{ t("grid.copyExtractorPreview") }}</Label>
<div class="relative min-h-72 rounded-md border bg-muted/30">
<pre class="max-h-[55vh] overflow-auto whitespace-pre p-3 font-mono text-xs">{{ previewError || previewText }}</pre>
<span v-if="previewLoading" class="absolute right-2 top-2 text-xs text-muted-foreground">{{ t("common.loading") }}</span>
</div>
<p v-if="previewTruncatedRows" class="text-xs text-muted-foreground">{{ t("grid.copyExtractorPreviewTruncated", { count: previewTruncatedRows }) }}</p>
<p v-for="warning in previewWarnings" :key="warning" class="text-xs text-amber-700 dark:text-amber-300">{{ warning }}</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="emit('update:open', false)">{{ t("common.cancel") }}</Button>
<Button :disabled="!!optionsError" @click="save">{{ t("common.save") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed } from "vue";
import { Check, Eye, Loader2, Plus, RefreshCcw, RotateCcw, Rows3, Save, TableProperties, Timer, Upload } from "@lucide/vue";
import { Check, ChevronDown, Copy, Eye, Loader2, Plus, RefreshCcw, RotateCcw, Rows3, Save, TableProperties, Timer, Upload } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@@ -9,11 +9,14 @@ import {
isDataGridToolbarCapabilityDisabled,
isDataGridToolbarCapabilityVisible,
selectDataGridToolbarAutoRefreshInterval,
selectDataGridToolbarCopyItem,
selectDataGridToolbarExportItem,
toggleDataGridToolbarAutoRefresh,
triggerDataGridToolbarCopy,
triggerDataGridToolbarAction,
type DataGridToolbarActionCapability,
type DataGridToolbarAutoRefreshCapability,
type DataGridToolbarCopyCapability,
type DataGridToolbarExportCapability,
type DataGridToolbarSaveCapability,
} from "@/lib/dataGrid/dataGridToolbar";
@@ -23,6 +26,7 @@ const props = defineProps<{
refresh: DataGridToolbarActionCapability;
autoRefresh?: DataGridToolbarAutoRefreshCapability;
addRow?: DataGridToolbarActionCapability;
copyData?: DataGridToolbarCopyCapability;
exportData?: DataGridToolbarExportCapability;
transpose?: DataGridToolbarActionCapability;
tableInfo?: DataGridToolbarActionCapability;
@@ -85,6 +89,35 @@ function actionLabelClass() {
<slot name="navigation" />
<div v-if="isDataGridToolbarCapabilityVisible(copyData)" class="flex h-5 shrink-0 items-stretch overflow-hidden rounded-md border border-border">
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="sm" class="h-5 rounded-none border-0 px-1.5 text-xs" :disabled="copyData?.disabled" @click="void triggerDataGridToolbarCopy(copyData)">
<Copy class="h-3 w-3" :class="compact ? '' : 'mr-1'" />
<span v-if="!compact">{{ copyData?.label }}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{{ copyData?.tooltip ?? copyData?.label }}</TooltipContent>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon" class="h-5 w-5 rounded-none border-0 border-l border-border" :aria-label="copyData?.label">
<ChevronDown class="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="min-w-52">
<template v-for="item in copyData?.items ?? []" :key="item.value">
<DropdownMenuSeparator v-if="item.separatorBefore" />
<DropdownMenuItem class="gap-2" :disabled="item.disabled" @select="void selectDataGridToolbarCopyItem(copyData, item.value)">
<Check v-if="copyData?.currentValue === item.value" class="h-3.5 w-3.5" />
<span v-else class="h-3.5 w-3.5" />
{{ item.label }}
</DropdownMenuItem>
</template>
</DropdownMenuContent>
</DropdownMenu>
</div>
<Tooltip v-if="isDataGridToolbarCapabilityVisible(addRow)">
<TooltipTrigger as-child>
<Button variant="ghost" size="sm" :class="actionButtonClass" :disabled="isDataGridToolbarCapabilityDisabled(addRow)" @click="void triggerDataGridToolbarAction(addRow)">
@@ -268,7 +268,7 @@ async function resendMessage() {
resendSuccess.value = undefined;
try {
const message = selectedMessage.value;
const headers: Record<string, string> = { ...(message?.headers ?? {}) };
const headers: Record<string, string> = { ...message?.headers };
if (isRocketMqCluster.value && message?.tag?.trim()) {
headers.TAGS = message.tag.trim();
}
+434 -308
View File
@@ -1,10 +1,13 @@
import { computed, ref } from "vue";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useDataGridExport, type UseDataGridExportOptions } from "@/composables/useDataGridExport";
import { buildDataGridCopyInsertStatement, buildDataGridCopyUpdateStatements } from "@/lib/dataGrid/dataGridSql";
import { buildDataGridCopyUpdateStatements } from "@/lib/dataGrid/dataGridSql";
import { copyToClipboard } from "@/lib/common/clipboard";
import type { DataGridTableMeta } from "@/lib/dataGrid/dataGridSql";
import type { CellSelectionMatrix } from "@/lib/dataGrid/gridSelection";
import type { CellSelectionMatrix, SelectionData } from "@/lib/dataGrid/gridSelection";
import { extractDataGridSelection } from "@/lib/backend/api";
import { DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS } from "@/lib/dataGrid/dataGridCopyExtractor";
import { clearDataGridClipboardCopy, parseDataGridClipboard } from "@/lib/dataGrid/dataGridClipboard";
const toast = vi.fn();
@@ -16,34 +19,29 @@ vi.mock("@/composables/useToast", () => ({
useToast: () => ({ toast }),
}));
vi.mock("@/lib/common/clipboard", () => ({
copyToClipboard: vi.fn(),
}));
vi.mock("@/lib/common/clipboard", async (importOriginal) => {
const original = await importOriginal<typeof import("@/lib/common/clipboard")>();
return {
...original,
copyToClipboard: vi.fn(),
};
});
vi.mock("@/lib/dataGrid/dataGridSql", async (importOriginal) => {
const original = await importOriginal<typeof import("@/lib/dataGrid/dataGridSql")>();
return {
...original,
buildDataGridCopyInsertStatement: vi.fn(),
buildDataGridCopyUpdateStatements: vi.fn(),
};
});
interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (error: unknown) => void;
}
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
vi.mock("@/lib/backend/api", async (importOriginal) => {
const original = await importOriginal<typeof import("@/lib/backend/api")>();
return {
...original,
extractDataGridSelection: vi.fn(),
};
});
function row(data: unknown[]) {
return {
@@ -89,6 +87,7 @@ function createMongoExportState(options: {
selectedCellMatrix: computed(() => options.selectedCellMatrix ?? null),
selectedRange: computed(() => null),
contextCell: ref({ rowId: options.item.id, rowIndex: 0, col: -1 }),
contextSelectionIsSynthetic: ref(false),
getRowItem: (rowId) => items.find((item) => item.id === rowId),
selectedRowIds: ref(selectedRowIds),
hasRowSelection: computed(() => selectedRowIds.size > 0),
@@ -96,11 +95,25 @@ function createMongoExportState(options: {
return useDataGridExport(state);
}
function createExportState(tableMeta: DataGridTableMeta, columns = tableMeta.columns?.map((column) => column.name) ?? ["id", "name"], selectedCellMatrix?: CellSelectionMatrix) {
const item = row(columns.map((column, index) => (column === "id" ? 1 : `value-${index}`)));
function createExportState(
tableMeta: DataGridTableMeta,
columns = tableMeta.columns?.map((column) => column.name) ?? ["id", "name"],
selectedCellMatrix?: CellSelectionMatrix,
rowData?: unknown[],
selectedCellsOverride?: SelectionData,
rowDataList?: unknown[][],
selectedRowIdValues: number[] = [],
extractorOptions = DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS,
hasColumnSelection = false,
visibleColumnIndexes?: number[],
isSyntheticContext = false,
) {
const rows = (rowDataList ?? [rowData ?? columns.map((column, index) => (column === "id" ? 1 : `value-${index}`))]).map((data, index) => ({ ...row(data), id: index + 1 }));
const item = rows[0]!;
const selectedRowIds = ref(new Set(selectedRowIdValues));
const options: UseDataGridExportOptions = {
columns: computed(() => columns),
displayItems: computed(() => [item]),
displayItems: computed(() => rows),
sql: computed(() => undefined),
tableMeta: computed(() => tableMeta),
databaseType: computed(() => "mysql"),
@@ -108,18 +121,22 @@ function createExportState(tableMeta: DataGridTableMeta, columns = tableMeta.col
database: computed(() => "dbx"),
context: computed(() => "table-data"),
sourceColumns: computed(() => columns),
visibleColumnIndexes: computed(() => visibleColumnIndexes ?? columns.map((_, index) => index)),
columnTypes: computed(() => columns.map(() => "varchar")),
extractorOptions: computed(() => extractorOptions),
whereInput: computed(() => undefined),
orderBy: computed(() => undefined),
exportBatchSize: computed(() => 1000),
hasCellSelection: computed(() => !!selectedCellMatrix),
selectedCells: computed(() => selectedCellMatrix ?? { columns: [], rows: [] }),
hasCellSelection: computed(() => !!selectedCellMatrix || !!selectedCellsOverride),
hasColumnSelection: computed(() => hasColumnSelection),
selectedCells: computed(() => selectedCellMatrix ?? selectedCellsOverride ?? { columns: [], rows: [] }),
selectedCellMatrix: computed(() => selectedCellMatrix ?? null),
selectedRange: computed(() => null),
contextCell: ref({ rowId: item.id, rowIndex: 0, col: -1 }),
getRowItem: (rowId) => (rowId === item.id ? item : undefined),
selectedRowIds: ref(new Set<number>()),
hasRowSelection: computed(() => false),
contextCell: ref({ rowId: item.id, rowIndex: 0, col: isSyntheticContext ? 0 : -1 }),
contextSelectionIsSynthetic: ref(isSyntheticContext),
getRowItem: (rowId) => rows.find((candidate) => candidate.id === rowId),
selectedRowIds,
hasRowSelection: computed(() => selectedRowIds.value.size > 0),
};
return useDataGridExport(options);
}
@@ -136,114 +153,24 @@ const editableTable: DataGridTableMeta = {
describe("useDataGridExport prepared row statements", () => {
beforeEach(() => {
vi.clearAllMocks();
clearDataGridClipboardCopy();
});
it("reuses an in-flight INSERT prefetch when the copy action runs", async () => {
const pending = deferred<string | undefined>();
vi.mocked(buildDataGridCopyInsertStatement).mockReturnValueOnce(pending.promise);
const state = createExportState(editableTable);
const prefetch = state.prefetchRowAsInsertStatement(false);
const copy = state.copyRowAsInsert();
await vi.waitFor(() => expect(buildDataGridCopyInsertStatement).toHaveBeenCalledTimes(1));
pending.resolve("INSERT INTO users VALUES (1, 'Alice');");
await Promise.all([prefetch, copy]);
expect(copyToClipboard).toHaveBeenCalledWith("INSERT INTO users VALUES (1, 'Alice');");
});
it("reuses an in-flight UPDATE prefetch on the first copy action", async () => {
const pending = deferred<string[]>();
vi.mocked(buildDataGridCopyUpdateStatements).mockReturnValueOnce(pending.promise);
const state = createExportState(editableTable);
const prefetch = state.prefetchRowAsUpdateStatement();
const copy = state.copyRowAsUpdate();
await vi.waitFor(() => expect(buildDataGridCopyUpdateStatements).toHaveBeenCalledTimes(1));
pending.resolve(["UPDATE users SET name = 'Alice' WHERE id = 1;"]);
await Promise.all([prefetch, copy]);
expect(copyToClipboard).toHaveBeenCalledWith("UPDATE users SET name = 'Alice' WHERE id = 1;");
});
it.each(["GENERATED ALWAYS AS (1)", "IDENTITY(1, 1)"])("disables copy-as-insert when every result column is non-insertable (%s)", (extra) => {
const state = createExportState(
{
tableName: "generated_values",
primaryKeys: [],
columns: [{ name: "computed_value", data_type: "int", is_nullable: true, extra }],
},
["computed_value"],
);
expect(state.canCopyRowAsInsert.value).toBe(false);
});
it("reports a shared builder failure when the user invokes copy", async () => {
const pending = deferred<string | undefined>();
vi.mocked(buildDataGridCopyInsertStatement).mockReturnValueOnce(pending.promise);
const state = createExportState(editableTable);
const prefetch = state.prefetchRowAsInsertStatement(false);
const copy = state.copyRowAsInsert();
await vi.waitFor(() => expect(buildDataGridCopyInsertStatement).toHaveBeenCalledTimes(1));
pending.reject(new Error("builder unavailable"));
await Promise.all([prefetch, copy]);
expect(toast).toHaveBeenCalledWith("grid.copyFailed: builder unavailable", 5000);
expect(copyToClipboard).not.toHaveBeenCalled();
});
it("reports an UPDATE builder failure from the first copy action", async () => {
const pending = deferred<string[]>();
vi.mocked(buildDataGridCopyUpdateStatements).mockReturnValueOnce(pending.promise);
const state = createExportState(editableTable);
const prefetch = state.prefetchRowAsUpdateStatement();
const copy = state.copyRowAsUpdate();
await vi.waitFor(() => expect(buildDataGridCopyUpdateStatements).toHaveBeenCalledTimes(1));
pending.reject(new Error("update builder unavailable"));
await Promise.all([prefetch, copy]);
expect(toast).toHaveBeenCalledWith("grid.copyFailed: update builder unavailable", 5000);
expect(copyToClipboard).not.toHaveBeenCalled();
});
it("reports selection INSERT prefetch failures", async () => {
it("builds SQL UPDATE from only selected writable columns while retaining a hidden primary key", async () => {
const item = row([7, "Ada", true]);
const matrix: CellSelectionMatrix = {
rowIndexes: [0],
columnIndexes: [1],
columns: ["name"],
rows: [["value-1"]],
};
vi.mocked(buildDataGridCopyInsertStatement).mockRejectedValueOnce(new Error("selection builder unavailable"));
const state = createExportState(editableTable, ["id", "name"], matrix);
await state.prefetchSelectionAsInsertStatement();
expect(toast).toHaveBeenCalledTimes(1);
expect(toast).toHaveBeenCalledWith("grid.copyFailed: selection builder unavailable", 5000);
expect(state.canCopyPreparedSelectionInsert()).toBe(false);
expect(copyToClipboard).not.toHaveBeenCalled();
});
it("builds selection INSERT statements from only the selected source columns and rows", async () => {
const items = [
{ ...row([1, "Ada", true, "math"]), id: 1 },
{ ...row([2, "Grace", false, "compiler"]), id: 2 },
];
const matrix: CellSelectionMatrix = {
rowIndexes: [0, 1],
columnIndexes: [1, 3],
columns: ["display_name", "display_note"],
rows: [
["Ada", "math"],
["Grace", "compiler"],
],
columns: ["active"],
rows: [[true]],
};
const options: UseDataGridExportOptions = {
columns: computed(() => ["id", "display_name", "active", "display_note"]),
displayItems: computed(() => items),
columns: computed(() => ["display_name", "active"]),
displayItems: computed(() => [{ ...item, data: ["Ada", true], isDirtyCol: [false, false] }]),
allColumns: computed(() => ["id", "display_name", "active"]),
allDisplayItems: computed(() => [item]),
allSourceColumns: computed(() => ["id", "name", "active"]),
visibleColumnIndexes: computed(() => [1, 2]),
sql: computed(() => undefined),
tableMeta: computed(() => ({
tableName: "users",
@@ -252,52 +179,386 @@ describe("useDataGridExport prepared row statements", () => {
{ name: "id", data_type: "int", is_nullable: false, is_primary_key: true },
{ name: "name", data_type: "varchar", is_nullable: false },
{ name: "active", data_type: "boolean", is_nullable: false },
{ name: "note", data_type: "text", is_nullable: true },
],
})),
databaseType: computed(() => "mysql"),
connectionId: computed(() => "connection-1"),
database: computed(() => "dbx"),
context: computed(() => "table-data"),
sourceColumns: computed(() => ["id", "name", "active", "note"]),
columnTypes: computed(() => ["int", "varchar", "boolean", "text"]),
sourceColumns: computed(() => ["name", "active"]),
columnTypes: computed(() => ["varchar", "boolean"]),
whereInput: computed(() => undefined),
orderBy: computed(() => undefined),
exportBatchSize: computed(() => 1000),
hasCellSelection: computed(() => true),
selectedCells: computed(() => matrix),
selectedCellMatrix: computed(() => matrix),
selectedRange: computed(() => ({ startRow: 0, endRow: 1, startCol: 1, endCol: 3 })),
contextCell: ref({ rowId: 1, rowIndex: 0, col: 1 }),
getRowItem: (rowId) => items.find((item) => item.id === rowId),
selectedRange: computed(() => ({ startRow: 0, endRow: 0, startCol: 1, endCol: 1 })),
contextCell: ref({ rowId: item.id, rowIndex: 0, col: 1 }),
contextSelectionIsSynthetic: ref(false),
getRowItem: (rowId) => (rowId === item.id ? item : undefined),
selectedRowIds: ref(new Set<number>()),
hasRowSelection: computed(() => false),
};
const pending = deferred<string | undefined>();
vi.mocked(buildDataGridCopyInsertStatement).mockReturnValueOnce(pending.promise);
vi.mocked(extractDataGridSelection).mockResolvedValueOnce({
text: "UPDATE users SET active = TRUE WHERE id = 7;",
mimeType: "application/sql",
fileExtension: "sql",
rowCount: 1,
columnCount: 1,
});
const state = useDataGridExport(options);
const copy = state.copySelectionAsInsert("merged");
await vi.waitFor(() => expect(buildDataGridCopyInsertStatement).toHaveBeenCalledTimes(1));
expect(copyToClipboard).not.toHaveBeenCalled();
pending.resolve("INSERT INTO users (name, note) VALUES ('Ada', 'math'), ('Grace', 'compiler');");
await copy;
expect(state.canCopyPreparedSelectionInsert("merged")).toBe(true);
expect(state.canCopyWithExtractor("sql-updates")).toBe(true);
await state.copyWithExtractor("sql-updates");
expect(buildDataGridCopyInsertStatement).toHaveBeenCalledWith(
expect(extractDataGridSelection).toHaveBeenCalledWith(
expect.objectContaining({
columns: ["display_name", "display_note"],
sourceColumns: ["name", "note"],
columnTypes: ["varchar", "text"],
rows: [
["Ada", "math"],
["Grace", "compiler"],
],
excludePrimaryKeys: false,
insertMode: "merged",
extractor: "sql-updates",
selectedColumnIndexes: [0],
rows: [[true, 7]],
selectionKind: "cells",
}),
);
expect(copyToClipboard).toHaveBeenCalledWith("INSERT INTO users (name, note) VALUES ('Ada', 'math'), ('Grace', 'compiler');");
expect(copyToClipboard).toHaveBeenCalledWith("UPDATE users SET active = TRUE WHERE id = 7;");
});
it("preserves drag-reordered visible column order when copying", async () => {
vi.mocked(extractDataGridSelection).mockResolvedValue({ text: "x", mimeType: "text/csv", fileExtension: "csv", rowCount: 1, columnCount: 3 });
const matrix: CellSelectionMatrix = {
rowIndexes: [0],
columnIndexes: [0, 1, 2],
columns: ["id", "name", "note"],
rows: [[1, "Ada", "x"]],
};
// User dragged columns into note, id, name order (source indexes 2, 0, 1).
const state = createExportState(editableTable, ["id", "name", "note"], matrix, undefined, undefined, undefined, [], DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, false, [2, 0, 1]);
await state.copyWithExtractor("csv");
expect(extractDataGridSelection).toHaveBeenCalledWith(
expect.objectContaining({
columns: [expect.objectContaining({ displayName: "note" }), expect.objectContaining({ displayName: "id" }), expect.objectContaining({ displayName: "name" })],
}),
);
});
it("excludes hidden columns and keeps visible order when copying", async () => {
vi.mocked(extractDataGridSelection).mockResolvedValue({ text: "x", mimeType: "text/csv", fileExtension: "csv", rowCount: 1, columnCount: 2 });
const matrix: CellSelectionMatrix = {
rowIndexes: [0],
columnIndexes: [0, 1],
columns: ["note", "id"],
rows: [["x", 1]],
};
// Columns id/name/note; "name" is hidden, so visible order is note, id (source 2, 0).
const state = createExportState(editableTable, ["id", "name", "note"], matrix, undefined, undefined, undefined, [], DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, false, [2, 0]);
await state.copyWithExtractor("csv");
expect(extractDataGridSelection).toHaveBeenCalledWith(
expect.objectContaining({
columns: [expect.objectContaining({ displayName: "note" }), expect.objectContaining({ displayName: "id" })],
}),
);
});
it("copies the full row when right-clicking a cell with a synthetic single-cell selection", async () => {
vi.mocked(extractDataGridSelection).mockResolvedValue({ text: "x", mimeType: "text/csv", fileExtension: "csv", rowCount: 1, columnCount: 3 });
// A synthetic 1×1 selection (what right-click creates) — only column 0 of row 0.
const matrix: CellSelectionMatrix = {
rowIndexes: [0],
columnIndexes: [0],
columns: ["id"],
rows: [[1]],
};
const state = createExportState(editableTable, ["id", "name", "note"], matrix, undefined, undefined, undefined, [], DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, false, [0, 1, 2], true);
await state.copyWithExtractor("csv");
// Despite the 1×1 matrix, the context-cell fallback should produce a full-row request (3 columns).
expect(extractDataGridSelection).toHaveBeenCalledWith(
expect.objectContaining({
columns: [expect.objectContaining({ displayName: "id" }), expect.objectContaining({ displayName: "name" }), expect.objectContaining({ displayName: "note" })],
}),
);
});
it("copies only the selected cell for a genuine 1×1 selection (not synthetic)", async () => {
vi.mocked(extractDataGridSelection).mockResolvedValue({ text: "x", mimeType: "text/csv", fileExtension: "csv", rowCount: 1, columnCount: 1 });
// A genuine 1×1 selection (user Ctrl+click) — column 1 of row 0.
const matrix: CellSelectionMatrix = {
rowIndexes: [0],
columnIndexes: [1],
columns: ["name"],
rows: [["Ada"]],
};
// isSyntheticContext = false → should NOT use the context-cell full-row fallback.
const state = createExportState(editableTable, ["id", "name", "note"], matrix, undefined, undefined, undefined, [], DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, false, [0, 1, 2], false);
await state.copyWithExtractor("csv");
expect(extractDataGridSelection).toHaveBeenCalledWith(
expect.objectContaining({
columns: [expect.objectContaining({ displayName: "name" })],
}),
);
});
it("preserves the effective row-header TSV matrix across copy and paste", async () => {
const rows = [
[1, null],
[2, "inside\ttab\nnext line"],
];
const matrix: CellSelectionMatrix = {
rowIndexes: [0, 1],
columnIndexes: [0, 1],
columns: ["id", "name"],
rows,
};
const extractorOptions = {
...DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS,
dsv: { ...DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS.dsv, includeColumnHeader: true, includeRowHeader: true },
};
const text = '#\tid\tname\n1\t1\tNULL\n2\t2\t"inside\ttab\nnext line"';
vi.mocked(extractDataGridSelection).mockResolvedValueOnce({ text, mimeType: "text/tab-separated-values", fileExtension: "tsv", rowCount: 2, columnCount: 3 });
const state = createExportState(editableTable, ["id", "name"], matrix, undefined, undefined, rows, [], extractorOptions);
await expect(state.copyWithExtractor("tsv-with-headers")).resolves.toBe(true);
expect(parseDataGridClipboard(text)).toEqual([
["#", "id", "name"],
["1", "1", null],
["2", "2", "inside\ttab\nnext line"],
]);
});
it("rejects SQL UPDATE when the selection contains no writable non-key column", () => {
const matrix: CellSelectionMatrix = {
rowIndexes: [0],
columnIndexes: [0],
columns: ["id"],
rows: [[1]],
};
const state = createExportState(editableTable, ["id", "name"], matrix);
expect(state.canCopyWithExtractor("sql-updates")).toBe(false);
});
it("rejects SQL UPDATE when the only selected value is generated", () => {
const tableMeta: DataGridTableMeta = {
tableName: "users",
primaryKeys: ["id"],
columns: [
{ name: "id", data_type: "int", is_nullable: false, is_primary_key: true },
{ name: "search_text", data_type: "text", is_nullable: true, extra: "GENERATED ALWAYS AS" },
],
};
const matrix: CellSelectionMatrix = {
rowIndexes: [0],
columnIndexes: [1],
columns: ["search_text"],
rows: [["generated"]],
};
const state = createExportState(tableMeta, ["id", "search_text"], matrix, [1, "generated"]);
expect(state.canCopyWithExtractor("sql-updates")).toBe(false);
});
it("allows a computed SQL UPDATE only after the user disables computed-column skipping", () => {
const tableMeta: DataGridTableMeta = {
tableName: "users",
primaryKeys: ["id"],
columns: [
{ name: "id", data_type: "int", is_nullable: false, is_primary_key: true },
{ name: "search_text", data_type: "text", is_nullable: true, extra: "GENERATED ALWAYS AS" },
],
};
const matrix: CellSelectionMatrix = {
rowIndexes: [0],
columnIndexes: [1],
columns: ["search_text"],
rows: [["generated"]],
};
const extractorOptions = {
...DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS,
sql: { ...DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS.sql, skipComputedColumns: false },
};
const state = createExportState(tableMeta, ["id", "search_text"], matrix, [1, "generated"], undefined, undefined, [], extractorOptions);
expect(state.canCopyWithExtractor("sql-updates")).toBe(true);
});
it("uses current SQL options when deciding whether INSERT is available", () => {
const computedTable: DataGridTableMeta = {
tableName: "users",
primaryKeys: [],
columns: [{ name: "search_text", data_type: "text", is_nullable: true, extra: "GENERATED ALWAYS AS" }],
};
const matrix: CellSelectionMatrix = { rowIndexes: [0], columnIndexes: [0], columns: ["search_text"], rows: [["generated"]] };
const includeComputed = {
...DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS,
sql: { ...DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS.sql, skipComputedColumns: false },
};
expect(createExportState(computedTable, ["search_text"], matrix).canCopyWithExtractor("sql-inserts")).toBe(false);
expect(createExportState(computedTable, ["search_text"], matrix, undefined, undefined, undefined, [], includeComputed).canCopyWithExtractor("sql-inserts")).toBe(true);
});
it("disables INSERT when primary-key exclusion removes every selected column", () => {
const matrix: CellSelectionMatrix = { rowIndexes: [0], columnIndexes: [0], columns: ["id"], rows: [[1]] };
const excludePrimaryKeys = {
...DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS,
sql: { ...DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS.sql, skipGeneratedColumns: false, excludePrimaryKeysFromInsert: true },
};
const state = createExportState(editableTable, ["id"], matrix, [1], undefined, undefined, [], excludePrimaryKeys);
expect(state.canCopyWithExtractor("sql-inserts")).toBe(false);
});
it("sends only selected values for non-SQL extraction and marks column selections", async () => {
const matrix: CellSelectionMatrix = { rowIndexes: [0], columnIndexes: [1], columns: ["name"], rows: [["Ada"]] };
vi.mocked(extractDataGridSelection).mockResolvedValueOnce({
text: '[{"name":"Ada"}]',
mimeType: "application/json",
fileExtension: "json",
rowCount: 1,
columnCount: 1,
});
const state = createExportState(editableTable, ["id", "name"], matrix, [7, "Ada"], undefined, undefined, [], DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, true);
await state.copyWithExtractor("json");
const request = vi.mocked(extractDataGridSelection).mock.calls[0]?.[0];
expect(request).toEqual(
expect.objectContaining({
columns: [{ displayName: "name", sourceName: "name", sourceIndex: 0 }],
selectedColumnIndexes: [0],
rows: [["Ada"]],
selectionKind: "columns",
}),
);
expect(request?.databaseType).toBeUndefined();
expect(request?.tableMeta).toBeUndefined();
});
it("surfaces extractor warnings after copying", async () => {
const matrix: CellSelectionMatrix = { rowIndexes: [0], columnIndexes: [0], columns: ["id"], rows: [[1]] };
vi.mocked(extractDataGridSelection).mockResolvedValueOnce({
text: "1",
mimeType: "text/plain",
fileExtension: "txt",
rowCount: 1,
columnCount: 1,
warnings: [{ code: "omitted-columns", message: "backend text" }],
});
const state = createExportState(editableTable, ["id"], matrix, [1]);
await state.copyWithExtractor("pretty");
expect(toast).toHaveBeenCalledWith("grid.copyExtractorWarningOmittedColumns", 5000);
});
it("reports extractor copy failure without presenting post-copy warnings", async () => {
const matrix: CellSelectionMatrix = { rowIndexes: [0], columnIndexes: [0], columns: ["id"], rows: [[1]] };
vi.mocked(extractDataGridSelection).mockResolvedValueOnce({
text: "1",
mimeType: "text/plain",
fileExtension: "txt",
rowCount: 1,
columnCount: 1,
warnings: [{ code: "omitted-columns", message: "backend text" }],
});
vi.mocked(copyToClipboard).mockRejectedValueOnce(new Error("clipboard unavailable"));
const state = createExportState(editableTable, ["id"], matrix, [1]);
await expect(state.copyWithExtractor("pretty")).resolves.toBe(false);
expect(toast).toHaveBeenCalledWith("grid.copyFailed: clipboard unavailable", 5000);
expect(toast).not.toHaveBeenCalledWith("grid.copyExtractorWarningOmittedColumns", 5000);
});
it("rejects irregular discrete cell selections before building an extractor request", async () => {
const state = createExportState(editableTable, ["id", "name"], undefined, undefined, {
columns: ["id", "name"],
rows: [[1], ["Grace"]],
});
expect(state.canCopyWithExtractor("json")).toBe(false);
await expect(state.copyWithExtractor("json")).resolves.toBe(false);
expect(extractDataGridSelection).not.toHaveBeenCalled();
expect(toast).toHaveBeenCalledWith("grid.copyExtractorUnsupportedSelection", 5000);
});
it("limits live extractor previews to the first 100 selected rows", async () => {
const rows = Array.from({ length: 101 }, (_, index) => [index + 1, `name-${index + 1}`]);
const matrix: CellSelectionMatrix = {
rowIndexes: rows.map((_, index) => index),
columnIndexes: [0, 1],
columns: ["id", "name"],
rows,
};
vi.mocked(extractDataGridSelection).mockResolvedValueOnce({
text: "preview",
mimeType: "text/csv",
fileExtension: "csv",
rowCount: 100,
columnCount: 2,
});
const state = createExportState(editableTable, ["id", "name"], matrix, undefined, undefined, rows);
const preview = await state.previewWithExtractor("csv", DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS);
expect(vi.mocked(extractDataGridSelection).mock.calls[0]?.[0].rows).toHaveLength(100);
expect(preview).toEqual(expect.objectContaining({ sourceRowCount: 101, truncated: true }));
});
it("builds row extractor requests from selected row ids in display order", async () => {
vi.mocked(extractDataGridSelection).mockResolvedValueOnce({
text: '[{"id":1,"name":"Ada"},{"id":3,"name":"Linus"}]',
mimeType: "application/json",
fileExtension: "json",
rowCount: 2,
columnCount: 2,
});
const state = createExportState(
editableTable,
["id", "name"],
undefined,
undefined,
undefined,
[
[1, "Ada"],
[2, "Grace"],
[3, "Linus"],
],
[3, 1],
);
await expect(state.copyWithExtractor("json")).resolves.toBe(true);
expect(extractDataGridSelection).toHaveBeenCalledWith(
expect.objectContaining({
extractor: "json",
selectedColumnIndexes: [0, 1],
rows: [
[1, "Ada"],
[3, "Linus"],
],
selectionKind: "rows",
}),
);
});
it("rejects SQL UPDATE instead of silently skipping a row with a null primary key", async () => {
const matrix: CellSelectionMatrix = {
rowIndexes: [0],
columnIndexes: [1],
columns: ["name"],
rows: [["Ada"]],
};
const state = createExportState(editableTable, ["id", "name"], matrix, [null, "Ada"]);
expect(state.canCopyWithExtractor("sql-updates")).toBe(false);
await expect(state.copyWithExtractor("sql-updates")).resolves.toBe(false);
expect(buildDataGridCopyUpdateStatements).not.toHaveBeenCalled();
});
it("copies Mongo JSON from the original document using the sorted source index and visible columns", async () => {
@@ -316,122 +577,12 @@ describe("useDataGridExport prepared row statements", () => {
expect(copyToClipboard).toHaveBeenCalledWith(JSON.stringify({ booleanText: "true", profile: { role: "admin" } }, null, 2));
});
it("preserves original Mongo string types in INSERT and applies explicit edits", async () => {
const item = { ...row(["123", "true", '{"kind":"literal"}', "2024-01-01 00:00:00", '{"role":"maintainer"}', 'ISODate("2025-05-06T08:35:32Z")']), sourceIndex: 0 };
item.isDirtyCol = [false, false, false, false, true, false];
it("uses the Mongo insert formatter for extractor copy and preview", async () => {
const item = { ...row(["123", "true"]), sourceIndex: 0 };
const state = createMongoExportState({
columns: ["numericText", "booleanText", "jsonText", "dateText", "profile", "lastUpdatedDate"],
columns: ["numericText", "booleanText"],
item,
mongoDocuments: [
{
numericText: "123",
booleanText: "true",
jsonText: '{"kind":"literal"}',
dateText: "2024-01-01 00:00:00",
profile: { role: "admin" },
lastUpdatedDate: { $date: "2025-05-06T08:35:32Z" },
},
],
});
await state.copyRowAsInsert();
expect(copyToClipboard).toHaveBeenCalledWith(`db.getCollection("documents").insert({
"numericText": "123",
"booleanText": "true",
"jsonText": "{\\"kind\\":\\"literal\\"}",
"dateText": "2024-01-01 00:00:00",
"profile": {
"role": "maintainer"
},
"lastUpdatedDate": ISODate("2025-05-06T08:35:32Z")
});`);
});
it("copies a Mongo row as updateOne while preserving BSON types and missing fields", async () => {
const item = {
...row(["507f1f77bcf86cd799439011", "123", 'NumberLong("9007199254740993")', null, null, '{"role":"maintainer"}', "12.34", "AQI="]),
sourceIndex: 0,
};
item.isDirtyCol = [false, false, false, false, false, true, false, false];
const state = createMongoExportState({
columns: ["_id", "numericText", "counter", "nullable", "missing", "profile", "decimal", "payload"],
item,
mongoDocuments: [
{
_id: { $oid: "507f1f77bcf86cd799439011" },
numericText: "123",
counter: { $numberLong: "9007199254740993" },
nullable: null,
profile: { role: "admin" },
decimal: { $numberDecimal: "12.34" },
payload: { $binary: { base64: "AQI=", subType: "00" } },
},
],
});
expect(state.canCopyRowAsUpdate.value).toBe(true);
await state.copyRowAsUpdate();
expect(buildDataGridCopyUpdateStatements).not.toHaveBeenCalled();
const copied = vi.mocked(copyToClipboard).mock.calls[0]?.[0] ?? "";
expect(copied).toContain('db.getCollection("documents")');
expect(copied).toContain(".updateOne(");
expect(copied).toContain('"_id": ObjectId("507f1f77bcf86cd799439011")');
expect(copied).toContain('"numericText": "123"');
expect(copied).toContain('"counter": NumberLong("9007199254740993")');
expect(copied).toContain('"nullable": null');
expect(copied).toContain('"profile":');
expect(copied).toContain('"role": "maintainer"');
expect(copied).toContain('"decimal": EJSON.deserialize(');
expect(copied).toContain('"$numberDecimal": "12.34"');
expect(copied).toContain('"payload": EJSON.deserialize(');
expect(copied).toContain('"$binary":');
expect(copied).toContain('"$unset":');
expect(copied).toContain('"missing": ""');
});
it("does not expose Mongo UPDATE copy without an explicit data-list target", async () => {
const item = { ...row(["507f1f77bcf86cd799439011", "Alice"]), sourceIndex: 0 };
const state = createMongoExportState({
columns: ["_id", "name"],
item,
mongoDocuments: [{ _id: { $oid: "507f1f77bcf86cd799439011" }, name: "Alice" }],
mongoUpdateTarget: false,
});
expect(state.canCopyRowAsUpdate.value).toBe(false);
await state.copyRowAsUpdate();
expect(copyToClipboard).not.toHaveBeenCalled();
});
it("copies selected Mongo rows as separate updates using each sorted source document", async () => {
const first = { ...row(["second-id", "Second"]), id: 1, sourceIndex: 1 };
const second = { ...row(["first-id", "First"]), id: 2, sourceIndex: 0 };
const state = createMongoExportState({
columns: ["_id", "name"],
item: first,
items: [first, second],
mongoDocuments: [
{ _id: "first-id", name: "First" },
{ _id: "second-id", name: "Second" },
],
selectedRowIds: new Set([1, 2]),
});
await state.copyRowAsUpdate();
const copied = vi.mocked(copyToClipboard).mock.calls[0]?.[0] ?? "";
expect(copied.match(/\.updateOne\(/g)).toHaveLength(2);
expect(copied.indexOf('"_id": "second-id"')).toBeLessThan(copied.indexOf('"_id": "first-id"'));
});
it("preserves original Mongo types while limiting INSERT to the selected fields", async () => {
const item = { ...row(["123", "true", '{"kind":"literal"}']), sourceIndex: 0 };
const state = createMongoExportState({
columns: ["numericText", "booleanText", "jsonText"],
item,
mongoDocuments: [{ numericText: "123", booleanText: "true", jsonText: '{"kind":"literal"}' }],
mongoDocuments: [{ numericText: "123", booleanText: "true" }],
selectedCellMatrix: {
rowIndexes: [0],
columnIndexes: [1],
@@ -440,47 +591,22 @@ describe("useDataGridExport prepared row statements", () => {
},
});
await state.copySelectionAsInsert();
await expect(state.copyWithExtractor("sql-inserts")).resolves.toBe(true);
const preview = await state.previewWithExtractor("sql-inserts", DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS);
expect(copyToClipboard).toHaveBeenCalledWith(`db.getCollection("documents").insert({
"booleanText": "true"
});`);
});
it("does not traverse Mongo documents while checking copy availability", async () => {
let documentReads = 0;
const originalDocument = Object.defineProperty({}, "payload", {
enumerable: true,
get() {
documentReads++;
return "large-value";
},
});
const item = { ...row(["large-value"]), sourceIndex: 0 };
const state = createMongoExportState({ columns: ["payload"], item, mongoDocuments: [originalDocument] });
expect(state.canCopyRowAsInsert.value).toBe(true);
expect(documentReads).toBe(0);
const copy = state.copyRowAsInsert();
expect(documentReads).toBe(0);
expect(copyToClipboard).not.toHaveBeenCalled();
await copy;
expect(documentReads).toBeGreaterThan(0);
expect(copyToClipboard).toHaveBeenCalledWith(expect.stringContaining('"payload": "large-value"'));
});
it("preserves oversized Mongo documents without running the formatter", async () => {
const payload = "x".repeat(1_100_000);
const item = { ...row([payload]), sourceIndex: 0 };
const state = createMongoExportState({ columns: ["payload"], item, mongoDocuments: [{ payload }] });
await state.copyRowAsInsert();
const copied = vi.mocked(copyToClipboard).mock.calls[0]?.[0] ?? "";
expect(copied).toHaveLength(payload.length + 'db.getCollection("documents").insert({"payload":""});'.length);
expect(copied.startsWith('db.getCollection("documents").insert({"payload":"')).toBe(true);
expect(copied.endsWith('"});')).toBe(true);
expect(preview).toEqual(
expect.objectContaining({
text: `db.getCollection("documents").insert({
"booleanText": "true"
});`,
rowCount: 1,
sourceRowCount: 1,
truncated: false,
}),
);
expect(extractDataGridSelection).not.toHaveBeenCalled();
});
});
@@ -1,8 +1,8 @@
import { computed, ref } from "vue";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { useDataGridSelection } from "@/composables/useDataGridSelection";
function createSelection(options?: { getScrollElement?: () => HTMLElement | null; cellFromClientPoint?: (clientX: number, clientY: number) => { rowIndex: number; colIndex: number } | null }) {
function createSelection(options?: { getScrollElement?: () => HTMLElement | null; cellFromClientPoint?: (clientX: number, clientY: number) => { rowIndex: number; colIndex: number } | null; rowFromClientPoint?: (clientX: number, clientY: number) => number | null; onUserCellSelection?: () => void }) {
const columns = computed(() => ["id", "name", "email"]);
const displayItems = computed(() =>
[1, 2, 3, 4].map((id, index) => ({
@@ -26,6 +26,8 @@ function createSelection(options?: { getScrollElement?: () => HTMLElement | null
gridRef: ref(undefined),
getScrollElement: options?.getScrollElement,
cellFromClientPoint: options?.cellFromClientPoint,
rowFromClientPoint: options?.rowFromClientPoint,
onUserCellSelection: options?.onUserCellSelection,
});
}
@@ -38,6 +40,29 @@ function rowEvent(options: { meta?: boolean; shift?: boolean } = {}): MouseEvent
}
describe("useDataGridSelection", () => {
it("invalidates synthetic context state for ordinary, Ctrl, and Cmd cell selection", () => {
const originalDocument = globalThis.document;
const fakeDocument = {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
} as unknown as Document;
Object.defineProperty(globalThis, "document", { configurable: true, value: fakeDocument });
const onUserCellSelection = vi.fn();
const selection = createSelection({ onUserCellSelection });
const event = (options: { ctrlKey?: boolean; metaKey?: boolean } = {}) => ({ button: 0, clientX: 0, clientY: 0, ctrlKey: false, metaKey: false, shiftKey: false, preventDefault: vi.fn(), ...options }) as unknown as MouseEvent;
try {
selection.handleDataCellMousedown(0, 0, 1, event());
selection.finishCellSelection();
selection.handleDataCellMousedown(0, 1, 1, event({ ctrlKey: true }));
selection.handleDataCellMousedown(0, 2, 1, event({ metaKey: true }));
expect(onUserCellSelection).toHaveBeenCalledTimes(3);
} finally {
Object.defineProperty(globalThis, "document", { configurable: true, value: originalDocument });
}
});
it("describes a rectangular selection with its display row and column indexes", () => {
const selection = createSelection();
@@ -79,7 +104,7 @@ describe("useDataGridSelection", () => {
expect(selection.selectedCellMatrix.value).toBeNull();
});
it("creates a whole-row cell range for contiguous meta row selections", () => {
it("keeps contiguous row selections separate from cell ranges", () => {
const selection = createSelection();
selection.handleRowClick(1, 2, rowEvent({ meta: true }));
@@ -87,13 +112,8 @@ describe("useDataGridSelection", () => {
selection.handleRowClick(3, 4, rowEvent({ meta: true }));
expect(selection.selectedRowIds.value).toEqual(new Set([2, 3, 4]));
expect(selection.selectedRange.value).toEqual({
startRow: 1,
endRow: 3,
startCol: 0,
endCol: 2,
});
expect(selection.hasCellSelection.value).toBe(true);
expect(selection.selectedRange.value).toBeNull();
expect(selection.hasCellSelection.value).toBe(false);
});
it("does not create a rectangular cell range for non-contiguous meta row selections", () => {
@@ -107,6 +127,100 @@ describe("useDataGridSelection", () => {
expect(selection.hasCellSelection.value).toBe(false);
});
it("replaces disjoint rows with the anchored range on Shift selection", () => {
const selection = createSelection();
selection.handleRowClick(0, 1, rowEvent());
selection.handleRowClick(3, 4, rowEvent({ meta: true }));
selection.handleRowClick(1, 2, rowEvent({ shift: true }));
expect(selection.selectedRowIds.value).toEqual(new Set([2, 3, 4]));
expect(selection.hasCellSelection.value).toBe(false);
});
it("selects a continuous row range while dragging the row-number gutter", () => {
const originalDocument = globalThis.document;
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
const originalCancelAnimationFrame = globalThis.cancelAnimationFrame;
const listeners = new Map<string, Set<EventListenerOrEventListenerObject>>();
const fakeDocument = {
addEventListener(type: string, listener: EventListenerOrEventListenerObject) {
const handlers = listeners.get(type) ?? new Set();
handlers.add(listener);
listeners.set(type, handlers);
},
removeEventListener(type: string, listener: EventListenerOrEventListenerObject) {
listeners.get(type)?.delete(listener);
},
} as Document;
Object.defineProperty(globalThis, "document", { configurable: true, value: fakeDocument });
const animationFrames: FrameRequestCallback[] = [];
globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => {
animationFrames.push(callback);
return animationFrames.length;
}) as typeof requestAnimationFrame;
globalThis.cancelAnimationFrame = (() => undefined) as typeof cancelAnimationFrame;
let pointerRow = 1;
const selection = createSelection({ rowFromClientPoint: () => pointerRow });
try {
selection.beginRowSelection(1, 2, { button: 0, clientX: 5, clientY: 10, preventDefault() {} } as MouseEvent);
pointerRow = 3;
listeners.get("mousemove")?.forEach((listener) => {
const event = { clientX: 5, clientY: 80 } as MouseEvent;
if (typeof listener === "function") listener(event);
else listener.handleEvent(event);
});
animationFrames.shift()?.(0);
expect(selection.selectedRowIds.value).toEqual(new Set([2, 3, 4]));
expect(selection.hasCellSelection.value).toBe(false);
} finally {
selection.finishRowSelection();
Object.defineProperty(globalThis, "document", { configurable: true, value: originalDocument });
globalThis.requestAnimationFrame = originalRequestAnimationFrame;
globalThis.cancelAnimationFrame = originalCancelAnimationFrame;
}
});
it("keeps a meta-deselected row removed after pointer movement", () => {
const originalDocument = globalThis.document;
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
const originalCancelAnimationFrame = globalThis.cancelAnimationFrame;
const listeners = new Map<string, Set<EventListenerOrEventListenerObject>>();
const fakeDocument = {
addEventListener(type: string, listener: EventListenerOrEventListenerObject) {
const handlers = listeners.get(type) ?? new Set();
handlers.add(listener);
listeners.set(type, handlers);
},
removeEventListener(type: string, listener: EventListenerOrEventListenerObject) {
listeners.get(type)?.delete(listener);
},
} as Document;
Object.defineProperty(globalThis, "document", { configurable: true, value: fakeDocument });
globalThis.requestAnimationFrame = (() => 1) as typeof requestAnimationFrame;
globalThis.cancelAnimationFrame = (() => undefined) as typeof cancelAnimationFrame;
const selection = createSelection({ rowFromClientPoint: () => 1 });
try {
selection.selectedRowIds.value = new Set([1, 2, 3]);
selection.beginRowSelection(1, 2, { button: 0, clientX: 5, clientY: 10, metaKey: true, preventDefault() {} } as MouseEvent);
listeners.get("mousemove")?.forEach((listener) => {
const event = { clientX: 6, clientY: 11 } as MouseEvent;
if (typeof listener === "function") listener(event);
else listener.handleEvent(event);
});
expect(selection.selectedRowIds.value).toEqual(new Set([1, 3]));
} finally {
selection.finishRowSelection();
Object.defineProperty(globalThis, "document", { configurable: true, value: originalDocument });
globalThis.requestAnimationFrame = originalRequestAnimationFrame;
globalThis.cancelAnimationFrame = originalCancelAnimationFrame;
}
});
it("scrolls and extends the selection while dragging near an edge", () => {
const animationFrames: FrameRequestCallback[] = [];
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
+103 -566
View File
@@ -1,14 +1,16 @@
import { computed, ref, type ComputedRef, type Ref } from "vue";
import { computed, type ComputedRef, type Ref } from "vue";
import { useI18n } from "vue-i18n";
import { useDataGridExtractor } from "@/composables/useDataGridExtractor";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import * as api from "@/lib/backend/api";
import { formatSelectionAsCsv, formatSelectionAsJson, formatSelectionAsSqlInList, formatSelectionAsTsv, type CellSelectionMatrix, type CellSelectionRange, type SelectionData } from "@/lib/dataGrid/gridSelection";
import { type CellSelectionMatrix, type CellSelectionRange, type SelectionData } from "@/lib/dataGrid/gridSelection";
import type { DataGridExtractorOptions } from "@/lib/dataGrid/dataGridCopyExtractor";
import { useToast } from "@/composables/useToast";
import { displayCellValue, type CellValue } from "@/lib/dataGrid/cellValue";
import { tryStartExclusiveActivation, type ActionActivationGuard } from "@/lib/connection/actionActivation";
import { copyToClipboard } from "@/lib/common/clipboard";
import { clearDataGridClipboardCopy, rememberDataGridClipboardCopy } from "@/lib/dataGrid/dataGridClipboard";
import { buildDataGridCopyInsertStatement, buildDataGridCopyUpdateStatements, type DataGridCopyInsertMode, type DataGridTableMeta } from "@/lib/dataGrid/dataGridSql";
import { buildDataGridCopyInsertStatement, type DataGridCopyInsertMode, type DataGridTableMeta } from "@/lib/dataGrid/dataGridSql";
import { formatSqlInsert, formatTsv } from "@/lib/export/exportFormats";
import { uuid } from "@/lib/common/utils";
import { useSettingsStore } from "@/stores/settingsStore";
@@ -59,6 +61,11 @@ export interface MongoCopyUpdateTarget {
export interface UseDataGridExportOptions {
columns: ComputedRef<string[]>;
displayItems: ComputedRef<RowItem[]>;
allColumns?: ComputedRef<string[]>;
allDisplayItems?: ComputedRef<RowItem[]>;
allSourceColumns?: ComputedRef<Array<string | undefined> | undefined>;
visibleColumnIndexes?: ComputedRef<number[]>;
extractorOptions?: ComputedRef<DataGridExtractorOptions>;
sql: ComputedRef<string | undefined>;
exportSql?: ComputedRef<string | undefined>;
tableMeta: ComputedRef<DataGridTableMeta | undefined>;
@@ -75,10 +82,12 @@ export interface UseDataGridExportOptions {
orderBy: ComputedRef<string | undefined>;
exportBatchSize: ComputedRef<number>;
hasCellSelection: ComputedRef<boolean>;
hasColumnSelection?: ComputedRef<boolean>;
selectedCells: ComputedRef<SelectionData>;
selectedCellMatrix?: ComputedRef<CellSelectionMatrix | null>;
selectedCellMatrix: ComputedRef<CellSelectionMatrix | null>;
selectedRange: ComputedRef<CellSelectionRange | null>;
contextCell: Ref<{ rowId: number; rowIndex: number; col: number } | null> | ComputedRef<{ rowId: number; rowIndex: number; col: number } | null>;
contextSelectionIsSynthetic: Ref<boolean> | ComputedRef<boolean>;
getRowItem: (rowId: number) => RowItem | undefined;
selectedRowIds: Ref<Set<number>> | ComputedRef<Set<number>>;
hasRowSelection: ComputedRef<boolean>;
@@ -118,14 +127,6 @@ export interface UseDataGridExportOptions {
exportCancelHandler?: Ref<(() => Promise<void>) | null>;
}
interface CopyStatementCache {
key: string;
text: string;
loading: boolean;
ready: boolean;
promise?: Promise<string | undefined>;
}
interface CopyInsertData {
columns: string[];
sourceColumns?: Array<string | undefined>;
@@ -137,57 +138,19 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
const { t } = useI18n();
const { toast } = useToast();
const exportGuard: ActionActivationGuard = {};
const copyRowInsertCache = ref<CopyStatementCache>({
key: "",
text: "",
loading: false,
ready: false,
});
const copyRowInsertRowByRowCache = ref<CopyStatementCache>({
key: "",
text: "",
loading: false,
ready: false,
});
const copyRowInsertWithoutPrimaryKeysCache = ref<CopyStatementCache>({
key: "",
text: "",
loading: false,
ready: false,
});
const copyRowInsertWithoutPrimaryKeysRowByRowCache = ref<CopyStatementCache>({
key: "",
text: "",
loading: false,
ready: false,
});
const copySelectionInsertCache = ref<CopyStatementCache>({
key: "",
text: "",
loading: false,
ready: false,
});
const copySelectionInsertRowByRowCache = ref<CopyStatementCache>({
key: "",
text: "",
loading: false,
ready: false,
});
const copyRowUpdateCache = ref<CopyStatementCache>({
key: "",
text: "",
loading: false,
ready: false,
});
const {
columns,
displayItems,
allColumns: allColumnsOption,
allDisplayItems: allDisplayItemsOption,
allSourceColumns: allSourceColumnsOption,
visibleColumnIndexes: visibleColumnIndexesOption,
extractorOptions: extractorOptionsOption,
sql,
exportSql: resultExportSql,
tableMeta,
copyInsertTargetLabel,
mongoUpdateTarget,
sourceColumns,
databaseType,
connectionId,
@@ -198,10 +161,12 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
columnTypes,
exportBatchSize,
hasCellSelection,
hasColumnSelection: hasColumnSelectionOption,
selectedCells,
selectedCellMatrix: selectedCellMatrixOption,
selectedRange,
contextCell,
contextSelectionIsSynthetic,
getRowItem,
selectedRowIds,
hasRowSelection,
@@ -216,17 +181,25 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
exportProgressState,
exportCancelHandler,
} = options;
const selectedCellMatrix = selectedCellMatrixOption ?? computed<CellSelectionMatrix | null>(() => null);
const selectedCellMatrix = selectedCellMatrixOption;
const allColumns = allColumnsOption ?? columns;
const allDisplayItems = allDisplayItemsOption ?? displayItems;
const allSourceColumns = allSourceColumnsOption ?? sourceColumns;
const visibleColumnIndexes = visibleColumnIndexesOption ?? computed(() => columns.value.map((_, index) => index));
const hasColumnSelection = hasColumnSelectionOption ?? computed(() => false);
async function copyText(text: string, gridCopy?: { rows: readonly (readonly unknown[])[]; includeHeader?: boolean }) {
async function copyText(text: string, gridCopy?: { rows: readonly (readonly unknown[])[]; header?: readonly unknown[] }) {
const copiedRows = gridCopy?.rows.map((row) => [...row]);
const copiedHeader = gridCopy?.header ? [...gridCopy.header] : undefined;
clearDataGridClipboardCopy();
try {
await copyToClipboard(text);
if (copiedRows) rememberDataGridClipboardCopy(text, copiedRows, gridCopy?.includeHeader);
if (copiedRows) rememberDataGridClipboardCopy(text, copiedRows, copiedHeader);
toast(t("grid.copied"));
return true;
} catch (e: any) {
toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000);
return false;
}
}
@@ -318,58 +291,6 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
return item && !item.isDraft ? [item] : [];
}
function updateEligibleRows(): RowItem[] {
return targetedRows().filter((item) => !item.isNew && !item.isDraft && !item.isDeleted);
}
function updateCopyKey(): string {
const rows = copyStatementRowsKey(updateEligibleRows());
return JSON.stringify({
databaseType: databaseType.value ?? null,
schema: tableMeta.value?.schema ?? null,
tableName: tableMeta.value?.tableName ?? null,
primaryKeys: tableMeta.value?.primaryKeys ?? [],
columns: columns.value,
sourceColumns: sourceColumns.value ?? null,
rows,
});
}
function buildMongoCopyUpdateStatements(rows: RowItem[], target: MongoCopyUpdateTarget): string[] {
const documents = options.mongoDocuments?.value;
if (!documents) return [];
const copyColumns = effectiveColumns(sourceColumns.value, columns.value).map((column) => column ?? "");
const statements: string[] = [];
for (const item of rows) {
if (item.sourceIndex === undefined) continue;
const originalDocument = documents[item.sourceIndex];
if (!originalDocument || typeof originalDocument !== "object" || Array.isArray(originalDocument)) continue;
const source = originalDocument as Record<string, unknown>;
if (!Object.prototype.hasOwnProperty.call(source, target.idColumn)) continue;
const update = buildMongoCopyUpdateDocument(item.data as MongoInputValue[], copyColumns, item.isDirtyCol, originalDocument, target.idColumn);
if (!update) continue;
const statement = `db.getCollection(${JSON.stringify(target.collection)}).updateOne({${JSON.stringify(target.idColumn)}:${formatMongoShellLiteral(source[target.idColumn])}},${formatMongoShellLiteral(update)});`;
statements.push(formatMongoCopyStatement(statement) ?? statement);
}
return statements;
}
function insertCopyKey(excludePrimaryKeys: boolean, insertMode: DataGridCopyInsertMode): string {
return copyInsertKey(
{
columns: columns.value,
sourceColumns: sourceColumns.value,
columnTypes: columnTypes.value?.map((type) => type ?? undefined),
rows: insertEligibleRows(),
},
excludePrimaryKeys,
insertMode,
);
}
function selectionInsertData(): CopyInsertData | null {
const matrix = selectedCellMatrix.value;
if (!matrix) return null;
@@ -391,68 +312,6 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
};
}
function selectionInsertCopyKey(insertMode: DataGridCopyInsertMode): string {
const data = selectionInsertData();
return data ? copyInsertKey(data, false, insertMode) : "";
}
function copyInsertKey(data: CopyInsertData, excludePrimaryKeys: boolean, insertMode: DataGridCopyInsertMode): string {
const rows = copyStatementRowsKey(data.rows);
const originalMongoDocuments = data.rows.map((item) => (item.sourceIndex === undefined ? undefined : options.mongoDocuments?.value?.[item.sourceIndex]));
return JSON.stringify({
databaseType: databaseType.value ?? null,
schema: tableMeta.value?.schema ?? null,
tableName: tableMeta.value?.tableName ?? null,
copyInsertTargetLabel: copyInsertTargetLabel?.value ?? null,
columns: data.columns,
columnTypes: data.columnTypes ?? null,
sourceColumns: data.sourceColumns ?? null,
excludePrimaryKeys,
insertMode,
rows,
originalMongoDocuments,
});
}
function copyStatementRowsKey(rows: RowItem[]): Array<{ id: number; sourceIndex?: number; data: CellValue[]; isDirtyCol: boolean[] }> {
// Prepared copy SQL depends on current cell values; edited rows keep the same id while their data changes.
return rows.map((item) => ({ id: item.id, sourceIndex: item.sourceIndex, data: item.data, isDirtyCol: item.isDirtyCol }));
}
function insertCopyCache(excludePrimaryKeys: boolean, insertMode: DataGridCopyInsertMode): CopyStatementCache {
if (excludePrimaryKeys) {
return insertMode === "row-by-row" ? copyRowInsertWithoutPrimaryKeysRowByRowCache.value : copyRowInsertWithoutPrimaryKeysCache.value;
}
return insertMode === "row-by-row" ? copyRowInsertRowByRowCache.value : copyRowInsertCache.value;
}
function setInsertCopyCache(excludePrimaryKeys: boolean, insertMode: DataGridCopyInsertMode, cache: CopyStatementCache) {
if (excludePrimaryKeys) {
if (insertMode === "row-by-row") {
copyRowInsertWithoutPrimaryKeysRowByRowCache.value = cache;
} else {
copyRowInsertWithoutPrimaryKeysCache.value = cache;
}
} else if (insertMode === "row-by-row") {
copyRowInsertRowByRowCache.value = cache;
} else {
copyRowInsertCache.value = cache;
}
}
function selectionInsertCopyCache(insertMode: DataGridCopyInsertMode): CopyStatementCache {
return insertMode === "row-by-row" ? copySelectionInsertRowByRowCache.value : copySelectionInsertCache.value;
}
function setSelectionInsertCopyCache(insertMode: DataGridCopyInsertMode, cache: CopyStatementCache) {
if (insertMode === "row-by-row") copySelectionInsertRowByRowCache.value = cache;
else copySelectionInsertCache.value = cache;
}
function setUpdateCopyCache(cache: CopyStatementCache) {
copyRowUpdateCache.value = cache;
}
async function buildCopyInsertStatement(data: CopyInsertData, excludePrimaryKeys: boolean, insertMode: DataGridCopyInsertMode): Promise<string | undefined> {
if (databaseType.value === "mongodb") {
return formatMongoCopyStatement(
@@ -479,326 +338,6 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
});
}
async function prefetchRowAsInsertStatement(excludePrimaryKeys: boolean, insertMode: DataGridCopyInsertMode = "merged") {
try {
await prepareRowAsInsertStatement(excludePrimaryKeys, insertMode);
} catch {
// Prefetch failures are reported only if the user invokes the copy action.
}
}
async function prepareRowAsInsertStatement(excludePrimaryKeys: boolean, insertMode: DataGridCopyInsertMode = "merged"): Promise<string | undefined> {
const rows = insertEligibleRows();
if (!rows.length) {
setInsertCopyCache(excludePrimaryKeys, insertMode, {
key: "",
text: "",
loading: false,
ready: false,
});
return;
}
const data: CopyInsertData = {
columns: columns.value,
sourceColumns: sourceColumns.value,
columnTypes: columnTypes.value?.map((type) => type ?? undefined),
rows,
};
if (databaseType.value === "mongodb") {
// Mongo documents can approach the 16 MiB BSON limit. Yield before the
// synchronous shell serialization so menu close/rendering is never held up.
await yieldToMainThread();
return buildCopyInsertStatement(data, excludePrimaryKeys, insertMode);
}
const key = insertCopyKey(excludePrimaryKeys, insertMode);
const current = insertCopyCache(excludePrimaryKeys, insertMode);
if (current.ready && current.key === key) return current.text;
if (current.loading && current.key === key && current.promise) return current.promise;
const promise = Promise.resolve().then(async () => {
const statement = await buildCopyInsertStatement(data, excludePrimaryKeys, insertMode);
const latest = insertCopyCache(excludePrimaryKeys, insertMode);
if (latest.key !== key || latest.promise !== promise) return undefined;
setInsertCopyCache(excludePrimaryKeys, insertMode, {
key,
text: statement ?? "",
loading: false,
ready: !!statement,
});
return statement;
});
setInsertCopyCache(excludePrimaryKeys, insertMode, {
key,
text: "",
loading: true,
ready: false,
promise,
});
try {
return await promise;
} catch (error) {
const latest = insertCopyCache(excludePrimaryKeys, insertMode);
if (latest.key === key && latest.promise === promise) {
setInsertCopyCache(excludePrimaryKeys, insertMode, {
key,
text: "",
loading: false,
ready: false,
});
}
throw error;
}
}
function canCopyPreparedInsert(excludePrimaryKeys: boolean, insertMode: DataGridCopyInsertMode = "merged"): boolean {
const cache = insertCopyCache(excludePrimaryKeys, insertMode);
return cache.ready && cache.key === insertCopyKey(excludePrimaryKeys, insertMode);
}
async function copyPreparedRowAsInsert(excludePrimaryKeys: boolean, insertMode: DataGridCopyInsertMode = "merged"): Promise<boolean> {
try {
const statement = await prepareRowAsInsertStatement(excludePrimaryKeys, insertMode);
if (!statement) return false;
await copyText(statement);
return true;
} catch (error: any) {
toast(t("grid.copyFailed", { message: error?.message || String(error) }), 5000);
return false;
}
}
async function prefetchSelectionAsInsertStatement(insertMode: DataGridCopyInsertMode = "merged") {
try {
await prepareSelectionAsInsertStatement(insertMode);
} catch (error: any) {
toast(t("grid.copyFailed", { message: error?.message || String(error) }), 5000);
}
}
async function prepareSelectionAsInsertStatement(insertMode: DataGridCopyInsertMode = "merged"): Promise<string | undefined> {
const data = selectionInsertData();
if (!data) {
setSelectionInsertCopyCache(insertMode, {
key: "",
text: "",
loading: false,
ready: false,
});
return;
}
if (databaseType.value === "mongodb") {
// Keep context-menu work bounded; serialize the selected Mongo fields only
// after the user explicitly invokes the copy command.
await yieldToMainThread();
return buildCopyInsertStatement(data, false, insertMode);
}
const key = selectionInsertCopyKey(insertMode);
const current = selectionInsertCopyCache(insertMode);
if (current.ready && current.key === key) return current.text;
if (current.loading && current.key === key && current.promise) return current.promise;
const promise = Promise.resolve().then(async () => {
const statement = await buildCopyInsertStatement(data, false, insertMode);
const latest = selectionInsertCopyCache(insertMode);
if (latest.key !== key || latest.promise !== promise) return undefined;
setSelectionInsertCopyCache(insertMode, {
key,
text: statement ?? "",
loading: false,
ready: !!statement,
});
return statement;
});
setSelectionInsertCopyCache(insertMode, {
key,
text: "",
loading: true,
ready: false,
promise,
});
try {
return await promise;
} catch (error) {
const latest = selectionInsertCopyCache(insertMode);
if (latest.key === key && latest.promise === promise) {
setSelectionInsertCopyCache(insertMode, {
key,
text: "",
loading: false,
ready: false,
});
}
throw error;
}
}
function canCopyPreparedSelectionInsert(insertMode: DataGridCopyInsertMode = "merged"): boolean {
const cache = selectionInsertCopyCache(insertMode);
return cache.ready && cache.key === selectionInsertCopyKey(insertMode);
}
async function copySelectionAsInsert(insertMode: DataGridCopyInsertMode = "merged"): Promise<boolean> {
try {
const statement = await prepareSelectionAsInsertStatement(insertMode);
if (!statement) return false;
await copyText(statement);
return true;
} catch (error: any) {
toast(t("grid.copyFailed", { message: error?.message || String(error) }), 5000);
return false;
}
}
async function prefetchRowAsUpdateStatement() {
try {
await prepareRowAsUpdateStatement();
} catch {
// Prefetch failures are reported only if the user invokes the copy action.
}
}
async function prepareRowAsUpdateStatement(): Promise<string | undefined> {
const rows = updateEligibleRows();
if (!rows.length) {
setUpdateCopyCache({
key: "",
text: "",
loading: false,
ready: false,
});
return;
}
if (databaseType.value === "mongodb") {
const target = mongoUpdateTarget?.value;
if (!target) return;
await yieldToMainThread();
const text = buildMongoCopyUpdateStatements(rows, target).join("\n");
return text || undefined;
}
const currentTableMeta = tableMeta.value;
if (!currentTableMeta?.primaryKeys.length) {
setUpdateCopyCache({
key: "",
text: "",
loading: false,
ready: false,
});
return;
}
const key = updateCopyKey();
const current = copyRowUpdateCache.value;
if (current.ready && current.key === key) return current.text;
if (current.loading && current.key === key && current.promise) return current.promise;
const promise = Promise.resolve().then(async () => {
const statements = await buildDataGridCopyUpdateStatements({
databaseType: databaseType.value,
tableMeta: currentTableMeta,
columns: columns.value,
sourceColumns: sourceColumns.value,
rows: rows.map((item) => item.data),
});
const latest = copyRowUpdateCache.value;
if (latest.key !== key || latest.promise !== promise) return undefined;
const text = statements.join("\n");
setUpdateCopyCache({
key,
text,
loading: false,
ready: statements.length > 0,
});
return text || undefined;
});
setUpdateCopyCache({
key,
text: "",
loading: true,
ready: false,
promise,
});
try {
return await promise;
} catch (error) {
const latest = copyRowUpdateCache.value;
if (latest.key === key && latest.promise === promise) {
setUpdateCopyCache({
key,
text: "",
loading: false,
ready: false,
});
}
throw error;
}
}
function canCopyPreparedUpdate(): boolean {
const cache = copyRowUpdateCache.value;
return cache.ready && cache.key === updateCopyKey();
}
async function copyPreparedRowAsUpdate(): Promise<boolean> {
try {
const statement = await prepareRowAsUpdateStatement();
if (!statement) return false;
await copyText(statement);
return true;
} catch (error: any) {
toast(t("grid.copyFailed", { message: error?.message || String(error) }), 5000);
return false;
}
}
// --- Selection copy functions ---
async function copySelectionTsv() {
if (!hasCellSelection.value) return;
const selection = selectedCells.value;
await copyText(formatSelectionAsTsv(selection), { rows: selection.rows });
}
async function copySelectionTsvWithHeaders() {
if (!hasCellSelection.value) return;
const selection = selectedCells.value;
await copyText(formatSelectionAsTsv(selection, true), { rows: selection.rows, includeHeader: true });
}
async function copySelectionCsv() {
if (!hasCellSelection.value) return;
await copyText(formatSelectionAsCsv(selectedCells.value));
}
async function copySelectionJson() {
if (!hasCellSelection.value) return;
await copyText(formatSelectionAsJson(selectedCells.value));
}
async function copySelectionSqlInList() {
if (!hasCellSelection.value) return;
await copyText(formatSelectionAsSqlInList(selectedCells.value));
}
async function copySelectedRowsTsv() {
if (!hasRowSelection.value || selectedRowIds.value.size === 0) return;
const rows = displayItems.value.filter((item) => selectedRowIds.value.has(item.id) && !item.isDraft).map((item) => item.data);
if (rows.length === 0) return;
await copyText(formatSelectionAsTsv({ columns: columns.value, rows }), { rows });
}
async function copySelectedRowsTsvWithHeaders() {
if (!hasRowSelection.value || selectedRowIds.value.size === 0) return;
const rows = displayItems.value.filter((item) => selectedRowIds.value.has(item.id) && !item.isDraft).map((item) => item.data);
if (rows.length === 0) return;
await copyText(formatSelectionAsTsv({ columns: columns.value, rows }, true), { rows, includeHeader: true });
}
function rowToJsonObject(item: RowItem): Record<string, unknown> {
if (options.databaseType.value === "mongodb" && item.sourceIndex !== undefined) {
const original = options.mongoDocuments?.value?.[item.sourceIndex];
@@ -851,60 +390,82 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
return targetedRows().filter((item) => !item.isDraft);
}
async function copyRowAsInsert(insertMode: DataGridCopyInsertMode = "merged") {
await copyPreparedRowAsInsert(false, insertMode);
}
async function copyRowAsInsertWithoutPrimaryKeys(insertMode: DataGridCopyInsertMode = "merged") {
await copyPreparedRowAsInsert(true, insertMode);
}
async function copyRowAsUpdate() {
await copyPreparedRowAsUpdate();
}
const canCopyRowAsUpdate = computed(() => {
const rows = updateEligibleRows();
if (!rows.length) return false;
const saveColumns = effectiveColumns(sourceColumns.value, columns.value);
if (databaseType.value === "mongodb") {
const target = mongoUpdateTarget?.value;
if (!target || !options.mongoDocuments?.value || rows.some((item) => item.sourceIndex === undefined)) return false;
if (findColumnIndex(saveColumns, target.idColumn) === -1) return false;
return saveColumns.some((column) => column && normalizeColumnName(column) !== normalizeColumnName(target.idColumn));
}
if (!tableMeta.value?.primaryKeys.length) return false;
if (databaseType.value === "neo4j" || databaseType.value === "tdengine") return false;
const primaryKeys = tableMeta.value.primaryKeys;
if (primaryKeys.some((primaryKey) => findColumnIndex(saveColumns, primaryKey) === -1)) return false;
const primaryKeySet = new Set(primaryKeys.map(normalizeColumnName));
return saveColumns.some((column) => column && !primaryKeySet.has(normalizeColumnName(column)));
});
function insertableCopyColumnCount(excludePrimaryKeys: boolean, copyColumns = effectiveColumns(sourceColumns.value, columns.value)): number {
function insertableCopyColumnCount(excludePrimaryKeys: boolean, copyColumns = effectiveColumns(sourceColumns.value, columns.value), extractorOptions?: DataGridExtractorOptions): number {
const primaryKeySet = new Set((tableMeta.value?.primaryKeys ?? []).map(normalizeColumnName));
return copyColumns.filter((column): column is string => !!column && !isCopyInsertOmittedColumn(databaseType.value, column, tableMeta.value) && (!excludePrimaryKeys || !primaryKeySet.has(normalizeColumnName(column)))).length;
return copyColumns.filter((column): column is string => !!column && !isCopyInsertOmittedColumn(databaseType.value, column, tableMeta.value, extractorOptions) && (!excludePrimaryKeys || !primaryKeySet.has(normalizeColumnName(column)))).length;
}
const canCopyRowAsInsert = computed(() => insertEligibleRows().length > 0 && insertableCopyColumnCount(false) > 0);
const selectionInsertRowCount = computed(() => selectionInsertData()?.rows.length ?? 0);
const canCopySelectionAsInsert = computed(() => {
const data = selectionInsertData();
return !!data?.rows.length && insertableCopyColumnCount(false, effectiveColumns(data.sourceColumns, data.columns)) > 0;
});
async function buildMongoExtractorInsert(extractorOptions: DataGridExtractorOptions, rowLimit?: number): Promise<string | undefined> {
const data: CopyInsertData | null =
hasRowSelection.value || !hasCellSelection.value
? {
columns: columns.value,
sourceColumns: sourceColumns.value,
columnTypes: columnTypes.value?.map((type) => type ?? undefined),
rows: insertEligibleRows(),
}
: selectionInsertData();
if (!data) return undefined;
await yieldToMainThread();
return buildCopyInsertStatement(rowLimit === undefined ? data : { ...data, rows: data.rows.slice(0, rowLimit) }, extractorOptions.sql.excludePrimaryKeysFromInsert, extractorOptions.sql.insertMode);
}
const canCopyRowAsInsertWithoutPrimaryKeys = computed(() => {
if (!tableMeta.value?.primaryKeys.length) return false;
async function buildMongoExtractorUpdate(_extractorOptions: DataGridExtractorOptions, rowLimit?: number): Promise<string | undefined> {
const target = options.mongoUpdateTarget?.value;
const documents = options.mongoDocuments?.value;
if (!target || !documents) return undefined;
const rows = insertEligibleRows();
if (!rows.length) return false;
return insertableCopyColumnCount(true) > 0;
if (rows.length === 0) return undefined;
const limitedRows = rowLimit === undefined ? rows : rows.slice(0, rowLimit);
const copyColumns = effectiveColumns(sourceColumns.value, columns.value).map((column) => column ?? "");
await yieldToMainThread();
const statements: string[] = [];
for (const item of limitedRows) {
if (item.sourceIndex === undefined) continue;
const originalDocument = documents[item.sourceIndex];
if (!originalDocument || typeof originalDocument !== "object" || Array.isArray(originalDocument)) continue;
const source = originalDocument as Record<string, unknown>;
if (!Object.prototype.hasOwnProperty.call(source, target.idColumn)) continue;
const update = buildMongoCopyUpdateDocument(item.data as MongoInputValue[], copyColumns, item.isDirtyCol, originalDocument, target.idColumn);
if (!update) continue;
const statement = `db.getCollection(${JSON.stringify(target.collection)}).updateOne({${JSON.stringify(target.idColumn)}:${formatMongoShellLiteral(source[target.idColumn])}},${formatMongoShellLiteral(update)});`;
statements.push(formatMongoCopyStatement(statement) ?? statement);
}
return statements.length > 0 ? statements.join("\n") : undefined;
}
const { copyWithExtractor, previewWithExtractor, canCopyWithExtractor } = useDataGridExtractor({
columns,
displayItems,
allColumns,
allDisplayItems,
allSourceColumns,
visibleColumnIndexes,
extractorOptions: extractorOptionsOption,
databaseType,
tableMeta,
hasCellSelection,
selectedCells,
selectedCellMatrix,
hasRowSelection,
hasColumnSelection,
selectedRowIds,
copyText,
canCopySqlInsert: (request) => {
const selectedColumns = request.selectedColumnIndexes.map((index) => request.columns[index]?.sourceName ?? request.columns[index]?.displayName).filter((column): column is string => !!column);
return request.rows.length > 0 && insertableCopyColumnCount(request.options.sql.excludePrimaryKeysFromInsert, selectedColumns, request.options) > 0;
},
buildMongoInsert: buildMongoExtractorInsert,
buildMongoUpdate: buildMongoExtractorUpdate,
contextCell,
contextSelectionIsSynthetic,
});
async function copyAll() {
const header = columns.value.join("\t");
const rows = displayItems.value.filter((item) => !item.isDraft).map((item) => item.data);
const body = rows.map((row) => row.map((cell) => displayCellValue(cell)).join("\t")).join("\n");
await copyText(`${header}\n${body}`, { rows, includeHeader: true });
await copyText(`${header}\n${body}`, { rows, header: columns.value });
}
// --- Export functions ---
@@ -1517,31 +1078,10 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
copyText,
copyCell,
copyRow,
copyRowAsInsert,
copyRowAsInsertWithoutPrimaryKeys,
prefetchRowAsInsertStatement,
canCopyPreparedInsert,
copyPreparedRowAsInsert,
canCopyRowAsInsert,
prefetchRowAsUpdateStatement,
canCopyPreparedUpdate,
copyPreparedRowAsUpdate,
copyRowAsUpdate,
canCopyRowAsInsertWithoutPrimaryKeys,
canCopyRowAsUpdate,
copyAll,
copySelectionTsv,
copySelectionTsvWithHeaders,
copySelectionCsv,
copySelectionJson,
copySelectionSqlInList,
copySelectionAsInsert,
prefetchSelectionAsInsertStatement,
canCopyPreparedSelectionInsert,
canCopySelectionAsInsert,
selectionInsertRowCount,
copySelectedRowsTsv,
copySelectedRowsTsvWithHeaders,
copyWithExtractor,
previewWithExtractor,
canCopyWithExtractor,
exportCsv,
exportCurrentPageCsv,
exportJson,
@@ -1655,18 +1195,15 @@ function effectiveColumns(sourceColumns: Array<string | undefined> | undefined,
return sourceColumns;
}
function isCopyInsertOmittedColumn(databaseType: DatabaseType | undefined, column: string, tableMeta: DataGridTableMeta | undefined): boolean {
function isCopyInsertOmittedColumn(databaseType: DatabaseType | undefined, column: string, tableMeta: DataGridTableMeta | undefined, extractorOptions?: DataGridExtractorOptions): boolean {
if (usesSyntheticRowIdKey(databaseType, [column])) return true;
const columnInfo = tableMeta?.columns?.find((item) => normalizeColumnName(item.name) === normalizeColumnName(column));
const normalizedType = columnInfo?.data_type.trim().replace(/^"|"$/g, "").toLowerCase();
if (databaseType === "postgres" && (normalizedType === "tsvector" || normalizedType?.endsWith(".tsvector"))) return true;
const extra = columnInfo?.extra?.toLowerCase() ?? "";
return /\b(auto_increment|autoincrement|identity)\b/.test(extra) || (extra.includes("generated always as") && !extra.includes("identity"));
}
function findColumnIndex(columns: Array<string | undefined>, target: string): number {
const normalizedTarget = normalizeColumnName(target);
return columns.findIndex((column) => (column ? normalizeColumnName(column) : "") === normalizedTarget);
const isAutoGenerated = /\b(auto_increment|autoincrement|identity)\b/.test(extra);
const isComputed = extra.includes("generated always as") && !extra.includes("identity");
return ((extractorOptions?.sql.skipGeneratedColumns ?? true) && isAutoGenerated) || ((extractorOptions?.sql.skipComputedColumns ?? true) && isComputed);
}
function normalizeColumnName(name: string): string {
@@ -0,0 +1,281 @@
import { computed, type ComputedRef, type Ref } from "vue";
import { useI18n } from "vue-i18n";
import * as api from "@/lib/backend/api";
import { useToast } from "@/composables/useToast";
import { dedupeColumnIndexes, type CellSelectionMatrix, type SelectionData } from "@/lib/dataGrid/gridSelection";
import {
DATA_GRID_COPY_EXTRACTOR_DESCRIPTORS,
DATA_GRID_EXTRACTOR_CONTRACT_VERSION,
DATA_GRID_EXTRACTOR_PREVIEW_MAX_ROWS,
DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS,
extractorUnavailableForDatabase,
normalizeDataGridExtractorOptions,
type DataGridCopyExtractorId,
type DataGridExtractPreview,
type DataGridExtractRequest,
type DataGridExtractorOptions,
type DataGridExtractWarningCode,
} from "@/lib/dataGrid/dataGridCopyExtractor";
import type { DataGridTableMeta } from "@/lib/dataGrid/dataGridSql";
import type { DatabaseType } from "@/types/database";
interface ExtractorRowItem {
id: number;
data: SelectionData["rows"][number];
isDraft?: boolean;
}
interface UseDataGridExtractorOptions {
columns: ComputedRef<string[]>;
displayItems: ComputedRef<ExtractorRowItem[]>;
allColumns: ComputedRef<string[]>;
allDisplayItems: ComputedRef<ExtractorRowItem[]>;
allSourceColumns: ComputedRef<Array<string | undefined> | undefined>;
visibleColumnIndexes: ComputedRef<number[]>;
extractorOptions?: ComputedRef<DataGridExtractorOptions>;
databaseType: ComputedRef<DatabaseType | undefined>;
tableMeta: ComputedRef<DataGridTableMeta | undefined>;
hasCellSelection: ComputedRef<boolean>;
selectedCells: ComputedRef<SelectionData>;
selectedCellMatrix: ComputedRef<CellSelectionMatrix | null>;
hasRowSelection: ComputedRef<boolean>;
hasColumnSelection: ComputedRef<boolean>;
selectedRowIds: Ref<Set<number>> | ComputedRef<Set<number>>;
contextCell: ComputedRef<{ rowId: number; rowIndex: number; col: number } | null> | Ref<{ rowId: number; rowIndex: number; col: number } | null>;
contextSelectionIsSynthetic: ComputedRef<boolean> | Ref<boolean>;
copyText: (text: string, gridCopy?: { rows: readonly (readonly unknown[])[]; header?: readonly unknown[] }) => Promise<boolean>;
canCopySqlInsert: (request: DataGridExtractRequest) => boolean;
buildMongoInsert: (extractorOptions: DataGridExtractorOptions, rowLimit?: number) => Promise<string | undefined>;
buildMongoUpdate?: (extractorOptions: DataGridExtractorOptions, rowLimit?: number) => Promise<string | undefined>;
}
export function useDataGridExtractor(options: UseDataGridExtractorOptions) {
const { t } = useI18n();
const { toast } = useToast();
const hasUnsupportedDiscreteSelection = computed(() => options.hasCellSelection.value && options.selectedCellMatrix.value === null);
function selectionData(): SelectionData | null {
if (options.hasRowSelection.value && options.selectedRowIds.value.size > 0) {
const rows = options.displayItems.value.filter((item) => options.selectedRowIds.value.has(item.id) && !item.isDraft).map((item) => item.data);
return rows.length > 0 ? { columns: options.columns.value, rows } : null;
}
return options.hasCellSelection.value ? options.selectedCells.value : null;
}
function buildRequest(extractor: DataGridCopyExtractorId, extractorOptions: DataGridExtractorOptions = options.extractorOptions?.value ?? DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS): DataGridExtractRequest | null {
const visibleIndexes = options.visibleColumnIndexes.value;
const sourceNames = options.allSourceColumns.value;
const fullColumns = options.allColumns.value;
const fullItemsById = new Map(options.allDisplayItems.value.map((item) => [item.id, item]));
let sourceRows: unknown[][] = [];
let selectedSourceIndexes: number[] = [];
let selectionKind: DataGridExtractRequest["selectionKind"] = "cells";
const matrix = options.selectedCellMatrix.value;
const isMultiCellSelection = !!matrix && (matrix.rowIndexes.length > 1 || matrix.columnIndexes.length > 1);
// A right-click sets contextCell.col to the clicked column (≥ 0); the test
// harness and non-right-click paths leave it at -1. Only treat a 1×1 matrix
// as synthetic when there's a real right-click context.
const hasRightClickContext = !!options.contextCell.value && options.contextSelectionIsSynthetic.value;
if (options.hasRowSelection.value && options.selectedRowIds.value.size > 0) {
sourceRows = options.displayItems.value.filter((item) => options.selectedRowIds.value.has(item.id) && !item.isDraft).map((item) => (fullItemsById.get(item.id) ?? item).data);
selectedSourceIndexes = dedupeColumnIndexes(visibleIndexes).filter((index) => index < fullColumns.length);
selectionKind = "rows";
} else if (isMultiCellSelection && matrix) {
sourceRows = matrix.rowIndexes
.map((rowIndex) => options.displayItems.value[rowIndex])
.filter((item): item is ExtractorRowItem => !!item && !item.isDraft)
.map((item) => (fullItemsById.get(item.id) ?? item).data);
selectedSourceIndexes = dedupeColumnIndexes(matrix.columnIndexes.map((index) => visibleIndexes[index] ?? index)).filter((index) => index < fullColumns.length);
if (options.hasColumnSelection.value) selectionKind = "columns";
} else if (hasRightClickContext && options.contextCell.value) {
// Right-click on a cell with no (or synthetic single-cell) selection: copy the full row.
const item = fullItemsById.get(options.contextCell.value.rowId);
if (item && !item.isDraft) {
sourceRows = [item.data];
selectedSourceIndexes = dedupeColumnIndexes(visibleIndexes).filter((index) => index < fullColumns.length);
selectionKind = "rows";
}
} else if (matrix) {
// Single-cell matrix without a context cell — still honor the explicit selection.
sourceRows = matrix.rowIndexes
.map((rowIndex) => options.displayItems.value[rowIndex])
.filter((item): item is ExtractorRowItem => !!item && !item.isDraft)
.map((item) => (fullItemsById.get(item.id) ?? item).data);
selectedSourceIndexes = dedupeColumnIndexes(matrix.columnIndexes.map((index) => visibleIndexes[index] ?? index)).filter((index) => index < fullColumns.length);
if (options.hasColumnSelection.value) selectionKind = "columns";
}
if (sourceRows.length === 0 || selectedSourceIndexes.length === 0) return null;
const requiredSourceIndexes = [...selectedSourceIndexes];
if (extractor === "sql-updates") {
for (const primaryKey of options.tableMeta.value?.primaryKeys ?? []) {
const primaryKeyIndex = fullColumns.findIndex((displayName, index) => normalizeName(sourceNames?.[index] ?? displayName) === normalizeName(primaryKey));
if (primaryKeyIndex >= 0 && !requiredSourceIndexes.includes(primaryKeyIndex)) requiredSourceIndexes.push(primaryKeyIndex);
}
}
const compactIndexBySource = new Map(requiredSourceIndexes.map((sourceIndex, compactIndex) => [sourceIndex, compactIndex]));
const columns = requiredSourceIndexes.map((sourceIndex, compactIndex) => ({
displayName: fullColumns[sourceIndex],
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) => row[sourceIndex]));
const descriptor = DATA_GRID_COPY_EXTRACTOR_DESCRIPTORS[extractor];
const tableMeta =
descriptor.category === "sql"
? compactTableMeta(
options.tableMeta.value,
columns.map((column) => column.sourceName ?? column.displayName),
)
: undefined;
return {
version: DATA_GRID_EXTRACTOR_CONTRACT_VERSION,
extractor,
databaseType: descriptor.category === "sql" ? options.databaseType.value : undefined,
tableMeta,
columns,
selectedColumnIndexes,
rows,
selectionKind,
options: normalizeDataGridExtractorOptions(extractorOptions),
};
}
function canBuildSqlUpdateRequest(): boolean {
const request = buildRequest("sql-updates");
const primaryKeys = request?.tableMeta?.primaryKeys ?? [];
if (!request || primaryKeys.length === 0) return false;
const primaryKeyNames = new Set(primaryKeys.map(normalizeName));
const hasWritableColumn = request.selectedColumnIndexes.some((index) => {
const column = request.columns[index];
if (!column) return false;
const sourceName = column.sourceName ?? column.displayName;
return !primaryKeyNames.has(normalizeName(sourceName)) && (!request.options.sql.skipGeneratedColumns || !isAutoGeneratedColumn(sourceName, request.tableMeta)) && (!request.options.sql.skipComputedColumns || !isComputedColumn(sourceName, request.tableMeta));
});
if (!hasWritableColumn) return false;
return primaryKeys.every((primaryKey) => {
const column = request.columns.find((candidate) => normalizeName(candidate.sourceName ?? candidate.displayName) === normalizeName(primaryKey));
return !!column && request.rows.every((row) => row[column.sourceIndex] !== null && row[column.sourceIndex] !== undefined);
});
}
function canCopyWithExtractor(extractor: DataGridCopyExtractorId): boolean {
if (hasUnsupportedDiscreteSelection.value) return false;
if (!options.hasRowSelection.value && !options.hasCellSelection.value && !options.contextCell.value) return false;
if (extractorUnavailableForDatabase(extractor, options.databaseType.value)) return false;
if (extractor === "sql-inserts") {
const request = buildRequest(extractor);
return request !== null && options.canCopySqlInsert(request);
}
if (extractor === "sql-updates") {
// Mongo has a dedicated updateOne path that doesn't need SQL primary keys.
if (options.databaseType.value === "mongodb") return !!options.buildMongoUpdate;
return canBuildSqlUpdateRequest();
}
return selectionData() !== null;
}
async function resolveMongoExtractorResult(extractor: DataGridCopyExtractorId, request: DataGridExtractRequest, rowLimit?: number) {
if (options.databaseType.value !== "mongodb") return null;
const builder = extractor === "sql-inserts" ? options.buildMongoInsert : extractor === "sql-updates" ? options.buildMongoUpdate : undefined;
if (!builder) return null;
const text = (await builder(request.options, rowLimit)) ?? "";
if (!text) return null;
return { text, mimeType: "application/javascript", fileExtension: "js", rowCount: rowLimit ?? request.rows.length, columnCount: request.selectedColumnIndexes.length, warnings: undefined, omittedColumns: undefined };
}
async function copyWithExtractor(extractor: DataGridCopyExtractorId): Promise<boolean> {
if (hasUnsupportedDiscreteSelection.value) {
toast(t("grid.copyExtractorUnsupportedSelection"), 5000);
return false;
}
if (!canCopyWithExtractor(extractor)) return false;
const request = buildRequest(extractor);
if (!request) return false;
try {
const mongoResult = await resolveMongoExtractorResult(extractor, request);
const result = mongoResult ?? (await api.extractDataGridSelection(request));
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
? (() => {
const includeRowHeader = 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;
});
const selectedHeaders = request.selectedColumnIndexes.map((index) => request.columns[index]?.displayName ?? "");
const header = extractor === "tsv-with-headers" ? (includeRowHeader ? ["#", ...selectedHeaders] : selectedHeaders) : undefined;
return { rows, header };
})()
: undefined;
const copied = await options.copyText(result.text, gridCopy);
if (!copied) return false;
showWarnings(result.warnings, result.omittedColumns);
return true;
} catch (error: unknown) {
toast(t("grid.copyFailed", { message: errorMessage(error) }), 5000);
return false;
}
}
async function previewWithExtractor(extractor: DataGridCopyExtractorId, extractorOptions: DataGridExtractorOptions): Promise<DataGridExtractPreview> {
if (hasUnsupportedDiscreteSelection.value) throw new Error(t("grid.copyExtractorUnsupportedSelection"));
const request = buildRequest(extractor, extractorOptions);
if (!request) throw new Error(t("grid.copyExtractorEmptySelection"));
const sourceRowCount = request.rows.length;
const previewRowCount = Math.min(sourceRowCount, DATA_GRID_EXTRACTOR_PREVIEW_MAX_ROWS);
const mongoResult = await resolveMongoExtractorResult(extractor, request, previewRowCount);
const result = mongoResult ?? (await api.extractDataGridSelection({ ...request, rows: request.rows.slice(0, DATA_GRID_EXTRACTOR_PREVIEW_MAX_ROWS) }));
if (!result.text) throw new Error(t("grid.copyExtractorEmptySelection"));
return { ...result, sourceRowCount, truncated: sourceRowCount > result.rowCount };
}
function showWarnings(warnings: Array<{ code: DataGridExtractWarningCode; message: string }> | undefined, omittedColumns: string[] | undefined) {
if (!warnings?.length) return;
for (const warning of warnings) {
const message = warning.code === "omitted-columns" ? t("grid.copyExtractorWarningOmittedColumns", { columns: omittedColumns?.join(", ") || "-" }) : t("grid.copyExtractorWarningDuplicateJsonColumns");
toast(message, 5000);
}
}
return { copyWithExtractor, previewWithExtractor, canCopyWithExtractor };
}
function compactTableMeta(tableMeta: DataGridTableMeta | undefined, requiredColumns: string[]): DataGridTableMeta | undefined {
if (!tableMeta?.columns) return tableMeta;
const requiredNames = new Set(requiredColumns.map(normalizeName));
return { ...tableMeta, columns: tableMeta.columns.filter((column) => requiredNames.has(normalizeName(column.name))) };
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function normalizeName(name: string): string {
const trimmed = name.trim();
const first = trimmed[0];
const last = trimmed[trimmed.length - 1];
const unquoted = (first === "`" && last === "`") || (first === '"' && last === '"') || (first === "[" && last === "]") ? trimmed.slice(1, -1) : trimmed;
return unquoted.replace(/[a-z]/g, (character) => character.toUpperCase());
}
function columnExtra(column: string, tableMeta: DataGridTableMeta | undefined): string {
const columnInfo = tableMeta?.columns?.find((item) => normalizeName(item.name) === normalizeName(column));
return columnInfo?.extra?.toLowerCase() ?? "";
}
function isAutoGeneratedColumn(column: string, tableMeta: DataGridTableMeta | undefined): boolean {
return /\b(auto_increment|autoincrement|identity)\b/.test(columnExtra(column, tableMeta));
}
function isComputedColumn(column: string, tableMeta: DataGridTableMeta | undefined): boolean {
const extra = columnExtra(column, tableMeta);
return extra.includes("generated always as") && !extra.includes("identity");
}
@@ -1,5 +1,5 @@
import { ref, computed, getCurrentScope, onScopeDispose, type ComputedRef, type Ref } from "vue";
import { allCellsSelectionRange, extractColumnsSelection, extractSelection, isCellInSelection, normalizeSelectionRange, normalizeSelectedColumnIndexes, rowSelectionRange, type CellPosition, type CellSelectionMatrix, type CellSelectionRange, type SelectionData } from "@/lib/dataGrid/gridSelection";
import { allCellsSelectionRange, extractColumnsSelection, extractSelection, isCellInSelection, normalizeSelectionRange, normalizeSelectedColumnIndexes, type CellPosition, type CellSelectionMatrix, type CellSelectionRange, type SelectionData } from "@/lib/dataGrid/gridSelection";
import type { DataGridRuntimeScope } from "@/lib/dataGrid/dataGridRuntime";
type CellValue = string | number | boolean | null;
@@ -25,21 +25,29 @@ export interface UseDataGridSelectionOptions {
gridRef: Ref<HTMLDivElement | undefined>;
getScrollElement?: () => HTMLElement | null;
cellFromClientPoint?: (clientX: number, clientY: number) => CellPosition | null;
rowFromClientPoint?: (clientX: number, clientY: number) => number | null;
onUserCellSelection?: () => void;
runtimeScope?: DataGridRuntimeScope;
}
const AUTO_SCROLL_EDGE_SIZE = 40;
const AUTO_SCROLL_MAX_SPEED = 28;
type RowSelectionOperation = "replace" | "add" | "remove";
export function useDataGridSelection(options: UseDataGridSelectionOptions) {
const { columns, displayItems, editingCell, showTranspose, transposeRowIndex, gridRef, getScrollElement, cellFromClientPoint } = options;
const { columns, displayItems, editingCell, showTranspose, transposeRowIndex, gridRef, getScrollElement, cellFromClientPoint, rowFromClientPoint } = options;
const selectionAnchor = ref<CellPosition | null>(null);
const selectionFocus = ref<CellPosition | null>(null);
const isSelectingCells = ref(false);
const isSelectingRows = ref(false);
let selectionPointerClientX = 0;
let selectionPointerClientY = 0;
let selectionAutoScrollFrame = 0;
let rowSelectionRangeAnchorIndex = -1;
let rowSelectionFocusIndex = -1;
let rowSelectionBaseIds = new Set<number>();
let rowSelectionOperation: RowSelectionOperation = "replace";
const isSelectingAll = ref(false);
const selectedCellKeys = ref<Set<string>>(new Set());
@@ -159,19 +167,11 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) {
}
function selectRow(rowIndex: number) {
const range = rowSelectionRange(rowIndex, columns.value.length);
if (!range) return;
selectedCellKeys.value = new Set();
selectionAnchor.value = { rowIndex: range.startRow, colIndex: range.startCol };
selectionFocus.value = { rowIndex: range.endRow, colIndex: range.endCol };
}
function selectRows(startRow: number, endRow: number) {
const range = rowSelectionRange(startRow, columns.value.length, endRow);
if (!range) return;
selectedCellKeys.value = new Set();
selectionAnchor.value = { rowIndex: range.startRow, colIndex: range.startCol };
selectionFocus.value = { rowIndex: range.endRow, colIndex: range.endCol };
const item = displayItems.value[rowIndex];
if (!item) return;
clearCellSelection();
selectedRowIds.value = new Set([item.id]);
lastClickedRowIndex.value = rowIndex;
}
function selectColumns(startCol: number, endCol: number, options?: { merge?: boolean }) {
@@ -270,47 +270,104 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) {
next.add(rowId);
}
selectedRowIds.value = next;
selectContiguousRowIds(next);
lastClickedRowIndex.value = rowIndex;
} else if (isShift && lastClickedRowIndex.value !== null) {
const start = Math.min(lastClickedRowIndex.value, rowIndex);
const end = Math.max(lastClickedRowIndex.value, rowIndex);
const next = new Set(selectedRowIds.value);
for (let i = start; i <= end; i++) {
const item = displayItems.value[i];
if (item) next.add(item.id);
}
selectedRowIds.value = next;
selectRows(start, end);
selectedRowIds.value = rowIdsInRange(lastClickedRowIndex.value, rowIndex);
} else {
selectedRowIds.value = new Set([rowId]);
selectRow(rowIndex);
lastClickedRowIndex.value = rowIndex;
}
}
function selectContiguousRowIds(rowIds: Set<number>) {
if (rowIds.size === 0) {
clearCellSelection();
return;
function rowIdsInRange(startRow: number, endRow: number): Set<number> {
const ids = new Set<number>();
const start = Math.max(0, Math.min(startRow, endRow));
const end = Math.min(displayItems.value.length - 1, Math.max(startRow, endRow));
for (let index = start; index <= end; index++) {
const item = displayItems.value[index];
if (item) ids.add(item.id);
}
return ids;
}
const indexes = displayItems.value.reduce<number[]>((selectedIndexes, item, index) => {
if (rowIds.has(item.id)) selectedIndexes.push(index);
return selectedIndexes;
}, []);
if (indexes.length !== rowIds.size) {
clearCellSelection();
return;
function applyDraggedRowRange(focusRowIndex: number) {
if (focusRowIndex === rowSelectionFocusIndex) return;
const previousFocusIndex = rowSelectionFocusIndex;
const previousStart = previousFocusIndex < 0 ? 0 : Math.min(rowSelectionRangeAnchorIndex, previousFocusIndex);
const previousEnd = previousFocusIndex < 0 ? -1 : Math.max(rowSelectionRangeAnchorIndex, previousFocusIndex);
const nextStart = Math.min(rowSelectionRangeAnchorIndex, focusRowIndex);
const nextEnd = Math.max(rowSelectionRangeAnchorIndex, focusRowIndex);
const changedStart = previousFocusIndex < 0 ? nextStart : Math.min(previousStart, nextStart);
const changedEnd = Math.max(previousEnd, nextEnd);
const selectedIds = selectedRowIds.value;
for (let index = changedStart; index <= changedEnd; index++) {
const wasInRange = index >= previousStart && index <= previousEnd;
const isInRange = index >= nextStart && index <= nextEnd;
if (wasInRange === isInRange) continue;
const item = displayItems.value[index];
if (!item) continue;
if (isInRange) {
if (rowSelectionOperation === "remove") selectedIds.delete(item.id);
else selectedIds.add(item.id);
} else if (rowSelectionOperation !== "replace" && rowSelectionBaseIds.has(item.id)) {
selectedIds.add(item.id);
} else {
selectedIds.delete(item.id);
}
}
rowSelectionFocusIndex = focusRowIndex;
}
const start = Math.min(...indexes);
const end = Math.max(...indexes);
if (end - start + 1 !== indexes.length) {
clearCellSelection();
return;
function updateRowSelectionFromPointer() {
const rowIndex = rowFromClientPoint?.(selectionPointerClientX, selectionPointerClientY);
if (rowIndex === null || rowIndex === undefined) return;
applyDraggedRowRange(rowIndex);
}
function handleRowSelectionPointerMove(event: MouseEvent) {
if (!isSelectingRows.value) return;
selectionPointerClientX = event.clientX;
selectionPointerClientY = event.clientY;
if (!selectionAutoScrollFrame) selectionAutoScrollFrame = requestAnimationFrame(runSelectionAutoScroll);
}
function finishRowSelection(event?: MouseEvent) {
if (!isSelectingRows.value) return;
if (event) {
selectionPointerClientX = event.clientX;
selectionPointerClientY = event.clientY;
}
selectRows(start, end);
updateRowSelectionFromPointer();
isSelectingRows.value = false;
// Keep the range origin stable across repeated Shift selections. For a
// plain or meta selection this is the row where the gesture started; for
// Shift it remains the previous anchor.
lastClickedRowIndex.value = rowSelectionRangeAnchorIndex >= 0 ? rowSelectionRangeAnchorIndex : lastClickedRowIndex.value;
document.removeEventListener("mouseup", finishRowSelection);
document.removeEventListener("mousemove", handleRowSelectionPointerMove);
stopSelectionAutoScroll();
}
function beginRowSelection(rowIndex: number, rowId: number, event: MouseEvent) {
if (event.button !== 0) return;
event.preventDefault();
focusGridWithoutScrolling();
clearCellSelection();
const isMeta = event.metaKey || event.ctrlKey;
const isShift = event.shiftKey;
rowSelectionRangeAnchorIndex = isShift && lastClickedRowIndex.value !== null ? lastClickedRowIndex.value : rowIndex;
rowSelectionFocusIndex = -1;
rowSelectionBaseIds = new Set(selectedRowIds.value);
rowSelectionOperation = isMeta ? (rowSelectionBaseIds.has(rowId) ? "remove" : "add") : "replace";
selectedRowIds.value = rowSelectionOperation === "replace" ? new Set() : new Set(rowSelectionBaseIds);
applyDraggedRowRange(rowIndex);
isSelectingRows.value = true;
selectionPointerClientX = event.clientX;
selectionPointerClientY = event.clientY;
document.addEventListener("mouseup", finishRowSelection);
document.addEventListener("mousemove", handleRowSelectionPointerMove);
}
function finishCellSelection() {
@@ -343,20 +400,24 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) {
function runSelectionAutoScroll() {
selectionAutoScrollFrame = 0;
if (!isSelectingCells.value) return;
if (!isSelectingCells.value && !isSelectingRows.value) return;
const scroller = getScrollElement?.();
if (!scroller) return;
if (!scroller) {
if (isSelectingRows.value) updateRowSelectionFromPointer();
else updateSelectionFromPointer();
return;
}
const rect = scroller.getBoundingClientRect();
const deltaX = selectionScrollVelocity(selectionPointerClientX, rect.left, rect.right);
const deltaX = isSelectingCells.value ? selectionScrollVelocity(selectionPointerClientX, rect.left, rect.right) : 0;
const deltaY = selectionScrollVelocity(selectionPointerClientY, rect.top, rect.bottom);
const previousLeft = scroller.scrollLeft;
const previousTop = scroller.scrollTop;
scroller.scrollLeft += deltaX;
scroller.scrollTop += deltaY;
updateSelectionFromPointer();
if (isSelectingRows.value) updateRowSelectionFromPointer();
else updateSelectionFromPointer();
if (scroller.scrollLeft !== previousLeft || scroller.scrollTop !== previousTop) {
selectionAutoScrollFrame = requestAnimationFrame(runSelectionAutoScroll);
}
@@ -390,8 +451,13 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) {
document.addEventListener("mousemove", handleSelectionPointerMove);
}
if (options.runtimeScope) options.runtimeScope.addCleanup(finishCellSelection);
else if (getCurrentScope()) onScopeDispose(finishCellSelection);
function finishSelection() {
finishCellSelection();
finishRowSelection();
}
if (options.runtimeScope) options.runtimeScope.addCleanup(finishSelection);
else if (getCurrentScope()) onScopeDispose(finishSelection);
function extendCellSelection(rowIndex: number, colIndex: number) {
if (!isSelectingCells.value || !selectionAnchor.value) return;
@@ -401,6 +467,7 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) {
function handleDataCellMousedown(rowIndex: number, colIndex: number, _rowId: number, event: MouseEvent) {
if (event.button !== 0) return;
if (editingCell.value) return;
options.onUserCellSelection?.();
const isMeta = event.metaKey || event.ctrlKey;
const isShift = event.shiftKey;
@@ -452,6 +519,7 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) {
selectionAnchor,
selectionFocus,
isSelectingCells,
isSelectingRows,
selectedRange,
selectedCells,
selectedCellMatrix,
@@ -480,6 +548,8 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) {
hasColumnSelection,
clearRowSelection,
handleRowClick,
beginRowSelection,
finishRowSelection,
handleDataCellMousedown,
isRowSelected,
};
+42 -23
View File
@@ -956,6 +956,48 @@ export default {
noFilteredRows: "No rows match the filter",
noFilteredRowsDescription: "Adjust the search text or row status filter.",
copy: "Copy",
copyExtractorWithHeaders: "{format} with Headers",
copyExtractorOneRow: "One-row",
copyExtractorSqlInList: "SQL IN List",
copyExtractorSqlInserts: "SQL Inserts",
copyExtractorSqlUpdates: "SQL Updates",
copyExtractorWhereClause: "Where Clause",
copyExtractorPipeSeparated: "Pipe-separated",
copyExtractorPretty: "Pretty Table",
copyExtractorConfigure: "Configure Data Extractors…",
copyExtractorFormat: "Extractor",
copyExtractorColumnSeparator: "Column separator",
copyExtractorRowSeparator: "Row separator",
copyExtractorNullText: "NULL text",
copyExtractorQuote: "Quote character",
copyExtractorQuotePolicy: "Quoting",
copyExtractorQuoteMinimal: "When needed",
copyExtractorQuoteAlways: "Always",
copyExtractorQuoteNever: "Never",
copyExtractorIncludeHeader: "Include column header",
copyExtractorIncludeRowHeader: "Include row numbers",
copyExtractorSkipComputed: "Skip computed columns",
copyExtractorSkipGenerated: "Skip generated columns",
copyExtractorExcludePrimaryKeys: "Exclude primary keys",
copyExtractorInsertMode: "INSERT statements",
copyExtractorInsertMerged: "Merge rows",
copyExtractorInsertRowByRow: "One statement per row",
copyExtractorPrettyJson: "Pretty-print JSON",
copyExtractorPreview: "Preview (current selection)",
copyExtractorEmptySelection: "Select data to preview the extractor output.",
copyExtractorUnsupportedSelection: "This extractor requires the same columns to be selected in every row.",
copyExtractorPreviewTruncated: "Previewing the first 100 rows of {count} selected rows.",
copyExtractorWarningOmittedColumns: "Copied output omitted these columns: {columns}.",
copyExtractorWarningDuplicateJsonColumns: "Duplicate JSON column names were renamed to prevent data loss.",
copyExtractorValidation: {
"column-separator-empty": "Column separator must not be empty.",
"row-separator-empty": "Row separator must not be empty.",
"separator-too-long": "Separators may contain at most 8 characters.",
"null-text-too-long": "NULL text may contain at most 64 characters.",
"separators-overlap": "Column and row separators must not overlap.",
"invalid-quote": "Quote must be exactly one character.",
"quote-conflicts": "Quote must not be part of a column or row separator.",
},
mongoJsonPreview: "JSON Preview",
mongoJsonPreviewEmpty: "Select a MongoDB document to preview.",
copyJson: "Copy JSON",
@@ -969,21 +1011,9 @@ export default {
copyColumnNamesSeparator: "Column Separator",
copyColumnNamesQuote: "Quote Column Names",
copyColumnNamesPreview: "Preview",
copyRowInsert: "Copy as INSERT",
copyRowInsertWithoutPrimaryKeys: "Copy as INSERT without Primary Keys",
copyRowUpdate: "Copy as UPDATE",
copyAll: "Copy All (TSV)",
selection: "Selection",
selectionSum: "SUM: {value}",
selectionCells: "{count} cells",
copySelectionTsv: "Copy Selection (TSV)",
copySelectionTsvWithHeaders: "Copy Selection with Headers (TSV)",
copySelectionCsv: "Copy Selection (CSV)",
copySelectionJson: "Copy Selection (JSON)",
copySelectionSql: "Copy Selection as SQL IN List",
copySelectionInsert: "Copy Selection as INSERT",
copySelectionInsertMerged: "Copy Selection as INSERT (Merged)",
copySelectionInsertRowByRow: "Copy Selection as INSERT (Row by Row)",
clearSelection: "Clear Selection",
export: "Export",
exportCsv: "Export CSV",
@@ -1162,18 +1192,7 @@ export default {
cloneRows: "Clone {count} as New Rows",
restoreRows: "Restore {count} Rows",
copyRows: "Copy {count} Rows (JSON)",
copySelectedRowsTsv: "Copy {count} Rows (TSV)",
copySelectedRowsTsvWithHeaders: "Copy {count} Rows with Headers (TSV)",
copySelectedRowTsv: "Copy 1 Row (TSV)",
copySelectedRowTsvWithHeaders: "Copy 1 Row with Headers (TSV)",
copyColumnsJson: "Copy {count} Columns (JSON)",
copyRowsInsert: "Copy {count} Rows as INSERT",
copyRowsInsertMerged: "Copy {count} Rows as INSERT (Merged)",
copyRowsInsertRowByRow: "Copy {count} Rows as INSERT (Row by Row)",
copyRowsInsertWithoutPrimaryKeys: "Copy {count} Rows as INSERT without Primary Keys",
copyRowsInsertWithoutPrimaryKeysMerged: "Copy {count} Rows as INSERT without Primary Keys (Merged)",
copyRowsInsertWithoutPrimaryKeysRowByRow: "Copy {count} Rows as INSERT without Primary Keys (Row by Row)",
copyRowsUpdate: "Copy {count} Rows as UPDATE",
selectedRows: "{count} rows selected",
restoreRow: "Restore Row",
statusClean: "Clean",
+42 -23
View File
@@ -917,21 +917,9 @@ export default withEnglishFallback({
copyColumnNamesSeparator: "Separador de columnas",
copyColumnNamesQuote: "Escapar nombres de columnas",
copyColumnNamesPreview: "Vista previa",
copyRowInsert: "Copiar como INSERT",
copyRowInsertWithoutPrimaryKeys: "Copiar como INSERT sin claves primarias",
copyRowUpdate: "Copiar como UPDATE",
copyAll: "Copiar todo (TSV)",
selection: "Selección",
selectionSum: "SUM: {value}",
selectionCells: "{count} celdas",
copySelectionTsv: "Copiar selección (TSV)",
copySelectionTsvWithHeaders: "Copiar selección con encabezados (TSV)",
copySelectionCsv: "Copiar selección (CSV)",
copySelectionJson: "Copiar selección (JSON)",
copySelectionSql: "Copiar selección como lista SQL IN",
copySelectionInsert: "Copiar selección como INSERT",
copySelectionInsertMerged: "Copiar selección como INSERT (combinado)",
copySelectionInsertRowByRow: "Copiar selección como INSERT (fila por fila)",
clearSelection: "Limpiar selección",
export: "Exportar",
exportCsv: "Exportar CSV",
@@ -1104,18 +1092,7 @@ export default withEnglishFallback({
cloneRows: "Clonar {count} como filas nuevas",
restoreRows: "Restaurar {count} filas",
copyRows: "Copiar {count} filas (JSON)",
copySelectedRowsTsv: "Copiar {count} filas (TSV)",
copySelectedRowsTsvWithHeaders: "Copiar {count} filas con encabezados (TSV)",
copySelectedRowTsv: "Copiar 1 fila (TSV)",
copySelectedRowTsvWithHeaders: "Copiar 1 fila con encabezados (TSV)",
copyColumnsJson: "Copiar {count} columnas (JSON)",
copyRowsInsert: "Copiar {count} filas como INSERT",
copyRowsInsertMerged: "Copiar {count} filas como INSERT (combinado)",
copyRowsInsertRowByRow: "Copiar {count} filas como INSERT (fila por fila)",
copyRowsInsertWithoutPrimaryKeys: "Copiar {count} filas como INSERT sin claves primarias",
copyRowsInsertWithoutPrimaryKeysMerged: "Copiar {count} filas como INSERT sin claves primarias (combinado)",
copyRowsInsertWithoutPrimaryKeysRowByRow: "Copiar {count} filas como INSERT sin claves primarias (fila por fila)",
copyRowsUpdate: "Copiar {count} filas como UPDATE",
selectedRows: "{count} filas seleccionadas",
restoreRow: "Restaurar fila",
statusClean: "Sin cambios",
@@ -1265,6 +1242,48 @@ 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",
copyExtractorWithHeaders: "{format} (con encabezados)",
copyExtractorOneRow: "CSV de una fila",
copyExtractorSqlInList: "Lista SQL IN",
copyExtractorSqlInserts: "Sentencias SQL INSERT",
copyExtractorSqlUpdates: "Sentencias SQL UPDATE",
copyExtractorWhereClause: "Cláusula WHERE",
copyExtractorPipeSeparated: "Separado por barra vertical",
copyExtractorPretty: "Tabla formateada",
copyExtractorConfigure: "Configurar extractor de datos…",
copyExtractorFormat: "Extractor",
copyExtractorColumnSeparator: "Separador de columnas",
copyExtractorRowSeparator: "Separador de filas",
copyExtractorNullText: "Texto NULL",
copyExtractorQuote: "Carácter de comillas",
copyExtractorQuotePolicy: "Política de comillas",
copyExtractorQuoteMinimal: "Cuando sea necesario",
copyExtractorQuoteAlways: "Siempre",
copyExtractorQuoteNever: "Nunca",
copyExtractorIncludeHeader: "Incluir nombres de columnas",
copyExtractorIncludeRowHeader: "Incluir números de fila",
copyExtractorSkipComputed: "Saltar columnas calculadas",
copyExtractorSkipGenerated: "Saltar columnas generadas",
copyExtractorExcludePrimaryKeys: "Excluir claves primarias",
copyExtractorInsertMode: "Modo de sentencia INSERT",
copyExtractorInsertMerged: "Combinar varias filas",
copyExtractorInsertRowByRow: "Una sentencia por fila",
copyExtractorPrettyJson: "JSON formateado",
copyExtractorPreview: "Vista previa (selección actual)",
copyExtractorEmptySelection: "Seleccione datos para previsualizar el resultado de la extracción.",
copyExtractorUnsupportedSelection: "El extractor actual requiere seleccionar las mismas columnas en cada fila.",
copyExtractorPreviewTruncated: "Actualmente solo se muestran las primeras 100 filas de las {count} seleccionadas.",
copyExtractorWarningOmittedColumns: "El resultado de la copia ha omitido las siguientes columnas: {columns}.",
copyExtractorWarningDuplicateJsonColumns: "Se detectaron nombres de columna JSON duplicados; se renombraron automáticamente para evitar pérdida de datos.",
copyExtractorValidation: {
"column-separator-empty": "El separador de columnas no puede estar vacío.",
"row-separator-empty": "El separador de filas no puede estar vacío.",
"separator-too-long": "El separador puede tener un máximo de 8 caracteres.",
"null-text-too-long": "El texto NULL puede tener un máximo de 64 caracteres.",
"separators-overlap": "El separador de columnas y el separador de filas no pueden contenerse entre sí.",
"invalid-quote": "El carácter de comillas debe ser exactamente un carácter.",
"quote-conflicts": "El carácter de comillas no puede aparecer en el separador de columnas o de filas.",
},
cachedResultUnavailable: "Resultado en caché faltante o incompatible.",
reexecuteQuery: "Volver a ejecutar consulta",
},
+42 -23
View File
@@ -915,21 +915,9 @@ export default withEnglishFallback({
copyColumnNamesSeparator: "Separatore di colonne",
copyColumnNamesQuote: "Racchiudi i nomi tra virgolette",
copyColumnNamesPreview: "Anteprima",
copyRowInsert: "Copia come INSERT",
copyRowInsertWithoutPrimaryKeys: "Copia come INSERT senza Chiavi Primarie",
copyRowUpdate: "Copia come UPDATE",
copyAll: "Copia Tutto (TSV)",
selection: "Selezione",
selectionSum: "SUM: {value}",
selectionCells: "{count} celle",
copySelectionTsv: "Copia Selezione (TSV)",
copySelectionTsvWithHeaders: "Copia Selezione con Intestazioni (TSV)",
copySelectionCsv: "Copia Selezione (CSV)",
copySelectionJson: "Copia Selezione (JSON)",
copySelectionSql: "Copia Selezione come elenco SQL IN",
copySelectionInsert: "Copia Selezione come INSERT",
copySelectionInsertMerged: "Copia Selezione come INSERT (unito)",
copySelectionInsertRowByRow: "Copia Selezione come INSERT (riga per riga)",
clearSelection: "Cancella Selezione",
export: "Esporta",
exportCsv: "Esporta CSV",
@@ -1102,18 +1090,7 @@ export default withEnglishFallback({
cloneRows: "Clona {count} come Nuove Righe",
restoreRows: "Ripristina {count} Righe",
copyRows: "Copia {count} Righe (JSON)",
copySelectedRowsTsv: "Copia {count} Righe (TSV)",
copySelectedRowsTsvWithHeaders: "Copia {count} Righe con Intestazioni (TSV)",
copySelectedRowTsv: "Copia 1 Riga (TSV)",
copySelectedRowTsvWithHeaders: "Copia 1 Riga con Intestazioni (TSV)",
copyColumnsJson: "Copia {count} Colonne (JSON)",
copyRowsInsert: "Copia {count} Righe come INSERT",
copyRowsInsertMerged: "Copia {count} Righe come INSERT (unito)",
copyRowsInsertRowByRow: "Copia {count} Righe come INSERT (riga per riga)",
copyRowsInsertWithoutPrimaryKeys: "Copia {count} Righe come INSERT senza Chiavi Primarie",
copyRowsInsertWithoutPrimaryKeysMerged: "Copia {count} Righe come INSERT senza Chiavi Primarie (unito)",
copyRowsInsertWithoutPrimaryKeysRowByRow: "Copia {count} Righe come INSERT senza Chiavi Primarie (riga per riga)",
copyRowsUpdate: "Copia {count} Righe come UPDATE",
selectedRows: "{count} righe selezionate",
restoreRow: "Ripristina Riga",
statusClean: "Pulito",
@@ -1263,6 +1240,48 @@ 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",
copyExtractorWithHeaders: "{format} (con intestazione)",
copyExtractorOneRow: "CSV a riga singola",
copyExtractorSqlInList: "Lista SQL IN",
copyExtractorSqlInserts: "Istruzioni SQL INSERT",
copyExtractorSqlUpdates: "Istruzioni SQL UPDATE",
copyExtractorWhereClause: "Clausola WHERE",
copyExtractorPipeSeparated: "Separato da pipe",
copyExtractorPretty: "Testo tabellare",
copyExtractorConfigure: "Configura estrattore dati…",
copyExtractorFormat: "Estrattore",
copyExtractorColumnSeparator: "Separatore di colonna",
copyExtractorRowSeparator: "Separatore di riga",
copyExtractorNullText: "Testo NULL",
copyExtractorQuote: "Carattere di citazione",
copyExtractorQuotePolicy: "Regola di citazione",
copyExtractorQuoteMinimal: "Quando necessario",
copyExtractorQuoteAlways: "Sempre",
copyExtractorQuoteNever: "Mai",
copyExtractorIncludeHeader: "Includi nomi colonna",
copyExtractorIncludeRowHeader: "Includi numeri riga",
copyExtractorSkipComputed: "Salta colonne calcolate",
copyExtractorSkipGenerated: "Salta colonne generate",
copyExtractorExcludePrimaryKeys: "Escludi chiavi primarie",
copyExtractorInsertMode: "Modalità istruzione INSERT",
copyExtractorInsertMerged: "Unisci più righe",
copyExtractorInsertRowByRow: "Una istruzione per riga",
copyExtractorPrettyJson: "JSON formattato",
copyExtractorPreview: "Anteprima (selezione corrente)",
copyExtractorEmptySelection: "Seleziona i dati per visualizzare l'anteprima dell'estrazione.",
copyExtractorUnsupportedSelection: "L'estrattore corrente richiede la stessa selezione di colonne per ogni riga.",
copyExtractorPreviewTruncated: "Anteprima solo delle prime 100 righe su {count} selezionate.",
copyExtractorWarningOmittedColumns: "Il risultato della copia ha ignorato le seguenti colonne: {columns}.",
copyExtractorWarningDuplicateJsonColumns: "Rilevati nomi di colonne JSON duplicati, rinominati automaticamente per evitare perdita di dati.",
copyExtractorValidation: {
"column-separator-empty": "Il separatore di colonna non può essere vuoto.",
"row-separator-empty": "Il separatore di riga non può essere vuoto.",
"separator-too-long": "Il separatore può contenere al massimo 8 caratteri.",
"null-text-too-long": "Il testo NULL può contenere al massimo 64 caratteri.",
"separators-overlap": "Il separatore di colonna e quello di riga non possono contenersi a vicenda.",
"invalid-quote": "Il carattere di citazione deve essere esattamente un carattere.",
"quote-conflicts": "Il carattere di citazione non può apparire nel separatore di colonna o di riga.",
},
cachedResultUnavailable: "Risultato in cache mancante o non compatibile.",
reexecuteQuery: "Esegui di nuovo la query",
},
+42 -23
View File
@@ -914,21 +914,9 @@ export default withEnglishFallback({
copyColumnNamesSeparator: "列区切り文字",
copyColumnNamesQuote: "列名を引用符で囲む",
copyColumnNamesPreview: "プレビュー",
copyRowInsert: "INSERTとしてコピー",
copyRowInsertWithoutPrimaryKeys: "主キーなしでINSERTとしてコピー",
copyRowUpdate: "UPDATEとしてコピー",
copyAll: "すべてコピー (TSV)",
selection: "選択範囲",
selectionSum: "SUM: {value}",
selectionCells: "{count} 個のセル",
copySelectionTsv: "選択範囲をコピー (TSV)",
copySelectionTsvWithHeaders: "ヘッダー付きで選択範囲をコピー (TSV)",
copySelectionCsv: "選択範囲をコピー (CSV)",
copySelectionJson: "選択範囲をコピー (JSON)",
copySelectionSql: "選択範囲をSQL INリストとしてコピー",
copySelectionInsert: "選択範囲をINSERTとしてコピー",
copySelectionInsertMerged: "選択範囲をINSERTとしてコピー(まとめて)",
copySelectionInsertRowByRow: "選択範囲をINSERTとしてコピー(行ごと)",
clearSelection: "選択をクリア",
export: "エクスポート",
exportCsv: "CSVエクスポート",
@@ -1099,18 +1087,7 @@ export default withEnglishFallback({
cloneRows: "{count}行を新しい行として複製",
restoreRows: "{count}行を復元",
copyRows: "{count}行をコピー (JSON)",
copySelectedRowsTsv: "{count}行をコピー (TSV)",
copySelectedRowsTsvWithHeaders: "{count}行をヘッダー付きでコピー (TSV)",
copySelectedRowTsv: "1行をコピー (TSV)",
copySelectedRowTsvWithHeaders: "1行をヘッダー付きでコピー (TSV)",
copyColumnsJson: "{count}列をコピー (JSON)",
copyRowsInsert: "{count}行をINSERTとしてコピー",
copyRowsInsertMerged: "{count}行をINSERTとしてコピー(まとめて)",
copyRowsInsertRowByRow: "{count}行をINSERTとしてコピー(行ごと)",
copyRowsInsertWithoutPrimaryKeys: "{count}行を主キーなしでINSERTとしてコピー",
copyRowsInsertWithoutPrimaryKeysMerged: "{count}行を主キーなしでINSERTとしてコピー(まとめて)",
copyRowsInsertWithoutPrimaryKeysRowByRow: "{count}行を主キーなしでINSERTとしてコピー(行ごと)",
copyRowsUpdate: "{count}行をUPDATEとしてコピー",
selectedRows: "{count}行選択中",
restoreRow: "行を復元",
statusClean: "未変更",
@@ -1264,6 +1241,48 @@ export default withEnglishFallback({
redoChange: "変更をやり直す",
filterBuilderSearchColumns: "フィールドを検索...",
filterBuilderNoMatchingColumns: "一致するフィールドがありません",
copyExtractorWithHeaders: "{format}(ヘッダー付き)",
copyExtractorOneRow: "単行CSV",
copyExtractorSqlInList: "SQL IN リスト",
copyExtractorSqlInserts: "SQL INSERT 文",
copyExtractorSqlUpdates: "SQL UPDATE 文",
copyExtractorWhereClause: "WHERE 条件",
copyExtractorPipeSeparated: "パイプ区切り",
copyExtractorPretty: "テーブルテキスト",
copyExtractorConfigure: "データ抽出の設定…",
copyExtractorFormat: "抽出器",
copyExtractorColumnSeparator: "列区切り文字",
copyExtractorRowSeparator: "行区切り文字",
copyExtractorNullText: "NULL テキスト",
copyExtractorQuote: "引用文字",
copyExtractorQuotePolicy: "引用規則",
copyExtractorQuoteMinimal: "必要時",
copyExtractorQuoteAlways: "常に",
copyExtractorQuoteNever: "なし",
copyExtractorIncludeHeader: "列名を含む",
copyExtractorIncludeRowHeader: "行番号を含む",
copyExtractorSkipComputed: "計算列をスキップ",
copyExtractorSkipGenerated: "生成列をスキップ",
copyExtractorExcludePrimaryKeys: "主キーを除外",
copyExtractorInsertMode: "INSERT文モード",
copyExtractorInsertMerged: "複数行をマージ",
copyExtractorInsertRowByRow: "1行ごとに1文",
copyExtractorPrettyJson: "JSONを整形",
copyExtractorPreview: "プレビュー(現在の選択)",
copyExtractorEmptySelection: "データを選択してから抽出結果をプレビューしてください。",
copyExtractorUnsupportedSelection: "現在の抽出器では各行で同じ列を選択する必要があります。",
copyExtractorPreviewTruncated: "現在は選択された{count}行のうち最初の100行のみをプレビューしています。",
copyExtractorWarningOmittedColumns: "コピー結果は以下の列を無視しました:{columns}。",
copyExtractorWarningDuplicateJsonColumns: "重複したJSON列名が検出されました。データ損失を避けるために自動的に名前を変更しました。",
copyExtractorValidation: {
"column-separator-empty": "列区切り文字は空にできません。",
"row-separator-empty": "行区切り文字は空にできません。",
"separator-too-long": "区切り文字は最大8文字までです。",
"null-text-too-long": "NULLテキストは最大64文字までです。",
"separators-overlap": "列区切り文字と行区切り文字は互いに含めることはできません。",
"invalid-quote": "引用文字は1文字でなければなりません。",
"quote-conflicts": "引用文字は列区切り文字または行区切り文字に含めることはできません。",
},
cachedResultUnavailable: "キャッシュ結果が不足しているか互換性がありません。",
reexecuteQuery: "クエリを再実行",
},
+42 -23
View File
@@ -917,21 +917,9 @@ export default withEnglishFallback({
copyColumnNamesSeparator: "Separador de colunas",
copyColumnNamesQuote: "Escapar nomes das colunas",
copyColumnNamesPreview: "Pré-visualização",
copyRowInsert: "Copiar como INSERT",
copyRowInsertWithoutPrimaryKeys: "Copiar como INSERT sem Chaves Primárias",
copyRowUpdate: "Copiar como UPDATE",
copyAll: "Copiar Tudo (TSV)",
selection: "Seleção",
selectionSum: "SUM: {value}",
selectionCells: "{count} células",
copySelectionTsv: "Copiar Seleção (TSV)",
copySelectionTsvWithHeaders: "Copiar Seleção com Cabeçalhos (TSV)",
copySelectionCsv: "Copiar Seleção (CSV)",
copySelectionJson: "Copiar Seleção (JSON)",
copySelectionSql: "Copiar Seleção como Lista IN do SQL",
copySelectionInsert: "Copiar Seleção como INSERT",
copySelectionInsertMerged: "Copiar Seleção como INSERT (mesclado)",
copySelectionInsertRowByRow: "Copiar Seleção como INSERT (linha por linha)",
clearSelection: "Limpar Seleção",
export: "Exportar",
exportCsv: "Exportar CSV",
@@ -1104,18 +1092,7 @@ export default withEnglishFallback({
cloneRows: "Clonar {count} como Novas Linhas",
restoreRows: "Restaurar {count} Linhas",
copyRows: "Copiar {count} Linhas (JSON)",
copySelectedRowsTsv: "Copiar {count} Linhas (TSV)",
copySelectedRowsTsvWithHeaders: "Copiar {count} Linhas com Cabeçalhos (TSV)",
copySelectedRowTsv: "Copiar 1 Linha (TSV)",
copySelectedRowTsvWithHeaders: "Copiar 1 Linha com Cabeçalhos (TSV)",
copyColumnsJson: "Copiar {count} Colunas (JSON)",
copyRowsInsert: "Copiar {count} Linhas como INSERT",
copyRowsInsertMerged: "Copiar {count} Linhas como INSERT (mesclado)",
copyRowsInsertRowByRow: "Copiar {count} Linhas como INSERT (linha por linha)",
copyRowsInsertWithoutPrimaryKeys: "Copiar {count} Linhas como INSERT sem Chaves Primárias",
copyRowsInsertWithoutPrimaryKeysMerged: "Copiar {count} Linhas como INSERT sem Chaves Primárias (mesclado)",
copyRowsInsertWithoutPrimaryKeysRowByRow: "Copiar {count} Linhas como INSERT sem Chaves Primárias (linha por linha)",
copyRowsUpdate: "Copiar {count} Linhas como UPDATE",
selectedRows: "{count} linhas selecionadas",
restoreRow: "Restaurar Linha",
statusClean: "Sem alterações",
@@ -1265,6 +1242,48 @@ 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",
copyExtractorWithHeaders: "{format} (com cabeçalhos)",
copyExtractorOneRow: "CSV de linha única",
copyExtractorSqlInList: "Lista SQL IN",
copyExtractorSqlInserts: "Instruções SQL INSERT",
copyExtractorSqlUpdates: "Instruções SQL UPDATE",
copyExtractorWhereClause: "Condição WHERE",
copyExtractorPipeSeparated: "Separado por pipe",
copyExtractorPretty: "Tabela formatada",
copyExtractorConfigure: "Configurar extrator de dados…",
copyExtractorFormat: "Extrator",
copyExtractorColumnSeparator: "Separador de coluna",
copyExtractorRowSeparator: "Separador de linha",
copyExtractorNullText: "Texto NULL",
copyExtractorQuote: "Caractere de aspas",
copyExtractorQuotePolicy: "Política de aspas",
copyExtractorQuoteMinimal: "Quando necessário",
copyExtractorQuoteAlways: "Sempre",
copyExtractorQuoteNever: "Nunca",
copyExtractorIncludeHeader: "Incluir nomes de colunas",
copyExtractorIncludeRowHeader: "Incluir números de linha",
copyExtractorSkipComputed: "Pular colunas computadas",
copyExtractorSkipGenerated: "Pular colunas geradas",
copyExtractorExcludePrimaryKeys: "Excluir chaves primárias",
copyExtractorInsertMode: "Modo de instrução INSERT",
copyExtractorInsertMerged: "Mesclar várias linhas",
copyExtractorInsertRowByRow: "Uma instrução por linha",
copyExtractorPrettyJson: "JSON formatado",
copyExtractorPreview: "Visualizar (seleção atual)",
copyExtractorEmptySelection: "Selecione dados para visualizar os resultados da extração.",
copyExtractorUnsupportedSelection: "O extrator atual exige que as mesmas colunas sejam selecionadas para cada linha.",
copyExtractorPreviewTruncated: "Atualmente visualizando apenas as primeiras 100 das {count} linhas selecionadas.",
copyExtractorWarningOmittedColumns: "O resultado da cópia omitiu as seguintes colunas: {columns}.",
copyExtractorWarningDuplicateJsonColumns: "Nomes de colunas JSON duplicados detectados, renomeados automaticamente para evitar perda de dados.",
copyExtractorValidation: {
"column-separator-empty": "O separador de coluna não pode estar vazio.",
"row-separator-empty": "O separador de linha não pode estar vazio.",
"separator-too-long": "O separador pode conter no máximo 8 caracteres.",
"null-text-too-long": "O texto NULL pode conter no máximo 64 caracteres.",
"separators-overlap": "O separador de coluna e o separador de linha não podem se conter mutuamente.",
"invalid-quote": "O caractere de aspas deve ter exatamente um caractere.",
"quote-conflicts": "O caractere de aspas não pode aparecer no separador de coluna ou no separador de linha.",
},
cachedResultUnavailable: "Resultado em cache ausente ou incompatível.",
reexecuteQuery: "Reexecutar consulta",
},
+42 -23
View File
@@ -957,6 +957,48 @@ export default withEnglishFallback({
noFilteredRows: "没有符合条件的行",
noFilteredRowsDescription: "调整搜索词或状态筛选试试。",
copy: "复制",
copyExtractorWithHeaders: "{format}(含表头)",
copyExtractorOneRow: "单行 CSV",
copyExtractorSqlInList: "SQL IN 列表",
copyExtractorSqlInserts: "SQL INSERT 语句",
copyExtractorSqlUpdates: "SQL UPDATE 语句",
copyExtractorWhereClause: "WHERE 条件",
copyExtractorPipeSeparated: "竖线分隔",
copyExtractorPretty: "表格文本",
copyExtractorConfigure: "配置数据提取器…",
copyExtractorFormat: "提取器",
copyExtractorColumnSeparator: "列分隔符",
copyExtractorRowSeparator: "行分隔符",
copyExtractorNullText: "NULL 文本",
copyExtractorQuote: "引用字符",
copyExtractorQuotePolicy: "引用规则",
copyExtractorQuoteMinimal: "必要时",
copyExtractorQuoteAlways: "始终",
copyExtractorQuoteNever: "从不",
copyExtractorIncludeHeader: "包含列名",
copyExtractorIncludeRowHeader: "包含行号",
copyExtractorSkipComputed: "跳过计算列",
copyExtractorSkipGenerated: "跳过生成列",
copyExtractorExcludePrimaryKeys: "排除主键",
copyExtractorInsertMode: "INSERT 语句模式",
copyExtractorInsertMerged: "合并多行",
copyExtractorInsertRowByRow: "每行一条语句",
copyExtractorPrettyJson: "格式化 JSON",
copyExtractorPreview: "预览(当前选择)",
copyExtractorEmptySelection: "请选择数据后预览提取结果。",
copyExtractorUnsupportedSelection: "当前提取器要求每一行选择相同的列。",
copyExtractorPreviewTruncated: "当前仅预览所选 {count} 行中的前 100 行。",
copyExtractorWarningOmittedColumns: "复制结果已忽略以下列:{columns}。",
copyExtractorWarningDuplicateJsonColumns: "检测到重复的 JSON 列名,已自动重命名以避免数据丢失。",
copyExtractorValidation: {
"column-separator-empty": "列分隔符不能为空。",
"row-separator-empty": "行分隔符不能为空。",
"separator-too-long": "分隔符最多包含 8 个字符。",
"null-text-too-long": "NULL 文本最多包含 64 个字符。",
"separators-overlap": "列分隔符和行分隔符不能相互包含。",
"invalid-quote": "引用字符必须正好是一个字符。",
"quote-conflicts": "引用字符不能出现在列分隔符或行分隔符中。",
},
mongoJsonPreview: "JSON 预览",
mongoJsonPreviewEmpty: "选择一条 MongoDB 文档以预览。",
copyJson: "复制 JSON",
@@ -970,21 +1012,9 @@ export default withEnglishFallback({
copyColumnNamesSeparator: "列分隔符",
copyColumnNamesQuote: "转义列名",
copyColumnNamesPreview: "预览",
copyRowInsert: "复制为 INSERT 语句",
copyRowInsertWithoutPrimaryKeys: "复制为 INSERT 语句(不含主键)",
copyRowUpdate: "复制为 UPDATE 语句",
copyAll: "复制全部 (TSV)",
selection: "选区",
selectionSum: "SUM: {value}",
selectionCells: "{count} 个单元",
copySelectionTsv: "复制选区 (TSV)",
copySelectionTsvWithHeaders: "复制选区含表头 (TSV)",
copySelectionCsv: "复制选区 (CSV)",
copySelectionJson: "复制选区 (JSON)",
copySelectionSql: "复制选区为 SQL IN 列表",
copySelectionInsert: "复制选区为 INSERT",
copySelectionInsertMerged: "复制选区为 INSERT(合并)",
copySelectionInsertRowByRow: "复制选区为 INSERT(逐行)",
clearSelection: "清除选区",
export: "导出",
exportCsv: "导出 CSV",
@@ -1163,18 +1193,7 @@ export default withEnglishFallback({
cloneRows: "克隆 {count} 行为新行",
restoreRows: "恢复 {count} 行",
copyRows: "复制 {count} 行 (JSON)",
copySelectedRowsTsv: "复制 {count} 行 (TSV)",
copySelectedRowsTsvWithHeaders: "复制 {count} 行含表头 (TSV)",
copySelectedRowTsv: "复制 1 行 (TSV)",
copySelectedRowTsvWithHeaders: "复制 1 行含表头 (TSV)",
copyColumnsJson: "复制 {count} 列 (JSON)",
copyRowsInsert: "复制 {count} 行为 INSERT",
copyRowsInsertMerged: "复制 {count} 行为 INSERT(合并)",
copyRowsInsertRowByRow: "复制 {count} 行为 INSERT(逐行)",
copyRowsInsertWithoutPrimaryKeys: "复制 {count} 行为 INSERT(不含主键)",
copyRowsInsertWithoutPrimaryKeysMerged: "复制 {count} 行为 INSERT(不含主键,合并)",
copyRowsInsertWithoutPrimaryKeysRowByRow: "复制 {count} 行为 INSERT(不含主键,逐行)",
copyRowsUpdate: "复制 {count} 行为 UPDATE",
selectedRows: "已选 {count} 行",
restoreRow: "恢复行",
statusClean: "未改",
+42 -23
View File
@@ -916,21 +916,9 @@ export default withEnglishFallback({
copyColumnNamesSeparator: "欄分隔符",
copyColumnNamesQuote: "轉義欄名",
copyColumnNamesPreview: "預覽",
copyRowInsert: "複製為 INSERT 語句",
copyRowInsertWithoutPrimaryKeys: "複製為 INSERT 語句(不含主鍵)",
copyRowUpdate: "複製為 UPDATE 語句",
copyAll: "複製全部 (TSV)",
selection: "選區",
selectionSum: "SUM: {value}",
selectionCells: "{count} 個儲存格",
copySelectionTsv: "複製選區 (TSV)",
copySelectionTsvWithHeaders: "複製選區含表頭 (TSV)",
copySelectionCsv: "複製選區 (CSV)",
copySelectionJson: "複製選區 (JSON)",
copySelectionSql: "複製選區為 SQL IN 清單",
copySelectionInsert: "複製選區為 INSERT",
copySelectionInsertMerged: "複製選區為 INSERT(合併)",
copySelectionInsertRowByRow: "複製選區為 INSERT(逐筆)",
clearSelection: "清除選區",
export: "匯出",
exportCsv: "匯出 CSV",
@@ -1103,18 +1091,7 @@ export default withEnglishFallback({
cloneRows: "複製 {count} 筆為新資料",
restoreRows: "復原 {count} 筆",
copyRows: "複製 {count} 筆 (JSON)",
copySelectedRowsTsv: "複製 {count} 筆 (TSV)",
copySelectedRowsTsvWithHeaders: "複製 {count} 筆含表頭 (TSV)",
copySelectedRowTsv: "複製 1 筆 (TSV)",
copySelectedRowTsvWithHeaders: "複製 1 筆含表頭 (TSV)",
copyColumnsJson: "複製 {count} 欄 (JSON)",
copyRowsInsert: "複製 {count} 筆為 INSERT",
copyRowsInsertMerged: "複製 {count} 筆為 INSERT(合併)",
copyRowsInsertRowByRow: "複製 {count} 筆為 INSERT(逐筆)",
copyRowsInsertWithoutPrimaryKeys: "複製 {count} 筆為 INSERT(不含主鍵)",
copyRowsInsertWithoutPrimaryKeysMerged: "複製 {count} 筆為 INSERT(不含主鍵,合併)",
copyRowsInsertWithoutPrimaryKeysRowByRow: "複製 {count} 筆為 INSERT(不含主鍵,逐筆)",
copyRowsUpdate: "複製 {count} 筆為 UPDATE",
selectedRows: "已選 {count} 筆",
restoreRow: "復原資料",
statusClean: "未改",
@@ -1264,6 +1241,48 @@ export default withEnglishFallback({
truncatedHint: "結果已截斷,已載入前 {count} 筆。可透過底部分頁瀏覽已載入資料;匯出完整結果時會重新查詢資料庫。",
filterBuilderSearchColumns: "搜尋欄位...",
filterBuilderNoMatchingColumns: "沒有匹配的欄位",
copyExtractorWithHeaders: "{format}(含表頭)",
copyExtractorOneRow: "單行 CSV",
copyExtractorSqlInList: "SQL IN 列表",
copyExtractorSqlInserts: "SQL INSERT 語句",
copyExtractorSqlUpdates: "SQL UPDATE 語句",
copyExtractorWhereClause: "WHERE 條件",
copyExtractorPipeSeparated: "豎線分隔",
copyExtractorPretty: "表格文本",
copyExtractorConfigure: "配置資料提取器…",
copyExtractorFormat: "提取器",
copyExtractorColumnSeparator: "列分隔符",
copyExtractorRowSeparator: "行分隔符",
copyExtractorNullText: "NULL 文本",
copyExtractorQuote: "引用字元",
copyExtractorQuotePolicy: "引用規則",
copyExtractorQuoteMinimal: "必要時",
copyExtractorQuoteAlways: "始終",
copyExtractorQuoteNever: "從不",
copyExtractorIncludeHeader: "包含列名",
copyExtractorIncludeRowHeader: "包含行號",
copyExtractorSkipComputed: "跳過計算列",
copyExtractorSkipGenerated: "跳過生成列",
copyExtractorExcludePrimaryKeys: "排除主鍵",
copyExtractorInsertMode: "INSERT 語句模式",
copyExtractorInsertMerged: "合併多行",
copyExtractorInsertRowByRow: "每行一條語句",
copyExtractorPrettyJson: "格式化 JSON",
copyExtractorPreview: "預覽(目前選擇)",
copyExtractorEmptySelection: "請選擇資料後預覽提取結果。",
copyExtractorUnsupportedSelection: "目前提取器要求每一行選擇相同的列。",
copyExtractorPreviewTruncated: "目前僅預覽所選 {count} 行中的前 100 行。",
copyExtractorWarningOmittedColumns: "複製結果已忽略以下列:{columns}。",
copyExtractorWarningDuplicateJsonColumns: "偵測到重複的 JSON 列名,已自動重新命名以避免資料遺失。",
copyExtractorValidation: {
"column-separator-empty": "列分隔符不能為空。",
"row-separator-empty": "行分隔符不能為空。",
"separator-too-long": "分隔符最多包含 8 個字元。",
"null-text-too-long": "NULL 文本最多包含 64 個字元。",
"separators-overlap": "列分隔符和行分隔符不能相互包含。",
"invalid-quote": "引用字元必須正好是一個字元。",
"quote-conflicts": "引用字元不能出現在列分隔符或行分隔符中。",
},
cachedResultUnavailable: "快取結果缺失或不相容。",
reexecuteQuery: "重新執行查詢",
},
@@ -125,8 +125,15 @@ describe("parseDataGridClipboard", () => {
]);
});
it("preserves embedded tabs and newlines even without null values", () => {
const text = '"left\tinside"\t"line 1\nline 2"';
rememberDataGridClipboardCopy(text, [["left\tinside", "line 1\nline 2"]]);
expect(parseDataGridClipboard(text)).toEqual([["left\tinside", "line 1\nline 2"]]);
});
it("restores null positions after copied headers", () => {
rememberDataGridClipboardCopy("name\tnote\nAda\tNULL", [["Ada", null]], true);
rememberDataGridClipboardCopy("name\tnote\nAda\tNULL", [["Ada", null]], ["name", "note"]);
expect(parseDataGridClipboard("name\tnote\nAda\tNULL")).toEqual([
["name", "note"],
@@ -64,7 +64,7 @@ describe("dataGridContextMenu", () => {
icons: { cellDetails: icon, columnDetails: icon, rowDetails: icon, setNull: icon, bulkEdit: icon, transpose: icon },
actions: { cellDetails: action, columnDetails: action, rowDetails: action, setNull: action, bulkEdit: action, transpose: action },
copySubmenu: { label: "copy" },
selectionSubmenu: { label: "selection" },
clearSelectionItem: { label: "clear" },
});
expect(cellItems.find((item) => item.label === "bulk")?.disabled).toBe(true);
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, normalizeDataGridExtractorOptions, validateDataGridExtractorOptions } from "@/lib/dataGrid/dataGridCopyExtractor";
describe("data-grid extractor options", () => {
it("normalizes persisted values without sharing the default object", () => {
const normalized = normalizeDataGridExtractorOptions(DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS);
expect(normalized).toEqual(DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS);
expect(normalized).not.toBe(DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS);
expect(normalized.dsv).not.toBe(DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS.dsv);
});
it("rejects overlapping effective row and column separators", () => {
const options = normalizeDataGridExtractorOptions(DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS);
options.dsv.rowSeparator = ",";
expect(validateDataGridExtractorOptions("csv", options)).toBe("separators-overlap");
});
it("rejects an empty custom separator before normalization can hide it", () => {
const options = normalizeDataGridExtractorOptions(DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS);
options.dsv.columnSeparator = "";
expect(validateDataGridExtractorOptions("dsv", options)).toBe("column-separator-empty");
});
it("requires exactly one Unicode quote character", () => {
const options = normalizeDataGridExtractorOptions(DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS);
options.dsv.quote = "🙂";
expect(validateDataGridExtractorOptions("one-row", options)).toBeNull();
options.dsv.quote = "''";
expect(validateDataGridExtractorOptions("one-row", options)).toBe("invalid-quote");
});
it("normalizes and validates separator and null-text limits by Unicode code point", () => {
const columnSeparator = "🙂".repeat(5);
const nullText = "🧊".repeat(40);
const normalized = normalizeDataGridExtractorOptions({
...DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS,
dsv: { ...DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS.dsv, columnSeparator, nullText },
});
expect(normalized.dsv.columnSeparator).toBe(columnSeparator);
expect(normalized.dsv.nullText).toBe(nullText);
expect(validateDataGridExtractorOptions("dsv", normalized)).toBeNull();
});
it("rejects quote conflicts, control quotes, and oversized values", () => {
const options = normalizeDataGridExtractorOptions(DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS);
options.dsv.quote = ",";
expect(validateDataGridExtractorOptions("csv", options)).toBe("quote-conflicts");
options.dsv.quote = "\n";
expect(validateDataGridExtractorOptions("one-row", options)).toBe("invalid-quote");
options.dsv.quote = '"';
options.dsv.nullText = "x".repeat(65);
expect(validateDataGridExtractorOptions("dsv", options)).toBe("null-text-too-long");
});
});
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { DATA_GRID_DARK_ACTIVE_ROW_BG, DATA_GRID_LIGHT_ACTIVE_ROW_BG, dataGridActiveRowBackground, resolveDataGridPaintTheme } from "@/lib/dataGrid/dataGridPaintTheme";
describe("data grid paint theme", () => {
it("uses a subtle blue active-row surface in both color schemes", () => {
expect(dataGridActiveRowBackground(false)).toBe(DATA_GRID_LIGHT_ACTIVE_ROW_BG);
expect(dataGridActiveRowBackground(true)).toBe(DATA_GRID_DARK_ACTIVE_ROW_BG);
const emptyCssVariable = () => "";
expect(resolveDataGridPaintTheme({ getVar: emptyCssVariable, isDark: false }).cellActive).toBe(DATA_GRID_LIGHT_ACTIVE_ROW_BG);
expect(resolveDataGridPaintTheme({ getVar: emptyCssVariable, isDark: false }).rowNumberActive).toBe(DATA_GRID_LIGHT_ACTIVE_ROW_BG);
expect(resolveDataGridPaintTheme({ getVar: emptyCssVariable, isDark: true }).cellActive).toBe(DATA_GRID_DARK_ACTIVE_ROW_BG);
expect(resolveDataGridPaintTheme({ getVar: emptyCssVariable, isDark: true }).rowNumberActive).toBe(DATA_GRID_DARK_ACTIVE_ROW_BG);
});
});
@@ -1,5 +1,15 @@
import { describe, expect, it, vi } from "vitest";
import { dataGridToolbarIntervalOptions, selectDataGridToolbarAutoRefreshInterval, selectDataGridToolbarExportItem, toggleDataGridToolbarAutoRefresh, triggerDataGridToolbarAction, type DataGridToolbarAutoRefreshCapability } from "@/lib/dataGrid/dataGridToolbar";
import {
dataGridToolbarIntervalOptions,
selectDataGridToolbarAutoRefreshInterval,
selectDataGridToolbarCopyItem,
selectDataGridToolbarExportItem,
toggleDataGridToolbarAutoRefresh,
triggerDataGridToolbarAction,
triggerDataGridToolbarCopy,
type DataGridToolbarAutoRefreshCapability,
type DataGridToolbarCopyCapability,
} from "@/lib/dataGrid/dataGridToolbar";
function autoRefreshCapability(overrides: Partial<DataGridToolbarAutoRefreshCapability> = {}): DataGridToolbarAutoRefreshCapability {
return {
@@ -81,4 +91,26 @@ describe("data grid toolbar capabilities", () => {
expect(onSelect).toHaveBeenCalledOnce();
expect(onSelect).toHaveBeenCalledWith("csv");
});
it("keeps copy format selection available when the current copy action is disabled", async () => {
const onCopy = vi.fn();
const onSelect = vi.fn();
const capability: DataGridToolbarCopyCapability = {
label: "SQL Updates",
disabled: true,
currentValue: "sql-updates",
items: [
{ value: "tsv", label: "TSV" },
{ value: "sql-updates", label: "SQL Updates", disabled: true },
],
onCopy,
onSelect,
};
await expect(triggerDataGridToolbarCopy(capability)).resolves.toBe(false);
await expect(selectDataGridToolbarCopyItem(capability, "tsv")).resolves.toBe(true);
await expect(selectDataGridToolbarCopyItem(capability, "sql-updates")).resolves.toBe(false);
expect(onCopy).not.toHaveBeenCalled();
expect(onSelect).toHaveBeenCalledWith("tsv");
});
});
+11 -28
View File
@@ -1,34 +1,7 @@
import { describe, expect, it } from "vitest";
import { formatSelectionAsTsv, summarizeSelection } from "@/lib/dataGrid/gridSelection";
import { dedupeColumnIndexes, summarizeSelection } from "@/lib/dataGrid/gridSelection";
describe("gridSelection", () => {
it("formats TSV selections without headers by default", () => {
expect(
formatSelectionAsTsv({
columns: ["id", "name"],
rows: [
[1, "Ada"],
[2, "Lin"],
],
}),
).toBe("1\tAda\n2\tLin");
});
it("can include column headers in TSV selections", () => {
expect(
formatSelectionAsTsv(
{
columns: ["id", "name"],
rows: [
[1, "Ada"],
[2, "Lin"],
],
},
true,
),
).toBe("id\tname\n1\tAda\n2\tLin");
});
it("summarizes empty selections", () => {
expect(summarizeSelection({ columns: [], rows: [] })).toEqual({
cellCount: 0,
@@ -71,4 +44,14 @@ describe("gridSelection", () => {
sum: 105.5,
});
});
it("dedupeColumnIndexes preserves the first visible occurrence instead of sorting", () => {
// After drag-reordering, visible order is [2, 0, 1]; extraction must keep it,
// not revert to numeric [0, 1, 2].
expect(dedupeColumnIndexes([2, 0, 1])).toEqual([2, 0, 1]);
});
it("dedupeColumnIndexes dedups keeping the first occurrence and drops negatives", () => {
expect(dedupeColumnIndexes([2, 0, 2, -1, 1, 0])).toEqual([2, 0, 1]);
});
});
+1
View File
@@ -225,6 +225,7 @@ export const buildCreateTableSql = forward("buildCreateTableSql");
export const buildSingleColumnAlterSql = forward("buildSingleColumnAlterSql");
export const analyzeEditableQueryEditability = forward("analyzeEditableQueryEditability");
export const prepareDataGridSave = forward("prepareDataGridSave");
export const extractDataGridSelection = forward("extractDataGridSelection");
export const buildDataGridCopyUpdateStatements = forward("buildDataGridCopyUpdateStatements");
export const buildDataGridCopyInsertStatement = forward("buildDataGridCopyInsertStatement");
export const buildDataGridContextFilterCondition = forward("buildDataGridContextFilterCondition");
+5
View File
@@ -135,6 +135,7 @@ import type {
DataGridSaveStatementOptions,
HiveTablePropertiesSqlOptions,
} from "@/lib/dataGrid/dataGridSql";
import type { DataGridExtractRequest, DataGridExtractResult } from "@/lib/dataGrid/dataGridCopyExtractor";
import type { BuildTableStructureChangeSqlOptions, BuildSingleColumnAlterSqlOptions, SqliteTableStructureChangePreview, TableStructureChangeSql } from "@/lib/table/tableStructureEditorSql";
import type { BuildTableSelectSqlOptions } from "@/lib/table/tableSelectSql";
import type { DatabaseSearchSql, DatabaseSearchSqlOptions, SearchResultWhereOptions } from "@/lib/database/databaseSearch";
@@ -1001,6 +1002,10 @@ export async function prepareDataGridSave(options: DataGridSaveStatementOptions)
return post("/api/query/prepare-data-grid-save", { options });
}
export async function extractDataGridSelection(request: DataGridExtractRequest): Promise<DataGridExtractResult> {
return post("/api/query/extract-data-grid-selection", { request });
}
export async function buildDataGridCopyUpdateStatements(options: DataGridCopyUpdateStatementOptions): Promise<string[]> {
return post("/api/query/build-data-grid-copy-update-statements", { options });
}
+5
View File
@@ -61,6 +61,7 @@ import type {
DataGridSaveStatementOptions,
HiveTablePropertiesSqlOptions,
} from "@/lib/dataGrid/dataGridSql";
import type { DataGridExtractRequest, DataGridExtractResult } from "@/lib/dataGrid/dataGridCopyExtractor";
import type { DataCompareFromTablesOptions, DataCompareFromTablesPreparation, DataCompareSyncPlan, DataCompareSyncPlanOptions, DataComparePreparation, DataComparePreparationOptions } from "@/lib/dataGrid/dataCompare";
import type { SchemaDiffPreparation, SchemaDiffPreparationOptions, TableDiff, FunctionDiff, SequenceDiff, RuleDiff, OwnerDiff } from "@/lib/schema/schemaDiff";
import type { BuildTableStructureChangeSqlOptions, BuildSingleColumnAlterSqlOptions, SqliteTableStructureChangePreview, TableStructureChangeSql } from "@/lib/table/tableStructureEditorSql";
@@ -1105,6 +1106,10 @@ export async function prepareDataGridSave(options: DataGridSaveStatementOptions)
return invoke("prepare_data_grid_save", { options });
}
export async function extractDataGridSelection(request: DataGridExtractRequest): Promise<DataGridExtractResult> {
return invoke("extract_data_grid_selection", { request });
}
export async function buildDataGridCopyUpdateStatements(options: DataGridCopyUpdateStatementOptions): Promise<string[]> {
return invoke("build_data_grid_copy_update_statements", { options });
}
@@ -87,6 +87,12 @@ interface CanvasRenderState {
currentSearchBorder: string;
}
export function resolveCanvasDataGridRowFill(theme: Pick<DataGridPaintTheme, "cellActive" | "cellSelected">, rowBase: string, options: { isActive: boolean; isDeleted: boolean; isSelected: boolean }): string {
if (options.isSelected) return theme.cellSelected;
if (options.isActive && !options.isDeleted) return theme.cellActive;
return rowBase;
}
const canvasRenderStateCache = new WeakMap<HTMLCanvasElement, CanvasRenderState>();
function setCanvasNumericVariant(ctx: CanvasRenderingContext2D, value: "normal" | "tabular-nums") {
@@ -296,14 +302,32 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) {
if (!item) continue;
const y = rowIndex * CANVAS_DATA_GRID_ROW_HEIGHT - scrollTop;
const rowIsActive = isRowActive(item.displayIndex);
const rowSelectionVisual = rowCellsUseSelectionVisual(item.id);
const rowBase = item.isDeleted ? theme.rowDeleted : item.isNew && !rowIsActive ? theme.rowNew : item.isDraft && !rowIsActive ? theme.rowMuted : item.displayIndex % 2 === 1 && !rowIsActive ? theme.rowMuted : theme.background;
const rowFill = resolveCanvasDataGridRowFill(theme, rowBase, {
isActive: rowIsActive,
isDeleted: item.isDeleted,
isSelected: rowSelectionVisual,
});
const rowBorderY = crispCanvasLine(y + CANVAS_DATA_GRID_ROW_HEIGHT - 1, dpr);
ctx.globalAlpha = item.isDeleted ? 0.7 : 1;
ctx.fillStyle = rowIsActive && !item.isDeleted ? theme.cellSelectedSingle : rowBase;
ctx.fillStyle = rowFill;
ctx.fillRect(0, y, width, CANVAS_DATA_GRID_ROW_HEIGHT);
const rowNumberFill = item.status === "draft" ? theme.rowNumberDefault : item.status === "new" ? theme.rowNumberNew : item.status === "edited" ? theme.rowNumberEdited : item.status === "deleted" ? theme.rowNumberDeleted : theme.rowNumberDefault;
const rowNumberFill = rowSelectionVisual
? theme.rowNumberSelected
: item.status === "draft"
? theme.rowNumberDefault
: item.status === "new"
? theme.rowNumberNew
: item.status === "edited"
? theme.rowNumberEdited
: item.status === "deleted"
? theme.rowNumberDeleted
: rowIsActive
? theme.rowNumberActive
: theme.rowNumberDefault;
ctx.fillStyle = rowNumberFill;
ctx.fillRect(0, y, rowNumberWidth, CANVAS_DATA_GRID_ROW_HEIGHT);
ctx.strokeStyle = theme.border;
@@ -339,10 +363,9 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) {
if (drawX + colWidth < rowNumberWidth || drawX >= width) return;
const selectedCell = cellIsSelected(item.displayIndex, visibleColIdx);
const rowSelectionVisual = rowCellsUseSelectionVisual(item.id);
const isDirtyCell = item.isDirtyCol[actualColIdx];
const selectedFillVisual = rowSelectionVisual || selectedCell;
const selectedBorderVisual = rowSelectionVisual || selectedCell;
const selectedBorderVisual = selectedCell;
const isSearchMatch = paintSearchMatches && searchMatchKeys.has(dataGridSearchMatchKey(item.displayIndex, actualColIdx));
const isCurrentSearchMatch = paintSearchMatches && currentSearchMatch?.displayRow === item.displayIndex && currentSearchMatch.col === actualColIdx;
const clippedX = Math.max(drawX, rowNumberWidth);
@@ -357,7 +380,7 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) {
ctx.fillStyle = theme.cellHover;
ctx.fillRect(clippedX, y, cellPaintWidth, CANVAS_DATA_GRID_ROW_HEIGHT);
}
if ((rowIsActive || selectedCell) && !item.isDeleted && !isDirtyCell) {
if (selectedCell && !item.isDeleted && !isDirtyCell) {
ctx.fillStyle = theme.cellSelectedSingle;
ctx.fillRect(clippedX, y, cellPaintWidth, CANVAS_DATA_GRID_ROW_HEIGHT);
}
@@ -463,16 +486,11 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) {
// 用不透明底色覆盖冻结区域(遮挡第一轮溢入的非冻结列内容)
// 注意:rowBase 在浅色主题下可能是 color-mix(..., transparent) 半透明色,
// 单次半透明填充无法遮挡文字,需先填不透明底再叠加半透明色调
if (rowIsActive && !item.isDeleted) {
ctx.fillStyle = theme.cellSelectedSingle;
ctx.fillStyle = theme.background;
ctx.fillRect(rowNumberWidth, y, frozenWidth, CANVAS_DATA_GRID_ROW_HEIGHT);
if (rowFill !== theme.background) {
ctx.fillStyle = rowFill;
ctx.fillRect(rowNumberWidth, y, frozenWidth, CANVAS_DATA_GRID_ROW_HEIGHT);
} else {
ctx.fillStyle = theme.background;
ctx.fillRect(rowNumberWidth, y, frozenWidth, CANVAS_DATA_GRID_ROW_HEIGHT);
if (rowBase !== theme.background) {
ctx.fillStyle = rowBase;
ctx.fillRect(rowNumberWidth, y, frozenWidth, CANVAS_DATA_GRID_ROW_HEIGHT);
}
}
// 绘制冻结列的每个单元格(x 坐标不受 scrollLeft 影响)
for (let fcIdx = 0; fcIdx < frozenColumnCount; fcIdx++) {
@@ -35,14 +35,9 @@ export function clearDataGridClipboardCopy(): void {
internalClipboardCopy = null;
}
export function rememberDataGridClipboardCopy(text: string, rows: readonly (readonly unknown[])[], includeHeader = false): void {
if (!rows.some((row) => row.some((value) => value === null))) {
internalClipboardCopy = null;
return;
}
export function rememberDataGridClipboardCopy(text: string, rows: readonly (readonly unknown[])[], header?: readonly unknown[]): void {
// Preserve the logical grid matrix because plain TSV cannot escape embedded tabs or newlines.
const headerRows = includeHeader ? parseClipboardTable(text).slice(0, 1) : [];
const headerRows = header ? [header.map((value) => displayCellValue(value as CellValue))] : [];
const copiedRows = rows.map((row) => row.map((value) => (value === null ? null : displayCellValue(value as CellValue))));
internalClipboardCopy = { text, rows: [...headerRows, ...copiedRows], writeRevision: getClipboardWriteRevision() };
}
@@ -129,7 +129,7 @@ export function createDataGridCellContextMenuItems(options: {
actions: Record<"cellDetails" | "columnDetails" | "rowDetails" | "setNull" | "bulkEdit" | "transpose", () => void>;
downloadItem?: DataGridContextMenuItem | null;
copySubmenu: DataGridContextMenuItem;
selectionSubmenu: DataGridContextMenuItem;
clearSelectionItem?: DataGridContextMenuItem;
generateSubmenu?: DataGridContextMenuItem;
}): DataGridContextMenuItem[] {
const items: DataGridContextMenuItem[] = [];
@@ -148,7 +148,7 @@ export function createDataGridCellContextMenuItems(options: {
if (options.generateSubmenu) items.push(options.generateSubmenu);
}
if (options.hasCell) items.push({ label: options.labels.transpose, action: options.actions.transpose, icon: options.icons.transpose });
if (options.hasSelection) items.push(options.selectionSubmenu);
if (options.hasSelection && options.clearSelectionItem) items.push(options.clearSelectionItem);
return items;
}
@@ -0,0 +1,178 @@
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 type DataGridCopyExtractorId = (typeof DATA_GRID_COPY_EXTRACTOR_IDS)[number];
export type DataGridExtractorCategory = "delimited" | "json" | "sql" | "document";
export const DATA_GRID_EXTRACTOR_CONTRACT_VERSION = 1 as const;
export const DATA_GRID_COPY_EXTRACTOR_DESCRIPTORS: Record<DataGridCopyExtractorId, { category: DataGridExtractorCategory; separatorBefore?: boolean }> = {
tsv: { category: "delimited" },
"tsv-with-headers": { category: "delimited" },
csv: { category: "delimited" },
"csv-with-headers": { category: "delimited" },
"pipe-separated": { category: "delimited" },
dsv: { category: "delimited" },
"one-row": { category: "delimited" },
json: { category: "json", separatorBefore: true },
"json-lines": { category: "json" },
"sql-in-list": { category: "sql", separatorBefore: true },
"sql-inserts": { category: "sql" },
"sql-updates": { category: "sql" },
"where-clause": { category: "sql" },
markdown: { category: "document", separatorBefore: true },
html: { category: "document" },
xml: { category: "document" },
pretty: { category: "document" },
};
export type DataGridSelectionKind = "cells" | "rows" | "columns";
export type DataGridQuotePolicy = "always" | "minimal" | "never";
export type DataGridExtractorOptionsError = "column-separator-empty" | "row-separator-empty" | "separator-too-long" | "null-text-too-long" | "separators-overlap" | "invalid-quote" | "quote-conflicts";
/** Extractors that emit relational SQL predicates not supported by some stores.
* sql-updates has a Mongo-specific updateOne path, so Mongo is NOT disabled;
* where-clause has no Mongo equivalent, so Mongo IS disabled. */
const NO_SQL_UPDATE_DATABASE_TYPES: ReadonlyArray<string> = ["neo4j", "tdengine"];
const NO_WHERE_CLAUSE_DATABASE_TYPES: ReadonlyArray<string> = ["neo4j", "tdengine", "mongodb"];
/** True when the extractor emits relational SQL the database type cannot run. */
export function extractorUnavailableForDatabase(extractor: DataGridCopyExtractorId, databaseType: string | undefined | null): boolean {
const db = databaseType ?? "";
if (extractor === "sql-updates") return NO_SQL_UPDATE_DATABASE_TYPES.includes(db);
if (extractor === "where-clause") return NO_WHERE_CLAUSE_DATABASE_TYPES.includes(db);
return false;
}
export interface DataGridDsvOptions {
columnSeparator: string;
rowSeparator: string;
nullText: string;
quote: string;
quotePolicy: DataGridQuotePolicy;
includeColumnHeader: boolean;
includeRowHeader: boolean;
}
export interface DataGridExtractorOptions {
dsv: DataGridDsvOptions;
sql: {
skipComputedColumns: boolean;
skipGeneratedColumns: boolean;
insertMode: DataGridCopyInsertMode;
excludePrimaryKeysFromInsert: boolean;
};
json: { pretty: boolean };
}
export interface DataGridExtractColumn {
displayName: string;
sourceName?: string;
sourceIndex: number;
}
export interface DataGridExtractRequest {
version: typeof DATA_GRID_EXTRACTOR_CONTRACT_VERSION;
extractor: DataGridCopyExtractorId;
databaseType?: DatabaseType;
tableMeta?: DataGridTableMeta;
columns: DataGridExtractColumn[];
selectedColumnIndexes: number[];
rows: unknown[][];
selectionKind: DataGridSelectionKind;
options: DataGridExtractorOptions;
}
export type DataGridExtractWarningCode = "omitted-columns" | "duplicate-json-column-names";
export interface DataGridExtractResult {
text: string;
mimeType: string;
fileExtension: string;
rowCount: number;
columnCount: number;
omittedColumns?: string[];
warnings?: Array<{ code: DataGridExtractWarningCode; message: string }>;
}
export interface DataGridExtractPreview extends DataGridExtractResult {
sourceRowCount: number;
truncated: boolean;
}
export const DATA_GRID_EXTRACTOR_PREVIEW_MAX_ROWS = 100;
export const DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS: DataGridExtractorOptions = {
dsv: {
columnSeparator: ",",
rowSeparator: "\n",
nullText: "NULL",
quote: '"',
quotePolicy: "minimal",
includeColumnHeader: false,
includeRowHeader: false,
},
sql: {
skipComputedColumns: true,
skipGeneratedColumns: true,
insertMode: "merged",
excludePrimaryKeysFromInsert: false,
},
json: { pretty: true },
};
function unicodeCodePointLength(value: string): number {
return [...value].length;
}
export function normalizeDataGridExtractorOptions(value: unknown): DataGridExtractorOptions {
const source = typeof value === "object" && value !== null ? (value as Partial<DataGridExtractorOptions>) : {};
const dsv: Partial<DataGridDsvOptions> = typeof source.dsv === "object" && source.dsv !== null ? source.dsv : {};
const sql: Partial<DataGridExtractorOptions["sql"]> = typeof source.sql === "object" && source.sql !== null ? source.sql : {};
const json: Partial<DataGridExtractorOptions["json"]> = typeof source.json === "object" && source.json !== null ? source.json : {};
const separator = (candidate: unknown, fallback: string) => (typeof candidate === "string" && unicodeCodePointLength(candidate) > 0 && unicodeCodePointLength(candidate) <= 8 ? candidate : fallback);
const quote = typeof dsv.quote === "string" && unicodeCodePointLength(dsv.quote) === 1 ? dsv.quote : DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS.dsv.quote;
return {
dsv: {
columnSeparator: separator(dsv.columnSeparator, DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS.dsv.columnSeparator),
rowSeparator: separator(dsv.rowSeparator, DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS.dsv.rowSeparator),
nullText: typeof dsv.nullText === "string" && unicodeCodePointLength(dsv.nullText) <= 64 ? dsv.nullText : DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS.dsv.nullText,
quote,
quotePolicy: dsv.quotePolicy === "always" || dsv.quotePolicy === "never" ? dsv.quotePolicy : "minimal",
includeColumnHeader: dsv.includeColumnHeader === true,
includeRowHeader: dsv.includeRowHeader === true,
},
sql: {
skipComputedColumns: sql.skipComputedColumns !== false,
skipGeneratedColumns: sql.skipGeneratedColumns !== false,
insertMode: sql.insertMode === "row-by-row" ? "row-by-row" : "merged",
excludePrimaryKeysFromInsert: sql.excludePrimaryKeysFromInsert === true,
},
json: { pretty: json.pretty !== false },
};
}
const DATA_GRID_COPY_EXTRACTOR_ID_SET = new Set<string>(DATA_GRID_COPY_EXTRACTOR_IDS);
export function normalizeDataGridCopyExtractorId(value: unknown): DataGridCopyExtractorId {
return typeof value === "string" && DATA_GRID_COPY_EXTRACTOR_ID_SET.has(value) ? (value as DataGridCopyExtractorId) : "tsv";
}
export function validateDataGridExtractorOptions(extractor: DataGridCopyExtractorId, options: DataGridExtractorOptions): DataGridExtractorOptionsError | null {
const usesDsv = DATA_GRID_COPY_EXTRACTOR_DESCRIPTORS[extractor].category === "delimited";
if (!usesDsv) return null;
const quoteCodePoint = options.dsv.quote.codePointAt(0);
if (unicodeCodePointLength(options.dsv.quote) !== 1 || quoteCodePoint === undefined || quoteCodePoint <= 0x1f || quoteCodePoint === 0x7f) return "invalid-quote";
if (unicodeCodePointLength(options.dsv.nullText) > 64) return "null-text-too-long";
const columnSeparator = extractor === "tsv" || extractor === "tsv-with-headers" ? "\t" : extractor === "csv" || extractor === "csv-with-headers" ? "," : extractor === "pipe-separated" ? "|" : options.dsv.columnSeparator;
if (!columnSeparator) return "column-separator-empty";
const usesRowSeparator = extractor !== "one-row";
if (usesRowSeparator && !options.dsv.rowSeparator) return "row-separator-empty";
if (unicodeCodePointLength(columnSeparator) > 8 || unicodeCodePointLength(options.dsv.rowSeparator) > 8) return "separator-too-long";
if (usesRowSeparator && (columnSeparator.includes(options.dsv.rowSeparator) || options.dsv.rowSeparator.includes(columnSeparator))) return "separators-overlap";
if (columnSeparator.includes(options.dsv.quote) || (usesRowSeparator && options.dsv.rowSeparator.includes(options.dsv.quote))) return "quote-conflicts";
return null;
}
@@ -35,6 +35,8 @@ export const DATA_GRID_DARK_SEARCH_COLORS = {
current: "rgb(116, 87, 0)",
currentBorder: "rgb(239, 177, 0)",
} as const;
export const DATA_GRID_LIGHT_ACTIVE_ROW_BG = "rgb(244, 248, 255)";
export const DATA_GRID_DARK_ACTIVE_ROW_BG = "rgb(25, 34, 46)";
export const DATA_GRID_LIGHT_STRIPED_ROW_BG = "rgb(240, 240, 240)";
export const DATA_GRID_DARK_STRIPED_ROW_BG = "rgb(40, 40, 43)";
export const DATA_GRID_DARK_ROW_NUMBER_BG = "rgb(35, 37, 42)";
@@ -218,6 +220,10 @@ export function cssVarColor(getVar: (name: string) => string, name: string, fall
return toCanvasSafeColor(getVar(name), fallback);
}
export function dataGridActiveRowBackground(isDark: boolean): string {
return isDark ? DATA_GRID_DARK_ACTIVE_ROW_BG : DATA_GRID_LIGHT_ACTIVE_ROW_BG;
}
function resolveCssVarReferences(value: string, getVar: (name: string) => string, depth = 0): string {
if (depth > 6 || !value.includes("var(")) return value;
return value.replace(/var\(\s*(--[\w-]+)(?:\s*,\s*([^)]+))?\s*\)/g, (_match, name: string, fallback?: string) => {
@@ -240,16 +246,16 @@ export function resolveDataGridPaintTheme(options: { getVar: (name: string) => s
const primary = cssVarColor(getVar, "--primary", isDark ? "rgb(208, 208, 214)" : "rgb(23, 23, 23)");
const destructive = cssVarColor(getVar, "--destructive", isDark ? "rgb(243, 98, 95)" : "rgb(231, 0, 11)");
const accent = cssVarColor(getVar, "--accent", isDark ? "rgb(46, 47, 51)" : "rgb(245, 245, 245)");
const activeSurface = isDark ? "rgb(64, 64, 64)" : "rgb(232, 232, 232)";
const activeSurface = dataGridActiveRowBackground(isDark);
const rowMuted = isDark ? DATA_GRID_DARK_STRIPED_ROW_BG : DATA_GRID_LIGHT_STRIPED_ROW_BG;
const rowNew = isDark ? "rgb(51, 51, 55)" : "rgb(243, 243, 243)";
const rowDeleted = isDark ? "rgb(55, 31, 32)" : "rgb(255, 244, 244)";
const cellActive = activeSurface;
const cellDirty = isDark ? "rgb(94, 75, 26)" : "rgb(255, 248, 230)";
const cellSelected = isDark ? "rgb(66, 67, 70)" : "rgb(226, 226, 226)";
const cellSelectedDirty = isDark ? "rgb(94, 75, 26)" : "rgb(244, 229, 186)";
const cellSelectedBorder = isDark ? "rgb(170, 170, 175)" : "rgb(90, 90, 90)";
const cellSelectedSingle = isDark ? "rgb(17, 24, 39)" : "rgb(209, 213, 219)";
const cellSelected = isDark ? "rgb(20, 40, 60)" : "rgb(239, 246, 255)";
const cellSelectedDirty = isDark ? "rgb(76, 66, 38)" : "rgb(235, 224, 184)";
const cellSelectedBorder = isDark ? "rgb(96, 165, 250)" : "rgb(59, 130, 246)";
const cellSelectedSingle = isDark ? "rgb(30, 64, 96)" : "rgb(191, 219, 254)";
const cellHover = accent;
const cellSearch = isDark ? DATA_GRID_DARK_SEARCH_COLORS.match : "rgb(253, 245, 184)";
const cellCurrentSearch = isDark ? DATA_GRID_DARK_SEARCH_COLORS.current : "rgba(253, 224, 71, 0.52)";
@@ -259,7 +265,7 @@ export function resolveDataGridPaintTheme(options: { getVar: (name: string) => s
const rowNumberEdited = isDark ? DATA_GRID_DARK_ROW_NUMBER_EDITED_BG : DATA_GRID_LIGHT_ROW_NUMBER_EDITED_BG;
const rowNumberDeleted = isDark ? DATA_GRID_DARK_ROW_NUMBER_DELETED_BG : DATA_GRID_LIGHT_ROW_NUMBER_DELETED_BG;
const rowNumberActive = activeSurface;
const rowNumberSelected = isDark ? "rgb(31, 41, 55)" : "rgb(209, 213, 219)";
const rowNumberSelected = isDark ? "rgb(30, 64, 96)" : "rgb(191, 219, 254)";
return {
background,
@@ -51,6 +51,7 @@ export interface DataGridCopyInsertStatementOptions {
sourceColumns?: Array<string | undefined>;
rows: GridCellValue[][];
excludePrimaryKeys?: boolean;
includeComputedColumns?: boolean;
insertMode?: DataGridCopyInsertMode;
}
@@ -32,6 +32,17 @@ export interface DataGridToolbarExportCapability {
onSelect: (value: string) => void | Promise<void>;
}
export interface DataGridToolbarCopyCapability {
label: string;
tooltip?: string;
visible?: boolean;
disabled?: boolean;
currentValue: string;
items: readonly DataGridToolbarMenuItem[];
onCopy: () => void | Promise<void>;
onSelect: (value: string) => void | Promise<void>;
}
export interface DataGridToolbarAutoRefreshCapability {
label: string;
shortLabel: string;
@@ -86,3 +97,17 @@ export async function selectDataGridToolbarExportItem(capability: DataGridToolba
await capability.onSelect(value);
return true;
}
export async function triggerDataGridToolbarCopy(capability: DataGridToolbarCopyCapability | undefined): Promise<boolean> {
if (!capability || !isDataGridToolbarCapabilityVisible(capability) || capability.disabled) return false;
await capability.onCopy();
return true;
}
export async function selectDataGridToolbarCopyItem(capability: DataGridToolbarCopyCapability | undefined, value: string): Promise<boolean> {
if (!capability || !isDataGridToolbarCapabilityVisible(capability)) return false;
const item = capability.items.find((candidate) => candidate.value === value);
if (!item || item.disabled) return false;
await capability.onSelect(value);
return true;
}
+7 -47
View File
@@ -39,6 +39,13 @@ export function normalizeSelectedColumnIndexes(columnIndexes: Iterable<number>):
return [...new Set(columnIndexes)].filter((index) => index >= 0).sort((a, b) => a - b);
}
export function dedupeColumnIndexes(columnIndexes: Iterable<number>): number[] {
// Deduplicate while preserving the first visible occurrence (insertion order)
// instead of numeric order. Used by the copy/extractor path so drag-reordered
// columns stay in the order the user sees them.
return [...new Set(columnIndexes)].filter((index) => index >= 0);
}
export function normalizeSelectionRange(anchor: CellPosition, focus: CellPosition): CellSelectionRange {
return {
startRow: Math.min(anchor.rowIndex, focus.rowIndex),
@@ -118,50 +125,3 @@ export function summarizeSelection(selection: SelectionData): SelectionSummary {
sum,
};
}
function displayValue(value: GridCellValue): string {
if (value === null) return "NULL";
if (typeof value === "boolean") return value ? "true" : "false";
if (typeof value === "object") return JSON.stringify(value);
return String(value);
}
function csvValue(value: GridCellValue | string): string {
const text = typeof value === "string" ? value : displayValue(value);
return `"${text.replace(/"/g, '""')}"`;
}
function sqlValue(value: GridCellValue): string {
if (value === null) return "NULL";
if (typeof value === "boolean") return value ? "TRUE" : "FALSE";
if (typeof value === "number" && Number.isFinite(value)) return String(value);
return `'${String(value).replace(/'/g, "''")}'`;
}
export function formatSelectionAsTsv(selection: SelectionData, includeHeader = false): string {
const body = selection.rows.map((row) => row.map(displayValue).join("\t")).join("\n");
if (!includeHeader) return body;
return [selection.columns.join("\t"), body].filter(Boolean).join("\n");
}
export function formatSelectionAsCsv(selection: SelectionData): string {
const header = selection.columns.map(csvValue).join(",");
const body = selection.rows.map((row) => row.map(csvValue).join(",")).join("\n");
return [header, body].filter(Boolean).join("\n");
}
export function formatSelectionAsJson(selection: SelectionData): string {
const objects = selection.rows.map((row) => {
const item: Record<string, GridCellValue> = {};
selection.columns.forEach((column, index) => {
item[column] = row[index] ?? null;
});
return item;
});
return JSON.stringify(objects, null, 2);
}
export function formatSelectionAsSqlInList(selection: SelectionData): string {
const values = selection.rows.flat().map(sqlValue);
return `(${values.join(", ")})`;
}
+25
View File
@@ -115,6 +115,31 @@ 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");
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");
});
it("normalizes persistent extractor configuration fail-fast defaults", () => {
const defaults = normalizeEditorSettings({}).dataGridExtractorOptions;
expect(defaults.dsv).toMatchObject({ columnSeparator: ",", rowSeparator: "\n", quote: '"', quotePolicy: "minimal" });
expect(defaults.sql).toMatchObject({ skipComputedColumns: true, skipGeneratedColumns: true, insertMode: "merged" });
const configured = normalizeEditorSettings({
dataGridExtractorOptions: {
dsv: { ...defaults.dsv, columnSeparator: "|", quotePolicy: "always" },
sql: { ...defaults.sql, insertMode: "row-by-row" },
json: { pretty: false },
},
}).dataGridExtractorOptions;
expect(configured.dsv.columnSeparator).toBe("|");
expect(configured.dsv.quotePolicy).toBe("always");
expect(configured.sql.insertMode).toBe("row-by-row");
expect(configured.json.pretty).toBe(false);
});
it("defaults retained result runs to tiled tabs and preserves list mode", () => {
expect(normalizeEditorSettings({}).resultRunDisplayMode).toBe("tabs");
expect(normalizeEditorSettings({ resultRunDisplayMode: "list" }).resultRunDisplayMode).toBe("list");
+9
View File
@@ -5,6 +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 { normalizeSidebarHiddenTablePrefixes } from "@/lib/sidebar/sidebarTableNameDisplay";
import type { ConnectionListSortMode } from "@/lib/sidebar/connectionListSort";
import { DEFAULT_SQL_FORMATTER_SETTINGS, normalizeSqlFormatterSettings, type SqlFormatterSettings } from "@/lib/sql/sqlFormatterConfig";
@@ -420,6 +421,8 @@ export interface EditorSettings {
dataGridQuickEntry: boolean;
dataGridRenderMode: DataGridRenderMode;
dataGridSearchMode: DataGridSearchMode;
dataGridCopyExtractor: DataGridCopyExtractorId;
dataGridExtractorOptions: DataGridExtractorOptions;
resultRunDisplayMode: ResultRunDisplayMode;
dataGridAutoTransposeSingleRow: boolean;
dataGridMultiRowTranspose: boolean;
@@ -585,6 +588,8 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = {
dataGridQuickEntry: false,
dataGridRenderMode: "canvas",
dataGridSearchMode: "filter",
dataGridCopyExtractor: "tsv",
dataGridExtractorOptions: normalizeDataGridExtractorOptions(DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS),
resultRunDisplayMode: "tabs",
dataGridAutoTransposeSingleRow: false,
dataGridMultiRowTranspose: false,
@@ -869,6 +874,8 @@ 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),
dataGridExtractorOptions: normalizeDataGridExtractorOptions(settings.dataGridExtractorOptions),
resultRunDisplayMode: normalizeResultRunDisplayMode(settings.resultRunDisplayMode),
dataGridAutoTransposeSingleRow: settings.dataGridAutoTransposeSingleRow === true,
dataGridMultiRowTranspose: settings.dataGridMultiRowTranspose === true,
@@ -1242,6 +1249,8 @@ 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.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;
if (partial.dataGridMultiRowTranspose !== undefined) editorSettings.value.dataGridMultiRowTranspose = partial.dataGridMultiRowTranspose === true;