feat(context-menu): copy INSERT from selected columns

This commit is contained in:
二丫讲梵
2026-07-20 13:59:49 +08:00
committed by GitHub
parent 7078c3fe19
commit 5803df3f73
18 changed files with 551 additions and 49 deletions
@@ -3345,6 +3345,7 @@ const {
isSelectingAll,
selectedRange,
selectedCells,
selectedCellMatrix,
selectedCellCount,
hasCellSelection,
clearCellSelection,
@@ -4979,6 +4980,11 @@ const {
copySelectionCsv,
copySelectionJson,
copySelectionSqlInList,
copySelectionAsInsert,
prefetchSelectionAsInsertStatement,
canCopyPreparedSelectionInsert,
canCopySelectionAsInsert,
selectionInsertRowCount,
copySelectedRowsTsv,
copySelectedRowsTsvWithHeaders,
copyColumnNames,
@@ -5018,6 +5024,7 @@ const {
exportBatchSize: computed(() => settingsStore.editorSettings.exportBatchSize),
hasCellSelection,
selectedCells,
selectedCellMatrix,
selectedRange,
contextCell: exportContextCell,
getRowItem: (rowId: number) => visibleDisplayItems.value.find((item) => item.id === rowId),
@@ -6356,6 +6363,7 @@ function onHeaderContext(col: string, columnIndex: number) {
}
contextHeaderColumn.value = col;
contextHeaderColumnIndex.value = columnIndex;
void prefetchCopyStatements();
}
async function copyHeaderColumn() {
if (!contextHeaderColumn.value) return;
@@ -6568,6 +6576,12 @@ function onRowContext(rowId: number, rowIndex: number) {
}
async function prefetchCopyStatements() {
if (canCopySelectionAsInsert.value) {
await prefetchSelectionAsInsertStatement();
if (selectionInsertRowCount.value > 1 && canCopyPreparedSelectionInsert()) {
await prefetchSelectionAsInsertStatement("row-by-row");
}
}
await prefetchRowAsInsertStatement(false);
if (isMultiRow.value) {
await prefetchRowAsInsertStatement(false, "row-by-row");
@@ -7262,6 +7276,13 @@ function copySubmenu(): ContextMenuItem {
}
function selectionSubmenu(): ContextMenuItem {
const insertItems: ContextMenuItem[] =
selectedCells.value.rows.length > 1
? [
{ label: t("grid.copySelectionInsertMerged"), action: () => copySelectionAsInsert("merged"), disabled: () => !canCopySelectionAsInsert.value || !canCopyPreparedSelectionInsert("merged") },
{ label: t("grid.copySelectionInsertRowByRow"), action: () => copySelectionAsInsert("row-by-row"), disabled: () => !canCopySelectionAsInsert.value || !canCopyPreparedSelectionInsert("row-by-row") },
]
: [{ label: t("grid.copySelectionInsert"), action: () => copySelectionAsInsert(), disabled: () => !canCopySelectionAsInsert.value || !canCopyPreparedSelectionInsert() }];
return {
label: t("grid.selection"),
icon: SquareDashed,
@@ -7271,6 +7292,7 @@ function selectionSubmenu(): ContextMenuItem {
{ 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 },
],
@@ -7,7 +7,7 @@ import { registerGlobalContextMenu, type ContextMenuRegistration } from "@/compo
export interface ContextMenuItem {
label: string;
action?: () => void;
disabled?: boolean;
disabled?: boolean | (() => boolean);
separator?: boolean;
icon?: Component;
iconClass?: string;
@@ -102,14 +102,14 @@ onMounted(() => {
});
function handleItemClick(item: ContextMenuItem) {
if (item.disabled) return;
if (itemIsDisabled(item)) return;
if (item.children?.length) return; // submenu trigger — do nothing on click
close();
item.action?.();
}
function handleSubItemClick(item: ContextMenuItem) {
if (item.disabled) return;
if (itemIsDisabled(item)) return;
close();
item.action?.();
}
@@ -143,7 +143,7 @@ function onItemMouseEnter(index: number, event: MouseEvent) {
lastMouseX = event.clientX;
lastMouseY = event.clientY;
const item = activeItems.value[index];
if (!item?.children?.length || item.disabled) {
if (!item?.children?.length || itemIsDisabled(item)) {
// Moving to an item without children — close submenu immediately, no delay needed
activeSubIndex.value = null;
return;
@@ -240,6 +240,10 @@ function itemButtonClass(variant?: "default" | "destructive") {
];
}
function itemIsDisabled(item: ContextMenuItem): boolean {
return typeof item.disabled === "function" ? item.disabled() : !!item.disabled;
}
function shortcutKeys(shortcut?: string): string[] {
return shortcutDisplayKeys(shortcut);
}
@@ -264,7 +268,7 @@ onBeforeUnmount(() => {
<div v-if="item.separator" class="-mx-1 my-1 flex items-center px-1">
<div class="h-px flex-1 bg-border/70" />
</div>
<button v-else :disabled="item.disabled" :class="[...itemButtonClass(item.variant), activeSubIndex === index ? 'bg-accent text-accent-foreground' : '']" @click="handleItemClick(item)" @mouseenter="(e) => onItemMouseEnter(index, e)" @mouseleave="onItemMouseLeave">
<button v-else :disabled="itemIsDisabled(item)" :class="[...itemButtonClass(item.variant), activeSubIndex === index ? 'bg-accent text-accent-foreground' : '']" @click="handleItemClick(item)" @mouseenter="(e) => onItemMouseEnter(index, e)" @mouseleave="onItemMouseLeave">
<span class="flex size-4 shrink-0 items-center justify-center">
<component :is="item.icon" v-if="item.icon" :class="['size-4', item.iconClass]" />
</span>
@@ -293,7 +297,7 @@ onBeforeUnmount(() => {
<div v-if="child.separator" class="-mx-1 my-1 flex items-center px-1">
<div class="h-px flex-1 bg-border/70" />
</div>
<button v-else :disabled="child.disabled" :class="itemButtonClass(child.variant)" @click="handleSubItemClick(child)">
<button v-else :disabled="itemIsDisabled(child)" :class="itemButtonClass(child.variant)" @click="handleSubItemClick(child)">
<span class="flex size-4 shrink-0 items-center justify-center">
<component :is="child.icon" v-if="child.icon" :class="['size-4', child.iconClass]" />
</span>
@@ -4,6 +4,7 @@ import { useDataGridExport, type UseDataGridExportOptions } from "@/composables/
import { buildDataGridCopyInsertStatement, 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";
const toast = vi.fn();
@@ -55,7 +56,7 @@ function row(data: unknown[]) {
};
}
function createMongoExportState(options: { columns: string[]; item: ReturnType<typeof row> & { sourceIndex: number }; mongoDocuments: unknown[] }) {
function createMongoExportState(options: { columns: string[]; item: ReturnType<typeof row> & { sourceIndex: number }; mongoDocuments: unknown[]; selectedCellMatrix?: CellSelectionMatrix }) {
const state: UseDataGridExportOptions = {
columns: computed(() => options.columns),
displayItems: computed(() => [options.item]),
@@ -72,8 +73,9 @@ function createMongoExportState(options: { columns: string[]; item: ReturnType<t
whereInput: computed(() => undefined),
orderBy: computed(() => undefined),
exportBatchSize: computed(() => 1000),
hasCellSelection: computed(() => false),
selectedCells: computed(() => ({ columns: [], rows: [] })),
hasCellSelection: computed(() => !!options.selectedCellMatrix),
selectedCells: computed(() => options.selectedCellMatrix ?? { columns: [], rows: [] }),
selectedCellMatrix: computed(() => options.selectedCellMatrix ?? null),
selectedRange: computed(() => null),
contextCell: ref({ rowId: options.item.id, rowIndex: 0, col: -1 }),
getRowItem: (rowId) => (rowId === options.item.id ? options.item : undefined),
@@ -83,7 +85,7 @@ function createMongoExportState(options: { columns: string[]; item: ReturnType<t
return useDataGridExport(state);
}
function createExportState(tableMeta: DataGridTableMeta, columns = tableMeta.columns?.map((column) => column.name) ?? ["id", "name"]) {
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}`)));
const options: UseDataGridExportOptions = {
columns: computed(() => columns),
@@ -99,8 +101,9 @@ function createExportState(tableMeta: DataGridTableMeta, columns = tableMeta.col
whereInput: computed(() => undefined),
orderBy: computed(() => undefined),
exportBatchSize: computed(() => 1000),
hasCellSelection: computed(() => false),
selectedCells: computed(() => ({ columns: [], rows: [] })),
hasCellSelection: computed(() => !!selectedCellMatrix),
selectedCells: computed(() => selectedCellMatrix ?? { 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),
@@ -195,6 +198,99 @@ describe("useDataGridExport prepared row statements", () => {
expect(copyToClipboard).not.toHaveBeenCalled();
});
it("reports selection INSERT prefetch failures", async () => {
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"],
],
};
const options: UseDataGridExportOptions = {
columns: computed(() => ["id", "display_name", "active", "display_note"]),
displayItems: computed(() => items),
sql: computed(() => undefined),
tableMeta: computed(() => ({
tableName: "users",
primaryKeys: ["id"],
columns: [
{ 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"]),
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),
selectedRowIds: ref(new Set<number>()),
hasRowSelection: computed(() => false),
};
const pending = deferred<string | undefined>();
vi.mocked(buildDataGridCopyInsertStatement).mockReturnValueOnce(pending.promise);
const state = useDataGridExport(options);
const prefetch = state.prefetchSelectionAsInsertStatement("merged");
await vi.waitFor(() => expect(buildDataGridCopyInsertStatement).toHaveBeenCalledTimes(1));
expect(state.copySelectionAsInsert("merged")).toBe(false);
expect(copyToClipboard).not.toHaveBeenCalled();
pending.resolve("INSERT INTO users (name, note) VALUES ('Ada', 'math'), ('Grace', 'compiler');");
await prefetch;
expect(state.canCopyPreparedSelectionInsert("merged")).toBe(true);
expect(state.copySelectionAsInsert("merged")).toBe(true);
expect(buildDataGridCopyInsertStatement).toHaveBeenCalledWith(
expect.objectContaining({
columns: ["display_name", "display_note"],
sourceColumns: ["name", "note"],
columnTypes: ["varchar", "text"],
rows: [
["Ada", "math"],
["Grace", "compiler"],
],
excludePrimaryKeys: false,
insertMode: "merged",
}),
);
expect(copyToClipboard).toHaveBeenCalledWith("INSERT INTO users (name, note) VALUES ('Ada', 'math'), ('Grace', 'compiler');");
});
it("copies Mongo JSON from the original document using the sorted source index and visible columns", async () => {
const item = { ...row(["true", '{"role":"admin"}']), sourceIndex: 1 };
const state = createMongoExportState({
@@ -240,6 +336,28 @@ describe("useDataGridExport prepared row statements", () => {
"role": "maintainer"
},
"lastUpdatedDate": ISODate("2025-05-06T08:35:32Z")
});`);
});
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"}' }],
selectedCellMatrix: {
rowIndexes: [0],
columnIndexes: [1],
columns: ["booleanText"],
rows: [["true"]],
},
});
await state.prefetchSelectionAsInsertStatement();
state.copySelectionAsInsert();
expect(copyToClipboard).toHaveBeenCalledWith(`db.getCollection("documents").insert({
"booleanText": "true"
});`);
});
});
@@ -38,6 +38,47 @@ function rowEvent(options: { meta?: boolean; shift?: boolean } = {}): MouseEvent
}
describe("useDataGridSelection", () => {
it("describes a rectangular selection with its display row and column indexes", () => {
const selection = createSelection();
selection.selectSingleCell(1, 0);
selection.extendCellSelectionTo(2, 1);
expect(selection.selectedCellMatrix.value).toEqual({
rowIndexes: [1, 2],
columnIndexes: [0, 1],
columns: ["id", "name"],
rows: [
[2, "name-2"],
[3, "name-3"],
],
});
});
it("supports non-contiguous columns when every selected row has the same columns", () => {
const selection = createSelection();
selection.selectedCellKeys.value = new Set(["0:0", "0:2", "2:0", "2:2"]);
expect(selection.selectedCellMatrix.value).toEqual({
rowIndexes: [0, 2],
columnIndexes: [0, 2],
columns: ["id", "email"],
rows: [
[1, "user-1@example.com"],
[3, "user-3@example.com"],
],
});
});
it("rejects discrete selections whose rows do not share the same columns", () => {
const selection = createSelection();
selection.selectedCellKeys.value = new Set(["0:0", "0:2", "2:0"]);
expect(selection.selectedCellMatrix.value).toBeNull();
});
it("creates a whole-row cell range for contiguous meta row selections", () => {
const selection = createSelection();
+193 -32
View File
@@ -2,7 +2,7 @@ import { computed, ref, type ComputedRef, type Ref } from "vue";
import { useI18n } from "vue-i18n";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import * as api from "@/lib/backend/api";
import { formatSelectionAsCsv, formatSelectionAsJson, formatSelectionAsSqlInList, formatSelectionAsTsv, type CellSelectionRange, type SelectionData } from "@/lib/dataGrid/gridSelection";
import { formatSelectionAsCsv, formatSelectionAsJson, formatSelectionAsSqlInList, formatSelectionAsTsv, type CellSelectionMatrix, type CellSelectionRange, type SelectionData } from "@/lib/dataGrid/gridSelection";
import { useToast } from "@/composables/useToast";
import { displayCellValue, type CellValue } from "@/lib/dataGrid/cellValue";
import { tryStartExclusiveActivation, type ActionActivationGuard } from "@/lib/connection/actionActivation";
@@ -69,6 +69,7 @@ export interface UseDataGridExportOptions {
exportBatchSize: ComputedRef<number>;
hasCellSelection: ComputedRef<boolean>;
selectedCells: ComputedRef<SelectionData>;
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>;
getRowItem: (rowId: number) => RowItem | undefined;
@@ -118,6 +119,13 @@ interface CopyStatementCache {
promise?: Promise<string | undefined>;
}
interface CopyInsertData {
columns: string[];
sourceColumns?: Array<string | undefined>;
columnTypes?: Array<string | undefined>;
rows: RowItem[];
}
export function useDataGridExport(options: UseDataGridExportOptions) {
const { t } = useI18n();
const { toast } = useToast();
@@ -146,6 +154,18 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
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: "",
@@ -171,6 +191,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
exportBatchSize,
hasCellSelection,
selectedCells,
selectedCellMatrix: selectedCellMatrixOption,
selectedRange,
contextCell,
getRowItem,
@@ -187,6 +208,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
exportProgressState,
exportCancelHandler,
} = options;
const selectedCellMatrix = selectedCellMatrixOption ?? computed<CellSelectionMatrix | null>(() => null);
async function copyText(text: string) {
try {
@@ -287,17 +309,55 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
}
function insertCopyKey(excludePrimaryKeys: boolean, insertMode: DataGridCopyInsertMode): string {
const eligibleRows = insertEligibleRows();
const rows = copyStatementRowsKey(eligibleRows);
const originalMongoDocuments = eligibleRows.map((item) => (item.sourceIndex === undefined ? undefined : options.mongoDocuments?.value?.[item.sourceIndex]));
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;
const selectedRows = matrix.rowIndexes.map((rowIndex) => displayItems.value[rowIndex]).filter((item): item is RowItem => !!item && !item.isDraft);
if (selectedRows.length !== matrix.rowIndexes.length) return null;
const selectedColumns = matrix.columnIndexes.map((columnIndex) => columns.value[columnIndex]).filter((column): column is string => column !== undefined);
if (selectedColumns.length !== matrix.columnIndexes.length) return null;
const selectedSourceColumns = sourceColumns.value?.length === columns.value.length ? matrix.columnIndexes.map((columnIndex) => sourceColumns.value?.[columnIndex]) : undefined;
const selectedColumnTypes = columnTypes.value?.length === columns.value.length ? matrix.columnIndexes.map((columnIndex) => columnTypes.value?.[columnIndex] ?? undefined) : undefined;
return {
columns: selectedColumns,
sourceColumns: selectedSourceColumns,
columnTypes: selectedColumnTypes,
rows: selectedRows.map((item) => ({
...item,
data: matrix.columnIndexes.map((columnIndex) => item.data[columnIndex] ?? null),
isDirtyCol: matrix.columnIndexes.map((columnIndex) => item.isDirtyCol[columnIndex] ?? false),
})),
};
}
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: columns.value,
columnTypes: columnTypes.value ?? null,
sourceColumns: sourceColumns.value ?? null,
columns: data.columns,
columnTypes: data.columnTypes ?? null,
sourceColumns: data.sourceColumns ?? null,
excludePrimaryKeys,
insertMode,
rows,
@@ -331,10 +391,45 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
}
}
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 formatMongoCopyInsertStatement(
buildMongoCopyInsertStatement({
collection: copyInsertTargetLabel?.value || tableMeta.value?.tableName || "collection",
columns: data.columns,
sourceColumns: data.sourceColumns,
rows: data.rows,
mongoDocuments: options.mongoDocuments?.value,
excludePrimaryKeys,
insertMode,
}),
);
}
return buildDataGridCopyInsertStatement({
databaseType: databaseType.value,
tableMeta: tableMeta.value,
columns: data.columns,
columnTypes: data.columnTypes,
sourceColumns: data.sourceColumns,
rows: data.rows.map((item) => item.data),
excludePrimaryKeys,
insertMode,
});
}
async function prefetchRowAsInsertStatement(excludePrimaryKeys: boolean, insertMode: DataGridCopyInsertMode = "merged") {
try {
await prepareRowAsInsertStatement(excludePrimaryKeys, insertMode);
@@ -359,30 +454,14 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
if (current.ready && current.key === key) return current.text;
if (current.loading && current.key === key && current.promise) return current.promise;
const data: CopyInsertData = {
columns: columns.value,
sourceColumns: sourceColumns.value,
columnTypes: columnTypes.value?.map((type) => type ?? undefined),
rows,
};
const promise = Promise.resolve().then(async () => {
const statement =
databaseType.value === "mongodb"
? formatMongoCopyInsertStatement(
buildMongoCopyInsertStatement({
collection: copyInsertTargetLabel?.value || tableMeta.value?.tableName || "collection",
columns: columns.value,
sourceColumns: sourceColumns.value,
rows,
mongoDocuments: options.mongoDocuments?.value,
excludePrimaryKeys,
insertMode,
}),
)
: await buildDataGridCopyInsertStatement({
databaseType: databaseType.value,
tableMeta: tableMeta.value,
columns: columns.value,
columnTypes: columnTypes.value,
sourceColumns: sourceColumns.value,
rows: rows.map((item) => item.data),
excludePrimaryKeys,
insertMode,
});
const statement = await buildCopyInsertStatement(data, excludePrimaryKeys, insertMode);
const latest = insertCopyCache(excludePrimaryKeys, insertMode);
if (latest.key !== key || latest.promise !== promise) return undefined;
setInsertCopyCache(excludePrimaryKeys, insertMode, {
@@ -435,6 +514,78 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
}
}
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;
}
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);
}
function copySelectionAsInsert(insertMode: DataGridCopyInsertMode = "merged"): boolean {
if (!canCopyPreparedSelectionInsert(insertMode)) return false;
void copyText(selectionInsertCopyCache(insertMode).text);
return true;
}
async function prefetchRowAsUpdateStatement() {
try {
await prepareRowAsUpdateStatement();
@@ -651,12 +802,17 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
return saveColumns.some((column) => column && !primaryKeySet.has(normalizeColumnName(column)));
});
function insertableCopyColumnCount(excludePrimaryKeys: boolean): number {
function insertableCopyColumnCount(excludePrimaryKeys: boolean, copyColumns = effectiveColumns(sourceColumns.value, columns.value)): number {
const primaryKeySet = new Set((tableMeta.value?.primaryKeys ?? []).map(normalizeColumnName));
return effectiveColumns(sourceColumns.value, columns.value).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) && (!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;
});
const canCopyRowAsInsertWithoutPrimaryKeys = computed(() => {
if (!tableMeta.value?.primaryKeys.length) return false;
@@ -1302,6 +1458,11 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
copySelectionCsv,
copySelectionJson,
copySelectionSqlInList,
copySelectionAsInsert,
prefetchSelectionAsInsertStatement,
canCopyPreparedSelectionInsert,
canCopySelectionAsInsert,
selectionInsertRowCount,
copySelectedRowsTsv,
copySelectedRowsTsvWithHeaders,
copyColumnNames,
@@ -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 CellSelectionRange, type SelectionData } from "@/lib/dataGrid/gridSelection";
import { allCellsSelectionRange, extractColumnsSelection, extractSelection, isCellInSelection, normalizeSelectionRange, normalizeSelectedColumnIndexes, rowSelectionRange, 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;
@@ -86,6 +86,53 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) {
});
});
function buildCellSelectionMatrix(rowIndexes: number[], columnIndexes: number[]): CellSelectionMatrix | null {
const normalizedRows = normalizeSelectedColumnIndexes(rowIndexes).filter((index) => index < displayItems.value.length && !displayItems.value[index]?.isDraft);
const normalizedColumns = normalizeSelectedColumnIndexes(columnIndexes).filter((index) => index < columns.value.length);
if (normalizedRows.length === 0 || normalizedColumns.length === 0) return null;
return {
rowIndexes: normalizedRows,
columnIndexes: normalizedColumns,
columns: normalizedColumns.map((index) => columns.value[index]),
rows: normalizedRows.map((rowIndex) => normalizedColumns.map((columnIndex) => displayItems.value[rowIndex]?.data[columnIndex] ?? null)),
};
}
const selectedCellMatrix = computed<CellSelectionMatrix | null>(() => {
if (hasColumnSelection.value) {
return buildCellSelectionMatrix(
displayItems.value.map((_, index) => index),
[...selectedColumnIndexes.value],
);
}
if (selectedCellKeys.value.size > 0) {
const columnsByRow = new Map<number, number[]>();
for (const key of selectedCellKeys.value) {
const position = parseCellKey(key);
if (!position || position.rowIndex >= displayItems.value.length || position.colIndex >= columns.value.length || displayItems.value[position.rowIndex]?.isDraft) continue;
const selectedColumns = columnsByRow.get(position.rowIndex) ?? [];
selectedColumns.push(position.colIndex);
columnsByRow.set(position.rowIndex, selectedColumns);
}
const rowIndexes = [...columnsByRow.keys()].sort((a, b) => a - b);
const columnIndexes = normalizeSelectedColumnIndexes(columnsByRow.get(rowIndexes[0]) ?? []);
if (rowIndexes.length === 0 || columnIndexes.length === 0) return null;
const hasConsistentColumns = rowIndexes.every((rowIndex) => {
const rowColumns = normalizeSelectedColumnIndexes(columnsByRow.get(rowIndex) ?? []);
return rowColumns.length === columnIndexes.length && rowColumns.every((columnIndex, index) => columnIndex === columnIndexes[index]);
});
return hasConsistentColumns ? buildCellSelectionMatrix(rowIndexes, columnIndexes) : null;
}
const range = selectedRange.value;
if (!range) return null;
return buildCellSelectionMatrix(
Array.from({ length: range.endRow - range.startRow + 1 }, (_, index) => range.startRow + index),
Array.from({ length: range.endCol - range.startCol + 1 }, (_, index) => range.startCol + index),
);
});
const selectedCellCount = computed(() => selectedCells.value.rows.reduce((count, row) => count + row.length, 0));
const hasCellSelection = computed(() => selectedCellCount.value > 0);
@@ -407,6 +454,7 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) {
isSelectingCells,
selectedRange,
selectedCells,
selectedCellMatrix,
selectedCellCount,
hasCellSelection,
isSelectingAll,
+3
View File
@@ -821,6 +821,9 @@ export default {
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",
+3
View File
@@ -770,6 +770,9 @@ export default withEnglishFallback({
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",
+3
View File
@@ -768,6 +768,9 @@ export default withEnglishFallback({
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",
+3
View File
@@ -767,6 +767,9 @@ export default withEnglishFallback({
copySelectionCsv: "選択範囲をコピー (CSV)",
copySelectionJson: "選択範囲をコピー (JSON)",
copySelectionSql: "選択範囲をSQL INリストとしてコピー",
copySelectionInsert: "選択範囲をINSERTとしてコピー",
copySelectionInsertMerged: "選択範囲をINSERTとしてコピー(まとめて)",
copySelectionInsertRowByRow: "選択範囲をINSERTとしてコピー(行ごと)",
clearSelection: "選択をクリア",
export: "エクスポート",
exportCsv: "CSVエクスポート",
+3
View File
@@ -770,6 +770,9 @@ export default withEnglishFallback({
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",
+3
View File
@@ -823,6 +823,9 @@ export default withEnglishFallback({
copySelectionCsv: "复制选区 (CSV)",
copySelectionJson: "复制选区 (JSON)",
copySelectionSql: "复制选区为 SQL IN 列表",
copySelectionInsert: "复制选区为 INSERT",
copySelectionInsertMerged: "复制选区为 INSERT(合并)",
copySelectionInsertRowByRow: "复制选区为 INSERT(逐行)",
clearSelection: "清除选区",
export: "导出",
exportCsv: "导出 CSV",
+3
View File
@@ -770,6 +770,9 @@ export default withEnglishFallback({
copySelectionCsv: "複製選區 (CSV)",
copySelectionJson: "複製選區 (JSON)",
copySelectionSql: "複製選區為 SQL IN 清單",
copySelectionInsert: "複製選區為 INSERT",
copySelectionInsertMerged: "複製選區為 INSERT(合併)",
copySelectionInsertRowByRow: "複製選區為 INSERT(逐筆)",
clearSelection: "清除選區",
export: "匯出",
exportCsv: "匯出 CSV",
+1 -1
View File
@@ -119,7 +119,7 @@ export async function copyToClipboard(text: string, env: ClipboardEnvironment =
await writeText(text);
return;
} catch {
// Fall through to Web Clipboard / legacy copy when the native plugin is unavailable.
// Preserve Web Clipboard and legacy copy compatibility when native writes fail.
}
}
@@ -12,7 +12,7 @@ export type DataGridColumnMenuItem = {
export type DataGridContextMenuItem = {
label: string;
action?: () => void;
disabled?: boolean;
disabled?: boolean | (() => boolean);
separator?: boolean;
icon?: Component;
iconClass?: string;
@@ -17,6 +17,11 @@ export interface SelectionData {
rows: GridCellValue[][];
}
export interface CellSelectionMatrix extends SelectionData {
rowIndexes: number[];
columnIndexes: number[];
}
export interface SelectionSummary {
cellCount: number;
rowCount: number;
+78 -2
View File
@@ -1,6 +1,12 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
import { copyToClipboard, eventTargetAllowsAppClipboardShortcut, eventTargetAllowsNativeClipboard, eventTargetUsesNativeClipboard, hasNativeClipboardSelection, isPlainClipboardShortcut, readTextFromClipboard } from "../../apps/desktop/src/lib/common/clipboard.ts";
import { test, vi } from "vitest";
import { copyToClipboard, eventTargetAllowsAppClipboardShortcut, eventTargetAllowsNativeClipboard, eventTargetUsesNativeClipboard, hasNativeClipboardSelection, isPlainClipboardShortcut, readTextFromClipboard, type ClipboardEnvironment } from "../../apps/desktop/src/lib/common/clipboard.ts";
const tauriClipboardMock = vi.hoisted(() => ({
writeText: vi.fn<(text: string) => Promise<void>>(),
}));
vi.mock("@tauri-apps/plugin-clipboard-manager", () => tauriClipboardMock);
test("copyToClipboard falls back when navigator clipboard is unavailable", async () => {
const appended: unknown[] = [];
@@ -49,6 +55,76 @@ test("copyToClipboard falls back when navigator clipboard is unavailable", async
assert.equal(removed[0], textarea);
});
test("copyToClipboard falls back to navigator clipboard after a Tauri write failure", async () => {
const webWrite = vi.fn(async () => undefined);
const legacyCopy = vi.fn(() => true);
tauriClipboardMock.writeText.mockRejectedValueOnce(new Error("native clipboard unavailable"));
await copyToClipboard("INSERT INTO users VALUES (1);", {
__TAURI_INTERNALS__: {},
navigator: { clipboard: { writeText: webWrite } },
document: {
body: { appendChild: vi.fn(), removeChild: vi.fn() },
createElement: vi.fn(),
execCommand: legacyCopy,
},
} as unknown as ClipboardEnvironment & Record<string, unknown>);
assert.deepEqual(webWrite.mock.calls, [["INSERT INTO users VALUES (1);"]]);
assert.equal(legacyCopy.mock.calls.length, 0);
});
test("copyToClipboard falls back to legacy copy after Tauri and navigator failures", async () => {
const selected: string[] = [];
const textarea = {
value: "",
style: {} as Record<string, string>,
setAttribute: vi.fn(),
select() {
selected.push(this.value);
},
};
const webWrite = vi.fn(async () => {
throw new Error("web clipboard unavailable");
});
const legacyCopy = vi.fn(() => true);
tauriClipboardMock.writeText.mockRejectedValueOnce(new Error("native clipboard unavailable"));
await copyToClipboard("INSERT INTO users VALUES (2);", {
__TAURI_INTERNALS__: {},
navigator: { clipboard: { writeText: webWrite } },
document: {
body: { appendChild: vi.fn(), removeChild: vi.fn() },
createElement: vi.fn(() => textarea),
execCommand: legacyCopy,
},
} as unknown as ClipboardEnvironment & Record<string, unknown>);
assert.deepEqual(webWrite.mock.calls, [["INSERT INTO users VALUES (2);"]]);
assert.deepEqual(selected, ["INSERT INTO users VALUES (2);"]);
assert.deepEqual(legacyCopy.mock.calls, [["copy"]]);
});
test("copyToClipboard does not fall back after a successful Tauri write", async () => {
const webWrite = vi.fn(async () => undefined);
const legacyCopy = vi.fn(() => true);
tauriClipboardMock.writeText.mockResolvedValueOnce(undefined);
await copyToClipboard("INSERT INTO users VALUES (3);", {
__TAURI_INTERNALS__: {},
navigator: { clipboard: { writeText: webWrite } },
document: {
body: { appendChild: vi.fn(), removeChild: vi.fn() },
createElement: vi.fn(),
execCommand: legacyCopy,
},
} as unknown as ClipboardEnvironment & Record<string, unknown>);
assert.deepEqual(tauriClipboardMock.writeText.mock.calls.at(-1), ["INSERT INTO users VALUES (3);"]);
assert.equal(webWrite.mock.calls.length, 0);
assert.equal(legacyCopy.mock.calls.length, 0);
});
test("readTextFromClipboard uses navigator clipboard when available", async () => {
const text = await readTextFromClipboard({
navigator: {
+6
View File
@@ -23,6 +23,12 @@ test("set NULL applies a real null value only to editable selections", () => {
assert.doesNotMatch(handler, /fillSelectionWithValue\(["'](?:NULL)?["']\)/);
});
test("column-header context menus prefetch prepared copy statements", () => {
const handler = dataGridSource.match(/function onHeaderContext\([^]*?\n\}/)?.[0] ?? "";
assert.match(handler, /void prefetchCopyStatements\(\);/);
});
test("editable cell selections expose generation after bulk edit", () => {
const icon = {};
const action = () => {};