fix(sidebar): separate root connection filtering

This commit is contained in:
二丫讲梵 2026-07-23 17:29:52 +08:00 committed by GitHub
parent ad2d52e3ca
commit b9c1feab82
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 158 additions and 11 deletions

View File

@ -1,13 +1,13 @@
<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, Crosshair, Server, Database, FolderTree, Table2, Eye, RotateCcw } from "@lucide/vue";
import { Search, X, ListFilter, ListOrdered, ArrowDownAZ, ArrowUpZA, CircleDot, Crosshair, Server, Database, FolderTree, Table2, Eye, RotateCcw } from "@lucide/vue";
import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { useToast } from "@/composables/useToast";
import type { ObjectSourceKind, TreeNode, TreeNodeType } from "@/types/database";
import { filterSidebarSearchRootsByConnectionState, filterSidebarTree } from "@/lib/sidebar/sidebarSearchTree";
import { filterSidebarSearchRootsByConnectionState, filterSidebarTree, filterSidebarTreeToConnectedConnections, resolveSidebarFilterGuards } from "@/lib/sidebar/sidebarSearchTree";
import { isCancelSearchShortcut, isCopySidebarSelectionShortcut, isEditSidebarConnectionShortcut, isPasteSidebarSelectionShortcut } from "@/lib/editor/keyboardShortcuts";
import { copyNameForTreeNode, objectSourceKindForTreeNode } from "@/lib/sidebar/treeNodeClick";
import { copyToClipboard } from "@/lib/common/clipboard";
@ -49,6 +49,7 @@ const settingsStore = useSettingsStore();
const { toast } = useToast();
const searchQuery = ref("");
const deferredSearchQuery = ref("");
const showConnectedConnectionsOnly = ref(false);
const searchInputRef = ref<HTMLInputElement>();
const rootRef = ref<HTMLElement>();
const pointerInsideTree = ref(false);
@ -118,7 +119,7 @@ watch(
);
function refreshActiveSidebarTableSearches() {
if (isFiltering.value) return;
if (isTreeSearchFiltering.value) return;
for (const parentNodeId of Object.keys(store.sidebarTableSearchQueries)) {
scheduleSidebarTableSearchRefresh(parentNodeId);
}
@ -200,7 +201,11 @@ function collectExpandedObjectSearchTargets(node: TreeNode, tasks: Promise<void>
}
const isSearching = computed(() => !!deferredSearchQuery.value);
const isFiltering = computed(() => !!searchQuery.value.trim() || hasSearchScopeFilter.value);
const sidebarFilterGuards = computed(() => resolveSidebarFilterGuards(showConnectedConnectionsOnly.value, searchQuery.value, hasSearchScopeFilter.value));
// Connected-only filtering changes only root visibility, so descendant-local
// features stay available while operations requiring the full root list pause.
const isTreeSearchFiltering = computed(() => sidebarFilterGuards.value.isTreeSearchFiltering);
const isRootListPartial = computed(() => sidebarFilterGuards.value.isRootListPartial);
const SEARCH_SCOPE_TO_NODE_TYPES: Record<SearchScope, TreeNodeType[]> = {
connection: ["connection"],
@ -300,7 +305,7 @@ function clearSearchScopeFilter() {
function scheduleSidebarTableSearchRefresh(parentNodeId: string, options?: { restoreFocus?: boolean }) {
window.clearTimeout(tableSearchTimers.get(parentNodeId));
if (isFiltering.value) return;
if (isTreeSearchFiltering.value) return;
const restoreToken = options?.restoreFocus ? ++tableSearchFocusRestoreTokenSeq : 0;
if (restoreToken) {
tableSearchFocusRestoreTokens.clear();
@ -343,6 +348,9 @@ const displayedTreeNodes = computed(() => sortConnectionListForDisplay(store.tre
const filteredNodes = computed(() => {
let nodes = displayedTreeNodes.value;
if (showConnectedConnectionsOnly.value) {
nodes = filterSidebarTreeToConnectedConnections(nodes, store.connectedIds);
}
const q = deferredSearchQuery.value;
nodes = filterSidebarTree(nodes, q, searchCollapsedIds.value, searchableNodeTypes.value);
@ -355,7 +363,7 @@ const filteredNodes = computed(() => {
const flatNodes = computed<FlatTreeNode[]>(() =>
insertSidebarTableSearchControls(flattenTree(filteredNodes.value), {
enabled: settingsStore.editorSettings.sidebarTableSearchEnabled && !isFiltering.value,
enabled: settingsStore.editorSettings.sidebarTableSearchEnabled && !isTreeSearchFiltering.value,
sidebarObjectDisplay: settingsStore.editorSettings.sidebarObjectDisplay,
activeQueries: store.sidebarTableSearchQueries,
}),
@ -512,7 +520,7 @@ watch(
);
const stickyNode = computed<FlatTreeNode | null>(() => {
if (!useVirtualTree.value || isFiltering.value) return null;
if (!useVirtualTree.value || isTreeSearchFiltering.value) return null;
const nodes = flatNodes.value;
const len = nodes.length;
if (len === 0) return null;
@ -717,9 +725,10 @@ async function createNewGroup() {
async function startRenamingCreatedGroup(groupId: string) {
pendingRenameGroupId.value = groupId;
store.selectedTreeNodeId = groupId;
if (isFiltering.value) {
if (isRootListPartial.value) {
searchQuery.value = "";
deferredSearchQuery.value = "";
showConnectedConnectionsOnly.value = false;
clearSearchScopeFilter();
}
@ -754,9 +763,10 @@ async function locateActiveTabInSidebar() {
await ensureTreeLoadedForTarget(initialTarget);
// Clear any active search filter so the node is visible
if (isFiltering.value) {
if (isRootListPartial.value) {
searchQuery.value = "";
deferredSearchQuery.value = "";
showConnectedConnectionsOnly.value = false;
clearSearchScopeFilter();
}
@ -1450,6 +1460,17 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes });
align="end"
@update:model-value="selectSearchScopeMenuItem"
/>
<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"
:title="t('sidebar.showActiveConnectionsOnly')"
@click="showConnectedConnectionsOnly = !showConnectedConnectionsOnly"
>
<CircleDot class="h-3.5 w-3.5" />
</button>
</div>
</div>
<CustomContextMenu ref="sidebarContextMenuRef" :items="sidebarContextMenuItems" v-slot="contextMenuSlot">
@ -1472,7 +1493,7 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes });
<TreeItem
:node="item.node"
:depth="item.depth"
:drag-disabled="isFiltering || isConnectionListAlphabeticallySorted"
:drag-disabled="isRootListPartial || isConnectionListAlphabeticallySorted"
:pending-rename="pendingRenameGroupId === item.node.id"
:highlighted="highlightedNodeId === item.node.id"
:comment-label-width="sidebarCommentLabelWidths.get(item.node.id)"
@ -1496,7 +1517,7 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes });
:key="item.id"
:node="item.node"
:depth="item.depth"
:drag-disabled="isFiltering || isConnectionListAlphabeticallySorted"
:drag-disabled="isRootListPartial || isConnectionListAlphabeticallySorted"
:pending-rename="pendingRenameGroupId === item.node.id"
:highlighted="highlightedNodeId === item.id"
:comment-label-width="sidebarCommentLabelWidths.get(item.node.id)"

View File

@ -95,6 +95,7 @@ export default {
expand: "Expand sidebar",
showMore: "Show {count} more...",
filterByType: "Filter by type",
showActiveConnectionsOnly: "Show active connections only",
sortConnections: "Sort connections",
sortConnectionsManual: "Manual order",
sortConnectionsAscending: "Name: AZ",

View File

@ -97,6 +97,7 @@ export default withEnglishFallback({
expand: "Expandir barra lateral",
showMore: "Mostrar {count} más...",
filterByType: "Filtrar por tipo",
showActiveConnectionsOnly: "Mostrar solo conexiones activas",
sortConnections: "Ordenar conexiones",
sortConnectionsManual: "Orden manual",
sortConnectionsAscending: "Nombre: AZ",

View File

@ -96,6 +96,7 @@ export default withEnglishFallback({
expand: "Espandi barra laterale",
showMore: "Mostra altri {count}...",
filterByType: "Filtra per tipo",
showActiveConnectionsOnly: "Mostra solo connessioni attive",
sortConnections: "Ordina connessioni",
sortConnectionsManual: "Ordine manuale",
sortConnectionsAscending: "Nome: AZ",

View File

@ -97,6 +97,7 @@ export default withEnglishFallback({
expand: "サイドバーを展開",
showMore: "さらに{count}件表示...",
filterByType: "タイプでフィルター",
showActiveConnectionsOnly: "アクティブな接続のみを表示",
sortConnections: "接続を並べ替え",
sortConnectionsManual: "手動順",
sortConnectionsAscending: "名前: AZ",

View File

@ -97,6 +97,7 @@ export default withEnglishFallback({
expand: "Expandir barra lateral",
showMore: "Mostrar mais {count}...",
filterByType: "Filtrar por tipo",
showActiveConnectionsOnly: "Mostrar apenas conexões ativas",
sortConnections: "Ordenar conexões",
sortConnectionsManual: "Ordem manual",
sortConnectionsAscending: "Nome: AZ",

View File

@ -97,6 +97,7 @@ export default withEnglishFallback({
expand: "展开侧边栏",
showMore: "加载更多 ({count})...",
filterByType: "按类型筛选",
showActiveConnectionsOnly: "仅显示活跃连接",
sortConnections: "连接排序",
sortConnectionsManual: "手动排序",
sortConnectionsAscending: "名称AZ",

View File

@ -97,6 +97,7 @@ export default withEnglishFallback({
expand: "展開側邊欄",
showMore: "再顯示 {count} 個……",
filterByType: "依類型篩選",
showActiveConnectionsOnly: "僅顯示作用中連線",
sortConnections: "連線排序",
sortConnectionsManual: "手動排序",
sortConnectionsAscending: "名稱AZ",

View File

@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { filterSidebarTreeToConnectedConnections } from "@/lib/sidebar/sidebarSearchTree";
import type { TreeNode } from "@/types/database";
function connection(id: string): TreeNode {
return {
id,
label: id,
type: "connection",
connectionId: id,
isExpanded: true,
children: [{ id: `${id}:database`, label: "database", type: "database", connectionId: id }],
};
}
function group(id: string, children: TreeNode[]): TreeNode {
return { id, label: id, type: "connection-group", isExpanded: true, children };
}
describe("connected sidebar connection filter", () => {
it("keeps connected connections and their nested groups while removing disconnected connections and empty groups", () => {
const connected = connection("connected");
const tree = [connection("disconnected"), group("team", [group("production", [connected]), group("inactive", [connection("other")])])];
const result = filterSidebarTreeToConnectedConnections(tree, new Set(["connected"]));
expect(result).toHaveLength(1);
expect(result[0]?.id).toBe("team");
expect(result[0]?.children?.[0]?.id).toBe("production");
expect(result[0]?.children?.[0]?.children?.[0]).toBe(connected);
expect(result[0]?.children?.some((node) => node.id === "inactive")).toBe(false);
});
it("preserves the existing tree when every displayed connection is active", () => {
const first = connection("first");
const tree = [group("team", [first])];
expect(filterSidebarTreeToConnectedConnections(tree, new Set(["first"]))).toBe(tree);
});
});

View File

@ -0,0 +1,31 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { resolveSidebarFilterGuards } from "@/lib/sidebar/sidebarSearchTree";
describe("sidebar filter guards", () => {
it.each([
{ connectedOnly: false, query: "", scoped: false, treeSearch: false, rootPartial: false },
{ connectedOnly: true, query: "", scoped: false, treeSearch: false, rootPartial: true },
{ connectedOnly: false, query: "table", scoped: false, treeSearch: true, rootPartial: true },
{ connectedOnly: true, query: "table", scoped: false, treeSearch: true, rootPartial: true },
{ connectedOnly: false, query: " ", scoped: true, treeSearch: true, rootPartial: true },
{ connectedOnly: true, query: " ", scoped: true, treeSearch: true, rootPartial: true },
{ connectedOnly: false, query: "table", scoped: true, treeSearch: true, rootPartial: true },
{ connectedOnly: true, query: "table", scoped: true, treeSearch: true, rootPartial: true },
])("separates connected-only=$connectedOnly query=$query scoped=$scoped", ({ connectedOnly, query, scoped, treeSearch, rootPartial }) => {
expect(resolveSidebarFilterGuards(connectedOnly, query, scoped)).toEqual({
isTreeSearchFiltering: treeSearch,
isRootListPartial: rootPartial,
});
});
it("keeps descendant-local features separate from partial-root operations", () => {
const source = readFileSync(new URL("../../../components/sidebar/ConnectionTree.vue", import.meta.url), "utf8");
expect(source).toContain("sidebarTableSearchEnabled && !isTreeSearchFiltering.value");
expect(source).toContain("!useVirtualTree.value || isTreeSearchFiltering.value");
expect(source.match(/if \(isRootListPartial\.value\)/g)).toHaveLength(2);
expect(source.match(/:drag-disabled="isRootListPartial \|\| isConnectionListAlphabeticallySorted"/g)).toHaveLength(2);
expect(source).not.toContain("isFiltering");
});
});

View File

@ -79,3 +79,51 @@ export function filterSidebarSearchRootsByConnectionState(nodes: TreeNode[], con
return node.connectionId ? connectedIds.has(node.connectionId) : true;
});
}
export function resolveSidebarFilterGuards(showConnectedConnectionsOnly: boolean, searchQuery: string, hasSearchScopeFilter: boolean) {
const isTreeSearchFiltering = !!searchQuery.trim() || hasSearchScopeFilter;
return {
isTreeSearchFiltering,
isRootListPartial: showConnectedConnectionsOnly || isTreeSearchFiltering,
};
}
/**
* Produces a display-only connection tree containing connected connections and
* the groups that contain them. Connection descendants stay intact because
* this filter controls the connection list, not database-object visibility.
*/
export function filterSidebarTreeToConnectedConnections(nodes: readonly TreeNode[], connectedIds: ReadonlySet<string>): TreeNode[] {
let changed = false;
const filtered: TreeNode[] = [];
for (const node of nodes) {
if (node.type === "connection") {
if (node.connectionId && connectedIds.has(node.connectionId)) {
filtered.push(node);
} else {
changed = true;
}
continue;
}
if (node.type !== "connection-group") {
filtered.push(node);
continue;
}
const children = filterSidebarTreeToConnectedConnections(node.children ?? [], connectedIds);
if (children.length === 0) {
changed = true;
continue;
}
if (children !== node.children) {
changed = true;
filtered.push({ ...node, children });
} else {
filtered.push(node);
}
}
return changed ? filtered : (nodes as TreeNode[]);
}