feat(objects): copy table column metadata (#12771)
* feat(objects): copy table info columns * fix(objects): reset copied column selection on filter * fix(objects): scope column shortcuts to selection * fix(objects): support shortcuts from columns area * fix(objects): copy selected columns through native copy * docs: document copying table column info * feat(objects): copy table info columns * fix(objects): reset copied column selection on filter * fix(objects): scope column shortcuts to selection * fix(objects): support shortcuts from columns area * fix(objects): copy selected columns through native copy * docs: document copying table column info * fix(objects): guard formulas in copied column metadata * fix(objects): remove obsolete TSV escape helper
This commit is contained in:
1 parent
72a584568f
commit
12443d769c
19 files changed
+285
-26
No files matched your search
@@ -125,6 +125,7 @@ import { buildXlsxHeaderOverrides, hasXlsxHeaderComments, xlsxHeaderUsesCommentR
|
||||
import { showSqlInsertModeDialog, type SqlInsertDialect, type SqlInsertMode } from "@/lib/export/sqlInsertMode";
|
||||
import { csvNullLiteralForMode } from "@/lib/export/csvNullMode";
|
||||
import { copyToClipboard, eventTargetAllowsAppClipboardShortcut } from "@/lib/common/clipboard";
|
||||
import { encodeSpreadsheetTsvCell } from "@/lib/common/spreadsheetTsv";
|
||||
import {
|
||||
defaultPasteTableMode,
|
||||
pasteTableModeCopiesData,
|
||||
@@ -324,6 +325,10 @@ const tablePartitionStatusResolved = ref(false);
|
||||
// is also shown, mirroring DataGrid/TableStructureEditor.
|
||||
const tableConstraintsForTab = computed(() => constraintsForConstraintsTab(tableConstraints.value, tableMetadataCapabilities.value.foreignKeys));
|
||||
const tableInfoSearchQuery = ref("");
|
||||
const selectedTableInfoColumns = ref<string[]>([]);
|
||||
watch(tableInfoSearchQuery, () => {
|
||||
selectedTableInfoColumns.value = [];
|
||||
});
|
||||
const tableInfoDdlPreRef = ref<HTMLPreElement | null>(null);
|
||||
const activeTableInfoLoading = computed(() => {
|
||||
if (tableInfoTab.value === "info") return tableOverviewLoading.value;
|
||||
@@ -1253,6 +1258,64 @@ const tableInfoTabListStyle = computed(() => ({
|
||||
}));
|
||||
|
||||
const filteredTableColumns = computed(() => filterObjectBrowserTableColumns(tableColumns.value, tableInfoSearchQuery.value));
|
||||
|
||||
function toggleTableInfoColumnSelection(name: string, event: MouseEvent) {
|
||||
const current = selectedTableInfoColumns.value;
|
||||
const index = current.indexOf(name);
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
selectedTableInfoColumns.value = index < 0 ? [...current, name] : current.filter((column) => column !== name);
|
||||
} else if (event.shiftKey && current.length > 0) {
|
||||
const visibleNames = filteredTableColumns.value.map((column) => column.name);
|
||||
const anchor = visibleNames.indexOf(current[current.length - 1]!);
|
||||
const target = visibleNames.indexOf(name);
|
||||
selectedTableInfoColumns.value = anchor < 0 || target < 0 ? [name] : visibleNames.slice(Math.min(anchor, target), Math.max(anchor, target) + 1);
|
||||
} else {
|
||||
selectedTableInfoColumns.value = [name];
|
||||
}
|
||||
}
|
||||
|
||||
function onTableInfoColumnContextMenu(name: string, event: MouseEvent, openMenu: (event: MouseEvent) => void) {
|
||||
if (!selectedTableInfoColumns.value.includes(name)) selectedTableInfoColumns.value = [name];
|
||||
openMenu(event);
|
||||
}
|
||||
|
||||
function copyTableInfoColumnDetails() {
|
||||
const ordered = filteredTableColumns.value.filter((column) => selectedTableInfoColumns.value.includes(column.name));
|
||||
if (ordered.length === 0) return;
|
||||
const headers = ["#", t("grid.columnName"), t("grid.columnType"), t("grid.tableInfoNullable"), t("structureEditor.defaultValue"), t("grid.columnComment")];
|
||||
const rows = ordered.map((column) => [String(filteredTableColumns.value.findIndex((item) => item.name === column.name) + 1), column.name, gaussdbMColumnType(column.data_type), column.is_nullable ? "YES" : "NO", tableColumnDefaultDisplayValue(column.column_default), column.comment ?? ""]);
|
||||
const text = [headers, ...rows].map((row) => row.map(encodeSpreadsheetTsvCell).join("\t")).join("\n");
|
||||
void copyToClipboard(text)
|
||||
.then(() => toast(t("contextMenu.copyAdvancedInfo"), 2000))
|
||||
.catch((error) => toast(t("grid.copyFailed", { message: error?.message || String(error) }), 5000));
|
||||
}
|
||||
|
||||
function tableInfoColumnContextMenuItems(): ContextMenuItem[] {
|
||||
return [{ label: t("contextMenu.copyAdvancedInfo"), icon: Copy, shortcut: "Ctrl+Shift+C", action: copyTableInfoColumnDetails }];
|
||||
}
|
||||
|
||||
function onTableInfoColumnsCopy(event: ClipboardEvent) {
|
||||
if (selectedTableInfoColumns.value.length === 0 || !event.clipboardData) return;
|
||||
const names = filteredTableColumns.value.filter((column) => selectedTableInfoColumns.value.includes(column.name)).map((column) => column.name);
|
||||
if (names.length === 0) return;
|
||||
event.clipboardData.setData("text/plain", names.join("\n"));
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function onTableInfoColumnsKeydown(event: KeyboardEvent) {
|
||||
const target = event.target;
|
||||
if (event.defaultPrevented || selectedTableInfoColumns.value.length === 0 || !(target instanceof Element) || (!target.closest("[data-table-info-column]") && target !== event.currentTarget) || !(event.ctrlKey || event.metaKey) || event.altKey) return;
|
||||
const key = event.key.toLowerCase();
|
||||
if (key !== "c") return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (key === "c" && !event.shiftKey) {
|
||||
const names = filteredTableColumns.value.filter((column) => selectedTableInfoColumns.value.includes(column.name)).map((column) => column.name);
|
||||
if (names.length) void copyToClipboard(names.join("\n")).catch((error) => toast(t("grid.copyFailed", { message: error?.message || String(error) }), 5000));
|
||||
return;
|
||||
}
|
||||
copyTableInfoColumnDetails();
|
||||
}
|
||||
const tableOverviewRows = computed(() => {
|
||||
const stats = tableOverviewStats.value;
|
||||
const rows = [
|
||||
@@ -1317,6 +1380,7 @@ const filteredTableDdlContent = computed(() => {
|
||||
});
|
||||
|
||||
async function openTableInfo(row: ObjectBrowserRow, initialTab?: TableInfoTab) {
|
||||
selectedTableInfoColumns.value = [];
|
||||
selectedRowId.value = row.id;
|
||||
// Toggle off if clicking the same table
|
||||
if (sidePanelRow.value?.id === row.id && sidePanelMode.value === "table-info" && !initialTab) {
|
||||
@@ -1371,6 +1435,7 @@ async function selectTableInfoTab(tab: TableInfoTab) {
|
||||
const nextTab = tableInfoTabs.value.some((item) => item.id === tab) ? tab : tableInfoTabs.value[0]?.id;
|
||||
if (!nextTab) return;
|
||||
tableInfoTab.value = nextTab;
|
||||
selectedTableInfoColumns.value = [];
|
||||
tableInfoSearchQuery.value = "";
|
||||
if (nextTab === "info") await fetchTableOverview();
|
||||
else if (nextTab === "ddl") await fetchTableDdl();
|
||||
@@ -1715,6 +1780,7 @@ function onSidePanelResizeEnd() {
|
||||
}
|
||||
|
||||
function closeSidePanel() {
|
||||
selectedTableInfoColumns.value = [];
|
||||
sidePanelRow.value = null;
|
||||
sourceRow.value = null;
|
||||
sidePanelMode.value = "source";
|
||||
@@ -3425,7 +3491,8 @@ function openPasteTableDialog() {
|
||||
}
|
||||
|
||||
function onObjectBrowserKeydown(event: KeyboardEvent) {
|
||||
if (event.defaultPrevented || !supportsBatchTableActions.value) return;
|
||||
if (event.defaultPrevented) return;
|
||||
if (!supportsBatchTableActions.value) return;
|
||||
if (eventTargetAllowsAppClipboardShortcut(event, "c")) {
|
||||
if (selectedTableCount.value === 0) return;
|
||||
event.preventDefault();
|
||||
@@ -5206,7 +5273,7 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="tableInfoTab === 'columns'" class="flex-1 min-h-0 overflow-auto">
|
||||
<div v-else-if="tableInfoTab === 'columns'" data-table-info-columns class="flex-1 min-h-0 overflow-auto" tabindex="0" @keydown="onTableInfoColumnsKeydown" @copy="onTableInfoColumnsCopy">
|
||||
<div v-if="tableColumnsLoading" class="h-full flex items-center justify-center">
|
||||
<Loader2 class="w-4 h-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
@@ -5224,23 +5291,33 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(column, index) in filteredTableColumns" :key="column.name" class="border-b hover:bg-gray-200 dark:hover:bg-gray-800/30" :title="column.name">
|
||||
<td class="px-3 py-2 text-muted-foreground w-8">{{ index + 1 }}</td>
|
||||
<td class="px-3 py-2 font-medium">
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<KeyRound v-if="column.is_primary_key" class="h-3 w-3 text-amber-500" />
|
||||
{{ column.name }}
|
||||
</span>
|
||||
<div v-if="column.comment" class="mt-0.5 text-[11px] text-muted-foreground truncate">
|
||||
{{ column.comment }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-2 font-mono text-[11px] text-muted-foreground">{{ gaussdbMColumnType(column.data_type) }}</td>
|
||||
<td class="px-3 py-2">{{ column.is_nullable ? "YES" : "NO" }}</td>
|
||||
<td data-table-info-column-default class="max-w-56 px-3 py-2 font-mono text-[11px]" :class="{ 'text-muted-foreground/70': column.column_default == null }" :title="column.column_default ?? undefined">
|
||||
<span class="block max-w-56 truncate">{{ tableColumnDefaultDisplayValue(column.column_default) }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<CustomContextMenu v-for="(column, index) in filteredTableColumns" :key="column.name" :items="tableInfoColumnContextMenuItems" v-slot="{ onContextMenu, isOpen }">
|
||||
<tr
|
||||
tabindex="0"
|
||||
:data-table-info-column="column.name"
|
||||
class="border-b cursor-default outline-none"
|
||||
:class="selectedTableInfoColumns.includes(column.name) || isOpen ? 'bg-primary/15' : 'hover:bg-gray-200 dark:hover:bg-gray-800/30'"
|
||||
:title="column.name"
|
||||
@click="toggleTableInfoColumnSelection(column.name, $event)"
|
||||
@contextmenu="onTableInfoColumnContextMenu(column.name, $event, onContextMenu)"
|
||||
>
|
||||
<td class="px-3 py-2 text-muted-foreground w-8">{{ index + 1 }}</td>
|
||||
<td class="px-3 py-2 font-medium">
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<KeyRound v-if="column.is_primary_key" class="h-3 w-3 text-amber-500" />
|
||||
{{ column.name }}
|
||||
</span>
|
||||
<div v-if="column.comment" class="mt-0.5 text-[11px] text-muted-foreground truncate">
|
||||
{{ column.comment }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-2 font-mono text-[11px] text-muted-foreground">{{ gaussdbMColumnType(column.data_type) }}</td>
|
||||
<td class="px-3 py-2">{{ column.is_nullable ? "YES" : "NO" }}</td>
|
||||
<td data-table-info-column-default class="max-w-56 px-3 py-2 font-mono text-[11px]" :class="{ 'text-muted-foreground/70': column.column_default == null }" :title="column.column_default ?? undefined">
|
||||
<span class="block max-w-56 truncate">{{ tableColumnDefaultDisplayValue(column.column_default) }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</CustomContextMenu>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
Vendored
+148
-7
@@ -9,6 +9,7 @@ import type { ConnectionConfig } from "@/types/database";
|
||||
const mocks = vi.hoisted(() => ({
|
||||
listObjects: vi.fn(),
|
||||
listSchemas: vi.fn(),
|
||||
getColumns: vi.fn(),
|
||||
ensureConnected: vi.fn(),
|
||||
copyToClipboard: vi.fn(),
|
||||
toast: vi.fn(),
|
||||
@@ -18,6 +19,7 @@ const mocks = vi.hoisted(() => ({
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
listObjects: (...args: unknown[]) => mocks.listObjects(...args),
|
||||
listSchemas: (...args: unknown[]) => mocks.listSchemas(...args),
|
||||
getColumns: (...args: unknown[]) => mocks.getColumns(...args),
|
||||
listObjectStatistics: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
vi.mock("@/stores/connectionStore", () => ({
|
||||
@@ -67,13 +69,6 @@ vi.mock("vue-virtual-scroller", () => ({
|
||||
}));
|
||||
vi.mock("@/components/ui/searchable-select", () => ({ SearchableSelect: { render: () => null } }));
|
||||
vi.mock("@/components/ui/ToolbarOverflowMenu.vue", () => ({ default: { render: () => null } }));
|
||||
vi.mock("@/components/ui/CustomContextMenu.vue", () => ({
|
||||
default: defineComponent({
|
||||
setup(_, { slots }) {
|
||||
return () => slots.default?.({ onContextMenu: () => undefined, isOpen: false });
|
||||
},
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/components/editor/QueryEditor.vue", () => ({ default: { render: () => null } }));
|
||||
vi.mock("@/components/editor/DangerConfirmDialog.vue", () => ({ default: { render: () => null } }));
|
||||
vi.mock("@/components/objects/ProcedureExecutionDialog.vue", () => ({ default: { render: () => null } }));
|
||||
@@ -101,6 +96,7 @@ beforeEach(() => {
|
||||
{ name: "audit", object_type: "TABLE" },
|
||||
]);
|
||||
mocks.listSchemas.mockResolvedValue([]);
|
||||
mocks.getColumns.mockResolvedValue([]);
|
||||
mocks.ensureConnected.mockResolvedValue(undefined);
|
||||
mocks.copyToClipboard.mockResolvedValue(undefined);
|
||||
invalidateObjectBrowserRowsCache({});
|
||||
@@ -130,7 +126,152 @@ function rowFor(host: HTMLElement, name: string): HTMLElement {
|
||||
return row!;
|
||||
}
|
||||
|
||||
async function openColumnInfo(host: HTMLElement, columnCount = 2) {
|
||||
rowFor(host, "orders").dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await vi.waitFor(() => expect(host.querySelector("[data-object-table-info-panel]")).not.toBeNull());
|
||||
const panel = host.querySelector<HTMLElement>("[data-object-table-info-panel]")!;
|
||||
const columnsTab = [...panel.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.includes("grid.tableInfoColumns"));
|
||||
expect(columnsTab).toBeDefined();
|
||||
columnsTab!.click();
|
||||
await vi.waitFor(() => expect(panel.querySelectorAll("[data-table-info-column]")).toHaveLength(columnCount));
|
||||
return panel;
|
||||
}
|
||||
|
||||
describe("ObjectBrowser table copy", () => {
|
||||
it("copies selected table-info column names and escaped advanced metadata in display order", async () => {
|
||||
connection.id = "mysql-copy-columns-keyboard";
|
||||
mocks.getColumns.mockResolvedValue([
|
||||
{ name: "second", data_type: "VARCHAR(20)", is_nullable: false, column_default: "x\ty", is_primary_key: false, extra: null, comment: "line 1\nline 2" },
|
||||
{ name: "first", data_type: "NUMBER", is_nullable: true, column_default: null, is_primary_key: true, extra: null, comment: 'quote "value"' },
|
||||
]);
|
||||
const host = await mountBrowser();
|
||||
const panel = await openColumnInfo(host);
|
||||
const columns = [...panel.querySelectorAll<HTMLElement>("[data-table-info-column]")];
|
||||
columns[0]!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
columns[1]!.dispatchEvent(new MouseEvent("click", { bubbles: true, ctrlKey: true }));
|
||||
const columnsArea = panel.querySelector<HTMLElement>("[data-table-info-columns]")!;
|
||||
columnsArea.focus();
|
||||
const search = panel.querySelector<HTMLInputElement>("[data-table-info-search]")!;
|
||||
const searchShortcut = new KeyboardEvent("keydown", { key: "c", ctrlKey: true, shiftKey: true, bubbles: true, cancelable: true });
|
||||
search.dispatchEvent(searchShortcut);
|
||||
expect(mocks.copyToClipboard).not.toHaveBeenCalled();
|
||||
expect(searchShortcut.defaultPrevented).toBe(false);
|
||||
|
||||
columnsArea.dispatchEvent(new KeyboardEvent("keydown", { key: "c", ctrlKey: true, bubbles: true }));
|
||||
await vi.waitFor(() => expect(mocks.copyToClipboard).toHaveBeenLastCalledWith("second\nfirst"));
|
||||
|
||||
const copied: string[] = [];
|
||||
const copyEvent = new Event("copy", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(copyEvent, "clipboardData", { value: { setData: (_type: string, value: string) => copied.push(value) } });
|
||||
columnsArea.dispatchEvent(copyEvent);
|
||||
expect(copyEvent.defaultPrevented).toBe(true);
|
||||
expect(copied).toEqual(["second\nfirst"]);
|
||||
|
||||
columnsArea.dispatchEvent(new KeyboardEvent("keydown", { key: "c", ctrlKey: true, shiftKey: true, bubbles: true }));
|
||||
await vi.waitFor(() => expect(mocks.copyToClipboard).toHaveBeenLastCalledWith('#\tgrid.columnName\tgrid.columnType\tgrid.tableInfoNullable\tstructureEditor.defaultValue\tgrid.columnComment\n1\tsecond\tVARCHAR(20)\tNO\t"x\ty"\t"line 1\nline 2"\n2\tfirst\tNUMBER\tYES\t—\t"quote ""value"""'));
|
||||
});
|
||||
|
||||
it("neutralizes spreadsheet formulas only in advanced metadata TSV", async () => {
|
||||
connection.id = "mysql-copy-columns-formula-guard";
|
||||
mocks.getColumns.mockResolvedValue([{ name: "=1+1", data_type: "VARCHAR(20)", is_nullable: false, column_default: " +SUM(1,1)", is_primary_key: false, extra: null, comment: "\tCMD" }]);
|
||||
const host = await mountBrowser();
|
||||
const panel = await openColumnInfo(host, 1);
|
||||
const column = panel.querySelector<HTMLElement>("[data-table-info-column]")!;
|
||||
column.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
|
||||
column.dispatchEvent(new KeyboardEvent("keydown", { key: "c", ctrlKey: true, bubbles: true }));
|
||||
await vi.waitFor(() => expect(mocks.copyToClipboard).toHaveBeenLastCalledWith("=1+1"));
|
||||
|
||||
column.dispatchEvent(new KeyboardEvent("keydown", { key: "c", ctrlKey: true, shiftKey: true, bubbles: true }));
|
||||
await vi.waitFor(() => expect(mocks.copyToClipboard).toHaveBeenLastCalledWith("#\tgrid.columnName\tgrid.columnType\tgrid.tableInfoNullable\tstructureEditor.defaultValue\tgrid.columnComment\n1\t'=1+1\tVARCHAR(20)\tNO\t' +SUM(1,1)\t" + '"\'\tCMD"'));
|
||||
});
|
||||
|
||||
it("opens the advanced-copy context menu and copies only after its action is chosen", async () => {
|
||||
connection.id = "mysql-copy-columns-context";
|
||||
mocks.getColumns.mockResolvedValue([
|
||||
{ name: "id", data_type: "INT", is_nullable: false, column_default: null, is_primary_key: true, extra: null, comment: null },
|
||||
{ name: "note", data_type: "TEXT", is_nullable: true, column_default: null, is_primary_key: false, extra: null, comment: "memo" },
|
||||
{ name: "status", data_type: "TEXT", is_nullable: true, column_default: "new", is_primary_key: false, extra: null, comment: null },
|
||||
]);
|
||||
const host = await mountBrowser();
|
||||
const panel = await openColumnInfo(host, 3);
|
||||
const columns = [...panel.querySelectorAll<HTMLElement>("[data-table-info-column]")];
|
||||
columns[0]!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
columns[1]!.dispatchEvent(new MouseEvent("click", { bubbles: true, ctrlKey: true }));
|
||||
columns[1]!.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 12, clientY: 14 }));
|
||||
expect(mocks.copyToClipboard).not.toHaveBeenCalled();
|
||||
const action = await vi.waitFor(() => {
|
||||
const button = [...document.querySelectorAll<HTMLButtonElement>("[data-dbx-context-menu] button")].find((item) => item.textContent?.includes("contextMenu.copyAdvancedInfo"));
|
||||
expect(button).toBeDefined();
|
||||
return button!;
|
||||
});
|
||||
action.click();
|
||||
await vi.waitFor(() => expect(mocks.copyToClipboard).toHaveBeenCalledWith("#\tgrid.columnName\tgrid.columnType\tgrid.tableInfoNullable\tstructureEditor.defaultValue\tgrid.columnComment\n1\tid\tINT\tNO\t—\t\n2\tnote\tTEXT\tYES\t—\tmemo"));
|
||||
columns[2]!.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 12, clientY: 14 }));
|
||||
expect(mocks.copyToClipboard).toHaveBeenCalledTimes(1);
|
||||
const secondAction = await vi.waitFor(() => {
|
||||
const button = [...document.querySelectorAll<HTMLButtonElement>("[data-dbx-context-menu] button")].find((item) => item.textContent?.includes("contextMenu.copyAdvancedInfo"));
|
||||
expect(button).toBeDefined();
|
||||
return button!;
|
||||
});
|
||||
secondAction.click();
|
||||
await vi.waitFor(() => expect(mocks.copyToClipboard).toHaveBeenLastCalledWith("#\tgrid.columnName\tgrid.columnType\tgrid.tableInfoNullable\tstructureEditor.defaultValue\tgrid.columnComment\n3\tstatus\tTEXT\tYES\tnew\t"));
|
||||
});
|
||||
|
||||
it("clears the column selection when switching tables or closing the table-info panel", async () => {
|
||||
connection.id = "mysql-copy-columns-reset";
|
||||
mocks.getColumns.mockResolvedValue([
|
||||
{ name: "id", data_type: "INT", is_nullable: false, column_default: null, is_primary_key: true, extra: null, comment: null },
|
||||
{ name: "name", data_type: "TEXT", is_nullable: true, column_default: null, is_primary_key: false, extra: null, comment: null },
|
||||
]);
|
||||
const host = await mountBrowser();
|
||||
let panel = await openColumnInfo(host);
|
||||
panel.querySelector<HTMLElement>('[data-table-info-column="id"]')!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
|
||||
rowFor(host, "users").dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await vi.waitFor(() => expect(host.querySelector("[data-object-table-info-panel]")).not.toBeNull());
|
||||
panel = host.querySelector<HTMLElement>("[data-object-table-info-panel]")!;
|
||||
const columnsTab = [...panel.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.includes("grid.tableInfoColumns"));
|
||||
columnsTab!.click();
|
||||
await vi.waitFor(() => expect(panel.querySelectorAll("[data-table-info-column]")).toHaveLength(2));
|
||||
const id = panel.querySelector<HTMLElement>('[data-table-info-column="id"]')!;
|
||||
const switchShortcut = new KeyboardEvent("keydown", { key: "c", ctrlKey: true, bubbles: true, cancelable: true });
|
||||
id.dispatchEvent(switchShortcut);
|
||||
expect(mocks.copyToClipboard).not.toHaveBeenCalled();
|
||||
expect(switchShortcut.defaultPrevented).toBe(false);
|
||||
|
||||
[...panel.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.classList.contains("h-5"))!.click();
|
||||
await vi.waitFor(() => expect(host.querySelector("[data-object-table-info-panel]")).toBeNull());
|
||||
rowFor(host, "users").dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await vi.waitFor(() => expect(host.querySelector("[data-object-table-info-panel]")).not.toBeNull());
|
||||
panel = await openColumnInfo(host);
|
||||
panel.querySelector<HTMLElement>('[data-table-info-column="id"]')!.dispatchEvent(new KeyboardEvent("keydown", { key: "c", ctrlKey: true, bubbles: true }));
|
||||
expect(mocks.copyToClipboard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears hidden column selection when the table-info filter changes", async () => {
|
||||
connection.id = "mysql-copy-columns-filter-reset";
|
||||
mocks.getColumns.mockResolvedValue([
|
||||
{ name: "id", data_type: "INT", is_nullable: false, column_default: null, is_primary_key: true, extra: null, comment: null },
|
||||
{ name: "name", data_type: "TEXT", is_nullable: true, column_default: null, is_primary_key: false, extra: null, comment: null },
|
||||
]);
|
||||
const host = await mountBrowser();
|
||||
const panel = await openColumnInfo(host);
|
||||
panel.querySelector<HTMLElement>('[data-table-info-column="id"]')!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
const search = panel.querySelector<HTMLInputElement>("[data-table-info-search]")!;
|
||||
search.value = "name";
|
||||
search.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await vi.waitFor(() => expect(panel.querySelectorAll("[data-table-info-column]")).toHaveLength(1));
|
||||
search.value = "";
|
||||
search.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await vi.waitFor(() => expect(panel.querySelectorAll("[data-table-info-column]")).toHaveLength(2));
|
||||
const name = panel.querySelector<HTMLElement>('[data-table-info-column="name"]')!;
|
||||
const filterShortcut = new KeyboardEvent("keydown", { key: "c", ctrlKey: true, bubbles: true, cancelable: true });
|
||||
name.dispatchEvent(filterShortcut);
|
||||
expect(mocks.copyToClipboard).not.toHaveBeenCalled();
|
||||
expect(filterShortcut.defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it("writes the selected table names to the system clipboard on Ctrl+C", async () => {
|
||||
const host = await mountBrowser();
|
||||
rowFor(host, "orders").dispatchEvent(new MouseEvent("click", { bubbles: true, ctrlKey: true }));
|
||||
|
||||
@@ -3643,6 +3643,7 @@ export default withEnglishFallback({
|
||||
closeAllRegularTabs: "Bütün adi vərəqləri bağla",
|
||||
closeAllFixedTabs: "Bütün sabit vərəqləri bağla",
|
||||
copyName: "Adı kopyala",
|
||||
copyAdvancedInfo: "Təkmil məlumatı kopyala",
|
||||
name: "Ad",
|
||||
fullPath: "Tam yol",
|
||||
copyFinalProxyPort: "Son proksi portunu kopyala",
|
||||
|
||||
@@ -4562,6 +4562,7 @@ export default {
|
||||
closeAllRegularTabs: "Close All Regular Tabs",
|
||||
closeAllFixedTabs: "Close All Fixed Tabs",
|
||||
copyName: "Copy Name",
|
||||
copyAdvancedInfo: "Copy Advanced Info",
|
||||
name: "Name",
|
||||
fullPath: "Full Path",
|
||||
copyFinalProxyPort: "Copy Final Proxy Port",
|
||||
|
||||
@@ -4319,6 +4319,7 @@ export default withEnglishFallback({
|
||||
closeAllRegularTabs: "Cerrar todas las pestañas normales",
|
||||
closeAllFixedTabs: "Cerrar todas las pestañas fijadas",
|
||||
copyName: "Copiar nombre",
|
||||
copyAdvancedInfo: "Copiar información avanzada",
|
||||
name: "Nombre",
|
||||
fullPath: "Ruta completa",
|
||||
copyFinalProxyPort: "Copiar puerto proxy final",
|
||||
|
||||
@@ -3963,6 +3963,7 @@ export default withEnglishFallback({
|
||||
closeAllRegularTabs: "Tutup Semua Tab Reguler",
|
||||
closeAllFixedTabs: "Tutup Semua Tab Tetap",
|
||||
copyName: "Salin Nama",
|
||||
copyAdvancedInfo: "Salin info lanjutan",
|
||||
name: "Nama",
|
||||
fullPath: "Path Lengkap",
|
||||
copyFinalProxyPort: "Salin Port Proxy Akhir",
|
||||
|
||||
@@ -4163,6 +4163,7 @@ export default withEnglishFallback({
|
||||
closeAllRegularTabs: "Chiudi Tutte le Schede Normali",
|
||||
closeAllFixedTabs: "Chiudi Tutte le Schede Fisse",
|
||||
copyName: "Copia Nome",
|
||||
copyAdvancedInfo: "Copia informazioni avanzate",
|
||||
name: "Nome",
|
||||
fullPath: "Percorso completo",
|
||||
copyFinalProxyPort: "Copia porta proxy finale",
|
||||
|
||||
@@ -4184,6 +4184,7 @@ export default withEnglishFallback({
|
||||
closeAllRegularTabs: "すべての通常タブを閉じる",
|
||||
closeAllFixedTabs: "すべての固定タブを閉じる",
|
||||
copyName: "名前をコピー",
|
||||
copyAdvancedInfo: "詳細情報をコピー",
|
||||
name: "名前",
|
||||
fullPath: "フルパス",
|
||||
copyFinalProxyPort: "最終プロキシポートをコピー",
|
||||
|
||||
@@ -4110,6 +4110,7 @@ export default withEnglishFallback({
|
||||
closeAllRegularTabs: "모든 일반 탭 닫기",
|
||||
closeAllFixedTabs: "모든 고정 탭 닫기",
|
||||
copyName: "이름 복사",
|
||||
copyAdvancedInfo: "고급 정보 복사",
|
||||
name: "이름",
|
||||
fullPath: "전체 경로",
|
||||
copyFinalProxyPort: "최종 프록시 포트 복사",
|
||||
|
||||
@@ -4031,6 +4031,7 @@ export default withEnglishFallback({
|
||||
closeAllRegularTabs: "Zamknij wszystkie zwykłe karty",
|
||||
closeAllFixedTabs: "Zamknij wszystkie przypięte karty",
|
||||
copyName: "Kopiuj nazwę",
|
||||
copyAdvancedInfo: "Kopiuj informacje zaawansowane",
|
||||
name: "Nazwa",
|
||||
fullPath: "Pełna ścieżka",
|
||||
copyFinalProxyPort: "Kopiuj końcowy port proxy",
|
||||
|
||||
@@ -4165,6 +4165,7 @@ export default withEnglishFallback({
|
||||
copyFinalProxyPort: "Copiar porta final do proxy",
|
||||
finalProxyPortCopied: "Porta final do proxy {port} copiada",
|
||||
copyName: "Copiar Nome",
|
||||
copyAdvancedInfo: "Copiar informações avançadas",
|
||||
name: "Nome",
|
||||
fullPath: "Caminho completo",
|
||||
setDefaultDatabase: "Definir como Banco de Dados Padrão",
|
||||
|
||||
@@ -4273,6 +4273,7 @@ export default withEnglishFallback({
|
||||
closeAllRegularTabs: "Закрыть все обычные вкладки",
|
||||
closeAllFixedTabs: "Закрыть все закреплённые вкладки",
|
||||
copyName: "Копировать имя",
|
||||
copyAdvancedInfo: "Копировать расширенную информацию",
|
||||
name: "Имя",
|
||||
fullPath: "Полный путь",
|
||||
copyFinalProxyPort: "Копировать итоговый порт прокси",
|
||||
|
||||
@@ -4484,6 +4484,7 @@ export default withEnglishFallback({
|
||||
closeAllRegularTabs: "ปิดแท็บปกติทั้งหมด",
|
||||
closeAllFixedTabs: "ปิดแท็บถาวรทั้งหมด",
|
||||
copyName: "คัดลอกชื่อ",
|
||||
copyAdvancedInfo: "คัดลอกข้อมูลขั้นสูง",
|
||||
name: "ชื่อ",
|
||||
fullPath: "Path เต็ม",
|
||||
copyFinalProxyPort: "คัดลอก Final Proxy Port",
|
||||
|
||||
@@ -3613,6 +3613,7 @@ export default withEnglishFallback({
|
||||
closeAllRegularTabs: "Tüm Normal Sekmeleri Kapat",
|
||||
closeAllFixedTabs: "Tüm Sabit Sekmeleri Kapat",
|
||||
copyName: "Adı Kopyala",
|
||||
copyAdvancedInfo: "Gelişmiş bilgileri kopyala",
|
||||
name: "Ad",
|
||||
fullPath: "Tam Yol",
|
||||
copyFinalProxyPort: "Son Proxy Portunu Kopyala",
|
||||
|
||||
@@ -4491,6 +4491,7 @@ export default withEnglishFallback({
|
||||
closeAllRegularTabs: "关闭全部普通标签页",
|
||||
closeAllFixedTabs: "关闭全部固定标签页",
|
||||
copyName: "复制名称",
|
||||
copyAdvancedInfo: "复制高级信息",
|
||||
name: "名称",
|
||||
fullPath: "完整路径",
|
||||
copyFinalProxyPort: "复制最终代理端口",
|
||||
|
||||
@@ -4161,6 +4161,7 @@ export default withEnglishFallback({
|
||||
closeAllRegularTabs: "關閉全部普通分頁",
|
||||
closeAllFixedTabs: "關閉全部固定分頁",
|
||||
copyName: "複製名稱",
|
||||
copyAdvancedInfo: "複製進階資訊",
|
||||
name: "名稱",
|
||||
fullPath: "完整路徑",
|
||||
copyFinalProxyPort: "複製最終代理連接埠",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
export function encodeSpreadsheetTsvCell(value: string | undefined): string {
|
||||
if (value === undefined) return "";
|
||||
const guarded = guardSpreadsheetFormula(value);
|
||||
return /[\t\r\n"]/.test(guarded) ? `"${guarded.replace(/"/g, '""')}"` : guarded;
|
||||
}
|
||||
|
||||
function guardSpreadsheetFormula(value: string): string {
|
||||
const candidate = value.replace(/^ +/, "");
|
||||
if (candidate.startsWith("-") && isExactSpreadsheetDecimal(candidate.slice(1))) return value;
|
||||
if (candidate.startsWith("@") && (candidate.length === 1 || candidate.charCodeAt(1) > 0x7f)) return value;
|
||||
return /^[=+\-@\t\r]/.test(candidate) ? `'${value}` : value;
|
||||
}
|
||||
|
||||
function isExactSpreadsheetDecimal(value: string): boolean {
|
||||
if (!/^\d+(?:\.\d+)?$/.test(value)) return false;
|
||||
const significant = value.replace(".", "").replace(/^0+/, "");
|
||||
return significant.length <= 15;
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
@@ -0,0 +1,9 @@
|
||||
# 复制表字段信息
|
||||
|
||||
在对象浏览器中选择表,在右侧详情面板打开“字段”页签。单击选择字段;按住 Ctrl/⌘ 多选,按住 Shift 选择连续范围。
|
||||
|
||||
- `Ctrl+C`:按显示顺序复制所选字段名,每行一个。
|
||||
- `Ctrl+Shift+C`:复制完整字段信息为 TSV,包含 `#`、列名、类型、可空、默认值和注释;注释独立为一列,含 Tab、换行或双引号的单元格会按 TSV 规则转义。
|
||||
- 右键所选字段并选择“复制高级信息”:执行与 `Ctrl+Shift+C` 相同的复制。
|
||||
|
||||

|
||||
Reference in new issue
Block a user