From 19f36557b9fbc10803ee835ced541d4f59df44d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E4=B8=AB=E8=AE=B2=E6=A2=B5?= Date: Wed, 23 Sep 2026 10:44:40 +0800 Subject: [PATCH] feat(sidebar): show column nullability --- .../src/components/sidebar/ConnectionTree.vue | 11 +- .../src/components/sidebar/TreeItem.vue | 14 +- .../ConnectionTree.commentAlignment.spec.ts | 169 ++++++++++++++++++ apps/desktop/src/i18n/locales/en.ts | 1 + apps/desktop/src/i18n/locales/es.ts | 1 + apps/desktop/src/i18n/locales/it.ts | 1 + apps/desktop/src/i18n/locales/ja.ts | 1 + apps/desktop/src/i18n/locales/ko.ts | 1 + apps/desktop/src/i18n/locales/pt-BR.ts | 1 + apps/desktop/src/i18n/locales/zh-CN.ts | 1 + apps/desktop/src/i18n/locales/zh-TW.ts | 1 + 11 files changed, 197 insertions(+), 5 deletions(-) create mode 100644 apps/desktop/src/components/sidebar/__tests__/ConnectionTree.commentAlignment.spec.ts diff --git a/apps/desktop/src/components/sidebar/ConnectionTree.vue b/apps/desktop/src/components/sidebar/ConnectionTree.vue index 36932ed8bd..545f6ed1ba 100644 --- a/apps/desktop/src/components/sidebar/ConnectionTree.vue +++ b/apps/desktop/src/components/sidebar/ConnectionTree.vue @@ -7,7 +7,7 @@ import { useQueryStore } from "@/stores/queryStore"; import { useSavedSqlStore } from "@/stores/savedSqlStore"; import { useSettingsStore } from "@/stores/settingsStore"; import { useToast } from "@/composables/useToast"; -import type { ObjectSourceKind, QueryTab, TableInfo, TableNameFilter, TreeNode, TreeNodeType } from "@/types/database"; +import type { ColumnInfo, ObjectSourceKind, QueryTab, TableInfo, TableNameFilter, TreeNode, TreeNodeType } from "@/types/database"; import type { ElasticsearchIndexMetadataKind } from "@/lib/backend/tauri"; import { filterLocallySearchedTables, @@ -94,7 +94,7 @@ import { createSidebarLayoutMonitor, type SidebarExpandedConnectionInfo } from " import { disconnectSidebarConnections } from "@/lib/sidebar/sidebarConnectionDisconnect"; import { compileSearchRegex } from "@/lib/common/searchPattern"; -const { t } = useI18n(); +const { t, locale } = useI18n(); const store = useConnectionStore(); const queryStore = useQueryStore(); const savedSqlStore = useSavedSqlStore(); @@ -884,6 +884,10 @@ function measureSidebarCommentLabelWidths() { const context = document.createElement("canvas").getContext("2d"); if (!context) return; const style = window.getComputedStyle(rootRef.value); + context.font = `${style.fontWeight} 10px ${style.fontFamily}`; + const badgePaddingAndGapWidth = 16; + const nullableBadgeWidth = context.measureText(t("structureEditor.nullable")).width + badgePaddingAndGapWidth; + const notNullBadgeWidth = context.measureText(t("structureEditor.notNull")).width + badgePaddingAndGapWidth; context.font = style.font || `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`; sidebarCommentLabelWidths.value = alignedSidebarCommentLabelWidths( flatNodes.value.map(({ id, depth, node }) => ({ @@ -891,7 +895,7 @@ function measureSidebarCommentLabelWidths() { depth, alignable: isSidebarCommentAlignableNode(node), hasComment: !!sidebarTreeNodeComment(node, settingsStore.editorSettings.sidebarShowConnectionNotes), - labelWidth: context.measureText(sidebarCommentLabel(node)).width, + labelWidth: context.measureText(sidebarCommentLabel(node)).width + (node.type === "column" && node.meta ? ((node.meta as ColumnInfo).is_nullable ? nullableBadgeWidth : notNullBadgeWidth) : 0), })), ); } @@ -947,6 +951,7 @@ function scheduleSidebarTreeContentWidthMeasure() { watch( [ flatNodes, + locale, () => settingsStore.editorSettings.sidebarObjectInfoMode, () => settingsStore.editorSettings.sidebarShowConnectionNotes, () => settingsStore.editorSettings.sidebarHiddenTablePrefixes, diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index dd829c2e6e..412fa1fe97 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -638,10 +638,12 @@ const detailTooltip = computed(() => { ], }; } - const comment = node.type === "column" && node.meta && "comment" in node.meta ? (node.meta as ColumnInfo).comment : node.comment; - if (!comment || (node.type !== "schema" && node.type !== "table" && node.type !== "view" && node.type !== "column")) return null; + const column = node.type === "column" ? (node.meta as ColumnInfo | undefined) : undefined; + const comment = column && "comment" in column ? column.comment : node.comment; + if ((!comment && !column) || (node.type !== "schema" && node.type !== "table" && node.type !== "view" && node.type !== "column")) return null; const rows: DetailTooltipRow[] = [ { label: t("connection.name"), value: visibleLabel(node) }, + ...(column ? [{ label: t("structureEditor.nullable"), value: t(column.is_nullable ? "structureEditor.nullable" : "structureEditor.notNull") }] : []), { label: t("structureEditor.comment"), value: cleanTooltipValue(comment), multiline: true }, ].filter((row) => row.value); return { rows }; @@ -1695,6 +1697,14 @@ function onKeydown(event: KeyboardEvent) { ]" >{{ visibleLabel(node) }} + + {{ t((node.meta as ColumnInfo).is_nullable ? "structureEditor.nullable" : "structureEditor.notNull") }} + diff --git a/apps/desktop/src/components/sidebar/__tests__/ConnectionTree.commentAlignment.spec.ts b/apps/desktop/src/components/sidebar/__tests__/ConnectionTree.commentAlignment.spec.ts new file mode 100644 index 0000000000..3892363f03 --- /dev/null +++ b/apps/desktop/src/components/sidebar/__tests__/ConnectionTree.commentAlignment.spec.ts @@ -0,0 +1,169 @@ +import { readFileSync } from "node:fs"; +import ts from "typescript"; +import { parse } from "vue/compiler-sfc"; +import { nextTick, reactive, ref, shallowRef, watch } from "vue"; +import { describe, expect, it, vi } from "vitest"; +import en from "@/i18n/locales/en"; +import es from "@/i18n/locales/es"; +import itLocale from "@/i18n/locales/it"; +import ja from "@/i18n/locales/ja"; +import ko from "@/i18n/locales/ko"; +import ptBR from "@/i18n/locales/pt-BR"; +import zhCN from "@/i18n/locales/zh-CN"; +import zhTW from "@/i18n/locales/zh-TW"; +import { alignedSidebarCommentLabelWidths, isSidebarCommentAlignableNode, sidebarTreeNodeComment } from "@/lib/sidebar/sidebarTreeItemLayout"; +import type { ColumnInfo, TreeNode } from "@/types/database"; + +const source = parse(readFileSync(new URL("../ConnectionTree.vue", import.meta.url), "utf8")).descriptor.scriptSetup!.content; +const script = ts.createSourceFile("ConnectionTree.ts", source, ts.ScriptTarget.Latest, true); +const measureStatement = script.statements.find((statement) => ts.isFunctionDeclaration(statement) && statement.name?.text === "measureSidebarCommentLabelWidths")!.getText(script); +const watchStatement = script.statements + .find((statement) => ts.isExpressionStatement(statement) && ts.isCallExpression(statement.expression) && statement.expression.expression.getText(script) === "watch" && statement.expression.arguments[1]?.getText(script) === "scheduleSidebarCommentLabelMeasure")! + .getText(script); +const messages = { en, es, it: itLocale, ja, ko, "pt-BR": ptBR, "zh-CN": zhCN, "zh-TW": zhTW }; +type Locale = keyof typeof messages; + +function execute(code: string, bindings: Record) { + const compiled = ts.transpileModule(code, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText; + return new Function(...Object.keys(bindings), compiled)(...Object.values(bindings)); +} + +function textWidth(text: string, fontSize: number): number { + return [...text].reduce((width, character) => width + (character.charCodeAt(0) > 127 ? fontSize : fontSize / 2), 0); +} + +function column(label: string, nullable = true, comment?: string | null): TreeNode { + const meta: ColumnInfo = { name: label, data_type: "int", is_nullable: nullable, column_default: null, is_primary_key: false, extra: null, comment }; + return { id: label, label, type: "column", meta }; +} + +function fixture(nodes: TreeNode[], initialLocale: Locale = "en", mode = "comment-aligned") { + const locale = ref(initialLocale); + const measurements: Array<{ text: string; font: string }> = []; + const context = { + font: "", + measureText: vi.fn((text: string) => { + measurements.push({ text, font: context.font }); + return { width: textWidth(text, Number(context.font.match(/([\d.]+)px/)![1])) }; + }), + }; + const canvas = { getContext: vi.fn((): typeof context | null => context) }; + const scope = { + locale, + flatNodes: shallowRef(nodes.map((node) => ({ id: node.id, depth: 1, node }))), + settingsStore: reactive({ editorSettings: { sidebarObjectInfoMode: mode, sidebarShowConnectionNotes: false, sidebarHiddenTablePrefixes: [], uiFontFamily: "sans-serif", uiScale: 1, sidebarFontSize: 14 } }), + rootRef: { value: {} as object | undefined }, + sidebarCommentLabelWidths: shallowRef(new Map()), + sidebarCommentMeasureFrame: 0, + document: { createElement: vi.fn(() => canvas) } as { createElement: () => typeof canvas } | undefined, + window: { getComputedStyle: () => ({ font: "400 14px sans-serif", fontWeight: "400", fontSize: "14px", fontFamily: "sans-serif" }) }, + t: (key: string) => messages[locale.value].structureEditor[key === "structureEditor.nullable" ? "nullable" : "notNull"], + sidebarCommentLabel: (node: TreeNode) => node.label, + alignedSidebarCommentLabelWidths, + isSidebarCommentAlignableNode, + sidebarTreeNodeComment, + }; + const measure = () => execute(`${measureStatement}\nmeasureSidebarCommentLabelWidths();`, scope); + return { scope, measure, measurements, canvas }; +} + +describe("ConnectionTree comment alignment measurement", () => { + for (const locale of Object.keys(messages) as Locale[]) { + for (const nullable of [true, false]) { + it.each(["id", "long_column_name_with_type (varchar(255))"])(`${locale}, nullable=${nullable}: preserves the complete %s label beside its badge`, (label) => { + const { scope, measure, measurements } = fixture([column(label, nullable, "comment")], locale); + measure(); + const badge = messages[locale].structureEditor[nullable ? "nullable" : "notNull"]; + expect(scope.sidebarCommentLabelWidths.value.get(label)).toBe(textWidth(label, 14) + textWidth(badge, 10) + 8 + 8); + expect(measurements).toContainEqual({ text: label, font: "400 14px sans-serif" }); + expect(measurements).toContainEqual({ text: badge, font: "400 10px sans-serif" }); + }); + } + } + + it("aligns mixed badges to the widest complete sibling without crossing parent groups", () => { + const short = column("id", true, "short"); + const long = column("long_column_name", false, "long"); + const separate = column("other", false, "other"); + const { scope, measure } = fixture([], "zh-CN"); + scope.flatNodes.value = [ + { id: "first", depth: 0, node: { id: "first", label: "first", type: "group-columns" } }, + ...[short, long].map((node) => ({ id: node.id, depth: 1, node })), + { id: "second", depth: 0, node: { id: "second", label: "second", type: "group-columns" } }, + { id: separate.id, depth: 1, node: separate }, + ]; + measure(); + const expected = textWidth(long.label, 14) + textWidth(zhCN.structureEditor.notNull, 10) + 16; + expect(scope.sidebarCommentLabelWidths.value.get(short.id)).toBe(expected); + expect(scope.sidebarCommentLabelWidths.value.get(long.id)).toBe(expected); + expect(scope.sidebarCommentLabelWidths.value.get(separate.id)).toBe(textWidth(separate.label, 14) + textWidth(zhCN.structureEditor.notNull, 10) + 16); + }); + + it.each([undefined, null, ""])("keeps uncommented labels unconstrained for comment=%s while measuring sibling alignment", (comment) => { + const plain = column("long_uncommented_column", false, comment); + const commented = column("id", true, "comment"); + const { scope, measure } = fixture([plain, commented]); + measure(); + expect(scope.sidebarCommentLabelWidths.value.has(plain.id)).toBe(false); + expect(scope.sidebarCommentLabelWidths.value.get(commented.id)).toBe(textWidth(plain.label, 14) + textWidth(en.structureEditor.notNull, 10) + 16); + scope.flatNodes.value = [{ id: plain.id, depth: 1, node: plain }]; + measure(); + expect(scope.sidebarCommentLabelWidths.value.size).toBe(0); + }); + + it.each(["column", "schema", "table", "view", "materialized_view"] as const)("does not reserve a hidden badge for %s", (type) => { + const node: TreeNode = { id: "object", label: "object", type, comment: "comment" }; + if (type !== "column") node.meta = column("ignored").meta; + const { scope, measure } = fixture([node]); + measure(); + expect(scope.sidebarCommentLabelWidths.value.get(node.id)).toBe(textWidth(node.label, 14)); + }); + + it("keeps connection notes opt-in", () => { + const node: TreeNode = { id: "connection", label: "connection", type: "connection", comment: "note" }; + const { scope, measure } = fixture([node]); + measure(); + expect(scope.sidebarCommentLabelWidths.value.size).toBe(0); + scope.settingsStore.editorSettings.sidebarShowConnectionNotes = true; + measure(); + expect(scope.sidebarCommentLabelWidths.value.get(node.id)).toBe(textWidth(node.label, 14)); + }); + + it.each(["none", "size", "comment-inline", "comment-right"])("leaves %s layout unchanged and clears stale alignment", (mode) => { + const { scope, measure, measurements } = fixture([column("id", true, "comment")]); + measure(); + expect(scope.sidebarCommentLabelWidths.value.size).toBe(1); + measurements.length = 0; + scope.settingsStore.editorSettings.sidebarObjectInfoMode = mode; + measure(); + expect(scope.sidebarCommentLabelWidths.value.size).toBe(0); + expect(measurements).toEqual([]); + }); + + it.each(["document", "root", "canvas"])("preserves the unavailable %s measurement path", (missing) => { + const { scope, measure, canvas } = fixture([column("id", true, "comment")]); + scope.sidebarCommentLabelWidths.value.set("old", 40); + if (missing === "document") scope.document = undefined; + if (missing === "root") scope.rootRef.value = undefined; + if (missing === "canvas") canvas.getContext.mockReturnValue(null); + expect(measure).not.toThrow(); + expect([...scope.sidebarCommentLabelWidths.value]).toEqual(missing === "canvas" ? [["old", 40]] : []); + }); + + it("remeasures localized badges after a language change without a tree change", async () => { + const { scope, measure } = fixture([column("id", true, "comment")], "zh-CN"); + const scheduleSidebarCommentLabelMeasure = vi.fn(measure); + const stop = execute(`return ${watchStatement}`, { ...scope, watch, scheduleSidebarCommentLabelMeasure }); + try { + expect(scheduleSidebarCommentLabelMeasure).toHaveBeenCalledTimes(1); + const previous = scope.sidebarCommentLabelWidths.value.get("id")!; + scope.locale.value = "es"; + await nextTick(); + expect(scheduleSidebarCommentLabelMeasure).toHaveBeenCalledTimes(2); + expect(scope.sidebarCommentLabelWidths.value.get("id")).toBe(textWidth("id", 14) + textWidth(es.structureEditor.nullable, 10) + 16); + expect(scope.sidebarCommentLabelWidths.value.get("id")).toBeGreaterThan(previous); + } finally { + stop(); + } + }); +}); diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 973144ffda..77fbf2a581 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -4981,6 +4981,7 @@ export default { removeEnumValue: "Remove enum value", enumValuePlaceholder: "Value (empty is allowed)", nullable: "Nullable", + notNull: "Not null", primaryKey: "Primary Key", defaultValue: "Default", defaultValuePresets: "Default value presets", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 8687357dac..b2a53f0b15 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -4603,6 +4603,7 @@ export default withEnglishFallback({ partitionHashBoundInvalid: "HASH requiere módulo > 0 y 0 <= resto < módulo.", partitionSqlPreview: "SQL que se ejecutará", partitionNameRequired: "Introduce el nombre de la partición.", + notNull: "No nulo", }, diagram: { title: "Diagrama de relaciones", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 4a2c8e835d..c6e39b5461 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -4600,6 +4600,7 @@ export default withEnglishFallback({ partitionHashBoundInvalid: "HASH richiede modulo > 0 e 0 <= resto < modulo.", partitionSqlPreview: "SQL da eseguire", partitionNameRequired: "Inserisci il nome della partizione.", + notNull: "Non NULL", }, diagram: { title: "Diagramma delle Relazioni", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 29731a122a..368c653fad 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -4666,6 +4666,7 @@ export default withEnglishFallback({ partitionHashBoundInvalid: "HASH にはモジュラス > 0 が必要で、0 <= 剰余 < モジュラス である必要があります。", partitionSqlPreview: "実行される SQL", partitionNameRequired: "パーティション名を入力してください。", + notNull: "NOT NULL", }, diagram: { title: "リレーション図", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 56562ad95f..861ec42be5 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -4587,6 +4587,7 @@ export default withEnglishFallback({ partitionHashBoundInvalid: "HASH에는 모듈러스 > 0이 필요하며, 0 <= 나머지 < 모듈러스여야 합니다.", partitionSqlPreview: "실행될 SQL", partitionNameRequired: "파티션 이름을 입력하세요.", + notNull: "NOT NULL", }, diagram: { title: "관계 다이어그램", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 432ce9e757..a48f50ecd2 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -4602,6 +4602,7 @@ export default withEnglishFallback({ partitionHashBoundInvalid: "HASH requer módulo > 0 e 0 <= resto < módulo.", partitionSqlPreview: "SQL a ser executado", partitionNameRequired: "Digite o nome da partição.", + notNull: "Não nulo", }, diagram: { title: "Diagrama de relacionamentos", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 3309ed8932..6432872fc5 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -4955,6 +4955,7 @@ export default withEnglishFallback({ numeric: "NUMERIC 亲和性会尝试先将格式正确的数字文本转换为 INTEGER,再转换为 REAL;它不是独立的 SQLite 存储类。DATE、DATETIME 和 TIME 声明同样具有 NUMERIC 亲和性;应统一使用 ISO-8601 TEXT、儒略日 REAL 或 Unix 时间 INTEGER。", }, nullable: "可为空", + notNull: "非空", primaryKey: "主键", defaultValue: "默认值", defaultValuePresets: "默认值预设", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 12a2a57804..696ef9da79 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -4340,6 +4340,7 @@ export default withEnglishFallback({ partitionHashBoundInvalid: "HASH 需要模數 > 0,且 0 <= 餘數 < 模數。", partitionSqlPreview: "將執行的 SQL", partitionNameRequired: "請輸入分割區名稱。", + notNull: "非空", }, diagram: { title: "關係圖",