feat(sidebar): add local table search

This commit is contained in:
二丫讲梵 2026-07-05 00:09:04 +08:00 committed by GitHub
parent 8a1a791927
commit fdaa949fb1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 421 additions and 17 deletions

View File

@ -19,6 +19,7 @@ import { activeTabSidebarTarget, findSidebarNodeForActiveTab, findSidebarNodeFor
import { findLoadedTableTargetForCandidate, queryContextTargetFromCandidate, queryCursorTableCandidate, type QueryCursorTableCandidate } from "@/lib/sql/queryCursorTableTarget";
import { SIDEBAR_TREE_ROW_HEIGHT, SIDEBAR_TREE_PRERENDER_COUNT, SIDEBAR_TREE_SCROLL_BUFFER, flattenTree, shouldVirtualizeFlatTree, type FlatTreeNode } from "@/composables/useFlatTree";
import { sidebarTreeContextKey } from "@/lib/sidebar/sidebarTreeContext";
import { insertSidebarTableSearchControls, isSidebarTableSearchControlNode } from "@/lib/sidebar/sidebarTableSearchControl";
import TreeItem from "./TreeItem.vue";
import { RecycleScroller } from "vue-virtual-scroller";
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
@ -42,6 +43,10 @@ const selectedSearchScopes = ref<SearchScope[]>([]);
const searchCollapsedIds = ref<Set<string>>(new Set());
const searchRefreshedNodeIds = new Set<string>();
let searchTimer: number | undefined;
const tableSearchTimers = new Map<string, number>();
const tableSearchFocusRestoreTokens = new Map<string, number>();
let tableSearchFocusRestoreTokenSeq = 0;
let latestTableSearchInteractionParentId: string | null = null;
watch(
searchQuery,
@ -61,6 +66,13 @@ watch(
{ flush: "sync" },
);
function refreshActiveSidebarTableSearches() {
if (isFiltering.value) return;
for (const parentNodeId of Object.keys(store.sidebarTableSearchQueries)) {
scheduleSidebarTableSearchRefresh(parentNodeId);
}
}
watch(deferredSearchQuery, (newQuery, oldQuery) => {
store.sidebarSearchQuery = newQuery;
const tasks: Promise<void>[] = [];
@ -70,7 +82,11 @@ watch(deferredSearchQuery, (newQuery, oldQuery) => {
if (!newQuery && oldQuery) {
searchRefreshedNodeIds.clear();
}
Promise.all(tasks).catch(() => {});
Promise.all(tasks)
.then(() => {
if (!newQuery && oldQuery) refreshActiveSidebarTableSearches();
})
.catch(() => {});
});
const searchableObjectGroupTypes = new Set<TreeNodeType>(["group-tables", "group-views", "group-materialized-views"]);
@ -78,7 +94,7 @@ const simpleObjectParentTypes = new Set<TreeNodeType>(["database", "schema", "li
const simpleObjectChildTypes = new Set<TreeNodeType>(["table", "view", "materialized_view", "procedure", "function", "sequence", "package", "package-body", "load-more"]);
function isSimpleObjectSearchParent(node: TreeNode): boolean {
return settingsStore.editorSettings.sidebarObjectDisplay === "simple" && simpleObjectParentTypes.has(node.type) && node.isExpanded === true && !!node.children?.some((child) => simpleObjectChildTypes.has(child.type));
return settingsStore.editorSettings.sidebarObjectDisplay === "simple" && simpleObjectParentTypes.has(node.type) && node.isExpanded === true && (!!node.children?.some((child) => simpleObjectChildTypes.has(child.type)) || !!store.sidebarTableSearchQueries[node.id]?.trim());
}
function collectExpandedObjectSearchTargets(node: TreeNode, tasks: Promise<void>[], refreshedNodeIds?: Set<string>) {
@ -185,6 +201,47 @@ function clearSearchScopeFilter() {
selectedSearchScopes.value = [];
}
function scheduleSidebarTableSearchRefresh(parentNodeId: string, options?: { restoreFocus?: boolean }) {
window.clearTimeout(tableSearchTimers.get(parentNodeId));
if (isFiltering.value) return;
const restoreToken = options?.restoreFocus ? ++tableSearchFocusRestoreTokenSeq : 0;
if (restoreToken) {
tableSearchFocusRestoreTokens.clear();
tableSearchFocusRestoreTokens.set(parentNodeId, restoreToken);
}
const timer = window.setTimeout(() => {
tableSearchTimers.delete(parentNodeId);
void store.refreshSidebarTableSearch(parentNodeId).then(() => {
if (!restoreToken) return;
if (tableSearchFocusRestoreTokens.get(parentNodeId) !== restoreToken) return;
tableSearchFocusRestoreTokens.delete(parentNodeId);
if (latestTableSearchInteractionParentId !== parentNodeId) return;
if (document.activeElement === document.body || activeTableSearchParentId() === parentNodeId) {
focusTableSearchInput(parentNodeId);
}
});
}, 250);
tableSearchTimers.set(parentNodeId, timer);
}
function activeTableSearchParentId(): string | null {
const active = document.activeElement;
if (!(active instanceof HTMLElement)) return null;
return active.dataset.sidebarTableSearchParentId || null;
}
function focusTableSearchInput(parentNodeId: string) {
void nextTick(() => {
const root = rootRef.value;
if (!root) return;
const input = Array.from(root.querySelectorAll<HTMLInputElement>("[data-sidebar-table-search-parent-id]")).find((item) => item.dataset.sidebarTableSearchParentId === parentNodeId);
if (!input) return;
input.focus({ preventScroll: true });
const end = input.value.length;
input.setSelectionRange(end, end);
});
}
const filteredNodes = computed(() => {
let nodes = store.treeNodes;
@ -197,11 +254,18 @@ const filteredNodes = computed(() => {
return nodes;
});
const flatNodes = computed<FlatTreeNode[]>(() => flattenTree(filteredNodes.value));
const flatNodes = computed<FlatTreeNode[]>(() =>
insertSidebarTableSearchControls(flattenTree(filteredNodes.value), {
enabled: !isFiltering.value,
sidebarObjectDisplay: settingsStore.editorSettings.sidebarObjectDisplay,
activeQueries: store.sidebarTableSearchQueries,
}),
);
const visibleNodes = computed<TreeNode[]>(() => flatNodes.value.map((item) => item.node));
const visibleNodeIndexById = computed(() => {
const selectableVisibleNodes = computed<TreeNode[]>(() => visibleNodes.value.filter((node) => !isSidebarTableSearchControlNode(node)));
const selectableVisibleNodeIndexById = computed(() => {
const next = new Map<string, number>();
visibleNodes.value.forEach((node, index) => next.set(node.id, index));
selectableVisibleNodes.value.forEach((node, index) => next.set(node.id, index));
return next;
});
const useVirtualTree = computed(() => shouldVirtualizeFlatTree(flatNodes.value.length));
@ -405,8 +469,13 @@ function onSidebarScrollbarThumbPointerDown(event: PointerEvent) {
}
provide(sidebarTreeContextKey, {
getVisibleNodes: () => visibleNodes.value,
getVisibleNodeIndex: (id: string) => visibleNodeIndexById.value.get(id) ?? -1,
getVisibleNodes: () => selectableVisibleNodes.value,
getVisibleNodeIndex: (id: string) => selectableVisibleNodeIndexById.value.get(id) ?? -1,
setTableSearchQuery: (parentNodeId, query) => {
latestTableSearchInteractionParentId = parentNodeId;
store.setSidebarTableSearchQuery(parentNodeId, query);
scheduleSidebarTableSearchRefresh(parentNodeId, { restoreFocus: true });
},
});
const pendingRenameGroupId = ref<string | null>(null);
@ -805,7 +874,7 @@ function onWindowKeydown(event: KeyboardEvent) {
}
}
if (!pointerInsideTree.value || isEditableSidebarTypeSearchTarget(event.target)) return;
if (!pointerInsideTree.value || isEditableSidebarTypeSearchTarget(event.target) || isEditableSidebarTypeSearchTarget(document.activeElement)) return;
if (isCancelSearchShortcut(event)) {
if (!searchQuery.value) return;
event.preventDefault();
@ -905,6 +974,12 @@ onMounted(() => {
onUnmounted(() => {
window.removeEventListener("keydown", onWindowKeydown);
for (const timer of tableSearchTimers.values()) {
window.clearTimeout(timer);
}
tableSearchTimers.clear();
tableSearchFocusRestoreTokens.clear();
latestTableSearchInteractionParentId = null;
stopSidebarScrollbarDrag();
sidebarScrollbarResizeObserver?.disconnect();
window.cancelAnimationFrame(sidebarScrollbarAnimationFrame);

View File

@ -3650,6 +3650,11 @@ const tableComment = computed(() =>
: null,
);
const paddingLeft = computed(() => treeItemPaddingLeft(props.depth));
const tableSearchParentId = computed(() => props.node.tableSearchParentId || "");
const tableSearchValue = computed(() => {
const parentId = tableSearchParentId.value;
return parentId ? connectionStore.sidebarTableSearchQueries[parentId] || "" : "";
});
const isConnected = computed(() => props.node.type === "connection" && !!props.node.connectionId && connectionStore.connectedIds.has(props.node.connectionId));
const isConnecting = computed(() => props.node.type === "connection" && !!props.node.connectionId && connectionStore.connectingIds.has(props.node.connectionId));
const isConnectionReadonly = computed(() => props.node.type === "connection" && !!props.node.connectionId && (connectionStore.getConfig(props.node.connectionId)?.read_only ?? false));
@ -3713,6 +3718,22 @@ function togglePin() {
connectionStore.toggleTreeNodePin(props.node.id);
}
function updateTableSearchQuery(value: string | number) {
const parentId = tableSearchParentId.value;
if (!parentId) return;
const query = String(value);
if (sidebarTreeContext?.setTableSearchQuery) {
sidebarTreeContext.setTableSearchQuery(parentId, query);
return;
}
connectionStore.setSidebarTableSearchQuery(parentId, query);
void connectionStore.refreshSidebarTableSearch(parentId);
}
function clearTableSearchQuery() {
updateTableSearchQuery("");
}
function openVisibleDatabasesDialog() {
showVisibleDatabasesDialog.value = true;
}
@ -4653,7 +4674,27 @@ function treeItemMenuItems(): ContextMenuItem[] {
</script>
<template>
<CustomContextMenu :items="treeItemMenuItems()" v-slot="contextMenuSlot">
<div v-if="node.type === 'table-search-control'" class="flex h-7 items-center py-0.5 pr-2" :style="{ paddingLeft }" @click.stop @dblclick.stop @mousedown.stop @keydown.stop>
<div class="relative w-full min-w-0">
<Search class="pointer-events-none absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" />
<Input
:model-value="tableSearchValue"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
class="h-6 w-full rounded border-border/70 bg-background pl-7 pr-6 text-xs shadow-none focus-visible:ring-1"
:placeholder="t(node.label)"
:aria-label="t(node.label)"
:data-sidebar-table-search-parent-id="tableSearchParentId"
@update:model-value="updateTableSearchQuery"
/>
<button v-if="tableSearchValue" type="button" class="absolute right-1.5 top-1/2 flex h-4 w-4 -translate-y-1/2 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground" :aria-label="t('sidebar.clearTableSearch')" @click.stop="clearTableSearchQuery">
<X class="h-3 w-3" />
</button>
</div>
</div>
<CustomContextMenu v-else :items="treeItemMenuItems()" v-slot="contextMenuSlot">
<div @contextmenu="onTreeItemContextMenu($event, contextMenuSlot.onContextMenu)">
<LightTooltip :text="displayLabel(node)" :disabled="isTooltipDisabled()" side="right" :side-offset="8" :delay="0" :close-delay="0">
<div

View File

@ -95,6 +95,8 @@ export default {
searchScopeSchema: "Schema",
searchScopeTable: "Table",
searchScopeView: "View",
searchTablesInCurrentScope: "Search tables in this database...",
clearTableSearch: "Clear table search",
clearFilter: "Clear filter",
locateActiveTab: "Locate in sidebar",
},

View File

@ -97,6 +97,8 @@ export default withEnglishFallback({
searchScopeSchema: "Schema",
searchScopeTable: "Tabla",
searchScopeView: "Vista",
searchTablesInCurrentScope: "Buscar tablas en esta base de datos...",
clearTableSearch: "Limpiar búsqueda de tablas",
clearFilter: "Limpiar filtro",
locateActiveTab: "Localizar en la barra lateral",
},

View File

@ -96,6 +96,8 @@ export default withEnglishFallback({
searchScopeSchema: "Schema",
searchScopeTable: "Tabella",
searchScopeView: "Vista",
searchTablesInCurrentScope: "Cerca tabelle in questo database...",
clearTableSearch: "Cancella ricerca tabelle",
clearFilter: "Cancella filtro",
locateActiveTab: "Trova nella barra laterale",
},

View File

@ -97,6 +97,8 @@ export default withEnglishFallback({
searchScopeSchema: "スキーマ",
searchScopeTable: "テーブル",
searchScopeView: "ビュー",
searchTablesInCurrentScope: "このデータベースのテーブルを検索...",
clearTableSearch: "テーブル検索をクリア",
clearFilter: "フィルターをクリア",
locateActiveTab: "サイドバーで表示",
},

View File

@ -97,6 +97,8 @@ export default withEnglishFallback({
searchScopeSchema: "Schema",
searchScopeTable: "Tabela",
searchScopeView: "Visão",
searchTablesInCurrentScope: "Pesquisar tabelas neste banco...",
clearTableSearch: "Limpar busca de tabelas",
clearFilter: "Limpar filtro",
locateActiveTab: "Localizar na barra lateral",
},

View File

@ -97,6 +97,8 @@ export default withEnglishFallback({
searchScopeSchema: "Schema",
searchScopeTable: "表",
searchScopeView: "视图",
searchTablesInCurrentScope: "搜索当前库的表...",
clearTableSearch: "清除表搜索",
clearFilter: "清除筛选",
locateActiveTab: "在侧边栏中定位",
},

View File

@ -97,6 +97,8 @@ export default withEnglishFallback({
searchScopeSchema: "Schema",
searchScopeTable: "資料表",
searchScopeView: "檢視",
searchTablesInCurrentScope: "搜尋目前資料庫的資料表...",
clearTableSearch: "清除資料表搜尋",
clearFilter: "清除篩選",
locateActiveTab: "在側邊欄中定位",
},

View File

@ -0,0 +1,66 @@
import type { FlatTreeNode } from "@/composables/useFlatTree";
import type { TreeNode, TreeNodeType } from "@/types/database";
const simpleObjectParentTypes = new Set<TreeNodeType>(["database", "schema", "linked-server-schema"]);
const simpleObjectChildTypes = new Set<TreeNodeType>(["table", "view", "materialized_view", "procedure", "function", "sequence", "package", "package-body", "load-more"]);
export function isSidebarTableSearchControlNode(node: TreeNode): boolean {
return node.type === "table-search-control";
}
function tableSearchControlId(parentId: string): string {
return `${parentId}:__table_search`;
}
function parentHasDirectTableList(node: TreeNode): boolean {
return !!node.children?.some((child) => simpleObjectChildTypes.has(child.type));
}
function shouldInsertTableSearchControl(item: FlatTreeNode, sidebarObjectDisplay: "simple" | "grouped", activeQueries: Readonly<Record<string, string | undefined>>): boolean {
const node = item.node;
if (!node.isExpanded) return false;
if (sidebarObjectDisplay === "grouped") {
return node.type === "group-tables";
}
if (!simpleObjectParentTypes.has(node.type)) return false;
return parentHasDirectTableList(node) || !!activeQueries[node.id]?.trim();
}
function buildTableSearchControlNode(parent: TreeNode): TreeNode {
return {
id: tableSearchControlId(parent.id),
label: "sidebar.searchTablesInCurrentScope",
type: "table-search-control",
connectionId: parent.connectionId,
database: parent.database,
schema: parent.schema,
tableSearchParentId: parent.id,
};
}
export function insertSidebarTableSearchControls(
flatNodes: readonly FlatTreeNode[],
options: {
enabled: boolean;
sidebarObjectDisplay: "simple" | "grouped";
activeQueries: Readonly<Record<string, string | undefined>>;
},
): FlatTreeNode[] {
if (!options.enabled) return [...flatNodes];
const result: FlatTreeNode[] = [];
for (const item of flatNodes) {
result.push(item);
if (!shouldInsertTableSearchControl(item, options.sidebarObjectDisplay, options.activeQueries)) continue;
const node = buildTableSearchControlNode(item.node);
result.push({
node,
depth: item.depth + 1,
id: node.id,
type: node.type,
poolType: `${node.type}:${node.id}`,
});
}
return result;
}

View File

@ -4,6 +4,7 @@ import type { TreeNode } from "@/types/database";
export interface SidebarTreeContext {
getVisibleNodes: () => TreeNode[];
getVisibleNodeIndex: (id: string) => number;
setTableSearchQuery?: (parentNodeId: string, query: string) => void;
}
export const sidebarTreeContextKey: InjectionKey<SidebarTreeContext> = Symbol("sidebar-tree-context");

View File

@ -19,6 +19,7 @@ const leafTypes: Set<TreeNodeType> = new Set([
"elasticsearch-index",
"user-admin",
"saved-sql-file",
"table-search-control",
"load-more",
]);

View File

@ -165,6 +165,9 @@ export type TreeClipboard =
interface LoadTreeOptions {
force?: boolean;
expectedSidebarSearchQuery?: string;
searchFilter?: string;
sidebarTableSearchParentId?: string;
expectedSidebarTableSearchQuery?: string;
}
interface PersistedTreeChildrenLoadResult {
@ -218,6 +221,7 @@ export const useConnectionStore = defineStore("connection", () => {
const mongoCompletionFieldsCache = ref<Record<string, MongoCompletionField[]>>({});
const schemaListCache = ref<Record<string, string[]>>({});
const sidebarSearchQuery = ref("");
const sidebarTableSearchQueries = ref<Record<string, string>>({});
const completionTableIndex = new Map<string, { touched: number; tables: SqlCompletionTable[] }>();
const completionObjectIndex = new Map<string, { touched: number; objects: SqlCompletionObject[] }>();
const completionColumnIndex = new Map<string, { touched: number; columns: SqlCompletionColumn[] }>();
@ -974,7 +978,7 @@ export const useConnectionStore = defineStore("connection", () => {
if (!options.node.connectionId || !options.node.database) {
return { children: [], objectCount: 0, hasMore: false, nextOffset: options.offset };
}
const searchFilter = options.searchFilter || sidebarSearchQuery.value || undefined;
const searchFilter = (options.searchFilter ?? sidebarSearchQuery.value) || undefined;
const fetchLimit = searchFilter ? options.pageSize : options.pageSize + 1;
const tables = await api.listTables(options.node.connectionId, options.node.database, options.querySchema, searchFilter, fetchLimit, searchFilter ? undefined : options.offset, options.objectTypes);
const hasMore = searchFilter ? false : tables.length > options.pageSize;
@ -1007,7 +1011,7 @@ export const useConnectionStore = defineStore("connection", () => {
pageSize: number;
searchFilter?: string;
}): Promise<{ children: TreeNode[]; objectCount: number; hasMore: boolean; nextOffset: number }> {
const searchFilter = options.searchFilter || sidebarSearchQuery.value || undefined;
const searchFilter = (options.searchFilter ?? sidebarSearchQuery.value) || undefined;
const fetchLimit = searchFilter ? options.pageSize : options.pageSize + 1;
const tables = await api.listTables(options.connectionId, options.database, options.querySchema, searchFilter, fetchLimit, searchFilter ? undefined : options.offset);
const hasMore = searchFilter ? false : tables.length > options.pageSize;
@ -1110,6 +1114,20 @@ export const useConnectionStore = defineStore("connection", () => {
return options?.expectedSidebarSearchQuery !== undefined && (sidebarSearchQuery.value || "") !== options.expectedSidebarSearchQuery;
}
function isSidebarTableSearchQueryChanged(options?: LoadTreeOptions) {
if (!options?.sidebarTableSearchParentId || options.expectedSidebarTableSearchQuery === undefined) return false;
return (sidebarTableSearchQueries.value[options.sidebarTableSearchParentId]?.trim() || "") !== options.expectedSidebarTableSearchQuery;
}
function activeTreeLoadSearchFilter(options?: LoadTreeOptions): string {
return (options?.searchFilter ?? sidebarSearchQuery.value) || "";
}
function isTreeLoadSearchChanged(searchFilter: string, options?: LoadTreeOptions): boolean {
if (options?.sidebarTableSearchParentId) return isSidebarTableSearchQueryChanged(options);
return (sidebarSearchQuery.value || "") !== searchFilter || isSidebarSearchQueryChanged(options);
}
function isTreeNodeChildrenLoaded(nodeId: string): boolean {
return loadedTreeNodeChildrenIds.value.has(nodeId);
}
@ -2350,7 +2368,8 @@ export const useConnectionStore = defineStore("connection", () => {
if (useCachedChildren(node, options)) return;
const simpleObjectDisplay = useSettingsStore().editorSettings.sidebarObjectDisplay === "simple";
const cacheKey = schemaCacheKey(connectionId, database, schema || "", simpleObjectDisplay ? "objects-simple-v3" : "objects-grouped-v3");
const searchFilter = sidebarSearchQuery.value || "";
const searchFilter = activeTreeLoadSearchFilter(options);
const isSidebarTableSearch = !!options?.sidebarTableSearchParentId;
if (!options?.force && !searchFilter) {
const cached = await loadPersistedTreeChildren(node, cacheKey);
if (cached.hit) {
@ -2388,9 +2407,9 @@ export const useConnectionStore = defineStore("connection", () => {
objectTypes: supportedSidebarObjectTypes(config),
});
}
if ((sidebarSearchQuery.value || "") !== searchFilter || isSidebarSearchQueryChanged(options)) return;
if (isTreeLoadSearchChanged(searchFilter, options)) return;
setChildren(node, children);
if (!searchFilter) {
if (!searchFilter && !isSidebarTableSearch) {
await savePersistedTreeChildren(cacheKey, children);
}
node.isExpanded = true;
@ -2416,7 +2435,8 @@ export const useConnectionStore = defineStore("connection", () => {
const querySchema = connectionObjectTreeQuerySchema(config, node.database, node.schema);
const effectiveSchema = connectionObjectTreeNodeSchema(config, node.database, node.schema);
const cacheKey = objectGroupCacheKey(node);
const searchFilter = sidebarSearchQuery.value || "";
const searchFilter = activeTreeLoadSearchFilter(options);
const isSidebarTableSearch = !!options?.sidebarTableSearchParentId;
if (!options?.force && !searchFilter) {
const cached = await loadPersistedTreeChildren(node, cacheKey);
if (cached.hit) {
@ -2451,9 +2471,9 @@ export const useConnectionStore = defineStore("connection", () => {
});
node.objectCount = children.length;
}
if ((sidebarSearchQuery.value || "") !== searchFilter || isSidebarSearchQueryChanged(options)) return;
if (isTreeLoadSearchChanged(searchFilter, options)) return;
setChildren(node, children);
if (!searchFilter) {
if (!searchFilter && !isSidebarTableSearch) {
await savePersistedTreeChildren(cacheKey, children);
}
node.isExpanded = true;
@ -2623,6 +2643,39 @@ export const useConnectionStore = defineStore("connection", () => {
}
}
function setSidebarTableSearchQuery(parentNodeId: string, query: string) {
const normalized = query.trim();
const next = { ...sidebarTableSearchQueries.value };
if (normalized) {
next[parentNodeId] = query;
} else {
delete next[parentNodeId];
}
sidebarTableSearchQueries.value = next;
}
async function refreshSidebarTableSearch(parentNodeId: string) {
const parent = findNode(treeNodes.value, parentNodeId);
if (!parent?.connectionId || !hasTreeNodeDatabaseContext(parent)) return;
const searchFilter = sidebarTableSearchQueries.value[parentNodeId]?.trim() || "";
const options: LoadTreeOptions = {
force: true,
searchFilter: searchFilter || undefined,
sidebarTableSearchParentId: parentNodeId,
expectedSidebarTableSearchQuery: searchFilter,
};
if (parent.type === "group-tables") {
await loadObjectGroupChildren(parent, options);
return;
}
if (parent.type === "database" || parent.type === "schema" || parent.type === "linked-server-schema") {
await loadTables(parent.connectionId, parent.database, parent.schema, options);
}
}
function normalizedObjectTreeKind(type: string): DatabaseObjectTreeKind {
return normalizeSidebarObjectKind(type);
}
@ -4313,6 +4366,9 @@ export const useConnectionStore = defineStore("connection", () => {
databaseSearchSource,
databaseExportSource,
sidebarSearchQuery,
sidebarTableSearchQueries,
setSidebarTableSearchQuery,
refreshSidebarTableSearch,
createConnectionGroup(name: string, parentGroupId?: string | null) {
const result = createGroupOp(sidebarLayout.value, name, parentGroupId);
updateLayoutAndRebuild(result.layout);

View File

@ -512,6 +512,7 @@ export type TreeNodeType =
| "saved-sql-root"
| "saved-sql-folder"
| "saved-sql-file"
| "table-search-control"
| "load-more"
| "column"
| "index"
@ -570,6 +571,7 @@ export interface TreeNode {
partitionParentSchema?: string;
partitionParentName?: string;
hiddenChildren?: TreeNode[];
tableSearchParentId?: string;
savedSqlId?: string;
savedSqlFolderId?: string;
meta?: ColumnInfo | IndexInfo | ForeignKeyInfo | TriggerInfo | VectorCollectionMeta;

View File

@ -0,0 +1,148 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
import { insertSidebarTableSearchControls } from "../../apps/desktop/src/lib/sidebar/sidebarTableSearchControl.ts";
import type { FlatTreeNode } from "../../apps/desktop/src/composables/useFlatTree.ts";
import type { TreeNode } from "../../apps/desktop/src/types/database.ts";
function flat(node: TreeNode, depth = 0): FlatTreeNode {
return {
node,
depth,
id: node.id,
type: node.type,
poolType: node.type,
};
}
test("inserts a local table search control above simple table children", () => {
const database: TreeNode = {
id: "conn:app",
label: "app",
type: "database",
connectionId: "conn",
database: "app",
isExpanded: true,
children: [{ id: "conn:app:orders", label: "orders", type: "table", connectionId: "conn", database: "app" }],
};
const table = database.children![0];
const nodes = insertSidebarTableSearchControls([flat(database), flat(table, 1)], {
enabled: true,
sidebarObjectDisplay: "simple",
activeQueries: {},
});
assert.deepEqual(
nodes.map((item) => item.node.type),
["database", "table-search-control", "table"],
);
assert.equal(nodes[1].node.tableSearchParentId, "conn:app");
assert.equal(nodes[1].depth, 1);
});
test("keeps a simple local table search control visible when the current search has no results", () => {
const database: TreeNode = {
id: "conn:app",
label: "app",
type: "database",
connectionId: "conn",
database: "app",
isExpanded: true,
children: [],
};
const nodes = insertSidebarTableSearchControls([flat(database)], {
enabled: true,
sidebarObjectDisplay: "simple",
activeQueries: { "conn:app": "invoice" },
});
assert.deepEqual(
nodes.map((item) => item.node.type),
["database", "table-search-control"],
);
});
test("inserts a grouped local table search control only for expanded table groups", () => {
const tableGroup: TreeNode = {
id: "conn:app:__tables",
label: "tree.tables",
type: "group-tables",
connectionId: "conn",
database: "app",
isExpanded: true,
children: [],
};
const viewGroup: TreeNode = {
id: "conn:app:__views",
label: "tree.views",
type: "group-views",
connectionId: "conn",
database: "app",
isExpanded: true,
children: [],
};
const nodes = insertSidebarTableSearchControls([flat(tableGroup, 1), flat(viewGroup, 1)], {
enabled: true,
sidebarObjectDisplay: "grouped",
activeQueries: {},
});
assert.deepEqual(
nodes.map((item) => item.node.type),
["group-tables", "table-search-control", "group-views"],
);
assert.equal(nodes[1].node.tableSearchParentId, "conn:app:__tables");
});
test("uses isolated virtual scroller pools for each local table search input", () => {
const first: TreeNode = {
id: "conn:first:__tables",
label: "tree.tables",
type: "group-tables",
connectionId: "conn",
database: "first",
isExpanded: true,
children: [],
};
const second: TreeNode = {
id: "conn:second:__tables",
label: "tree.tables",
type: "group-tables",
connectionId: "conn",
database: "second",
isExpanded: true,
children: [],
};
const nodes = insertSidebarTableSearchControls([flat(first, 1), flat(second, 1)], {
enabled: true,
sidebarObjectDisplay: "grouped",
activeQueries: {},
});
const pools = nodes.filter((item) => item.node.type === "table-search-control").map((item) => item.poolType);
assert.equal(pools.length, 2);
assert.equal(new Set(pools).size, 2);
});
test("hides local table search controls while global sidebar filtering is active", () => {
const database: TreeNode = {
id: "conn:app",
label: "app",
type: "database",
connectionId: "conn",
database: "app",
isExpanded: true,
children: [{ id: "conn:app:orders", label: "orders", type: "table", connectionId: "conn", database: "app" }],
};
const nodes = insertSidebarTableSearchControls([flat(database)], {
enabled: false,
sidebarObjectDisplay: "simple",
activeQueries: { "conn:app": "orders" },
});
assert.deepEqual(nodes.map((item) => item.node.type), ["database"]);
});