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: "關係圖",