mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-31 08:23:34 +00:00
feat(shortcuts): add search focus shortcut
This commit is contained in:
+13
-1
@@ -34,7 +34,7 @@ import * as api from "@/lib/api";
|
||||
import { resolveDefaultDatabase } from "@/lib/defaultDatabase";
|
||||
import { resolveExecutableSql } from "@/lib/sqlExecutionTarget";
|
||||
import { isTauriRuntime } from "@/lib/tauriRuntime";
|
||||
import { isCloseTabShortcut, isExecuteSqlShortcut } from "@/lib/keyboardShortcuts";
|
||||
import { isCloseTabShortcut, isExecuteSqlShortcut, isFocusSearchShortcut } from "@/lib/keyboardShortcuts";
|
||||
import { isPreviewTab } from "@/lib/tabPresentation";
|
||||
import { SQL_FILE_UNSUPPORTED_TYPES } from "@/lib/databaseCapabilities";
|
||||
import { classifyAiSqlExecution } from "@/lib/aiSqlExecutionPolicy";
|
||||
@@ -80,6 +80,8 @@ const showAiPanel = ref(localStorage.getItem("dbx-ai-panel-open") !== "false");
|
||||
const { sidebarWidth, aiPanelWidth, historyWidth, startSidebarResize, startAiPanelResize, startHistoryResize } =
|
||||
usePanelResize();
|
||||
const aiAssistantRef = ref<InstanceType<typeof AiAssistant> | null>(null);
|
||||
const appSidebarRef = ref<InstanceType<typeof AppSidebar> | null>(null);
|
||||
const contentAreaRef = ref<InstanceType<typeof ContentArea> | null>(null);
|
||||
|
||||
const selectedSql = ref("");
|
||||
const cursorPos = ref(0);
|
||||
@@ -409,6 +411,14 @@ function onAiRequestAutoExecuteSql(sql: string) {
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (isFocusSearchShortcut(e)) {
|
||||
const focused = contentAreaRef.value?.focusSearch() || appSidebarRef.value?.focusSearch();
|
||||
if (focused) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (isCloseTabShortcut(e)) {
|
||||
e.preventDefault();
|
||||
if (showDriverStore.value) {
|
||||
@@ -574,6 +584,7 @@ onUnmounted(() => {
|
||||
"
|
||||
>
|
||||
<AppSidebar
|
||||
ref="appSidebarRef"
|
||||
:sidebar-width="sidebarWidth"
|
||||
:classic-layout="isClassicLayout"
|
||||
@import="dialogs.onImportClick"
|
||||
@@ -614,6 +625,7 @@ onUnmounted(() => {
|
||||
@clear-default-database="clearActiveDefaultDatabase"
|
||||
/>
|
||||
<ContentArea
|
||||
ref="contentAreaRef"
|
||||
:active-tab="activeTab"
|
||||
:active-connection="activeConnection"
|
||||
:executable-sql="executableSql"
|
||||
|
||||
@@ -72,6 +72,7 @@ import { isHiddenGridColumn, usesSyntheticRowIdKey } from "@/lib/tableEditing";
|
||||
import { formatGridSqlLiteral } from "@/lib/dataGridSql";
|
||||
import { matchesRowStatusFilter, type RowStatus, type RowStatusFilter } from "@/lib/gridRowStatus";
|
||||
import { displayCellValue, type CellValue } from "@/lib/cellValue";
|
||||
import { isCancelSearchShortcut } from "@/lib/keyboardShortcuts";
|
||||
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { useDataGridExport } from "@/composables/useDataGridExport";
|
||||
@@ -460,6 +461,15 @@ function navigateSuggestion(delta: number) {
|
||||
suggestionIndex.value = Math.min(Math.max(suggestionIndex.value + delta, 0), searchSuggestions.value.length - 1);
|
||||
}
|
||||
|
||||
function focusSearch(): boolean {
|
||||
const input = searchInputRef.value;
|
||||
if (!input) return false;
|
||||
input.focus();
|
||||
input.select();
|
||||
updateSuggestionPosition();
|
||||
return true;
|
||||
}
|
||||
|
||||
const PAIRS: Record<string, string> = { "'": "'", '"': '"', "(": ")" };
|
||||
|
||||
function onSearchKeydown(e: KeyboardEvent) {
|
||||
@@ -503,7 +513,7 @@ function onSearchKeydown(e: KeyboardEvent) {
|
||||
acceptSuggestion();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
if (isCancelSearchShortcut(e)) {
|
||||
e.preventDefault();
|
||||
dismissSuggestions();
|
||||
return;
|
||||
@@ -519,7 +529,8 @@ function onSearchKeydown(e: KeyboardEvent) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
if (isCancelSearchShortcut(e)) {
|
||||
e.preventDefault();
|
||||
searchText.value = "";
|
||||
}
|
||||
}
|
||||
@@ -1742,6 +1753,7 @@ defineExpose({
|
||||
onToolbarRollback,
|
||||
showDdl,
|
||||
toggleDdl,
|
||||
focusSearch,
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Upload, Download, RefreshCw } from "lucide-vue-next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -27,6 +28,7 @@ const emit = defineEmits<{
|
||||
const { t } = useI18n();
|
||||
const connectionStore = useConnectionStore();
|
||||
const { toast } = useToast();
|
||||
const connectionTreeRef = ref<InstanceType<typeof ConnectionTree>>();
|
||||
|
||||
async function refreshTree() {
|
||||
try {
|
||||
@@ -35,6 +37,12 @@ async function refreshTree() {
|
||||
toast(t("connection.connectFailed", { message: e?.message || String(e) }), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function focusSearch(): boolean {
|
||||
return connectionTreeRef.value?.focusSearch() ?? false;
|
||||
}
|
||||
|
||||
defineExpose({ focusSearch });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -86,7 +94,7 @@ async function refreshTree() {
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div class="flex-1 min-h-0">
|
||||
<ConnectionTree />
|
||||
<ConnectionTree ref="connectionTreeRef" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-resize-handle panel-resize-handle--right" @mousedown="emit('startResize', $event)" />
|
||||
|
||||
@@ -58,6 +58,8 @@ const columnInfoColumns = ref<ColumnInfo[]>([]);
|
||||
const columnInfoLoading = ref(false);
|
||||
const columnInfoError = ref<string | undefined>(undefined);
|
||||
const dataGridRef = ref<InstanceType<typeof DataGrid>>();
|
||||
const redisKeyBrowserRef = ref<InstanceType<typeof RedisKeyBrowser>>();
|
||||
const objectBrowserRef = ref<InstanceType<typeof ObjectBrowser>>();
|
||||
|
||||
const activeSqlFormatDialect = computed<SqlFormatDialect>(() => {
|
||||
switch (props.activeConnection?.db_type) {
|
||||
@@ -160,6 +162,15 @@ function onHandleCloseColumnPanel() {
|
||||
columnInfoColumns.value = [];
|
||||
columnInfoError.value = undefined;
|
||||
}
|
||||
|
||||
function focusSearch(): boolean {
|
||||
if (props.activeTab.mode === "redis") return redisKeyBrowserRef.value?.focusSearch() ?? false;
|
||||
if (props.activeTab.mode === "objects") return objectBrowserRef.value?.focusSearch() ?? false;
|
||||
if (props.activeTab.mode === "query" && props.activeOutputView !== "result") return false;
|
||||
return dataGridRef.value?.focusSearch() ?? false;
|
||||
}
|
||||
|
||||
defineExpose({ focusSearch });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -433,7 +444,12 @@ function onHandleCloseColumnPanel() {
|
||||
<!-- Redis mode: key browser -->
|
||||
<template v-else-if="activeTab.mode === 'redis'">
|
||||
<div class="flex-1 min-h-0">
|
||||
<RedisKeyBrowser :key="activeTab.id" :connection-id="activeTab.connectionId" :db="Number(activeTab.database)" />
|
||||
<RedisKeyBrowser
|
||||
ref="redisKeyBrowserRef"
|
||||
:key="activeTab.id"
|
||||
:connection-id="activeTab.connectionId"
|
||||
:db="Number(activeTab.database)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -452,6 +468,7 @@ function onHandleCloseColumnPanel() {
|
||||
<!-- Objects mode: virtualized database object browser -->
|
||||
<template v-else-if="activeTab.mode === 'objects' && activeConnection">
|
||||
<ObjectBrowser
|
||||
ref="objectBrowserRef"
|
||||
:key="`${activeTab.id}-${activeTab.objectBrowser?.schema || ''}`"
|
||||
:connection="activeConnection"
|
||||
:database="activeTab.database"
|
||||
|
||||
@@ -37,6 +37,7 @@ import { buildExecutableObjectSourceSql, objectSourceSaveExecutionMode } from "@
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
import QueryEditor from "@/components/editor/QueryEditor.vue";
|
||||
import type { SqlFormatDialect } from "@/lib/sqlFormatter";
|
||||
import { isCancelSearchShortcut } from "@/lib/keyboardShortcuts";
|
||||
|
||||
type ObjectRow = {
|
||||
id: string;
|
||||
@@ -66,6 +67,7 @@ const queryStore = useQueryStore();
|
||||
const schemas = ref<string[]>([]);
|
||||
const selectedSchema = ref<string | undefined>(props.schema);
|
||||
const rows = ref<ObjectRow[]>([]);
|
||||
const rootRef = ref<HTMLElement>();
|
||||
const search = ref("");
|
||||
const objectFilter = ref<ObjectFilter>("all");
|
||||
const loadingSchemas = ref(false);
|
||||
@@ -411,6 +413,26 @@ function filterLabel(filter: ObjectFilter) {
|
||||
return `${t(key)} ${filterCount(filter)}`;
|
||||
}
|
||||
|
||||
function getSearchInput(): HTMLInputElement | null {
|
||||
return rootRef.value?.querySelector<HTMLInputElement>("[data-object-search-input]") ?? null;
|
||||
}
|
||||
|
||||
function focusSearch(): boolean {
|
||||
const input = getSearchInput();
|
||||
if (!input) return false;
|
||||
input.focus();
|
||||
input.select();
|
||||
return true;
|
||||
}
|
||||
|
||||
function onSearchKeydown(event: KeyboardEvent) {
|
||||
if (!isCancelSearchShortcut(event)) return;
|
||||
event.preventDefault();
|
||||
search.value = "";
|
||||
}
|
||||
|
||||
defineExpose({ focusSearch });
|
||||
|
||||
watch(
|
||||
() => [props.connection.id, props.database, props.schema] as const,
|
||||
() => {
|
||||
@@ -422,7 +444,7 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full min-h-0 flex-col bg-background">
|
||||
<div ref="rootRef" class="flex h-full min-h-0 flex-col bg-background">
|
||||
<div class="flex h-10 shrink-0 items-center gap-2 border-b px-3">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<Table2 class="h-4 w-4 text-muted-foreground" />
|
||||
@@ -435,7 +457,13 @@ watch(
|
||||
</div>
|
||||
<div class="flex min-w-[240px] flex-1 items-center gap-2">
|
||||
<Search class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<Input v-model="search" class="h-7 text-xs" :placeholder="t('objects.search')" />
|
||||
<Input
|
||||
v-model="search"
|
||||
data-object-search-input
|
||||
class="h-7 text-xs"
|
||||
:placeholder="t('objects.search')"
|
||||
@keydown="onSearchKeydown"
|
||||
/>
|
||||
<div v-if="showObjectFilter" class="flex h-7 shrink-0 items-center rounded border bg-muted/20 p-0.5">
|
||||
<button
|
||||
v-for="filter in objectFilters"
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
type RedisKeyTreeNode,
|
||||
} from "@/lib/redisKeyTree";
|
||||
import { classifyRedisCommandSafety } from "@/lib/redisCommandSafety";
|
||||
import { isCancelSearchShortcut } from "@/lib/keyboardShortcuts";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -45,6 +46,7 @@ const flatKeys = ref<RedisKeyInfo[]>([]);
|
||||
const treeKeys = ref<RedisKeyTreeNode[]>([]);
|
||||
const loading = ref(false);
|
||||
const loadingMore = ref(false);
|
||||
const rootRef = ref<HTMLElement>();
|
||||
const searchPattern = ref("*");
|
||||
const selectedKeyRaw = ref<string | null>(null);
|
||||
const hasMore = ref(false);
|
||||
@@ -317,27 +319,53 @@ function onSearchInput() {
|
||||
searchTimer = setTimeout(loadKeys, 400);
|
||||
}
|
||||
|
||||
function getSearchInput(): HTMLInputElement | null {
|
||||
return rootRef.value?.querySelector<HTMLInputElement>("[data-redis-search-input]") ?? null;
|
||||
}
|
||||
|
||||
function focusSearch(): boolean {
|
||||
const input = getSearchInput();
|
||||
if (!input) return false;
|
||||
input.focus();
|
||||
input.select();
|
||||
return true;
|
||||
}
|
||||
|
||||
function onSearchKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Enter") {
|
||||
void loadKeys();
|
||||
return;
|
||||
}
|
||||
if (!isCancelSearchShortcut(event)) return;
|
||||
event.preventDefault();
|
||||
searchPattern.value = "*";
|
||||
void loadKeys();
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (searchTimer) clearTimeout(searchTimer);
|
||||
});
|
||||
|
||||
onMounted(loadKeys);
|
||||
|
||||
defineExpose({ focusSearch });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Splitpanes class="h-full" horizontal>
|
||||
<!-- Key table (top) -->
|
||||
<Pane :size="selectedKey ? 50 : 100" :min-size="25">
|
||||
<div class="h-full flex flex-col overflow-hidden">
|
||||
<div ref="rootRef" class="h-full flex flex-col overflow-hidden">
|
||||
<!-- Toolbar -->
|
||||
<div class="h-9 flex items-center gap-1 px-2 border-b shrink-0">
|
||||
<Search class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||
<Input
|
||||
v-model="searchPattern"
|
||||
data-redis-search-input
|
||||
class="h-6 text-xs border-0 shadow-none focus-visible:ring-0"
|
||||
:placeholder="t('redis.pattern')"
|
||||
@input="onSearchInput"
|
||||
@keydown.enter="loadKeys"
|
||||
@keydown="onSearchKeydown"
|
||||
/>
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6 shrink-0" @click="loadKeys">
|
||||
<Loader2 v-if="loading" class="h-3 w-3 animate-spin" />
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Search, X, ListFilter, Check, FolderPlus } from "lucide-vue-next";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import type { TreeNode } from "@/types/database";
|
||||
import { matchSidebarLabel } from "@/lib/sidebarSearch";
|
||||
import { isCancelSearchShortcut } from "@/lib/keyboardShortcuts";
|
||||
import {
|
||||
SIDEBAR_TREE_ROW_HEIGHT,
|
||||
SIDEBAR_TREE_PRERENDER_COUNT,
|
||||
@@ -30,6 +31,7 @@ const { t } = useI18n();
|
||||
const store = useConnectionStore();
|
||||
const searchQuery = ref("");
|
||||
const deferredSearchQuery = ref("");
|
||||
const searchInputRef = ref<HTMLInputElement>();
|
||||
const selectedTypes = ref<string[]>([]);
|
||||
const searchCollapsedIds = ref<Set<string>>(new Set());
|
||||
let searchTimer: number | undefined;
|
||||
@@ -184,6 +186,22 @@ function onSearchToggle(node: TreeNode) {
|
||||
else next.delete(node.id);
|
||||
searchCollapsedIds.value = next;
|
||||
}
|
||||
|
||||
function focusSearch(): boolean {
|
||||
const input = searchInputRef.value;
|
||||
if (!input) return false;
|
||||
input.focus();
|
||||
input.select();
|
||||
return true;
|
||||
}
|
||||
|
||||
function onSearchKeydown(event: KeyboardEvent) {
|
||||
if (!isCancelSearchShortcut(event)) return;
|
||||
event.preventDefault();
|
||||
searchQuery.value = "";
|
||||
}
|
||||
|
||||
defineExpose({ focusSearch });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -193,12 +211,14 @@ function onSearchToggle(node: TreeNode) {
|
||||
<div class="relative flex-1">
|
||||
<Search class="absolute left-2 top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground" />
|
||||
<input
|
||||
ref="searchInputRef"
|
||||
v-model="searchQuery"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
class="w-full h-6 pl-7 pr-6 text-xs rounded border border-border bg-background focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
:placeholder="t('grid.search')"
|
||||
@keydown="onSearchKeydown"
|
||||
/>
|
||||
<button
|
||||
v-if="searchQuery"
|
||||
|
||||
@@ -18,3 +18,15 @@ export function isCloseTabShortcut(event: ShortcutLikeEvent): boolean {
|
||||
if (!event.metaKey) return false;
|
||||
return event.key.toLowerCase() === "w";
|
||||
}
|
||||
|
||||
export function isFocusSearchShortcut(event: ShortcutLikeEvent): boolean {
|
||||
if (event.isComposing) return false;
|
||||
if (event.altKey) return false;
|
||||
if (!event.metaKey && !event.ctrlKey) return false;
|
||||
return event.key.toLowerCase() === "f";
|
||||
}
|
||||
|
||||
export function isCancelSearchShortcut(event: ShortcutLikeEvent): boolean {
|
||||
if (event.isComposing) return false;
|
||||
return event.key === "Escape";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user