fix(sidebar): match cross-word table names via prefix concatenation

This commit is contained in:
Abeautifulsnow authored and GitHub committed 2026-08-07 13:43:42 +08:00
1 parent fd3f9b169c
commit 13878a1a30
4 files changed
+234 -29

No files matched your search

@@ -428,15 +428,14 @@ function filterLocallySearchedTables(nodes: TreeNode[]): TreeNode[] {
if (!query || !children) return children === node.children ? node : { ...node, children };
const indexed = localTableSearchResults.value[node.id];
// matchSidebarLabel compares case-insensitively internally and needs the
// ORIGINAL label (and entry name) so camelCase boundaries stay detectable.
const matchingChildren =
indexed === null
? children.filter((child) => localTableSearchChildTypes.has(child.type) && !!matchSidebarLabel(child.label.toLowerCase(), query.toLowerCase()))
? children.filter((child) => localTableSearchChildTypes.has(child.type) && !!matchSidebarLabel(child.label, query))
: indexed
? reuseLiveSidebarTreeNodes(
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,
)
: children.filter((child) => localTableSearchChildTypes.has(child.type) && !!matchSidebarLabel(child.label.toLowerCase(), query.toLowerCase()));
? reuseLiveSidebarTreeNodes(buildTableTreeNodes({ nodeId: node.id, connectionId: node.connectionId || "", database: node.database || "", schema: node.schema, catalog: node.catalog, tables: indexed.filter((entry) => !!matchSidebarLabel(entry.name, query)) }), children)
: children.filter((child) => localTableSearchChildTypes.has(child.type) && !!matchSidebarLabel(child.label, query));
return { ...node, children: matchingChildren };
});
}
+104 -14
View File
@@ -12,7 +12,12 @@ export type SidebarLabelMatcher = (label: string) => SidebarSearchMatch | null;
function isWordBoundary(text: string, index: number): boolean {
if (index === 0) return true;
const prev = text[index - 1];
return prev === "_" || prev === "-" || prev === "." || prev === " " || prev === "/" || prev === "\\";
if (prev === "_" || prev === "-" || prev === "." || prev === " " || prev === "/" || prev === "\\") return true;
// camelCase transition: "camelCaseTable" reads as "camel" | "Case" | "Table".
// Extends word segmentation only — tier priority and labels without a
// lowercase-to-uppercase transition are unchanged.
const curr = text[index];
return prev >= "a" && prev <= "z" && curr >= "A" && curr <= "Z";
}
const SEPARATOR_RE = /[_\-. /\\]/g;
@@ -27,17 +32,23 @@ function stripSeparators(text: string): string {
return text.replace(SEPARATOR_RE, "");
}
// Boundary-aware helpers take the ORIGINAL label so `isWordBoundary` can see
// camelCase transitions, and a LOWER-CASED query. `toLowerCase` preserves
// string length, so label/query indices stay aligned.
function matchesWordPrefix(text: string, query: string): boolean {
const lowerText = text.toLowerCase();
for (let i = 0; i < text.length; i++) {
if (isWordBoundary(text, i) && text.startsWith(query, i)) return true;
if (isWordBoundary(text, i) && lowerText.startsWith(query, i)) return true;
}
return false;
}
function matchesAbbreviation(text: string, query: string): boolean {
const lowerText = text.toLowerCase();
let j = 0;
for (let i = 0; i < text.length && j < query.length; i++) {
if (isWordBoundary(text, i) && text[i] === query[j]) j++;
if (isWordBoundary(text, i) && lowerText[i] === query[j]) j++;
}
return j === query.length;
}
@@ -45,35 +56,114 @@ function matchesAbbreviation(text: string, query: string): boolean {
function matchesSubsequence(text: string, query: string): boolean {
if (query.length < 2 || query.length > text.length) return false;
const lowerText = text.toLowerCase();
let j = 0;
for (let i = 0; i < text.length && j < query.length; i++) {
if (isWordBoundary(text, i) && i > 0) {
j = 0;
}
if (text[i] === query[j]) j++;
if (lowerText[i] === query[j]) j++;
}
return j === query.length;
}
const MIN_CONCAT_PREFIX_LEN = 2;
/**
* Split an identifier into words for word-segment prefix concatenation.
* Splits on the same separators as `isWordBoundary`/`stripSeparators` and on
* camelCase transitions ("camelCaseTable" -> "camel", "Case", "Table").
* Original case is preserved so transitions stay detectable.
*/
function splitLabelWords(label: string): string[] {
const words: string[] = [];
for (const segment of label.split(SEPARATOR_RE)) {
if (!segment) continue;
let start = 0;
for (let index = 1; index < segment.length; index++) {
const prev = segment[index - 1];
const curr = segment[index];
if (prev >= "a" && prev <= "z" && curr >= "A" && curr <= "Z") {
words.push(segment.slice(start, index));
start = index;
}
}
words.push(segment.slice(start));
}
return words;
}
/**
* Word-segment prefix concatenation (DataGrip-style). A query matches when it
* can be split into k >= 2 pieces, each length >= 2 and a prefix of a distinct
* label word consumed in order (words may be skipped). This is the tier that
* makes "exclog" match "system_exception_log" ("exc" from exception + "log")
* and "syslog" match it (skipping the middle word). The min-2-chars-per-piece
* rule keeps loose cross-word queries ("roles" on "sys_role_data_scope",
* "urf" on "user_profile") from matching. Matching is case-insensitive so the
* camelCase words found by `splitLabelWords` align with lowercase queries.
*/
function matchesWordPrefixConcatenation(label: string, query: string): boolean {
const words = splitLabelWords(label);
if (words.length < 2 || query.length < 2 * MIN_CONCAT_PREFIX_LEN) return false;
const lowerQuery = query.toLowerCase();
const queryLength = query.length;
const INF = Infinity;
// dp[j] = fewest words needed to consume lowerQuery[0..j).
let dp = Array.from({ length: queryLength + 1 }, () => INF);
dp[0] = 0;
for (const word of words) {
const next = [...dp];
const lowerWord = word.toLowerCase();
for (let j = 0; j < queryLength; j++) {
if (dp[j] === INF) continue;
const maxLen = Math.min(word.length, queryLength - j);
for (let len = MIN_CONCAT_PREFIX_LEN; len <= maxLen; len++) {
if (lowerWord.startsWith(lowerQuery.slice(j, j + len))) {
next[j + len] = Math.min(next[j + len], dp[j] + 1);
}
}
}
dp = next;
}
return dp[queryLength] !== INF && dp[queryLength] >= 2;
}
function matchSidebarLabelWithRegex(label: string, query: string, regex: RegExp | null): SidebarSearchMatch | null {
if (!query) return null;
if (regex) return regex.test(label) ? { kind: "regex", score: 95 } : null;
if (label === query) return { kind: "exact", score: 100 };
if (label.startsWith(query)) return { kind: "prefix", score: 90 };
if (matchesWordPrefix(label, query)) return { kind: "word-prefix", score: 80 };
if (label.includes(query)) return { kind: "substring", score: 70 };
if (query.length >= 2 && matchesAbbreviation(label, query)) return { kind: "abbreviation", score: 60 };
if (matchesSubsequence(label, query)) return { kind: "fuzzy", score: 40 };
// Case-insensitive comparison is centralized here so callers can pass the
// original label and query. The original label is what the boundary-aware
// tiers below need to detect camelCase transitions ("camelCaseTable" reads
// as "camel" | "Case" | "Table").
const lowerLabel = label.toLowerCase();
const lowerQuery = query.toLowerCase();
if (lowerLabel === lowerQuery) return { kind: "exact", score: 100 };
if (lowerLabel.startsWith(lowerQuery)) return { kind: "prefix", score: 90 };
if (matchesWordPrefix(label, lowerQuery)) return { kind: "word-prefix", score: 80 };
if (lowerLabel.includes(lowerQuery)) return { kind: "substring", score: 70 };
if (lowerQuery.length >= 2 && matchesAbbreviation(label, lowerQuery)) return { kind: "abbreviation", score: 60 };
if (matchesSubsequence(label, lowerQuery)) return { kind: "fuzzy", score: 40 };
// Separator-blind matching: strip underscores, hyphens, dots, spaces,
// and slashes, then try again. This lets "delo" match "del_order"
// without typing the underscore separator between prefix and name.
const stripped = stripSeparators(label);
if (stripped !== label && stripped.length >= query.length) {
if (stripped.startsWith(query)) return { kind: "word-prefix", score: 65 };
if (stripped.includes(query)) return { kind: "substring", score: 55 };
const stripped = stripSeparators(lowerLabel);
if (stripped !== lowerLabel && stripped.length >= lowerQuery.length) {
if (stripped.startsWith(lowerQuery)) return { kind: "word-prefix", score: 65 };
if (stripped.includes(lowerQuery)) return { kind: "substring", score: 55 };
}
// Word-segment prefix concatenation: lets "exclog" find
// "system_exception_log". Uses the ORIGINAL label so camelCase boundaries
// survive for word segmentation, and compares case-insensitively. Flat 50
// keeps it below every existing tier — purely additive.
if (matchesWordPrefixConcatenation(label, query)) {
return { kind: "abbreviation", score: 50 };
}
return null;
@@ -3,23 +3,20 @@ import { createSidebarLabelMatcher, type SidebarLabelMatcher } from "@/lib/sideb
const preserveMatchedSubtreeTypes = new Set(["connection", "database", "schema", "table", "view", "mongo-db", "mongo-collection"]);
const normalizedLabelCache = new WeakMap<TreeNode, { label: string; normalized: string }>();
function bestMatch(matchLabel: SidebarLabelMatcher, label: string, comment?: string | null) {
const lm = matchLabel(label);
if (!comment) return lm;
const cm = matchLabel(comment.toLowerCase());
const cm = matchLabel(comment);
if (lm && cm) return lm.score >= cm.score ? lm : cm;
return lm ?? cm;
}
function normalizedLabel(node: TreeNode): string {
const cached = normalizedLabelCache.get(node);
if (cached?.label === node.label) return cached.normalized;
const normalized = node.label.toLowerCase();
normalizedLabelCache.set(node, { label: node.label, normalized });
return normalized;
// Keep the original case. The matcher lowercases internally for comparison;
// preserving case here lets it tokenize camelCase labels ("camelCaseTable"
// -> "camel" | "Case" | "Table") instead of treating them as one lowercase
// blob.
return node.label;
}
export function filterSidebarTree(nodes: TreeNode[], query: string, collapsedIds: ReadonlySet<string>, searchableNodeTypes?: ReadonlySet<TreeNodeType>): TreeNode[] {