feat(sidebar): add local table search

This commit is contained in:
yanguibao1997
2026-07-29 21:27:37 +08:00
committed by GitHub
parent 551f696cd5
commit 51b5c8ae95
16 changed files with 396 additions and 17 deletions
@@ -6,8 +6,10 @@ import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { useToast } from "@/composables/useToast";
import type { ObjectSourceKind, TableNameFilter, TreeNode, TreeNodeType } from "@/types/database";
import type { ObjectSourceKind, TableInfo, TableNameFilter, TreeNode, TreeNodeType } from "@/types/database";
import { filterSidebarSearchRootsByConnectionState, filterSidebarTree, filterSidebarTreeToConnectedConnections, resolveSidebarFilterGuards } from "@/lib/sidebar/sidebarSearchTree";
import { matchSidebarLabel } from "@/lib/sidebar/sidebarSearch";
import { buildTableTreeNodes } from "@/lib/table/tableTree";
import { isCancelSearchShortcut, isCopySidebarSelectionShortcut, isEditSidebarConnectionShortcut, isPasteSidebarSelectionShortcut } from "@/lib/editor/keyboardShortcuts";
import { copyNameForTreeNode, objectSourceKindForTreeNode } from "@/lib/sidebar/treeNodeClick";
import { copyToClipboard } from "@/lib/common/clipboard";
@@ -29,6 +31,8 @@ import InstallExtensionDialog from "@/components/objects/InstallExtensionDialog.
import { RecycleScroller } from "vue-virtual-scroller";
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
import LightDropdown from "@/components/ui/LightDropdown.vue";
import LightTooltip from "@/components/ui/LightTooltip.vue";
import { Switch } from "@/components/ui/switch";
import { cancelPendingSidebarDataOpen, runSidebarDataOpenImmediately, type SidebarDataOpenRequest } from "@/lib/sidebar/sidebarDataOpenCoordinator";
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
import { Button } from "@/components/ui/button";
@@ -111,6 +115,7 @@ const tableSearchTimers = new Map<string, number>();
const tableSearchFocusRestoreTokens = new Map<string, number>();
let tableSearchFocusRestoreTokenSeq = 0;
let latestTableSearchInteractionParentId: string | null = null;
let localTableSearchFocusPending = false;
watch(
searchQuery,
@@ -157,6 +162,10 @@ watch(
watch(deferredSearchQuery, (newQuery, oldQuery) => {
store.sidebarSearchQuery = newQuery;
if (settingsStore.editorSettings.sidebarGlobalSearchLocal) {
if (!newQuery && oldQuery) searchRefreshedNodeIds.clear();
return;
}
const tasks: Promise<void>[] = [];
for (const root of store.treeNodes) {
collectExpandedObjectSearchTargets(root, tasks, newQuery ? searchRefreshedNodeIds : undefined);
@@ -357,6 +366,36 @@ function focusTableSearchInput(parentNodeId: string) {
}
const displayedTreeNodes = computed(() => sortConnectionListForDisplay(store.treeNodes, settingsStore.editorSettings.sidebarConnectionSortMode));
const localTableSearchResults = ref<Record<string, TableInfo[] | null>>({});
const localTableSearchParentTypes = new Set<TreeNodeType>(["database", "schema", "linked-server-schema", "group-tables"]);
const localTableSearchChildTypes = new Set<TreeNodeType>(["table", "view", "materialized_view"]);
function filterLocallySearchedTables(nodes: TreeNode[]): TreeNode[] {
return nodes.map((node) => {
const children = node.children ? filterLocallySearchedTables(node.children) : undefined;
const query = settingsStore.editorSettings.sidebarTableSearchLocal && localTableSearchParentTypes.has(node.type) ? store.sidebarTableSearchQueries[node.id]?.trim() : "";
if (!query || !children) return children === node.children ? node : { ...node, children };
const indexed = localTableSearchResults.value[node.id];
const matchingChildren =
indexed === null
? children.filter((child) => localTableSearchChildTypes.has(child.type) && !!matchSidebarLabel(child.label.toLowerCase(), query.toLowerCase()))
: indexed
? buildTableTreeNodes({ nodeId: node.id, connectionId: node.connectionId || "", database: node.database || "", schema: node.schema, catalog: node.catalog, tables: indexed.filter((entry) => !!matchSidebarLabel(entry.name.toLowerCase(), query.toLowerCase())) })
: children.filter((child) => localTableSearchChildTypes.has(child.type) && !!matchSidebarLabel(child.label.toLowerCase(), query.toLowerCase()));
return { ...node, children: matchingChildren, isExpanded: true };
});
}
async function loadLocalTableSearchResults(parentNodeId: string, refresh = false) {
try {
const entries = refresh ? await store.refreshSidebarTableSearchIndex(parentNodeId) : await store.loadSidebarTableSearchIndex(parentNodeId);
localTableSearchResults.value = { ...localTableSearchResults.value, [parentNodeId]: entries };
} finally {
if (latestTableSearchInteractionParentId === parentNodeId) focusTableSearchInput(parentNodeId);
}
}
const filteredNodes = computed(() => {
let nodes = displayedTreeNodes.value;
@@ -364,6 +403,8 @@ const filteredNodes = computed(() => {
nodes = filterSidebarTreeToConnectedConnections(nodes, store.connectedIds);
}
nodes = filterLocallySearchedTables(nodes);
const q = deferredSearchQuery.value;
nodes = filterSidebarTree(nodes, q, searchCollapsedIds.value, searchableNodeTypes.value);
if (q) {
@@ -626,7 +667,10 @@ watch(flatNodes, (nodes) => {
}
}
stickyScrollTop.value = 0;
void nextTick(scheduleSidebarScrollMetricsUpdate);
void nextTick(() => {
treeScrollerRef.value?.forceUpdate(true);
scheduleSidebarScrollMetricsUpdate();
});
});
const sidebarTreeOverflowClass = computed(() => (settingsStore.editorSettings.sidebarAllowHorizontalScroll ? "overflow-x-auto sidebar-tree-horizontal-scroll" : "overflow-x-hidden"));
@@ -797,11 +841,19 @@ const pasteHandlerRegistry = createSidebarPasteHandlerRegistry();
provide(sidebarTreeContextKey, {
getVisibleNodes: () => selectableVisibleNodes.value,
getVisibleNodeIndex: (id: string) => selectableVisibleNodeIndexById.value.get(id) ?? -1,
setTableSearchQuery: (parentNodeId, query) => {
setTableSearchQuery: (parentNodeId, query, local) => {
latestTableSearchInteractionParentId = parentNodeId;
store.setSidebarTableSearchQuery(parentNodeId, query);
scheduleSidebarTableSearchRefresh(parentNodeId, { restoreFocus: true });
if (local) {
localTableSearchFocusPending = true;
void nextTick(() => {
focusTableSearchInput(parentNodeId);
localTableSearchFocusPending = false;
});
void loadLocalTableSearchResults(parentNodeId);
} else scheduleSidebarTableSearchRefresh(parentNodeId, { restoreFocus: true });
},
refreshTableSearchIndex: (parentNodeId) => void loadLocalTableSearchResults(parentNodeId, true),
registerPasteHandler: pasteHandlerRegistry.register,
});
provide(sidebarTreeRuntimeKey, sidebarTreeRuntime);
@@ -1449,6 +1501,7 @@ function focusSearchAtEnd() {
function onWindowKeydown(event: KeyboardEvent) {
if (event.defaultPrevented) return;
if (localTableSearchFocusPending) return;
if (sidebarShortcutTargetIsActive(event.target)) {
if (sidebarShortcutTargetAllowsAppShortcut(event.target) && isEditConnectionShortcut(event)) {
if (requestSelectedConnectionEdit()) {
@@ -1641,6 +1694,9 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes });
<X class="h-3 w-3" />
</button>
</div>
<LightTooltip :text="t('sidebar.globalLocalSearchTooltip')" side="top" :delay="300">
<Switch size="sm" :model-value="settingsStore.editorSettings.sidebarGlobalSearchLocal" :aria-label="t('sidebar.globalLocalSearch')" @update:model-value="settingsStore.updateEditorSettings({ sidebarGlobalSearchLocal: Boolean($event) })" />
</LightTooltip>
<button class="shrink-0 h-6 w-6 flex items-center justify-center rounded border border-border text-muted-foreground hover:bg-accent hover:text-foreground" :title="t('sidebar.locateActiveTab')" @click="locateActiveTabInSidebar">
<Crosshair class="h-3.5 w-3.5" />
</button>
@@ -33,6 +33,7 @@ import {
Archive,
Square,
X,
RefreshCw,
} from "@lucide/vue";
import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
@@ -43,6 +44,7 @@ import ConnectionErrorIndicator from "@/components/connection/ConnectionErrorInd
import ProductionContextBadge from "@/components/common/ProductionContextBadge.vue";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import LightTooltip from "@/components/ui/LightTooltip.vue";
import type { ColumnInfo, ConnectionConfig, DatabaseType, TreeNode } from "@/types/database";
import { alignedCommentLeadingWidth, canTreeNodePin, canTreeNodeShowExpander, sidebarTreeNodeComment, trailingCommentAvailableWidth, trailingCommentGapPx, treeItemPaddingLeft, treeLabelWidthClass, usesFullWidthTreeLabel } from "@/lib/sidebar/sidebarTreeItemLayout";
@@ -713,11 +715,21 @@ function updateTableSearchQuery(value: string | number) {
if (!parentId) return;
const query = String(value);
if (sidebarTreeContext?.setTableSearchQuery) {
sidebarTreeContext.setTableSearchQuery(parentId, query);
sidebarTreeContext.setTableSearchQuery(parentId, query, settingsStore.editorSettings.sidebarTableSearchLocal);
return;
}
connectionStore.setSidebarTableSearchQuery(parentId, query);
void connectionStore.refreshSidebarTableSearch(parentId);
if (!settingsStore.editorSettings.sidebarTableSearchLocal) void connectionStore.refreshSidebarTableSearch(parentId);
}
function updateTableSearchLocal(value: boolean) {
settingsStore.updateEditorSettings({ sidebarTableSearchLocal: value });
updateTableSearchQuery(tableSearchValue.value);
}
function refreshTableSearchIndex() {
const parentId = tableSearchParentId.value;
if (parentId) sidebarTreeContext?.refreshTableSearchIndex?.(parentId);
}
function clearTableSearchQuery() {
@@ -1061,8 +1073,8 @@ function onKeydown(event: KeyboardEvent) {
</script>
<template>
<div v-if="node.type === 'table-search-control'" class="tree-table-search-control flex h-7 items-center py-0.5 pr-2" :style="tableSearchStyle" @click.stop @dblclick.stop @mousedown.stop @keydown.stop>
<div class="relative w-full min-w-0">
<div v-if="node.type === 'table-search-control'" class="tree-table-search-control flex h-7 items-center gap-1.5 py-0.5 pr-2" :style="tableSearchStyle" @click.stop @dblclick.stop @mousedown.stop @keydown.stop>
<div class="relative min-w-0 flex-1">
<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"
@@ -1080,6 +1092,14 @@ function onKeydown(event: KeyboardEvent) {
<X class="h-3 w-3" />
</button>
</div>
<LightTooltip :text="t('sidebar.localTableSearchTooltip')" side="top" :delay="300">
<Switch size="sm" :model-value="settingsStore.editorSettings.sidebarTableSearchLocal" :aria-label="t('sidebar.localTableSearch')" @update:model-value="updateTableSearchLocal(Boolean($event))" />
</LightTooltip>
<LightTooltip v-if="settingsStore.editorSettings.sidebarTableSearchLocal" :text="t('sidebar.refreshLocalTableSearchIndex')" side="top" :delay="300">
<button type="button" class="flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground" :aria-label="t('sidebar.refreshLocalTableSearchIndex')" @click.stop="refreshTableSearchIndex">
<RefreshCw class="h-3 w-3" />
</button>
</LightTooltip>
</div>
<div v-else @contextmenu="onTreeItemContextMenu">
+5
View File
@@ -109,6 +109,11 @@ export default {
searchScopeView: "View",
searchTablesInCurrentScope: "Search tables in this database...",
clearTableSearch: "Clear table search",
localTableSearch: "Local search",
localTableSearchTooltip: "Search the local table index without remote requests. Turn off to search remotely. Use refresh to build or update the index.",
refreshLocalTableSearchIndex: "Refresh local table index",
globalLocalSearch: "Local search",
globalLocalSearchTooltip: "Search only currently loaded sidebar objects without remote requests.",
clearFilter: "Clear filter",
locateActiveTab: "Locate in sidebar",
},
+5
View File
@@ -111,6 +111,11 @@ export default withEnglishFallback({
searchScopeView: "Vista",
searchTablesInCurrentScope: "Buscar tablas en esta base de datos...",
clearTableSearch: "Limpiar búsqueda de tablas",
localTableSearch: "Búsqueda local",
localTableSearchTooltip: "Busca en el índice local de tablas sin solicitudes remotas. Desactívalo para buscar de forma remota. Usa actualizar para crear o actualizar el índice.",
refreshLocalTableSearchIndex: "Actualizar índice local de tablas",
globalLocalSearch: "Búsqueda local",
globalLocalSearchTooltip: "Busca solo los objetos de la barra lateral cargados actualmente sin solicitudes remotas.",
clearFilter: "Limpiar filtro",
locateActiveTab: "Localizar en la barra lateral",
},
+5
View File
@@ -110,6 +110,11 @@ export default withEnglishFallback({
searchScopeView: "Vista",
searchTablesInCurrentScope: "Cerca tabelle in questo database...",
clearTableSearch: "Cancella ricerca tabelle",
localTableSearch: "Ricerca locale",
localTableSearchTooltip: "Cerca nell'indice locale delle tabelle senza richieste remote. Disattiva per cercare in remoto. Usa aggiorna per creare o aggiornare l'indice.",
refreshLocalTableSearchIndex: "Aggiorna indice locale delle tabelle",
globalLocalSearch: "Ricerca locale",
globalLocalSearchTooltip: "Cerca solo gli oggetti della barra laterale attualmente caricati senza richieste remote.",
clearFilter: "Cancella filtro",
locateActiveTab: "Trova nella barra laterale",
},
+5
View File
@@ -111,6 +111,11 @@ export default withEnglishFallback({
searchScopeView: "ビュー",
searchTablesInCurrentScope: "このデータベースのテーブルを検索...",
clearTableSearch: "テーブル検索をクリア",
localTableSearch: "ローカル検索",
localTableSearchTooltip: "リモート要求を行わずにローカルのテーブルインデックスを検索します。リモート検索するにはオフにしてください。更新でインデックスを作成または更新します。",
refreshLocalTableSearchIndex: "ローカルテーブルインデックスを更新",
globalLocalSearch: "ローカル検索",
globalLocalSearchTooltip: "リモート要求を行わず、現在読み込まれているサイドバーオブジェクトのみを検索します。",
clearFilter: "フィルターをクリア",
locateActiveTab: "サイドバーで表示",
},
+5
View File
@@ -111,6 +111,11 @@ export default withEnglishFallback({
searchScopeView: "Visão",
searchTablesInCurrentScope: "Pesquisar tabelas neste banco...",
clearTableSearch: "Limpar busca de tabelas",
localTableSearch: "Busca local",
localTableSearchTooltip: "Pesquisa o índice local de tabelas sem solicitações remotas. Desative para pesquisar remotamente. Use atualizar para criar ou atualizar o índice.",
refreshLocalTableSearchIndex: "Atualizar índice local de tabelas",
globalLocalSearch: "Busca local",
globalLocalSearchTooltip: "Pesquisa apenas os objetos da barra lateral carregados no momento, sem solicitações remotas.",
clearFilter: "Limpar filtro",
locateActiveTab: "Localizar na barra lateral",
},
+5
View File
@@ -111,6 +111,11 @@ export default withEnglishFallback({
searchScopeView: "视图",
searchTablesInCurrentScope: "搜索当前库的表...",
clearTableSearch: "清除表搜索",
localTableSearch: "本地搜索",
localTableSearchTooltip: "开启后搜索本地表索引,不发送远程请求;关闭后使用远程搜索。请使用刷新按钮构建或更新索引。",
refreshLocalTableSearchIndex: "刷新本地表索引",
globalLocalSearch: "本地搜索",
globalLocalSearchTooltip: "开启后仅搜索当前已加载的侧边栏对象,不发送远程请求。",
clearFilter: "清除筛选",
locateActiveTab: "在侧边栏中定位",
},
+5
View File
@@ -111,6 +111,11 @@ export default withEnglishFallback({
searchScopeView: "檢視",
searchTablesInCurrentScope: "搜尋目前資料庫的資料表...",
clearTableSearch: "清除資料表搜尋",
localTableSearch: "本機搜尋",
localTableSearchTooltip: "使用本機資料表索引搜尋,不會發出遠端請求。關閉後改用遠端搜尋;使用重新整理建立或更新索引。",
refreshLocalTableSearchIndex: "重新整理本機資料表索引",
globalLocalSearch: "本機搜尋",
globalLocalSearchTooltip: "只搜尋目前已載入的側邊欄物件,不會發出遠端請求。",
clearFilter: "清除篩選",
locateActiveTab: "在側邊欄中定位",
},
@@ -1,24 +1,46 @@
export const SCHEMA_TREE_CACHE_TTL_MS = 15 * 60 * 1000;
export interface TableSearchIndexEntry {
name: string;
tableType: string;
}
export interface TableSearchIndex {
complete: true;
indexedAt: string;
entries: TableSearchIndexEntry[];
}
export interface SchemaTreeCacheEnvelope<T> {
version: 2;
version: 3;
cachedAt: string;
children: T;
tableSearchIndex?: TableSearchIndex;
}
export interface DecodedSchemaTreeCache<T> {
children: T;
isStale: boolean;
tableSearchIndex?: TableSearchIndex;
}
export function encodeSchemaTreeCache<T>(children: T, nowMs = Date.now()): SchemaTreeCacheEnvelope<T> {
export function encodeSchemaTreeCache<T>(children: T, nowMs = Date.now(), tableSearchIndex?: TableSearchIndex): SchemaTreeCacheEnvelope<T> {
return {
version: 2,
version: 3,
cachedAt: new Date(nowMs).toISOString(),
children,
...(tableSearchIndex ? { tableSearchIndex } : {}),
};
}
function decodeTableSearchIndex(value: unknown): TableSearchIndex | undefined {
if (!value || typeof value !== "object") return undefined;
const index = value as Partial<TableSearchIndex>;
if (index.complete !== true || typeof index.indexedAt !== "string" || !Array.isArray(index.entries)) return undefined;
const entries = index.entries.filter((entry): entry is TableSearchIndexEntry => !!entry && typeof entry.name === "string" && typeof entry.tableType === "string");
return entries.length === index.entries.length ? { complete: true, indexedAt: index.indexedAt, entries } : undefined;
}
export function decodeSchemaTreeCache<T>(payload: unknown, nowMs = Date.now(), ttlMs = SCHEMA_TREE_CACHE_TTL_MS): DecodedSchemaTreeCache<T> | null {
if (Array.isArray(payload)) {
return { children: payload as T, isStale: true };
@@ -26,16 +48,18 @@ export function decodeSchemaTreeCache<T>(payload: unknown, nowMs = Date.now(), t
if (!payload || typeof payload !== "object") return null;
const envelope = payload as Partial<SchemaTreeCacheEnvelope<unknown>>;
if (envelope.version !== 2 || !Array.isArray(envelope.children) || typeof envelope.cachedAt !== "string") {
const envelope = payload as { version?: unknown; cachedAt?: unknown; children?: unknown; tableSearchIndex?: unknown };
if ((envelope.version !== 2 && envelope.version !== 3) || !Array.isArray(envelope.children) || typeof envelope.cachedAt !== "string") {
return null;
}
const cachedAtMs = Date.parse(envelope.cachedAt);
if (!Number.isFinite(cachedAtMs)) return null;
const tableSearchIndex = decodeTableSearchIndex(envelope.tableSearchIndex);
return {
children: envelope.children as T,
isStale: nowMs - cachedAtMs >= ttlMs,
...(tableSearchIndex ? { tableSearchIndex } : {}),
};
}
@@ -4,7 +4,8 @@ import type { TreeNode } from "@/types/database";
export interface SidebarTreeContext {
getVisibleNodes: () => TreeNode[];
getVisibleNodeIndex: (id: string) => number;
setTableSearchQuery?: (parentNodeId: string, query: string) => void;
setTableSearchQuery?: (parentNodeId: string, query: string, local: boolean) => void;
refreshTableSearchIndex?: (parentNodeId: string) => void;
registerPasteHandler?: (nodeId: string, callback: () => void) => () => void;
}
@@ -312,6 +312,112 @@ describe("connectionStore metadata loading", () => {
expect(schemaNode.children?.map((node) => node.label)).toEqual(["users"]);
});
it("keeps concurrent table-tree and local-index refreshes in separate cache entries", async () => {
const treeCacheKey = "pg-1:app:public:group-tables:objects-v6";
const indexCacheKey = `${treeCacheKey}:table-search-index-v1`;
const cachedPayloads = new Map<string, unknown>([
[
treeCacheKey,
{
version: 3,
cachedAt: new Date().toISOString(),
children: [{ id: "old", label: "old_table", type: "table", connectionId: "pg-1", database: "app", schema: "public", isExpanded: false }],
},
],
]);
let releaseTableLists!: () => void;
let tableListCalls = 0;
const bothTableListsStarted = new Promise<void>((resolve) => {
releaseTableLists = resolve;
});
const listTables = vi.fn(async (_connectionId: string, _database: string, _schema: string, _filter?: string, limit?: number) => {
tableListCalls += 1;
if (tableListCalls === 2) releaseTableLists();
await bothTableListsStarted;
return limit === 2 ? ([{ name: "indexed_table", table_type: "TABLE", comment: null }] satisfies TableInfo[]) : ([{ name: "fresh_table", table_type: "TABLE", comment: null }] satisfies TableInfo[]);
});
const loadSchemaCache = vi.fn(async (key: string) => {
const payload = cachedPayloads.get(key) ?? null;
await Promise.resolve();
return payload == null ? null : structuredClone(payload);
});
const saveSchemaCache = vi.fn(async (key: string, payload: unknown) => {
cachedPayloads.set(key, structuredClone(payload));
});
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
listInstalledAgents: vi.fn().mockResolvedValue([]),
listTables,
loadSchemaCache,
saveConnections: vi.fn().mockResolvedValue(undefined),
saveSchemaCache,
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
}));
const { decodeSchemaTreeCache } = await import("@/lib/metadata/schemaTreeCache");
const { useConnectionStore } = await import("@/stores/connectionStore");
const { useSettingsStore } = await import("@/stores/settingsStore");
const store = useConnectionStore();
useSettingsStore().desktopSettings.sidebar_table_page_size = 2;
const connection = postgresConnection();
const tablesGroup: TreeNode = {
id: "pg-1:app:public:__tables",
label: "tree.tables",
type: "group-tables",
connectionId: connection.id,
database: "app",
schema: "public",
isExpanded: true,
children: [],
};
store.connections = [connection];
store.connectedIds.add(connection.id);
store.treeNodes = [
{
id: connection.id,
label: connection.name,
type: "connection",
connectionId: connection.id,
isExpanded: true,
children: [
{
id: "pg-1:app",
label: "app",
type: "database",
connectionId: connection.id,
database: "app",
isExpanded: true,
children: [
{
id: "pg-1:app:public",
label: "public",
type: "schema",
connectionId: connection.id,
database: "app",
schema: "public",
isExpanded: true,
children: [tablesGroup],
},
],
},
],
},
];
await Promise.all([store.loadObjectGroupChildren(tablesGroup, { force: true }), store.refreshSidebarTableSearchIndex(tablesGroup.id)]);
const treeCache = decodeSchemaTreeCache<TreeNode[]>(cachedPayloads.get(treeCacheKey));
const indexCache = decodeSchemaTreeCache<TreeNode[]>(cachedPayloads.get(indexCacheKey));
expect(treeCache?.children.map((node) => node.label)).toEqual(["fresh_table"]);
expect(indexCache?.tableSearchIndex?.entries).toEqual([{ name: "indexed_table", tableType: "TABLE" }]);
await expect(store.loadSidebarTableSearchIndex(tablesGroup.id)).resolves.toEqual([{ name: "indexed_table", table_type: "TABLE" }]);
expect(loadSchemaCache).toHaveBeenLastCalledWith(indexCacheKey);
});
it("bypasses Oracle object-group caches created before DIP visibility was fixed", async () => {
const listTables = vi.fn().mockResolvedValue([
{ name: "V_ONE", table_type: "VIEW", comment: null },
@@ -336,6 +442,7 @@ describe("connectionStore metadata loading", () => {
vi.doMock("@/lib/backend/api", () => ({
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
listInstalledAgents: vi.fn().mockResolvedValue([]),
listTables,
loadSchemaCache,
saveSchemaCache: vi.fn().mockResolvedValue(undefined),
@@ -555,6 +662,7 @@ describe("connectionStore metadata loading", () => {
vi.doMock("@/lib/backend/api", () => ({
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
listInstalledAgents: vi.fn().mockResolvedValue([]),
listSchemaInfos,
loadSchemaCache,
saveConnections: vi.fn().mockResolvedValue(undefined),
@@ -949,6 +1057,7 @@ describe("connectionStore metadata loading", () => {
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
listDatabases,
listInstalledAgents: vi.fn().mockResolvedValue([]),
listTables,
listObjects,
loadSchemaCache: vi.fn().mockResolvedValue(null),
@@ -1282,6 +1391,7 @@ describe("connectionStore metadata loading", () => {
vi.doMock("@/lib/backend/api", () => ({
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
listInstalledAgents: vi.fn().mockResolvedValue([]),
listTables,
loadSchemaCache,
saveSchemaCache: vi.fn().mockResolvedValue(undefined),
@@ -1351,6 +1461,7 @@ describe("connectionStore metadata loading", () => {
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
listDatabases,
listInstalledAgents: vi.fn().mockResolvedValue([]),
listTables,
loadSchemaCache: vi.fn().mockResolvedValue(null),
saveSchemaCache: vi.fn().mockResolvedValue(undefined),
+121 -2
View File
@@ -1885,12 +1885,81 @@ export const useConnectionStore = defineStore("connection", () => {
await api.saveSchemaCache(cacheKey, encodeSchemaTreeCache(children)).catch(() => undefined);
}
function sidebarTableSearchTreeCacheKey(parent: TreeNode): string | null {
if (!parent.connectionId || !parent.database) return null;
if (parent.type === "group-tables") return objectGroupCacheKey(parent);
if (parent.type !== "database" && parent.type !== "schema" && parent.type !== "linked-server-schema") return null;
const simpleObjectDisplay = useSettingsStore().editorSettings.sidebarObjectDisplay === "simple";
return schemaCacheKey(parent.connectionId, parent.database, parent.schema || "", simpleObjectDisplay ? "objects-simple-v6" : "objects-grouped-v7");
}
function sidebarTableSearchIndexCacheKey(parent: TreeNode): string | null {
const treeCacheKey = sidebarTableSearchTreeCacheKey(parent);
return treeCacheKey ? `${treeCacheKey}:table-search-index-v1` : null;
}
async function loadSidebarTableSearchIndex(parentNodeId: string): Promise<TableInfo[] | null> {
const parent = findNode(treeNodes.value, parentNodeId);
if (!parent) return null;
const cacheKey = sidebarTableSearchIndexCacheKey(parent);
if (!cacheKey) return null;
const decoded = decodeSchemaTreeCache<TreeNode[]>(await api.loadSchemaCache<unknown>(cacheKey).catch(() => null));
const index = decoded?.tableSearchIndex;
if (!index) return null;
return index.entries.map((entry) => ({ name: entry.name, table_type: entry.tableType }));
}
async function refreshSidebarTableSearchIndex(parentNodeId: string): Promise<TableInfo[]> {
const parent = findNode(treeNodes.value, parentNodeId);
if (!parent?.connectionId || !hasTreeNodeDatabaseContext(parent)) return [];
const cacheKey = sidebarTableSearchIndexCacheKey(parent);
if (!cacheKey) return [];
await ensureConnected(parent.connectionId);
const config = getConfig(parent.connectionId);
const querySchema = connectionObjectTreeQuerySchema(config, parent.database, parent.schema);
const objectTypes = parent.type === "group-tables" ? (objectTypesForGroupNode(parent.type) ?? undefined) : undefined;
const pageSize = sidebarObjectGroupPageSize();
const entries: TableInfo[] = [];
for (let offset = 0; ; offset += pageSize) {
const page = await listTablesWithOptionalTableNameFilter(parent.connectionId, parent.database, querySchema, undefined, pageSize, offset, objectTypes, parent.catalog);
entries.push(...page);
if (page.length < pageSize) break;
}
const deduped = [...new Map(entries.map((entry) => [`${entry.table_type}\0${entry.name}`, entry])).values()];
const tableSearchIndex = { complete: true as const, indexedAt: new Date().toISOString(), entries: deduped.map((entry) => ({ name: entry.name, tableType: entry.table_type })) };
await api.saveSchemaCache(cacheKey, encodeSchemaTreeCache<TreeNode[]>([], Date.now(), tableSearchIndex));
return deduped;
}
async function savePersistedConnectionTreeChildren(cacheKey: string, children: TreeNode[]) {
const metadataChildren = connectionMetadataChildren(children);
if (metadataChildren.length === 0) return;
await savePersistedTreeChildren(cacheKey, metadataChildren);
}
function connectionRootCacheKey(connectionId: string, config: ConnectionConfig | undefined): string | null {
if (!config || connectionIsDorisFamilyCatalogCapable(config)) return null;
if (config.db_type === "duckdb") return schemaCacheKey(connectionId, "duckdb-root");
if (connectionUsesVisibleSchemaFilter(config)) {
return schemaCacheKey(connectionId, config.database || "", config.db_type === "oracle" ? "schemas-v2" : "schemas", config.show_system_schemas === true ? "show-system" : "hide-system");
}
return schemaCacheKey(connectionId, "databases-v2");
}
async function hydrateTreeNodeFromCache(node: TreeNode | null, cacheKey: string | null): Promise<boolean> {
if (!node || !cacheKey || loadedTreeNodeChildrenIds.value.has(node.id)) return false;
const load = beginTreeNodeLoad(node);
try {
return (await loadPersistedTreeChildren(node, cacheKey, load)).hit;
} finally {
finishTreeNodeLoad(load);
}
}
async function hydrateConnectionRootFromCache(connectionId: string, config: ConnectionConfig | undefined): Promise<boolean> {
return hydrateTreeNodeFromCache(findConnectionNode(connectionId), connectionRootCacheKey(connectionId, config));
}
function isTreeNodeLoadedChildrenUsable(node: TreeNode): boolean {
const sidebarObjectDisplay = useSettingsStore().editorSettings.sidebarObjectDisplay;
if (!treeNodeLoadedChildrenContentPresent(node, sidebarObjectDisplay)) return false;
@@ -2023,7 +2092,7 @@ export const useConnectionStore = defineStore("connection", () => {
return current;
}
function clearLoadedChildrenCache(prefix: string) {
function clearLoadedChildrenCache(prefix: string, options?: { deletePersisted?: boolean }) {
for (const id of loadedTreeNodeChildrenIds.value) {
if (id === prefix || id.startsWith(`${prefix}:`)) {
loadedTreeNodeChildrenIds.value.delete(id);
@@ -2035,6 +2104,7 @@ export const useConnectionStore = defineStore("connection", () => {
}
}
invalidateMetadataCachesByTreePrefix(prefix);
if (options?.deletePersisted === false) return;
const rawPrefix = `${prefix}:`;
const encodedPrefix = `${schemaCacheKey(prefix)}:`;
if (rawPrefix === encodedPrefix) {
@@ -2634,7 +2704,9 @@ export const useConnectionStore = defineStore("connection", () => {
node.children = [];
}
clearConnectionRootMetadataLoad(connectionId);
clearLoadedChildrenCache(connectionId);
// Disconnecting only tears down the live session. Keep the schema snapshot so
// reconnecting can render databases and table names before the remote refresh.
clearLoadedChildrenCache(connectionId, { deletePersisted: false });
if (activeConnectionId.value === connectionId) {
activeConnectionId.value = null;
}
@@ -2868,6 +2940,16 @@ export const useConnectionStore = defineStore("connection", () => {
async function loadDatabases(connectionId: string, options?: LoadTreeOptions) {
const configForScope = getConfig(connectionId);
const searchFilter = activeTreeLoadSearchFilter(options);
if (!options?.force && !options?.connectedOnly && !searchFilter) {
const cacheHit = await hydrateConnectionRootFromCache(connectionId, configForScope);
if (cacheHit) {
// Render the last known metadata immediately; network validation and refresh
// continue in the background so opening a connection never waits on them.
void loadDatabases(connectionId, { ...options, force: true }).catch((error) => recordMetadataLoadError(connectionId, error));
return;
}
}
return runTreeMetadataLoad(
{
kind: "connection-databases",
@@ -3897,6 +3979,23 @@ export const useConnectionStore = defineStore("connection", () => {
const configForScope = getConfig(connectionId);
const simpleObjectDisplayForScope = useSettingsStore().editorSettings.sidebarObjectDisplay === "simple";
const objectTypesForScope = simpleObjectDisplayForScope ? supportedSidebarObjectTypes(configForScope) : undefined;
const searchFilter = activeTreeLoadSearchFilter(options);
const querySchemaForScope = connectionObjectTreeQuerySchema(configForScope, database, schema);
const effectiveSchemaForScope = connectionObjectTreeNodeSchema(configForScope, database, schema);
const tableNameFilterForScope = activeTableNameFilterForScope({
connectionId,
database,
schema: effectiveSchemaForScope ?? querySchemaForScope,
nodeKind: simpleObjectDisplayForScope ? "simple-tables" : "group-tables",
});
if (!options?.force && simpleObjectDisplayForScope && !searchFilter && !options?.sidebarTableSearchParentId && !tableNameFilterForScope) {
const nodeId = schema ? `${connectionId}:${database}:${schema}` : `${connectionId}:${database}`;
const cacheKey = schemaCacheKey(connectionId, database, schema || "", "objects-simple-v6");
if (await hydrateTreeNodeFromCache(findNode(treeNodes.value, nodeId), cacheKey)) {
void loadTables(connectionId, database, schema, { ...options, force: true }).catch((error) => recordMetadataLoadError(connectionId, error));
return;
}
}
return runTreeMetadataLoad(
{
kind: "schema-tables",
@@ -4012,6 +4111,24 @@ export const useConnectionStore = defineStore("connection", () => {
const configForScope = node.connectionId ? getConfig(node.connectionId) : undefined;
const objectTypesForScope = objectTypesForGroupNode(node.type);
const pageSizeForScope = sidebarObjectGroupPageSize();
const searchFilter = activeTreeLoadSearchFilter(options);
const querySchemaForScope = connectionObjectTreeQuerySchema(configForScope, node.database || "", node.schema);
const effectiveSchemaForScope = connectionObjectTreeNodeSchema(configForScope, node.database || "", node.schema);
const tableNameFilterForScope = activeTableNameFilterForScope({
connectionId: node.connectionId,
database: node.database,
schema: effectiveSchemaForScope ?? querySchemaForScope,
nodeKind: node.type,
catalog: node.catalog,
});
if (!options?.force && !searchFilter && !options?.sidebarTableSearchParentId && !tableNameFilterForScope) {
if (await hydrateTreeNodeFromCache(node, objectGroupCacheKey(node))) {
void loadObjectGroupChildren(node, { ...options, force: true }).catch((error) => {
if (node.connectionId) recordMetadataLoadError(node.connectionId, error);
});
return;
}
}
return runTreeMetadataLoad(
{
kind: "object-group",
@@ -6609,6 +6726,8 @@ export const useConnectionStore = defineStore("connection", () => {
refreshTreeNodeForTableNameFilter,
setSidebarTableSearchQuery,
refreshSidebarTableSearch,
loadSidebarTableSearchIndex,
refreshSidebarTableSearchIndex,
createConnectionGroup(name: string, parentGroupId?: string | null) {
const result = createGroupOp(sidebarLayout.value, name, parentGroupId);
updateLayoutAndRebuild(result.layout);
+8
View File
@@ -477,6 +477,8 @@ export interface EditorSettings {
sidebarObjectDisplay: "grouped" | "simple";
routineSourceOpenMode: "query-tab" | "dialog";
sidebarTableSearchEnabled: boolean;
sidebarTableSearchLocal: boolean;
sidebarGlobalSearchLocal: boolean;
autoSelectActiveSidebarNode: boolean;
openTabsRestoreMode: OpenTabsRestoreMode;
disconnectTabHandlingMode: DisconnectTabHandlingMode;
@@ -650,6 +652,8 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = {
sidebarObjectDisplay: "grouped",
routineSourceOpenMode: "query-tab",
sidebarTableSearchEnabled: false,
sidebarTableSearchLocal: true,
sidebarGlobalSearchLocal: false,
autoSelectActiveSidebarNode: false,
openTabsRestoreMode: "all",
disconnectTabHandlingMode: "close-tabs",
@@ -950,6 +954,8 @@ export function normalizeEditorSettings(settings: Partial<EditorSettings>, exist
sidebarObjectDisplay: settings.sidebarObjectDisplay === "simple" || settings.sidebarObjectDisplay === "grouped" ? settings.sidebarObjectDisplay : DEFAULT_EDITOR_SETTINGS.sidebarObjectDisplay,
routineSourceOpenMode: settings.routineSourceOpenMode === "query-tab" || settings.routineSourceOpenMode === "dialog" ? settings.routineSourceOpenMode : DEFAULT_EDITOR_SETTINGS.routineSourceOpenMode,
sidebarTableSearchEnabled: typeof settings.sidebarTableSearchEnabled === "boolean" ? settings.sidebarTableSearchEnabled : DEFAULT_EDITOR_SETTINGS.sidebarTableSearchEnabled,
sidebarTableSearchLocal: typeof settings.sidebarTableSearchLocal === "boolean" ? settings.sidebarTableSearchLocal : DEFAULT_EDITOR_SETTINGS.sidebarTableSearchLocal,
sidebarGlobalSearchLocal: typeof settings.sidebarGlobalSearchLocal === "boolean" ? settings.sidebarGlobalSearchLocal : DEFAULT_EDITOR_SETTINGS.sidebarGlobalSearchLocal,
autoSelectActiveSidebarNode: settings.autoSelectActiveSidebarNode ?? DEFAULT_EDITOR_SETTINGS.autoSelectActiveSidebarNode,
openTabsRestoreMode: normalizeOpenTabsRestoreMode(
(settings as Partial<EditorSettings>).openTabsRestoreMode,
@@ -1429,6 +1435,8 @@ export const useSettingsStore = defineStore("settings", () => {
if (partial.sidebarObjectDisplay !== undefined) editorSettings.value.sidebarObjectDisplay = partial.sidebarObjectDisplay;
if (partial.routineSourceOpenMode !== undefined) editorSettings.value.routineSourceOpenMode = partial.routineSourceOpenMode;
if (partial.sidebarTableSearchEnabled !== undefined) editorSettings.value.sidebarTableSearchEnabled = partial.sidebarTableSearchEnabled;
if (partial.sidebarTableSearchLocal !== undefined) editorSettings.value.sidebarTableSearchLocal = partial.sidebarTableSearchLocal;
if (partial.sidebarGlobalSearchLocal !== undefined) editorSettings.value.sidebarGlobalSearchLocal = partial.sidebarGlobalSearchLocal;
if (partial.autoSelectActiveSidebarNode !== undefined) editorSettings.value.autoSelectActiveSidebarNode = partial.autoSelectActiveSidebarNode;
if (partial.openTabsRestoreMode !== undefined) editorSettings.value.openTabsRestoreMode = normalizeOpenTabsRestoreMode(partial.openTabsRestoreMode);
if (partial.disconnectTabHandlingMode !== undefined) editorSettings.value.disconnectTabHandlingMode = normalizeDisconnectTabHandlingMode(partial.disconnectTabHandlingMode);
+1 -1
View File
@@ -7,7 +7,7 @@ const now = Date.parse("2026-05-17T10:00:00.000Z");
test("wraps tree children with a cache timestamp", () => {
assert.deepEqual(encodeSchemaTreeCache(children, now), {
version: 2,
version: 3,
cachedAt: "2026-05-17T10:00:00.000Z",
children,
});
+5
View File
@@ -288,6 +288,11 @@ test("defaults sidebar table search to disabled and preserves saved booleans", (
assert.equal(normalizeEditorSettings({ sidebarTableSearchEnabled: true }).sidebarTableSearchEnabled, true);
assert.equal(normalizeEditorSettings({ sidebarTableSearchEnabled: false }).sidebarTableSearchEnabled, false);
assert.equal(normalizeEditorSettings({ sidebarTableSearchEnabled: "yes" as any }).sidebarTableSearchEnabled, false);
assert.equal(DEFAULT_EDITOR_SETTINGS.sidebarTableSearchLocal, true);
assert.equal(normalizeEditorSettings({}).sidebarTableSearchLocal, true);
assert.equal(normalizeEditorSettings({ sidebarTableSearchLocal: false }).sidebarTableSearchLocal, false);
assert.equal(DEFAULT_EDITOR_SETTINGS.sidebarGlobalSearchLocal, false);
assert.equal(normalizeEditorSettings({ sidebarGlobalSearchLocal: true }).sidebarGlobalSearchLocal, true);
});
test("defaults shortcut settings", () => {