From 3c8ae159fb386d331c61c8b8d225cf6a87661db2 Mon Sep 17 00:00:00 2001 From: zipg Date: Tue, 15 Sep 2026 23:20:05 +0800 Subject: [PATCH] feat(ui): route Ctrl+F search by focused surface --- apps/desktop/src/App.vue | 83 +++++++++++++++++-- .../__tests__/searchShortcutRouting.spec.ts | 56 +++++++++++++ .../components/config/DriverStoreDialog.vue | 4 +- .../connection/ConnectionDialog.vue | 2 +- .../src/components/editor/AiAssistant.vue | 8 +- .../editor/EditorSettingsDialog.vue | 1 + .../src/components/editor/QueryHistory.vue | 2 +- .../__tests__/EditorSearchPanel.spec.ts | 2 +- apps/desktop/src/components/grid/DataGrid.vue | 16 +++- .../src/components/layout/AppSidebar.vue | 6 +- .../src/components/layout/ContentArea.vue | 30 ++----- .../src/components/layout/EditorGroup.vue | 2 +- .../components/layout/QueryEditorSurface.vue | 2 +- .../components/layout/QueryResultSurface.vue | 2 +- .../components/layout/SqlEditorWorkspace.vue | 4 +- .../src/components/layout/SqlLibraryPanel.vue | 2 +- .../__tests__/ContentArea.dataToolbar.spec.ts | 12 +++ .../src/components/objects/ObjectBrowser.vue | 21 ++++- .../plugins/PluginContributionsPanel.vue | 2 +- .../src/components/sidebar/ConnectionTree.vue | 11 ++- .../src/components/sidebar/TreeItem.vue | 14 +++- .../__tests__/sql/sqlShortcutActions.spec.ts | 5 ++ .../elasticsearchJsonResponse.test.ts | 2 +- 23 files changed, 233 insertions(+), 56 deletions(-) create mode 100644 apps/desktop/src/__tests__/searchShortcutRouting.spec.ts create mode 100644 apps/desktop/src/components/layout/__tests__/ContentArea.dataToolbar.spec.ts diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 8a2d66a997..bb59394568 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -174,10 +174,13 @@ type AiAssistantHandle = { setPrompt: (text: string) => void; addTableMention: (target: { schema?: string; table: string }) => void; clearContextReferences: () => void; + focusSearch: () => boolean; /** Opens a conversation by id (used by the background-run toast, §9). */ selectConversationById: (conversationId: string) => void; }; +type AuxiliarySearchSurface = "ai" | "history" | "sqlLibrary" | null; + const { t } = useI18n(); const connectionStore = useConnectionStore(); const queryStore = useQueryStore(); @@ -365,6 +368,7 @@ const aiAssistantRef = ref(null); const appSidebarRef = ref | null>(null); const appTabBarRef = ref | null>(null); const contentAreaRef = ref | null>(null); +const lastFocusedAuxiliarySurface = ref(null); const selectedSql = ref(""); const cursorPos = ref(0); @@ -1428,6 +1432,9 @@ function setRightSidebarPanelOpen(panelId: RightSidebarPanelId, open: boolean) { applyRightSidebarPanelState(transitionRightSidebarPanels(currentRightSidebarPanelState(), panelId, open, exclusive)); if (open) { lastOpenedRightSidebarPanel = panelId; + if (panelId === "ai" || panelId === "history" || panelId === "sqlLibrary") { + lastFocusedAuxiliarySurface.value = panelId; + } } else if (lastOpenedRightSidebarPanel === panelId) { lastOpenedRightSidebarPanel = RIGHT_SIDEBAR_PANEL_IDS.find((candidate) => rightSidebarPanelRefs[candidate].value); } @@ -3148,6 +3155,14 @@ function handleTabSwitcherKeydownCapture(e: KeyboardEvent) { tabSwitcherKeyboard.handleKeydownCapture(e); } +function handleAuxiliarySearchKeydownCapture(e: KeyboardEvent) { + if (e.defaultPrevented || !isFocusSearchShortcut(e, settingsStore.editorSettings.shortcuts)) return; + const target = e.target instanceof Element ? e.target : document.activeElement instanceof Element ? document.activeElement : null; + if (!focusSearchInAuxiliarySurface(target)) return; + e.preventDefault(); + e.stopPropagation(); +} + function handleTabSwitcherWindowBlur() { tabSwitcherKeyboard.handleWindowBlur(); } @@ -3180,6 +3195,50 @@ function handleNativeSelectAll(e: KeyboardEvent) { if (shouldBlockAppNativeSelectAll(e)) e.preventDefault(); } +function focusSearchInput(selector: string): boolean { + const input = Array.from(document.querySelectorAll(selector)).find((candidate) => candidate.getClientRects().length > 0); + if (!input) return false; + input.focus(); + input.select(); + return true; +} + +function focusSearchInAuxiliarySurface(target: Element | null): boolean { + if (showConnectionDialog.value) { + // The dialog is modal: keep the shortcut inside it and never focus a + // surface hidden behind the overlay, even when its search input is absent. + focusSearchInput("[data-connection-db-search]"); + return true; + } + if (showSettingsPage.value) return focusSearchInput("[data-settings-global-search]"); + if (showDriverStore.value) { + const selector = driverStoreActiveTab.value === "jdbc" ? "[data-driver-store-jdbc-search]" : "[data-driver-store-agent-search]"; + return focusSearchInput(selector); + } + if (showPluginCenter.value) return focusSearchInput("[data-plugin-marketplace-search]"); + + if (showAiPanel.value && target?.closest("[data-ai-assistant-root], [data-ai-conversation-search]")) { + if (aiAssistantRef.value) return aiAssistantRef.value.focusSearch(); + invokeWhenAiReady((handle) => handle.focusSearch()); + return true; + } + if (showHistory.value && target?.closest("[data-history-panel], [data-history-search]")) return focusSearchInput("[data-history-search]"); + if (showSqlLibraryPanel.value && target?.closest("[data-sql-library-panel], [data-sql-library-search]")) return focusSearchInput("[data-sql-library-search]"); + + if (lastFocusedAuxiliarySurface.value === "ai" && showAiPanel.value) { + if (aiAssistantRef.value) return aiAssistantRef.value.focusSearch(); + invokeWhenAiReady((handle) => handle.focusSearch()); + return true; + } + if (lastFocusedAuxiliarySurface.value === "history" && showHistory.value) return focusSearchInput("[data-history-search]"); + if (lastFocusedAuxiliarySurface.value === "sqlLibrary" && showSqlLibraryPanel.value) return focusSearchInput("[data-sql-library-search]"); + return false; +} + +function rememberAuxiliarySearchSurface(surface: Exclude) { + lastFocusedAuxiliarySurface.value = surface; +} + function setPluginWorkbenchTabRef(tabId: string, element: unknown) { if (element && typeof element === "object" && "refresh" in element && typeof element.refresh === "function") { pluginWorkbenchTabRefs.set(tabId, element as PluginWorkbenchTabHandle); @@ -3252,9 +3311,11 @@ async function handleKeydown(e: KeyboardEvent) { return; } if (isFocusSearchShortcut(e, shortcuts)) { - // Route by the event target so focus inside the shared result grid (or a - // Teleported cell-detail dialog) lands on the result search, not the editor. - const focused = contentAreaRef.value?.focusSearch(e.target instanceof Element ? e.target : null) || appSidebarRef.value?.focusSearch(); + // Keep the focused navigation surface ahead of the active content tab. + // Otherwise Ctrl+F from empty space in the sidebar incorrectly opens the + // search belonging to the active table/query tab. + const target = e.target instanceof Element ? e.target : null; + const focused = target?.closest("[data-app-sidebar]") ? appSidebarRef.value?.focusSearch(target) : focusSearchInAuxiliarySurface(target) || contentAreaRef.value?.focusSearch(target) || appSidebarRef.value?.focusSearch(target); if (focused) { e.preventDefault(); e.stopPropagation(); @@ -3547,6 +3608,7 @@ onMounted(async () => { void applyUiScale(settingsStore.editorSettings.uiScale); window.addEventListener("keydown", handleNativeSelectAll, true); window.addEventListener("keydown", handleTabSwitcherKeydownCapture, true); + window.addEventListener("keydown", handleAuxiliarySearchKeydownCapture, true); window.addEventListener("keydown", handleKeydown); window.addEventListener("keyup", handleKeyup, true); window.addEventListener("blur", handleTabSwitcherWindowBlur); @@ -3628,6 +3690,7 @@ onUnmounted(() => { } window.removeEventListener("keydown", handleNativeSelectAll, true); window.removeEventListener("keydown", handleTabSwitcherKeydownCapture, true); + window.removeEventListener("keydown", handleAuxiliarySearchKeydownCapture, true); window.removeEventListener("keydown", handleKeydown); window.removeEventListener("keyup", handleKeyup, true); window.removeEventListener("blur", handleTabSwitcherWindowBlur); @@ -3958,7 +4021,7 @@ onUnmounted(() => { :style="isAiPanelMaximized ? {} : { width: aiPanelWidth + 'px' }" >
-
+
{ :style="{ width: historyWidth + 'px' }" >
-
- +
+
+ +
@@ -3997,8 +4062,10 @@ onUnmounted(() => { :style="{ width: sqlLibraryWidth + 'px' }" >
-
- +
+
+ +
diff --git a/apps/desktop/src/__tests__/searchShortcutRouting.spec.ts b/apps/desktop/src/__tests__/searchShortcutRouting.spec.ts new file mode 100644 index 0000000000..9eb7eea834 --- /dev/null +++ b/apps/desktop/src/__tests__/searchShortcutRouting.spec.ts @@ -0,0 +1,56 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const appSource = readFileSync(new URL("../App.vue", import.meta.url), "utf8"); +const sidebarSource = readFileSync(new URL("../components/layout/AppSidebar.vue", import.meta.url), "utf8"); +const connectionTreeSource = readFileSync(new URL("../components/sidebar/ConnectionTree.vue", import.meta.url), "utf8"); +const treeItemSource = readFileSync(new URL("../components/sidebar/TreeItem.vue", import.meta.url), "utf8"); +const contentAreaSource = readFileSync(new URL("../components/layout/ContentArea.vue", import.meta.url), "utf8"); +const dataGridSource = readFileSync(new URL("../components/grid/DataGrid.vue", import.meta.url), "utf8"); +const objectBrowserSource = readFileSync(new URL("../components/objects/ObjectBrowser.vue", import.meta.url), "utf8"); +const aiAssistantSource = readFileSync(new URL("../components/editor/AiAssistant.vue", import.meta.url), "utf8"); +const settingsSource = readFileSync(new URL("../components/editor/EditorSettingsDialog.vue", import.meta.url), "utf8"); +const sqlLibrarySource = readFileSync(new URL("../components/layout/SqlLibraryPanel.vue", import.meta.url), "utf8"); +const historySource = readFileSync(new URL("../components/editor/QueryHistory.vue", import.meta.url), "utf8"); +const driverStoreSource = readFileSync(new URL("../components/config/DriverStoreDialog.vue", import.meta.url), "utf8"); +const pluginCenterSource = readFileSync(new URL("../components/plugins/PluginContributionsPanel.vue", import.meta.url), "utf8"); +const connectionDialogSource = readFileSync(new URL("../components/connection/ConnectionDialog.vue", import.meta.url), "utf8"); + +describe("search shortcut focus routing", () => { + it("prioritizes the sidebar when Ctrl+F originates in the navigation area", () => { + expect(appSource).toMatch(/const target = e\.target instanceof Element \? e\.target : null;[\s\S]*target\?\.closest\("\[data-app-sidebar\]"\)[\s\S]*appSidebarRef\.value\?\.focusSearch\(target\)/); + expect(sidebarSource).toContain("
{ + expect(connectionTreeSource).toMatch(/function focusSearch\(target: Element \| null = null\)[\s\S]*data-sidebar-table-search-control[\s\S]*data-sidebar-table-search-parent-id/); + expect(treeItemSource).toMatch(/function onTableSearchControlKeydown\(event: KeyboardEvent\)[\s\S]*isFocusSearchShortcut\(event, settingsStore\.editorSettings\.shortcuts\)[\s\S]*data-sidebar-table-search-parent-id/); + expect(treeItemSource).toContain("data-sidebar-table-search-control"); + }); + + it("routes a focused table properties panel to its local search input", () => { + expect(contentAreaSource).toMatch(/function focusSearch\(target: Element \| null = null\)[\s\S]*dataGridRef\.value\?\.focusSearch\(target\)/); + expect(dataGridSource).toMatch(/function focusSearch\(target: Element \| null = null\)[\s\S]*data-table-info-drawer[\s\S]*data-table-info-search/); + expect(dataGridSource).toMatch(/focusSearch\(event\.target instanceof Element \? event\.target : document\.activeElement instanceof Element \? document\.activeElement : null\);/); + expect(objectBrowserSource).toMatch(/function focusSearch\(target: Element \| null = null\)[\s\S]*data-object-table-info-panel[\s\S]*data-table-info-search/); + }); + + it("routes auxiliary page and panel searches before the query surface", () => { + expect(appSource).toMatch(/function focusSearchInAuxiliarySurface\(target: Element \| null\)[\s\S]*data-connection-db-search[\s\S]*data-settings-global-search[\s\S]*data-driver-store-agent-search[\s\S]*data-plugin-marketplace-search[\s\S]*data-history-panel[\s\S]*data-sql-library-panel/); + expect(appSource).toMatch(/lastFocusedAuxiliarySurface[\s\S]*rememberAuxiliarySearchSurface[\s\S]*data-sql-library-panel/); + expect(appSource).toMatch(/function handleAuxiliarySearchKeydownCapture\(e: KeyboardEvent\)[\s\S]*focusSearchInAuxiliarySurface\(target\)[\s\S]*addEventListener\("keydown", handleAuxiliarySearchKeydownCapture, true\)/); + expect(appSource).toContain("aiAssistantRef.value.focusSearch()"); + expect(aiAssistantSource).toMatch(/function focusSearch\(\): boolean[\s\S]*setConversationListOpen\(true\)/); + expect(aiAssistantSource).toContain("data-ai-conversation-search"); + expect(settingsSource).toContain("data-settings-global-search"); + expect(sqlLibrarySource).toContain("data-sql-library-search"); + expect(historySource).toContain("data-history-search"); + expect(driverStoreSource).toContain("data-driver-store-agent-search"); + expect(pluginCenterSource).toContain("data-plugin-marketplace-search"); + expect(connectionDialogSource).toContain("data-connection-db-search"); + }); + + it("keeps the search shortcut inside the modal connection dialog", () => { + expect(appSource).toMatch(/if \(showConnectionDialog\.value\) \{[\s\S]*?data-connection-db-search[\s\S]*?return true;\s*\}/); + }); +}); diff --git a/apps/desktop/src/components/config/DriverStoreDialog.vue b/apps/desktop/src/components/config/DriverStoreDialog.vue index 3362e25d2a..af8b30b5bb 100644 --- a/apps/desktop/src/components/config/DriverStoreDialog.vue +++ b/apps/desktop/src/components/config/DriverStoreDialog.vue @@ -1547,7 +1547,7 @@ watch(driverStoreTab, (tab) => {
- +
@@ -1972,7 +1972,7 @@ watch(driverStoreTab, (tab) => {
- +
diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index bf1b0c4d2b..1f9f813459 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -6274,7 +6274,7 @@ function openExternalUrl(url: string) {
diff --git a/apps/desktop/src/components/layout/AppSidebar.vue b/apps/desktop/src/components/layout/AppSidebar.vue index 0b65aa9602..5bf3083025 100644 --- a/apps/desktop/src/components/layout/AppSidebar.vue +++ b/apps/desktop/src/components/layout/AppSidebar.vue @@ -110,8 +110,8 @@ function collapseAllTreeNodes() { connectionTreeRef.value?.collapseAllTreeNodes(); } -function focusSearch(): boolean { - return connectionTreeRef.value?.focusSearch() ?? false; +function focusSearch(target: Element | null = null): boolean { + return connectionTreeRef.value?.focusSearch(target) ?? false; } function locateTabInSidebar(tab: QueryTab) { @@ -197,7 +197,7 @@ defineExpose({ focusSearch, locateTabInSidebar });