diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 6a0863432..cdeed2262 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -51,6 +51,7 @@ import { isNewQueryShortcut, isObjectSourceSaveShortcutTarget, isOpenSettingsShortcut, + isQuickOpenShortcut, isResetZoomShortcut, isRefreshDataShortcut, isSaveShortcut, @@ -65,7 +66,8 @@ import { buildHistoryAiAnalysisPrompt } from "@/lib/historyAiAnalysis"; import { countAvailableAgentDriverUpdates, type AgentDriverUpdateBadgeState } from "@/lib/agentDriverUpdateBadge"; import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/safeStorage"; import { rankSavedSqlHistory } from "@/lib/savedSqlHistory"; -import { isSchemaAware, isSingleDatabase } from "@/lib/databaseFeatureSupport"; +import { isSchemaAware, isSingleDatabase, usesTreeSchemaMode } from "@/lib/databaseFeatureSupport"; +import { connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection } from "@/lib/jdbcDialect"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -79,6 +81,7 @@ const SqlLibraryPanel = defineAsyncComponent(() => import("@/components/layout/S const DriverStorePage = defineAsyncComponent(() => import("@/components/config/DriverStoreDialog.vue")); const UpdateDialog = defineAsyncComponent(() => import("@/components/layout/UpdateDialog.vue")); const LoginPage = defineAsyncComponent(() => import("@/components/auth/LoginPage.vue")); +const QuickOpenDialog = defineAsyncComponent(() => import("@/components/quick-open/QuickOpenDialog.vue")); type AiAssistantHandle = { triggerAction: (action: AiAction, instruction?: string) => void; @@ -105,6 +108,7 @@ const showConnectionDialog = ref(false); const connectionDialogPrefill = ref(null); const showSettingsDialog = ref(false); const showDriverStore = ref(false); +const showQuickOpen = ref(false); const agentDriverUpdateCount = ref(0); const showHistory = ref(false); const showAiPanel = ref(safeLocalStorageGet("dbx-ai-panel-open") === "true"); @@ -931,6 +935,124 @@ function onAiOpenExplainPlan(sql: string) { }); } +async function handleQuickOpenSelect(item: any) { + const connectionStore = useConnectionStore(); + const queryStore = useQueryStore(); + + // For all types, set the active connection + connectionStore.activeConnectionId = item.connectionId; + + // Ensure connection is connected + try { + await connectionStore.ensureConnected(item.connectionId); + } catch (error) { + console.error("Failed to connect:", error); + return; + } + + // Navigate based on type + if (item.type === "connection") { + // Expand connection node in sidebar + // Tree node ID for connection is just the connectionId + const connNode = findTreeNodeById(connectionStore.treeNodes, item.connectionId); + if (connNode && !connNode.isExpanded) { + const config = connectionStore.getConfig(item.connectionId); + if (config?.db_type === "redis") { + await connectionStore.loadRedisDatabases(item.connectionId); + } else if (config?.db_type === "etcd") { + await connectionStore.loadEtcdRoot(item.connectionId); + } else if (config?.db_type === "mongodb") { + await connectionStore.loadMongoDatabases(item.connectionId); + } else if (config?.db_type === "elasticsearch") { + await connectionStore.loadElasticsearchIndices(item.connectionId); + } else if (config?.db_type === "qdrant" || config?.db_type === "milvus") { + await connectionStore.loadVectorCollections(item.connectionId); + } else if (config?.db_type === "mq") { + await connectionStore.loadMqTenants(item.connectionId); + } else { + await connectionStore.loadDatabases(item.connectionId); + } + } + return; + } else if (item.type === "database") { + // Expand connection node first + // Tree node ID for connection is just the connectionId + const connNode = findTreeNodeById(connectionStore.treeNodes, item.connectionId); + if (connNode && !connNode.isExpanded) { + const config = connectionStore.getConfig(item.connectionId); + if (config?.db_type === "redis") { + await connectionStore.loadRedisDatabases(item.connectionId); + } else if (config?.db_type === "etcd") { + await connectionStore.loadEtcdRoot(item.connectionId); + } else if (config?.db_type === "mongodb") { + await connectionStore.loadMongoDatabases(item.connectionId); + } else if (config?.db_type === "elasticsearch") { + await connectionStore.loadElasticsearchIndices(item.connectionId); + } else if (config?.db_type === "qdrant" || config?.db_type === "milvus") { + await connectionStore.loadVectorCollections(item.connectionId); + } else if (config?.db_type === "mq") { + await connectionStore.loadMqTenants(item.connectionId); + } else { + await connectionStore.loadDatabases(item.connectionId); + } + } + + // Expand database node + // Tree node ID for database is `${connectionId}:${database_name}` + const dbNodeId = `${item.connectionId}:${item.database}`; + const dbNode = findTreeNodeById(connectionStore.treeNodes, dbNodeId); + if (dbNode && !dbNode.isExpanded) { + const config = connectionStore.getConfig(item.connectionId); + const effectiveDbType = effectiveDatabaseTypeForConnection(config); + if (config?.db_type === "sqlserver") { + await connectionStore.loadSqlServerDatabaseObjects(item.connectionId, item.database); + } else if (usesTreeSchemaMode(effectiveDbType) && !connectionUsesDatabaseObjectTreeMode(config)) { + await connectionStore.loadSchemas(item.connectionId, item.database); + } else { + await connectionStore.loadTables(item.connectionId, item.database); + } + } + return; + } else if (item.type === "table" || item.type === "view" || item.type === "materialized_view") { + // Open the table/view in a data tab + await openTableTarget({ + connectionId: item.connectionId, + database: item.database, + schema: item.schema, + tableName: item.objectName || item.tableName, + }); + } else if (item.type === "procedure" || item.type === "function" || item.type === "sequence" || item.type === "package" || item.type === "package-body") { + // Open the object source in a source tab + const objectTypeMap: Record = { + procedure: "PROCEDURE", + function: "FUNCTION", + sequence: "SEQUENCE", + package: "PACKAGE", + "package-body": "PACKAGE_BODY", + }; + + const objectType = objectTypeMap[item.type]; + if (!objectType) return; + + const schema = item.schema || item.database; + try { + const result = await api.getObjectSource(item.connectionId, item.database, schema, item.objectName || item.tableName, objectType as any); + const tabId = queryStore.createTab(item.connectionId, item.database, `Source - ${item.objectName || item.tableName}`); + queryStore.updateSql(tabId, result.source); + if (item.type !== "sequence") { + queryStore.setObjectSource(tabId, { + schema, + name: item.objectName || item.tableName, + objectType, + }); + } + queryStore.markTabClean(queryStore.tabs.find((tab) => tab.id === tabId)); + } catch (error) { + toast((error as any)?.message || String(error), 5000); + } + } +} + function handleKeydown(e: KeyboardEvent) { if (e.defaultPrevented) return; @@ -942,6 +1064,12 @@ function handleKeydown(e: KeyboardEvent) { showSettingsDialog.value = true; return; } + if (isQuickOpenShortcut(e, shortcuts)) { + e.preventDefault(); + e.stopPropagation(); + showQuickOpen.value = true; + return; + } if (isFocusSearchShortcut(e, shortcuts)) { const focused = contentAreaRef.value?.focusSearch() || appSidebarRef.value?.focusSearch(); if (focused) { @@ -1405,6 +1533,7 @@ onUnmounted(() => { @download-and-install="downloadAndInstallUpdate" @restart="restartApp" /> + diff --git a/apps/desktop/src/components/quick-open/QuickOpenDialog.vue b/apps/desktop/src/components/quick-open/QuickOpenDialog.vue new file mode 100644 index 000000000..a82a7500c --- /dev/null +++ b/apps/desktop/src/components/quick-open/QuickOpenDialog.vue @@ -0,0 +1,189 @@ + + + + + diff --git a/apps/desktop/src/composables/__tests__/useQuickOpen.spec.ts b/apps/desktop/src/composables/__tests__/useQuickOpen.spec.ts new file mode 100644 index 000000000..690ceea9b --- /dev/null +++ b/apps/desktop/src/composables/__tests__/useQuickOpen.spec.ts @@ -0,0 +1,485 @@ +import { describe, expect, it, beforeEach, vi } from "vitest"; +import { useQuickOpen, type QuickOpenItem } from "@/composables/useQuickOpen"; +import { useConnectionStore } from "@/stores/connectionStore"; + +vi.mock("@/stores/connectionStore", () => ({ + useConnectionStore: vi.fn(), +})); + +describe("useQuickOpen", () => { + describe("fuzzyMatch function", () => { + it("should return exact substring match with score 1", () => { + // Mock store with test data + const mockStore = { + connections: [{ id: "conn1", name: "MyConnection", type: "mssql" }], + treeNodes: [ + { + connectionId: "conn1", + type: "database", + database: "MyDatabase", + label: "MyDatabase", + }, + ], + }; + vi.mocked(useConnectionStore).mockReturnValue(mockStore as any); + + const { filteredItems, setQuery } = useQuickOpen(); + + setQuery("MyDatabase"); + + // After search, we should find the exact match + expect(filteredItems.value.length).toBeGreaterThan(0); + const result = filteredItems.value.find((item) => item.label === "MyDatabase"); + expect(result).toBeDefined(); + if (result) { + expect(result.matchScore).toBe(1); // Exact substring match score + } + }); + + it("should handle empty query by returning all items", () => { + const mockStore = { + connections: [ + { id: "conn1", name: "Connection1", type: "mssql" }, + { id: "conn2", name: "Connection2", type: "postgres" }, + ], + treeNodes: [], + }; + vi.mocked(useConnectionStore).mockReturnValue(mockStore as any); + + const { filteredItems, setQuery } = useQuickOpen(); + + setQuery(""); + + // Empty query should return all items + expect(filteredItems.value.length).toBe(2); + filteredItems.value.forEach((item) => { + expect(item.matchScore).toBe(Infinity); + }); + }); + + it("should perform fuzzy matching for non-consecutive characters", () => { + const mockStore = { + connections: [{ id: "conn1", name: "MyConnection", type: "mssql" }], + treeNodes: [], + }; + vi.mocked(useConnectionStore).mockReturnValue(mockStore as any); + + const { filteredItems, setQuery } = useQuickOpen(); + + setQuery("MyCo"); + + // Fuzzy match should find "MyConnection" + const result = filteredItems.value.find((item) => item.label === "MyConnection"); + expect(result).toBeDefined(); + }); + + it("should return null for non-matching query", () => { + const mockStore = { + connections: [{ id: "conn1", name: "MyConnection", type: "mssql" }], + treeNodes: [], + }; + vi.mocked(useConnectionStore).mockReturnValue(mockStore as any); + + const { filteredItems, setQuery } = useQuickOpen(); + + setQuery("XYZ"); + + // No match should return empty results + expect(filteredItems.value.length).toBe(0); + }); + + it("should score consecutive characters higher than non-consecutive", () => { + const mockStore = { + connections: [ + { id: "conn1", name: "user_login_table", type: "mssql" }, + { id: "conn2", name: "user_data_login", type: "mssql" }, + ], + treeNodes: [], + }; + vi.mocked(useConnectionStore).mockReturnValue(mockStore as any); + + const { filteredItems, setQuery } = useQuickOpen(); + + setQuery("login"); + + // "user_login_table" has consecutive "login" match (better score) + // "user_data_login" has consecutive "login" match too + expect(filteredItems.value.length).toBe(2); + // Both should have score 1.0 (consecutive match: login appears consecutively) + }); + }); + + describe("filtering and searching", () => { + it("should filter items based on search query", () => { + const mockStore = { + connections: [ + { id: "conn1", name: "ProdDB", type: "mssql" }, + { id: "conn2", name: "DevDB", type: "mssql" }, + ], + treeNodes: [], + }; + vi.mocked(useConnectionStore).mockReturnValue(mockStore as any); + + const { filteredItems, setQuery } = useQuickOpen(); + + setQuery("Prod"); + + expect(filteredItems.value.length).toBe(1); + expect(filteredItems.value[0].label).toBe("ProdDB"); + }); + + it("should be case-insensitive", () => { + const mockStore = { + connections: [{ id: "conn1", name: "MyConnection", type: "mssql" }], + treeNodes: [], + }; + vi.mocked(useConnectionStore).mockReturnValue(mockStore as any); + + const { filteredItems, setQuery } = useQuickOpen(); + + setQuery("myconnection"); + + expect(filteredItems.value.length).toBe(1); + expect(filteredItems.value[0].label).toBe("MyConnection"); + }); + + it("should search across connection name and database name", () => { + const mockStore = { + connections: [{ id: "conn1", name: "ProdConnection", type: "mssql" }], + treeNodes: [ + { + connectionId: "conn1", + type: "database", + database: "UserDB", + label: "UserDB", + }, + ], + }; + vi.mocked(useConnectionStore).mockReturnValue(mockStore as any); + + const { filteredItems, setQuery } = useQuickOpen(); + + // Search by connection name + setQuery("Prod"); + expect(filteredItems.value.length).toBeGreaterThan(0); + }); + + it("should sort by match score (lower scores first)", () => { + const mockStore = { + connections: [ + { id: "conn1", name: "Database", type: "mssql" }, + { id: "conn2", name: "MyDB", type: "mssql" }, + ], + treeNodes: [], + }; + vi.mocked(useConnectionStore).mockReturnValue(mockStore as any); + + const { filteredItems, setQuery } = useQuickOpen(); + + setQuery("db"); + + expect(filteredItems.value.length).toBe(2); + // First result should have better (lower) score + expect(filteredItems.value[0].matchScore).toBeLessThanOrEqual(filteredItems.value[1].matchScore); + }); + + it("should sort by type for equal match scores", () => { + const mockStore = { + connections: [{ id: "conn1", name: "test", type: "mssql" }], + treeNodes: [ + { + connectionId: "conn1", + type: "database", + database: "test_db", + label: "test_db", + }, + ], + }; + vi.mocked(useConnectionStore).mockReturnValue(mockStore as any); + + const { filteredItems, setQuery } = useQuickOpen(); + + setQuery("test"); + + // Connections should come before databases for the same query + if (filteredItems.value.length >= 2) { + const connectionItem = filteredItems.value.find((item) => item.type === "connection"); + const databaseItem = filteredItems.value.find((item) => item.type === "database"); + + if (connectionItem && databaseItem) { + expect(filteredItems.value.indexOf(connectionItem)).toBeLessThan(filteredItems.value.indexOf(databaseItem)); + } + } + }); + }); + + describe("item selection navigation", () => { + it("should initialize with selectedIndex at 0", () => { + const mockStore = { + connections: [], + treeNodes: [], + }; + vi.mocked(useConnectionStore).mockReturnValue(mockStore as any); + + const { selectedIndex } = useQuickOpen(); + expect(selectedIndex.value).toBe(0); + }); + + it("should select next item", () => { + const mockStore = { + connections: [ + { id: "conn1", name: "Conn1", type: "mssql" }, + { id: "conn2", name: "Conn2", type: "mssql" }, + ], + treeNodes: [], + }; + vi.mocked(useConnectionStore).mockReturnValue(mockStore as any); + + const { selectNext, selectedIndex, setQuery } = useQuickOpen(); + + setQuery(""); + + expect(selectedIndex.value).toBe(0); + selectNext(); + expect(selectedIndex.value).toBe(1); + }); + + it("should not exceed max index when selecting next", () => { + const mockStore = { + connections: [{ id: "conn1", name: "Conn1", type: "mssql" }], + treeNodes: [], + }; + vi.mocked(useConnectionStore).mockReturnValue(mockStore as any); + + const { selectNext, selectedIndex, setQuery } = useQuickOpen(); + + setQuery(""); + + selectNext(); + selectNext(); // Attempt to go beyond max + expect(selectedIndex.value).toBe(0); // Should stay at 0 (only 1 item) + }); + + it("should select previous item", () => { + const mockStore = { + connections: [ + { id: "conn1", name: "Conn1", type: "mssql" }, + { id: "conn2", name: "Conn2", type: "mssql" }, + ], + treeNodes: [], + }; + vi.mocked(useConnectionStore).mockReturnValue(mockStore as any); + + const { selectNext, selectPrevious, selectedIndex, setQuery } = useQuickOpen(); + + setQuery(""); + + selectNext(); + expect(selectedIndex.value).toBe(1); + selectPrevious(); + expect(selectedIndex.value).toBe(0); + }); + + it("should not go below 0 when selecting previous", () => { + const mockStore = { + connections: [], + treeNodes: [], + }; + vi.mocked(useConnectionStore).mockReturnValue(mockStore as any); + + const { selectPrevious, selectedIndex } = useQuickOpen(); + + // Verify initial state + expect(selectedIndex.value).toBe(0); + + selectPrevious(); + expect(selectedIndex.value).toBe(0); + }); + + it("should return correct selectedItem", () => { + const mockStore = { + connections: [ + { id: "conn1", name: "Conn1", type: "mssql" }, + { id: "conn2", name: "Conn2", type: "mssql" }, + ], + treeNodes: [], + }; + vi.mocked(useConnectionStore).mockReturnValue(mockStore as any); + + const { selectNext, selectedItem, setQuery } = useQuickOpen(); + + setQuery(""); + + expect(selectedItem.value?.label).toBe("Conn1"); + selectNext(); + expect(selectedItem.value?.label).toBe("Conn2"); + }); + + it("should return null selectedItem when index is out of bounds", () => { + const mockStore = { + connections: [{ id: "conn1", name: "Conn1", type: "mssql" }], + treeNodes: [], + }; + vi.mocked(useConnectionStore).mockReturnValue(mockStore as any); + + const { selectedItem, selectedIndex } = useQuickOpen(); + + // Manually set invalid index + selectedIndex.value = 999; + expect(selectedItem.value).toBeNull(); + }); + }); + + describe("reset and query setting", () => { + it("should reset selection to 0 when setQuery is called", () => { + const mockStore = { + connections: [ + { id: "conn1", name: "Conn1", type: "mssql" }, + { id: "conn2", name: "Conn2", type: "mssql" }, + ], + treeNodes: [], + }; + vi.mocked(useConnectionStore).mockReturnValue(mockStore as any); + + const { selectNext, setQuery, selectedIndex } = useQuickOpen(); + + selectNext(); + expect(selectedIndex.value).toBe(1); + + setQuery("test"); + expect(selectedIndex.value).toBe(0); + }); + + it("should update searchQuery when setQuery is called", () => { + const mockStore = { + connections: [], + treeNodes: [], + }; + vi.mocked(useConnectionStore).mockReturnValue(mockStore as any); + + const { searchQuery, setQuery } = useQuickOpen(); + + setQuery("NewQuery"); + expect(searchQuery.value).toBe("NewQuery"); + }); + + it("should resetSelection to 0", () => { + const mockStore = { + connections: [ + { id: "conn1", name: "Conn1", type: "mssql" }, + { id: "conn2", name: "Conn2", type: "mssql" }, + ], + treeNodes: [], + }; + vi.mocked(useConnectionStore).mockReturnValue(mockStore as any); + + const { resetSelection, selectNext, selectedIndex, setQuery } = useQuickOpen(); + + setQuery(""); + + selectNext(); + expect(selectedIndex.value).toBe(1); + + resetSelection(); + expect(selectedIndex.value).toBe(0); + }); + }); + + describe("allItems with different database object types", () => { + it("should include tables from tree nodes", () => { + const mockStore = { + connections: [{ id: "conn1", name: "MyConn", type: "mssql" }], + treeNodes: [ + { + connectionId: "conn1", + type: "table", + database: "MyDB", + schema: "dbo", + label: "Users", + }, + ], + }; + vi.mocked(useConnectionStore).mockReturnValue(mockStore as any); + + const { filteredItems, setQuery } = useQuickOpen(); + + setQuery(""); + + const tableItem = filteredItems.value.find((item) => item.type === "table"); + expect(tableItem).toBeDefined(); + expect(tableItem?.label).toBe("Users"); + }); + + it("should include views from tree nodes", () => { + const mockStore = { + connections: [{ id: "conn1", name: "MyConn", type: "mssql" }], + treeNodes: [ + { + connectionId: "conn1", + type: "view", + database: "MyDB", + schema: "dbo", + label: "UserView", + }, + ], + }; + vi.mocked(useConnectionStore).mockReturnValue(mockStore as any); + + const { filteredItems, setQuery } = useQuickOpen(); + + setQuery(""); + + const viewItem = filteredItems.value.find((item) => item.type === "view"); + expect(viewItem).toBeDefined(); + expect(viewItem?.label).toBe("UserView"); + }); + + it("should include procedures from tree nodes", () => { + const mockStore = { + connections: [{ id: "conn1", name: "MyConn", type: "mssql" }], + treeNodes: [ + { + connectionId: "conn1", + type: "procedure", + database: "MyDB", + schema: "dbo", + label: "GetUsers", + }, + ], + }; + vi.mocked(useConnectionStore).mockReturnValue(mockStore as any); + + const { filteredItems, setQuery } = useQuickOpen(); + + setQuery(""); + + const procItem = filteredItems.value.find((item) => item.type === "procedure"); + expect(procItem).toBeDefined(); + expect(procItem?.label).toBe("GetUsers"); + }); + + it("should include functions from tree nodes", () => { + const mockStore = { + connections: [{ id: "conn1", name: "MyConn", type: "mssql" }], + treeNodes: [ + { + connectionId: "conn1", + type: "function", + database: "MyDB", + schema: "dbo", + label: "ComputeAge", + }, + ], + }; + vi.mocked(useConnectionStore).mockReturnValue(mockStore as any); + + const { filteredItems, setQuery } = useQuickOpen(); + + setQuery(""); + + const funcItem = filteredItems.value.find((item) => item.type === "function"); + expect(funcItem).toBeDefined(); + expect(funcItem?.label).toBe("ComputeAge"); + }); + }); +}); diff --git a/apps/desktop/src/composables/useQuickOpen.ts b/apps/desktop/src/composables/useQuickOpen.ts new file mode 100644 index 000000000..395d86883 --- /dev/null +++ b/apps/desktop/src/composables/useQuickOpen.ts @@ -0,0 +1,349 @@ +import { computed, ref } from "vue"; +import type { ConnectionConfig } from "@/types/database"; +import { useConnectionStore } from "@/stores/connectionStore"; + +export interface QuickOpenItem { + id: string; + type: "connection" | "database" | "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 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) { + const connectionTreeNodes = 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 - skip them but process their children + if (node.type === "schema" && 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); + } + } + } + + const filteredItems = computed((): MatchedItem[] => { + if (!searchQuery.value.trim()) { + return allItems.value.map((item) => ({ + ...item, + matchScore: Infinity, + matchIndices: [], + })); + } + + const matched: MatchedItem[] = []; + + for (const item of allItems.value) { + 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, + table: 2, + view: 3, + materialized_view: 4, + procedure: 5, + function: 6, + sequence: 7, + package: 8, + "package-body": 9, + }; + return (typeOrder[a.type] ?? 10) - (typeOrder[b.type] ?? 10); + }); + + return matched; + }); + + 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, + }; +} diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index c634480cd..da6f68f15 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -836,6 +836,25 @@ export default { retry: "Retry", more: "More", done: "Done", + connection: "Connection", + database: "Database", + table: "Table", + view: "View", + materializedView: "Materialized View", + procedure: "Procedure", + function: "Function", + sequence: "Sequence", + package: "Package", + packageBody: "Package Body", + }, + quickOpen: { + placeholder: "Search connections, databases, tables, and other objects...", + emptyPlaceholder: "Start typing to search", + noResults: "No results found", + results: "results", + navigate: "Navigate", + select: "Select", + close: "Close", }, explain: { title: "Explain Plan", @@ -2365,6 +2384,7 @@ export default { shortcutOpenSettings: "Open settings", shortcutCloseTab: "Close tab", shortcutFocusSearch: "Focus search", + shortcutQuickOpen: "Quick open (search all database objects)", shortcutZoomInUi: "Zoom in UI", shortcutZoomOutUi: "Zoom out UI", shortcutResetUiZoom: "Reset UI zoom", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 56059a3c0..9b2c435c4 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -715,9 +715,30 @@ export default { loading: "Cargando...", stopping: "Deteniendo...", close: "Cerrar", + cancel: "Cancelar", + save: "Guardar", retry: "Reintentar", more: "Más", done: "Listo", + connection: "Conexión", + database: "Base de datos", + table: "Tabla", + view: "Vista", + materializedView: "Vista Materializada", + procedure: "Procedimiento", + function: "Función", + sequence: "Secuencia", + package: "Paquete", + packageBody: "Cuerpo del Paquete", + }, + quickOpen: { + placeholder: "Buscar conexiones, bases de datos, tablas y otros objetos...", + emptyPlaceholder: "Comienza a escribir para buscar", + noResults: "No se encontraron resultados", + results: "resultados", + navigate: "Navegar", + select: "Seleccionar", + close: "Cerrar", }, explain: { title: "Plan de ejecución", @@ -2040,6 +2061,7 @@ export default { shortcutCloseTab: "Cerrar pestaña", shortcutToggleSidebar: "Alternar barra lateral", shortcutFocusSearch: "Enfocar búsqueda", + shortcutQuickOpen: "Abrir rápido (buscar todos los objetos de base de datos)", shortcutZoomInUi: "Ampliar interfaz", shortcutZoomOutUi: "Reducir interfaz", shortcutResetUiZoom: "Restablecer zoom de interfaz", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 69f0718f9..8c476c5d7 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -775,9 +775,30 @@ export default { loading: "Caricamento...", stopping: "Interruzione...", close: "Chiudi", + cancel: "Annulla", + save: "Salva", retry: "Riprova", more: "Altro", done: "Fatto", + connection: "Connessione", + database: "Database", + table: "Tabella", + view: "Vista", + materializedView: "Vista Materializzata", + procedure: "Procedura", + function: "Funzione", + sequence: "Sequenza", + package: "Pacchetto", + packageBody: "Corpo Pacchetto", + }, + quickOpen: { + placeholder: "Cerca connessioni, database, tabelle e altri oggetti...", + emptyPlaceholder: "Inizia a digitare per cercare", + noResults: "Nessun risultato trovato", + results: "risultati", + navigate: "Naviga", + select: "Seleziona", + close: "Chiudi", }, explain: { title: "Piano di Spiegazione", @@ -2115,6 +2136,7 @@ export default { shortcutCloseTab: "Chiudi scheda", shortcutToggleSidebar: "Attiva/disattiva barra laterale", shortcutFocusSearch: "Focalizza ricerca", + shortcutQuickOpen: "Apertura rapida (ricerca tutti gli oggetti del database)", shortcutZoomInUi: "Ingrandisci UI", shortcutZoomOutUi: "Rimpicciolisci UI", shortcutResetUiZoom: "Reimposta zoom UI", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 3a5931b59..d0c83e464 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -829,7 +829,28 @@ export default { close: "閉じる", cancel: "キャンセル", save: "保存", + retry: "再試行", more: "もっと見る", + done: "完了", + connection: "接続", + database: "データベース", + table: "テーブル", + view: "ビュー", + materializedView: "マテリアライズドビュー", + procedure: "プロシージャ", + function: "関数", + sequence: "シーケンス", + package: "パッケージ", + packageBody: "パッケージ本体", + }, + quickOpen: { + placeholder: "接続、データベース、テーブル、その他のオブジェクトを検索...", + emptyPlaceholder: "入力して検索を開始", + noResults: "結果が見つかりません", + results: "件の結果", + navigate: "ナビゲート", + select: "選択", + close: "閉じる", }, explain: { title: "実行計画", @@ -2333,6 +2354,7 @@ export default { shortcutOpenSettings: "設定を開く", shortcutCloseTab: "タブを閉じる", shortcutFocusSearch: "検索にフォーカス", + shortcutQuickOpen: "クイックオープン (すべてのデータベースオブジェクトを検索)", shortcutZoomInUi: "UIを拡大", shortcutZoomOutUi: "UIを縮小", shortcutResetUiZoom: "UIズームをリセット", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 3ad1c9327..2ee991eff 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -775,9 +775,30 @@ export default { loading: "Carregando...", stopping: "Parando...", close: "Fechar", + cancel: "Cancelar", + save: "Salvar", retry: "Tentar novamente", more: "Mais", done: "Concluído", + connection: "Conexão", + database: "Banco de dados", + table: "Tabela", + view: "Visualização", + materializedView: "Visualização Materializada", + procedure: "Procedimento", + function: "Função", + sequence: "Sequência", + package: "Pacote", + packageBody: "Corpo do Pacote", + }, + quickOpen: { + placeholder: "Pesquisar conexões, bancos de dados, tabelas e outros objetos...", + emptyPlaceholder: "Comece a digitar para pesquisar", + noResults: "Nenhum resultado encontrado", + results: "resultados", + navigate: "Navegar", + select: "Selecionar", + close: "Fechar", }, explain: { title: "Plano de Execução", @@ -2126,6 +2147,7 @@ export default { shortcutCloseTab: "Fechar aba", shortcutToggleSidebar: "Alternar barra lateral", shortcutFocusSearch: "Focar na pesquisa", + shortcutQuickOpen: "Abrir rapidamente (pesquisar todos os objetos do banco de dados)", shortcutZoomInUi: "Aumentar zoom da UI", shortcutZoomOutUi: "Diminuir zoom da UI", shortcutResetUiZoom: "Redefinir zoom da UI", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 677d3519b..87c87eb94 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -835,6 +835,25 @@ export default { retry: "重试", more: "更多", done: "完成", + connection: "连接", + database: "数据库", + table: "表", + view: "视图", + materializedView: "物化视图", + procedure: "存储过程", + function: "函数", + sequence: "序列", + package: "包", + packageBody: "包体", + }, + quickOpen: { + placeholder: "搜索连接、数据库、表和其他对象...", + emptyPlaceholder: "开始输入进行搜索", + noResults: "未找到结果", + results: "个结果", + navigate: "导航", + select: "选择", + close: "关闭", }, explain: { title: "执行计划", @@ -2390,6 +2409,7 @@ export default { shortcutCloseTab: "关闭标签页", shortcutToggleSidebar: "切换侧边栏", shortcutFocusSearch: "聚焦搜索", + shortcutQuickOpen: "快速打开 (搜索所有数据库对象)", shortcutZoomInUi: "放大全局界面", shortcutZoomOutUi: "缩小全局界面", shortcutResetUiZoom: "重置全局界面缩放", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 2ba30d731..ae4b5a1de 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -755,9 +755,30 @@ export default { loading: "載入中……", stopping: "正在停止……", close: "關閉", + cancel: "取消", + save: "保存", retry: "重試", more: "更多", done: "完成", + connection: "連線", + database: "資料庫", + table: "資料表", + view: "檢視", + materializedView: "物化檢視", + procedure: "預存程序", + function: "函數", + sequence: "序列", + package: "套件", + packageBody: "套件本體", + }, + quickOpen: { + placeholder: "搜尋連線、資料庫、資料表和其他物件……", + emptyPlaceholder: "開始輸入進行搜尋", + noResults: "找不到結果", + results: "個結果", + navigate: "導航", + select: "選擇", + close: "關閉", }, explain: { title: "執行計畫", @@ -2159,6 +2180,7 @@ export default { shortcutCloseTab: "關閉分頁", shortcutToggleSidebar: "切換側邊欄", shortcutFocusSearch: "聚焦搜尋", + shortcutQuickOpen: "快速開啟 (搜尋所有資料庫物件)", shortcutZoomInUi: "放大全域介面", shortcutZoomOutUi: "縮小全域介面", shortcutResetUiZoom: "重設全域介面縮放", diff --git a/apps/desktop/src/lib/keyboardShortcuts.ts b/apps/desktop/src/lib/keyboardShortcuts.ts index bb6eed5d5..5c759aab6 100644 --- a/apps/desktop/src/lib/keyboardShortcuts.ts +++ b/apps/desktop/src/lib/keyboardShortcuts.ts @@ -144,6 +144,10 @@ export function isToggleSidebarShortcut(event: ShortcutLikeEvent, shortcuts?: Pa return matchesShortcut(event, actionShortcut("toggleSidebar", shortcuts)); } +export function isQuickOpenShortcut(event: ShortcutLikeEvent, shortcuts?: Partial): boolean { + return matchesShortcut(event, actionShortcut("quickOpen", shortcuts)); +} + export function isBrowserReloadShortcut(event: ShortcutLikeEvent): boolean { if (event.isComposing || event.altKey) return false; const key = normalizeKey(event.key); diff --git a/apps/desktop/src/lib/shortcutRegistry.ts b/apps/desktop/src/lib/shortcutRegistry.ts index 822cb558f..91a0b6947 100644 --- a/apps/desktop/src/lib/shortcutRegistry.ts +++ b/apps/desktop/src/lib/shortcutRegistry.ts @@ -20,6 +20,7 @@ export type ShortcutActionId = | "openSettings" | "closeTab" | "focusSearch" + | "quickOpen" | "zoomInUi" | "zoomOutUi" | "resetUiZoom" @@ -168,6 +169,12 @@ export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [ scope: "global", defaultShortcut: "Mod+F", }, + { + id: "quickOpen", + labelKey: "settings.shortcutQuickOpen", + scope: "global", + defaultShortcut: "Mod+P", + }, { id: "zoomInUi", labelKey: "settings.shortcutZoomInUi",