feat(sidebar): add regular expression search

This commit is contained in:
Lan-zk authored and GitHub committed 2026-08-14 02:25:24 +08:00
1 parent 5df8990802
commit 98f5c52f8e
26 files changed
+2203 -54

No files matched your search

+39 -1
View File
@@ -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";
}
+36 -8
View File
@@ -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;