feat(sidebar): show column nullability

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-09-23 10:44:40 +08:00
1 parent c6d31c0577
commit 19f36557b9
11 files changed
+197 -5

No files matched your search

@@ -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,
@@ -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) }}</span
>
<span
v-if="node.type === 'column' && node.meta"
class="shrink-0 rounded px-1 text-[10px] leading-4"
:class="(node.meta as ColumnInfo).is_nullable ? 'text-muted-foreground bg-muted/50' : 'text-amber-700 bg-amber-500/10 dark:text-amber-300'"
:title="t((node.meta as ColumnInfo).is_nullable ? 'structureEditor.nullable' : 'structureEditor.notNull')"
>
{{ t((node.meta as ColumnInfo).is_nullable ? "structureEditor.nullable" : "structureEditor.notNull") }}
</span>
<button v-if="node.type === 'oracle-db-links'" class="ml-auto rounded p-0.5 text-muted-foreground hover:bg-muted" :aria-label="t('databaseLinks.manage')" :title="t('databaseLinks.manage')" @click.stop="showDatabaseLinks = true" @dblclick.stop>
<TableProperties class="h-3.5 w-3.5" />
</button>
@@ -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<string, unknown>) {
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<string, number>()),
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();
}
});
});
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -4666,6 +4666,7 @@ export default withEnglishFallback({
partitionHashBoundInvalid: "HASH にはモジュラス > 0 が必要で、0 <= 剰余 < モジュラス である必要があります。",
partitionSqlPreview: "実行される SQL",
partitionNameRequired: "パーティション名を入力してください。",
notNull: "NOT NULL",
},
diagram: {
title: "リレーション図",
+1
View File
@@ -4587,6 +4587,7 @@ export default withEnglishFallback({
partitionHashBoundInvalid: "HASH에는 모듈러스 > 0이 필요하며, 0 <= 나머지 < 모듈러스여야 합니다.",
partitionSqlPreview: "실행될 SQL",
partitionNameRequired: "파티션 이름을 입력하세요.",
notNull: "NOT NULL",
},
diagram: {
title: "관계 다이어그램",
+1
View File
@@ -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",
+1
View File
@@ -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: "默认值预设",
+1
View File
@@ -4340,6 +4340,7 @@ export default withEnglishFallback({
partitionHashBoundInvalid: "HASH 需要模數 > 0,且 0 <= 餘數 < 模數。",
partitionSqlPreview: "將執行的 SQL",
partitionNameRequired: "請輸入分割區名稱。",
notNull: "非空",
},
diagram: {
title: "關係圖",