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

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