From 04c6e67bbd4a0fcf292b9c23db520f149d361113 Mon Sep 17 00:00:00 2001 From: gggaiitx <62124152+gggaiitx@users.noreply.github.com> Date: Sat, 19 Sep 2026 00:21:27 +0800 Subject: [PATCH] feat(search): add global file content search in QuickOpen --- Cargo.lock | 118 +++++- apps/desktop/src/App.vue | 36 +- .../src/components/editor/QueryEditor.vue | 35 ++ .../src/components/layout/ContentArea.vue | 8 + .../components/quick-open/QuickOpenDialog.vue | 229 ++++++++++- apps/desktop/src/composables/useQuickOpen.ts | 167 +++++++- .../quickOpenNamespaceParity.spec.ts | 59 +++ apps/desktop/src/i18n/locales/az.ts | 14 + apps/desktop/src/i18n/locales/en.ts | 14 + apps/desktop/src/i18n/locales/es.ts | 14 + apps/desktop/src/i18n/locales/it.ts | 14 + apps/desktop/src/i18n/locales/ja.ts | 14 + apps/desktop/src/i18n/locales/ko.ts | 14 + apps/desktop/src/i18n/locales/pt-BR.ts | 14 + apps/desktop/src/i18n/locales/tr.ts | 14 + apps/desktop/src/i18n/locales/zh-CN.ts | 14 + apps/desktop/src/i18n/locales/zh-TW.ts | 14 + apps/desktop/src/lib/backend/api.ts | 6 + apps/desktop/src/lib/backend/http.ts | 41 ++ apps/desktop/src/lib/backend/tauri.ts | 38 ++ .../src/lib/editor/keyboardShortcuts.ts | 4 + .../src/lib/editor/shortcutRegistry.ts | 7 + .../__tests__/globalSearchSettings.spec.ts | 104 +++++ .../lib/globalSearch/globalSearchSettings.ts | 111 +++++ apps/desktop/src/stores/queryStore.ts | 6 +- apps/desktop/src/types/database.ts | 6 + src-tauri/Cargo.toml | 2 + src-tauri/src/commands/app_settings.rs | 24 ++ src-tauri/src/commands/global_search.rs | 380 ++++++++++++++++++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/lib.rs | 3 + 31 files changed, 1509 insertions(+), 16 deletions(-) create mode 100644 apps/desktop/src/i18n/__tests__/quickOpenNamespaceParity.spec.ts create mode 100644 apps/desktop/src/lib/globalSearch/__tests__/globalSearchSettings.spec.ts create mode 100644 apps/desktop/src/lib/globalSearch/globalSearchSettings.ts create mode 100644 src-tauri/src/commands/global_search.rs diff --git a/Cargo.lock b/Cargo.lock index a1f445b108..ff9e72b7ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1128,6 +1128,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" dependencies = [ "memchr", + "regex-automata", "serde_core", ] @@ -2031,6 +2032,8 @@ dependencies = [ "deadpool-postgres", "font-kit", "futures", + "grep", + "ignore", "libloading 0.8.9", "log", "minisign-verify", @@ -2873,6 +2876,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "encoding_rs_io" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fba3fe847045ecff794b9c138293a80db914678c453ad63fbf0c6a9eb6e00b22" +dependencies = [ + "encoding_rs", +] + [[package]] name = "endi" version = "1.1.1" @@ -3648,6 +3660,85 @@ dependencies = [ "system-deps", ] +[[package]] +name = "grep" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "308ae749734e28d749a86f33212c7b756748568ce332f970ac1d9cd8531f32e6" +dependencies = [ + "grep-cli", + "grep-matcher", + "grep-printer", + "grep-regex", + "grep-searcher", +] + +[[package]] +name = "grep-cli" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf32d263c5d5cc2a23ce587097f5ddafdb188492ba2e6fb638eaccdc22453631" +dependencies = [ + "bstr", + "globset", + "libc", + "log", + "termcolor", + "winapi-util", +] + +[[package]] +name = "grep-matcher" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9417543f4870fc8f1c8e1af870afae2431007626d9e703fce6471c468d33847" +dependencies = [ + "memchr", +] + +[[package]] +name = "grep-printer" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c112110ae4a891aa4d83ab82ecf734b307497d066f437686175e83fbd4e013fe" +dependencies = [ + "bstr", + "grep-matcher", + "grep-searcher", + "log", + "serde", + "serde_json", + "termcolor", +] + +[[package]] +name = "grep-regex" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce0c256c3ad82bcc07b812c15a45ec1d398122e8e15124f96695234db7112ef" +dependencies = [ + "bstr", + "grep-matcher", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "grep-searcher" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72348823a0eafc4bc2e9051064f28b5b42cc100b571b3a35d67918d711efcbc6" +dependencies = [ + "bstr", + "encoding_rs", + "encoding_rs_io", + "grep-matcher", + "log", + "memchr", + "memmap2", +] + [[package]] name = "gtk" version = "0.18.2" @@ -4291,6 +4382,22 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "ignore" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b17771570a2b94107741a7b033f19132c2eee21d59d21b24d2ced26500bd66e" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "image" version = "0.25.10" @@ -5029,6 +5136,15 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + [[package]] name = "memo-map" version = "0.3.3" @@ -9218,7 +9334,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 7ca41b70f0..4218665e9b 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -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(() => { - + diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 7a2482d591..27d931ae72 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -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); diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index 86cf071a1f..2b8a00cc66 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -242,6 +242,12 @@ const emit = defineEmits(); 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)" diff --git a/apps/desktop/src/components/quick-open/QuickOpenDialog.vue b/apps/desktop/src/components/quick-open/QuickOpenDialog.vue index 646fdc3010..c484a942bc 100644 --- a/apps/desktop/src/components/quick-open/QuickOpenDialog.vue +++ b/apps/desktop/src/components/quick-open/QuickOpenDialog.vue @@ -1,13 +1,18 @@