feat(quick-open): add SQL Library and SQL Files
This commit is contained in:
1 parent
fa26a56ab0
commit
438164dd64
13 files changed
+409
-37
No files matched your search
@@ -1634,7 +1634,30 @@ async function handleQuickOpenSelect(item: any) {
|
||||
const connectionStore = useConnectionStore();
|
||||
const queryStore = useQueryStore();
|
||||
|
||||
// For all types, set the active connection
|
||||
// Handle SQL file types first — they don't require a database connection
|
||||
if (item.type === "sql_file" && item.filePath) {
|
||||
try {
|
||||
const content = await api.readExternalSqlFile(item.filePath);
|
||||
const connectionId = connectionStore.activeConnectionId || connectionStore.connections[0]?.id || "";
|
||||
const connection = connectionId ? connectionStore.getConfig(connectionId) : undefined;
|
||||
const database = connection ? resolveDefaultDatabase(connection, []) : "";
|
||||
queryStore.openExternalSqlFile(connectionId, database, item.filePath, content);
|
||||
} catch (e: any) {
|
||||
toast(e?.message || String(e), 5000);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.type === "sql_library_file" && item.sqlFileId) {
|
||||
const file = await savedSqlStore.ensureFileContent(item.sqlFileId);
|
||||
if (!file) return;
|
||||
queryStore.openSavedSql(file);
|
||||
connectionStore.activeConnectionId = file.connectionId;
|
||||
void savedSqlStore.recordFileUsage(file.id);
|
||||
return;
|
||||
}
|
||||
|
||||
// For all other types, set the active connection
|
||||
connectionStore.activeConnectionId = item.connectionId;
|
||||
|
||||
// Ensure connection is connected
|
||||
|
||||
@@ -9,13 +9,11 @@ import { useQueryStore } from "@/stores/queryStore";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
|
||||
import { resolveDefaultDatabase } from "@/lib/database/defaultDatabase";
|
||||
import { copyToClipboard } from "@/lib/common/clipboard";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import type { SqlFileEntry } from "@/lib/backend/api";
|
||||
|
||||
const STORAGE_KEY = "dbx-sql-file-folders";
|
||||
import { getSqlFileFolderPaths, saveSqlFileFolderPaths, notifySqlFileFoldersChanged } from "@/lib/sqlFile/sqlFileFolders";
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
@@ -52,19 +50,12 @@ function selectPath(path: string | null) {
|
||||
}
|
||||
|
||||
function loadSavedFolders(): string[] {
|
||||
try {
|
||||
const raw = safeLocalStorageGet(STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed.filter((p): p is string => typeof p === "string") : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return getSqlFileFolderPaths();
|
||||
}
|
||||
|
||||
function saveFolders() {
|
||||
const paths = folders.value.map((f) => f.path);
|
||||
safeLocalStorageSet(STORAGE_KEY, JSON.stringify(paths));
|
||||
saveSqlFileFolderPaths(paths);
|
||||
}
|
||||
|
||||
async function pickFolder() {
|
||||
@@ -141,11 +132,13 @@ function collectPaths(entries: SqlFileEntry[], into: Set<string>) {
|
||||
|
||||
async function refreshFolder(folderPath: string) {
|
||||
await loadFolderEntries(folderPath);
|
||||
notifySqlFileFoldersChanged();
|
||||
toast(t("sqlFileTree.refreshed"), 1500);
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
await Promise.all(folders.value.map((f) => loadFolderEntries(f.path)));
|
||||
notifySqlFileFoldersChanged();
|
||||
toast(t("sqlFileTree.refreshed"), 1500);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, nextTick } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Command } from "@lucide/vue";
|
||||
import { Command, FileCode, FileText } from "@lucide/vue";
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useQuickOpen, type QuickOpenItem } from "@/composables/useQuickOpen";
|
||||
@@ -16,7 +16,7 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const { searchQuery, filteredItems, selectedIndex, selectedItem, selectNext, selectPrevious, setQuery } = useQuickOpen();
|
||||
const { searchQuery, filteredItems, selectedIndex, selectedItem, selectNext, selectPrevious, setQuery, loadExternalSqlFiles } = useQuickOpen();
|
||||
const inputRef = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const dialogOpen = computed({
|
||||
@@ -107,16 +107,28 @@ function getTypeLabel(type: string): string {
|
||||
return t("common.package");
|
||||
case "package-body":
|
||||
return t("common.packageBody");
|
||||
case "sql_file":
|
||||
return t("quickOpen.sqlFile");
|
||||
case "sql_library_file":
|
||||
return t("quickOpen.sqlLibraryFile");
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
function getItemIcon(type: string) {
|
||||
if (type === "sql_file") return FileCode;
|
||||
if (type === "sql_library_file") return FileText;
|
||||
return null;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(newOpen) => {
|
||||
if (newOpen) {
|
||||
setQuery("");
|
||||
// Eagerly load external SQL files so they appear in the initial list
|
||||
void loadExternalSqlFiles();
|
||||
nextTick(() => {
|
||||
inputRef.value?.focus();
|
||||
});
|
||||
@@ -145,17 +157,20 @@ watch(
|
||||
<div v-else class="divide-y">
|
||||
<div v-for="(item, index) in filteredItems" :key="item.id" :class="['px-4 py-2 cursor-pointer', index === selectedIndex ? 'bg-accent' : 'hover:bg-muted']" @click="handleSelect(item)" @mouseenter="selectedIndex = index">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium truncate">
|
||||
<template v-for="(part, i) in getHighlightedLabel(item)" :key="i">
|
||||
<span v-if="typeof part === 'object'" :class="{ 'bg-yellow-200 dark:bg-yellow-800 font-semibold': part.highlight }">
|
||||
{{ part.text }}
|
||||
</span>
|
||||
<span v-else>{{ part }}</span>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="item.description" class="text-xs text-muted-foreground truncate">
|
||||
{{ item.description }}
|
||||
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||||
<component v-if="getItemIcon(item.type)" :is="getItemIcon(item.type)" class="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium truncate">
|
||||
<template v-for="(part, i) in getHighlightedLabel(item)" :key="i">
|
||||
<span v-if="typeof part === 'object'" :class="{ 'bg-yellow-200 dark:bg-yellow-800 font-semibold': part.highlight }">
|
||||
{{ part.text }}
|
||||
</span>
|
||||
<span v-else>{{ part }}</span>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="item.description" class="text-xs text-muted-foreground truncate">
|
||||
{{ item.description }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs px-2 py-1 rounded bg-muted text-muted-foreground whitespace-nowrap">
|
||||
|
||||
+142
-1
@@ -1,12 +1,40 @@
|
||||
import { nextTick } from "vue";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useQuickOpen } from "@/composables/useQuickOpen";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { getSqlFileFolderPaths, sqlFileFoldersVersion } from "@/lib/sqlFile/sqlFileFolders";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useSavedSqlStore } from "@/stores/savedSqlStore";
|
||||
|
||||
vi.mock("@/stores/connectionStore", () => ({
|
||||
useConnectionStore: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/savedSqlStore", () => ({
|
||||
useSavedSqlStore: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
listSqlFilesInFolder: vi.fn(),
|
||||
readExternalSqlFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/sqlFile/sqlFileFolders", async () => {
|
||||
const { ref } = await import("vue");
|
||||
return {
|
||||
getSqlFileFolderPaths: vi.fn(),
|
||||
sqlFileFoldersVersion: ref(0),
|
||||
};
|
||||
});
|
||||
|
||||
function emptySavedSqlStore() {
|
||||
return {
|
||||
allFiles: [] as any[],
|
||||
orphanedFileIds: vi.fn().mockReturnValue(new Set<string>()),
|
||||
getFile: vi.fn().mockReturnValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
@@ -24,6 +52,38 @@ async function flushAsyncWork(): Promise<void> {
|
||||
}
|
||||
|
||||
describe("useQuickOpen", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(useSavedSqlStore).mockReturnValue(emptySavedSqlStore() as any);
|
||||
vi.mocked(getSqlFileFolderPaths).mockReturnValue([]);
|
||||
});
|
||||
|
||||
describe("external SQL files", () => {
|
||||
it("reloads when folders change during an in-flight scan", async () => {
|
||||
vi.mocked(useConnectionStore).mockReturnValue({ connections: [], treeNodes: [] } as any);
|
||||
vi.mocked(getSqlFileFolderPaths).mockReturnValueOnce(["/old"]).mockReturnValue(["/new"]);
|
||||
const oldScan = deferred<Awaited<ReturnType<typeof api.listSqlFilesInFolder>>>();
|
||||
vi.mocked(api.listSqlFilesInFolder).mockImplementation((path) => {
|
||||
if (path === "/old") return oldScan.promise;
|
||||
return Promise.resolve([{ name: "new.sql", path: "/new/new.sql", is_dir: false, children: [] }]);
|
||||
});
|
||||
|
||||
const { filteredItems, loadExternalSqlFiles, setQuery } = useQuickOpen();
|
||||
const initialLoad = loadExternalSqlFiles();
|
||||
expect(api.listSqlFilesInFolder).toHaveBeenCalledWith("/old");
|
||||
|
||||
sqlFileFoldersVersion.value++;
|
||||
await nextTick();
|
||||
oldScan.resolve([{ name: "old.sql", path: "/old/old.sql", is_dir: false, children: [] }]);
|
||||
await initialLoad;
|
||||
|
||||
expect(api.listSqlFilesInFolder).toHaveBeenCalledTimes(2);
|
||||
expect(api.listSqlFilesInFolder).toHaveBeenLastCalledWith("/new");
|
||||
setQuery(".sql");
|
||||
expect(filteredItems.value.map((item) => item.label)).toContain("new.sql");
|
||||
expect(filteredItems.value.map((item) => item.label)).not.toContain("old.sql");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fuzzyMatch function", () => {
|
||||
it("should return exact substring match with score 1", () => {
|
||||
// Mock store with test data
|
||||
@@ -67,7 +127,7 @@ describe("useQuickOpen", () => {
|
||||
|
||||
setQuery("");
|
||||
|
||||
// Empty query should return all items
|
||||
// Empty query should return all items (2 connections + 0 SQL library files)
|
||||
expect(filteredItems.value.length).toBe(2);
|
||||
filteredItems.value.forEach((item) => {
|
||||
expect(item.matchScore).toBe(Infinity);
|
||||
@@ -596,6 +656,87 @@ describe("useQuickOpen", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("SQL library files", () => {
|
||||
function savedSqlStoreWithFiles(files: any[]) {
|
||||
const fileMap = new Map(files.map((f) => [f.id, f]));
|
||||
return {
|
||||
allFiles: files,
|
||||
orphanedFileIds: vi.fn().mockReturnValue(new Set<string>()),
|
||||
getFile: vi.fn().mockImplementation((id: string) => fileMap.get(id)),
|
||||
};
|
||||
}
|
||||
|
||||
it("shows limited SQL library files when no search query", () => {
|
||||
const mockConnStore = {
|
||||
connections: [{ id: "conn1", name: "MyConn", type: "mssql" }],
|
||||
treeNodes: [],
|
||||
};
|
||||
vi.mocked(useConnectionStore).mockReturnValue(mockConnStore as any);
|
||||
|
||||
const files = Array.from({ length: 30 }, (_, i) => ({
|
||||
id: `file${i}`,
|
||||
name: `query_${i}.sql`,
|
||||
connectionId: "conn1",
|
||||
updatedAt: `2024-01-${String(i + 1).padStart(2, "0")}T00:00:00.000Z`,
|
||||
}));
|
||||
vi.mocked(useSavedSqlStore).mockReturnValue(savedSqlStoreWithFiles(files) as any);
|
||||
|
||||
const { filteredItems, setQuery } = useQuickOpen();
|
||||
setQuery("");
|
||||
|
||||
// Should show 1 connection + 20 recent SQL library files = 21
|
||||
expect(filteredItems.value.length).toBe(21);
|
||||
const sqlItems = filteredItems.value.filter((item) => item.type === "sql_library_file");
|
||||
expect(sqlItems.length).toBe(20);
|
||||
});
|
||||
|
||||
it("includes all SQL library files when searching", () => {
|
||||
const mockConnStore = {
|
||||
connections: [{ id: "conn1", name: "MyConn", type: "mssql" }],
|
||||
treeNodes: [],
|
||||
};
|
||||
vi.mocked(useConnectionStore).mockReturnValue(mockConnStore as any);
|
||||
|
||||
const files = [
|
||||
{ id: "f1", name: "get_users.sql", connectionId: "conn1", updatedAt: "2024-01-01T00:00:00.000Z" },
|
||||
{ id: "f2", name: "create_orders.sql", connectionId: "conn1", updatedAt: "2024-01-02T00:00:00.000Z" },
|
||||
{ id: "f3", name: "update_inventory.sql", connectionId: "conn1", updatedAt: "2024-01-03T00:00:00.000Z" },
|
||||
];
|
||||
vi.mocked(useSavedSqlStore).mockReturnValue(savedSqlStoreWithFiles(files) as any);
|
||||
|
||||
const { filteredItems, setQuery } = useQuickOpen();
|
||||
setQuery("users");
|
||||
|
||||
const sqlItems = filteredItems.value.filter((item) => item.type === "sql_library_file");
|
||||
expect(sqlItems).toHaveLength(1);
|
||||
expect(sqlItems[0].label).toBe("get_users.sql");
|
||||
expect(sqlItems[0].sqlFileId).toBe("f1");
|
||||
});
|
||||
|
||||
it("excludes orphaned SQL library files", () => {
|
||||
const mockConnStore = {
|
||||
connections: [{ id: "conn1", name: "Active", type: "mssql" }],
|
||||
treeNodes: [],
|
||||
};
|
||||
vi.mocked(useConnectionStore).mockReturnValue(mockConnStore as any);
|
||||
|
||||
const files = [
|
||||
{ id: "f1", name: "active_query.sql", connectionId: "conn1", updatedAt: "2024-01-01T00:00:00.000Z" },
|
||||
{ id: "f2", name: "orphaned_query.sql", connectionId: "deleted_conn", updatedAt: "2024-01-02T00:00:00.000Z" },
|
||||
];
|
||||
const store = savedSqlStoreWithFiles(files);
|
||||
store.orphanedFileIds = vi.fn().mockReturnValue(new Set(["f2"]));
|
||||
vi.mocked(useSavedSqlStore).mockReturnValue(store as any);
|
||||
|
||||
const { filteredItems, setQuery } = useQuickOpen();
|
||||
setQuery("query");
|
||||
|
||||
const sqlItems = filteredItems.value.filter((item) => item.type === "sql_library_file");
|
||||
expect(sqlItems).toHaveLength(1);
|
||||
expect(sqlItems[0].label).toBe("active_query.sql");
|
||||
});
|
||||
});
|
||||
|
||||
describe("remote metadata search", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
@@ -2,6 +2,10 @@ import { computed, ref, watch } from "vue";
|
||||
import type { ConnectionConfig } from "@/types/database";
|
||||
import type { SqlCompletionTable } from "@/lib/sql/sqlCompletion";
|
||||
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 { getSqlFileFolderPaths, sqlFileFoldersVersion } from "@/lib/sqlFile/sqlFileFolders";
|
||||
|
||||
const REMOTE_SEARCH_DEBOUNCE_MS = 180;
|
||||
const REMOTE_SEARCH_MIN_QUERY_LENGTH = 2;
|
||||
@@ -10,12 +14,14 @@ 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 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";
|
||||
type: "connection" | "database" | "schema" | "table" | "view" | "materialized_view" | "procedure" | "function" | "sequence" | "package" | "package-body" | "sql_file" | "sql_library_file";
|
||||
label: string;
|
||||
description?: string;
|
||||
connectionId: string;
|
||||
@@ -25,6 +31,8 @@ export interface QuickOpenItem {
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,15 +81,137 @@ interface MatchedItem extends QuickOpenItem {
|
||||
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<() => void> = [];
|
||||
let sqlFilesLoaded = false;
|
||||
let sqlFilesLoadingPromise: Promise<void> | null = null;
|
||||
let sqlFilesLoadGeneration = 0;
|
||||
|
||||
function getConnectionLabel(connectionId: string): string {
|
||||
const conn = connectionStore.connections.find((c) => c.id === connectionId);
|
||||
return conn?.name || connectionId;
|
||||
}
|
||||
|
||||
const sqlLibraryAllItems = computed<QuickOpenItem[]>(() => {
|
||||
const activeConnectionIds = new Set(connectionStore.connections.map((c) => c.id));
|
||||
const orphanedIds = savedSqlStore.orphanedFileIds(activeConnectionIds);
|
||||
return savedSqlStore.allFiles
|
||||
.filter((file) => !orphanedIds.has(file.id))
|
||||
.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);
|
||||
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);
|
||||
});
|
||||
|
||||
const allItems = computed((): QuickOpenItem[] => {
|
||||
const items: QuickOpenItem[] = [];
|
||||
@@ -405,6 +535,17 @@ export function useQuickOpen() {
|
||||
remoteItems.value = groups.flat().slice(0, REMOTE_SEARCH_MAX_RESULTS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) => {
|
||||
@@ -413,6 +554,12 @@ export function useQuickOpen() {
|
||||
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 < REMOTE_SEARCH_MIN_QUERY_LENGTH) return;
|
||||
const contexts = remoteSearchContexts();
|
||||
if (contexts.length === 0) return;
|
||||
@@ -427,7 +574,8 @@ export function useQuickOpen() {
|
||||
|
||||
const filteredItems = computed((): MatchedItem[] => {
|
||||
if (!searchQuery.value.trim()) {
|
||||
return allItems.value.map((item) => ({
|
||||
// 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: [],
|
||||
@@ -437,7 +585,8 @@ export function useQuickOpen() {
|
||||
const matched: MatchedItem[] = [];
|
||||
|
||||
const seen = new Set<string>();
|
||||
for (const item of [...allItems.value, ...remoteItems.value]) {
|
||||
// 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);
|
||||
@@ -469,6 +618,8 @@ export function useQuickOpen() {
|
||||
sequence: 8,
|
||||
package: 9,
|
||||
"package-body": 10,
|
||||
sql_library_file: 11,
|
||||
sql_file: 12,
|
||||
};
|
||||
return typeOrder[a.type] - typeOrder[b.type];
|
||||
});
|
||||
@@ -513,5 +664,6 @@ export function useQuickOpen() {
|
||||
selectPrevious,
|
||||
resetSelection,
|
||||
setQuery,
|
||||
loadExternalSqlFiles,
|
||||
};
|
||||
}
|
||||
@@ -1336,13 +1336,15 @@ export default {
|
||||
back: "Back",
|
||||
},
|
||||
quickOpen: {
|
||||
placeholder: "Search connections, databases, tables, and other objects...",
|
||||
placeholder: "Search connections, databases, tables, SQL files, and other objects...",
|
||||
emptyPlaceholder: "Start typing to search",
|
||||
noResults: "No results found",
|
||||
results: "results",
|
||||
navigate: "Navigate",
|
||||
select: "Select",
|
||||
close: "Close",
|
||||
sqlFile: "SQL File",
|
||||
sqlLibraryFile: "SQL Library",
|
||||
},
|
||||
explain: {
|
||||
title: "Explain Plan",
|
||||
|
||||
@@ -1278,13 +1278,15 @@ export default withEnglishFallback({
|
||||
back: "Volver",
|
||||
},
|
||||
quickOpen: {
|
||||
placeholder: "Buscar conexiones, bases de datos, tablas y otros objetos...",
|
||||
placeholder: "Buscar conexiones, bases de datos, tablas, archivos SQL y otros objetos...",
|
||||
emptyPlaceholder: "Comienza a escribir para buscar",
|
||||
noResults: "No se encontraron resultados",
|
||||
results: "resultados",
|
||||
navigate: "Navegar",
|
||||
select: "Seleccionar",
|
||||
close: "Cerrar",
|
||||
sqlFile: "Archivo SQL",
|
||||
sqlLibraryFile: "Biblioteca SQL",
|
||||
},
|
||||
explain: {
|
||||
title: "Plan de ejecución",
|
||||
|
||||
@@ -1276,13 +1276,15 @@ export default withEnglishFallback({
|
||||
back: "Indietro",
|
||||
},
|
||||
quickOpen: {
|
||||
placeholder: "Cerca connessioni, database, tabelle e altri oggetti...",
|
||||
placeholder: "Cerca connessioni, database, tabelle, file SQL e altri oggetti...",
|
||||
emptyPlaceholder: "Inizia a digitare per cercare",
|
||||
noResults: "Nessun risultato trovato",
|
||||
results: "risultati",
|
||||
navigate: "Naviga",
|
||||
select: "Seleziona",
|
||||
close: "Chiudi",
|
||||
sqlFile: "File SQL",
|
||||
sqlLibraryFile: "Libreria SQL",
|
||||
},
|
||||
explain: {
|
||||
title: "Piano di Spiegazione",
|
||||
|
||||
@@ -1277,13 +1277,15 @@ export default withEnglishFallback({
|
||||
back: "戻る",
|
||||
},
|
||||
quickOpen: {
|
||||
placeholder: "接続、データベース、テーブル、その他のオブジェクトを検索...",
|
||||
placeholder: "接続、データベース、テーブル、SQLファイル、その他のオブジェクトを検索...",
|
||||
emptyPlaceholder: "入力して検索を開始",
|
||||
noResults: "結果が見つかりません",
|
||||
results: "件の結果",
|
||||
navigate: "ナビゲート",
|
||||
select: "選択",
|
||||
close: "閉じる",
|
||||
sqlFile: "SQLファイル",
|
||||
sqlLibraryFile: "SQLライブラリ",
|
||||
},
|
||||
explain: {
|
||||
title: "実行計画",
|
||||
|
||||
@@ -1278,13 +1278,15 @@ export default withEnglishFallback({
|
||||
back: "Voltar",
|
||||
},
|
||||
quickOpen: {
|
||||
placeholder: "Pesquisar conexões, bancos de dados, tabelas e outros objetos...",
|
||||
placeholder: "Pesquisar conexões, bancos de dados, tabelas, arquivos SQL e outros objetos...",
|
||||
emptyPlaceholder: "Comece a digitar para pesquisar",
|
||||
noResults: "Nenhum resultado encontrado",
|
||||
results: "resultados",
|
||||
navigate: "Navegar",
|
||||
select: "Selecionar",
|
||||
close: "Fechar",
|
||||
sqlFile: "Arquivo SQL",
|
||||
sqlLibraryFile: "Biblioteca SQL",
|
||||
},
|
||||
explain: {
|
||||
title: "Plano de Execução",
|
||||
|
||||
@@ -1336,13 +1336,15 @@ export default withEnglishFallback({
|
||||
back: "返回",
|
||||
},
|
||||
quickOpen: {
|
||||
placeholder: "搜索连接、数据库、表和其他对象...",
|
||||
placeholder: "搜索连接、数据库、表、SQL文件和其他对象...",
|
||||
emptyPlaceholder: "开始输入进行搜索",
|
||||
noResults: "未找到结果",
|
||||
results: "个结果",
|
||||
navigate: "导航",
|
||||
select: "选择",
|
||||
close: "关闭",
|
||||
sqlFile: "SQL文件",
|
||||
sqlLibraryFile: "SQL库",
|
||||
},
|
||||
explain: {
|
||||
title: "执行计划",
|
||||
|
||||
@@ -1278,13 +1278,15 @@ export default withEnglishFallback({
|
||||
back: "返回",
|
||||
},
|
||||
quickOpen: {
|
||||
placeholder: "搜尋連線、資料庫、資料表和其他物件……",
|
||||
placeholder: "搜尋連線、資料庫、資料表、SQL檔案和其他物件……",
|
||||
emptyPlaceholder: "開始輸入進行搜尋",
|
||||
noResults: "找不到結果",
|
||||
results: "個結果",
|
||||
navigate: "導航",
|
||||
select: "選擇",
|
||||
close: "關閉",
|
||||
sqlFile: "SQL檔案",
|
||||
sqlLibraryFile: "SQL庫",
|
||||
},
|
||||
explain: {
|
||||
title: "執行計畫",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { ref } from "vue";
|
||||
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
|
||||
|
||||
const STORAGE_KEY = "dbx-sql-file-folders";
|
||||
|
||||
/**
|
||||
* Shared reactive version counter — bumped whenever SQL file folder paths change.
|
||||
* Components that cache folder contents (e.g. useQuickOpen) can watch this
|
||||
* to know when they should re-read from localStorage and reload.
|
||||
*/
|
||||
export const sqlFileFoldersVersion = ref(0);
|
||||
|
||||
/** Read the current list of SQL file folder paths from localStorage. */
|
||||
export function getSqlFileFolderPaths(): string[] {
|
||||
try {
|
||||
const raw = safeLocalStorageGet(STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed.filter((p): p is string => typeof p === "string") : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist folder paths to localStorage and notify subscribers. */
|
||||
export function saveSqlFileFolderPaths(paths: string[]): void {
|
||||
safeLocalStorageSet(STORAGE_KEY, JSON.stringify(paths));
|
||||
sqlFileFoldersVersion.value++;
|
||||
}
|
||||
|
||||
/** Notify subscribers that folder contents may have changed (e.g. after refresh). */
|
||||
export function notifySqlFileFoldersChanged(): void {
|
||||
sqlFileFoldersVersion.value++;
|
||||
}
|
||||
Reference in new issue
Block a user