feat(search): add global file content search in QuickOpen

This commit is contained in:
gggaiitx authored and GitHub committed 2026-09-19 00:21:27 +08:00
1 parent 5278594ed2
commit 04c6e67bbd
31 files changed
+1509 -16

No files matched your search

+35 -1
View File
@@ -95,6 +95,7 @@ import {
isObjectSourceSaveShortcutTarget,
isOpenSettingsShortcut,
isQuickOpenShortcut,
isGlobalSearchShortcut,
isResetZoomShortcut,
isRefreshDataShortcut,
isSaveShortcut,
@@ -349,6 +350,7 @@ const showDriverStore = computed(() => driverStoreTabOpen.value && driverStoreAc
const showPluginCenter = computed(() => pluginCenterTabOpen.value && pluginCenterActive.value);
const showSettingsPage = computed(() => Boolean(settingsPageTabOpen.value && settingsStore.settingsPageActive));
const showQuickOpen = ref(false);
const quickOpenForceContent = ref(false);
const showTabSwitcher = ref(false);
const tabSwitcherIndex = ref(0);
const agentDriverUpdateCount = ref(0);
@@ -3034,6 +3036,20 @@ async function handleQuickOpenSelect(item: any) {
const queryStore = useQueryStore();
// Handle SQL file types first — they don't require a database connection
if (item.type === "content_match" && item.filePath) {
try {
const snapshot = await api.readExternalSqlFileSnapshot(item.filePath, externalSqlEditorMaxBytes(settingsStore.editorSettings.externalSqlEditorMaxMb));
const target = resolveExternalSqlFileTarget(item.filePath, (savedConnectionId) => !!connectionStore.getConfig(savedConnectionId), unassociatedExternalSqlFileTarget());
queryStore.openExternalSqlFile(target.connectionId, target.database, item.filePath, snapshot.content, snapshot.version, target.catalog, target.schema, { line: item.line ?? 1, column: item.column });
} catch (e: any) {
toast(
externalSqlFileOpenErrorMessage(e, (key, params) => t(key, params)),
5000,
);
}
return;
}
if (item.type === "sql_file" && item.filePath) {
try {
const snapshot = await api.readExternalSqlFileSnapshot(item.filePath, externalSqlEditorMaxBytes(settingsStore.editorSettings.externalSqlEditorMaxMb));
@@ -3275,6 +3291,14 @@ function handleTabSwitcherKeydownCapture(e: KeyboardEvent) {
tabSwitcherKeyboard.handleKeydownCapture(e);
}
function handleGlobalSearchKeydownCapture(e: KeyboardEvent) {
if (e.defaultPrevented || !isGlobalSearchShortcut(e, settingsStore.editorSettings.shortcuts)) return;
e.preventDefault();
e.stopPropagation();
quickOpenForceContent.value = true;
showQuickOpen.value = true;
}
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;
@@ -3441,6 +3465,14 @@ async function handleKeydown(e: KeyboardEvent) {
if (isQuickOpenShortcut(e, shortcuts)) {
e.preventDefault();
e.stopPropagation();
quickOpenForceContent.value = false;
showQuickOpen.value = true;
return;
}
if (isGlobalSearchShortcut(e, shortcuts)) {
e.preventDefault();
e.stopPropagation();
quickOpenForceContent.value = true;
showQuickOpen.value = true;
return;
}
@@ -3736,6 +3768,7 @@ onMounted(async () => {
applyTheme();
void applyUiScale(settingsStore.editorSettings.uiScale);
window.addEventListener("keydown", handleNativeSelectAll, true);
window.addEventListener("keydown", handleGlobalSearchKeydownCapture, true);
window.addEventListener("keydown", handleTabSwitcherKeydownCapture, true);
window.addEventListener("keydown", handleAuxiliarySearchKeydownCapture, true);
window.addEventListener("keydown", handleKeydown);
@@ -3819,6 +3852,7 @@ onUnmounted(() => {
clearInterval(updateCheckTimer);
}
window.removeEventListener("keydown", handleNativeSelectAll, true);
window.removeEventListener("keydown", handleGlobalSearchKeydownCapture, true);
window.removeEventListener("keydown", handleTabSwitcherKeydownCapture, true);
window.removeEventListener("keydown", handleAuxiliarySearchKeydownCapture, true);
window.removeEventListener("keydown", handleKeydown);
@@ -4334,7 +4368,7 @@ onUnmounted(() => {
<ExternalSqlFileChangeDialog :prompt="externalSqlFilePrompt" @decide="externalSqlFileChanges.resolvePrompt" />
<CloseActionPromptDialog v-if="isDesktop && showCloseActionPrompt" :open="showCloseActionPrompt" @update:open="handleCloseActionPromptOpenChange" @quit="chooseQuit" @minimize="chooseMinimize" />
<AiRunsClosePromptDialog v-if="isDesktop && showAiRunsClosePrompt" v-model:open="showAiRunsClosePrompt" :count="blockingAiRunCount" @cancel="cancelPendingAppClose" @quit="confirmQuitWithActiveAiRuns" />
<QuickOpenDialog :open="showQuickOpen" @update:open="showQuickOpen = $event" @select="handleQuickOpenSelect" />
<QuickOpenDialog :open="showQuickOpen" :initial-content-mode="quickOpenForceContent" @update:open="showQuickOpen = $event" @select="handleQuickOpenSelect" />
<TabSwitcherDialog :open="showTabSwitcher" :tabs="tabSwitcherTabs" :selected-index="tabSwitcherIndex" :shortcut-hint="tabSwitcherShortcutHint" @update:open="handleTabSwitcherOpenChange" @update:selected-index="tabSwitcherIndex = $event" @select="handleTabSwitcherSelect" />
</div>
<Teleport to="body">
@@ -254,6 +254,7 @@ const props = defineProps<{
canExplain?: boolean;
initialViewport?: { scrollTop: number; scrollLeft: number };
initialSelection?: { anchor: number; head: number };
revealRequest?: { id: number; line: number; column?: number };
statementExecutionMarkers?: StatementExecutionMarker[];
}>();
@@ -299,6 +300,7 @@ const emit = defineEmits<{
viewportChange: [viewport: { scrollTop: number; scrollLeft: number }, tabId?: string];
selectionStateChange: [selection: { anchor: number; head: number }];
editorStateFlushed: [];
editorRevealConsumed: [];
sendSelectionToAi: [sql: string];
}>();
@@ -7493,6 +7495,7 @@ onMounted(async () => {
restoreEditorSelection(props.initialSelection, !props.initialViewport);
restoreEditorViewport();
performEditorReveal();
syncContextMenuState(view.value);
emit("previewChangesAvailable", !!previewContextSql.value);
syncEditorFontCssVars(liveFontSize.value, initialSettings.fontFamily);
@@ -7674,6 +7677,16 @@ watch(
{ deep: true },
);
// A content-search jump. Fires on id change (a new or reused tab, or a repeat
// click on the same result), and on mount when the request is already pending.
watch(
() => props.revealRequest?.id,
(id, previousId) => {
if (!id || id === previousId) return;
performEditorReveal();
},
);
watch(
() => props.formatRequestId,
(val) => {
@@ -8045,6 +8058,28 @@ function restoreEditorSelection(selection = props.initialSelection ?? latestSele
view.value.dispatch({ selection: normalizedSelection, scrollIntoView });
}
/** Move cursor/scroll to a 1-based line/column, e.g. from a global content-search match. */
function revealEditorSelection(line: number, column?: number) {
const currentView = view.value;
const EditorSelection = codeMirrorEditorSelection;
if (!currentView || !EditorSelection) return;
const doc = currentView.state.doc;
const targetLine = Math.max(1, Math.min(line ?? 1, doc.lines));
const lineOffset = doc.line(targetLine).from;
const charColumn = Math.max(1, column ?? 1);
const offset = Math.min(lineOffset + charColumn - 1, doc.length);
currentView.dispatch({ selection: EditorSelection.cursor(offset), scrollIntoView: true });
focusEditorView(currentView);
}
/** Consume a pending reveal request (fires once per new id / on mount). */
function performEditorReveal() {
const request = props.revealRequest;
if (!request || !view.value) return;
revealEditorSelection(request.line, request.column);
emit("editorRevealConsumed");
}
function restoreEditorFocus() {
const focusEditorAcrossFrames = () => {
focusEditorView(view.value);
@@ -242,6 +242,12 @@ const emit = defineEmits<ContentAreaSurfaceEmits>();
const { t, locale } = useI18n();
const queryStore = useQueryStore();
const connectionStore = useConnectionStore();
/** Clear a consumed editor reveal request so a later normal tab re-visit doesn't re-jump. */
function clearEditorRevealRequest(tab: { editorRevealRequest?: unknown }): void {
if (tab.editorRevealRequest !== undefined) {
tab.editorRevealRequest = undefined;
}
}
provideTabUiState(() => {
const tab = props.activeTab;
const mode = tab.mode;
@@ -1501,6 +1507,7 @@ defineExpose({
:statement-execution-markers="activeStatementExecutionMarkers"
:initial-viewport="activeTab.editorViewport"
:initial-selection="activeTab.editorSelection"
:reveal-request="activeTab.editorRevealRequest"
:force-word-wrap="activeTab.forceWordWrap"
enable-explain-shortcut
:can-explain="!activeTab.isExecuting && !activeTab.isExplaining && !!executableSql.trim()"
@@ -1512,6 +1519,7 @@ defineExpose({
@viewport-change="(viewport, tabId) => emit('editorViewportChange', tabId ?? activeTab.id, viewport)"
@selection-state-change="emit('editorSelectionStateChange', activeTab.id, $event)"
@editor-state-flushed="emit('editorStateFlushed', activeTab.id)"
@editor-reveal-consumed="clearEditorRevealRequest(activeTab)"
@format-error="emit('formatError', activeTab.id)"
@execute="emit('execute', activeTab.id, $event)"
@execute-in-new-result-tab="emit('executeInNewResultTab', activeTab.id, $event)"
@@ -1,13 +1,18 @@
<script setup lang="ts">
import { computed, ref, watch, nextTick } from "vue";
import { useI18n } from "vue-i18n";
import { Command, FileCode, FileText } from "@lucide/vue";
import { Command, FileCode, FileText, Search, FolderPlus, SlidersHorizontal, X } from "@lucide/vue";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { useQuickOpen, type QuickOpenItem } from "@/composables/useQuickOpen";
import { useSettingsStore } from "@/stores/settingsStore";
import { formatShortcutDisplay } from "@/lib/editor/shortcutDisplay";
import { getGlobalSearchRoots, saveGlobalSearchRoots, getGlobalSearchExtensions, saveGlobalSearchExtensions } from "@/lib/globalSearch/globalSearchSettings";
const props = defineProps<{
open: boolean;
/** When true, start in content (full-text) search mode on open. */
initialContentMode?: boolean;
}>();
const emit = defineEmits<{
@@ -16,8 +21,87 @@ const emit = defineEmits<{
}>();
const { t } = useI18n();
const { searchQuery, filteredItems, selectedIndex, selectedItem, selectNext, selectPrevious, setQuery, loadExternalSqlFiles } = useQuickOpen();
const { searchQuery, filteredItems, selectedIndex, selectedItem, selectNext, selectPrevious, setQuery, loadExternalSqlFiles, contentMode, contentGroups, contentSelectedItem, contentSearching, setContentMode } = useQuickOpen();
const inputRef = ref<HTMLInputElement | null>(null);
const listRef = ref<HTMLElement | null>(null);
const settingsStore = useSettingsStore();
const globalSearchShortcut = computed(() => formatShortcutDisplay(settingsStore.editorSettings.shortcuts.globalSearch || "Mod+Shift+F"));
// Search settings panel state (extra search roots and file extensions).
const searchSettingsOpen = ref(false);
const searchRoots = ref<string[]>([]);
const extensionsInput = ref("");
function refreshSearchSettings(): void {
searchRoots.value = getGlobalSearchRoots();
extensionsInput.value = getGlobalSearchExtensions().join(", ");
}
async function addSearchDirectory(): Promise<void> {
try {
const { open } = await import("@tauri-apps/plugin-dialog");
const selected = await open({ directory: true, multiple: true });
if (!selected) return;
const chosen = Array.isArray(selected) ? selected : [selected];
const next = [...new Set([...searchRoots.value, ...chosen.filter((p): p is string => typeof p === "string")])];
saveGlobalSearchRoots(next);
refreshSearchSettings();
} catch {
// Native directory picker is only available inside the Tauri shell.
}
}
function removeSearchRoot(root: string): void {
saveGlobalSearchRoots(searchRoots.value.filter((r) => r !== root));
refreshSearchSettings();
}
function saveExtensions(): void {
const exts = extensionsInput.value
.split(/[\s,;]+/)
.map((s) => s.replace(/^\.+/, "").toLowerCase())
.filter((s) => s.length > 0);
saveGlobalSearchExtensions(exts);
refreshSearchSettings();
}
// Content mode exposes the same shared searchQuery; only featured results differ.
interface ContentRow {
isHeader: boolean;
header?: string;
filePath?: string;
count?: number; // match count under a header
item?: QuickOpenItem;
flatIndex: number; // index into the flattened selectable matches
}
const contentRows = computed<ContentRow[]>(() => {
const rows: ContentRow[] = [];
let flatIndex = 0;
for (const group of contentGroups.value) {
rows.push({ isHeader: true, header: group.header, filePath: group.filePath, count: group.matches.length, item: undefined, flatIndex: -1 });
for (const match of group.matches) {
rows.push({ isHeader: false, header: undefined, filePath: group.filePath, count: undefined, item: match, flatIndex: flatIndex++ });
}
}
return rows;
});
const contentMatchCount = computed(() => contentGroups.value.reduce((total, group) => total + group.matches.length, 0));
function getHighlightedLineParts(item: QuickOpenItem): Array<{ text: string; highlight: boolean }> {
const text = item.lineText || "";
const range = item.highlightIndices;
if (!range) return [{ text, highlight: false }];
const start = Math.max(0, Math.min(range[0], text.length));
const end = Math.max(start, Math.min(range[1], text.length));
return [
{ text: text.slice(0, start), highlight: false },
{ text: text.slice(start, end), highlight: true },
{ text: text.slice(end), highlight: false },
];
}
const dialogOpen = computed({
get: () => props.open,
@@ -31,9 +115,12 @@ function handleKeyDown(e: KeyboardEvent): void {
} else if (e.key === "ArrowUp") {
e.preventDefault();
selectPrevious();
} else if (e.key === "Enter" && selectedItem.value) {
e.preventDefault();
handleSelect(selectedItem.value);
} else if (e.key === "Enter") {
const target = contentMode.value ? contentSelectedItem.value : selectedItem.value;
if (target) {
e.preventDefault();
handleSelect(target);
}
} else if (e.key === "Escape") {
e.preventDefault();
dialogOpen.value = false;
@@ -127,6 +214,9 @@ watch(
(newOpen) => {
if (newOpen) {
setQuery("");
setContentMode(props.initialContentMode === true);
searchSettingsOpen.value = false;
refreshSearchSettings();
// Eagerly load external SQL files so they appear in the initial list
void loadExternalSqlFiles();
nextTick(() => {
@@ -135,6 +225,23 @@ watch(
}
},
);
// While the dialog is open, Ctrl+P / Ctrl+Shift+F switch the mode without
// closing it. The parent drives this by mutating the `initialContentMode` prop.
watch(
() => props.initialContentMode,
(enabled) => {
if (props.open) setContentMode(enabled === true);
},
);
// Keep the highlighted row in view when keyboard navigation moves the selection.
watch(selectedIndex, async () => {
await nextTick();
const container = listRef.value;
if (!container) return;
container.querySelector<HTMLElement>('[data-selected="true"]')?.scrollIntoView({ block: "nearest" });
});
</script>
<template>
@@ -142,20 +249,117 @@ watch(
<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">
<div class="flex items-center gap-3 px-4 pr-12 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" />
<Input
ref="inputRef"
v-model="searchQuery"
type="text"
:placeholder="contentMode ? t('quickOpen.contentPlaceholder') : 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 class="flex items-center gap-1 shrink-0">
<!-- Object/file ⇄ file content mode toggle -->
<div class="flex items-center rounded-md bg-muted p-0.5 text-muted-foreground">
<button class="flex h-7 w-7 items-center justify-center rounded" :class="contentMode ? 'hover:bg-muted-foreground/10' : 'bg-background text-foreground shadow-sm'" :title="t('quickOpen.modeObjects')" @click="setContentMode(false)">
<FileCode class="h-4 w-4" />
</button>
<button class="flex h-7 w-7 items-center justify-center rounded" :class="contentMode ? 'bg-background text-foreground shadow-sm' : 'hover:bg-muted-foreground/10'" :title="t('quickOpen.modeContent')" @click="setContentMode(true)">
<Search class="h-4 w-4" />
</button>
</div>
<!-- Search settings (roots & extensions) -->
<button class="flex h-7 w-7 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground" :class="searchSettingsOpen ? 'bg-muted text-foreground' : ''" :title="t('quickOpen.searchSettings')" @click="searchSettingsOpen = !searchSettingsOpen">
<SlidersHorizontal class="h-4 w-4" />
</button>
</div>
</div>
<!-- Search settings panel -->
<div v-if="searchSettingsOpen" class="space-y-3 border-b bg-muted/30 px-4 py-3 text-sm">
<div>
<div class="mb-1.5 flex items-center justify-between">
<span class="text-xs text-muted-foreground">{{ t("quickOpen.searchRoots") }}</span>
<button class="flex items-center gap-1 rounded px-2 py-1 text-xs text-foreground hover:bg-muted" @click="addSearchDirectory"><FolderPlus class="h-3.5 w-3.5" /> {{ t("quickOpen.addDirectory") }}</button>
</div>
<div v-if="searchRoots.length === 0" class="text-xs text-muted-foreground">
{{ t("quickOpen.noRootsConfigured") }}
</div>
<div v-else class="flex max-h-28 flex-wrap gap-1.5 overflow-y-auto">
<span v-for="root in searchRoots" :key="root" class="group flex max-w-full items-center gap-1 rounded bg-background px-2 py-1 text-xs">
<span class="max-w-[280px] truncate" :title="root">{{ root }}</span>
<button class="text-muted-foreground hover:text-foreground" :title="t('quickOpen.removePath')" @click="removeSearchRoot(root)">
<X class="h-3 w-3" />
</button>
</span>
</div>
</div>
<div>
<span class="text-xs text-muted-foreground">{{ t("quickOpen.searchExtensions") }}</span>
<div class="mt-1 flex items-center gap-2">
<Input v-model="extensionsInput" type="text" class="h-8 flex-1" :placeholder="t('quickOpen.extensionsPlaceholder')" />
<button class="rounded px-3 py-1.5 text-xs text-foreground hover:bg-muted" @click="saveExtensions">{{ t("quickOpen.apply") }}</button>
</div>
</div>
</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">
<div ref="listRef" class="max-h-[400px] overflow-y-auto">
<!-- Content search mode: group matches by file (DataGrip-style) -->
<template v-if="contentMode">
<div v-if="contentSearching" class="px-4 py-8 text-center text-muted-foreground">
{{ t("quickOpen.searchingContent") }}
</div>
<div v-else-if="contentRows.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">
<template v-for="row in contentRows" :key="row.isHeader ? `h-${row.filePath}` : row.item!.id">
<!-- Group header -->
<div v-if="row.isHeader" class="flex items-center justify-between gap-3 px-4 py-1.5 bg-muted/50">
<div class="flex items-center gap-2 min-w-0">
<FileCode class="h-4 w-4 shrink-0 text-muted-foreground" />
<span class="text-xs font-semibold text-foreground truncate">{{ row.header }}</span>
</div>
<span class="text-xs text-muted-foreground whitespace-nowrap">{{ row.count }}</span>
</div>
<!-- Match line -->
<div v-else class="px-4 py-1.5 cursor-pointer" :data-selected="row.flatIndex === selectedIndex" :class="[row.flatIndex === selectedIndex ? 'bg-accent' : 'hover:bg-muted']" @click="handleSelect(row.item!)" @mouseenter="selectedIndex = row.flatIndex">
<!-- Filename-only match (no content hit): show highlighted file name -->
<div v-if="row.item!.line === 0" class="flex items-center gap-2 min-w-0">
<FileText class="h-4 w-4 shrink-0 text-muted-foreground" />
<span class="font-mono text-xs truncate">
<template v-for="(part, i) in getHighlightedLineParts(row.item!)" :key="i">
<span v-if="part.highlight" class="bg-yellow-200 dark:bg-yellow-800 text-foreground rounded-[0.1875rem] px-0.5 font-semibold">{{ part.text }}</span>
<span v-else>{{ part.text }}</span>
</template>
</span>
</div>
<!-- Content match: line number + highlighted snippet -->
<div v-else class="flex items-center gap-2 min-w-0">
<span class="shrink-0 font-mono text-xs text-muted-foreground">{{ row.item!.line }}</span>
<span class="font-mono text-xs truncate">
<template v-for="(part, i) in getHighlightedLineParts(row.item!)" :key="i">
<span v-if="part.highlight" class="bg-yellow-200 dark:bg-yellow-800 text-foreground rounded-[0.1875rem] px-0.5 font-semibold">{{ part.text }}</span>
<span v-else>{{ part.text }}</span>
</template>
</span>
</div>
</div>
</template>
</div>
</template>
<!-- Normal mode -->
<div v-else-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', index === selectedIndex ? 'bg-accent' : 'hover:bg-muted']" @click="handleSelect(item)" @mouseenter="selectedIndex = index">
<div v-for="(item, index) in filteredItems" :key="item.id" :data-selected="index === selectedIndex" :class="['px-4 py-2 cursor-pointer', 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 items-center gap-2 flex-1 min-w-0">
<component v-if="getItemIcon(item.type)" :is="getItemIcon(item.type)" class="h-4 w-4 shrink-0 text-muted-foreground" />
@@ -183,11 +387,14 @@ watch(
<!-- 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>{{ contentMode ? contentMatchCount : 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>
<span class="hidden sm:inline"
><kbd class="px-2 py-1 rounded bg-muted">{{ globalSearchShortcut }}</kbd> {{ t("quickOpen.globalSearchHint") }}</span
>
</div>
</div>
</div>
+165 -2
View File
@@ -7,6 +7,7 @@ import { useSavedSqlStore } from "@/stores/savedSqlStore";
import * as api from "@/lib/backend/api";
import type { SqlFileEntry } from "@/lib/backend/api";
import { getSqlFileFilter, getSqlFileFolderPaths, sqlFileFoldersVersion } from "@/lib/sqlFile/sqlFileFolders";
import { composeGlobalSearchRoots, getGlobalSearchExtensions, globalSearchSettingsVersion } from "@/lib/globalSearch/globalSearchSettings";
import { containsHan, pinyinFirstLetters } from "@/lib/common/pinyin";
import i18n from "@/i18n";
@@ -20,11 +21,14 @@ const QUICK_OPEN_MAX_RESULTS = 200;
const INITIAL_SQL_LIBRARY_LIMIT = 20;
const INITIAL_SQL_FILE_LIMIT = 20;
const CONTENT_SEARCH_DEBOUNCE_MS = 200;
const CONTENT_SEARCH_MAX_RESULTS = 500;
const REMOTE_SEARCH_UNSUPPORTED_TYPES = new Set<ConnectionConfig["db_type"]>(["redis", "mongodb", "elasticsearch", "easysearch", "meilisearch", "qdrant", "milvus", "weaviate", "chromadb", "neo4j", "influxdb", "victoriametrics", "etcd", "zookeeper", "mq", "nacos", "consul"]);
export interface QuickOpenItem {
id: string;
type: "connection" | "database" | "schema" | "table" | "view" | "materialized_view" | "procedure" | "function" | "sequence" | "package" | "package-body" | "sql_file" | "sql_library_file";
type: "connection" | "database" | "schema" | "table" | "view" | "materialized_view" | "procedure" | "function" | "sequence" | "package" | "package-body" | "sql_file" | "sql_library_file" | "content_match";
label: string;
description?: string;
connectionId: string;
@@ -36,6 +40,12 @@ export interface QuickOpenItem {
searchText: string; // Lowercase text for searching
filePath?: string; // For external SQL files
sqlFileId?: string; // For saved SQL library files
fileName?: string; // For content matches: file name shown in the group header
line?: number; // For content matches: 1-based line
column?: number; // For content matches: 1-based char column
matchText?: string; // For content matches: matched slice
lineText?: string; // For content matches: full matching line
highlightIndices?: [number, number]; // For content matches: [start, end) chars into lineText to highlight
}
export type QuickOpenMatchKind = "exact" | "initials" | "prefix" | "word-prefix" | "substring" | "fuzzy";
@@ -273,6 +283,106 @@ export function useQuickOpen() {
let sqlFilesLoadingPromise: Promise<void> | null = null;
let sqlFilesLoadGeneration = 0;
// --- Content search mode (global search of local SQL/text file contents) ---
const contentMode = ref(false);
const contentSearching = ref(false);
const contentItems = ref<MatchedItem[]>([]);
let contentSearchGeneration = 0;
let contentSearchTimer: ReturnType<typeof setTimeout> | undefined;
interface ContentGroup {
header: string;
filePath: string;
matches: MatchedItem[];
}
const contentGroups = computed<ContentGroup[]>(() => {
const groups: ContentGroup[] = [];
const indexByPath = new Map<string, ContentGroup>();
for (const item of contentItems.value) {
const path = item.filePath;
if (!path) continue;
let group = indexByPath.get(path);
if (!group) {
group = { header: item.fileName || item.label, filePath: path, matches: [] };
indexByPath.set(path, group);
groups.push(group);
}
group.matches.push(item);
}
for (const group of groups) {
group.matches.sort((a, b) => (a.line ?? 0) - (b.line ?? 0));
}
return groups;
});
const selectableContentItems = computed<MatchedItem[]>(() => contentGroups.value.flatMap((group) => group.matches));
function clearContentResults(): void {
contentItems.value = [];
contentSearching.value = false;
}
async function runContentSearch(query: string, generation: number): Promise<void> {
const roots = composeGlobalSearchRoots();
if (roots.length === 0) {
if (generation === contentSearchGeneration) clearContentResults();
return;
}
contentSearching.value = true;
try {
const matches = await api.globalSearch({
roots,
query,
extensions: getGlobalSearchExtensions(),
limit: CONTENT_SEARCH_MAX_RESULTS,
});
if (generation !== contentSearchGeneration) return;
contentItems.value = matches.map((match) => {
const isFileName = match.line === 0;
const highlightIndices: [number, number] = [Math.max(0, match.column - 1), Math.max(match.column - 1, match.column - 1 + match.matchText.length)];
return {
id: `content-${match.path}-${match.line}-${match.column}`,
type: "content_match" as const,
label: match.fileName,
description: match.fileName,
connectionId: "",
filePath: match.path,
fileName: match.fileName,
line: match.line,
column: match.column,
matchText: match.matchText,
lineText: isFileName ? match.fileName : match.lineText,
highlightIndices,
searchText: match.lineText,
matchScore: 0,
matchIndices: [],
};
});
} catch {
if (generation === contentSearchGeneration) contentItems.value = [];
} finally {
if (generation === contentSearchGeneration) contentSearching.value = false;
}
}
function setContentMode(enabled: boolean): void {
if (contentMode.value === enabled) return;
contentMode.value = enabled;
selectedIndex.value = 0;
if (enabled) {
const query = searchQuery.value.trim();
if (query) {
const generation = ++contentSearchGeneration;
void runContentSearch(query, generation);
} else {
clearContentResults();
}
} else {
clearContentResults();
}
}
function getConnectionLabel(connectionId: string): string {
if (!connectionId) return i18n.global.t("sqlLibrary.unassociated");
const conn = connectionStore.connections.find((c) => c.id === connectionId);
@@ -804,6 +914,7 @@ export function useQuickOpen() {
"package-body": 10,
sql_library_file: 11,
sql_file: 12,
content_match: 13,
};
const typeDifference = typeOrder[a.type] - typeOrder[b.type];
if (typeDifference !== 0) return typeDifference;
@@ -822,8 +933,15 @@ export function useQuickOpen() {
return filteredItems.value[selectedIndex.value];
});
const contentSelectedItem = computed((): MatchedItem | null => {
if (!contentMode.value) return null;
if (selectedIndex.value < 0 || selectedIndex.value >= selectableContentItems.value.length) return null;
return selectableContentItems.value[selectedIndex.value];
});
function selectNext(): void {
if (selectedIndex.value < filteredItems.value.length - 1) {
const length = contentMode.value ? selectableContentItems.value.length : filteredItems.value.length;
if (selectedIndex.value < length - 1) {
selectedIndex.value++;
}
}
@@ -843,11 +961,56 @@ export function useQuickOpen() {
resetSelection();
}
// Content search: debounced full-text search fired from the shared query.
watch(
searchQuery,
(query) => {
selectedIndex.value = 0;
const generation = ++contentSearchGeneration;
if (contentSearchTimer) clearTimeout(contentSearchTimer);
contentSearchTimer = undefined;
if (!contentMode.value) {
clearContentResults();
return;
}
const normalizedQuery = query.trim();
if (!normalizedQuery) {
clearContentResults();
return;
}
contentSearchTimer = setTimeout(() => {
contentSearchTimer = undefined;
void runContentSearch(normalizedQuery, generation);
}, CONTENT_SEARCH_DEBOUNCE_MS);
},
{ flush: "sync" },
);
// Re-run the current content search when search roots or extensions change.
watch(globalSearchSettingsVersion, () => {
if (contentMode.value && searchQuery.value.trim()) {
const generation = ++contentSearchGeneration;
void runContentSearch(searchQuery.value.trim(), generation);
}
});
watch(sqlFileFoldersVersion, () => {
if (contentMode.value && searchQuery.value.trim()) {
const generation = ++contentSearchGeneration;
void runContentSearch(searchQuery.value.trim(), generation);
}
});
return {
searchQuery,
filteredItems,
selectedIndex,
selectedItem,
contentMode,
contentSearching,
contentGroups,
contentSelectedItem,
setContentMode,
selectNext,
selectPrevious,
resetSelection,
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import az from "../locales/az";
import en from "../locales/en";
import es from "../locales/es";
import it_ from "../locales/it";
import ja from "../locales/ja";
import ko from "../locales/ko";
import ptBR from "../locales/pt-BR";
import tr from "../locales/tr";
import zhCN from "../locales/zh-CN";
import zhTW from "../locales/zh-TW";
type Messages = Record<string, unknown>;
const locales: Array<[string, Messages]> = [
["az", az],
["es", es],
["it", it_],
["ja", ja],
["ko", ko],
["pt-BR", ptBR],
["tr", tr],
["zh-CN", zhCN],
["zh-TW", zhTW],
];
const namespaces = ["quickOpen"] as const;
function leafEntries(value: unknown, prefix = ""): Array<[string, string]> {
if (typeof value === "string") return [[prefix, value]];
if (value === null || typeof value !== "object" || Array.isArray(value)) return [];
return Object.entries(value as Messages).flatMap(([key, child]) => leafEntries(child, prefix ? `${prefix}.${key}` : key));
}
describe.each(namespaces)("%s i18n namespace parity", (namespace) => {
const englishEntries = new Map(leafEntries((en as Messages)[namespace]));
it("english declares the namespace", () => {
expect(englishEntries.size).toBeGreaterThan(0);
});
it("english declares the global-search keys", () => {
for (const key of ["searchingContent", "globalSearchHint", "modeObjects", "modeContent", "contentPlaceholder", "searchSettings", "searchRoots", "addDirectory", "noRootsConfigured", "removePath", "searchExtensions", "extensionsPlaceholder", "apply"]) {
expect(englishEntries.has(key), `missing quickOpen.${key}`).toBe(true);
}
});
it.each(locales)("%s declares the same keys", (_name, locale) => {
const localeEntries = new Map(leafEntries(locale[namespace]));
expect([...localeEntries.keys()].sort()).toEqual([...englishEntries.keys()].sort());
});
it("every locale declares the globalSearch shortcut label", () => {
for (const [name, locale] of locales) {
const settings = locale.settings as Messages | undefined;
expect(settings?.shortcutGlobalSearch, `${name}.settings.shortcutGlobalSearch`).toBeTruthy();
}
});
});
+14
View File
@@ -2530,6 +2530,19 @@ export default withEnglishFallback({
close: "Bağla",
sqlFile: "SQL faylı",
sqlLibraryFile: "SQL kitabxanası",
searchingContent: "Fayl məzmunu axtarılır…",
globalSearchHint: "Qlobal fayl məzmunu axtarışı",
modeObjects: "Obyektlər və faylları axtar",
modeContent: "Fayl məzmununda axtar",
contentPlaceholder: "Fayl məzmununda axtar…",
searchSettings: "Axtarış parametrləri",
searchRoots: "Axtarış qovluqları",
addDirectory: "Qovluq əlavə et",
noRootsConfigured: "Axtarış qovluğu təyin edilməyib",
removePath: "Sil",
searchExtensions: "Fayl uzantıları",
extensionsPlaceholder: "məs. sql, txt, md",
apply: "Tətbiq et",
},
explain: {
title: "İcra planı",
@@ -7509,6 +7522,7 @@ export default withEnglishFallback({
shortcutCloseTab: "Vərəqi bağla",
shortcutFocusSearch: "Cari görünüşün axtarış sahəsinə keç",
shortcutQuickOpen: "Sürətli açılış (bütün verilənlər bazası obyektlərini axtar)",
shortcutGlobalSearch: "Qlobal fayl məzmunu axtarışı",
shortcutNavigateTabHistoryBack: "Əvvəl baxılmış vərəqə qayıt",
shortcutTabSwitcher: "Vərəq dəyişdiricisi (göstərmək üçün basılı saxla, keçmək üçün burax)",
shortcutNavigateTabHistoryForward: "Növbəti baxılmış vərəqə keç",
+14
View File
@@ -2692,6 +2692,19 @@ export default {
close: "Close",
sqlFile: "SQL File",
sqlLibraryFile: "SQL Library",
searchingContent: "Searching file contents…",
globalSearchHint: "Global content search",
modeObjects: "Search objects and files",
modeContent: "Search file contents",
contentPlaceholder: "Search within file contents…",
searchSettings: "Search settings",
searchRoots: "Search directories",
addDirectory: "Add directory",
noRootsConfigured: "No search directories configured",
removePath: "Remove",
searchExtensions: "File extensions",
extensionsPlaceholder: "e.g. sql, txt, md",
apply: "Apply",
},
explain: {
title: "Explain Plan",
@@ -7740,6 +7753,7 @@ export default {
shortcutCloseTab: "Close tab",
shortcutFocusSearch: "Focus current view search",
shortcutQuickOpen: "Quick open (search all database objects)",
shortcutGlobalSearch: "Global file content search",
shortcutNavigateTabHistoryBack: "Go back to the previously viewed tab",
shortcutTabSwitcher: "Tab switcher (hold to show, release to switch)",
shortcutNavigateTabHistoryForward: "Go forward to the next viewed tab",
+14
View File
@@ -2590,6 +2590,19 @@ export default withEnglishFallback({
close: "Cerrar",
sqlFile: "Archivo SQL",
sqlLibraryFile: "Biblioteca SQL",
searchingContent: "Buscando en el contenido de archivos…",
globalSearchHint: "Búsqueda global de contenido de archivos",
modeObjects: "Buscar objetos y archivos",
modeContent: "Buscar en el contenido de archivos",
contentPlaceholder: "Buscar en el contenido de archivos…",
searchSettings: "Configuración de búsqueda",
searchRoots: "Directorios de búsqueda",
addDirectory: "Añadir directorio",
noRootsConfigured: "No hay directorios de búsqueda configurados",
removePath: "Quitar",
searchExtensions: "Extensiones de archivo",
extensionsPlaceholder: "p. ej. sql, txt, md",
apply: "Aplicar",
},
explain: {
title: "Plan de ejecución",
@@ -7243,6 +7256,7 @@ export default withEnglishFallback({
shortcutToggleZenMode: "Alternar modo Zen",
shortcutFocusSearch: "Enfocar búsqueda de la vista actual",
shortcutQuickOpen: "Abrir rápido (buscar todos los objetos de base de datos)",
shortcutGlobalSearch: "Búsqueda global de contenido de archivos",
shortcutNavigateTabHistoryBack: "Volver a la pestaña vista anteriormente",
shortcutTabSwitcher: "Selector de pestañas (mantén pulsado para mostrar, suelta para cambiar)",
shortcutNavigateTabHistoryForward: "Avanzar a la siguiente pestaña vista",
+14
View File
@@ -2588,6 +2588,19 @@ export default withEnglishFallback({
close: "Chiudi",
sqlFile: "File SQL",
sqlLibraryFile: "Libreria SQL",
searchingContent: "Ricerca nel contenuto dei file…",
globalSearchHint: "Ricerca globale nel contenuto dei file",
modeObjects: "Cerca oggetti e file",
modeContent: "Cerca nel contenuto dei file",
contentPlaceholder: "Cerca nel contenuto dei file…",
searchSettings: "Impostazioni di ricerca",
searchRoots: "Directory di ricerca",
addDirectory: "Aggiungi directory",
noRootsConfigured: "Nessuna directory di ricerca configurata",
removePath: "Rimuovi",
searchExtensions: "Estensioni file",
extensionsPlaceholder: "es. sql, txt, md",
apply: "Applica",
},
explain: {
title: "Piano di Spiegazione",
@@ -7241,6 +7254,7 @@ export default withEnglishFallback({
shortcutCloseTab: "Chiudi scheda",
shortcutFocusSearch: "Focalizza la ricerca nella vista corrente",
shortcutQuickOpen: "Apertura rapida (ricerca tutti gli oggetti del database)",
shortcutGlobalSearch: "Ricerca globale nel contenuto dei file",
shortcutNavigateTabHistoryBack: "Torna alla scheda visualizzata in precedenza",
shortcutTabSwitcher: "Selettore schede (tieni premuto per mostrare, rilascia per cambiare)",
shortcutNavigateTabHistoryForward: "Vai avanti alla scheda visualizzata successiva",
+14
View File
@@ -2615,6 +2615,19 @@ export default withEnglishFallback({
close: "閉じる",
sqlFile: "SQLファイル",
sqlLibraryFile: "SQLライブラリ",
searchingContent: "ファイル内容を検索中…",
globalSearchHint: "グローバルファイル内容検索",
modeObjects: "オブジェクトとファイルを検索",
modeContent: "ファイル内容を検索",
contentPlaceholder: "ファイル内容を検索…",
searchSettings: "検索設定",
searchRoots: "検索ディレクトリ",
addDirectory: "ディレクトリを追加",
noRootsConfigured: "検索ディレクトリが設定されていません",
removePath: "削除",
searchExtensions: "拡張子",
extensionsPlaceholder: "例: sql, txt, md",
apply: "適用",
},
explain: {
title: "実行計画",
@@ -7247,6 +7260,7 @@ export default withEnglishFallback({
shortcutCloseTab: "タブを閉じる",
shortcutFocusSearch: "現在のビューの検索にフォーカス",
shortcutQuickOpen: "クイックオープン (すべてのデータベースオブジェクトを検索)",
shortcutGlobalSearch: "グローバルファイル内容検索",
shortcutNavigateTabHistoryBack: "前に表示したタブに戻る",
shortcutTabSwitcher: "タブスイッチャー(押し続けて表示、離して切り替え)",
shortcutNavigateTabHistoryForward: "次に表示したタブに進む",
+14
View File
@@ -2498,6 +2498,19 @@ export default withEnglishFallback({
close: "닫기",
sqlFile: "SQL 파일",
sqlLibraryFile: "SQL 라이브러리",
searchingContent: "파일 내용 검색 중…",
globalSearchHint: "전역 파일 내용 검색",
modeObjects: "객체 및 파일 검색",
modeContent: "파일 내용 검색",
contentPlaceholder: "파일 내용 검색…",
searchSettings: "검색 설정",
searchRoots: "검색 디렉터리",
addDirectory: "디렉터리 추가",
noRootsConfigured: "검색 디렉터리가 설정되지 않았습니다",
removePath: "제거",
searchExtensions: "확장자",
extensionsPlaceholder: "예: sql, txt, md",
apply: "적용",
},
explain: {
title: "실행 계획",
@@ -7013,6 +7026,7 @@ export default withEnglishFallback({
shortcutCloseTab: "탭 닫기",
shortcutFocusSearch: "현재 화면 검색에 포커스",
shortcutQuickOpen: "빠른 열기 (모든 데이터베이스 객체 검색)",
shortcutGlobalSearch: "전역 파일 내용 검색",
shortcutNavigateTabHistoryBack: "이전에 본 탭으로 돌아가기",
shortcutTabSwitcher: "탭 전환기 (길게 눌러 표시, 놓으면 전환)",
shortcutNavigateTabHistoryForward: "다음에 본 탭으로 이동",
+14
View File
@@ -2590,6 +2590,19 @@ export default withEnglishFallback({
close: "Fechar",
sqlFile: "Arquivo SQL",
sqlLibraryFile: "Biblioteca SQL",
searchingContent: "Pesquisando conteúdo dos arquivos…",
globalSearchHint: "Pesquisa global de conteúdo de arquivos",
modeObjects: "Pesquisar objetos e arquivos",
modeContent: "Pesquisar conteúdo dos arquivos",
contentPlaceholder: "Pesquisar no conteúdo dos arquivos…",
searchSettings: "Configurações de pesquisa",
searchRoots: "Diretórios de pesquisa",
addDirectory: "Adicionar diretório",
noRootsConfigured: "Nenhum diretório de pesquisa configurado",
removePath: "Remover",
searchExtensions: "Extensões de arquivo",
extensionsPlaceholder: "ex.: sql, txt, md",
apply: "Aplicar",
},
explain: {
title: "Plano de Execução",
@@ -7245,6 +7258,7 @@ export default withEnglishFallback({
shortcutToggleZenMode: "Alternar modo Zen",
shortcutFocusSearch: "Focar na pesquisa da tela atual",
shortcutQuickOpen: "Abrir rapidamente (pesquisar todos os objetos do banco de dados)",
shortcutGlobalSearch: "Pesquisa global de conteúdo de arquivos",
shortcutNavigateTabHistoryBack: "Voltar para a aba visualizada anteriormente",
shortcutTabSwitcher: "Alternador de abas (segure para mostrar, solte para alternar)",
shortcutNavigateTabHistoryForward: "Avançar para a próxima aba visualizada",
+14
View File
@@ -2508,6 +2508,19 @@ export default withEnglishFallback({
close: "Kapat",
sqlFile: "SQL Dosyası",
sqlLibraryFile: "SQL Kitaplığı",
searchingContent: "Dosya içerikleri aranıyor…",
globalSearchHint: "Genel dosya içeriği arama",
modeObjects: "Nesne ve dosya ara",
modeContent: "Dosya içeriklerinde ara",
contentPlaceholder: "Dosya içeriklerinde ara…",
searchSettings: "Arama ayarları",
searchRoots: "Arama dizinleri",
addDirectory: "Dizin ekle",
noRootsConfigured: "Yapılandırılmış arama dizini yok",
removePath: "Kaldır",
searchExtensions: "Dosya uzantıları",
extensionsPlaceholder: "örn. sql, txt, md",
apply: "Uygula",
},
explain: {
title: "Yürütme Planı",
@@ -7393,6 +7406,7 @@ export default withEnglishFallback({
shortcutCloseTab: "Sekmeyi kapat",
shortcutFocusSearch: "Geçerli görünüm aramasına odaklan",
shortcutQuickOpen: "Hızlı aç (tüm veritabanı nesnelerinde ara)",
shortcutGlobalSearch: "Genel dosya içeriği arama",
shortcutNavigateTabHistoryBack: "Önceki görüntülenen sekmeye dön",
shortcutTabSwitcher: "Sekme değiştirici (göstermek için basılı tutun, geçmek için bırakın)",
shortcutNavigateTabHistoryForward: "Sonraki görüntülenen sekmeye git",
+14
View File
@@ -2606,6 +2606,19 @@ export default withEnglishFallback({
close: "关闭",
sqlFile: "SQL文件",
sqlLibraryFile: "SQL库",
searchingContent: "正在搜索文件内容…",
globalSearchHint: "全局文件内容搜索",
modeObjects: "搜索对象与文件",
modeContent: "搜索文件内容",
contentPlaceholder: "搜索文件内容…",
searchSettings: "搜索设置",
searchRoots: "检索目录",
addDirectory: "添加目录",
noRootsConfigured: "尚未配置检索目录",
removePath: "移除",
searchExtensions: "扩展名",
extensionsPlaceholder: "例如 sql, txt, md",
apply: "应用",
},
explain: {
title: "执行计划",
@@ -7718,6 +7731,7 @@ export default withEnglishFallback({
shortcutToggleZenMode: "切换禅模式",
shortcutFocusSearch: "聚焦当前页面搜索",
shortcutQuickOpen: "快速打开 (搜索所有数据库对象)",
shortcutGlobalSearch: "全局文件内容搜索",
shortcutNavigateTabHistoryBack: "返回到上一个查看的标签页",
shortcutTabSwitcher: "标签页切换器(按住显示,松开切换)",
shortcutNavigateTabHistoryForward: "前进到下一个查看的标签页",
+14
View File
@@ -2591,6 +2591,19 @@ export default withEnglishFallback({
close: "關閉",
sqlFile: "SQL檔案",
sqlLibraryFile: "SQL庫",
searchingContent: "正在搜尋檔案內容…",
globalSearchHint: "全域檔案內容搜尋",
modeObjects: "搜尋物件與檔案",
modeContent: "搜尋檔案內容",
contentPlaceholder: "搜尋檔案內容…",
searchSettings: "搜尋設定",
searchRoots: "搜尋目錄",
addDirectory: "新增目錄",
noRootsConfigured: "尚未設定搜尋目錄",
removePath: "移除",
searchExtensions: "副檔名",
extensionsPlaceholder: "例如 sql, txt, md",
apply: "套用",
},
explain: {
title: "執行計畫",
@@ -6553,6 +6566,7 @@ export default withEnglishFallback({
shortcutToggleZenMode: "切換禪模式",
shortcutFocusSearch: "聚焦目前頁面搜尋",
shortcutQuickOpen: "快速開啟 (搜尋所有資料庫物件)",
shortcutGlobalSearch: "全域檔案內容搜尋",
shortcutNavigateTabHistoryBack: "返回到上一個檢視的分頁",
shortcutTabSwitcher: "分頁切換器(按住顯示,放開切換)",
shortcutNavigateTabHistoryForward: "前進到下一個檢視的分頁",
+6
View File
@@ -427,6 +427,9 @@ export const inspectExternalSqlFile = forward("inspectExternalSqlFile");
export const writeExternalSqlFile = forward("writeExternalSqlFile");
export const saveExternalSqlFile = forward("saveExternalSqlFile");
export const listSqlFilesInFolder = forward("listSqlFilesInFolder");
export const globalSearch = forward("globalSearch");
export const loadGlobalSearchSettings = forward("loadGlobalSearchSettings");
export const saveGlobalSearchSettings = forward("saveGlobalSearchSettings");
export const createSqlFileInFolder = forward("createSqlFileInFolder");
export const renameSqlFileInFolder = forward("renameSqlFileInFolder");
export const deleteSqlFileInFolder = forward("deleteSqlFileInFolder");
@@ -1066,6 +1069,9 @@ export type {
QueryResultExportRequest,
AgentEvent,
SqlFileEntry,
GlobalSearchRequest,
GlobalSearchMatch,
GlobalSearchSettings,
} from "@/lib/backend/tauri";
// MQTT
+41
View File
@@ -2543,6 +2543,47 @@ export async function deleteSqlFileInFolder(_rootPath: string, _filePath: string
throw new Error("Managing SQL files in folders is only available in the desktop app");
}
export interface GlobalSearchRequest {
roots: string[];
query: string;
extensions?: string[];
caseSensitive?: boolean;
useRegex?: boolean;
wholeWord?: boolean;
limit?: number;
}
export interface GlobalSearchMatch {
path: string;
fileName: string;
line: number;
column: number;
matchText: string;
lineText: string;
}
export async function globalSearch(_request: GlobalSearchRequest): Promise<GlobalSearchMatch[]> {
throw new Error("Global content search is only available in the desktop app");
}
export interface GlobalSearchSettings {
roots: string[];
extensions: string[];
}
export async function loadGlobalSearchSettings(): Promise<GlobalSearchSettings | null> {
try {
const raw = globalThis.localStorage?.getItem("dbx-global-search-settings-disk");
return raw ? (JSON.parse(raw) as GlobalSearchSettings) : null;
} catch {
return null;
}
}
export async function saveGlobalSearchSettings(settings: GlobalSearchSettings): Promise<void> {
globalThis.localStorage?.setItem("dbx-global-search-settings-disk", JSON.stringify(settings));
}
// ---------------------------------------------------------------------------
// Data Transfer
// ---------------------------------------------------------------------------
+38
View File
@@ -789,6 +789,19 @@ export async function saveEditorSettings(settings: unknown): Promise<void> {
return invoke("save_editor_settings", { settings });
}
export interface GlobalSearchSettings {
roots: string[];
extensions: string[];
}
export function loadGlobalSearchSettings(): Promise<GlobalSearchSettings | null> {
return invoke("load_global_search_settings");
}
export function saveGlobalSearchSettings(settings: GlobalSearchSettings): Promise<void> {
return invoke("save_global_search_settings", { settings });
}
export interface BackgroundImageInfo {
storedPath: string;
fileName: string;
@@ -1079,6 +1092,31 @@ export async function deleteSqlFileInFolder(rootPath: string, filePath: string):
return invoke("delete_sql_file_in_folder", { rootPath, filePath });
}
export interface GlobalSearchRequest {
roots: string[];
query: string;
extensions?: string[];
caseSensitive?: boolean;
useRegex?: boolean;
wholeWord?: boolean;
limit?: number;
}
export interface GlobalSearchMatch {
path: string;
fileName: string;
/** 1-based line number. */
line: number;
/** 1-based char column within the line (for CodeMirror). */
column: number;
matchText: string;
lineText: string;
}
export async function globalSearch(request: GlobalSearchRequest): Promise<GlobalSearchMatch[]> {
return invoke("global_search", { request });
}
// --- AI Conversations ---
export interface AiChatMessage {
@@ -325,6 +325,10 @@ export function isQuickOpenShortcut(event: ShortcutLikeEvent, shortcuts?: Partia
return matchesShortcut(event, actionShortcut("quickOpen", shortcuts));
}
export function isGlobalSearchShortcut(event: ShortcutLikeEvent, shortcuts?: Partial<ShortcutSettings>): boolean {
return matchesShortcut(event, actionShortcut("globalSearch", shortcuts));
}
export function isNavigateTabHistoryBackShortcut(event: ShortcutLikeEvent, shortcuts?: Partial<ShortcutSettings>, platform = globalThis.navigator?.platform || ""): boolean {
return matchesShortcut(event, actionShortcut("navigateTabHistoryBack", shortcuts, platform), platform);
}
@@ -46,6 +46,7 @@ export type ShortcutActionId =
| "closeOtherTabs"
| "focusSearch"
| "quickOpen"
| "globalSearch"
| "toggleAiPanel"
| "navigateTabHistoryBack"
| "navigateTabHistoryForward"
@@ -410,6 +411,12 @@ export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [
scope: "global",
defaultShortcut: "Mod+P",
},
{
id: "globalSearch",
labelKey: "settings.shortcutGlobalSearch",
scope: "global",
defaultShortcut: "Mod+Alt+F",
},
{
id: "toggleAiPanel",
labelKey: "settings.shortcutToggleAiPanel",
@@ -0,0 +1,104 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
save: vi.fn().mockResolvedValue(undefined),
load: vi.fn().mockResolvedValue(null),
sqlFolders: vi.fn((): string[] => []),
}));
vi.mock("@/lib/backend/api", () => ({
saveGlobalSearchSettings: mocks.save,
loadGlobalSearchSettings: mocks.load,
}));
vi.mock("@/lib/sqlFile/sqlFileFolders", () => ({
getSqlFileFolderPaths: () => mocks.sqlFolders(),
}));
class MemoryStorage {
private map = new Map<string, string>();
getItem(key: string): string | null {
return this.map.get(key) ?? null;
}
setItem(key: string, value: string): void {
this.map.set(key, String(value));
}
removeItem(key: string): void {
this.map.delete(key);
}
clear(): void {
this.map.clear();
}
}
const { DEFAULT_GLOBAL_SEARCH_EXTENSIONS, getGlobalSearchExtensions, getGlobalSearchRoots, saveGlobalSearchExtensions, saveGlobalSearchRoots, composeGlobalSearchRoots, globalSearchSettingsVersion, initializeGlobalSearchSettings } = await import("../globalSearchSettings");
describe("globalSearchSettings", () => {
beforeEach(() => {
(globalThis as { localStorage?: Storage }).localStorage = new MemoryStorage() as unknown as Storage;
mocks.save.mockClear();
mocks.load.mockReset().mockResolvedValue(null);
mocks.sqlFolders.mockReset().mockReturnValue([]);
});
it("defaults extensions to sql", () => {
expect(getGlobalSearchExtensions()).toEqual(DEFAULT_GLOBAL_SEARCH_EXTENSIONS);
expect(getGlobalSearchExtensions()).toEqual(["sql"]);
});
it("normalizes extensions (dot prefix, case, whitespace, dedupe) and persists to disk", () => {
const versionBefore = globalSearchSettingsVersion.value;
saveGlobalSearchExtensions([" .SQL ", "Txt", "sql", ""]);
expect(getGlobalSearchExtensions()).toEqual(["sql", "txt"]);
expect(globalSearchSettingsVersion.value).toBe(versionBefore + 1);
expect(mocks.save).toHaveBeenCalledTimes(1);
expect(mocks.save).toHaveBeenCalledWith({ roots: [], extensions: ["sql", "txt"] });
});
it("falls back to default extensions when the list becomes empty", () => {
saveGlobalSearchExtensions([" ", "."]);
expect(getGlobalSearchExtensions()).toEqual(["sql"]);
});
it("saves roots, dropping blank entries, and persists to disk", () => {
const versionBefore = globalSearchSettingsVersion.value;
saveGlobalSearchRoots([" D:\\work\\sql ", "", "E:\\docs"]);
expect(getGlobalSearchRoots()).toEqual(["D:\\work\\sql", "E:\\docs"]);
expect(globalSearchSettingsVersion.value).toBe(versionBefore + 1);
expect(mocks.save).toHaveBeenCalledTimes(1);
expect(mocks.save).toHaveBeenCalledWith({ roots: ["D:\\work\\sql", "E:\\docs"], extensions: ["sql"] });
});
it("composes SQL folders plus extra roots without duplicates", () => {
mocks.sqlFolders.mockReturnValue(["D:\\sql", "D:\\shared"]);
saveGlobalSearchRoots(["D:\\shared", "E:\\extra", " "]);
expect(composeGlobalSearchRoots()).toEqual(["D:\\sql", "D:\\shared", "E:\\extra"]);
});
it("hydrates roots and extensions from the disk store", async () => {
mocks.load.mockResolvedValue({
roots: ["D:\\disk-root"],
extensions: [".sql", "txt"],
});
await initializeGlobalSearchSettings();
expect(getGlobalSearchRoots()).toEqual(["D:\\disk-root"]);
expect(getGlobalSearchExtensions()).toEqual(["sql", "txt"]);
// Hydration re-persists the normalized values back to disk.
expect(mocks.save).toHaveBeenCalledTimes(2);
expect(mocks.save).toHaveBeenLastCalledWith({ roots: ["D:\\disk-root"], extensions: ["sql", "txt"] });
});
it("keeps current values when the disk store is empty", async () => {
saveGlobalSearchRoots(["D:\\keep"]);
await initializeGlobalSearchSettings();
expect(getGlobalSearchRoots()).toEqual(["D:\\keep"]);
});
it("ignores malformed disk payloads", async () => {
saveGlobalSearchExtensions(["sql"]);
mocks.load.mockResolvedValue({ roots: "not-an-array", extensions: 42 });
await initializeGlobalSearchSettings();
expect(getGlobalSearchRoots()).toEqual([]);
expect(getGlobalSearchExtensions()).toEqual(["sql"]);
});
});
@@ -0,0 +1,111 @@
import { ref } from "vue";
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
import { getSqlFileFolderPaths } from "@/lib/sqlFile/sqlFileFolders";
import * as api from "@/lib/backend/api";
const ROOTS_STORAGE_KEY = "dbx-global-search-roots";
const EXTENSIONS_STORAGE_KEY = "dbx-global-search-extensions";
export const DEFAULT_GLOBAL_SEARCH_EXTENSIONS = ["sql"];
/**
* Shared reactive version counter — bumped whenever global-search settings change.
* useQuickOpen watches this to know when to re-run the current search.
*/
export const globalSearchSettingsVersion = ref(0);
/** Additional search root directories (on top of the configured SQL file folders). */
export function getGlobalSearchRoots(): string[] {
try {
const raw = safeLocalStorageGet(ROOTS_STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed.filter((p): p is string => typeof p === "string") : [];
} catch {
return [];
}
}
/** File extensions to search (normalized, without a leading dot). */
export function getGlobalSearchExtensions(): string[] {
try {
const raw = safeLocalStorageGet(EXTENSIONS_STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
const normalized = parsed
.filter((ext): ext is string => typeof ext === "string")
.map((ext) => ext.trim().replace(/^\.+/, "").toLowerCase())
.filter((ext) => ext.length > 0);
if (normalized.length > 0) return normalized;
}
}
} catch {
// fall through to default
}
return [...DEFAULT_GLOBAL_SEARCH_EXTENSIONS];
}
export function saveGlobalSearchRoots(roots: string[]): void {
const normalized = [...new Set(roots.map((root) => root.trim()).filter((root) => root.length > 0))];
safeLocalStorageSet(ROOTS_STORAGE_KEY, JSON.stringify(normalized));
globalSearchSettingsVersion.value++;
void persistGlobalSearchSettings();
}
export function saveGlobalSearchExtensions(extensions: string[]): void {
const normalized = extensions.map((ext) => ext.trim().replace(/^\.+/, "").toLowerCase()).filter((ext) => ext.length > 0);
if (normalized.length === 0) {
safeLocalStorageSet(EXTENSIONS_STORAGE_KEY, JSON.stringify(DEFAULT_GLOBAL_SEARCH_EXTENSIONS));
} else {
safeLocalStorageSet(EXTENSIONS_STORAGE_KEY, JSON.stringify([...new Set(normalized)]));
}
globalSearchSettingsVersion.value++;
void persistGlobalSearchSettings();
}
/** Persist the current roots + extensions to disk (Tauri) for durable storage. */
async function persistGlobalSearchSettings(): Promise<void> {
try {
await api.saveGlobalSearchSettings({
roots: getGlobalSearchRoots(),
extensions: getGlobalSearchExtensions(),
});
} catch (error) {
// Web/browser mode may not expose persistence; ignore.
}
}
/**
* Hydrate the localStorage-backed settings from the disk store once at startup.
* Prefers configured SQL-file folders, so this only seeds extra roots/extensions.
*/
export async function initializeGlobalSearchSettings(): Promise<void> {
try {
const disk = await api.loadGlobalSearchSettings();
if (!disk) return;
if (Array.isArray(disk.roots) && disk.roots.length > 0) {
saveGlobalSearchRoots(disk.roots);
}
if (Array.isArray(disk.extensions) && disk.extensions.length > 0) {
saveGlobalSearchExtensions(disk.extensions);
}
} catch {
// No disk store (pure web mode) — keep current values.
}
}
// Seed from disk once on import; later saves also write back to disk.
void initializeGlobalSearchSettings();
/** Union of the configured SQL file folders and the extra global-search roots. */
export function composeGlobalSearchRoots(): string[] {
const seen = new Set<string>();
const combined: string[] = [];
for (const root of [...getSqlFileFolderPaths(), ...getGlobalSearchRoots()]) {
const normalized = root.trim();
if (!normalized || seen.has(normalized)) continue;
seen.add(normalized);
combined.push(normalized);
}
return combined;
}
+5 -1
View File
@@ -803,6 +803,8 @@ function expandStarProjectionColumnsForSource(analysis: EditableQueryInfo, sourc
let saveTabsQueue = Promise.resolve();
let persistTimer: ReturnType<typeof setTimeout> | null = null;
let persistGeneration = 0;
/** Monotonic id for content-search jump requests; lets repeated clicks on the same result re-trigger the editor reveal. */
let contentRevealSeq = 0;
function saveTabs(tabs: QueryTab[], activeTabId: string | null, workspace?: EditorWorkspacePersistState): Promise<void> {
if (isDetachedWindow()) return Promise.resolve();
@@ -2939,10 +2941,11 @@ export const useQueryStore = defineStore("query", () => {
});
}
function openExternalSqlFile(connectionId: string, database: string, path: string, sql: string, version?: QueryTab["externalSqlFileVersion"], catalog?: string, schema?: string) {
function openExternalSqlFile(connectionId: string, database: string, path: string, sql: string, version?: QueryTab["externalSqlFileVersion"], catalog?: string, schema?: string, reveal?: { line: number; column?: number }) {
const normalizedPath = normalizeExternalSqlPath(path);
const existing = tabs.value.find((tab) => tab.mode === "query" && tab.externalSqlPath && normalizeExternalSqlPath(tab.externalSqlPath) === normalizedPath);
if (existing) {
if (reveal) existing.editorRevealRequest = { id: ++contentRevealSeq, line: reveal.line, column: reveal.column };
switchTab(existing.id);
return existing.id;
}
@@ -2966,6 +2969,7 @@ export const useQueryStore = defineStore("query", () => {
originalSql: sql,
externalSqlPath: path,
externalSqlFileVersion: version,
editorRevealRequest: reveal ? { id: ++contentRevealSeq, line: reveal.line, column: reveal.column } : undefined,
isExecuting: false,
isCancelling: false,
isExplaining: false,
+6
View File
@@ -1660,6 +1660,12 @@ export interface QueryTab {
anchor: number;
head: number;
};
/** Ephemeral request to move the cursor/scrolling to a specific line/column (e.g. global content search jump). */
editorRevealRequest?: {
id: number;
line: number;
column?: number;
};
executionId?: string;
/** Ephemeral result run targeted by the current execution; null means a new run is being produced. */
executingResultRunId?: string | null;