mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
feat(context-menu): copy INSERT from selected columns
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -767,6 +767,9 @@ export default withEnglishFallback({
|
||||
copySelectionCsv: "選択範囲をコピー (CSV)",
|
||||
copySelectionJson: "選択範囲をコピー (JSON)",
|
||||
copySelectionSql: "選択範囲をSQL INリストとしてコピー",
|
||||
copySelectionInsert: "選択範囲をINSERTとしてコピー",
|
||||
copySelectionInsertMerged: "選択範囲をINSERTとしてコピー(まとめて)",
|
||||
copySelectionInsertRowByRow: "選択範囲をINSERTとしてコピー(行ごと)",
|
||||
clearSelection: "選択をクリア",
|
||||
export: "エクスポート",
|
||||
exportCsv: "CSVエクスポート",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -823,6 +823,9 @@ export default withEnglishFallback({
|
||||
copySelectionCsv: "复制选区 (CSV)",
|
||||
copySelectionJson: "复制选区 (JSON)",
|
||||
copySelectionSql: "复制选区为 SQL IN 列表",
|
||||
copySelectionInsert: "复制选区为 INSERT",
|
||||
copySelectionInsertMerged: "复制选区为 INSERT(合并)",
|
||||
copySelectionInsertRowByRow: "复制选区为 INSERT(逐行)",
|
||||
clearSelection: "清除选区",
|
||||
export: "导出",
|
||||
exportCsv: "导出 CSV",
|
||||
|
||||
@@ -770,6 +770,9 @@ export default withEnglishFallback({
|
||||
copySelectionCsv: "複製選區 (CSV)",
|
||||
copySelectionJson: "複製選區 (JSON)",
|
||||
copySelectionSql: "複製選區為 SQL IN 清單",
|
||||
copySelectionInsert: "複製選區為 INSERT",
|
||||
copySelectionInsertMerged: "複製選區為 INSERT(合併)",
|
||||
copySelectionInsertRowByRow: "複製選區為 INSERT(逐筆)",
|
||||
clearSelection: "清除選區",
|
||||
export: "匯出",
|
||||
exportCsv: "匯出 CSV",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Vendored
+78
-2
@@ -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: {
|
||||
|
||||
@@ -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 = () => {};
|
||||
|
||||
Reference in New Issue
Block a user