fix(sidebar): improve active connection indicator

This commit is contained in:
zipg authored and GitHub committed 2026-08-25 21:54:53 +08:00
1 parent c6e9e94c58
commit 86f36e51ad
13 files changed
+162 -17

No files matched your search

@@ -0,0 +1,37 @@
<script setup lang="ts">
import { useId } from "vue";
import { useI18n } from "vue-i18n";
import { CircleDot } from "@lucide/vue";
import LightTooltip from "@/components/ui/LightTooltip.vue";
defineProps<{
activeConnectionCount: number;
pressed: boolean;
}>();
const emit = defineEmits<{
toggle: [];
}>();
const { t } = useI18n();
const activeConnectionStatusId = useId();
</script>
<template>
<LightTooltip :text="t('sidebar.showActiveConnectionsOnly')" side="top" :delay="300" nowrap>
<button
type="button"
data-active-connection-filter
class="relative shrink-0 h-6 w-6 flex items-center justify-center rounded border hover:bg-accent"
:class="pressed ? 'text-primary bg-primary/10 border-primary/30' : 'border-border text-muted-foreground hover:text-foreground'"
:aria-label="t('sidebar.showActiveConnectionsOnly')"
:aria-describedby="activeConnectionStatusId"
:aria-pressed="pressed"
@click="emit('toggle')"
>
<CircleDot data-active-connection-filter-icon class="h-3.5 w-3.5" />
<span v-if="activeConnectionCount > 0" data-active-connection-badge class="pointer-events-none absolute right-0.5 top-0.5 h-1.5 w-1.5 rounded-full bg-green-500 ring-1 ring-background" aria-hidden="true" />
<span :id="activeConnectionStatusId" class="sr-only" aria-live="polite" aria-atomic="true">{{ t("sidebar.activeConnectionsStatus", { count: activeConnectionCount }) }}</span>
</button>
</LightTooltip>
</template>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, shallowRef, computed, nextTick, watch, provide, onMounted, onUnmounted, type Component, type ComponentPublicInstance, type CSSProperties } from "vue";
import { useI18n } from "vue-i18n";
import { Search, X, ListFilter, ListOrdered, ArrowDownAZ, ArrowUpZA, CircleDot, Crosshair, Server, Database, FolderTree, Table2, Eye, RotateCcw, Loader2, Unplug } from "@lucide/vue";
import { Search, X, ListFilter, ListOrdered, ArrowDownAZ, ArrowUpZA, Crosshair, Server, Database, FolderTree, Table2, Eye, RotateCcw, Loader2, Unplug } from "@lucide/vue";
import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
import { useSavedSqlStore } from "@/stores/savedSqlStore";
@@ -55,6 +55,7 @@ import { createSidebarPasteHandlerRegistry } from "@/lib/sidebar/sidebarPasteHan
import { insertSidebarTableSearchControls, isSidebarTableSearchControlNode } from "@/lib/sidebar/sidebarTableSearchControl";
import { createSidebarTableSearchDebouncer, invalidateSidebarTableSearchBuild, loadOrBuildSidebarTableSearchIndex, scheduleExclusiveSidebarTableSearchDebounce } from "@/lib/sidebar/sidebarTableSearchIndex";
import TreeItem from "./TreeItem.vue";
import ActiveConnectionFilterButton from "./ActiveConnectionFilterButton.vue";
import SidebarTreeRuntimeHost from "./SidebarTreeRuntimeHost.vue";
import SidebarTreeItemDialogs from "./SidebarTreeItemDialogs.vue";
import InstallExtensionDialog from "@/components/objects/InstallExtensionDialog.vue";
@@ -2454,18 +2455,7 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes, locateTabInSid
/>
</span>
</LightTooltip>
<LightTooltip :text="t('sidebar.showActiveConnectionsOnly')" side="top" :delay="300" nowrap>
<button
type="button"
class="shrink-0 h-6 w-6 flex items-center justify-center rounded border hover:bg-accent"
:class="showConnectedConnectionsOnly ? 'text-primary bg-primary/10 border-primary/30' : 'border-border text-muted-foreground hover:text-foreground'"
:aria-label="t('sidebar.showActiveConnectionsOnly')"
:aria-pressed="showConnectedConnectionsOnly"
@click="showConnectedConnectionsOnly = !showConnectedConnectionsOnly"
>
<CircleDot class="h-3.5 w-3.5" />
</button>
</LightTooltip>
<ActiveConnectionFilterButton :active-connection-count="store.connectedIds.size" :pressed="showConnectedConnectionsOnly" @toggle="showConnectedConnectionsOnly = !showConnectedConnectionsOnly" />
</div>
</div>
<CustomContextMenu ref="sidebarContextMenuRef" :items="sidebarContextMenuItems" v-slot="contextMenuSlot">
@@ -0,0 +1,105 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, reactive, type App } from "vue";
import { createI18n } from "vue-i18n";
import { afterEach, describe, expect, it, vi } from "vitest";
import ActiveConnectionFilterButton from "../ActiveConnectionFilterButton.vue";
vi.mock("@/components/ui/LightTooltip.vue", async () => {
const { defineComponent, h } = await import("vue");
return {
default: defineComponent({
name: "LightTooltipStub",
setup:
(_, { slots }) =>
() =>
h("div", slots.default?.()),
}),
};
});
const mountedApps: App[] = [];
async function mountFilter(activeConnectionCount: number) {
const state = reactive({ activeConnectionCount, pressed: false });
const host = defineComponent({
setup: () => () =>
h(ActiveConnectionFilterButton, {
activeConnectionCount: state.activeConnectionCount,
pressed: state.pressed,
onToggle: () => {
state.pressed = !state.pressed;
},
}),
});
const container = document.createElement("div");
document.body.append(container);
const app = createApp(host);
app.use(
createI18n({
legacy: false,
locale: "en",
messages: {
en: {
sidebar: {
activeConnectionsStatus: "Active connections: {count}",
showActiveConnectionsOnly: "Show active connections only",
},
},
},
}),
);
mountedApps.push(app);
app.mount(container);
await nextTick();
const button = container.querySelector("[data-active-connection-filter]");
if (!(button instanceof HTMLButtonElement)) throw new Error("Active connection filter button was not rendered");
return { button, state };
}
function describedStatus(button: HTMLButtonElement) {
const statusId = button.getAttribute("aria-describedby");
if (!statusId) throw new Error("Active connection status description is missing");
const status = document.getElementById(statusId);
if (!(status instanceof HTMLSpanElement)) throw new Error("Active connection status element was not rendered");
return status;
}
afterEach(() => {
for (const app of mountedApps.splice(0)) app.unmount();
document.body.innerHTML = "";
});
describe("ActiveConnectionFilterButton", () => {
it("keeps the filter icon stable while the active connection status changes from 0 to 1 to 0", async () => {
const { button, state } = await mountFilter(0);
expect(button.querySelector("[data-active-connection-filter-icon]")).not.toBeNull();
expect(button.querySelector("[data-active-connection-badge]")).toBeNull();
expect(describedStatus(button).textContent).toBe("Active connections: 0");
state.activeConnectionCount = 1;
await nextTick();
expect(button.querySelector("[data-active-connection-filter-icon]")).not.toBeNull();
expect(button.querySelector("[data-active-connection-badge]")).not.toBeNull();
expect(describedStatus(button).textContent).toBe("Active connections: 1");
state.activeConnectionCount = 0;
await nextTick();
expect(button.querySelector("[data-active-connection-filter-icon]")).not.toBeNull();
expect(button.querySelector("[data-active-connection-badge]")).toBeNull();
expect(describedStatus(button).textContent).toBe("Active connections: 0");
});
it("preserves the pressed and unpressed filter states", async () => {
const { button } = await mountFilter(1);
expect(button.getAttribute("aria-pressed")).toBe("false");
button.click();
await nextTick();
expect(button.getAttribute("aria-pressed")).toBe("true");
button.click();
await nextTick();
expect(button.getAttribute("aria-pressed")).toBe("false");
});
});
+1
View File
@@ -322,6 +322,7 @@ export default {
showMore: "Show {count} more...",
filterByType: "Filter by type",
showActiveConnectionsOnly: "Show active connections only",
activeConnectionsStatus: "Active connections: {count}",
disconnectAllActiveConnections: "Disconnect all active connections",
sortConnections: "Sort connections",
sortConnectionsManual: "Manual order",
+1
View File
@@ -324,6 +324,7 @@ export default withEnglishFallback({
showMore: "Mostrar {count} más...",
filterByType: "Filtrar por tipo",
showActiveConnectionsOnly: "Mostrar solo conexiones activas",
activeConnectionsStatus: "Conexiones activas: {count}",
disconnectAllActiveConnections: "Desconectar todas las conexiones activas",
sortConnections: "Ordenar conexiones",
sortConnectionsManual: "Orden manual",
+1
View File
@@ -323,6 +323,7 @@ export default withEnglishFallback({
showMore: "Mostra altri {count}...",
filterByType: "Filtra per tipo",
showActiveConnectionsOnly: "Mostra solo connessioni attive",
activeConnectionsStatus: "Connessioni attive: {count}",
disconnectAllActiveConnections: "Disconnetti tutte le connessioni attive",
sortConnections: "Ordina connessioni",
sortConnectionsManual: "Ordine manuale",
+1
View File
@@ -324,6 +324,7 @@ export default withEnglishFallback({
showMore: "さらに{count}件表示...",
filterByType: "タイプでフィルター",
showActiveConnectionsOnly: "アクティブな接続のみを表示",
activeConnectionsStatus: "アクティブな接続: {count}",
disconnectAllActiveConnections: "すべてのアクティブな接続を切断",
sortConnections: "接続を並べ替え",
sortConnectionsManual: "手動順",
+1
View File
@@ -322,6 +322,7 @@ export default withEnglishFallback({
showMore: "{count}개 더 보기...",
filterByType: "유형별로 필터",
showActiveConnectionsOnly: "활성 연결만 표시",
activeConnectionsStatus: "활성 연결: {count}",
disconnectAllActiveConnections: "모든 활성 연결 끊기",
sortConnections: "연결 정렬",
sortConnectionsManual: "수동 순서",
+1
View File
@@ -324,6 +324,7 @@ export default withEnglishFallback({
showMore: "Mostrar mais {count}...",
filterByType: "Filtrar por tipo",
showActiveConnectionsOnly: "Mostrar apenas conexões ativas",
activeConnectionsStatus: "Conexões ativas: {count}",
disconnectAllActiveConnections: "Desconectar todas as conexões ativas",
sortConnections: "Ordenar conexões",
sortConnectionsManual: "Ordem manual",
+1
View File
@@ -247,6 +247,7 @@ export default withEnglishFallback({
showMore: "加载更多 ({count})...",
filterByType: "按类型筛选",
showActiveConnectionsOnly: "仅显示活跃连接",
activeConnectionsStatus: "活跃连接:{count}",
disconnectAllActiveConnections: "关闭所有活跃连接",
sortConnections: "连接排序",
sortConnectionsManual: "手动排序",
+1
View File
@@ -324,6 +324,7 @@ export default withEnglishFallback({
showMore: "再顯示 {count} 個……",
filterByType: "依類型篩選",
showActiveConnectionsOnly: "僅顯示作用中連線",
activeConnectionsStatus: "作用中連線:{count}",
disconnectAllActiveConnections: "關閉所有作用中連線",
sortConnections: "連線排序",
sortConnectionsManual: "手動排序",
@@ -9,6 +9,7 @@ const dialogOverlaySource = readFileSync(new URL("../../components/ui/dialog/Dia
const dataTransferDialogSource = readFileSync(new URL("../../components/transfer/DataTransferDialog.vue", import.meta.url), "utf8");
const connectionDialogSource = readFileSync(new URL("../../components/connection/ConnectionDialog.vue", import.meta.url), "utf8");
const connectionTreeSource = readFileSync(new URL("../../components/sidebar/ConnectionTree.vue", import.meta.url), "utf8");
const activeConnectionFilterSource = readFileSync(new URL("../../components/sidebar/ActiveConnectionFilterButton.vue", import.meta.url), "utf8");
const scheduledDatabaseBackupSource = readFileSync(new URL("../../components/backup/ScheduledDatabaseBackupSettings.vue", import.meta.url), "utf8");
const driverStoreDialogSource = readFileSync(new URL("../../components/config/DriverStoreDialog.vue", import.meta.url), "utf8");
const tunnelProfileManagerSource = readFileSync(new URL("../../components/connection/TunnelProfileManager.vue", import.meta.url), "utf8");
@@ -97,9 +98,10 @@ describe("legacy WebView CSS fallbacks", () => {
expect(globalsCss).toContain(".border-primary\\/30");
expect(globalsCss).toContain("border-color: rgba(var(--dbx-primary-rgb), 0.3) !important;");
expect(globalsCss).toContain(".hover\\:bg-primary\\/15:hover");
expect(connectionTreeSource).toContain("showActiveConnectionsOnly");
const activeConnectionSources = `${connectionTreeSource}\n${activeConnectionFilterSource}`;
expect(activeConnectionSources).toContain("showActiveConnectionsOnly");
// Three pre-existing usages plus the sidebar regex-search toggle.
expect(connectionTreeSource.match(/bg-primary\/10 border-primary\/30/g)?.length).toBe(4);
expect(activeConnectionSources.match(/bg-primary\/10 border-primary\/30/g)?.length).toBe(4);
});
it("keeps legacy tab triggers connected to the configured corner style", () => {