1077 lines
41 KiB
TypeScript
1077 lines
41 KiB
TypeScript
import { computed, ref, watch } from "vue";
|
|
import type { ConnectionConfig } from "@/types/database";
|
|
import type { SqlCompletionTable } from "@/lib/sql/sqlCompletion";
|
|
import { resolveDefaultDatabase } from "@/lib/database/defaultDatabase";
|
|
import { useConnectionStore } from "@/stores/connectionStore";
|
|
import { useSavedSqlStore } from "@/stores/savedSqlStore";
|
|
import * as api from "@/lib/backend/api";
|
|
import type { SqlFileEntry } from "@/lib/backend/api";
|
|
import { getSqlFileFilter, getSqlFileFolderPaths, sqlFileFoldersVersion } from "@/lib/sqlFile/sqlFileFolders";
|
|
import { composeGlobalSearchRoots, getGlobalSearchExtensions, globalSearchSettingsVersion } from "@/lib/globalSearch/globalSearchSettings";
|
|
import { containsHan, pinyinFirstLetters } from "@/lib/common/pinyin";
|
|
import { createFrontendPluginRegistry } from "@/lib/plugins/frontendPlugin";
|
|
import i18n, { currentLocale } from "@/i18n";
|
|
|
|
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 INITIAL_SQL_LIBRARY_LIMIT = 20;
|
|
const INITIAL_SQL_FILE_LIMIT = 20;
|
|
|
|
const CONTENT_SEARCH_DEBOUNCE_MS = 200;
|
|
const CONTENT_SEARCH_MAX_RESULTS = 500;
|
|
|
|
const REMOTE_SEARCH_UNSUPPORTED_TYPES = new Set<ConnectionConfig["db_type"]>(["redis", "mongodb", "elasticsearch", "easysearch", "meilisearch", "qdrant", "milvus", "weaviate", "chromadb", "neo4j", "influxdb", "victoriametrics", "etcd", "zookeeper", "mq", "nacos", "consul"]);
|
|
|
|
export interface QuickOpenItem {
|
|
id: string;
|
|
type: "connection" | "plugin_workbench" | "database" | "schema" | "table" | "view" | "materialized_view" | "procedure" | "function" | "sequence" | "package" | "package-body" | "sql_file" | "sql_library_file" | "content_match";
|
|
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
|
|
filePath?: string; // For external SQL files
|
|
sqlFileId?: string; // For saved SQL library files
|
|
fileName?: string; // For content matches: file name shown in the group header
|
|
line?: number; // For content matches: 1-based line
|
|
column?: number; // For content matches: 1-based char column
|
|
matchText?: string; // For content matches: matched slice
|
|
lineText?: string; // For content matches: full matching line
|
|
highlightIndices?: [number, number]; // For content matches: [start, end) chars into lineText to highlight
|
|
pluginId?: string; // For plugin workbenches: plugin manifest id
|
|
contributionId?: string; // For plugin workbenches: workbench contribution id
|
|
pluginIcon?: string; // For plugin workbenches: contribution icon path, falling back to the manifest icon
|
|
}
|
|
|
|
export type QuickOpenMatchKind = "exact" | "initials" | "prefix" | "word-prefix" | "substring" | "fuzzy";
|
|
|
|
export interface QuickOpenMatch {
|
|
kind: QuickOpenMatchKind;
|
|
score: number;
|
|
indices: number[];
|
|
}
|
|
|
|
interface IdentifierWord {
|
|
text: string;
|
|
start: number;
|
|
}
|
|
|
|
const IDENTIFIER_SEPARATOR_RE = /[_\-. /\\]/;
|
|
|
|
function identifierWords(text: string): IdentifierWord[] {
|
|
const words: IdentifierWord[] = [];
|
|
let start = -1;
|
|
|
|
function pushWord(end: number): void {
|
|
if (start < 0 || end <= start) return;
|
|
words.push({ text: text.slice(start, end), start });
|
|
}
|
|
|
|
for (let index = 0; index < text.length; index++) {
|
|
const char = text[index];
|
|
if (IDENTIFIER_SEPARATOR_RE.test(char)) {
|
|
pushWord(index);
|
|
start = -1;
|
|
continue;
|
|
}
|
|
if (start < 0) {
|
|
start = index;
|
|
continue;
|
|
}
|
|
const previous = text[index - 1];
|
|
if (previous >= "a" && previous <= "z" && char >= "A" && char <= "Z") {
|
|
pushWord(index);
|
|
start = index;
|
|
}
|
|
}
|
|
pushWord(text.length);
|
|
return words;
|
|
}
|
|
|
|
function rangeIndices(start: number, length: number): number[] {
|
|
return Array.from({ length }, (_, index) => start + index);
|
|
}
|
|
|
|
const PINYIN_QUERY_RE = /^[a-z0-9]+$/;
|
|
|
|
/**
|
|
* Original-text indices of the characters that feed `pinyinFirstLetters(text)`, in order.
|
|
* Iterates by Unicode code point (like `pinyinFirstLetters`), not UTF-16 code unit, so
|
|
* supplementary-plane Han characters (surrogate pairs) stay aligned with the letters they produce.
|
|
*/
|
|
function pinyinLetterPositions(text: string): number[] {
|
|
const positions: number[] = [];
|
|
let index = 0;
|
|
for (const char of text) {
|
|
if (/[\p{Script=Han}a-z0-9]/iu.test(char)) positions.push(index);
|
|
index += char.length;
|
|
}
|
|
return positions;
|
|
}
|
|
|
|
function matchWordPrefixes(words: IdentifierWord[], query: string): number[] | null {
|
|
interface PrefixState {
|
|
queryIndex: number;
|
|
firstWordIndex: number;
|
|
lastWordIndex: number;
|
|
usedWords: number;
|
|
indices: number[];
|
|
}
|
|
|
|
function stateScore(state: PrefixState): number {
|
|
if (state.usedWords === 0) return 0;
|
|
return (state.lastWordIndex - state.firstWordIndex - state.usedWords + 1) * 10 + state.usedWords;
|
|
}
|
|
|
|
function retainBest(states: Map<string, PrefixState>, candidate: PrefixState): void {
|
|
const key = `${candidate.queryIndex}:${candidate.usedWords}`;
|
|
const current = states.get(key);
|
|
if (!current || stateScore(candidate) < stateScore(current)) states.set(key, candidate);
|
|
}
|
|
|
|
let states = new Map<string, PrefixState>([["0:0", { queryIndex: 0, firstWordIndex: -1, lastWordIndex: -1, usedWords: 0, indices: [] }]]);
|
|
for (let wordIndex = 0; wordIndex < words.length; wordIndex++) {
|
|
const nextStates = new Map(states);
|
|
const word = words[wordIndex];
|
|
const lowerWord = word.text.toLowerCase();
|
|
for (const state of states.values()) {
|
|
const maxLength = Math.min(lowerWord.length, query.length - state.queryIndex);
|
|
for (let length = 1; length <= maxLength; length++) {
|
|
if (lowerWord.slice(0, length) !== query.slice(state.queryIndex, state.queryIndex + length)) break;
|
|
retainBest(nextStates, {
|
|
queryIndex: state.queryIndex + length,
|
|
firstWordIndex: state.usedWords === 0 ? wordIndex : state.firstWordIndex,
|
|
lastWordIndex: wordIndex,
|
|
usedWords: state.usedWords + 1,
|
|
indices: [...state.indices, ...rangeIndices(word.start, length)],
|
|
});
|
|
}
|
|
}
|
|
states = nextStates;
|
|
}
|
|
|
|
return [...states.values()].filter((state) => state.queryIndex === query.length && state.usedWords >= 2).sort((a, b) => stateScore(a) - stateScore(b))[0]?.indices ?? null;
|
|
}
|
|
|
|
/** Match one quick-open field and return label-relative highlight indices. */
|
|
export function matchQuickOpenText(query: string, text: string): QuickOpenMatch | null {
|
|
const lowerQuery = query.trim().toLowerCase();
|
|
const lowerText = text.toLowerCase();
|
|
if (!lowerQuery) return { kind: "exact", score: Infinity, indices: [] };
|
|
|
|
if (lowerText === lowerQuery) {
|
|
return { kind: "exact", score: 1, indices: rangeIndices(0, text.length) };
|
|
}
|
|
|
|
const words = identifierWords(text);
|
|
const initials = words.map((word) => word.text[0]?.toLowerCase() ?? "").join("");
|
|
if (words.length >= 2 && initials === lowerQuery) {
|
|
return { kind: "initials", score: 100 + Math.min(words.length, 99), indices: words.map((word) => word.start) };
|
|
}
|
|
|
|
if (lowerText.startsWith(lowerQuery)) {
|
|
return { kind: "prefix", score: 200 + Math.min(text.length - lowerQuery.length, 99), indices: rangeIndices(0, lowerQuery.length) };
|
|
}
|
|
|
|
// DataGrip-style pinyin-initials matching for Chinese identifiers, e.g. "总租金" via "zzj".
|
|
// Only tried after literal matches fail, so a mixed Han+Latin name that literally prefix-matches
|
|
// (e.g. "abc表" via "abc") keeps its better literal-prefix score instead of being intercepted here.
|
|
const isPinyinQuery = PINYIN_QUERY_RE.test(lowerQuery) && containsHan(text);
|
|
if (isPinyinQuery) {
|
|
const pinyinLetters = pinyinFirstLetters(text);
|
|
if (pinyinLetters === lowerQuery) {
|
|
return { kind: "initials", score: 150 + Math.min(text.length, 99), indices: pinyinLetterPositions(text) };
|
|
}
|
|
if (pinyinLetters.startsWith(lowerQuery)) {
|
|
return { kind: "prefix", score: 250 + Math.min(text.length - lowerQuery.length, 99), indices: pinyinLetterPositions(text).slice(0, lowerQuery.length) };
|
|
}
|
|
}
|
|
|
|
const wordPrefixIndices = matchWordPrefixes(words, lowerQuery);
|
|
if (wordPrefixIndices) {
|
|
return { kind: "word-prefix", score: 300 + Math.min(text.length - lowerQuery.length, 99), indices: wordPrefixIndices };
|
|
}
|
|
|
|
const substringIndex = lowerText.indexOf(lowerQuery);
|
|
if (substringIndex >= 0) {
|
|
return { kind: "substring", score: 400 + Math.min(substringIndex, 99), indices: rangeIndices(substringIndex, lowerQuery.length) };
|
|
}
|
|
|
|
if (lowerQuery.length < 2) return null;
|
|
const indices: number[] = [];
|
|
let queryIndex = 0;
|
|
for (let index = 0; index < lowerText.length && queryIndex < lowerQuery.length; index++) {
|
|
if (lowerText[index] !== lowerQuery[queryIndex]) continue;
|
|
indices.push(index);
|
|
queryIndex++;
|
|
}
|
|
if (queryIndex === lowerQuery.length) {
|
|
const span = indices[indices.length - 1] - indices[0] + 1;
|
|
return { kind: "fuzzy", score: 500 + Math.min(span - lowerQuery.length, 99), indices };
|
|
}
|
|
|
|
// Non-contiguous pinyin-initials fallback, e.g. "zj" matching "总租金" (pinyin initials "zzj"),
|
|
// matching the ordered-subsequence behavior every other pinyin call site in the app already has.
|
|
if (isPinyinQuery) {
|
|
const pinyinLetters = pinyinFirstLetters(text);
|
|
const positions = pinyinLetterPositions(text);
|
|
const letterIndices: number[] = [];
|
|
let letterQueryIndex = 0;
|
|
let letterCount = 0;
|
|
// Iterate the initials by code point, keeping letterCount aligned with
|
|
// positions: unmapped supplementary-plane Han characters occupy two code
|
|
// units in pinyinLetters but still correspond to exactly one position.
|
|
for (const letter of pinyinLetters) {
|
|
if (letterQueryIndex >= lowerQuery.length) break;
|
|
if (letter === lowerQuery[letterQueryIndex]) {
|
|
letterIndices.push(positions[letterCount] ?? letterCount);
|
|
letterQueryIndex += 1;
|
|
}
|
|
letterCount += 1;
|
|
}
|
|
if (letterQueryIndex === lowerQuery.length) {
|
|
const pinyinSpan = letterIndices[letterIndices.length - 1] - letterIndices[0] + 1;
|
|
return { kind: "fuzzy", score: 500 + Math.min(pinyinSpan - lowerQuery.length, 99), indices: letterIndices };
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
interface MatchedItem extends QuickOpenItem {
|
|
matchScore: number;
|
|
matchIndices: number[];
|
|
}
|
|
|
|
function loadSavedSqlFileFolderPaths(): string[] {
|
|
return getSqlFileFolderPaths();
|
|
}
|
|
|
|
function folderNameFromPath(path: string): string {
|
|
const normalized = path.replace(/\\/g, "/");
|
|
const parts = normalized.split("/").filter(Boolean);
|
|
return parts.pop() || path;
|
|
}
|
|
|
|
function collectSqlFileEntries(entries: SqlFileEntry[], results: SqlFileEntry[]): void {
|
|
for (const entry of entries) {
|
|
if (entry.is_dir) {
|
|
collectSqlFileEntries(entry.children, results);
|
|
} else {
|
|
results.push(entry);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function useQuickOpen() {
|
|
const connectionStore = useConnectionStore();
|
|
const savedSqlStore = useSavedSqlStore();
|
|
const searchQuery = ref("");
|
|
const selectedIndex = ref(0);
|
|
const remoteItems = ref<QuickOpenItem[]>([]);
|
|
const sqlFileItems = ref<QuickOpenItem[]>([]);
|
|
let remoteSearchGeneration = 0;
|
|
let remoteSearchTimer: ReturnType<typeof setTimeout> | undefined;
|
|
let activeRemoteRequests = 0;
|
|
const remoteRequestWaiters: Array<{ generation: number; resolve: (acquired: boolean) => void }> = [];
|
|
let sqlFilesLoaded = false;
|
|
let sqlFilesLoadingPromise: Promise<void> | null = null;
|
|
let sqlFilesLoadGeneration = 0;
|
|
const pluginWorkbenchItems = ref<QuickOpenItem[]>([]);
|
|
let pluginWorkbenchesLoading = false;
|
|
|
|
// --- Content search mode (global search of local SQL/text file contents) ---
|
|
const contentMode = ref(false);
|
|
const contentSearching = ref(false);
|
|
const contentItems = ref<MatchedItem[]>([]);
|
|
let contentSearchGeneration = 0;
|
|
let contentSearchTimer: ReturnType<typeof setTimeout> | undefined;
|
|
|
|
interface ContentGroup {
|
|
header: string;
|
|
filePath: string;
|
|
matches: MatchedItem[];
|
|
}
|
|
|
|
const contentGroups = computed<ContentGroup[]>(() => {
|
|
const groups: ContentGroup[] = [];
|
|
const indexByPath = new Map<string, ContentGroup>();
|
|
for (const item of contentItems.value) {
|
|
const path = item.filePath;
|
|
if (!path) continue;
|
|
let group = indexByPath.get(path);
|
|
if (!group) {
|
|
group = { header: item.fileName || item.label, filePath: path, matches: [] };
|
|
indexByPath.set(path, group);
|
|
groups.push(group);
|
|
}
|
|
group.matches.push(item);
|
|
}
|
|
for (const group of groups) {
|
|
group.matches.sort((a, b) => (a.line ?? 0) - (b.line ?? 0));
|
|
}
|
|
return groups;
|
|
});
|
|
|
|
const selectableContentItems = computed<MatchedItem[]>(() => contentGroups.value.flatMap((group) => group.matches));
|
|
|
|
function clearContentResults(): void {
|
|
contentItems.value = [];
|
|
contentSearching.value = false;
|
|
}
|
|
|
|
async function runContentSearch(query: string, generation: number): Promise<void> {
|
|
const roots = composeGlobalSearchRoots();
|
|
if (roots.length === 0) {
|
|
if (generation === contentSearchGeneration) clearContentResults();
|
|
return;
|
|
}
|
|
contentSearching.value = true;
|
|
try {
|
|
const matches = await api.globalSearch({
|
|
roots,
|
|
query,
|
|
extensions: getGlobalSearchExtensions(),
|
|
limit: CONTENT_SEARCH_MAX_RESULTS,
|
|
});
|
|
if (generation !== contentSearchGeneration) return;
|
|
contentItems.value = matches.map((match) => {
|
|
const isFileName = match.line === 0;
|
|
const highlightIndices: [number, number] = [Math.max(0, match.column - 1), Math.max(match.column - 1, match.column - 1 + match.matchText.length)];
|
|
return {
|
|
id: `content-${match.path}-${match.line}-${match.column}`,
|
|
type: "content_match" as const,
|
|
label: match.fileName,
|
|
description: match.fileName,
|
|
connectionId: "",
|
|
filePath: match.path,
|
|
fileName: match.fileName,
|
|
line: match.line,
|
|
column: match.column,
|
|
matchText: match.matchText,
|
|
lineText: isFileName ? match.fileName : match.lineText,
|
|
highlightIndices,
|
|
searchText: match.lineText,
|
|
matchScore: 0,
|
|
matchIndices: [],
|
|
};
|
|
});
|
|
} catch {
|
|
if (generation === contentSearchGeneration) contentItems.value = [];
|
|
} finally {
|
|
if (generation === contentSearchGeneration) contentSearching.value = false;
|
|
}
|
|
}
|
|
|
|
function setContentMode(enabled: boolean): void {
|
|
if (contentMode.value === enabled) return;
|
|
contentMode.value = enabled;
|
|
selectedIndex.value = 0;
|
|
if (enabled) {
|
|
const query = searchQuery.value.trim();
|
|
if (query) {
|
|
const generation = ++contentSearchGeneration;
|
|
void runContentSearch(query, generation);
|
|
} else {
|
|
clearContentResults();
|
|
}
|
|
} else {
|
|
clearContentResults();
|
|
}
|
|
}
|
|
|
|
function getConnectionLabel(connectionId: string): string {
|
|
if (!connectionId) return i18n.global.t("sqlLibrary.unassociated");
|
|
const conn = connectionStore.connections.find((c) => c.id === connectionId);
|
|
return conn?.name || i18n.global.t("sqlLibrary.deletedConnection");
|
|
}
|
|
|
|
const sqlLibraryAllItems = computed<QuickOpenItem[]>(() => {
|
|
return savedSqlStore.allFiles.map((file) => ({
|
|
id: `sqllib-${file.id}`,
|
|
type: "sql_library_file" as const,
|
|
label: file.name,
|
|
description: getConnectionLabel(file.connectionId),
|
|
connectionId: file.connectionId,
|
|
connectionName: getConnectionLabel(file.connectionId),
|
|
sqlFileId: file.id,
|
|
searchText: `${file.name} ${getConnectionLabel(file.connectionId)}`,
|
|
}));
|
|
});
|
|
|
|
const sqlLibraryRecentItems = computed<QuickOpenItem[]>(() => {
|
|
return [...sqlLibraryAllItems.value]
|
|
.sort((a, b) => {
|
|
const fileA = savedSqlStore.getFile(a.sqlFileId!);
|
|
const fileB = savedSqlStore.getFile(b.sqlFileId!);
|
|
const timeA = fileA?.openedAt || fileA?.updatedAt || "";
|
|
const timeB = fileB?.openedAt || fileB?.updatedAt || "";
|
|
return timeB.localeCompare(timeA);
|
|
})
|
|
.slice(0, INITIAL_SQL_LIBRARY_LIMIT);
|
|
});
|
|
|
|
async function loadExternalSqlFiles(): Promise<void> {
|
|
if (sqlFilesLoaded || sqlFilesLoadingPromise) return sqlFilesLoadingPromise ?? undefined;
|
|
const generation = sqlFilesLoadGeneration;
|
|
const loadingPromise = (async () => {
|
|
try {
|
|
const folderPaths = loadSavedSqlFileFolderPaths();
|
|
if (folderPaths.length === 0) {
|
|
if (generation === sqlFilesLoadGeneration) sqlFilesLoaded = true;
|
|
return;
|
|
}
|
|
const allEntries: Array<{ entry: SqlFileEntry; rootFolder: string }> = [];
|
|
for (const folderPath of folderPaths) {
|
|
try {
|
|
const entries = await api.listSqlFilesInFolder(folderPath, getSqlFileFilter());
|
|
const collected: SqlFileEntry[] = [];
|
|
collectSqlFileEntries(entries, collected);
|
|
const rootName = folderNameFromPath(folderPath);
|
|
for (const entry of collected) {
|
|
allEntries.push({ entry, rootFolder: rootName });
|
|
}
|
|
} catch {
|
|
// Skip folders that fail to load
|
|
}
|
|
}
|
|
// Folder changes invalidate this snapshot; an updated scan runs after cleanup below.
|
|
if (generation !== sqlFilesLoadGeneration) return;
|
|
sqlFileItems.value = allEntries.map(({ entry, rootFolder }) => {
|
|
const parentDir = entry.path.replace(/\\/g, "/").split("/").slice(0, -1).join("/");
|
|
const parentName = folderNameFromPath(parentDir || entry.path);
|
|
// Show the top-level folder name so users can distinguish files from different directories
|
|
const description = parentName === rootFolder ? rootFolder : `${rootFolder} / ${parentName}`;
|
|
return {
|
|
id: `sqlfile-${entry.path}`,
|
|
type: "sql_file" as const,
|
|
label: entry.name,
|
|
description,
|
|
connectionId: "",
|
|
filePath: entry.path,
|
|
searchText: `${entry.name} ${rootFolder} ${parentName}`,
|
|
};
|
|
});
|
|
sqlFilesLoaded = true;
|
|
} catch {
|
|
// ignore errors
|
|
}
|
|
})();
|
|
sqlFilesLoadingPromise = loadingPromise;
|
|
try {
|
|
await loadingPromise;
|
|
} finally {
|
|
if (sqlFilesLoadingPromise === loadingPromise) sqlFilesLoadingPromise = null;
|
|
}
|
|
if (generation !== sqlFilesLoadGeneration) await loadExternalSqlFiles();
|
|
}
|
|
|
|
/**
|
|
* Limited set of external SQL files for the initial (no-query) view.
|
|
* Shows files from all configured folders, capped to INITIAL_SQL_FILE_LIMIT.
|
|
*/
|
|
const sqlFileRecentItems = computed<QuickOpenItem[]>(() => {
|
|
return sqlFileItems.value.slice(0, INITIAL_SQL_FILE_LIMIT);
|
|
});
|
|
|
|
/**
|
|
* Refresh quick-open entries for plugin workbenches that no connection
|
|
* provider claims. A workbench bound via a provider's `workbench` pointer is
|
|
* opened through its connection (which quick open already lists as a
|
|
* connection item with full context), so listing it here would only offer a
|
|
* contextless dead end. Unclaimed workbenches otherwise have no entry point
|
|
* outside the Plugin Center's Installed tab.
|
|
* Re-runs on every dialog open so installs/uninstalls show up without a restart.
|
|
*/
|
|
async function loadPluginWorkbenches(): Promise<void> {
|
|
if (pluginWorkbenchesLoading) return;
|
|
pluginWorkbenchesLoading = true;
|
|
try {
|
|
const registry = createFrontendPluginRegistry(await api.listPlugins(), currentLocale());
|
|
const connectionBound = new Set(
|
|
registry
|
|
.listConnectionProviders()
|
|
.filter((entry) => entry.contribution.workbench)
|
|
.map((entry) => `${entry.plugin.manifest.id}/${entry.contribution.workbench}`),
|
|
);
|
|
pluginWorkbenchItems.value = registry
|
|
.listWorkbenches()
|
|
.filter((entry) => !connectionBound.has(`${entry.plugin.manifest.id}/${entry.contribution.id}`))
|
|
.map((entry) => ({
|
|
id: `pluginwb-${entry.plugin.manifest.id}-${entry.contribution.id}`,
|
|
type: "plugin_workbench" as const,
|
|
label: entry.contribution.label,
|
|
description: entry.plugin.manifest.name,
|
|
connectionId: "",
|
|
pluginId: entry.plugin.manifest.id,
|
|
contributionId: entry.contribution.id,
|
|
pluginIcon: entry.contribution.icon || entry.plugin.manifest.icon,
|
|
searchText: `${entry.plugin.manifest.name} ${entry.contribution.label} ${entry.contribution.id}`,
|
|
}));
|
|
} catch {
|
|
// Plugin listings are best-effort; quick open keeps working without them.
|
|
} finally {
|
|
pluginWorkbenchesLoading = false;
|
|
}
|
|
}
|
|
|
|
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}`,
|
|
});
|
|
}
|
|
|
|
// Standalone plugin workbenches sit right after connections so they stay
|
|
// reachable in the no-query list before the (much longer) tree items.
|
|
items.push(...pluginWorkbenchItems.value);
|
|
|
|
// 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 {
|
|
// Completion "tables" may carry routine navigation types; quick-open relation entries only accept relation kinds.
|
|
const type = table.type === "view" || table.type === "materialized_view" ? 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 databasesByConnection: Array<{ conn: ConnectionConfig; databases: string[] }> = [];
|
|
const orderedConnections = [...connectionStore.connections].sort((left, right) => {
|
|
const priority = (conn: ConnectionConfig) => {
|
|
if (conn.id === connectionStore.activeConnectionId) return 0;
|
|
if (connectionStore.connectedIds.has(conn.id)) return 1;
|
|
return 2;
|
|
};
|
|
return priority(left) - priority(right);
|
|
});
|
|
for (const conn of orderedConnections) {
|
|
// listCompletionTables connects on demand. Keeping disconnected connections
|
|
// out here makes quick-open blind to unloaded tables after a cold start.
|
|
if (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());
|
|
}
|
|
const defaultDatabase = resolveDefaultDatabase(conn, [...databases]);
|
|
if (defaultDatabase) databases.add(defaultDatabase);
|
|
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(generation: number): Promise<boolean> {
|
|
if (generation !== remoteSearchGeneration) return false;
|
|
if (activeRemoteRequests < REMOTE_SEARCH_CONCURRENCY) {
|
|
activeRemoteRequests++;
|
|
return true;
|
|
}
|
|
return new Promise<boolean>((resolve) => remoteRequestWaiters.push({ generation, resolve }));
|
|
}
|
|
|
|
function cancelStaleRemoteRequestWaiters(generation: number): void {
|
|
for (let index = remoteRequestWaiters.length - 1; index >= 0; index -= 1) {
|
|
const waiter = remoteRequestWaiters[index]!;
|
|
if (waiter.generation === generation) continue;
|
|
remoteRequestWaiters.splice(index, 1);
|
|
waiter.resolve(false);
|
|
}
|
|
}
|
|
|
|
function releaseRemoteRequestSlot(): void {
|
|
let next = remoteRequestWaiters.shift();
|
|
while (next && next.generation !== remoteSearchGeneration) {
|
|
next.resolve(false);
|
|
next = remoteRequestWaiters.shift();
|
|
}
|
|
if (next) next.resolve(true);
|
|
else activeRemoteRequests--;
|
|
}
|
|
|
|
async function runRemoteSearch(query: string, generation: number, contexts: Array<{ conn: ConnectionConfig; database: string }>): Promise<void> {
|
|
const groups = contexts.map(() => [] as QuickOpenItem[]);
|
|
await Promise.all(
|
|
contexts.map(async ({ conn, database }, index) => {
|
|
const acquired = await acquireRemoteRequestSlot(generation);
|
|
if (!acquired) return;
|
|
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, undefined, undefined, { activateConnection: false });
|
|
if (generation !== remoteSearchGeneration) return;
|
|
groups[index] = tables.slice(0, REMOTE_SEARCH_RESULTS_PER_REQUEST).map((table) => remoteTableItem(table, conn, database));
|
|
remoteItems.value = groups.flat().slice(0, REMOTE_SEARCH_MAX_RESULTS);
|
|
} catch {
|
|
return;
|
|
} finally {
|
|
releaseRemoteRequestSlot();
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Reload external SQL files when folder paths or folder contents change.
|
|
* `sqlFileFoldersVersion` is bumped by SqlFilePanel on add/remove/refresh.
|
|
*/
|
|
watch(sqlFileFoldersVersion, () => {
|
|
sqlFilesLoadGeneration++;
|
|
sqlFilesLoaded = false;
|
|
sqlFileItems.value = [];
|
|
void loadExternalSqlFiles();
|
|
});
|
|
|
|
watch(
|
|
searchQuery,
|
|
(query) => {
|
|
selectedIndex.value = 0;
|
|
const generation = ++remoteSearchGeneration;
|
|
cancelStaleRemoteRequestWaiters(generation);
|
|
if (remoteSearchTimer) clearTimeout(remoteSearchTimer);
|
|
remoteItems.value = [];
|
|
|
|
const normalizedQuery = query.trim();
|
|
|
|
// Ensure external SQL files are loaded when the user starts searching
|
|
if (normalizedQuery.length > 0 && !sqlFilesLoaded && !sqlFilesLoadingPromise) {
|
|
void loadExternalSqlFiles();
|
|
}
|
|
if (normalizedQuery.length > 0) {
|
|
void loadPluginWorkbenches();
|
|
}
|
|
|
|
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()) {
|
|
// Show all tree items plus a limited set of recent SQL library files and external SQL files
|
|
return [...allItems.value, ...sqlLibraryRecentItems.value, ...sqlFileRecentItems.value].map((item) => ({
|
|
...item,
|
|
matchScore: Infinity,
|
|
matchIndices: [],
|
|
}));
|
|
}
|
|
|
|
const matched: MatchedItem[] = [];
|
|
|
|
const seen = new Set<string>();
|
|
// When searching, include ALL SQL library files and external SQL files
|
|
for (const item of [...allItems.value, ...sqlLibraryAllItems.value, ...sqlFileItems.value, ...remoteItems.value]) {
|
|
const key = quickOpenItemKey(item);
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
const labelMatch = matchQuickOpenText(searchQuery.value, item.label);
|
|
const metadataMatch = labelMatch ? null : matchQuickOpenText(searchQuery.value, item.searchText);
|
|
const result = labelMatch ?? metadataMatch;
|
|
if (result) {
|
|
matched.push({
|
|
...item,
|
|
matchScore: result.score + (labelMatch ? 0 : 1000),
|
|
matchIndices: labelMatch ? 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,
|
|
plugin_workbench: 1,
|
|
database: 2,
|
|
schema: 3,
|
|
table: 4,
|
|
view: 5,
|
|
materialized_view: 6,
|
|
procedure: 7,
|
|
function: 8,
|
|
sequence: 9,
|
|
package: 10,
|
|
"package-body": 11,
|
|
sql_library_file: 12,
|
|
sql_file: 13,
|
|
content_match: 14,
|
|
};
|
|
const typeDifference = typeOrder[a.type] - typeOrder[b.type];
|
|
if (typeDifference !== 0) return typeDifference;
|
|
const lengthDifference = a.label.length - b.label.length;
|
|
if (lengthDifference !== 0) return lengthDifference;
|
|
return a.label.localeCompare(b.label);
|
|
});
|
|
|
|
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];
|
|
});
|
|
|
|
const contentSelectedItem = computed((): MatchedItem | null => {
|
|
if (!contentMode.value) return null;
|
|
if (selectedIndex.value < 0 || selectedIndex.value >= selectableContentItems.value.length) return null;
|
|
return selectableContentItems.value[selectedIndex.value];
|
|
});
|
|
|
|
function selectNext(): void {
|
|
const length = contentMode.value ? selectableContentItems.value.length : filteredItems.value.length;
|
|
if (selectedIndex.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();
|
|
}
|
|
|
|
// Content search: debounced full-text search fired from the shared query.
|
|
watch(
|
|
searchQuery,
|
|
(query) => {
|
|
selectedIndex.value = 0;
|
|
const generation = ++contentSearchGeneration;
|
|
if (contentSearchTimer) clearTimeout(contentSearchTimer);
|
|
contentSearchTimer = undefined;
|
|
if (!contentMode.value) {
|
|
clearContentResults();
|
|
return;
|
|
}
|
|
const normalizedQuery = query.trim();
|
|
if (!normalizedQuery) {
|
|
clearContentResults();
|
|
return;
|
|
}
|
|
contentSearchTimer = setTimeout(() => {
|
|
contentSearchTimer = undefined;
|
|
void runContentSearch(normalizedQuery, generation);
|
|
}, CONTENT_SEARCH_DEBOUNCE_MS);
|
|
},
|
|
{ flush: "sync" },
|
|
);
|
|
|
|
// Re-run the current content search when search roots or extensions change.
|
|
watch(globalSearchSettingsVersion, () => {
|
|
if (contentMode.value && searchQuery.value.trim()) {
|
|
const generation = ++contentSearchGeneration;
|
|
void runContentSearch(searchQuery.value.trim(), generation);
|
|
}
|
|
});
|
|
|
|
watch(sqlFileFoldersVersion, () => {
|
|
if (contentMode.value && searchQuery.value.trim()) {
|
|
const generation = ++contentSearchGeneration;
|
|
void runContentSearch(searchQuery.value.trim(), generation);
|
|
}
|
|
});
|
|
|
|
return {
|
|
searchQuery,
|
|
filteredItems,
|
|
selectedIndex,
|
|
selectedItem,
|
|
contentMode,
|
|
contentSearching,
|
|
contentGroups,
|
|
contentSelectedItem,
|
|
setContentMode,
|
|
selectNext,
|
|
selectPrevious,
|
|
resetSelection,
|
|
setQuery,
|
|
loadExternalSqlFiles,
|
|
loadPluginWorkbenches,
|
|
};
|
|
}
|