feat: add Ctrl+P quick-open for database objects

This commit is contained in:
Wakanlolz 2026-06-20 00:13:09 +08:00 committed by GitHub
parent 88a4f89584
commit ebde8b4fa1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1314 additions and 1 deletions

View File

@ -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<ConnectionDeepLinkDraft | null>(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<string, string> = {
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"
/>
<QuickOpenDialog :open="showQuickOpen" @update:open="showQuickOpen = $event" @select="handleQuickOpenSelect" />
</div>
<Teleport to="body">
<Transition name="toast">

View File

@ -0,0 +1,189 @@
<script setup lang="ts">
import { computed, ref, watch, nextTick } from "vue";
import { useI18n } from "vue-i18n";
import { Command } from "@lucide/vue";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { useQuickOpen, type QuickOpenItem } from "@/composables/useQuickOpen";
const props = defineProps<{
open: boolean;
}>();
const emit = defineEmits<{
"update:open": [value: boolean];
select: [item: QuickOpenItem];
}>();
const { t } = useI18n();
const { searchQuery, filteredItems, selectedIndex, selectedItem, selectNext, selectPrevious, setQuery } = useQuickOpen();
const inputRef = ref<HTMLInputElement | null>(null);
const dialogOpen = computed({
get: () => props.open,
set: (value) => emit("update:open", value),
});
function handleKeyDown(e: KeyboardEvent): void {
if (e.key === "ArrowDown") {
e.preventDefault();
selectNext();
} else if (e.key === "ArrowUp") {
e.preventDefault();
selectPrevious();
} else if (e.key === "Enter" && selectedItem.value) {
e.preventDefault();
handleSelect(selectedItem.value);
} else if (e.key === "Escape") {
e.preventDefault();
dialogOpen.value = false;
}
}
function handleSelect(item: QuickOpenItem): void {
emit("select", item);
dialogOpen.value = false;
}
function getHighlightedLabel(item: any): (string | { text: string; highlight: boolean })[] {
if (!searchQuery.value.trim() || !item.matchIndices) {
return [item.label];
}
const indices = new Set(item.matchIndices);
const parts: (string | { text: string; highlight: boolean })[] = [];
let current = "";
let isHighlighting = false;
for (let i = 0; i < item.label.length; i++) {
const char = item.label[i];
const shouldHighlight = indices.has(i);
if (shouldHighlight !== isHighlighting) {
if (current) {
parts.push({
text: current,
highlight: isHighlighting,
});
}
current = char;
isHighlighting = shouldHighlight;
} else {
current += char;
}
}
if (current) {
parts.push({
text: current,
highlight: isHighlighting,
});
}
return parts;
}
function getTypeLabel(type: string): string {
switch (type) {
case "connection":
return t("common.connection");
case "database":
return t("common.database");
case "table":
return t("common.table");
case "view":
return t("common.view");
case "materialized_view":
return t("common.materializedView");
case "procedure":
return t("common.procedure");
case "function":
return t("common.function");
case "sequence":
return t("common.sequence");
case "package":
return t("common.package");
case "package-body":
return t("common.packageBody");
default:
return type;
}
}
watch(
() => props.open,
(newOpen) => {
if (newOpen) {
setQuery("");
nextTick(() => {
inputRef.value?.focus();
});
}
},
);
</script>
<template>
<Dialog :open="dialogOpen" @update:open="dialogOpen = $event">
<DialogContent class="max-w-2xl p-0 gap-0 rounded-lg overflow-hidden">
<div class="flex flex-col bg-background">
<!-- Search Input -->
<div class="flex items-center gap-3 px-4 py-3 border-b">
<Command class="h-5 w-5 text-muted-foreground" />
<Input ref="inputRef" v-model="searchQuery" type="text" :placeholder="t('quickOpen.placeholder')" class="flex-1 border-0 bg-transparent p-0 placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:outline-none" @keydown="handleKeyDown" />
</div>
<!-- Results List -->
<div class="max-h-[400px] overflow-y-auto">
<div v-if="filteredItems.length === 0" class="px-4 py-8 text-center text-muted-foreground">
<p v-if="!searchQuery.trim()">{{ t("quickOpen.emptyPlaceholder") }}</p>
<p v-else>{{ t("quickOpen.noResults") }}</p>
</div>
<div v-else class="divide-y">
<div v-for="(item, index) in filteredItems" :key="item.id" :class="['px-4 py-2 cursor-pointer transition-colors', 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>
</div>
<div class="text-xs px-2 py-1 rounded bg-muted text-muted-foreground whitespace-nowrap">
{{ getTypeLabel(item.type) }}
</div>
</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="px-4 py-2 border-t text-xs text-muted-foreground flex justify-between">
<div>{{ filteredItems.length }} {{ t("quickOpen.results") }}</div>
<div class="flex gap-4">
<span><kbd class="px-2 py-1 rounded bg-muted"></kbd> {{ t("quickOpen.navigate") }}</span>
<span><kbd class="px-2 py-1 rounded bg-muted"></kbd> {{ t("quickOpen.select") }}</span>
<span><kbd class="px-2 py-1 rounded bg-muted">ESC</kbd> {{ t("quickOpen.close") }}</span>
</div>
</div>
</div>
</DialogContent>
</Dialog>
</template>
<style scoped>
:deep(.bg-yellow-200) {
background-color: rgb(254 227 92);
}
:deep(.dark .bg-yellow-800) {
background-color: rgb(92 51 0);
}
</style>

View File

@ -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");
});
});
});

View File

@ -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,
};
}

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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ズームをリセット",

View File

@ -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",

View File

@ -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: "重置全局界面缩放",

View File

@ -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: "重設全域介面縮放",

View File

@ -144,6 +144,10 @@ export function isToggleSidebarShortcut(event: ShortcutLikeEvent, shortcuts?: Pa
return matchesShortcut(event, actionShortcut("toggleSidebar", shortcuts));
}
export function isQuickOpenShortcut(event: ShortcutLikeEvent, shortcuts?: Partial<ShortcutSettings>): 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);

View File

@ -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",