feat(sidebar): show connection group paths

This commit is contained in:
二丫讲梵 2026-07-14 19:03:27 +08:00 committed by GitHub
parent 69dd363417
commit c2b8a1795a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 251 additions and 10 deletions

View File

@ -14,6 +14,7 @@ import { useSchemaOptions } from "@/composables/useSchemaOptions";
import { connectionIconType } from "@/lib/connection/connectionPresentation";
import { formatDatabaseLabel, isDefaultDatabase } from "@/lib/database/defaultDatabase";
import { connectionDisplayName } from "@/lib/tabs/tabPresentation";
import { buildConnectionGroupPathMap } from "@/lib/sidebar/sidebarLayout";
import { isSingleDatabase, supportsSqlInListPaste, supportsTransaction as supportsTransactionFeature } from "@/lib/database/databaseCapabilities";
import { hexToRgba } from "@/lib/common/color";
import { productionContextForDatabase } from "@/lib/database/productionSafety";
@ -66,6 +67,7 @@ const activeDatabaseOptions = computed(() => {
});
const connectionOptionIds = computed(() => connectionStore.connections.map((connection) => connection.id));
const connectionGroupPaths = computed(() => buildConnectionGroupPathMap(connectionStore.sidebarLayout));
const activeDatabaseValue = computed(() => props.activeTab.database || "");
const activeProductionContext = computed(() => productionContextForDatabase(props.activeConnection, props.activeTab.database));
const showConnectionProductionBadge = computed(() => activeProductionContext.value.reason === "connection");
@ -154,6 +156,10 @@ function connectionById(connectionId: string): ConnectionConfig | undefined {
return connectionStore.getConfig(connectionId);
}
function connectionGroupLabel(connectionId: string): string {
return connectionGroupPaths.value.get(connectionId)?.join(" / ") || t("connectionGroup.ungroupedLabel");
}
function databaseOptionIsProduction(database: string): boolean {
if (!database || props.activeConnection?.is_production) return false;
return productionContextForDatabase(props.activeConnection, database).reason === "database";
@ -326,6 +332,8 @@ function databaseOptionIsProduction(database: string): boolean {
:loading-text="t('common.loading')"
trigger-class="font-medium text-foreground"
:display-name="connectionDisplayName"
list-class="w-96 max-w-[calc(100vw-2rem)]"
item-class="h-9"
@update:model-value="(connectionId) => emit('changeConnection', connectionId)"
>
<template #trigger-label="{ label }">
@ -339,7 +347,10 @@ function databaseOptionIsProduction(database: string): boolean {
<template #option-label="{ option, label }">
<div class="flex min-w-0 items-center gap-2">
<DatabaseIcon :db-type="connectionIconType(connectionById(option))" class="h-3.5 w-3.5 shrink-0" />
<TruncatedTextTooltip :text="label" class="min-w-0 flex-1" side="left" :side-offset="8" />
<div class="flex min-w-0 flex-1 items-center gap-2">
<TruncatedTextTooltip :text="connectionGroupLabel(option)" class="block min-w-0 max-w-48 shrink-0 rounded-sm bg-muted/70 px-1.5 py-0.5 text-[11px] text-muted-foreground" side="left" :side-offset="8" />
<TruncatedTextTooltip :text="label" class="block min-w-[7rem] flex-1 text-sm font-medium" side="left" :side-offset="8" />
</div>
</div>
</template>
</SearchableSelect>

View File

@ -27,6 +27,7 @@ const props = withDefaults(
triggerClass?: HTMLAttributes["class"];
triggerIconClass?: HTMLAttributes["class"];
contentClass?: HTMLAttributes["class"];
listClass?: HTMLAttributes["class"];
itemClass?: HTMLAttributes["class"];
displayName?: (option: string) => string;
optionTooltip?: (option: string) => string | undefined;
@ -236,13 +237,13 @@ function handleKeydown(event: KeyboardEvent) {
</PopoverTrigger>
<PopoverContent :align="SEARCHABLE_SELECT_HELP_PANEL_ALIGN" :class="cn('w-auto max-w-[calc(100vw-1rem)] border-0 bg-transparent p-0 shadow-none ring-0', contentClass)">
<div class="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-start">
<div ref="listCard" class="w-52 shrink-0 rounded-md border bg-popover p-1.5 shadow-md">
<div ref="listCard" :class="cn('w-52 shrink-0 rounded-md border bg-popover p-1.5 shadow-md', listClass)">
<div class="relative rounded-sm border bg-background">
<Search class="pointer-events-none absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" />
<span v-if="!searchText" class="pointer-events-none absolute left-[25px] top-1/2 -translate-y-1/2 text-sm text-muted-foreground">{{ searchPlaceholder }}</span>
<Input ref="searchInput" :model-value="searchText" class="h-6 border-0 pl-6 pr-2 text-sm caret-foreground shadow-none focus-visible:ring-0" @update:model-value="(value) => (searchText = String(value))" @keydown="handleKeydown" />
</div>
<div ref="listContainer" class="max-h-64 overflow-y-auto py-1" @scroll="updateHelpPanelOffset">
<div ref="listContainer" class="dbx-searchable-select-list max-h-64 overflow-y-auto py-1" @scroll="updateHelpPanelOffset">
<div v-if="loading" class="px-2 py-2 text-sm text-muted-foreground">
{{ loadingText }}
</div>
@ -348,4 +349,43 @@ function handleKeydown(event: KeyboardEvent) {
border-color: rgb(147, 197, 253) !important;
box-shadow: 0 0 0 2px rgba(147, 197, 253, 0.24) !important;
}
.dbx-searchable-select-list {
scrollbar-width: thin;
scrollbar-color: color-mix(in oklch, var(--foreground) 30%, transparent) transparent;
}
.dbx-searchable-select-list::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.dbx-searchable-select-list::-webkit-scrollbar-track {
background: transparent;
}
.dbx-searchable-select-list::-webkit-scrollbar-thumb {
border: 1px solid transparent;
border-radius: 999px;
background: color-mix(in oklch, var(--foreground) 30%, transparent);
background-clip: padding-box;
}
.dbx-searchable-select-list:hover::-webkit-scrollbar-thumb {
border: 0;
background: color-mix(in oklch, var(--foreground) 48%, transparent);
}
.dark .dbx-searchable-select-list {
scrollbar-color: rgb(82, 82, 91) transparent;
}
.dark .dbx-searchable-select-list::-webkit-scrollbar-thumb {
background: rgb(82, 82, 91);
background-clip: padding-box;
}
.dark .dbx-searchable-select-list:hover::-webkit-scrollbar-thumb {
background: rgb(113, 113, 122);
}
</style>

View File

@ -595,6 +595,7 @@ export default {
tooltipTitle: "Title:",
tooltipFilePath: "File Path:",
tooltipConnection: "Connection:",
tooltipGroup: "Group:",
tooltipDatabase: "Database:",
tooltipTable: "Table:",
tooltipCollection: "Collection:",
@ -1191,6 +1192,7 @@ export default {
moveToGroup: "Move to Group",
moveToNewGroup: "Move to New Group",
ungrouped: "Ungrouped",
ungroupedLabel: "Ungrouped",
newGroup: "New Group...",
newGroupDefault: "New Group",
deleteGroupConfirmTitle: "Delete Group",

View File

@ -576,6 +576,7 @@ export default withEnglishFallback({
objects: "Objetos",
tooltipTitle: "Título:",
tooltipConnection: "Conexión:",
tooltipGroup: "Grupo:",
tooltipDatabase: "Base de datos:",
tooltipTable: "Tabla:",
tooltipCollection: "Colección:",
@ -1130,6 +1131,7 @@ export default withEnglishFallback({
moveToGroup: "Mover al grupo",
moveToNewGroup: "Mover a nuevo grupo",
ungrouped: "Sin grupo",
ungroupedLabel: "Sin grupo",
newGroup: "Nuevo grupo...",
newGroupDefault: "Nuevo grupo",
deleteGroupConfirmTitle: "Eliminar grupo",

View File

@ -574,6 +574,7 @@ export default withEnglishFallback({
executionSummary: "Riepilogo",
tooltipTitle: "Titolo:",
tooltipConnection: "Connessione:",
tooltipGroup: "Gruppo:",
tooltipDatabase: "Database:",
tooltipTable: "Tabella:",
tooltipCollection: "Collezione:",
@ -1128,6 +1129,7 @@ export default withEnglishFallback({
moveToGroup: "Sposta nel Gruppo",
moveToNewGroup: "Sposta in un Nuovo Gruppo",
ungrouped: "Non raggruppati",
ungroupedLabel: "Non raggruppati",
newGroup: "Nuovo Gruppo...",
newGroupDefault: "Nuovo Gruppo",
deleteGroupConfirmTitle: "Elimina Gruppo",

View File

@ -573,6 +573,7 @@ export default withEnglishFallback({
tooltipTitle: "タイトル:",
tooltipFilePath: "ファイルパス:",
tooltipConnection: "接続:",
tooltipGroup: "グループ:",
tooltipDatabase: "データベース:",
tooltipTable: "テーブル:",
tooltipCollection: "コレクション:",
@ -1129,6 +1130,7 @@ export default withEnglishFallback({
moveToGroup: "グループに移動",
moveToNewGroup: "新しいグループに移動",
ungrouped: "グループなし",
ungroupedLabel: "グループなし",
newGroup: "新しいグループ...",
newGroupDefault: "新しいグループ",
deleteGroupConfirmTitle: "グループを削除",

View File

@ -576,6 +576,7 @@ export default withEnglishFallback({
tooltipTitle: "Título:",
tooltipFilePath: "Caminho do arquivo:",
tooltipConnection: "Conexão:",
tooltipGroup: "Grupo:",
tooltipDatabase: "Banco de dados:",
tooltipTable: "Tabela:",
tooltipCollection: "Coleção:",
@ -1130,6 +1131,7 @@ export default withEnglishFallback({
moveToGroup: "Mover para Grupo",
moveToNewGroup: "Mover para Novo Grupo",
ungrouped: "Sem grupo",
ungroupedLabel: "Sem grupo",
newGroup: "Novo Grupo...",
newGroupDefault: "Novo Grupo",
deleteGroupConfirmTitle: "Excluir Grupo",

View File

@ -597,6 +597,7 @@ export default withEnglishFallback({
tooltipTitle: "标题:",
tooltipFilePath: "文件路径:",
tooltipConnection: "连接:",
tooltipGroup: "分组:",
tooltipDatabase: "数据库:",
tooltipTable: "表:",
tooltipCollection: "集合:",
@ -1193,6 +1194,7 @@ export default withEnglishFallback({
moveToGroup: "移至分组",
moveToNewGroup: "移至新分组",
ungrouped: "取消分组",
ungroupedLabel: "未分组",
newGroup: "新建分组...",
newGroupDefault: "新分组",
deleteGroupConfirmTitle: "删除分组",

View File

@ -576,6 +576,7 @@ export default withEnglishFallback({
tooltipTitle: "標題:",
tooltipFilePath: "檔案路徑:",
tooltipConnection: "連線:",
tooltipGroup: "群組:",
tooltipDatabase: "資料庫:",
tooltipTable: "資料表:",
tooltipCollection: "集合:",
@ -1130,6 +1131,7 @@ export default withEnglishFallback({
moveToGroup: "移至群組",
moveToNewGroup: "移至新群組",
ungrouped: "取消群組",
ungroupedLabel: "未分組",
newGroup: "新增群組……",
newGroupDefault: "新群組",
deleteGroupConfirmTitle: "刪除群組",

View File

@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { findConnectionGroupPath } from "@/lib/sidebar/sidebarLayout";
import type { SidebarLayout } from "@/types/database";
const layout: SidebarLayout = {
groups: [
{ id: "project", name: "Project", collapsed: false },
{ id: "staging", name: "Staging", collapsed: false },
],
order: [
{
type: "group",
id: "project",
children: [
{
type: "group",
id: "staging",
children: [{ type: "connection", id: "nested" }],
},
{ type: "connection", id: "grouped" },
],
},
{ type: "connection", id: "root" },
],
};
describe("findConnectionGroupPath", () => {
it("returns every containing group from root to leaf", () => {
expect(findConnectionGroupPath(layout, "nested")).toEqual(["Project", "Staging"]);
expect(findConnectionGroupPath(layout, "grouped")).toEqual(["Project"]);
});
it("distinguishes a top-level connection from a missing connection", () => {
expect(findConnectionGroupPath(layout, "root")).toEqual([]);
expect(findConnectionGroupPath(layout, "missing")).toBeNull();
});
});

View File

@ -1,6 +1,28 @@
import { describe, expect, it } from "vitest";
import { middleEllipsis, queryResultBaseSql, queryResultExecutionSql, resultSourceRange, tabularResultItems } from "@/lib/tabs/tabPresentation";
import type { QueryTab } from "@/types/database";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createPinia, setActivePinia } from "pinia";
import { useConnectionStore } from "@/stores/connectionStore";
import { connectionGroupDisplayName, middleEllipsis, queryResultBaseSql, queryResultExecutionSql, resultSourceRange, tabTooltipLines, tabularResultItems } from "@/lib/tabs/tabPresentation";
import type { ConnectionConfig, QueryTab } from "@/types/database";
const translations: Record<string, string> = {
"tabs.tooltipConnection": "Connection:",
"tabs.tooltipGroup": "Group:",
"tabs.tooltipDatabase": "Database:",
"connectionGroup.ungroupedLabel": "Ungrouped",
"editor.noDatabase": "No database",
};
const translate = (key: string) => translations[key] ?? key;
beforeEach(() => {
const values = new Map<string, string>();
vi.stubGlobal("localStorage", {
getItem: vi.fn((key: string) => values.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => values.set(key, value)),
removeItem: vi.fn((key: string) => values.delete(key)),
});
setActivePinia(createPinia());
});
function queryTab(overrides: Partial<QueryTab>): QueryTab {
return {
@ -110,6 +132,44 @@ describe("query result labels", () => {
});
});
describe("tab group presentation", () => {
it("adds the full, live group path to tab tooltips", () => {
const store = useConnectionStore();
store.connections = [{ id: "conn-1", name: "PostgreSQL", db_type: "postgres", database: "app" } as ConnectionConfig];
store.sidebarLayout = {
groups: [
{ id: "project", name: "Project", collapsed: false },
{ id: "staging", name: "Staging", collapsed: false },
],
order: [
{
type: "group",
id: "project",
children: [{ type: "group", id: "staging", children: [{ type: "connection", id: "conn-1" }] }],
},
],
};
expect(connectionGroupDisplayName("conn-1", translate)).toBe("Project / Staging");
expect(tabTooltipLines(queryTab({ database: "app" }), translate)).toEqual([
{ label: "Connection:", value: "PostgreSQL" },
{ label: "Group:", value: "Project / Staging" },
{ label: "Database:", value: "app" },
]);
});
it("labels a top-level connection as ungrouped", () => {
const store = useConnectionStore();
store.connections = [{ id: "conn-1", name: "PostgreSQL", db_type: "postgres", database: "app" } as ConnectionConfig];
store.sidebarLayout = {
groups: [],
order: [{ type: "connection", id: "conn-1" }],
};
expect(connectionGroupDisplayName("conn-1", translate)).toBe("Ungrouped");
});
});
describe("query result source ranges", () => {
it("prefers the preserved editor range for a selected duplicate statement", () => {
const sql = "SELECT * FROM users;\nSELECT * FROM users;";

View File

@ -135,6 +135,53 @@ export function findConnectionLocation(layout: SidebarLayout, connectionId: stri
return visit(layout.order);
}
/**
* Returns the display-name path for a connection's containing groups.
* A top-level connection returns an empty path; an absent connection returns null.
*/
export function findConnectionGroupPath(layout: SidebarLayout, connectionId: string): string[] | null {
const groupMap = new Map(layout.groups.map((group) => [group.id, group]));
const visit = (entries: SidebarOrderEntry[], path: string[]): string[] | null => {
for (const entry of entries) {
if (entry.type === "connection") {
if (entry.id === connectionId) return path;
continue;
}
const group = groupMap.get(entry.id);
if (!group) continue;
const found = visit(entryChildren(entry), [...path, group.name]);
if (found) return found;
}
return null;
};
return visit(layout.order, []);
}
/** Build all connection group paths in one traversal for list rendering. */
export function buildConnectionGroupPathMap(layout: SidebarLayout): Map<string, string[]> {
const groupMap = new Map(layout.groups.map((group) => [group.id, group]));
const paths = new Map<string, string[]>();
const visit = (entries: SidebarOrderEntry[], path: string[]) => {
for (const entry of entries) {
if (entry.type === "connection") {
paths.set(entry.id, path);
continue;
}
const group = groupMap.get(entry.id);
if (!group) continue;
visit(entryChildren(entry), [...path, group.name]);
}
};
visit(layout.order, []);
return paths;
}
function findGroupEntry(entries: SidebarOrderEntry[], groupId: string): Extract<SidebarOrderEntry, { type: "group" }> | null {
for (const entry of entries) {
if (entry.type !== "group") continue;

View File

@ -1,5 +1,6 @@
import { useConnectionStore } from "@/stores/connectionStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { findConnectionGroupPath } from "@/lib/sidebar/sidebarLayout";
import { splitMongoCommandRanges } from "@/lib/mongo/mongoShellCommand";
import { executableStatementRanges, splitSqlStatementRanges, type SqlTextRange } from "@/lib/sql/sqlStatementRanges";
import type { ConnectionConfig, DatabaseType, QueryResult, QueryTab } from "@/types/database";
@ -12,6 +13,13 @@ export function connectionDisplayName(connectionId: string): string {
return connectionStore.getConfig(connectionId)?.name || connectionId;
}
export function connectionGroupDisplayName(connectionId: string, t: Translate): string | undefined {
const connectionStore = useConnectionStore();
const path = findConnectionGroupPath(connectionStore.sidebarLayout, connectionId);
if (path === null) return undefined;
return path.join(" / ") || t("connectionGroup.ungroupedLabel");
}
export function connectionColor(connectionId: string): string {
const connectionStore = useConnectionStore();
return connectionStore.getConfig(connectionId)?.color || "";
@ -111,11 +119,9 @@ export function tabDisplayTitle(tab: QueryTab, t: Translate): string {
export function tabTooltipLines(tab: QueryTab, t: Translate): { label: string; value: string }[] {
const connName = connectionDisplayName(tab.connectionId);
const groupName = connectionGroupDisplayName(tab.connectionId, t);
const database = databaseDisplayNameForTab(tab.connectionId, tab.database, t);
const lines: { label: string; value: string }[] = [
{ label: t("tabs.tooltipConnection"), value: connName },
{ label: t("tabs.tooltipDatabase"), value: database },
];
const lines: { label: string; value: string }[] = [{ label: t("tabs.tooltipConnection"), value: connName }, ...(groupName ? [{ label: t("tabs.tooltipGroup"), value: groupName }] : []), { label: t("tabs.tooltipDatabase"), value: database }];
if (tab.mode === "query" && queryTitle(tab)) {
lines.unshift({ label: t("tabs.tooltipTitle"), value: tab.title });
}

View File

@ -14,6 +14,7 @@ import {
emptyLayout,
remapSidebarLayoutConnectionIds,
collapseAllGroups,
buildConnectionGroupPathMap,
} from "../../apps/desktop/src/lib/sidebar/sidebarLayout.ts";
import type { ConnectionConfig, SidebarLayout } from "../../apps/desktop/src/types/database.ts";
@ -34,6 +35,31 @@ function groupConnectionIds(entry: SidebarLayout["order"][number]): string[] {
return (entry.children ?? []).filter((child) => child.type === "connection").map((child) => child.id);
}
test("builds all connection group paths in one layout traversal", () => {
const paths = buildConnectionGroupPathMap({
groups: [
{ id: "project", name: "Project", collapsed: false },
{ id: "staging", name: "Staging", collapsed: false },
],
order: [
{ type: "connection", id: "ungrouped" },
{
type: "group",
id: "project",
children: [
{ type: "connection", id: "project-db" },
{ type: "group", id: "staging", children: [{ type: "connection", id: "staging-db" }] },
],
},
],
});
assert.deepEqual(paths.get("ungrouped"), []);
assert.deepEqual(paths.get("project-db"), ["Project"]);
assert.deepEqual(paths.get("staging-db"), ["Project", "Staging"]);
assert.equal(paths.has("missing"), false);
});
// --- reconcileLayout ---
test("reconcileLayout returns all connections ungrouped when layout is null", () => {