feat(sidebar): add regular expression search
This commit is contained in:
1 parent
5df8990802
commit
98f5c52f8e
26 files changed
+2203
-54
No files matched your search
@@ -1,3 +1,11 @@
|
||||
/**
|
||||
* Parse the conventional `/pattern/flags` spelling used by search inputs.
|
||||
*
|
||||
* This legacy parser intentionally preserves ordinary-search behavior: it
|
||||
* accepts the established flag set and adds `i` when the user omits it.
|
||||
* Explicit Regex mode uses compileSearchRegex below for runtime-native flags
|
||||
* and exact explicit-flag semantics.
|
||||
*/
|
||||
export function parseSlashDelimitedRegexQuery(query: string): RegExp | null {
|
||||
if (!query.startsWith("/") || query.length < 2) return null;
|
||||
const lastSlash = query.lastIndexOf("/");
|
||||
@@ -6,10 +14,40 @@ export function parseSlashDelimitedRegexQuery(query: string): RegExp | null {
|
||||
const source = query.slice(1, lastSlash);
|
||||
const flags = query.slice(lastSlash + 1);
|
||||
if (!/^[dgimsuvy]*$/.test(flags)) return null;
|
||||
|
||||
try {
|
||||
return new RegExp(source, flags.includes("i") ? flags : `${flags}i`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface CompiledSearchRegex {
|
||||
regex: RegExp | null;
|
||||
invalid: boolean;
|
||||
}
|
||||
|
||||
/** Compile a search value when the caller has explicitly enabled Regex mode. */
|
||||
export function compileSearchRegex(query: string): CompiledSearchRegex {
|
||||
const value = query.trim();
|
||||
if (!value) return { regex: null, invalid: false };
|
||||
|
||||
// Keep `/pattern/flags` compatible with the existing search syntax. A
|
||||
// slash at the beginning without a closing slash is an ordinary pattern in
|
||||
// explicit Regex mode, and therefore goes through the regular constructor.
|
||||
if (value.startsWith("/")) {
|
||||
const lastSlash = value.lastIndexOf("/");
|
||||
if (lastSlash > 0) {
|
||||
try {
|
||||
return { regex: new RegExp(value.slice(1, lastSlash), value.slice(lastSlash + 1) || "i"), invalid: false };
|
||||
} catch {
|
||||
return { regex: null, invalid: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return { regex: new RegExp(value, "i"), invalid: false };
|
||||
} catch {
|
||||
return { regex: null, invalid: true };
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ export const SCHEMA_TREE_CACHE_TTL_MS = 15 * 60 * 1000;
|
||||
export interface TableSearchIndexEntry {
|
||||
name: string;
|
||||
tableType: string;
|
||||
comment?: string | null;
|
||||
}
|
||||
|
||||
export interface TableSearchIndex {
|
||||
@@ -11,6 +12,47 @@ export interface TableSearchIndex {
|
||||
entries: TableSearchIndexEntry[];
|
||||
}
|
||||
|
||||
/** Stable scope metadata used to discover complete local indexes without
|
||||
* walking or expanding the live connection tree. */
|
||||
export interface TableSearchIndexManifestEntry {
|
||||
cacheKey: string;
|
||||
parentNodeId: string;
|
||||
connectionId: string;
|
||||
database: string;
|
||||
schema?: string;
|
||||
catalog?: string;
|
||||
nodeType: string;
|
||||
path?: Array<Pick<import("@/types/database").TreeNode, "id" | "label" | "type" | "connectionId" | "database" | "catalog" | "schema" | "linkedServer" | "linkedCatalog" | "linkedSchema">>;
|
||||
}
|
||||
|
||||
export interface TableSearchIndexManifest {
|
||||
version: 1;
|
||||
scopes: TableSearchIndexManifestEntry[];
|
||||
}
|
||||
|
||||
export function encodeTableSearchIndexManifest(scopes: readonly TableSearchIndexManifestEntry[]): TableSearchIndexManifest {
|
||||
return { version: 1, scopes: [...scopes] };
|
||||
}
|
||||
|
||||
export function decodeTableSearchIndexManifest(payload: unknown): TableSearchIndexManifestEntry[] {
|
||||
if (!payload || typeof payload !== "object") return [];
|
||||
const value = payload as Partial<TableSearchIndexManifest>;
|
||||
if (value.version !== 1 || !Array.isArray(value.scopes)) return [];
|
||||
return value.scopes.filter(
|
||||
(scope): scope is TableSearchIndexManifestEntry =>
|
||||
!!scope &&
|
||||
typeof scope === "object" &&
|
||||
typeof scope.cacheKey === "string" &&
|
||||
typeof scope.parentNodeId === "string" &&
|
||||
typeof scope.connectionId === "string" &&
|
||||
typeof scope.database === "string" &&
|
||||
typeof scope.nodeType === "string" &&
|
||||
(scope.path === undefined || (Array.isArray(scope.path) && scope.path.every((node) => !!node && typeof node.id === "string" && typeof node.label === "string" && typeof node.type === "string"))) &&
|
||||
(scope.schema === undefined || typeof scope.schema === "string") &&
|
||||
(scope.catalog === undefined || typeof scope.catalog === "string"),
|
||||
);
|
||||
}
|
||||
|
||||
export interface SchemaTreeCacheEnvelope<T> {
|
||||
version: 3;
|
||||
cachedAt: string;
|
||||
@@ -37,7 +79,7 @@ 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");
|
||||
const entries = index.entries.filter((entry): entry is TableSearchIndexEntry => !!entry && typeof entry.name === "string" && typeof entry.tableType === "string" && (entry.comment === undefined || entry.comment === null || typeof entry.comment === "string"));
|
||||
return entries.length === index.entries.length ? { complete: true, indexedAt: index.indexedAt, entries } : undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { TableInfo, TreeNode, TreeNodeType } from "@/types/database";
|
||||
import type { SidebarRegexIndexScope, SidebarRegexScopeIdentity } from "@/lib/sidebar/sidebarSearchTree";
|
||||
|
||||
export const regexLocalTableParentTypes = new Set<TreeNodeType>(["database", "schema", "linked-server-schema", "group-tables"]);
|
||||
|
||||
export function regexTableSearchParents(nodes: readonly TreeNode[], result: TreeNode[] = []): TreeNode[] {
|
||||
for (const node of nodes) {
|
||||
if (regexLocalTableParentTypes.has(node.type)) result.push(node);
|
||||
if (node.children) regexTableSearchParents(node.children, result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Scope metadata plus its complete index entries. */
|
||||
export interface SidebarRegexIndexScopeResult {
|
||||
scope: Omit<SidebarRegexIndexScope, "entries">;
|
||||
entries: TableInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only local index access used by the regex search dispatch.
|
||||
*
|
||||
* The parent is addressed by its composite identity, never by the bare id:
|
||||
* TreeNode ids are not unique across the whole tree, and the store must find
|
||||
* the right cache key even when a same-id node exists in another branch.
|
||||
*/
|
||||
export interface SidebarRegexIndexReader {
|
||||
loadSidebarTableSearchIndexScopes(): Promise<SidebarRegexIndexScopeResult[]>;
|
||||
loadSidebarTableSearchIndex(parent: SidebarRegexScopeIdentity & { parentNodeId: string }): Promise<TableInfo[] | null>;
|
||||
}
|
||||
|
||||
function sidebarRegexIndexScopeKey(scope: SidebarRegexScopeIdentity & { parentNodeId: string }): string {
|
||||
return JSON.stringify([scope.parentNodeId, scope.connectionId, scope.database, scope.schema ?? null, scope.catalog ?? null, scope.nodeType]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The search state exposed to remote tree loading while regex mode is active.
|
||||
*
|
||||
* The regex source is a client-side projection and must never leak into the
|
||||
* store's remote-search query or a remote searchFilter; explicit user
|
||||
* expansion may still connect and load, but only without the regex filter.
|
||||
*/
|
||||
export function resolveSidebarRemoteSearchQuery(regexMode: boolean, query: string): string {
|
||||
return regexMode ? "" : query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble the complete local table indexes a regex search can consult.
|
||||
*
|
||||
* Only the two read methods above are ever invoked: this path cannot connect
|
||||
* data sources or refresh remote metadata. Manifest scopes keep their recorded
|
||||
* ancestor path; indexes that predate the manifest are discovered through the
|
||||
* live tree and returned without a path so the merge layer anchors them to the
|
||||
* live parent node. Reads are sequential so a large workspace never fans out
|
||||
* one storage request per database for every keypress.
|
||||
*/
|
||||
export async function collectSidebarRegexIndexScopes(reader: SidebarRegexIndexReader, liveNodes: readonly TreeNode[], shouldCancel: () => boolean): Promise<SidebarRegexIndexScope[]> {
|
||||
const loadedScopeKeys = new Set<string>();
|
||||
const manifestScopes = await reader.loadSidebarTableSearchIndexScopes();
|
||||
const loadedScopes: SidebarRegexIndexScope[] = [];
|
||||
for (const { scope, entries } of manifestScopes) {
|
||||
if (shouldCancel()) return loadedScopes;
|
||||
loadedScopeKeys.add(sidebarRegexIndexScopeKey(scope));
|
||||
loadedScopes.push({ ...scope, entries });
|
||||
}
|
||||
for (const parent of regexTableSearchParents(liveNodes)) {
|
||||
if (shouldCancel()) return loadedScopes;
|
||||
const parentScope = {
|
||||
parentNodeId: parent.id,
|
||||
connectionId: parent.connectionId || "",
|
||||
database: parent.database || "",
|
||||
schema: parent.schema,
|
||||
catalog: parent.catalog,
|
||||
nodeType: parent.type,
|
||||
};
|
||||
const scopeKey = sidebarRegexIndexScopeKey(parentScope);
|
||||
if (loadedScopeKeys.has(scopeKey)) continue;
|
||||
const entries = await reader.loadSidebarTableSearchIndex(parentScope);
|
||||
loadedScopeKeys.add(scopeKey);
|
||||
if (entries) {
|
||||
loadedScopes.push({ ...parentScope, path: undefined, entries });
|
||||
}
|
||||
}
|
||||
return loadedScopes;
|
||||
}
|
||||
|
||||
export type SidebarSearchDispatchMode = "regex" | "none" | "ordinary";
|
||||
|
||||
export interface SidebarSearchTransition {
|
||||
query: string;
|
||||
regexMode: boolean;
|
||||
wasRegexMode: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the sidebar global-search dispatch branch for a query/mode transition.
|
||||
*
|
||||
* Regex mode always short-circuits to the local index projection regardless of
|
||||
* the local-search setting. Ordinary remote refresh runs whenever regex is off,
|
||||
* a non-empty query remains, and local search is disabled, including an
|
||||
* explicit transition out of regex mode.
|
||||
*/
|
||||
export function resolveSidebarSearchDispatchMode(transition: SidebarSearchTransition, options: { localSearchEnabled: boolean }): SidebarSearchDispatchMode {
|
||||
if (transition.regexMode) return "regex";
|
||||
if (options.localSearchEnabled) return "none";
|
||||
if (transition.wasRegexMode && !transition.query) return "none";
|
||||
return "ordinary";
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { parseSlashDelimitedRegexQuery } from "@/lib/common/searchPattern";
|
||||
import { compileSearchRegex, parseSlashDelimitedRegexQuery } from "@/lib/common/searchPattern";
|
||||
|
||||
export type SidebarSearchMatchKind = "exact" | "prefix" | "word-prefix" | "substring" | "abbreviation" | "fuzzy" | "regex";
|
||||
|
||||
@@ -130,10 +130,22 @@ function matchesWordPrefixConcatenation(label: string, query: string): boolean {
|
||||
return dp[queryLength] !== INF && dp[queryLength] >= 2;
|
||||
}
|
||||
|
||||
function matchSidebarLabelWithRegex(label: string, query: string, regex: RegExp | null): SidebarSearchMatch | null {
|
||||
function testRegex(regex: RegExp, value: string): boolean {
|
||||
// Global and sticky expressions retain lastIndex between calls. Search
|
||||
// candidates are independent values, so every candidate must start from a
|
||||
// deterministic index (and leave no state for the next tree row).
|
||||
regex.lastIndex = 0;
|
||||
const matched = regex.test(value);
|
||||
regex.lastIndex = 0;
|
||||
return matched;
|
||||
}
|
||||
|
||||
function matchSidebarLabelWithRegex(label: string, query: string, regex: RegExp | null, invalidRegex = false): SidebarSearchMatch | null {
|
||||
if (!query) return null;
|
||||
|
||||
if (regex) return regex.test(label) ? { kind: "regex", score: 95 } : null;
|
||||
if (invalidRegex) return null;
|
||||
|
||||
if (regex) return testRegex(regex, label) ? { kind: "regex", score: 95 } : null;
|
||||
|
||||
// Case-insensitive comparison is centralized here so callers can pass the
|
||||
// original label and query. The original label is what the boundary-aware
|
||||
@@ -169,11 +181,27 @@ function matchSidebarLabelWithRegex(label: string, query: string, regex: RegExp
|
||||
return null;
|
||||
}
|
||||
|
||||
export function createSidebarLabelMatcher(query: string): SidebarLabelMatcher {
|
||||
const regex = parseSlashDelimitedRegexQuery(query);
|
||||
return (label) => matchSidebarLabelWithRegex(label, query, regex);
|
||||
export interface SidebarSearchMatcherOptions {
|
||||
regexMode?: boolean;
|
||||
}
|
||||
|
||||
export function matchSidebarLabel(label: string, query: string): SidebarSearchMatch | null {
|
||||
return matchSidebarLabelWithRegex(label, query, parseSlashDelimitedRegexQuery(query));
|
||||
function slashDelimitedCandidate(query: string): boolean {
|
||||
return query.startsWith("/") && query.lastIndexOf("/") > 0;
|
||||
}
|
||||
|
||||
export function createSidebarLabelMatcher(query: string, options: SidebarSearchMatcherOptions = {}): SidebarLabelMatcher {
|
||||
if (options.regexMode) {
|
||||
const compiled = compileSearchRegex(query);
|
||||
return (label) => matchSidebarLabelWithRegex(label, query, compiled.regex, compiled.invalid);
|
||||
}
|
||||
|
||||
const regex = parseSlashDelimitedRegexQuery(query);
|
||||
// A slash-delimited expression is an explicit regex request even when it is
|
||||
// malformed. Do not silently reinterpret an invalid pattern as fuzzy text.
|
||||
const invalidRegex = slashDelimitedCandidate(query) && !regex;
|
||||
return (label) => matchSidebarLabelWithRegex(label, query, regex, invalidRegex);
|
||||
}
|
||||
|
||||
export function matchSidebarLabel(label: string, query: string, options: SidebarSearchMatcherOptions = {}): SidebarSearchMatch | null {
|
||||
return createSidebarLabelMatcher(query, options)(label);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { TreeNode, TreeNodeType } from "@/types/database";
|
||||
import { createSidebarLabelMatcher, type SidebarLabelMatcher } from "@/lib/sidebar/sidebarSearch";
|
||||
import type { TableInfo, TreeNode, TreeNodeType } from "@/types/database";
|
||||
import { createSidebarLabelMatcher, type SidebarLabelMatcher, type SidebarSearchMatcherOptions } from "@/lib/sidebar/sidebarSearch";
|
||||
import { buildTableTreeNodes } from "@/lib/table/tableTree";
|
||||
|
||||
const preserveMatchedSubtreeTypes = new Set(["connection", "database", "schema", "table", "view", "mongo-db", "mongo-collection"]);
|
||||
const hiddenSearchNodeTypes = new Set<TreeNodeType>(["user-admin", "dameng-job-admin"]);
|
||||
@@ -39,21 +40,21 @@ function nodePreservesSearchSubtree(node: TreeNode, matchLabel: SidebarLabelMatc
|
||||
return preserveMatchedSubtreeTypes.has(node.type) && !!bestMatch(matchLabel, normalizedLabel(node), node.comment, node.searchAliases);
|
||||
}
|
||||
|
||||
export function createSidebarSearchSubtreePreserver(query: string, searchableNodeTypes?: ReadonlySet<TreeNodeType>): (node: TreeNode) => boolean {
|
||||
const matchLabel = query ? createSidebarLabelMatcher(query) : undefined;
|
||||
export function createSidebarSearchSubtreePreserver(query: string, searchableNodeTypes?: ReadonlySet<TreeNodeType>, options?: SidebarSearchMatcherOptions): (node: TreeNode) => boolean {
|
||||
const matchLabel = query ? createSidebarLabelMatcher(query, options) : undefined;
|
||||
return (node) => !!matchLabel && nodePreservesSearchSubtree(node, matchLabel, searchableNodeTypes);
|
||||
}
|
||||
|
||||
export function resolveSidebarObjectSearchFilter(nodes: readonly TreeNode[], targetNodeId: string, query: string, searchableNodeTypes?: ReadonlySet<TreeNodeType>): string {
|
||||
export function resolveSidebarObjectSearchFilter(nodes: readonly TreeNode[], targetNodeId: string, query: string, searchableNodeTypes?: ReadonlySet<TreeNodeType>, options?: SidebarSearchMatcherOptions): string {
|
||||
if (!query) return query;
|
||||
const matchLabel = createSidebarLabelMatcher(query);
|
||||
const matchLabel = createSidebarLabelMatcher(query, options);
|
||||
const path = findNodePath(nodes, targetNodeId);
|
||||
const preservesSubtree = path?.some((node) => nodePreservesSearchSubtree(node, matchLabel, searchableNodeTypes));
|
||||
return preservesSubtree ? "" : query;
|
||||
}
|
||||
|
||||
export function filterSidebarTree(nodes: TreeNode[], query: string, collapsedIds: ReadonlySet<string>, searchableNodeTypes?: ReadonlySet<TreeNodeType>): TreeNode[] {
|
||||
const matchLabel = query ? createSidebarLabelMatcher(query) : undefined;
|
||||
export function filterSidebarTree(nodes: TreeNode[], query: string, collapsedIds: ReadonlySet<string>, searchableNodeTypes?: ReadonlySet<TreeNodeType>, options?: SidebarSearchMatcherOptions): TreeNode[] {
|
||||
const matchLabel = query ? createSidebarLabelMatcher(query, options) : undefined;
|
||||
if (!matchLabel && searchableNodeTypes === undefined) return nodes;
|
||||
return filterSidebarTreeWithMatcher(nodes, matchLabel, collapsedIds, searchableNodeTypes);
|
||||
}
|
||||
@@ -63,6 +64,236 @@ export function reuseLiveSidebarTreeNodes(indexedNodes: TreeNode[], liveNodes: r
|
||||
return indexedNodes.map((node) => liveNodesById.get(node.id) ?? node);
|
||||
}
|
||||
|
||||
export interface SidebarRegexIndexScope {
|
||||
parentNodeId: string;
|
||||
connectionId: string;
|
||||
database: string;
|
||||
schema?: string;
|
||||
catalog?: string;
|
||||
nodeType: string;
|
||||
path?: Array<Pick<TreeNode, "id" | "label" | "type" | "connectionId" | "database" | "catalog" | "schema" | "linkedServer" | "linkedCatalog" | "linkedSchema">>;
|
||||
entries: TableInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Composite identity of a regex index parent scope. TreeNode ids are not
|
||||
* unique across the whole tree (a database named "a:b" and a schema "b" under
|
||||
* a database "a" can both produce "connectionId:a:b"), so index lookups,
|
||||
* manifest backfills, and result merging must match on id plus the full
|
||||
* database context instead of the bare id.
|
||||
*/
|
||||
export interface SidebarRegexScopeIdentity {
|
||||
connectionId: string;
|
||||
database: string;
|
||||
schema?: string;
|
||||
catalog?: string;
|
||||
nodeType: string;
|
||||
}
|
||||
|
||||
function identityFieldMatches(nodeValue: string | undefined, expected: string | undefined): boolean {
|
||||
return nodeValue === expected;
|
||||
}
|
||||
|
||||
export function nodeMatchesRegexScopeIdentity(node: TreeNode, id: string, identity: SidebarRegexScopeIdentity): boolean {
|
||||
if (node.id !== id) return false;
|
||||
if (node.type !== identity.nodeType) return false;
|
||||
if (node.connectionId !== identity.connectionId) return false;
|
||||
// Connection roots deliberately carry no database scope; all actual index
|
||||
// parents below them must match every database-context field strictly.
|
||||
if (node.type === "connection") return true;
|
||||
if (!identityFieldMatches(node.database, identity.database)) return false;
|
||||
if (!identityFieldMatches(node.schema, identity.schema)) return false;
|
||||
if (!identityFieldMatches(node.catalog, identity.catalog)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function findNodePathByIdentity(nodes: readonly TreeNode[], id: string, identity: SidebarRegexScopeIdentity, ancestors: readonly TreeNode[] = []): readonly TreeNode[] | undefined {
|
||||
for (const node of nodes) {
|
||||
const path = [...ancestors, node];
|
||||
if (node.id === id) {
|
||||
// Same-id nodes can exist in different branches; only the one matching
|
||||
// the full database context counts as the target parent.
|
||||
if (nodeMatchesRegexScopeIdentity(node, id, identity)) return path;
|
||||
continue;
|
||||
}
|
||||
if (node.children) {
|
||||
const childPath = findNodePathByIdentity(node.children, id, identity, path);
|
||||
if (childPath) return childPath;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function indexedTableChildren(scope: SidebarRegexIndexScope): TreeNode[] {
|
||||
// Reuse the regular table-node builder so index hits share live-node id
|
||||
// rules, normalized object types, name sorting, catalog/schema fields, and
|
||||
// partition parent/child nesting instead of hand-rolled node literals.
|
||||
return buildTableTreeNodes({
|
||||
nodeId: scope.parentNodeId,
|
||||
connectionId: scope.connectionId,
|
||||
database: scope.database,
|
||||
schema: scope.schema,
|
||||
catalog: scope.catalog,
|
||||
tables: scope.entries,
|
||||
});
|
||||
}
|
||||
|
||||
function syntheticRegexParent(scope: SidebarRegexIndexScope): TreeNode {
|
||||
const snapshot = scope.path?.find((node) => node.id === scope.parentNodeId);
|
||||
return {
|
||||
id: scope.parentNodeId,
|
||||
label: snapshot?.label || (scope.nodeType === "group-tables" ? "tree.tables" : scope.schema || scope.database),
|
||||
type: (snapshot?.type || scope.nodeType) as TreeNodeType,
|
||||
connectionId: scope.connectionId,
|
||||
database: scope.database,
|
||||
schema: scope.schema,
|
||||
catalog: scope.catalog,
|
||||
...(snapshot?.linkedServer ? { linkedServer: snapshot.linkedServer } : {}),
|
||||
...(snapshot?.linkedCatalog ? { linkedCatalog: snapshot.linkedCatalog } : {}),
|
||||
...(snapshot?.linkedSchema ? { linkedSchema: snapshot.linkedSchema } : {}),
|
||||
isExpanded: true,
|
||||
children: indexedTableChildren(scope),
|
||||
};
|
||||
}
|
||||
|
||||
function pathNode(pathNode: NonNullable<SidebarRegexIndexScope["path"]>[number]): TreeNode {
|
||||
return { ...pathNode, isExpanded: true, children: [] };
|
||||
}
|
||||
|
||||
function mergeRegexTreeNode(live: TreeNode | undefined, indexed: TreeNode): TreeNode {
|
||||
if (!live) return indexed;
|
||||
const children = mergeRegexTreeNodes(live.children ?? [], indexed.children ?? []);
|
||||
return children.length === (live.children ?? []).length && children.every((child, index) => child === live.children?.[index]) ? live : { ...live, children };
|
||||
}
|
||||
|
||||
function mergeRegexTreeNodes(liveNodes: readonly TreeNode[], indexedNodes: readonly TreeNode[]): TreeNode[] {
|
||||
const byId = new Map(liveNodes.map((node) => [node.id, node]));
|
||||
const merged = [...liveNodes];
|
||||
for (const indexed of indexedNodes) {
|
||||
const existing = byId.get(indexed.id);
|
||||
if (existing) {
|
||||
const next = mergeRegexTreeNode(existing, indexed);
|
||||
if (next !== existing) merged[merged.indexOf(existing)] = next;
|
||||
} else {
|
||||
merged.push(indexed);
|
||||
byId.set(indexed.id, indexed);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add complete local-index results to a display-only tree. The live tree is
|
||||
* never mutated; existing nodes win identity/state, while missing connection,
|
||||
* database, schema, and table-group ancestors are synthesized from manifest
|
||||
* scope metadata. Duplicate table identities prefer the narrowest scope.
|
||||
*/
|
||||
export function mergeSidebarRegexIndexScopes(liveNodes: readonly TreeNode[], scopes: readonly SidebarRegexIndexScope[]): TreeNode[] {
|
||||
const selected = new Map<string, { rank: number; scope: SidebarRegexIndexScope; entry: TableInfo }>();
|
||||
const rankFor = (scope: SidebarRegexIndexScope) => (scope.nodeType === "group-tables" ? 3 : scope.nodeType === "schema" ? 2 : 1);
|
||||
for (const scope of scopes) {
|
||||
for (const entry of scope.entries) {
|
||||
const key = `${scope.connectionId}\0${scope.catalog || ""}\0${scope.database}\0${scope.schema || ""}\0${entry.table_type}\0${entry.name}`;
|
||||
const rank = rankFor(scope);
|
||||
if (!selected.has(key) || selected.get(key)!.rank < rank) selected.set(key, { rank, scope, entry });
|
||||
}
|
||||
}
|
||||
|
||||
const scopeIdentity = (scope: SidebarRegexIndexScope): SidebarRegexScopeIdentity => ({
|
||||
connectionId: scope.connectionId,
|
||||
database: scope.database,
|
||||
schema: scope.schema,
|
||||
catalog: scope.catalog,
|
||||
nodeType: scope.nodeType,
|
||||
});
|
||||
const indexedRoots: TreeNode[] = [];
|
||||
const indexedRootIdentities = new Map<string, SidebarRegexScopeIdentity>();
|
||||
const anchoredParents: Array<{ node: TreeNode; identity: SidebarRegexScopeIdentity }> = [];
|
||||
const rootsById = new Map<string, TreeNode>();
|
||||
const ensureRoot = (id: string, label: string, type: TreeNodeType, scope: SidebarRegexIndexScope): TreeNode => {
|
||||
const existing = rootsById.get(id);
|
||||
if (existing) return existing;
|
||||
const created: TreeNode = { id, label, type, connectionId: scope.connectionId, database: scope.database, catalog: scope.catalog, isExpanded: true, children: [] };
|
||||
rootsById.set(id, created);
|
||||
indexedRoots.push(created);
|
||||
// The root is a connection: its database context does not apply to the
|
||||
// live connection node itself (it has no database field), so identity
|
||||
// matching compares id + type + connectionId.
|
||||
indexedRootIdentities.set(id, { ...scopeIdentity(scope), nodeType: "connection" });
|
||||
return created;
|
||||
};
|
||||
|
||||
const grouped = new Map<string, SidebarRegexIndexScope>();
|
||||
for (const { scope, entry } of selected.values()) {
|
||||
const key = JSON.stringify([scope.parentNodeId, scope.connectionId, scope.database, scope.schema ?? null, scope.catalog ?? null, scope.nodeType]);
|
||||
const previous = grouped.get(key);
|
||||
if (previous) previous.entries.push(entry);
|
||||
else grouped.set(key, { ...scope, entries: [entry] });
|
||||
}
|
||||
|
||||
const liveScopeParentExists = (scope: SidebarRegexIndexScope): boolean => !!findNodePathByIdentity(liveNodes, scope.parentNodeId, scopeIdentity(scope));
|
||||
|
||||
for (const scope of grouped.values()) {
|
||||
const path = scope.path;
|
||||
if (!path) {
|
||||
// First-time backfill of an index that predates the manifest: merge the
|
||||
// entries directly into the live parent wherever the tree places it
|
||||
// (connection groups, Oracle/Dameng connection->schema, Doris catalogs,
|
||||
// SQL Server linked servers). Do not guess ancestors from the id.
|
||||
if (!liveScopeParentExists(scope)) continue;
|
||||
anchoredParents.push({ node: syntheticRegexParent(scope), identity: scopeIdentity(scope) });
|
||||
continue;
|
||||
}
|
||||
const connectionPath = path.find((node) => node.type === "connection");
|
||||
if (!connectionPath) continue;
|
||||
// A removed connection must never come back as a ghost result: manifest
|
||||
// scopes only render when their recorded connection is still in the tree.
|
||||
// Same-id lookups are disambiguated by the database context.
|
||||
if (!findNodePathByIdentity(liveNodes, connectionPath.id, { ...scopeIdentity(scope), nodeType: "connection" })) continue;
|
||||
const connection = ensureRoot(connectionPath.id, connectionPath.label, "connection", scope);
|
||||
let parent: TreeNode = connection;
|
||||
const ancestors = path.slice(path.findIndex((node) => node.type === "connection") + 1);
|
||||
for (const ancestor of ancestors) {
|
||||
if (ancestor.id === scope.parentNodeId) break;
|
||||
const next = (parent.children ?? []).find((child) => child.id === ancestor.id) ?? pathNode(ancestor);
|
||||
if (!parent.children?.some((child) => child.id === next.id)) parent.children = [...(parent.children ?? []), next];
|
||||
parent = next;
|
||||
}
|
||||
const scopeParent = syntheticRegexParent(scope);
|
||||
const existing = (parent.children ?? []).find((child) => child.id === scope.parentNodeId);
|
||||
parent.children = existing ? (parent.children ?? []).map((child) => (child.id === existing.id ? mergeRegexTreeNode(existing, scopeParent) : child)) : [...(parent.children ?? []), scopeParent];
|
||||
}
|
||||
|
||||
let merged = [...liveNodes];
|
||||
// Same-id nodes can live in different branches (database "a:b" vs schema
|
||||
// "b" under database "a"), so every merge target is matched by id plus its
|
||||
// composite database context, never by the bare id.
|
||||
const mergeRoot = (nodes: readonly TreeNode[], indexed: TreeNode, identity: SidebarRegexScopeIdentity): { nodes: TreeNode[]; found: boolean } => {
|
||||
const direct = nodes.findIndex((node) => nodeMatchesRegexScopeIdentity(node, indexed.id, identity));
|
||||
if (direct >= 0) {
|
||||
return { nodes: nodes.map((node, index) => (index === direct ? mergeRegexTreeNode(node, indexed) : node)), found: true };
|
||||
}
|
||||
for (let index = 0; index < nodes.length; index++) {
|
||||
const node = nodes[index];
|
||||
if (!node.children) continue;
|
||||
const result = mergeRoot(node.children, indexed, identity);
|
||||
if (!result.found) continue;
|
||||
return { nodes: nodes.map((candidate, candidateIndex) => (candidateIndex === index ? { ...candidate, children: result.nodes } : candidate)), found: true };
|
||||
}
|
||||
return { nodes: [...nodes], found: false };
|
||||
};
|
||||
for (const indexed of indexedRoots) {
|
||||
const identity = indexedRootIdentities.get(indexed.id);
|
||||
const result = identity ? mergeRoot(merged, indexed, identity) : { nodes: merged, found: false };
|
||||
merged = result.found ? result.nodes : mergeRegexTreeNodes(merged, [indexed]);
|
||||
}
|
||||
for (const { node, identity } of anchoredParents) {
|
||||
const result = mergeRoot(merged, node, identity);
|
||||
if (result.found) merged = result.nodes;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function applySearchCollapsedState(node: TreeNode, collapsedIds: ReadonlySet<string>): TreeNode {
|
||||
const children = node.children?.map((child) => applySearchCollapsedState(child, collapsedIds));
|
||||
const childrenChanged = children?.some((child, index) => child !== node.children?.[index]) ?? false;
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { TreeNode } from "@/types/database";
|
||||
export interface SidebarTreeContext {
|
||||
getVisibleNodes: () => TreeNode[];
|
||||
getVisibleNodeIndex: (id: string) => number;
|
||||
isSearchProjectionActive?: () => boolean;
|
||||
getTreeLoadSearchOptions?: (node: TreeNode) => { searchFilter: string; allowGlobalSearchMismatch: true; expectedSidebarSearchQuery: string } | undefined;
|
||||
setTableSearchQuery?: (parentNodeId: string, query: string, local: boolean) => void;
|
||||
refreshTableSearchIndex?: (parentNodeId: string) => void;
|
||||
|
||||
Reference in new issue
Block a user