diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index 7b1bf20807..8221ba8194 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -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([]); +watch(tableInfoSearchQuery, () => { + selectedTableInfoColumns.value = []; +}); const tableInfoDdlPreRef = ref(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[] { -
+
@@ -5224,23 +5291,33 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] { - - {{ index + 1 }} - - - - {{ column.name }} - -
- {{ column.comment }} -
- - {{ gaussdbMColumnType(column.data_type) }} - {{ column.is_nullable ? "YES" : "NO" }} - - {{ tableColumnDefaultDisplayValue(column.column_default) }} - - + + + {{ index + 1 }} + + + + {{ column.name }} + +
+ {{ column.comment }} +
+ + {{ gaussdbMColumnType(column.data_type) }} + {{ column.is_nullable ? "YES" : "NO" }} + + {{ tableColumnDefaultDisplayValue(column.column_default) }} + + +
diff --git a/apps/desktop/src/components/objects/__tests__/ObjectBrowser.tableCopyClipboard.component.spec.ts b/apps/desktop/src/components/objects/__tests__/ObjectBrowser.tableCopyClipboard.component.spec.ts index c617c88e7a..b1e6fcd036 100644 --- a/apps/desktop/src/components/objects/__tests__/ObjectBrowser.tableCopyClipboard.component.spec.ts +++ b/apps/desktop/src/components/objects/__tests__/ObjectBrowser.tableCopyClipboard.component.spec.ts @@ -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("[data-object-table-info-panel]")!; + const columnsTab = [...panel.querySelectorAll("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("[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("[data-table-info-columns]")!; + columnsArea.focus(); + const search = panel.querySelector("[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("[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("[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("[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("[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('[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("[data-object-table-info-panel]")!; + const columnsTab = [...panel.querySelectorAll("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('[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("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('[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('[data-table-info-column="id"]')!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + const search = panel.querySelector("[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('[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 })); diff --git a/apps/desktop/src/i18n/locales/az.ts b/apps/desktop/src/i18n/locales/az.ts index 847bccf69d..0d1bb8ac90 100644 --- a/apps/desktop/src/i18n/locales/az.ts +++ b/apps/desktop/src/i18n/locales/az.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index a7d775d068..9218c57315 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 559bc11568..376c735b67 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/id.ts b/apps/desktop/src/i18n/locales/id.ts index eb433c7768..bacbc48499 100644 --- a/apps/desktop/src/i18n/locales/id.ts +++ b/apps/desktop/src/i18n/locales/id.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 988d8b550f..677336022a 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index b0c9dc314d..2e8dc0b4ff 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -4184,6 +4184,7 @@ export default withEnglishFallback({ closeAllRegularTabs: "すべての通常タブを閉じる", closeAllFixedTabs: "すべての固定タブを閉じる", copyName: "名前をコピー", + copyAdvancedInfo: "詳細情報をコピー", name: "名前", fullPath: "フルパス", copyFinalProxyPort: "最終プロキシポートをコピー", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 95faecc4a1..a8ebb23bb6 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -4110,6 +4110,7 @@ export default withEnglishFallback({ closeAllRegularTabs: "모든 일반 탭 닫기", closeAllFixedTabs: "모든 고정 탭 닫기", copyName: "이름 복사", + copyAdvancedInfo: "고급 정보 복사", name: "이름", fullPath: "전체 경로", copyFinalProxyPort: "최종 프록시 포트 복사", diff --git a/apps/desktop/src/i18n/locales/pl.ts b/apps/desktop/src/i18n/locales/pl.ts index 49b0ea4c3e..1a916889fc 100644 --- a/apps/desktop/src/i18n/locales/pl.ts +++ b/apps/desktop/src/i18n/locales/pl.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 1bbe15f4aa..b5e3a0d8e7 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/ru.ts b/apps/desktop/src/i18n/locales/ru.ts index 66981da6f5..f860d64bb5 100644 --- a/apps/desktop/src/i18n/locales/ru.ts +++ b/apps/desktop/src/i18n/locales/ru.ts @@ -4273,6 +4273,7 @@ export default withEnglishFallback({ closeAllRegularTabs: "Закрыть все обычные вкладки", closeAllFixedTabs: "Закрыть все закреплённые вкладки", copyName: "Копировать имя", + copyAdvancedInfo: "Копировать расширенную информацию", name: "Имя", fullPath: "Полный путь", copyFinalProxyPort: "Копировать итоговый порт прокси", diff --git a/apps/desktop/src/i18n/locales/th.ts b/apps/desktop/src/i18n/locales/th.ts index c7720a4575..79f34e178a 100644 --- a/apps/desktop/src/i18n/locales/th.ts +++ b/apps/desktop/src/i18n/locales/th.ts @@ -4484,6 +4484,7 @@ export default withEnglishFallback({ closeAllRegularTabs: "ปิดแท็บปกติทั้งหมด", closeAllFixedTabs: "ปิดแท็บถาวรทั้งหมด", copyName: "คัดลอกชื่อ", + copyAdvancedInfo: "คัดลอกข้อมูลขั้นสูง", name: "ชื่อ", fullPath: "Path เต็ม", copyFinalProxyPort: "คัดลอก Final Proxy Port", diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts index 3cb93d7ce8..856d1e92ac 100644 --- a/apps/desktop/src/i18n/locales/tr.ts +++ b/apps/desktop/src/i18n/locales/tr.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 8e4f6fb369..cb1ee3e5ab 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -4491,6 +4491,7 @@ export default withEnglishFallback({ closeAllRegularTabs: "关闭全部普通标签页", closeAllFixedTabs: "关闭全部固定标签页", copyName: "复制名称", + copyAdvancedInfo: "复制高级信息", name: "名称", fullPath: "完整路径", copyFinalProxyPort: "复制最终代理端口", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 81a6cee06b..a467438049 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -4161,6 +4161,7 @@ export default withEnglishFallback({ closeAllRegularTabs: "關閉全部普通分頁", closeAllFixedTabs: "關閉全部固定分頁", copyName: "複製名稱", + copyAdvancedInfo: "複製進階資訊", name: "名稱", fullPath: "完整路徑", copyFinalProxyPort: "複製最終代理連接埠", diff --git a/apps/desktop/src/lib/common/spreadsheetTsv.ts b/apps/desktop/src/lib/common/spreadsheetTsv.ts new file mode 100644 index 0000000000..65869d9a01 --- /dev/null +++ b/apps/desktop/src/lib/common/spreadsheetTsv.ts @@ -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; +} diff --git a/docs/images/table-column-copy.jpg b/docs/images/table-column-copy.jpg new file mode 100644 index 0000000000..96ab024c06 Binary files /dev/null and b/docs/images/table-column-copy.jpg differ diff --git a/docs/table-column-copy.md b/docs/table-column-copy.md new file mode 100644 index 0000000000..90a4570bb1 --- /dev/null +++ b/docs/table-column-copy.md @@ -0,0 +1,9 @@ +# 复制表字段信息 + +在对象浏览器中选择表,在右侧详情面板打开“字段”页签。单击选择字段;按住 Ctrl/⌘ 多选,按住 Shift 选择连续范围。 + +- `Ctrl+C`:按显示顺序复制所选字段名,每行一个。 +- `Ctrl+Shift+C`:复制完整字段信息为 TSV,包含 `#`、列名、类型、可空、默认值和注释;注释独立为一列,含 Tab、换行或双引号的单元格会按 TSV 规则转义。 +- 右键所选字段并选择“复制高级信息”:执行与 `Ctrl+Shift+C` 相同的复制。 + +![对象浏览器中的字段多选与“复制高级信息”菜单](images/table-column-copy.jpg)