feat(ui): route Ctrl+F search by focused surface

This commit is contained in:
zipg authored and GitHub committed 2026-09-15 23:20:05 +08:00
1 parent 4c80d16e26
commit 3c8ae159fb
23 files changed
+233 -56

No files matched your search

+75 -8
View File
@@ -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<AiAssistantHandle | null>(null);
const appSidebarRef = ref<InstanceType<typeof AppSidebar> | null>(null);
const appTabBarRef = ref<InstanceType<typeof AppTabBar> | null>(null);
const contentAreaRef = ref<InstanceType<typeof SqlEditorWorkspace> | null>(null);
const lastFocusedAuxiliarySurface = ref<AuxiliarySearchSurface>(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<HTMLInputElement>(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<AuxiliarySearchSurface, null>) {
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' }"
>
<div v-if="!isAiPanelMaximized" class="panel-resize-handle panel-resize-handle--left" @pointerdown="startAiPanelResize" />
<div class="h-full min-h-0 overflow-hidden rounded-[inherit]">
<div class="h-full min-h-0 overflow-hidden rounded-[inherit]" @mousedown="rememberAuxiliarySearchSurface('ai')">
<AiAssistant
v-if="aiPanelReady"
ref="aiAssistantRef"
@@ -3985,8 +4048,10 @@ onUnmounted(() => {
:style="{ width: historyWidth + 'px' }"
>
<div class="panel-resize-handle panel-resize-handle--left" @pointerdown="startHistoryResize" />
<div class="h-full min-h-0 overflow-hidden rounded-[inherit]">
<QueryHistory :current-connection-id="activeTab?.connectionId" :current-database="activeTab?.database" @restore="restoreHistorySql" @analyze-ai="analyzeHistoryWithAi" @close="closeRightSidebarPanel('history')" />
<div class="h-full min-h-0 overflow-hidden rounded-[inherit]" @mousedown="rememberAuxiliarySearchSurface('history')">
<div data-history-panel class="h-full min-h-0">
<QueryHistory :current-connection-id="activeTab?.connectionId" :current-database="activeTab?.database" @restore="restoreHistorySql" @analyze-ai="analyzeHistoryWithAi" @close="closeRightSidebarPanel('history')" />
</div>
</div>
</div>
@@ -3997,8 +4062,10 @@ onUnmounted(() => {
:style="{ width: sqlLibraryWidth + 'px' }"
>
<div class="panel-resize-handle panel-resize-handle--left" @pointerdown="startSqlLibraryResize" />
<div class="h-full min-h-0 overflow-hidden rounded-[inherit]">
<SqlLibraryPanel @close="closeRightSidebarPanel('sqlLibrary')" />
<div class="h-full min-h-0 overflow-hidden rounded-[inherit]" @mousedown="rememberAuxiliarySearchSurface('sqlLibrary')">
<div data-sql-library-panel class="h-full min-h-0">
<SqlLibraryPanel @close="closeRightSidebarPanel('sqlLibrary')" />
</div>
</div>
</div>
@@ -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("<div data-app-sidebar");
});
it("routes a focused sidebar table-search control to its own input", () => {
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*\}/);
});
});
@@ -1547,7 +1547,7 @@ watch(driverStoreTab, (tab) => {
<!-- Driver List -->
<div class="relative">
<Search class="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input v-model="agentDriverSearch" class="h-8 pl-8 text-xs" :placeholder="t('driverStore.searchDrivers')" />
<Input data-driver-store-agent-search v-model="agentDriverSearch" class="h-8 pl-8 text-xs" :placeholder="t('driverStore.searchDrivers')" />
</div>
<!-- Global update section — always above category navigation -->
<div v-if="globalUpdatableDrivers.length > 0" class="rounded-lg border divide-y">
@@ -1972,7 +1972,7 @@ watch(driverStoreTab, (tab) => {
</div>
<div class="relative">
<Search class="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input v-model="jdbcDriverSearch" class="h-8 pl-8 text-xs" :placeholder="t('driverStore.searchJdbcDrivers')" />
<Input data-driver-store-jdbc-search v-model="jdbcDriverSearch" class="h-8 pl-8 text-xs" :placeholder="t('driverStore.searchJdbcDrivers')" />
</div>
<div class="flex items-center gap-2">
<Input v-model="jdbcDriverPathInput" class="flex-1" :placeholder="t('settings.jdbcDriverPathPlaceholder')" @keydown.enter.prevent="importJdbcDriverPathInput" />
@@ -6274,7 +6274,7 @@ function openExternalUrl(url: string) {
</div>
<div class="connection-db-picker-search relative w-full sm:w-64">
<Search class="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input v-model="dbSearchQuery" v-connection-dialog-auto-focus class="h-9 pl-8" :placeholder="t('connection.searchDatabasePlaceholder')" />
<Input data-connection-db-search v-model="dbSearchQuery" v-connection-dialog-auto-focus class="h-9 pl-8" :placeholder="t('connection.searchDatabasePlaceholder')" />
</div>
</div>
<Button data-jdbc-connection-entry type="button" variant="outline" class="h-9 shrink-0 gap-2" @click="goToConnectionStep('jdbc')">
@@ -4484,7 +4484,12 @@ function clearContextReferences() {
mentionError.value = "";
}
defineExpose({ triggerAction, setPrompt, addTableMention, clearContextReferences, selectConversationById });
function focusSearch(): boolean {
void setConversationListOpen(true);
return true;
}
defineExpose({ triggerAction, setPrompt, addTableMention, clearContextReferences, selectConversationById, focusSearch });
const messageRenderer = computed(() => {
const appearance = aiCodeAppearance.value;
@@ -4555,6 +4560,7 @@ async function openExternalUrl(url: string) {
<Search class="pointer-events-none absolute left-3 h-3 w-3 text-muted-foreground" />
<input
ref="conversationSearchInput"
data-ai-conversation-search
v-model="conversationSearchQuery"
type="search"
:aria-label="t('history.conversationSearch')"
@@ -5237,6 +5237,7 @@ onUnmounted(() => {
<div ref="settingsSearchInputContainerRef" class="relative">
<Search class="pointer-events-none absolute top-1/2 left-4 z-10 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
data-settings-global-search
v-model="settingsSearchQuery"
type="text"
autocomplete="off"
@@ -463,7 +463,7 @@ onBeforeUnmount(() => {
</div>
<div class="relative flex items-center px-2 py-1">
<Search class="absolute left-3 w-3 h-3 text-muted-foreground pointer-events-none" />
<input v-model="searchText" autocapitalize="off" autocorrect="off" spellcheck="false" class="flex-1 h-5 text-xs bg-transparent border rounded pl-5 pr-1 outline-none placeholder:text-muted-foreground" :placeholder="t('history.search')" />
<input data-history-search v-model="searchText" autocapitalize="off" autocorrect="off" spellcheck="false" class="flex-1 h-5 text-xs bg-transparent border rounded pl-5 pr-1 outline-none placeholder:text-muted-foreground" :placeholder="t('history.search')" />
<Popover :open="dateRangeOpen" @update:open="setDateRangeOpen">
<PopoverTrigger as-child>
<button
@@ -80,7 +80,7 @@ describe("QueryEditor search shortcuts", () => {
expect(queryEditorSource).toMatch(/\.\.\.binding\(shortcuts\.sendSelectionToAi[\s\S]*\.\.\.createQueryEditorSearchKeymap/);
expect(queryEditorSource).not.toMatch(/Prec\.highest\(\s*keymap\.of\(\[\s*\.\.\.createQueryEditorSearchKeymap/);
expect(queryEditorSource).toMatch(/function openReplace\(\): boolean \{\s*if \(props\.readOnly\) return false;/);
expect(contentAreaSource).toContain("if (props.resultOnly) return dataGridRef.value?.focusSearch() ?? false;");
expect(contentAreaSource).toContain("if (props.resultOnly) return dataGridRef.value?.focusSearch(target) ?? false;");
expect(contentAreaSource).toContain("return queryEditorRef.value?.openSearch() ?? false;");
expect(contentAreaSource).toContain("return queryEditorRef.value?.openReplace() ?? false;");
expect(queryEditorSource).toMatch(/key:\s*"Escape"/);
+13 -3
View File
@@ -1707,7 +1707,16 @@ function navigateSuggestion(delta: number) {
dataGridSearch.navigateSuggestion(delta);
}
function focusSearch(): boolean {
function focusSearch(target: Element | null = null): boolean {
const tableInfoDrawer = target?.closest<HTMLElement>("[data-table-info-drawer]");
if (tableInfoDrawer) {
const input = tableInfoDrawer.querySelector<HTMLInputElement>("[data-table-info-search]");
if (input) {
input.focus();
input.select();
return true;
}
}
searchOverlayVisible.value = true;
nextTick(() => {
searchBarRef.value?.focus(true);
@@ -8845,7 +8854,7 @@ async function onGridKeydown(event: KeyboardEvent) {
if (!targetAllowsNativeClipboard && handleGridPaginationShortcut(event)) return;
if (isFocusSearchShortcut(event) && !isGoToColumnShortcut(event, settingsStore.editorSettings.shortcuts)) {
event.preventDefault();
focusSearch();
focusSearch(event.target instanceof Element ? event.target : document.activeElement instanceof Element ? document.activeElement : null);
return;
}
if (isModRShortcut(event)) {
@@ -13079,6 +13088,7 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
<div
v-if="showTableInfo"
data-native-clipboard
data-table-info-drawer
class="table-info-drawer relative col-start-2 row-start-1 border-l flex flex-col bg-background min-w-0 max-w-full"
:class="[{ 'row-span-2': cellDetailPanelIsBottom }, { 'ddl-drawer-resizing': isResizingDdl }]"
:style="ddlDrawerStyle"
@@ -13151,7 +13161,7 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
<div class="flex min-w-0 items-center gap-1">
<div class="relative min-w-0 flex-1">
<Search class="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<input v-model="searchQuery" :placeholder="t('grid.tableInfoSearch')" class="w-full h-7 pl-7 pr-6 text-xs bg-muted/50 rounded border border-border focus:outline-none focus:border-primary/50" @keydown="onTableInfoSearchKeydown" />
<input v-model="searchQuery" data-table-info-search :placeholder="t('grid.tableInfoSearch')" class="w-full h-7 pl-7 pr-6 text-xs bg-muted/50 rounded border border-border focus:outline-none focus:border-primary/50" @keydown="onTableInfoSearchKeydown" />
<button v-if="searchQuery" type="button" class="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground" @click="searchQuery = ''">
<X class="w-3 h-3" />
</button>
@@ -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 });
</script>
<template>
<div class="app-sidebar-panel h-full shrink-0 relative select-none" :class="classicLayout ? '' : 'rounded-md border border-border/80 bg-background'" :style="{ width: sidebarWidth + 'px' }" @keydown="onSidebarKeydown">
<div data-app-sidebar class="app-sidebar-panel h-full shrink-0 relative select-none" :class="classicLayout ? '' : 'rounded-md border border-border/80 bg-background'" :style="{ width: sidebarWidth + 'px' }" @keydown="onSidebarKeydown">
<div class="h-full flex flex-col overflow-hidden">
<div class="app-sidebar-toolbar flex items-center gap-px px-3 text-xs font-medium text-muted-foreground border-b bg-muted/20" :class="classicLayout ? 'h-9' : 'h-10'">
<span v-if="showConnectionMultiSelectToolbar" class="flex min-w-0 self-stretch items-center" data-tauri-drag-region>
@@ -170,7 +170,6 @@ import type { DataGridSortMode } from "@/lib/dataGrid/dataGridSort";
import { isDataGridToolbarCompact, type DataGridReloadIntent } from "@/lib/dataGrid/dataGridToolbar";
import { useTabScroll } from "@/composables/useTabScroll";
import { useToolbarOverflow } from "@/composables/useToolbarOverflow";
import ToolbarOverflowMenu from "@/components/ui/ToolbarOverflowMenu.vue";
import { formatElapsedSeconds } from "@/lib/common/elapsedTime";
import { copyToClipboard } from "@/lib/common/clipboard";
import type { CustomSaveHandler } from "@/composables/useDataGridEditor";
@@ -183,7 +182,7 @@ import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAcc
type DataGridHandle = DataGridColumnLayoutHandle & {
onToolbarRefresh: () => Promise<void> | void;
focusSearch: () => boolean;
focusSearch: (target?: Element | null) => boolean;
openGoToColumn: () => boolean;
openCellDetailSearch: () => boolean;
nullColumnsHidden: boolean;
@@ -206,7 +205,7 @@ type DataGridHandle = DataGridColumnLayoutHandle & {
};
type SearchableBrowserHandle = {
focusSearch: () => boolean;
focusSearch: (target?: Element | null) => boolean;
refresh?: () => boolean;
insertCommand?: (command: string) => Promise<boolean>;
executeCommand?: (command: string) => Promise<boolean>;
@@ -304,8 +303,6 @@ const dataGridViewOptionsOpen = ref(false);
const dataToolbarRef = ref<HTMLElement | null>(null);
const { tier: dataToolbarTier } = useToolbarOverflow(dataToolbarRef, [() => props.activeTab.id, () => !!props.activeTab.result]);
const dataToolbarCompact = computed(() => dataToolbarTier.value >= 1);
const showDataToolbarOverflow = computed(() => dataToolbarTier.value >= 2);
const showDataTableInfoButton = computed(() => dataToolbarTier.value < 2);
const showDataColumnsChip = computed(() => dataToolbarTier.value < 2);
const dataGridRenderMode = computed(() => settingsStore.editorSettings.dataGridRenderMode);
const dataGridSearchMode = computed(() => settingsStore.editorSettings.dataGridSearchMode);
@@ -953,7 +950,7 @@ function onHandleCloseColumnPanel() {
columnInfoError.value = undefined;
}
function focusSearch(): boolean {
function focusSearch(target: Element | null = null): boolean {
if (elasticsearchJsonResponsePanelRef.value?.focusSearch()) return true;
if (props.activeTab.mode === "mongo") return documentBrowserRef.value?.focusSearch() ?? false;
if (props.activeTab.mode === "redis") return redisKeyBrowserRef.value?.focusSearch() ?? false;
@@ -961,15 +958,15 @@ function focusSearch(): boolean {
if (props.activeTab.mode === "zookeeper") return zookeeperKeyBrowserRef.value?.focusSearch() ?? false;
if (props.activeTab.mode === "consul") return consulWorkspaceRef.value?.focusSearch() ?? false;
if (props.activeTab.mode === "databases") return databaseBrowserRef.value?.focusSearch() ?? false;
if (props.activeTab.mode === "objects") return objectBrowserRef.value?.focusSearch() ?? false;
if (props.activeTab.mode === "objects") return objectBrowserRef.value?.focusSearch(target) ?? false;
if (props.activeTab.mode === "structure") return tableStructureEditorRef.value?.focusSearch() ?? false;
if (props.activeTab.mode === "query") {
// The shared result surface (resultOnly) owns the grid, not the editor;
// route its search to the DataGrid instead of the missing QueryEditor.
if (props.resultOnly) return dataGridRef.value?.focusSearch() ?? false;
if (props.resultOnly) return dataGridRef.value?.focusSearch(target) ?? false;
return queryEditorRef.value?.openSearch() ?? false;
}
return dataGridRef.value?.focusSearch() ?? false;
return dataGridRef.value?.focusSearch(target) ?? false;
}
function openGoToColumn(): boolean {
@@ -2174,14 +2171,7 @@ defineExpose({
<span v-if="showDataColumnsChip && activeDataTabTableMeta" class="inline-flex shrink-0 items-center rounded border border-border bg-muted/30 px-2 py-0.5 font-medium text-muted-foreground tabular-nums"> {{ activeDataTabTableMeta.columns.length }} {{ t("tree.columns") }} </span>
<span class="ml-auto" />
<DataGridColumnLayoutPopover v-if="activeTab.result?.columns.length" :grid="dataGridRef" trigger-class="px-1.5" />
<Button
v-if="showDataTableInfoButton && activeTab.result && activeDataTabTableMeta && activeTab.connectionId"
variant="ghost"
size="sm"
class="h-5 text-xs px-1.5 shrink-0"
:class="{ 'bg-accent': dataGridRef?.showDdl }"
:title="dataToolbarCompact ? t('grid.tableInfo') : undefined"
@click="dataGridRef?.toggleDdl()"
<Button v-if="activeTab.result && activeDataTabTableMeta && activeTab.connectionId" variant="ghost" size="sm" class="h-5 text-xs px-1.5 shrink-0" :class="{ 'bg-accent': dataGridRef?.showDdl }" :title="dataToolbarCompact ? t('grid.tableInfo') : undefined" @click="dataGridRef?.toggleDdl()"
><TableProperties class="h-3.5 w-3.5" /><span v-if="!dataToolbarCompact">{{ t("grid.tableInfo") }}</span></Button
>
<DropdownMenu v-if="activeTab.result && activeDataTabTableMeta && activeTab.connectionId">
@@ -2426,12 +2416,6 @@ defineExpose({
/>
</PopoverContent>
</Popover>
<ToolbarOverflowMenu v-if="showDataToolbarOverflow" :label="t('toolbar.moreActions')">
<DropdownMenuItem v-if="activeTab.result && activeDataTabTableMeta && activeTab.connectionId" @select="dataGridRef?.toggleDdl()">
<TableProperties class="h-3.5 w-3.5" />
{{ t("grid.tableInfo") }}
</DropdownMenuItem>
</ToolbarOverflowMenu>
</div>
<DataGrid
v-if="activeTab.result"
@@ -70,7 +70,7 @@ const surfaceBindings = computed(() => ({ ...surfaceProps.value, ...contentEmits
const activeSurfaceRef = ref<QueryEditorSurfaceHandle | null>(null);
defineExpose({
focusSearch: () => activeSurfaceRef.value?.focusSearch() ?? false,
focusSearch: (target: Element | null = null) => activeSurfaceRef.value?.focusSearch(target) ?? false,
openGoToColumn: () => activeSurfaceRef.value?.openGoToColumn() ?? false,
refreshData: () => activeSurfaceRef.value?.refreshData() ?? false,
toggleResultsPane: () => activeSurfaceRef.value?.toggleResultsPane() ?? false,
@@ -22,7 +22,7 @@ const bindings = computed(() => ({ ...surfaceProps.value, ...contentEmits }));
const contentAreaRef = ref<InstanceType<typeof ContentArea> | null>(null);
defineExpose<QueryEditorSurfaceHandle>({
focusSearch: () => contentAreaRef.value?.focusSearch() ?? false,
focusSearch: (target: Element | null = null) => contentAreaRef.value?.focusSearch(target) ?? false,
openGoToColumn: () => contentAreaRef.value?.openGoToColumn() ?? false,
refreshData: () => contentAreaRef.value?.refreshData() ?? false,
toggleResultsPane: () => contentAreaRef.value?.toggleResultsPane() ?? false,
@@ -27,7 +27,7 @@ const bindings = computed(() => ({ ...surfaceProps.value, ...contentEmits }));
const contentAreaRef = ref<InstanceType<typeof ContentArea> | null>(null);
defineExpose<QueryResultSurfaceHandle>({
focusSearch: () => contentAreaRef.value?.focusSearch() ?? false,
focusSearch: (target: Element | null = null) => contentAreaRef.value?.focusSearch(target) ?? false,
refreshData: () => contentAreaRef.value?.refreshData() ?? false,
toggleResultsPane: () => contentAreaRef.value?.toggleResultsPane() ?? false,
handleModRTarget: (target: Element) => contentAreaRef.value?.handleModRTarget(target) ?? false,
@@ -66,10 +66,10 @@ defineExpose({
// A data-mode cell-detail dialog is portaled to body too but belongs to
// the group's grid — indistinguishable in the DOM, hence the gate.
if (element?.closest("[data-shared-result-surface]") || (showSharedResult.value && element?.closest("[data-cell-detail-editor-root]"))) {
return resultSurfaceRef.value?.focusSearch() ?? false;
return resultSurfaceRef.value?.focusSearch(element) ?? false;
}
const group = groupForElement(element) ?? activeEditorGroup();
return group?.focusSearch() ?? false;
return group?.focusSearch(element) ?? false;
},
openGoToColumn: () => activeEditorGroup()?.openGoToColumn() ?? false,
refreshData: (target: Element | null = null) => {
@@ -1199,7 +1199,7 @@ function showDropInside(targetId: string) {
<div class="border-b shrink-0 px-2 py-1">
<div class="relative">
<Search class="absolute left-2 top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground" />
<input v-model="searchText" autocapitalize="off" autocorrect="off" spellcheck="false" class="w-full h-6 pl-7 pr-6 text-[13px] rounded border border-border bg-background focus:outline-none focus:ring-1 focus:ring-ring" :placeholder="t('grid.search')" />
<input data-sql-library-search v-model="searchText" autocapitalize="off" autocorrect="off" spellcheck="false" class="w-full h-6 pl-7 pr-6 text-[13px] rounded border border-border bg-background focus:outline-none focus:ring-1 focus:ring-ring" :placeholder="t('grid.search')" />
<button v-if="searchText" type="button" class="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground" @click="searchText = ''">
<X class="h-3 w-3" />
</button>
@@ -0,0 +1,12 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const contentAreaSource = readFileSync(new URL("../ContentArea.vue", import.meta.url), "utf8");
describe("ContentArea data toolbar", () => {
it("keeps table properties in the primary toolbar at every overflow tier", () => {
expect(contentAreaSource).toContain('v-if="activeTab.result && activeDataTabTableMeta && activeTab.connectionId"');
expect(contentAreaSource).not.toContain("showDataTableInfoButton");
expect(contentAreaSource).not.toContain("ToolbarOverflowMenu");
});
});
@@ -3047,7 +3047,16 @@ function getSearchInput(): HTMLInputElement | null {
return rootRef.value?.querySelector<HTMLInputElement>("[data-object-search-input]") ?? null;
}
function focusSearch(): boolean {
function focusSearch(target: Element | null = null): boolean {
const tableInfoPanel = target?.closest<HTMLElement>("[data-object-table-info-panel]");
if (tableInfoPanel) {
const input = tableInfoPanel.querySelector<HTMLInputElement>("[data-table-info-search]");
if (input) {
input.focus();
input.select();
return true;
}
}
const input = getSearchInput();
if (!input) return false;
input.focus();
@@ -3694,7 +3703,13 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
</div>
</div>
<!-- Right-side panel: table info or source -->
<div v-if="sidePanelRow || isEventEditor" class="object-browser-side-panel relative flex min-h-0 min-w-0 shrink-0 flex-col border-l bg-background" :class="{ 'side-panel-resizing': isResizingSidePanel }" :style="{ width: `min(${sidePanelWidth}px, 100%)` }">
<div
v-if="sidePanelRow || isEventEditor"
:data-object-table-info-panel="sidePanelMode === 'table-info' ? '' : undefined"
class="object-browser-side-panel relative flex min-h-0 min-w-0 shrink-0 flex-col border-l bg-background"
:class="{ 'side-panel-resizing': isResizingSidePanel }"
:style="{ width: `min(${sidePanelWidth}px, 100%)` }"
>
<div class="absolute left-0 top-0 bottom-0 z-20 w-1.5 -translate-x-1/2 cursor-col-resize hover:bg-primary/30" @mousedown.prevent="onSidePanelResizeStart" />
<!-- Table info mode -->
<template v-if="sidePanelMode === 'table-info'">
@@ -3735,7 +3750,7 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
<div class="flex items-center gap-1 px-2 py-1.5 border-b shrink-0 bg-background">
<div class="relative min-w-0 flex-1">
<Search class="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<input v-model="tableInfoSearchQuery" :placeholder="t('grid.tableInfoSearch')" class="w-full h-7 pl-7 pr-6 text-xs bg-muted/50 rounded border border-border focus:outline-none focus:border-primary/50" @keydown.escape="tableInfoSearchQuery = ''" />
<input v-model="tableInfoSearchQuery" data-table-info-search :placeholder="t('grid.tableInfoSearch')" class="w-full h-7 pl-7 pr-6 text-xs bg-muted/50 rounded border border-border focus:outline-none focus:border-primary/50" @keydown.escape="tableInfoSearchQuery = ''" />
<button v-if="tableInfoSearchQuery" class="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground" @click="tableInfoSearchQuery = ''">
<X class="w-3 h-3" />
</button>
@@ -617,7 +617,7 @@ onBeforeUnmount(() => {
<div class="flex w-full flex-col gap-2 rounded-xl border bg-card/70 p-3 sm:flex-row sm:items-center">
<div class="relative">
<Search class="pointer-events-none absolute left-2.5 top-2.5 size-3.5 text-muted-foreground" />
<Input v-model="marketplaceQuery" class="h-8 min-w-0 pl-8 text-xs sm:w-[min(100%,28rem)]" :placeholder="t('pluginPlatform.searchMarketplace')" />
<Input data-plugin-marketplace-search v-model="marketplaceQuery" class="h-8 min-w-0 pl-8 text-xs sm:w-[min(100%,28rem)]" :placeholder="t('pluginPlatform.searchMarketplace')" />
</div>
<div class="flex min-w-0 items-center gap-2 sm:ml-auto">
<Select v-model="marketplaceRepositoryId">
@@ -2292,7 +2292,16 @@ watch(
{ flush: "post" },
);
function focusSearch(): boolean {
function focusSearch(target: Element | null = null): boolean {
const tableSearchControl = target?.closest<HTMLElement>("[data-sidebar-table-search-control]");
if (tableSearchControl) {
const input = tableSearchControl.querySelector<HTMLInputElement>("[data-sidebar-table-search-parent-id]");
if (input) {
input.focus();
input.select();
return true;
}
}
const input = searchInputRef.value;
if (!input) return false;
input.focus();
@@ -73,6 +73,7 @@ import { formatSidebarObjectStorage } from "@/lib/sidebar/sidebarDatabaseStorage
import { dataTabOpenModeFromTreeClick } from "@/lib/sidebar/dataTabOpenPolicy";
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import { connectionDisplayUrlScheme } from "@/lib/connection/connectionPresentation";
import { isFocusSearchShortcut } from "@/lib/editor/keyboardShortcuts";
import { encodeSpannerResourcePath } from "@/lib/connection/spannerResourcePath";
import { hexToRgba } from "@/lib/common/color";
import { sidebarDisplayTableName } from "@/lib/sidebar/sidebarTableNameDisplay";
@@ -1041,6 +1042,17 @@ function clearTableSearchQuery() {
updateTableSearchQuery("");
}
function onTableSearchControlKeydown(event: KeyboardEvent) {
if (isFocusSearchShortcut(event, settingsStore.editorSettings.shortcuts)) {
event.preventDefault();
const control = event.currentTarget instanceof HTMLElement ? event.currentTarget : null;
const input = control?.querySelector<HTMLInputElement>("[data-sidebar-table-search-parent-id]");
input?.focus();
input?.select();
}
event.stopPropagation();
}
// --- Connection Group Management ---
const isRenamingGroup = ref(false);
@@ -1504,7 +1516,7 @@ function onKeydown(event: KeyboardEvent) {
</script>
<template>
<div v-if="node.type === 'table-search-control'" class="tree-table-search-control flex h-7 items-center gap-1.5 py-0.5 pr-2" :style="tableSearchStyle" @click.stop @dblclick.stop @mousedown.stop @keydown.stop>
<div v-if="node.type === 'table-search-control'" data-sidebar-table-search-control class="tree-table-search-control flex h-7 items-center gap-1.5 py-0.5 pr-2" :style="tableSearchStyle" @click.stop @dblclick.stop @mousedown.stop @keydown="onTableSearchControlKeydown">
<div class="relative min-w-0 flex-1">
<Search class="pointer-events-none absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" />
<Input
@@ -30,6 +30,10 @@ describe("resolveSqlShortcutTemplate", () => {
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("enabledSqlShortcutActions", () => {
it("filters disabled and unbound actions", () => {
const actions = [action("a", "Mod+1"), action("b", "", { enabled: true }), action("c", "Mod+2", { enabled: false })];
@@ -104,6 +108,7 @@ describe("normalizeSqlShortcuts", () => {
});
it("keeps Ctrl+H on non-mac platforms where it is a legitimate SQL shortcut", async () => {
vi.stubGlobal("navigator", { platform: "Win32" });
const { normalizeEditorSettings } = await import("@/stores/settingsStore");
const normalized = normalizeEditorSettings({
sqlShortcuts: [action("h", "Mod+H")],