feat(sidebar): add node-type search scopes for tree filtering

This commit is contained in:
t8y2 2026-05-21 22:38:18 +08:00
parent e9d58e5aef
commit eef217d6eb
7 changed files with 126 additions and 61 deletions

View File

@ -3,7 +3,7 @@ import { ref, computed, nextTick, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Search, X, ListFilter, Check, FolderPlus } from "lucide-vue-next";
import { useConnectionStore } from "@/stores/connectionStore";
import type { TreeNode } from "@/types/database";
import type { TreeNode, TreeNodeType } from "@/types/database";
import { filterSidebarTree } from "@/lib/sidebarSearchTree";
import { isCancelSearchShortcut } from "@/lib/keyboardShortcuts";
import {
@ -16,7 +16,6 @@ import {
type FlatTreeNode,
} from "@/composables/useFlatTree";
import TreeItem from "./TreeItem.vue";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import { RecycleScroller } from "vue-virtual-scroller";
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
import {
@ -34,7 +33,8 @@ const searchQuery = ref("");
const deferredSearchQuery = ref("");
const searchInputRef = ref<HTMLInputElement>();
const treeScrollerRef = ref<InstanceType<typeof RecycleScroller> | null>(null);
const selectedTypes = ref<string[]>([]);
type SearchScope = "connection" | "database" | "schema" | "table" | "view";
const selectedSearchScopes = ref<SearchScope[]>([]);
const searchCollapsedIds = ref<Set<string>>(new Set());
let searchTimer: number | undefined;
@ -57,67 +57,61 @@ watch(
);
const isSearching = computed(() => !!deferredSearchQuery.value);
const isFiltering = computed(() => !!searchQuery.value.trim() || hasTypeFilter.value);
const isFiltering = computed(() => !!searchQuery.value.trim() || hasSearchScopeFilter.value);
const typeStats = computed(() => {
const map = new Map<string, { profile: string; label: string; count: number }>();
for (const c of store.connections) {
const profile = c.driver_profile || c.db_type;
const existing = map.get(profile);
if (existing) {
existing.count++;
} else {
map.set(profile, { profile, label: c.driver_label || profile, count: 1 });
}
}
return [...map.values()].sort((a, b) => a.label.localeCompare(b.label));
const SEARCH_SCOPE_TO_NODE_TYPES: Record<SearchScope, TreeNodeType[]> = {
connection: ["connection"],
database: ["database", "redis-db", "mongo-db"],
schema: ["schema"],
table: ["table", "mongo-collection"],
view: ["view"],
};
const searchScopeOptions = computed(() => {
return [
{ scope: "connection", label: t("sidebar.searchScopeConnection") },
{ scope: "database", label: t("sidebar.searchScopeDatabase") },
{ scope: "schema", label: t("sidebar.searchScopeSchema") },
{ scope: "table", label: t("sidebar.searchScopeTable") },
{ scope: "view", label: t("sidebar.searchScopeView") },
] as const satisfies ReadonlyArray<{ scope: SearchScope; label: string }>;
});
const hasTypeFilter = computed(() => selectedTypes.value.length > 0);
const hasSearchScopeFilter = computed(() => selectedSearchScopes.value.length > 0);
const searchableNodeTypes = computed<Set<TreeNodeType> | undefined>(() => {
if (!hasSearchScopeFilter.value) return undefined;
const types = new Set<TreeNodeType>();
for (const scope of selectedSearchScopes.value) {
for (const nodeType of SEARCH_SCOPE_TO_NODE_TYPES[scope]) {
types.add(nodeType);
}
}
return types;
});
function isTypeSelected(profile: string) {
return selectedTypes.value.includes(profile);
function isSearchScopeSelected(scope: SearchScope) {
return selectedSearchScopes.value.includes(scope);
}
function toggleType(profile: string) {
const idx = selectedTypes.value.indexOf(profile);
function toggleSearchScope(scope: SearchScope) {
const idx = selectedSearchScopes.value.indexOf(scope);
if (idx >= 0) {
selectedTypes.value.splice(idx, 1);
selectedSearchScopes.value.splice(idx, 1);
} else {
selectedTypes.value.push(profile);
selectedSearchScopes.value.push(scope);
}
}
function clearTypeFilter() {
selectedTypes.value = [];
}
function matchesType(node: TreeNode): boolean {
if (node.type === "connection-group") {
return node.children?.some(matchesType) ?? false;
}
if (node.type !== "connection" || !node.connectionId) return true;
const config = store.getConfig(node.connectionId);
if (!config) return true;
const profile = config.driver_profile || config.db_type;
return selectedTypes.value.includes(profile);
function clearSearchScopeFilter() {
selectedSearchScopes.value = [];
}
const filteredNodes = computed(() => {
let nodes = store.treeNodes;
if (hasTypeFilter.value) {
nodes = nodes.filter(matchesType).map((node) => {
if (node.type === "connection-group" && node.children) {
return { ...node, children: node.children.filter(matchesType) };
}
return node;
});
}
const q = deferredSearchQuery.value;
if (q) {
nodes = filterSidebarTree(nodes, q, searchCollapsedIds.value);
nodes = filterSidebarTree(nodes, q, searchCollapsedIds.value, searchableNodeTypes.value);
}
return nodes;
@ -211,11 +205,11 @@ defineExpose({ focusSearch });
>
<FolderPlus class="h-3.5 w-3.5" />
</button>
<DropdownMenu v-if="typeStats.length > 1">
<DropdownMenu v-if="searchScopeOptions.length > 0">
<DropdownMenuTrigger as-child>
<button
class="shrink-0 h-6 w-6 flex items-center justify-center rounded border border-border hover:bg-accent"
:class="hasTypeFilter ? 'text-primary bg-primary/10 border-primary/30' : 'text-muted-foreground'"
:class="hasSearchScopeFilter ? 'text-primary bg-primary/10 border-primary/30' : 'text-muted-foreground'"
:title="t('sidebar.filterByType')"
>
<ListFilter class="h-3.5 w-3.5" />
@ -225,21 +219,19 @@ defineExpose({ focusSearch });
<DropdownMenuLabel class="text-xs">{{ t("sidebar.filterByType") }}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
v-for="item in typeStats"
:key="item.profile"
v-for="item in searchScopeOptions"
:key="item.scope"
class="gap-2"
:class="isTypeSelected(item.profile) ? 'bg-primary/10 text-primary' : ''"
@select.prevent="toggleType(item.profile)"
:class="isSearchScopeSelected(item.scope) ? 'bg-primary/10 text-primary' : ''"
@select.prevent="toggleSearchScope(item.scope)"
>
<Check v-if="isTypeSelected(item.profile)" class="h-3.5 w-3.5 shrink-0 text-primary" />
<Check v-if="isSearchScopeSelected(item.scope)" class="h-3.5 w-3.5 shrink-0 text-primary" />
<span v-else class="h-3.5 w-3.5 shrink-0" />
<DatabaseIcon :db-type="item.profile" class="h-4 w-4 shrink-0" />
<span class="flex-1 truncate">{{ item.label }}</span>
<span class="text-muted-foreground text-xs">{{ item.count }}</span>
</DropdownMenuItem>
<template v-if="hasTypeFilter">
<template v-if="hasSearchScopeFilter">
<DropdownMenuSeparator />
<DropdownMenuItem @select.prevent="clearTypeFilter">
<DropdownMenuItem @select.prevent="clearSearchScopeFilter">
<span class="text-xs text-muted-foreground">{{ t("sidebar.clearFilter") }}</span>
</DropdownMenuItem>
</template>

View File

@ -70,6 +70,11 @@ export default {
export: "Export Connections",
showMore: "Show {count} more...",
filterByType: "Filter by type",
searchScopeConnection: "Connection",
searchScopeDatabase: "Database",
searchScopeSchema: "Schema",
searchScopeTable: "Table",
searchScopeView: "View",
clearFilter: "Clear filter",
},
savedSql: {

View File

@ -70,6 +70,11 @@ export default {
export: "Exportar conexiones",
showMore: "Mostrar {count} más...",
filterByType: "Filtrar por tipo",
searchScopeConnection: "Conexión",
searchScopeDatabase: "Base de datos",
searchScopeSchema: "Schema",
searchScopeTable: "Tabla",
searchScopeView: "Vista",
clearFilter: "Limpiar filtro",
},
savedSql: {

View File

@ -69,6 +69,11 @@ export default {
export: "导出连接",
showMore: "加载更多 ({count})...",
filterByType: "按类型筛选",
searchScopeConnection: "连接",
searchScopeDatabase: "数据库",
searchScopeSchema: "Schema",
searchScopeTable: "表",
searchScopeView: "视图",
clearFilter: "清除筛选",
},
savedSql: {

View File

@ -1,4 +1,4 @@
import type { TreeNode } from "@/types/database";
import type { TreeNode, TreeNodeType } from "@/types/database";
import { matchSidebarLabel } from "@/lib/sidebarSearch";
const preserveMatchedSubtreeTypes = new Set(["database", "schema", "table", "view"]);
@ -14,7 +14,12 @@ function normalizedLabel(node: TreeNode): string {
return normalized;
}
export function filterSidebarTree(nodes: TreeNode[], query: string, collapsedIds: ReadonlySet<string>): TreeNode[] {
export function filterSidebarTree(
nodes: TreeNode[],
query: string,
collapsedIds: ReadonlySet<string>,
searchableNodeTypes?: ReadonlySet<TreeNodeType>,
): TreeNode[] {
const filteredNodes: { node: TreeNode; score: number }[] = [];
for (const node of nodes) {
@ -27,12 +32,13 @@ export function filterSidebarTree(nodes: TreeNode[], query: string, collapsedIds
}
const label = normalizedLabel(node);
const selfMatch = matchSidebarLabel(label, query);
const canSelfMatch = !searchableNodeTypes || searchableNodeTypes.has(node.type);
const selfMatch = canSelfMatch ? matchSidebarLabel(label, query) : null;
const preservesSubtree = !!selfMatch && preserveMatchedSubtreeTypes.has(node.type);
const filteredChildren = preservesSubtree
? node.children
: node.children
? filterSidebarTree(node.children, query, collapsedIds)
? filterSidebarTree(node.children, query, collapsedIds, searchableNodeTypes)
: undefined;
if (selfMatch || (filteredChildren && filteredChildren.length > 0)) {

View File

@ -0,0 +1,17 @@
import { readFileSync } from "node:fs";
import { strict as assert } from "node:assert";
import test from "node:test";
const source = readFileSync("apps/desktop/src/components/sidebar/ConnectionTree.vue", "utf8");
test("connection tree exposes node search scopes instead of driver profile filters", () => {
assert.match(source, /type SearchScope = "connection" \| "database" \| "schema" \| "table" \| "view"/);
assert.match(source, /const selectedSearchScopes = ref<SearchScope\[]>\(\[\]\)/);
assert.match(source, /filterSidebarTree\(nodes, q, searchCollapsedIds\.value, searchableNodeTypes\.value\)/);
});
test("connection tree filter menu uses sidebar search scope i18n labels", () => {
assert.match(source, /t\("sidebar\.searchScopeConnection"\)/);
assert.match(source, /t\("sidebar\.searchScopeDatabase"\)/);
assert.match(source, /t\("sidebar\.searchScopeTable"\)/);
});

View File

@ -83,3 +83,38 @@ test("preserves loaded schema children when the database itself matches search",
assert.equal(filtered[0]?.label, "hdi");
assert.equal(filtered[0]?.children?.[0]?.label, "public");
});
test("search scope excludes non-selected node self matches", () => {
const nodes: TreeNode[] = [
{
id: "conn:1",
label: "orders-conn",
type: "connection",
connectionId: "conn",
isExpanded: true,
children: [
{
id: "conn:1:db",
label: "orders_db",
type: "database",
connectionId: "conn",
database: "orders_db",
isExpanded: true,
children: [
{
id: "conn:1:db:table",
label: "customers",
type: "table",
connectionId: "conn",
database: "orders_db",
},
],
},
],
},
];
const filtered = filterSidebarTree(nodes, "orders", new Set(), new Set(["table"]));
assert.equal(filtered.length, 0);
});