feat(search): add global file content search in QuickOpen
This commit is contained in:
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>
|
||||
|
||||
Reference in new issue
Block a user