feat(sidebar): add node-type search scopes for tree filtering
This commit is contained in:
parent
e9d58e5aef
commit
eef217d6eb
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -69,6 +69,11 @@ export default {
|
|||
export: "导出连接",
|
||||
showMore: "加载更多 ({count})...",
|
||||
filterByType: "按类型筛选",
|
||||
searchScopeConnection: "连接",
|
||||
searchScopeDatabase: "数据库",
|
||||
searchScopeSchema: "Schema",
|
||||
searchScopeTable: "表",
|
||||
searchScopeView: "视图",
|
||||
clearFilter: "清除筛选",
|
||||
},
|
||||
savedSql: {
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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"\)/);
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue