feat(sidebar): compact row density for connection tree

Closes #6683
This commit is contained in:
lxk955
2026-10-09 09:45:55 +08:00
committed by GitHub
parent 26085752a1
commit 95030c353a
24 changed files with 377 additions and 25 deletions
@@ -125,6 +125,9 @@ import {
SIDEBAR_INDENT_MAX,
SIDEBAR_FONT_SIZE_MIN,
SIDEBAR_FONT_SIZE_MAX,
SIDEBAR_DENSITIES,
isSidebarDensity,
type SidebarDensity,
} from "@/stores/settingsStore";
import { EDITOR_FONT_FAMILY_CSS_VAR, EDITOR_FONT_SIZE_CSS_VAR, createRunStatementButtonDom, loadEditorTheme, editorFontTheme } from "@/lib/editor/editorThemes";
import { orderAiConfigsForDisplay } from "@/lib/ai/aiConfigOrdering";
@@ -910,6 +913,7 @@ const editSidebarAllowHorizontalScroll = ref(settingsStore.editorSettings.sideba
const editSidebarShowTooltips = ref(settingsStore.editorSettings.sidebarShowTooltips);
const editSidebarIndent = ref(settingsStore.editorSettings.sidebarIndent);
const editSidebarFontSize = ref(settingsStore.editorSettings.sidebarFontSize);
const editSidebarDensity = ref<SidebarDensity>(settingsStore.editorSettings.sidebarDensity);
const editExportBatchSize = ref(settingsStore.editorSettings.exportBatchSize);
const editPreferredExportPath = ref(settingsStore.editorSettings.preferredExportPath);
const editAutoOpenExportFolder = ref(settingsStore.editorSettings.autoOpenExportFolder);
@@ -1192,6 +1196,7 @@ function currentEditorSettingsDraft(): EditorSettingsDraft {
sidebarShowTooltips: editSidebarShowTooltips.value,
sidebarIndent: editSidebarIndent.value,
sidebarFontSize: editSidebarFontSize.value,
sidebarDensity: editSidebarDensity.value,
sidebarHiddenTablePrefixes: normalizeSidebarHiddenTablePrefixes(editSidebarHiddenTablePrefixes.value),
sidebarCopyTableNameSeparator: editSidebarCopyTableNameSeparator.value,
sidebarCopyTableNameIncludeSchema: editSidebarCopyTableNameIncludeSchema.value,
@@ -1883,6 +1888,7 @@ function syncEditorSettingsDraftFromStore() {
editSidebarShowTooltips.value = settingsStore.editorSettings.sidebarShowTooltips;
editSidebarIndent.value = settingsStore.editorSettings.sidebarIndent;
editSidebarFontSize.value = settingsStore.editorSettings.sidebarFontSize;
editSidebarDensity.value = settingsStore.editorSettings.sidebarDensity;
editExportBatchSize.value = settingsStore.editorSettings.exportBatchSize;
editPreferredExportPath.value = settingsStore.editorSettings.preferredExportPath;
editAutoOpenExportFolder.value = settingsStore.editorSettings.autoOpenExportFolder;
@@ -2037,6 +2043,7 @@ const editorSettingsDraftRefs: EditorSettingsDraftRefMap = {
sidebarShowTooltips: editSidebarShowTooltips,
sidebarIndent: editSidebarIndent,
sidebarFontSize: editSidebarFontSize,
sidebarDensity: editSidebarDensity,
sidebarHiddenTablePrefixes: editSidebarHiddenTablePrefixes,
sidebarCopyTableNameSeparator: editSidebarCopyTableNameSeparator,
sidebarCopyTableNameIncludeSchema: editSidebarCopyTableNameIncludeSchema,
@@ -2553,6 +2560,7 @@ function resetDefaultsForTab(tab: SettingsCategory) {
editSidebarShowTooltips.value = DEFAULT_EDITOR_SETTINGS.sidebarShowTooltips;
editSidebarIndent.value = DEFAULT_EDITOR_SETTINGS.sidebarIndent;
editSidebarFontSize.value = DEFAULT_EDITOR_SETTINGS.sidebarFontSize;
editSidebarDensity.value = DEFAULT_EDITOR_SETTINGS.sidebarDensity;
editSidebarHiddenTablePrefixes.value = DEFAULT_EDITOR_SETTINGS.sidebarHiddenTablePrefixes.join("\n");
editSidebarCopyTableNameSeparator.value = DEFAULT_EDITOR_SETTINGS.sidebarCopyTableNameSeparator;
editSidebarCopyTableNameIncludeSchema.value = DEFAULT_EDITOR_SETTINGS.sidebarCopyTableNameIncludeSchema;
@@ -2765,6 +2773,7 @@ function resetAllDefaults() {
editSidebarShowTooltips.value = DEFAULT_EDITOR_SETTINGS.sidebarShowTooltips;
editSidebarIndent.value = DEFAULT_EDITOR_SETTINGS.sidebarIndent;
editSidebarFontSize.value = DEFAULT_EDITOR_SETTINGS.sidebarFontSize;
editSidebarDensity.value = DEFAULT_EDITOR_SETTINGS.sidebarDensity;
editSidebarHiddenTablePrefixes.value = DEFAULT_EDITOR_SETTINGS.sidebarHiddenTablePrefixes.join("\n");
editSidebarCopyTableNameSeparator.value = DEFAULT_EDITOR_SETTINGS.sidebarCopyTableNameSeparator;
editSidebarCopyTableNameIncludeSchema.value = DEFAULT_EDITOR_SETTINGS.sidebarCopyTableNameIncludeSchema;
@@ -8305,6 +8314,31 @@ onUnmounted(() => {
"
/>
</div>
<div class="settings-item flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="sidebar-density">{{ t("settings.sidebarDensity") }}</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.sidebarDensityDescription") }}
</p>
</div>
<Select
:model-value="editSidebarDensity"
@update:model-value="
(value) => {
if (isSidebarDensity(value)) editSidebarDensity = value;
}
"
>
<SelectTrigger id="sidebar-density" class="h-8 w-36 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper" align="end">
<SelectItem v-for="density in SIDEBAR_DENSITIES" :key="density" :value="density" class="text-xs">
{{ t(`settings.sidebarDensity_${density}`) }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label for="sidebar-hidden-table-prefixes">{{ t("settings.sidebarHiddenTablePrefixes") }}</Label>
<textarea
@@ -53,7 +53,7 @@ import {
type SidebarNodeScrollAlign,
} from "@/lib/sidebar/sidebarActiveTabTarget";
import { findLoadedTableTargetForCandidate, queryContextTargetFromCandidate, queryCursorTableCandidate, type QueryCursorTableCandidate } from "@/lib/sql/queryCursorTableTarget";
import { createFlatTreeIndex, flatTreeRowsChanged, SIDEBAR_TREE_ROW_HEIGHT, SIDEBAR_TREE_PRERENDER_COUNT, SIDEBAR_TREE_SCROLL_BUFFER, SIDEBAR_TREE_VIRTUALIZE_HYSTERESIS, SIDEBAR_TREE_VIRTUALIZE_THRESHOLD, flattenTree, shouldVirtualizeFlatTree, type FlatTreeNode } from "@/composables/useFlatTree";
import { createFlatTreeIndex, flatTreeRowsChanged, getSidebarTreeRowHeight, SIDEBAR_TREE_PRERENDER_COUNT, SIDEBAR_TREE_SCROLL_BUFFER, SIDEBAR_TREE_VIRTUALIZE_HYSTERESIS, SIDEBAR_TREE_VIRTUALIZE_THRESHOLD, flattenTree, shouldVirtualizeFlatTree, type FlatTreeNode } from "@/composables/useFlatTree";
import { sidebarTreeContextKey } from "@/lib/sidebar/sidebarTreeContext";
import { createSidebarTreeRuntime, sidebarTreeRuntimeKey, type SidebarTreeRuntimeHostInstance } from "@/lib/sidebar/sidebarTreeRuntime";
import { createSidebarPasteHandlerRegistry } from "@/lib/sidebar/sidebarPasteHandlerRegistry";
@@ -111,7 +111,11 @@ const showConnectedConnectionsOnly = ref(false);
const isDisconnectingAllActiveConnections = ref(false);
const searchInputRef = ref<HTMLInputElement>();
const rootRef = ref<HTMLElement>();
const sidebarRootStyle = computed<Record<string, string>>(() => ({ fontSize: `${settingsStore.editorSettings.sidebarFontSize}px` }));
const sidebarTreeRowHeight = computed(() => getSidebarTreeRowHeight(settingsStore.editorSettings.sidebarDensity));
const sidebarRootStyle = computed<Record<string, string>>(() => ({
fontSize: `${settingsStore.editorSettings.sidebarFontSize}px`,
"--sidebar-tree-row-height": `${sidebarTreeRowHeight.value}px`,
}));
const pointerInsideTree = ref(false);
const treeScrollerRef = ref<InstanceType<typeof RecycleScroller> | null>(null);
const plainTreeScrollerRef = ref<HTMLElement | null>(null);
@@ -855,7 +859,7 @@ const sidebarLayoutMonitor = createSidebarLayoutMonitor({
readContext: () => ({
flatNodeCount: flatNodes.value.length,
useVirtualTree: useVirtualTree.value,
virtualItemSize: SIDEBAR_TREE_ROW_HEIGHT,
virtualItemSize: sidebarTreeRowHeight.value,
scrollerEl: currentTreeScroller(),
shellEl: treeScrollShellRef.value,
rootEl: rootRef.value ?? null,
@@ -970,10 +974,14 @@ watch(
},
);
watch([sidebarTreeNaturalWidthItems, () => settingsStore.editorSettings.uiFontFamily, () => settingsStore.editorSettings.uiScale, () => settingsStore.editorSettings.sidebarIndent, () => settingsStore.editorSettings.sidebarFontSize], scheduleSidebarTreeContentWidthMeasure, {
flush: "post",
immediate: true,
});
watch(
[sidebarTreeNaturalWidthItems, () => settingsStore.editorSettings.uiFontFamily, () => settingsStore.editorSettings.uiScale, () => settingsStore.editorSettings.sidebarIndent, () => settingsStore.editorSettings.sidebarFontSize, () => settingsStore.editorSettings.sidebarDensity],
scheduleSidebarTreeContentWidthMeasure,
{
flush: "post",
immediate: true,
},
);
const visibleSidebarTableStorageScopes = computed(() => {
if (settingsStore.editorSettings.sidebarObjectInfoMode !== "size") return [];
@@ -1143,10 +1151,10 @@ const stickyContainerIndex = computed(() => {
const len = nodes.length;
if (len === 0) return -1;
const topIndex = Math.min(Math.floor(stickyScrollTop.value / SIDEBAR_TREE_ROW_HEIGHT), len - 1);
const topIndex = Math.min(Math.floor(stickyScrollTop.value / sidebarTreeRowHeight.value), len - 1);
const containerIndex = flatTreeIndex.value.stickyContainerIndexByIndex[topIndex] ?? -1;
if (containerIndex < 0) return -1;
return stickyScrollTop.value > containerIndex * SIDEBAR_TREE_ROW_HEIGHT ? containerIndex : -1;
return stickyScrollTop.value > containerIndex * sidebarTreeRowHeight.value ? containerIndex : -1;
});
const stickyNode = computed<FlatTreeNode | null>(() => flatNodes.value[stickyContainerIndex.value] ?? null);
@@ -1160,10 +1168,10 @@ const stickyHeaderStyle = computed<CSSProperties>(() => {
const nextBoundaryIndex = flatTreeIndex.value.nextBoundaryIndexByIndex[currentIndex] ?? -1;
const nextCollisionIndex = nextContainerIndex < 0 ? nextBoundaryIndex : nextBoundaryIndex < 0 ? nextContainerIndex : Math.min(nextContainerIndex, nextBoundaryIndex);
if (nextCollisionIndex < 0) return {};
const distanceToNext = nextCollisionIndex * SIDEBAR_TREE_ROW_HEIGHT - stickyScrollTop.value;
if (distanceToNext >= SIDEBAR_TREE_ROW_HEIGHT) return {};
const distanceToNext = nextCollisionIndex * sidebarTreeRowHeight.value - stickyScrollTop.value;
if (distanceToNext >= sidebarTreeRowHeight.value) return {};
return {
transform: `translateY(${Math.min(0, distanceToNext - SIDEBAR_TREE_ROW_HEIGHT)}px)`,
transform: `translateY(${Math.min(0, distanceToNext - sidebarTreeRowHeight.value)}px)`,
};
});
@@ -1494,7 +1502,7 @@ async function flashSidebarNode(nodeId: string) {
function topOcclusionHeightForSidebarNode(nodeId: string): number {
const sticky = stickyNode.value;
if (!sticky || sticky.id === nodeId) return 0;
return SIDEBAR_TREE_ROW_HEIGHT;
return sidebarTreeRowHeight.value;
}
/** Select and reveal a freshly created table group. */
@@ -1515,6 +1523,7 @@ async function scrollToSidebarNode(nodeId: string, options?: { align?: SidebarNo
currentScrollTop: scroller.scrollTop,
viewportHeight: scroller.clientHeight,
scrollHeight: scroller.scrollHeight,
rowHeight: sidebarTreeRowHeight.value,
topOcclusionHeight: topOcclusionHeightForSidebarNode(nodeId),
...(options?.align ? { align: options.align } : {}),
});
@@ -2259,6 +2268,7 @@ async function selectActiveTabSidebarNode(options: { scroll: boolean }) {
currentScrollTop: scroller.scrollTop,
viewportHeight: scroller.clientHeight,
scrollHeight: scroller.scrollHeight,
rowHeight: sidebarTreeRowHeight.value,
topOcclusionHeight: topOcclusionHeightForSidebarNode(match.id),
});
if (nextScrollTop !== scroller.scrollTop) {
@@ -2626,7 +2636,7 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes, locateTabInSid
:style="sidebarTreeScrollerStyle"
@click="clearSidebarSelection"
:items="flatNodes"
:item-size="SIDEBAR_TREE_ROW_HEIGHT"
:item-size="sidebarTreeRowHeight"
:buffer="SIDEBAR_TREE_SCROLL_BUFFER"
:prerender="SIDEBAR_TREE_PRERENDER_COUNT"
:skip-hover="true"
@@ -2871,12 +2881,12 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes, locateTabInSid
.connection-tree-scroller :deep(.vue-recycle-scroller__item-view) {
min-width: 100%;
contain: style;
/* The virtual renderer positions rows at fixed item-size offsets (28px, see
SIDEBAR_TREE_ROW_HEIGHT). TreeItem rows only guarantee min-h-7, so rename
inputs or larger sidebar fonts could grow a row beyond 28px and overlap
the next row. Pin every materialized row to the fixed height and clip any
overflow instead of letting the layout drift. */
height: 28px;
/* The virtual renderer positions rows at fixed item-size offsets (see
getSidebarTreeRowHeight). TreeItem rows only guarantee min-h, so rename
inputs or larger sidebar fonts could grow a row beyond the row height and
overlap the next row. Pin every materialized row to the fixed height and clip
any overflow instead of letting the layout drift. */
height: var(--sidebar-tree-row-height, 28px);
overflow: hidden;
}
@@ -945,6 +945,8 @@ function hasTrailingMetadata(): boolean {
const usesFullWidthLabel = computed(() => usesFullWidthTreeLabel(activeNode.value.type, settingsStore.editorSettings.sidebarAllowHorizontalScroll, hasTrailingMetadata()));
const isCompactSidebar = computed(() => settingsStore.editorSettings.sidebarDensity === "compact");
const rowWidthClass = computed(() => (usesFullWidthLabel.value ? "w-max min-w-full" : "w-full min-w-0"));
const labelWidthClass = computed(() => {
@@ -1583,7 +1585,17 @@ function onKeydown(event: KeyboardEvent) {
</script>
<template>
<div v-if="node.type === 'table-search-control'" data-sidebar-table-search-control class="tree-table-search-control flex h-7 items-center gap-1.5 py-0.5 pr-2" :style="tableSearchStyle" @click.stop @dblclick.stop @mousedown.stop @keydown="onTableSearchControlKeydown">
<div
v-if="node.type === 'table-search-control'"
data-sidebar-table-search-control
class="tree-table-search-control flex items-center gap-1.5 pr-2"
:class="isCompactSidebar ? 'h-6 py-0' : 'h-7 py-0.5'"
:style="tableSearchStyle"
@click.stop
@dblclick.stop
@mousedown.stop
@keydown="onTableSearchControlKeydown"
>
<div class="relative min-w-0 flex-1">
<Search class="pointer-events-none absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" />
<Input
@@ -1591,7 +1603,8 @@ function onKeydown(event: KeyboardEvent) {
autocapitalize="off"
autocorrect="off"
spellcheck="false"
class="h-6 w-full rounded border pl-7 pr-6 text-xs shadow-none focus-visible:ring-1"
class="w-full rounded border pl-7 pr-6 text-xs shadow-none focus-visible:ring-1"
:class="isCompactSidebar ? 'h-5' : 'h-6'"
:style="{ backgroundColor: 'var(--tree-table-search-input-bg)', borderColor: 'var(--tree-table-search-border)' }"
:placeholder="t(node.label)"
:aria-label="t(node.label)"
@@ -1616,9 +1629,10 @@ function onKeydown(event: KeyboardEvent) {
<LightTooltip :text="visibleLabel(node)" :disabled="isTooltipDisabled()" side="right" :side-offset="8" :delay="0" :close-delay="30" :surface="detailTooltip ? 'popover' : 'foreground'">
<div
ref="rowRef"
class="group flex cursor-default items-center gap-2 min-h-7 py-1 px-2 relative outline-none"
class="group flex cursor-default items-center gap-2 px-2 relative outline-none"
style="contain: layout style"
:class="[
isCompactSidebar ? 'min-h-6 py-0.5' : 'min-h-7 py-1',
rowWidthClass,
{
'group/sidebar-row': true,
@@ -0,0 +1,189 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, reactive, type App } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
import TreeItem from "@/components/sidebar/TreeItem.vue";
import { createSidebarTreeRuntime, sidebarTreeRuntimeKey, type SidebarTreeRuntimeHost } from "@/lib/sidebar/sidebarTreeRuntime";
import type { TreeNode } from "@/types/database";
import type { SidebarDensity } from "@/stores/settingsStore";
const mockSettings = reactive({
editorSettings: {
shortcuts: { openDataInNewTab: "" },
sidebarActivation: "double",
sidebarAllowHorizontalScroll: false,
sidebarHiddenTablePrefixes: [],
sidebarObjectInfoMode: "none",
sidebarDensity: "default" as SidebarDensity,
sidebarShowTooltips: false,
sidebarIndent: 16,
sidebarTableSearchLocal: true,
},
});
const connectionStore = {
activeConnectionId: "connection-1",
connectedIds: new Set(["connection-1"]),
connectingIds: new Set<string>(),
connectionErrors: {},
connectionMultiSelectActive: false,
connections: [],
getConfig: () => undefined,
getSidebarVisibleFilterSummary: () => null,
clearConnectionError: vi.fn(),
isDefaultDatabase: () => false,
isDefaultSchema: () => false,
isPinnedTreeNodeReorderTarget: () => false,
isTreeNodeChildrenLoaded: () => false,
isTreeNodePinned: () => false,
selectedTreeNodeId: null as string | null,
selectedTreeNodeIds: [] as string[],
selectedTreeNodeIdsSet: new Set<string>(),
sidebarTableSearchQueries: {},
tableNameFilterForScope: () => undefined,
treeNodes: [],
treeSelectionAnchorId: null as string | null,
refreshSidebarTableSearch: vi.fn(),
};
vi.mock("@/stores/connectionStore", () => ({
useConnectionStore: () => connectionStore,
}));
vi.mock("@/stores/queryStore", () => ({
useQueryStore: () => ({ openDatabaseKeys: new Set<string>() }),
}));
vi.mock("@/stores/settingsStore", () => ({
useSettingsStore: () => mockSettings,
}));
vi.mock("@/composables/useToast", () => ({
useToast: () => ({ toast: vi.fn() }),
}));
const mountedApps: App[] = [];
afterEach(() => {
while (mountedApps.length > 0) {
mountedApps.pop()?.unmount();
}
document.body.innerHTML = "";
mockSettings.editorSettings.sidebarDensity = "default";
});
function createTreeRuntimeHost(): SidebarTreeRuntimeHost {
return {
handleRowClick: vi.fn(),
handleRowDoubleClick: vi.fn(),
handleRowKeydown: vi.fn(),
handleToggleExpand: vi.fn(),
isSelected: () => false,
isMultiSelected: () => false,
isAnchor: () => false,
usesSelectionSetHighlight: () => false,
isSelectionLead: () => false,
isDragging: () => false,
dragVisualState: () => ({
dragging: false,
showBefore: false,
showAfter: false,
showInside: false,
dropTargetNodeId: null,
}),
tableVGroupDropTargetNodeId: () => null,
updateTreeDragTarget: vi.fn(),
clearTreeDragTarget: vi.fn(),
canDragPinnedOrder: () => false,
startPinnedOrderDrag: vi.fn(),
isNodeHighlighted: () => false,
onRowContextMenu: vi.fn(),
executeDefaultNodeAction: vi.fn(),
};
}
function mountTreeItem(node: TreeNode) {
const container = document.createElement("div");
document.body.appendChild(container);
const runtime = createSidebarTreeRuntime(createTreeRuntimeHost());
const rootComponent = defineComponent({
setup() {
return () => h(TreeItem, { node, depth: 0 });
},
});
const app = createApp(rootComponent);
app.use(i18n);
app.provide(sidebarTreeRuntimeKey, runtime);
app.mount(container);
mountedApps.push(app);
return container;
}
describe("TreeItem sidebarDensity layout", () => {
it("renders default min-h-7 and py-1 when sidebarDensity is default", async () => {
mockSettings.editorSettings.sidebarDensity = "default";
const node: TreeNode = {
id: "table-1",
label: "users",
type: "table",
connectionId: "connection-1",
};
const container = mountTreeItem(node);
await nextTick();
const row = container.querySelector('[data-node-id="table-1"]');
expect(row).not.toBeNull();
expect(row?.classList.contains("min-h-7")).toBe(true);
expect(row?.classList.contains("py-1")).toBe(true);
expect(row?.classList.contains("min-h-6")).toBe(false);
expect(row?.classList.contains("py-0.5")).toBe(false);
});
it("renders compact min-h-6 and py-0.5 when sidebarDensity is compact", async () => {
mockSettings.editorSettings.sidebarDensity = "compact";
const node: TreeNode = {
id: "table-1",
label: "users",
type: "table",
connectionId: "connection-1",
};
const container = mountTreeItem(node);
await nextTick();
const row = container.querySelector('[data-node-id="table-1"]');
expect(row).not.toBeNull();
expect(row?.classList.contains("min-h-6")).toBe(true);
expect(row?.classList.contains("py-0.5")).toBe(true);
expect(row?.classList.contains("min-h-7")).toBe(false);
expect(row?.classList.contains("py-1")).toBe(false);
});
it("adjusts search control row and input height in compact mode", async () => {
mockSettings.editorSettings.sidebarDensity = "compact";
const searchNode: TreeNode = {
id: "search-1",
label: "sidebar.searchTablesInCurrentScope",
type: "table-search-control",
tableSearchParentId: "db-1",
};
const container = mountTreeItem(searchNode);
await nextTick();
const control = container.querySelector("[data-sidebar-table-search-control]");
expect(control).not.toBeNull();
expect(control?.classList.contains("h-6")).toBe(true);
expect(control?.classList.contains("h-7")).toBe(false);
const input = container.querySelector("input");
expect(input).not.toBeNull();
expect(input?.classList.contains("h-5")).toBe(true);
expect(input?.classList.contains("h-6")).toBe(false);
});
});
+16 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { createFlatTreeIndex, flatTreeRowsChanged, flattenTree, mutateFlatTreeExpansion, replaceFlatTreeChildren, type FlatTreeNode } from "@/composables/useFlatTree";
import { createFlatTreeIndex, flatTreeRowsChanged, flattenTree, getSidebarTreeRowHeight, mutateFlatTreeExpansion, replaceFlatTreeChildren, SIDEBAR_TREE_ROW_HEIGHT, SIDEBAR_TREE_ROW_HEIGHT_COMPACT, SIDEBAR_TREE_ROW_HEIGHT_DEFAULT, type FlatTreeNode } from "@/composables/useFlatTree";
import type { TreeNode, TreeNodeType } from "@/types/database";
function item(id: string, type: TreeNodeType, depth: number, children?: TreeNode[]): FlatTreeNode {
@@ -194,3 +194,18 @@ describe("flat-tree render identities", () => {
expect(new Set(flattened.map((entry) => entry.renderKey))).toHaveLength(flattened.length);
});
});
describe("getSidebarTreeRowHeight", () => {
it("returns default row height (28px) when unset or default", () => {
expect(SIDEBAR_TREE_ROW_HEIGHT).toBe(28);
expect(SIDEBAR_TREE_ROW_HEIGHT_DEFAULT).toBe(28);
expect(getSidebarTreeRowHeight()).toBe(28);
expect(getSidebarTreeRowHeight("default")).toBe(28);
expect(getSidebarTreeRowHeight("unknown" as any)).toBe(28);
});
it("returns compact row height (24px) when density is compact", () => {
expect(SIDEBAR_TREE_ROW_HEIGHT_COMPACT).toBe(24);
expect(getSidebarTreeRowHeight("compact")).toBe(24);
});
});
+7 -1
View File
@@ -1,6 +1,12 @@
import type { TreeNode, TreeNodeType } from "@/types/database";
export const SIDEBAR_TREE_ROW_HEIGHT = 28;
export const SIDEBAR_TREE_ROW_HEIGHT_DEFAULT = 28;
export const SIDEBAR_TREE_ROW_HEIGHT_COMPACT = 24;
export const SIDEBAR_TREE_ROW_HEIGHT = SIDEBAR_TREE_ROW_HEIGHT_DEFAULT;
export function getSidebarTreeRowHeight(density?: string): number {
return density === "compact" ? SIDEBAR_TREE_ROW_HEIGHT_COMPACT : SIDEBAR_TREE_ROW_HEIGHT_DEFAULT;
}
export const SIDEBAR_TREE_SCROLL_BUFFER = 600;
export const SIDEBAR_TREE_PRERENDER_COUNT = 48;
+4
View File
@@ -7512,6 +7512,10 @@ export default withEnglishFallback({
sidebarIndentDescription: "Ağacın hər dərinlik səviyyəsi üçün əlavə olunan üfüqi məsafə, piksellə.",
sidebarFontSize: "Yan panel şriftinin ölçüsü",
sidebarFontSizeDescription: "Yan paneldəki obyekt ağacının şrift ölçüsü, piksellə. Redaktorun şrift ölçüsündən asılı deyil.",
sidebarDensity: "Yan panel sıxlığı",
sidebarDensityDescription: "Yan panel naviqasiya ağacı üçün sətir hündürlüyü sıxlığı.",
sidebarDensity_default: "Standart",
sidebarDensity_compact: "Kompakt",
snippetsDescription: "Redaktorda işə salınan SQL kod parçası şablonlarını fərdiləşdirin.",
snippetTriggerKey: "Kod parçası işə salma düyməsi",
snippetTriggerKeyDescription: "Redaktorda kod parçasının genişləndirilməsini işə salmaq üçün istifadə olunan düyməni seçin (Tab, Boşluq və ya Hər ikisi).",
+4
View File
@@ -8532,6 +8532,10 @@ export default {
sidebarIndentDescription: "Horizontal offset added per tree depth level, in pixels.",
sidebarFontSize: "Sidebar font size",
sidebarFontSizeDescription: "Font size for the sidebar object tree, in pixels. Independent of the editor font size.",
sidebarDensity: "Sidebar density",
sidebarDensityDescription: "Row height density for the sidebar navigation tree.",
sidebarDensity_default: "Default",
sidebarDensity_compact: "Compact",
snippetsDescription: "Customize SQL snippet templates triggered in the editor.",
snippetTriggerKey: "Snippet trigger key",
snippetTriggerKeyDescription: "Choose the key used to trigger snippet expansion in the editor (Tab, Space, or Both).",
+4
View File
@@ -8712,6 +8712,10 @@ export default withEnglishFallback({
sidebarIndentDescription: "Desplazamiento en píxeles de la sangría para cada nivel del árbol.",
sidebarFontSize: "Tamaño de fuente de la barra lateral",
sidebarFontSizeDescription: "Tamaño de fuente (en píxeles) del árbol de objetos de la barra lateral, independiente del tamaño de fuente del editor.",
sidebarDensity: "Densidad de la barra lateral",
sidebarDensityDescription: "Densidad de altura de fila para el árbol de navegación de la barra lateral.",
sidebarDensity_default: "Predeterminado",
sidebarDensity_compact: "Compacto",
themePaletteCustom: "Personalizado",
customUiReset: "Restablecer predeterminados",
customUiEditingLight: "Editando: Colores claros",
+4
View File
@@ -8260,6 +8260,10 @@ export default withEnglishFallback({
sidebarIndentDescription: "Offset horizontal yang ditambahkan per tingkat kedalaman pohon, dalam piksel.",
sidebarFontSize: "Ukuran font bilah sisi",
sidebarFontSizeDescription: "Ukuran font untuk pohon objek bilah sisi, dalam piksel. Independen dari ukuran font editor.",
sidebarDensity: "Kepadatan bilah sisi",
sidebarDensityDescription: "Kepadatan tinggi baris untuk pohon navigasi bilah sisi.",
sidebarDensity_default: "Default",
sidebarDensity_compact: "Ringkas",
snippetsDescription: "Sesuaikan template snippet SQL yang dipicu di editor.",
snippetTriggerKey: "Tombol pemicu snippet",
snippetTriggerKeyDescription: "Pilih tombol yang digunakan untuk memicu perluasan snippet di editor (Tab, Spasi, atau Keduanya).",
+4
View File
@@ -8458,6 +8458,10 @@ export default withEnglishFallback({
sidebarIndentDescription: "Offset in pixel per l'indentazione di ogni livello dell'albero.",
sidebarFontSize: "Dimensione del carattere della barra laterale",
sidebarFontSizeDescription: "Dimensione del carattere (in pixel) per l'albero degli oggetti della barra laterale, indipendente dalla dimensione del carattere dell'editor.",
sidebarDensity: "Densità della barra laterale",
sidebarDensityDescription: "Densità dell'altezza della riga per l'albero di navigazione della barra laterale.",
sidebarDensity_default: "Predefinito",
sidebarDensity_compact: "Compatto",
themePaletteCustom: "Personalizzato",
customUiReset: "Ripristina predefiniti",
customUiEditingLight: "Modifica in corso: tema chiaro",
+4
View File
@@ -8499,6 +8499,10 @@ export default withEnglishFallback({
sidebarIndentDescription: "各ツリー階層のインデントのピクセルオフセット。",
sidebarFontSize: "サイドバーのフォントサイズ",
sidebarFontSizeDescription: "サイドバーのオブジェクトツリーのフォントサイズ(ピクセル)。エディターのフォントサイズとは独立しています。",
sidebarDensity: "サイドバーの表示密度",
sidebarDensityDescription: "サイドバーのオブジェクトツリーの行の高さと表示密度。",
sidebarDensity_default: "デフォルト",
sidebarDensity_compact: "コンパクト",
themePaletteCustom: "カスタム",
customUiReset: "デフォルトに戻す",
customUiEditingLight: "編集中:ライト配色",
+4
View File
@@ -7413,6 +7413,10 @@ export default withEnglishFallback({
sidebarAllowHorizontalScrollDescription: "사이드바 가로 스크롤을 허용하여 긴 테이블, 뷰, 컬렉션 이름을 전체로 표시합니다.",
sidebarShowTooltips: "사이드바 호버 툴팁",
sidebarShowTooltipsDescription: "사이드바에서 연결 및 데이터베이스 개체에 마우스를 올리면 상세 툴팁을 표시합니다. 끄면 표시되지 않습니다.",
sidebarDensity: "사이드바 표시 밀도",
sidebarDensityDescription: "사이드바 탐색 트리의 행 높이 및 표시 밀도입니다.",
sidebarDensity_default: "기본",
sidebarDensity_compact: "컴팩트",
snippetsDescription: "편집기에서 트리거되는 SQL 스니펫 템플릿을 사용자 정의합니다.",
snippetTriggerKey: "스니펫 트리거 키",
snippetTriggerKeyDescription: "편집기에서 스니펫 확장을 트리거하는 키를 설정합니다(Tab, 스페이스 또는 둘 다).",
+4
View File
@@ -8458,6 +8458,10 @@ export default withEnglishFallback({
sidebarIndentDescription: "O deslocamento em pixels da indentação para cada nível da árvore.",
sidebarFontSize: "Tamanho da fonte da barra lateral",
sidebarFontSizeDescription: "Tamanho da fonte (em pixels) da árvore de objetos da barra lateral, independente do tamanho da fonte do editor.",
sidebarDensity: "Densidade da barra lateral",
sidebarDensityDescription: "Densidade da altura da linha para a árvore de navegação da barra lateral.",
sidebarDensity_default: "Padrão",
sidebarDensity_compact: "Compacto",
themePaletteCustom: "Personalizado",
customUiReset: "Restaurar padrão",
customUiEditingLight: "Editando: paleta de cores clara",
+4
View File
@@ -8827,6 +8827,10 @@ export default withEnglishFallback({
sidebarIndentDescription: "Горизонтальный отступ, добавляемый на каждый уровень глубины дерева, в пикселях.",
sidebarFontSize: "Размер шрифта боковой панели",
sidebarFontSizeDescription: "Размер шрифта дерева объектов боковой панели в пикселях. Не зависит от размера шрифта редактора.",
sidebarDensity: "Плотность боковой панели",
sidebarDensityDescription: "Плотность высоты строк дерева навигации боковой панели.",
sidebarDensity_default: "По умолчанию",
sidebarDensity_compact: "Компактная",
snippetsDescription: "Настройка шаблонов SQL-сниппетов, вызываемых в редакторе.",
snippetTriggerKey: "Клавиша вызова сниппетов",
snippetTriggerKeyDescription: "Выберите клавишу для вставки сниппетов в редакторе (Tab, пробел или оба).",
+4
View File
@@ -7411,6 +7411,10 @@ export default withEnglishFallback({
sidebarIndentDescription: "Ağaç derinliğinin her düzeyi için piksel cinsinden eklenen yatay kaydırma.",
sidebarFontSize: "Kenar çubuğu yazı tipi boyutu",
sidebarFontSizeDescription: "Kenar çubuğu nesne ağacı için piksel cinsinden yazı tipi boyutu. Düzenleyici yazı tipi boyutundan bağımsızdır.",
sidebarDensity: "Kenar çubuğu yoğunluğu",
sidebarDensityDescription: "Kenar çubuğu gezinti ağacı için satır yüksekliği yoğunluğu.",
sidebarDensity_default: "Varsayılan",
sidebarDensity_compact: "Sıkışık",
snippetsDescription: "Düzenleyicide tetiklenen SQL parçacığı şablonlarını özelleştirin.",
snippetTriggerKey: "Kod parçacığı tetikleme tuşu",
snippetTriggerKeyDescription: "Düzenleyicide kod parçacığı genişletmesini tetiklemek için kullanılan tuşu seçin (Tab, Boşluk veya Her İkisi).",
+4
View File
@@ -8517,6 +8517,10 @@ export default withEnglishFallback({
sidebarIndentDescription: "每个树层级缩进的像素偏移量。",
sidebarFontSize: "侧边栏字体大小",
sidebarFontSizeDescription: "侧边栏对象树的字体大小(像素),独立于编辑器字体大小。",
sidebarDensity: "侧边栏行高密度",
sidebarDensityDescription: "侧边栏对象导航树的行高密度。",
sidebarDensity_default: "默认",
sidebarDensity_compact: "紧凑",
snippetsDescription: "自定义编辑器中触发的 SQL 代码片段模板。",
snippetTriggerKey: "代码片段触发键",
snippetTriggerKeyDescription: "设置在编辑器中触发插入代码片段的按键(Tab、空格或两者均可)。",
+4
View File
@@ -7814,6 +7814,10 @@ export default withEnglishFallback({
sidebarIndentDescription: "每個樹層級縮排的像素位移量。",
sidebarFontSize: "側邊欄字型大小",
sidebarFontSizeDescription: "側邊欄物件樹的字型大小(像素),獨立於編輯器字型大小。",
sidebarDensity: "側邊欄行高密度",
sidebarDensityDescription: "側邊欄物件導覽樹的行高密度。",
sidebarDensity_default: "預設",
sidebarDensity_compact: "緊湊",
themePaletteCustom: "自訂",
customUiReset: "恢復預設",
customUiEditingLight: "正在編輯:淺色配色",
@@ -169,6 +169,19 @@ describe("settingsTransfer", () => {
expect(result.error.detail).toContain("appLayout");
});
it("round-trips sidebarDensity in navigation category and rejects invalid values", () => {
const valid = parseSettingsTransferFile(fileWith({ sidebarDensity: "compact" }));
expect(valid.ok).toBe(true);
if (!valid.ok) return;
expect(valid.value.editorSettings.sidebarDensity).toBe("compact");
expect(valid.value.categories).toContain("navigation");
const invalid = parseSettingsTransferFile(fileWith({ sidebarDensity: "ultra-compact" }));
expect(invalid.ok).toBe(false);
if (!invalid.ok) return;
expect(invalid.error.detail).toContain("sidebarDensity");
});
it("rejects pass-through boolean flags with non-boolean values", () => {
const result = parseSettingsTransferFile(fileWith({ wordWrap: "yes" }));
expect(result.ok).toBe(false);
@@ -135,6 +135,7 @@ export const EDITOR_SETTINGS_DRAFT_KEYS = [
"sidebarShowTooltips",
"sidebarIndent",
"sidebarFontSize",
"sidebarDensity",
"sidebarHiddenTablePrefixes",
"sidebarCopyTableNameSeparator",
"sidebarCopyTableNameIncludeSchema",
@@ -219,6 +219,7 @@ export const SETTINGS_SEARCH_DEFINITIONS: readonly SettingsSearchDefinition[] =
{ id: "navigation-sidebar-tooltips", category: "navigation", titleKey: "settings.sidebarShowTooltips", descriptionKey: "settings.sidebarShowTooltipsDescription", targetId: "navigation" },
{ id: "navigation-sidebar-indent", category: "navigation", titleKey: "settings.sidebarIndent", descriptionKey: "settings.sidebarIndentDescription", targetId: "navigation" },
{ id: "navigation-sidebar-font-size", category: "navigation", titleKey: "settings.sidebarFontSize", descriptionKey: "settings.sidebarFontSizeDescription", targetId: "navigation" },
{ id: "navigation-sidebar-density", category: "navigation", titleKey: "settings.sidebarDensity", descriptionKey: "settings.sidebarDensityDescription", targetId: "navigation" },
{ id: "navigation-hidden-tables", category: "navigation", titleKey: "settings.sidebarHiddenTablePrefixes", descriptionKey: "settings.sidebarHiddenTablePrefixesDescription", targetId: "navigation" },
{ id: "history-retention", category: "data", titleKey: "settings.historyRetentionLimit", descriptionKey: "settings.historyRetentionDescription", targetId: "history-retention" },
{ id: "mcp-history-retention", category: "data", titleKey: "settings.mcpHistoryRetentionLimit", descriptionKey: "settings.mcpHistoryRetentionDescription", targetId: "mcp-history-retention" },
@@ -144,6 +144,7 @@ const SETTINGS_TRANSFER_CATEGORY_KEYS: Record<SettingsTransferCategoryId, readon
"sidebarShowTooltips",
"sidebarIndent",
"sidebarFontSize",
"sidebarDensity",
"sidebarHiddenTablePrefixes",
"sidebarCopyTableNameSeparator",
"sidebarCopyTableNameIncludeSchema",
@@ -327,6 +328,7 @@ const PASS_THROUGH_FIELD_VALIDATORS: Partial<Record<EditorSettingsDraftKey, (val
webLogoPosition: (value) => value === "left" || value === "right" || value === "hidden",
resultTabNamingMode: (value) => value === "source" || value === "ordinal" || value === "comment",
activeCustomThemeId: (value) => typeof value === "string" && value.trim().length > 0,
sidebarDensity: (value) => value === "default" || value === "compact",
// normalizeToolbarItems keeps unknown/typed values for every known key, so
// each one must already be the boolean the UI writes, and no extra key may
// appear.
@@ -299,6 +299,11 @@ describe("normalizeEditorSettings", () => {
expect(normalizeEditorSettings({ sidebarIndent: 999, sidebarFontSize: 1 } as any).sidebarFontSize).toBe(9);
expect(normalizeEditorSettings({ sidebarIndent: 1.4, sidebarFontSize: 13.6 } as any).sidebarIndent).toBe(4);
expect(normalizeEditorSettings({ sidebarIndent: 1.4, sidebarFontSize: 13.6 } as any).sidebarFontSize).toBe(14);
expect(normalizeEditorSettings({}).sidebarDensity).toBe("default");
expect(normalizeEditorSettings({ sidebarDensity: "compact" }).sidebarDensity).toBe("compact");
expect(normalizeEditorSettings({ sidebarDensity: "default" }).sidebarDensity).toBe("default");
expect(normalizeEditorSettings({ sidebarDensity: "comfortable" } as any).sidebarDensity).toBe("default");
expect(normalizeEditorSettings({ sidebarDensity: 123 } as any).sidebarDensity).toBe("default");
});
it("uses inline comments by default and preserves legacy comment visibility", () => {
+14
View File
@@ -723,6 +723,12 @@ const STRUCTURE_EDITOR_DENSITIES = ["compact", "standard", "comfortable"] as con
export type StructureEditorDensity = (typeof STRUCTURE_EDITOR_DENSITIES)[number];
const COLUMN_WIDTH_DENSITIES = ["compact", "standard", "comfortable"] as const;
export type ColumnWidthDensity = (typeof COLUMN_WIDTH_DENSITIES)[number];
export const SIDEBAR_DENSITIES = ["default", "compact"] as const;
export type SidebarDensity = (typeof SIDEBAR_DENSITIES)[number];
export function isSidebarDensity(value: unknown): value is SidebarDensity {
return typeof value === "string" && (SIDEBAR_DENSITIES as readonly string[]).includes(value);
}
const DATA_GRID_COLUMN_WIDTH_MODES = ["fill", "content"] as const;
export type DataGridColumnWidthMode = (typeof DATA_GRID_COLUMN_WIDTH_MODES)[number];
const CELL_DETAIL_PANEL_LAYOUTS = ["bottom", "right"] as const;
@@ -1087,6 +1093,7 @@ export interface EditorSettings {
sidebarAllowHorizontalScroll: boolean;
sidebarIndent: number;
sidebarFontSize: number;
sidebarDensity: SidebarDensity;
columnFormatters: Record<string, ColumnFormatterConfig>;
customColumnFormatters: Record<string, CustomColumnFormatterConfig>;
globalDateTimeDisplayFormat: string;
@@ -1404,6 +1411,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = {
sidebarAllowHorizontalScroll: false,
sidebarIndent: SIDEBAR_INDENT_DEFAULT,
sidebarFontSize: SIDEBAR_FONT_SIZE_DEFAULT,
sidebarDensity: "default",
columnFormatters: {},
customColumnFormatters: {},
globalDateTimeDisplayFormat: "",
@@ -1586,6 +1594,10 @@ function normalizeSidebarIndent(value: unknown): number {
return Math.min(SIDEBAR_INDENT_MAX, Math.max(SIDEBAR_INDENT_MIN, Math.round(value)));
}
function normalizeSidebarDensity(value: unknown): SidebarDensity {
return isSidebarDensity(value) ? value : DEFAULT_EDITOR_SETTINGS.sidebarDensity;
}
function normalizeUpdateDownloadSource(value: unknown): UpdateDownloadSource {
// Preserve the intent of users who previously selected the mainland-China mirror.
if (value === "atomgit") return "cnb";
@@ -2087,6 +2099,7 @@ export function normalizeEditorSettings(settings: Partial<EditorSettings>, exist
sidebarAllowHorizontalScroll: settings.sidebarAllowHorizontalScroll ?? DEFAULT_EDITOR_SETTINGS.sidebarAllowHorizontalScroll,
sidebarIndent: normalizeSidebarIndent(settings.sidebarIndent),
sidebarFontSize: normalizeSidebarFontSize(settings.sidebarFontSize),
sidebarDensity: normalizeSidebarDensity(settings.sidebarDensity),
columnFormatters: normalizeColumnFormatters(settings.columnFormatters),
customColumnFormatters: normalizeCustomColumnFormatters(settings.customColumnFormatters),
globalDateTimeDisplayFormat: normalizeGlobalDateTimePattern(settings.globalDateTimeDisplayFormat),
@@ -2930,6 +2943,7 @@ export const useSettingsStore = defineStore("settings", () => {
if (partial.sidebarAllowHorizontalScroll !== undefined) editorSettings.value.sidebarAllowHorizontalScroll = partial.sidebarAllowHorizontalScroll;
if (partial.sidebarIndent !== undefined) editorSettings.value.sidebarIndent = normalizeSidebarIndent(partial.sidebarIndent);
if (partial.sidebarFontSize !== undefined) editorSettings.value.sidebarFontSize = normalizeSidebarFontSize(partial.sidebarFontSize);
if (partial.sidebarDensity !== undefined) editorSettings.value.sidebarDensity = normalizeSidebarDensity(partial.sidebarDensity);
if (partial.columnFormatters !== undefined) editorSettings.value.columnFormatters = partial.columnFormatters;
if (partial.customColumnFormatters !== undefined) editorSettings.value.customColumnFormatters = partial.customColumnFormatters;
if (partial.globalDateTimeDisplayFormat !== undefined) editorSettings.value.globalDateTimeDisplayFormat = normalizeGlobalDateTimePattern(partial.globalDateTimeDisplayFormat);