518 lines
18 KiB
TypeScript
518 lines
18 KiB
TypeScript
import { computed, ref, watch } from "vue";
|
|
import type { ConnectionConfig } from "@/types/database";
|
|
import type { SqlCompletionTable } from "@/lib/sql/sqlCompletion";
|
|
import { useConnectionStore } from "@/stores/connectionStore";
|
|
|
|
const REMOTE_SEARCH_DEBOUNCE_MS = 180;
|
|
const REMOTE_SEARCH_MIN_QUERY_LENGTH = 2;
|
|
const REMOTE_SEARCH_MAX_REQUESTS = 8;
|
|
const REMOTE_SEARCH_CONCURRENCY = 2;
|
|
const REMOTE_SEARCH_RESULTS_PER_REQUEST = 25;
|
|
const REMOTE_SEARCH_MAX_RESULTS = 100;
|
|
const QUICK_OPEN_MAX_RESULTS = 200;
|
|
|
|
const REMOTE_SEARCH_UNSUPPORTED_TYPES = new Set<ConnectionConfig["db_type"]>(["redis", "mongodb", "elasticsearch", "qdrant", "milvus", "weaviate", "chromadb", "neo4j", "influxdb", "etcd", "zookeeper", "mq", "nacos"]);
|
|
|
|
export interface QuickOpenItem {
|
|
id: string;
|
|
type: "connection" | "database" | "schema" | "table" | "view" | "materialized_view" | "procedure" | "function" | "sequence" | "package" | "package-body";
|
|
label: string;
|
|
description?: string;
|
|
connectionId: string;
|
|
database?: string;
|
|
schema?: string;
|
|
objectName?: string; // For non-table objects (views, procedures, functions, sequences, packages)
|
|
tableName?: string; // Kept for backward compatibility
|
|
connectionName?: string;
|
|
searchText: string; // Lowercase text for searching
|
|
}
|
|
|
|
/**
|
|
* Fuzzy match function that checks if query matches text
|
|
* Returns the matched indices for highlighting
|
|
*/
|
|
function fuzzyMatch(query: string, text: string): { score: number; indices: number[] } | null {
|
|
const lowerQuery = query.toLowerCase();
|
|
const lowerText = text.toLowerCase();
|
|
|
|
if (!lowerQuery) return { score: Infinity, indices: [] };
|
|
if (lowerText.includes(lowerQuery)) {
|
|
// Exact substring match gets highest score
|
|
const startIdx = lowerText.indexOf(lowerQuery);
|
|
return {
|
|
score: 1,
|
|
indices: Array.from({ length: lowerQuery.length }, (_, i) => startIdx + i),
|
|
};
|
|
}
|
|
|
|
// Fuzzy match: find all characters in order
|
|
let queryIdx = 0;
|
|
const indices: number[] = [];
|
|
let score = 0;
|
|
let lastMatchIdx = -1;
|
|
|
|
for (let i = 0; i < lowerText.length && queryIdx < lowerQuery.length; i++) {
|
|
if (lowerText[i] === lowerQuery[queryIdx]) {
|
|
indices.push(i);
|
|
// Score based on proximity (consecutive chars score better)
|
|
score += lastMatchIdx === i - 1 ? 2 : 1;
|
|
lastMatchIdx = i;
|
|
queryIdx++;
|
|
}
|
|
}
|
|
|
|
if (queryIdx === lowerQuery.length) {
|
|
return { score: score / lowerQuery.length, indices };
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
interface MatchedItem extends QuickOpenItem {
|
|
matchScore: number;
|
|
matchIndices: number[];
|
|
}
|
|
|
|
export function useQuickOpen() {
|
|
const connectionStore = useConnectionStore();
|
|
const searchQuery = ref("");
|
|
const selectedIndex = ref(0);
|
|
const remoteItems = ref<QuickOpenItem[]>([]);
|
|
let remoteSearchGeneration = 0;
|
|
let remoteSearchTimer: ReturnType<typeof setTimeout> | undefined;
|
|
let activeRemoteRequests = 0;
|
|
const remoteRequestWaiters: Array<() => void> = [];
|
|
|
|
const allItems = computed((): QuickOpenItem[] => {
|
|
const items: QuickOpenItem[] = [];
|
|
const connections = connectionStore.connections;
|
|
const treeNodes = connectionStore.treeNodes;
|
|
|
|
// Add connections
|
|
for (const conn of connections) {
|
|
items.push({
|
|
id: `conn-${conn.id}`,
|
|
type: "connection",
|
|
label: conn.name,
|
|
connectionId: conn.id,
|
|
connectionName: conn.name,
|
|
searchText: `${conn.name}`,
|
|
});
|
|
}
|
|
|
|
// Add databases and tables from tree nodes
|
|
// Filter tree nodes by connection
|
|
for (const conn of connections) {
|
|
// Connections may live under sidebar groups, so locate their tree recursively.
|
|
const connectionTreeNode = findConnectionTreeNode(treeNodes, conn.id);
|
|
const connectionTreeNodes = connectionTreeNode?.children || treeNodes.filter((node) => node.connectionId === conn.id);
|
|
if (connectionTreeNodes.length === 0) continue;
|
|
|
|
// Process tree nodes to extract databases and tables
|
|
processDatabaseTreeNodes(connectionTreeNodes, conn, items);
|
|
}
|
|
|
|
return items;
|
|
});
|
|
|
|
function processDatabaseTreeNodes(nodes: any[], conn: ConnectionConfig, items: QuickOpenItem[]): void {
|
|
for (const node of nodes) {
|
|
// Skip certain node types
|
|
if (node.type === "group" || node.type === "linked-server-root") {
|
|
if (node.children) {
|
|
processDatabaseTreeNodes(node.children, conn, items);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Database nodes
|
|
if (node.type === "database" && node.database) {
|
|
items.push({
|
|
id: `db-${conn.id}-${node.database}`,
|
|
type: "database",
|
|
label: node.label || node.database,
|
|
description: conn.name,
|
|
connectionId: conn.id,
|
|
database: node.database,
|
|
connectionName: conn.name,
|
|
searchText: `${conn.name} ${node.database}`,
|
|
});
|
|
}
|
|
|
|
// Schema nodes are navigable results and also contain database objects.
|
|
if (node.type === "schema" && node.database && node.schema) {
|
|
items.push({
|
|
id: `schema-${conn.id}-${node.database}-${node.schema}`,
|
|
type: "schema",
|
|
label: node.label || node.schema,
|
|
description: `${conn.name} / ${node.database}`,
|
|
connectionId: conn.id,
|
|
database: node.database,
|
|
schema: node.schema,
|
|
connectionName: conn.name,
|
|
searchText: `${conn.name} ${node.database} ${node.schema}`,
|
|
});
|
|
if (node.children) processDatabaseTreeNodes(node.children, conn, items);
|
|
continue;
|
|
}
|
|
|
|
// Table nodes
|
|
if (node.type === "table" && node.database && node.label) {
|
|
items.push({
|
|
id: `table-${conn.id}-${node.database}-${node.schema || ""}-${node.label}`,
|
|
type: "table",
|
|
label: node.label,
|
|
description: `${conn.name} / ${node.database}${node.schema ? " / " + node.schema : ""}`,
|
|
connectionId: conn.id,
|
|
database: node.database,
|
|
schema: node.schema,
|
|
tableName: node.label,
|
|
connectionName: conn.name,
|
|
searchText: `${conn.name} ${node.database} ${node.schema || ""} ${node.label}`,
|
|
});
|
|
}
|
|
|
|
// View nodes
|
|
if (node.type === "view" && node.database && node.label) {
|
|
items.push({
|
|
id: `view-${conn.id}-${node.database}-${node.schema || ""}-${node.label}`,
|
|
type: "view",
|
|
label: node.label,
|
|
description: `${conn.name} / ${node.database}${node.schema ? " / " + node.schema : ""}`,
|
|
connectionId: conn.id,
|
|
database: node.database,
|
|
schema: node.schema,
|
|
objectName: node.label,
|
|
connectionName: conn.name,
|
|
searchText: `${conn.name} ${node.database} ${node.schema || ""} ${node.label}`,
|
|
});
|
|
}
|
|
|
|
// Materialized view nodes
|
|
if (node.type === "materialized_view" && node.database && node.label) {
|
|
items.push({
|
|
id: `mview-${conn.id}-${node.database}-${node.schema || ""}-${node.label}`,
|
|
type: "materialized_view",
|
|
label: node.label,
|
|
description: `${conn.name} / ${node.database}${node.schema ? " / " + node.schema : ""}`,
|
|
connectionId: conn.id,
|
|
database: node.database,
|
|
schema: node.schema,
|
|
objectName: node.label,
|
|
connectionName: conn.name,
|
|
searchText: `${conn.name} ${node.database} ${node.schema || ""} ${node.label}`,
|
|
});
|
|
}
|
|
|
|
// Procedure nodes
|
|
if (node.type === "procedure" && node.database && node.label) {
|
|
items.push({
|
|
id: `proc-${conn.id}-${node.database}-${node.schema || ""}-${node.label}`,
|
|
type: "procedure",
|
|
label: node.label,
|
|
description: `${conn.name} / ${node.database}${node.schema ? " / " + node.schema : ""}`,
|
|
connectionId: conn.id,
|
|
database: node.database,
|
|
schema: node.schema,
|
|
objectName: node.label,
|
|
connectionName: conn.name,
|
|
searchText: `${conn.name} ${node.database} ${node.schema || ""} ${node.label}`,
|
|
});
|
|
}
|
|
|
|
// Function nodes
|
|
if (node.type === "function" && node.database && node.label) {
|
|
items.push({
|
|
id: `func-${conn.id}-${node.database}-${node.schema || ""}-${node.label}`,
|
|
type: "function",
|
|
label: node.label,
|
|
description: `${conn.name} / ${node.database}${node.schema ? " / " + node.schema : ""}`,
|
|
connectionId: conn.id,
|
|
database: node.database,
|
|
schema: node.schema,
|
|
objectName: node.label,
|
|
connectionName: conn.name,
|
|
searchText: `${conn.name} ${node.database} ${node.schema || ""} ${node.label}`,
|
|
});
|
|
}
|
|
|
|
// Sequence nodes
|
|
if (node.type === "sequence" && node.database && node.label) {
|
|
items.push({
|
|
id: `seq-${conn.id}-${node.database}-${node.schema || ""}-${node.label}`,
|
|
type: "sequence",
|
|
label: node.label,
|
|
description: `${conn.name} / ${node.database}${node.schema ? " / " + node.schema : ""}`,
|
|
connectionId: conn.id,
|
|
database: node.database,
|
|
schema: node.schema,
|
|
objectName: node.label,
|
|
connectionName: conn.name,
|
|
searchText: `${conn.name} ${node.database} ${node.schema || ""} ${node.label}`,
|
|
});
|
|
}
|
|
|
|
// Package nodes
|
|
if (node.type === "package" && node.database && node.label) {
|
|
items.push({
|
|
id: `pkg-${conn.id}-${node.database}-${node.schema || ""}-${node.label}`,
|
|
type: "package",
|
|
label: node.label,
|
|
description: `${conn.name} / ${node.database}${node.schema ? " / " + node.schema : ""}`,
|
|
connectionId: conn.id,
|
|
database: node.database,
|
|
schema: node.schema,
|
|
objectName: node.label,
|
|
connectionName: conn.name,
|
|
searchText: `${conn.name} ${node.database} ${node.schema || ""} ${node.label}`,
|
|
});
|
|
}
|
|
|
|
// Package-body nodes
|
|
if (node.type === "package-body" && node.database && node.label) {
|
|
items.push({
|
|
id: `pkgbody-${conn.id}-${node.database}-${node.schema || ""}-${node.label}`,
|
|
type: "package-body",
|
|
label: node.label,
|
|
description: `${conn.name} / ${node.database}${node.schema ? " / " + node.schema : ""}`,
|
|
connectionId: conn.id,
|
|
database: node.database,
|
|
schema: node.schema,
|
|
objectName: node.label,
|
|
connectionName: conn.name,
|
|
searchText: `${conn.name} ${node.database} ${node.schema || ""} ${node.label}`,
|
|
});
|
|
}
|
|
|
|
// Process children recursively
|
|
if (node.children) {
|
|
processDatabaseTreeNodes(node.children, conn, items);
|
|
}
|
|
}
|
|
}
|
|
|
|
function findConnectionTreeNode(nodes: any[], connectionId: string): any | undefined {
|
|
for (const node of nodes) {
|
|
if (node.type === "connection" && node.connectionId === connectionId) return node;
|
|
if (node.children) {
|
|
const match = findConnectionTreeNode(node.children, connectionId);
|
|
if (match) return match;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function quickOpenItemKey(item: QuickOpenItem): string {
|
|
if (item.type === "table" || item.type === "view" || item.type === "materialized_view") {
|
|
return `${item.connectionId}:${item.database ?? ""}:${item.schema ?? ""}:${item.tableName ?? item.objectName ?? item.label}`.toLowerCase();
|
|
}
|
|
return item.id.toLowerCase();
|
|
}
|
|
|
|
function remoteTableItem(table: SqlCompletionTable, conn: ConnectionConfig, database: string): QuickOpenItem {
|
|
const type = table.type ?? "table";
|
|
const prefix = type === "materialized_view" ? "mview" : type;
|
|
return {
|
|
id: `${prefix}-${conn.id}-${database}-${table.schema || ""}-${table.name}`,
|
|
type,
|
|
label: table.name,
|
|
description: `${conn.name} / ${database}${table.schema ? " / " + table.schema : ""}`,
|
|
connectionId: conn.id,
|
|
database,
|
|
schema: table.schema,
|
|
...(type === "table" ? { tableName: table.name } : { objectName: table.name }),
|
|
connectionName: conn.name,
|
|
searchText: `${conn.name} ${database} ${table.schema || ""} ${table.name}`,
|
|
};
|
|
}
|
|
|
|
function collectConnectionDatabases(nodes: any[], connectionId: string, databases: Set<string>): void {
|
|
for (const node of nodes) {
|
|
if (node.connectionId === connectionId && node.type === "database" && node.database) {
|
|
databases.add(node.database);
|
|
}
|
|
if (node.children) collectConnectionDatabases(node.children, connectionId, databases);
|
|
}
|
|
}
|
|
|
|
function remoteSearchContexts(): Array<{ conn: ConnectionConfig; database: string }> {
|
|
if (typeof connectionStore.listCompletionTables !== "function") return [];
|
|
const connectedIds = connectionStore.connectedIds;
|
|
if (!(connectedIds instanceof Set)) return [];
|
|
|
|
const databasesByConnection: Array<{ conn: ConnectionConfig; databases: string[] }> = [];
|
|
for (const conn of connectionStore.connections) {
|
|
if (!connectedIds.has(conn.id) || REMOTE_SEARCH_UNSUPPORTED_TYPES.has(conn.db_type)) continue;
|
|
const databases = new Set<string>();
|
|
collectConnectionDatabases(connectionStore.treeNodes, conn.id, databases);
|
|
if (conn.database?.trim()) databases.add(conn.database.trim());
|
|
for (const database of conn.visible_databases ?? []) {
|
|
if (database.trim()) databases.add(database.trim());
|
|
}
|
|
for (const database of conn.attached_databases ?? []) {
|
|
if (database.name.trim()) databases.add(database.name.trim());
|
|
}
|
|
if (databases.size > 0) databasesByConnection.push({ conn, databases: [...databases] });
|
|
}
|
|
|
|
const contexts: Array<{ conn: ConnectionConfig; database: string }> = [];
|
|
for (let databaseIndex = 0; contexts.length < REMOTE_SEARCH_MAX_REQUESTS; databaseIndex++) {
|
|
let added = false;
|
|
for (const { conn, databases } of databasesByConnection) {
|
|
const database = databases[databaseIndex];
|
|
if (!database) continue;
|
|
contexts.push({ conn, database });
|
|
added = true;
|
|
if (contexts.length >= REMOTE_SEARCH_MAX_REQUESTS) break;
|
|
}
|
|
if (!added) break;
|
|
}
|
|
return contexts;
|
|
}
|
|
|
|
async function acquireRemoteRequestSlot(): Promise<void> {
|
|
if (activeRemoteRequests < REMOTE_SEARCH_CONCURRENCY) {
|
|
activeRemoteRequests++;
|
|
return;
|
|
}
|
|
await new Promise<void>((resolve) => remoteRequestWaiters.push(resolve));
|
|
}
|
|
|
|
function releaseRemoteRequestSlot(): void {
|
|
const next = remoteRequestWaiters.shift();
|
|
if (next) next();
|
|
else activeRemoteRequests--;
|
|
}
|
|
|
|
async function runRemoteSearch(query: string, generation: number, contexts: Array<{ conn: ConnectionConfig; database: string }>): Promise<void> {
|
|
const groups = await Promise.all(
|
|
contexts.map(async ({ conn, database }) => {
|
|
await acquireRemoteRequestSlot();
|
|
try {
|
|
// A newer query may supersede queued work before it reaches the metadata API.
|
|
if (generation !== remoteSearchGeneration) return [];
|
|
const tables = await connectionStore.listCompletionTables(conn.id, database, query, REMOTE_SEARCH_RESULTS_PER_REQUEST, undefined, true);
|
|
return tables.slice(0, REMOTE_SEARCH_RESULTS_PER_REQUEST).map((table) => remoteTableItem(table, conn, database));
|
|
} catch {
|
|
return [];
|
|
} finally {
|
|
releaseRemoteRequestSlot();
|
|
}
|
|
}),
|
|
);
|
|
|
|
if (generation !== remoteSearchGeneration) return;
|
|
remoteItems.value = groups.flat().slice(0, REMOTE_SEARCH_MAX_RESULTS);
|
|
}
|
|
|
|
watch(
|
|
searchQuery,
|
|
(query) => {
|
|
const generation = ++remoteSearchGeneration;
|
|
if (remoteSearchTimer) clearTimeout(remoteSearchTimer);
|
|
remoteItems.value = [];
|
|
|
|
const normalizedQuery = query.trim();
|
|
if (normalizedQuery.length < REMOTE_SEARCH_MIN_QUERY_LENGTH) return;
|
|
const contexts = remoteSearchContexts();
|
|
if (contexts.length === 0) return;
|
|
|
|
remoteSearchTimer = setTimeout(() => {
|
|
remoteSearchTimer = undefined;
|
|
void runRemoteSearch(normalizedQuery, generation, contexts);
|
|
}, REMOTE_SEARCH_DEBOUNCE_MS);
|
|
},
|
|
{ flush: "sync" },
|
|
);
|
|
|
|
const filteredItems = computed((): MatchedItem[] => {
|
|
if (!searchQuery.value.trim()) {
|
|
return allItems.value.map((item) => ({
|
|
...item,
|
|
matchScore: Infinity,
|
|
matchIndices: [],
|
|
}));
|
|
}
|
|
|
|
const matched: MatchedItem[] = [];
|
|
|
|
const seen = new Set<string>();
|
|
for (const item of [...allItems.value, ...remoteItems.value]) {
|
|
const key = quickOpenItemKey(item);
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
const result = fuzzyMatch(searchQuery.value, item.searchText);
|
|
if (result) {
|
|
matched.push({
|
|
...item,
|
|
matchScore: result.score,
|
|
matchIndices: result.indices,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Sort by score and type (connections > databases > tables > other objects for equal scores)
|
|
matched.sort((a, b) => {
|
|
if (a.matchScore !== b.matchScore) {
|
|
return a.matchScore - b.matchScore; // Lower scores (better matches) come first
|
|
}
|
|
|
|
const typeOrder = {
|
|
connection: 0,
|
|
database: 1,
|
|
schema: 2,
|
|
table: 3,
|
|
view: 4,
|
|
materialized_view: 5,
|
|
procedure: 6,
|
|
function: 7,
|
|
sequence: 8,
|
|
package: 9,
|
|
"package-body": 10,
|
|
};
|
|
return typeOrder[a.type] - typeOrder[b.type];
|
|
});
|
|
|
|
return matched.slice(0, QUICK_OPEN_MAX_RESULTS);
|
|
});
|
|
|
|
const selectedItem = computed((): MatchedItem | null => {
|
|
if (selectedIndex.value < 0 || selectedIndex.value >= filteredItems.value.length) {
|
|
return null;
|
|
}
|
|
return filteredItems.value[selectedIndex.value];
|
|
});
|
|
|
|
function selectNext(): void {
|
|
if (selectedIndex.value < filteredItems.value.length - 1) {
|
|
selectedIndex.value++;
|
|
}
|
|
}
|
|
|
|
function selectPrevious(): void {
|
|
if (selectedIndex.value > 0) {
|
|
selectedIndex.value--;
|
|
}
|
|
}
|
|
|
|
function resetSelection(): void {
|
|
selectedIndex.value = 0;
|
|
}
|
|
|
|
function setQuery(query: string): void {
|
|
searchQuery.value = query;
|
|
resetSelection();
|
|
}
|
|
|
|
return {
|
|
searchQuery,
|
|
filteredItems,
|
|
selectedIndex,
|
|
selectedItem,
|
|
selectNext,
|
|
selectPrevious,
|
|
resetSelection,
|
|
setQuery,
|
|
};
|
|
}
|