feat(desktop): copy table structure as documents

This commit is contained in:
t8y2 committed 2026-06-02 01:45:20 +08:00
1 parent 6b4a8df4eb
commit ec5aa7a92b
12 files changed
+539 -1

No files matched your search

@@ -55,7 +55,7 @@ import { useSavedSqlStore } from "@/stores/savedSqlStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { useToast } from "@/composables/useToast";
import { useDatabaseOptions } from "@/composables/useDatabaseOptions";
import type { DatabaseType, TreeNode, TreeNodeType } from "@/types/database";
import type { ColumnInfo, DatabaseType, TreeNode, TreeNodeType } from "@/types/database";
import * as api from "@/lib/api";
import { uuid } from "@/lib/utils";
import { resolveDefaultDatabase } from "@/lib/defaultDatabase";
@@ -150,6 +150,8 @@ const savedSqlStore = useSavedSqlStore();
const settingsStore = useSettingsStore();
const { toast } = useToast();
const { highlight } = useSqlHighlighter();
type StructureCopyFormat = "tsv" | "markdown";
const { getDatabaseOptions } = useDatabaseOptions();
const showVisibleDatabasesDialog = ref(false);
@@ -795,10 +797,13 @@ const showDropTableConfirm = ref(false);
const showDropTableChildObjectConfirm = ref(false);
const showBatchDropConfirm = ref(false);
const showStructurePreviewDialog = ref(false);
const showStructureDocCopyDialog = ref(false);
const structurePreviewSql = ref("");
const structurePreviewTitle = ref("");
const structurePreviewDefaultFileName = ref("structure.sql");
const structurePreviewError = ref("");
const structureDocCopyText = ref("");
const structureDocCopyTitle = ref("");
const isLoadingStructurePreview = ref(false);
const showEmptyTableConfirm = ref(false);
const showTruncateTableConfirm = ref(false);
@@ -1741,6 +1746,124 @@ function structureExportTargets(): Array<TreeNode & { connectionId: string; data
return selected.some((node) => node.id === props.node.id) ? selected : [props.node];
}
function structureTargetName(target: TreeNode): string {
return target.schema ? `${target.schema}.${target.label}` : target.label;
}
function columnDocValue(value: unknown): string {
return value === null || value === undefined ? "" : String(value);
}
function tsvCell(value: unknown): string {
return columnDocValue(value).replace(/\t/g, " ").replace(/\r?\n/g, " ").trim();
}
function markdownCell(value: unknown): string {
return columnDocValue(value).replace(/\|/g, "\\|").replace(/\r?\n/g, "<br>").trim();
}
function columnDocHeaders(includeTable: boolean): string[] {
const headers = [
t("contextMenu.structureDocColumn"),
t("contextMenu.structureDocType"),
t("contextMenu.structureDocPrimaryKey"),
t("contextMenu.structureDocNullable"),
t("contextMenu.structureDocDefault"),
t("contextMenu.structureDocComment"),
];
return includeTable ? [t("contextMenu.structureDocTable"), ...headers] : headers;
}
function columnDocCells(target: TreeNode, column: ColumnInfo, includeTable: boolean): unknown[] {
const cells = [
column.name,
column.data_type,
column.is_primary_key ? t("contextMenu.structureDocYes") : t("contextMenu.structureDocNo"),
column.is_nullable ? t("contextMenu.structureDocYes") : t("contextMenu.structureDocNo"),
column.column_default,
column.comment,
];
return includeTable ? [structureTargetName(target), ...cells] : cells;
}
async function tableColumnsForStructureCopy(
target: TreeNode & { connectionId: string; database: string },
): Promise<ColumnInfo[]> {
await connectionStore.ensureConnected(target.connectionId);
return (await api.getColumns(
target.connectionId,
target.database,
target.schema || target.database,
target.label,
)) as ColumnInfo[];
}
async function buildStructureCopyText(format: StructureCopyFormat): Promise<string> {
const targets = structureExportTargets();
if (!targets.length) return "";
const includeTable = targets.length > 1;
const headers = columnDocHeaders(includeTable);
if (format === "tsv") {
const lines = [headers.map(tsvCell).join("\t")];
for (const target of targets) {
const columns = await tableColumnsForStructureCopy(target);
for (const column of columns) {
lines.push(columnDocCells(target, column, includeTable).map(tsvCell).join("\t"));
}
}
return `${lines.join("\n")}\n`;
}
const tables: string[] = [];
const markdownHeaders = columnDocHeaders(false);
for (const target of targets) {
const columns = await tableColumnsForStructureCopy(target);
const tableLines = [
`### ${markdownCell(structureTargetName(target))}`,
"",
`| ${markdownHeaders.map(markdownCell).join(" | ")} |`,
`| ${markdownHeaders.map(() => "---").join(" | ")} |`,
...columns.map((column) => `| ${columnDocCells(target, column, false).map(markdownCell).join(" | ")} |`),
];
tables.push(tableLines.join("\n"));
}
return `${tables.join("\n\n")}\n`;
}
async function copyStructureAs(format: StructureCopyFormat) {
let text = "";
try {
text = await buildStructureCopyText(format);
if (!text) return;
await copyToClipboard(text);
toast(t("contextMenu.structureDocCopied"), 2000);
} catch (e: any) {
if (text) {
structureDocCopyText.value = text;
structureDocCopyTitle.value =
format === "tsv" ? t("contextMenu.copyStructureAsTsv") : t("contextMenu.copyStructureAsMarkdown");
showStructureDocCopyDialog.value = true;
return;
}
toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000);
}
}
async function copyStructureDocText() {
if (!structureDocCopyText.value) return;
try {
await copyToClipboard(structureDocCopyText.value);
toast(t("contextMenu.structureDocCopied"), 2000);
} catch (e: any) {
toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000);
}
}
function selectTextareaContent(event: FocusEvent) {
if (event.target instanceof HTMLTextAreaElement) event.target.select();
}
async function copyStructurePreview() {
if (!structurePreviewSql.value) return;
try {
@@ -2428,6 +2551,17 @@ function exportDataSubmenu(): ContextMenuItem {
};
}
function copyStructureAsSubmenu(): ContextMenuItem {
return {
label: t("contextMenu.copyStructureAs"),
icon: Clipboard,
children: [
{ label: t("contextMenu.copyStructureAsTsv"), action: () => copyStructureAs("tsv") },
{ label: t("contextMenu.copyStructureAsMarkdown"), action: () => copyStructureAs("markdown") },
],
};
}
function treeItemMenuItems(): ContextMenuItem[] {
const node = props.node;
const items: ContextMenuItem[] = [];
@@ -2629,6 +2763,7 @@ function treeItemMenuItems(): ContextMenuItem[] {
items.push(exportDataSubmenu());
items.push({ label: t("contextMenu.exportDatabase"), action: openDatabaseExport, icon: Download });
items.push({ label: t("contextMenu.exportStructure"), action: exportStructure, icon: FileCode });
items.push(copyStructureAsSubmenu());
if (isTableNotView.value) {
items.push({ label: "", separator: true });
items.push({ label: t("contextMenu.duplicateStructure"), action: duplicateStructure, icon: CopyPlus });
@@ -3037,6 +3172,30 @@ function treeItemMenuItems(): ContextMenuItem[] {
</DialogContent>
</Dialog>
<Dialog v-model:open="showStructureDocCopyDialog">
<DialogContent class="sm:max-w-[760px]">
<DialogHeader>
<DialogTitle>{{ structureDocCopyTitle || t("contextMenu.copyStructureAs") }}</DialogTitle>
</DialogHeader>
<div class="grid gap-3">
<p class="text-sm text-muted-foreground">{{ t("contextMenu.structureDocCopyFallbackHint") }}</p>
<textarea
readonly
class="max-h-[56vh] min-h-64 resize-y overflow-auto rounded bg-muted p-3 font-mono text-xs whitespace-pre"
:value="structureDocCopyText"
@focus="selectTextareaContent"
></textarea>
</div>
<DialogFooter>
<Button variant="outline" @click="showStructureDocCopyDialog = false">{{ t("dangerDialog.cancel") }}</Button>
<Button :disabled="!structureDocCopyText" @click="copyStructureDocText">
<Clipboard class="h-4 w-4" />
{{ t("contextMenu.copyStructure") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog v-model:open="showDeleteSavedSqlFileConfirm">
<DialogContent class="sm:max-w-[400px]">
<DialogHeader>
+14
View File
@@ -922,6 +922,20 @@ export default {
exportStructurePreviewTitleMultiple: "Structure - {count} objects",
exportStructureLoading: "Loading structure...",
copyStructure: "Copy Structure",
copyStructureAs: "Copy Structure As",
copyStructureAsTsv: "TSV (for Excel)",
copyStructureAsMarkdown: "Markdown Table",
structureDocTable: "Table",
structureDocColumn: "Column",
structureDocType: "Type",
structureDocPrimaryKey: "Primary Key",
structureDocNullable: "Nullable",
structureDocDefault: "Default",
structureDocComment: "Comment",
structureDocYes: "Yes",
structureDocNo: "No",
structureDocCopied: "Structure copied",
structureDocCopyFallbackHint: "Automatic copy was blocked. Click Copy again, or select the content below.",
saveStructure: "Save SQL",
exportStructureCopied: "Structure copied",
createTable: "Create Table",
+15
View File
@@ -818,6 +818,21 @@ export default {
exportStructurePreviewTitleMultiple: "Estructura - {count} objetos",
exportStructureLoading: "Cargando estructura...",
copyStructure: "Copiar estructura",
copyStructureAs: "Copiar estructura como",
copyStructureAsTsv: "TSV (para Excel)",
copyStructureAsMarkdown: "Tabla Markdown",
structureDocTable: "Tabla",
structureDocColumn: "Columna",
structureDocType: "Tipo",
structureDocPrimaryKey: "Clave primaria",
structureDocNullable: "Permite NULL",
structureDocDefault: "Valor predeterminado",
structureDocComment: "Comentario",
structureDocYes: "Sí",
structureDocNo: "No",
structureDocCopied: "Estructura copiada",
structureDocCopyFallbackHint:
"La copia automática fue bloqueada. Haz clic en Copiar de nuevo o selecciona el contenido inferior.",
saveStructure: "Guardar SQL",
exportStructureCopied: "Estructura copiada",
createTable: "Crear tabla",
+14
View File
@@ -903,6 +903,20 @@ export default {
exportStructurePreviewTitleMultiple: "表结构 - {count} 个对象",
exportStructureLoading: "正在读取表结构...",
copyStructure: "复制表结构",
copyStructureAs: "复制表结构为",
copyStructureAsTsv: "TSV(粘贴到 Excel)",
copyStructureAsMarkdown: "Markdown 表格",
structureDocTable: "表",
structureDocColumn: "字段名称",
structureDocType: "类型",
structureDocPrimaryKey: "主键",
structureDocNullable: "允许为空",
structureDocDefault: "默认值",
structureDocComment: "注释",
structureDocYes: "是",
structureDocNo: "否",
structureDocCopied: "表结构已复制",
structureDocCopyFallbackHint: "自动复制被系统拦截。请再次点击复制,或选中下方内容手动复制。",
saveStructure: "保存 SQL",
exportStructureCopied: "表结构已复制",
createTable: "新建表",
+14
View File
@@ -903,6 +903,20 @@ export default {
exportStructurePreviewTitleMultiple: "資料表結構 - {count} 個物件",
exportStructureLoading: "正在讀取資料表結構...",
copyStructure: "複製資料表結構",
copyStructureAs: "複製資料表結構為",
copyStructureAsTsv: "TSV(貼到 Excel)",
copyStructureAsMarkdown: "Markdown 表格",
structureDocTable: "資料表",
structureDocColumn: "欄位名稱",
structureDocType: "類型",
structureDocPrimaryKey: "主鍵",
structureDocNullable: "允許空值",
structureDocDefault: "預設值",
structureDocComment: "註解",
structureDocYes: "是",
structureDocNo: "否",
structureDocCopied: "資料表結構已複製",
structureDocCopyFallbackHint: "自動複製被系統阻擋。請再次點擊複製,或選取下方內容手動複製。",
saveStructure: "儲存 SQL",
exportStructureCopied: "資料表結構已複製",
createTable: "建立資料表",
+14
View File
@@ -1,3 +1,5 @@
import { isTauriRuntime } from "./tauriRuntime";
interface ClipboardApi {
writeText?: (text: string) => Promise<void> | void;
}
@@ -15,6 +17,7 @@ interface ClipboardTextarea {
opacity?: string;
};
setAttribute(name: string, value: string): void;
focus?(): void;
select(): void;
setSelectionRange?(start: number, end: number): void;
}
@@ -37,6 +40,16 @@ export async function copyToClipboard(
text: string,
env: ClipboardEnvironment = globalThis as unknown as ClipboardEnvironment,
): Promise<void> {
if (isTauriRuntime(env as unknown as Record<string, unknown>)) {
try {
const { writeText } = await import("@tauri-apps/plugin-clipboard-manager");
await writeText(text);
return;
} catch {
// Fall through to Web Clipboard / legacy copy when the native plugin is unavailable.
}
}
try {
if (env.navigator?.clipboard?.writeText) {
await env.navigator.clipboard.writeText(text);
@@ -61,6 +74,7 @@ export async function copyToClipboard(
document.body.appendChild(textarea);
try {
textarea.focus?.();
textarea.select();
textarea.setSelectionRange?.(0, text.length);
if (!document.execCommand("copy")) {