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
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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ç",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "次に表示したタブに進む",
|
||||
|
||||
@@ -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: "다음에 본 탭으로 이동",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "前进到下一个查看的标签页",
|
||||
|
||||
@@ -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: "前進到下一個檢視的分頁",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user