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:
efeire authored and GitHub committed 2026-10-20 10:23:01 +08:00
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>
@@ -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 }));
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -4184,6 +4184,7 @@ export default withEnglishFallback({
closeAllRegularTabs: "すべての通常タブを閉じる",
closeAllFixedTabs: "すべての固定タブを閉じる",
copyName: "名前をコピー",
copyAdvancedInfo: "詳細情報をコピー",
name: "名前",
fullPath: "フルパス",
copyFinalProxyPort: "最終プロキシポートをコピー",
+1
View File
@@ -4110,6 +4110,7 @@ export default withEnglishFallback({
closeAllRegularTabs: "모든 일반 탭 닫기",
closeAllFixedTabs: "모든 고정 탭 닫기",
copyName: "이름 복사",
copyAdvancedInfo: "고급 정보 복사",
name: "이름",
fullPath: "전체 경로",
copyFinalProxyPort: "최종 프록시 포트 복사",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -4273,6 +4273,7 @@ export default withEnglishFallback({
closeAllRegularTabs: "Закрыть все обычные вкладки",
closeAllFixedTabs: "Закрыть все закреплённые вкладки",
copyName: "Копировать имя",
copyAdvancedInfo: "Копировать расширенную информацию",
name: "Имя",
fullPath: "Полный путь",
copyFinalProxyPort: "Копировать итоговый порт прокси",
+1
View File
@@ -4484,6 +4484,7 @@ export default withEnglishFallback({
closeAllRegularTabs: "ปิดแท็บปกติทั้งหมด",
closeAllFixedTabs: "ปิดแท็บถาวรทั้งหมด",
copyName: "คัดลอกชื่อ",
copyAdvancedInfo: "คัดลอกข้อมูลขั้นสูง",
name: "ชื่อ",
fullPath: "Path เต็ม",
copyFinalProxyPort: "คัดลอก Final Proxy Port",
+1
View File
@@ -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",
+1
View File
@@ -4491,6 +4491,7 @@ export default withEnglishFallback({
closeAllRegularTabs: "关闭全部普通标签页",
closeAllFixedTabs: "关闭全部固定标签页",
copyName: "复制名称",
copyAdvancedInfo: "复制高级信息",
name: "名称",
fullPath: "完整路径",
copyFinalProxyPort: "复制最终代理端口",
+1
View File
@@ -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

+9
View File
@@ -0,0 +1,9 @@
# 复制表字段信息
在对象浏览器中选择表,在右侧详情面板打开“字段”页签。单击选择字段;按住 Ctrl/⌘ 多选,按住 Shift 选择连续范围。
- `Ctrl+C`:按显示顺序复制所选字段名,每行一个。
- `Ctrl+Shift+C`:复制完整字段信息为 TSV,包含 `#`、列名、类型、可空、默认值和注释;注释独立为一列,含 Tab、换行或双引号的单元格会按 TSV 规则转义。
- 右键所选字段并选择“复制高级信息”:执行与 `Ctrl+Shift+C` 相同的复制。
![对象浏览器中的字段多选与“复制高级信息”菜单](images/table-column-copy.jpg)