feat(sidebar): expose visible object filters in the connection tree

* feat(sidebar): expose visible object filter

* fix(sidebar): refine visible filter status

* refactor(sidebar): move visible filter status to details

* fix(sidebar): clarify visible filter action
This commit is contained in:
Guoyu Su authored and GitHub committed 2026-08-07 03:24:25 +08:00
1 parent cc5a4068d2
commit dfb9971849
26 files changed
+793 -9

No files matched your search

@@ -4828,6 +4828,11 @@ function handleRowKeydown(node: TreeNode, event: KeyboardEvent) {
onKeydown(event);
}
function openPrimaryVisibleFilter(node: TreeNode) {
activateRuntimeNode(node);
openVisibleDatabasesDialog();
}
function openDataInNewTab(node: TreeNode) {
activateRuntimeNode(node);
openDataInNewTabImmediately(node);
@@ -4848,6 +4853,7 @@ defineExpose({
handleRowClick,
handleRowDoubleClick,
handleRowKeydown,
openPrimaryVisibleFilter,
openDataInNewTab,
requestPaste,
toggleNode,
@@ -62,6 +62,7 @@ import { shouldMeasureSidebarLabelOverflow } from "@/lib/sidebar/sidebarLabelToo
import { treeSelectionRangeIdsByIndex, treeSelectionRangeIds } from "@/lib/sidebar/sidebarTreeSelection";
import { isSidebarDatabaseOpenForVisual } from "@/lib/sidebar/sidebarDatabaseOpenState";
import { sidebarTreeContextKey } from "@/lib/sidebar/sidebarTreeContext";
import { connectionCanConfigureSidebarVisibleDatabases } from "@/lib/sidebar/sidebarVisibleFilterMenu";
import { isWindows } from "@/lib/backend/platform";
import { flattenTree } from "@/composables/useFlatTree";
import { productionContextForDatabase } from "@/lib/database/productionSafety";
@@ -364,6 +365,8 @@ type DetailTooltipRow = {
multiline?: boolean;
/** When set, renders each value on its own line (e.g. one host per line) */
values?: string[];
action?: () => void;
actionLabel?: string;
};
function cleanTooltipValue(value: string | number | null | undefined): string {
@@ -420,6 +423,16 @@ const detailTooltip = computed(() => {
.map((h) => h.trim())
.filter(Boolean)
: [];
const visibleFilterSummary = connectionCanConfigureSidebarVisibleDatabases(config.db_type) ? connectionStore.getSidebarVisibleFilterSummary(node.connectionId) : null;
const visibleFilterRow: DetailTooltipRow | null =
visibleFilterSummary?.selected != null && visibleFilterSummary.total != null
? {
label: t(visibleFilterSummary.mode === "schema" ? "visibleSchemas.detailLabel" : "visibleDatabases.detailLabel"),
value: `${visibleFilterSummary.selected}/${visibleFilterSummary.total}`,
action: () => treeRuntime.openPrimaryVisibleFilter(node),
actionLabel: t(visibleFilterSummary.mode === "schema" ? "visibleSchemas.detailActionLabel" : "visibleDatabases.detailActionLabel", { connection: config.name }),
}
: null;
const rows: DetailTooltipRow[] = [
{ label: t("connection.name"), value: cleanTooltipValue(config.name) },
{ label: "URL", value: connectionTooltipUrl(config), multiline: true },
@@ -429,6 +442,7 @@ const detailTooltip = computed(() => {
{ label: t("connection.user"), value: cleanTooltipValue(config.username) },
{ label: t("connection.type"), value: config.driver_label || config.driver_profile || config.db_type },
{ label: t("connection.databaseInfo.productVersion"), value: cleanTooltipValue(config.database_info?.productVersion) },
...(visibleFilterRow ? [visibleFilterRow] : []),
{ label: t("connection.note"), value: cleanTooltipValue(config.note), multiline: true },
].filter((row) => row.value);
return { rows };
@@ -1248,6 +1262,16 @@ function onKeydown(event: KeyboardEvent) {
<span v-for="(v, vi) in row.values" :key="vi" class="break-all">{{ v }}</span>
</div>
</template>
<button
v-else-if="row.action"
type="button"
class="w-fit rounded bg-primary/10 px-1 font-mono text-primary underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
:aria-label="row.actionLabel"
:title="row.actionLabel"
@click.stop="row.action()"
>
{{ row.value }}
</button>
<span v-else-if="row.multiline" class="max-h-20 overflow-hidden whitespace-pre-wrap break-words text-foreground/90">
{{ row.value }}
</span>
@@ -84,6 +84,7 @@ async function loadDatabases() {
searchText.value = "";
try {
const names = await loadObjectNames();
connectionStore.recordPrimaryVisibleObjectNames(props.connectionId, names);
objectNames.value = names;
showSystemDatabases.value = false;
const configured = isSchemaFilterMode.value ? connection.value?.visible_schemas?.[databaseKey.value] : connection.value?.visible_databases;
@@ -61,6 +61,7 @@ function runtimeHost(): SidebarTreeRuntimeHost {
handleRowClick: vi.fn(),
handleRowDoubleClick: vi.fn(),
handleRowKeydown: vi.fn(),
openPrimaryVisibleFilter: vi.fn(),
openDataInNewTab: vi.fn(),
requestPaste: vi.fn(() => false),
toggleNode: vi.fn(),
@@ -0,0 +1,180 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, 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 { SidebarVisibleFilterSummary } from "@/lib/sidebar/sidebarVisibleFilterSummary";
import type { ConnectionConfig, TreeNode } from "@/types/database";
const state: {
config: ConnectionConfig;
summary: SidebarVisibleFilterSummary;
} = {
config: mysqlConnection(),
summary: { mode: "database", isActive: true, selected: 2, total: 3 },
};
function mysqlConnection(): ConnectionConfig {
return {
id: "connection-1",
name: "Filtered connection",
db_type: "mysql",
host: "127.0.0.1",
port: 3306,
username: "root",
password: "",
database: "filter_alpha",
} as ConnectionConfig;
}
const connectionStore = {
activeConnectionId: "connection-1",
connectedIds: new Set(["connection-1"]),
connectingIds: new Set<string>(),
connectionErrors: {},
connectionMultiSelectActive: false,
connections: [],
getConfig: () => state.config,
getSidebarVisibleFilterSummary: () => state.summary,
clearConnectionError: vi.fn(),
isDefaultDatabase: () => 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,
};
vi.mock("@/stores/connectionStore", () => ({
useConnectionStore: () => connectionStore,
}));
vi.mock("@/stores/queryStore", () => ({
useQueryStore: () => ({ openDatabaseKeys: new Set<string>() }),
}));
vi.mock("@/stores/settingsStore", () => ({
useSettingsStore: () => ({
editorSettings: {
shortcuts: { openDataInNewTab: "" },
sidebarActivation: "double",
sidebarAllowHorizontalScroll: false,
sidebarHiddenTablePrefixes: [],
sidebarObjectInfoMode: "none",
},
}),
}));
vi.mock("@/composables/useToast", () => ({
useToast: () => ({ toast: vi.fn() }),
}));
const mountedApps: App[] = [];
function runtimeHost(): SidebarTreeRuntimeHost {
return {
buildContextMenu: vi.fn(() => []),
handleRowClick: vi.fn(),
handleRowDoubleClick: vi.fn(),
handleRowKeydown: vi.fn(),
openPrimaryVisibleFilter: vi.fn(),
openDataInNewTab: vi.fn(),
requestPaste: vi.fn(() => false),
toggleNode: vi.fn(),
};
}
async function openConnectionTooltip() {
vi.useFakeTimers();
const container = document.createElement("div");
document.body.append(container);
const node: TreeNode = {
id: "connection-1",
label: "Filtered connection",
type: "connection",
connectionId: "connection-1",
isExpanded: true,
children: [],
};
const app = createApp(
defineComponent({
setup: () => () => h(TreeItem, { node, depth: 0 }),
}),
);
mountedApps.push(app);
const runtime = createSidebarTreeRuntime();
const host = runtimeHost();
runtime.bindHost(host);
app.use(i18n);
app.provide(sidebarTreeRuntimeKey, runtime);
app.mount(container);
await nextTick();
const row = container.querySelector<HTMLElement>("[tabindex]");
if (!row) throw new Error(`Connection row was not rendered: ${container.innerHTML}`);
const trigger = row.parentElement;
if (!trigger) throw new Error(`Connection tooltip trigger was not rendered: ${container.innerHTML}`);
vi.spyOn(row, "matches").mockImplementation((selector) => selector === ":hover");
trigger.dispatchEvent(new MouseEvent("mouseenter"));
await vi.runAllTimersAsync();
await nextTick();
return { host, node };
}
afterEach(() => {
for (const app of mountedApps.splice(0)) app.unmount();
document.body.replaceChildren();
vi.useRealTimers();
state.config = mysqlConnection();
state.summary = { mode: "database", isActive: true, selected: 2, total: 3 };
});
describe("TreeItem visible filter connection detail", () => {
it("shows an active database filter count in the connection tooltip", async () => {
const { host, node } = await openConnectionTooltip();
expect(document.body.textContent).toContain("Visible databases");
expect(document.body.textContent).toContain("2/3");
const action = [...document.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.trim() === "2/3");
expect(action).toBeDefined();
expect(action?.getAttribute("aria-label")).toBe('Configure visible databases for "Filtered connection"');
expect(action?.classList.contains("bg-primary/10")).toBe(true);
expect(action?.classList.contains("underline")).toBe(false);
expect(action?.classList.contains("hover:underline")).toBe(true);
expect(action?.classList.contains("hover:bg-primary/10")).toBe(false);
action?.click();
expect(host.openPrimaryVisibleFilter).toHaveBeenCalledWith(node);
});
it("uses the schema label for a schema-mode summary", async () => {
state.config = { ...mysqlConnection(), db_type: "oracle", database: "ORCL" };
state.summary = { mode: "schema", isActive: true, selected: 1, total: 4 };
await openConnectionTooltip();
expect(document.body.textContent).toContain("Visible schemas");
expect(document.body.textContent).toContain("1/4");
const action = [...document.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.trim() === "1/4");
expect(action?.getAttribute("aria-label")).toBe('Configure visible schemas for "Filtered connection"');
});
it("shows a clickable detail row when the effective selection is full", async () => {
state.summary = { mode: "database", isActive: false, selected: 3, total: 3 };
const { host, node } = await openConnectionTooltip();
expect(document.body.textContent).toContain("Visible databases");
expect(document.body.textContent).toContain("3/3");
const action = [...document.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.trim() === "3/3");
expect(action).toBeDefined();
action?.click();
expect(host.openPrimaryVisibleFilter).toHaveBeenCalledWith(node);
});
});