From 350a49cef9b46374ebfdde724121058c6560d5ca Mon Sep 17 00:00:00 2001 From: travertexg Date: Mon, 9 Jun 2025 09:40:16 +0000 Subject: [PATCH] feat: Add Omnisearch support for regex search This commit introduces the option to use Omnisearch as a backend for the regex search functionality, in addition to the existing ripgrep backend. --- src/components/chat-view/ChatView.tsx | 13 +- src/core/regex/omnisearch-index.ts | 140 ++++++++++++++++++ src/core/regex/regex-common.ts | 63 ++++++++ .../index.ts => regex/ripgrep-index.ts} | 70 +-------- src/lang/locale/en.ts | 6 +- src/lang/locale/zh-cn.ts | 6 +- src/settings/SettingTab.tsx | 15 ++ src/types/settings.ts | 1 + 8 files changed, 246 insertions(+), 68 deletions(-) create mode 100644 src/core/regex/omnisearch-index.ts create mode 100644 src/core/regex/regex-common.ts rename src/core/{ripgrep/index.ts => regex/ripgrep-index.ts} (68%) diff --git a/src/components/chat-view/ChatView.tsx b/src/components/chat-view/ChatView.tsx index 7a2c762..5035a91 100644 --- a/src/components/chat-view/ChatView.tsx +++ b/src/components/chat-view/ChatView.tsx @@ -29,7 +29,8 @@ import { LLMBaseUrlNotSetException, LLMModelNotSetException, } from '../../core/llm/exception' -import { regexSearchFiles } from '../../core/ripgrep' +import { regexSearchFilesWithRipgrep } from '../../core/regex/ripgrep-index' +import { regexSearchFilesWithOmnisearch } from '../../core/regex/omnisearch-index' import { useChatHistory } from '../../hooks/use-chat-history' import { useCustomModes } from '../../hooks/use-custom-mode' import { t } from '../../lang/helpers' @@ -609,10 +610,16 @@ const Chat = forwardRef((props, ref) => { } } else if (toolArgs.type === 'regex_search_files') { // @ts-expect-error Obsidian API type mismatch + const searchBackend = settings.regexSearchBackend const baseVaultPath = String(app.vault.adapter.getBasePath()) - const ripgrepPath = settings.ripgrepPath const absolutePath = path.join(baseVaultPath, toolArgs.filepath) - const results = await regexSearchFiles(absolutePath, toolArgs.regex, ripgrepPath) + let results: string; + if (searchBackend === 'omnisearch') { + results = await regexSearchFilesWithOmnisearch(absolutePath, toolArgs.regex, app) + } else { + const ripgrepPath = settings.ripgrepPath + results = await regexSearchFilesWithRipgrep(absolutePath, toolArgs.regex, ripgrepPath) + } const formattedContent = `[regex_search_files for '${toolArgs.filepath}'] Result:\n${results}\n`; return { type: 'regex_search_files', diff --git a/src/core/regex/omnisearch-index.ts b/src/core/regex/omnisearch-index.ts new file mode 100644 index 0000000..15322c5 --- /dev/null +++ b/src/core/regex/omnisearch-index.ts @@ -0,0 +1,140 @@ +import { App } from "obsidian"; +import { + MAX_RESULTS, + truncateLine, + SearchResult, + formatResults, +} from './regex-common'; + +// --- Omnisearch API and Helper Types --- + +type SearchMatchApi = { + match: string; + offset: number; +}; + +type ResultNoteApi = { + score: number; + vault: string; + path: string; + basename: string; + foundWords: string[]; + matches: SearchMatchApi[]; + excerpt: string; +}; + +type OmnisearchApi = { + search: (query: string) => Promise; + // ... other API methods +}; + +declare global { + interface Window { + omnisearch: OmnisearchApi; + } +} + +/** + * Checks if the Omnisearch plugin's API is available. + * @returns {boolean} True if the API is ready, false otherwise. + */ +function isOmnisearchAvailable(): boolean { + return window.omnisearch && typeof window.omnisearch.search === "function"; +} + +/** + * Finds the line number, column number, and content for a given character offset in a file. + * @param allLines All lines in the file. + * @param offset The character offset of the match. + * @returns An object with line number, column number, and the full line content. + */ +function findLineAndColumnFromOffset( + allLines: string[], + offset: number +): { lineNumber: number; columnNumber: number; lineContent: string } { + let charCount = 0; + for (let i = 0; i < allLines.length; i++) { + const line = allLines[i]; + // The line ending length (1 for \n, 2 for \r\n) can vary. + // A simple +1 is a reasonable approximation for this calculation. + const lineEndOffset = charCount + line.length + 1; + + if (offset < lineEndOffset) { + const columnNumber = offset - charCount; + return { lineNumber: i, columnNumber, lineContent: line }; + } + charCount = lineEndOffset; + } + return { lineNumber: -1, columnNumber: -1, lineContent: "" }; +} + +/** + * Searches using Omnisearch and builds context for each match to replicate ripgrep's output. + * @param vaultPath The absolute path of the vault for making relative paths. + * @param query The search query for Omnisearch. Note: Omnisearch does not support full regex. + * @param app The Obsidian App instance. + * @returns A formatted string of search results. + */ +export async function regexSearchFilesWithOmnisearch( + vaultPath: string, + query: string, + app: App, +): Promise { + try { + if (!isOmnisearchAvailable()) { + throw new Error( + "Omnisearch plugin not found or not active. Please install and enable it to use this search feature." + ); + } + + // Omnisearch is not a regex engine. The function name is kept for consistency + // but the `query` will be treated as a keyword/fuzzy search by the plugin. + const apiResults = await window.omnisearch.search(query); + if (!apiResults || apiResults.length === 0) { + throw new Error("No results found."); + } + + const results: SearchResult[] = []; + + for (const result of apiResults) { + if (results.length >= MAX_RESULTS) { + break; // Stop processing new files if we have enough results + } + if (!result.matches || result.matches.length === 0) continue; + + const fileContent = await app.vault.adapter.read(result.path); + const allLines = fileContent.split("\n"); + + for (const match of result.matches) { + if (results.length >= MAX_RESULTS) { + break; // Stop processing matches if we have enough results + } + + const { lineNumber, columnNumber, lineContent } = findLineAndColumnFromOffset( + allLines, + match.offset + ); + + if (lineNumber === -1) continue; + + const searchResult: SearchResult = { + file: result.path, + line: lineNumber + 1, // ripgrep is 1-based, so we adjust + column: columnNumber + 1, + match: truncateLine(lineContent.trimEnd()), + beforeContext: lineNumber > 0 ? [truncateLine(allLines[lineNumber - 1].trimEnd())] : [], + afterContext: + lineNumber < allLines.length - 1 + ? [truncateLine(allLines[lineNumber + 1].trimEnd())] + : [], + }; + results.push(searchResult); + } + } + + return formatResults(results, vaultPath); + } catch (error) { + console.error("Error during Omnisearch processing:", error); + return "An error occurred during the search."; + } +} \ No newline at end of file diff --git a/src/core/regex/regex-common.ts b/src/core/regex/regex-common.ts new file mode 100644 index 0000000..0e5b3e7 --- /dev/null +++ b/src/core/regex/regex-common.ts @@ -0,0 +1,63 @@ +import * as path from "path" + +// Constants +export const MAX_RESULTS = 300 +export const MAX_LINE_LENGTH = 500 + +/** + * Truncates a line if it exceeds the maximum length + * @param line The line to truncate + * @param maxLength The maximum allowed length (defaults to MAX_LINE_LENGTH) + * @returns The truncated line, or the original line if it's shorter than maxLength + */ +export function truncateLine(line: string, maxLength: number = MAX_LINE_LENGTH): string { + return line.length > maxLength ? line.substring(0, maxLength) + " [truncated...]" : line +} + +export interface SearchResult { + file: string + line: number + column?: number + match?: string + beforeContext: string[] + afterContext: string[] +} + +export function formatResults(results: SearchResult[], cwd: string): string { + const groupedResults: { [key: string]: SearchResult[] } = {} + + let output = "" + if (results.length >= MAX_RESULTS) { + output += `Showing first ${MAX_RESULTS} of ${MAX_RESULTS}+ results. Use a more specific search if necessary.\n\n` + } else { + output += `Found ${results.length === 1 ? "1 result" : `${results.length.toLocaleString()} results`}.\n\n` + } + + // Group results by file name + results.slice(0, MAX_RESULTS).forEach((result) => { + const relativeFilePath = path.relative(cwd, result.file) + if (!groupedResults[relativeFilePath]) { + groupedResults[relativeFilePath] = [] + } + groupedResults[relativeFilePath].push(result) + }) + + for (const [filePath, fileResults] of Object.entries(groupedResults)) { + output += `${filePath.toPosix()}\n│----\n` + + fileResults.forEach((result, index) => { + const allLines = [...result.beforeContext, result.match, ...result.afterContext] + allLines.forEach((line) => { + output += `│${line?.trimEnd() ?? ""}\n` + }) + + if (index < fileResults.length - 1) { + output += "│----\n" + } + }) + + output += "│----\n\n" + } + + return output.trim() +} \ No newline at end of file diff --git a/src/core/ripgrep/index.ts b/src/core/regex/ripgrep-index.ts similarity index 68% rename from src/core/ripgrep/index.ts rename to src/core/regex/ripgrep-index.ts index 1572c89..eb972dc 100644 --- a/src/core/ripgrep/index.ts +++ b/src/core/regex/ripgrep-index.ts @@ -3,33 +3,16 @@ import * as childProcess from "child_process" import * as fs from "fs" import * as path from "path" import * as readline from "readline" +import { + MAX_RESULTS, + truncateLine, + SearchResult, + formatResults +} from './regex-common' const isWindows = /^win/.test(process.platform) const binName = isWindows ? "rg.exe" : "rg" -interface SearchResult { - file: string - line: number - column: number - match: string - beforeContext: string[] - afterContext: string[] -} - -// Constants -const MAX_RESULTS = 300 -const MAX_LINE_LENGTH = 500 - -/** - * Truncates a line if it exceeds the maximum length - * @param line The line to truncate - * @param maxLength The maximum allowed length (defaults to MAX_LINE_LENGTH) - * @returns The truncated line, or the original line if it's shorter than maxLength - */ -export function truncateLine(line: string, maxLength: number = MAX_LINE_LENGTH): string { - return line.length > maxLength ? line.substring(0, maxLength) + " [truncated...]" : line -} - async function getBinPath(ripgrepPath: string): Promise { const binPath = path.join(ripgrepPath, binName) return (await pathExists(binPath)) ? binPath : undefined @@ -83,7 +66,7 @@ async function execRipgrep(bin: string, args: string[]): Promise { }) } -export async function regexSearchFiles( +export async function regexSearchFilesWithRipgrep( directoryPath: string, regex: string, ripgrepPath: string, @@ -162,42 +145,3 @@ export async function regexSearchFiles( return formatResults(results, directoryPath) } - -function formatResults(results: SearchResult[], cwd: string): string { - const groupedResults: { [key: string]: SearchResult[] } = {} - - let output = "" - if (results.length >= MAX_RESULTS) { - output += `Showing first ${MAX_RESULTS} of ${MAX_RESULTS}+ results. Use a more specific search if necessary.\n\n` - } else { - output += `Found ${results.length === 1 ? "1 result" : `${results.length.toLocaleString()} results`}.\n\n` - } - - // Group results by file name - results.slice(0, MAX_RESULTS).forEach((result) => { - const relativeFilePath = path.relative(cwd, result.file) - if (!groupedResults[relativeFilePath]) { - groupedResults[relativeFilePath] = [] - } - groupedResults[relativeFilePath].push(result) - }) - - for (const [filePath, fileResults] of Object.entries(groupedResults)) { - output += `${filePath.toPosix()}\n│----\n` - - fileResults.forEach((result, index) => { - const allLines = [...result.beforeContext, result.match, ...result.afterContext] - allLines.forEach((line) => { - output += `│${line?.trimEnd() ?? ""}\n` - }) - - if (index < fileResults.length - 1) { - output += "│----\n" - } - }) - - output += "│----\n\n" - } - - return output.trim() -} diff --git a/src/lang/locale/en.ts b/src/lang/locale/en.ts index e339ae8..868ecc0 100644 --- a/src/lang/locale/en.ts +++ b/src/lang/locale/en.ts @@ -227,8 +227,12 @@ export default { auto: 'Auto', semantic: 'Semantic', regex: 'Regex', + regexBackend: 'Regex search backend', + regexBackendDescription: 'Choose the backend for regex search method.', + ripgrep: 'ripgrep', + omnisearch: 'Omnisearch', ripgrepPath: 'ripgrep path', - ripgrepPathDescription: 'Path to the ripgrep binary. When using regex search, this is required.', + ripgrepPathDescription: 'Path to the ripgrep binary. When using ripgrep regex search, this is required.', }, // Chat Behavior Section diff --git a/src/lang/locale/zh-cn.ts b/src/lang/locale/zh-cn.ts index bd30cdb..8fe4c84 100644 --- a/src/lang/locale/zh-cn.ts +++ b/src/lang/locale/zh-cn.ts @@ -228,8 +228,12 @@ export default { auto: '自动', semantic: '语义', regex: '正则', + regexBackend: '正则搜索后端', + regexBackendDescription: '选择正则搜索的后端。', + ripgrep: 'ripgrep', + omnisearch: 'Omnisearch', ripgrepPath: 'ripgrep 路径', - ripgrepPathDescription: 'ripgrep 二进制文件的路径。使用正则搜索时需要此项。', + ripgrepPathDescription: 'ripgrep 二进制文件的路径。使用 ripgrep 正则搜索时需要此项。', }, // 聊天行为部分 diff --git a/src/settings/SettingTab.tsx b/src/settings/SettingTab.tsx index 2500749..fba345e 100644 --- a/src/settings/SettingTab.tsx +++ b/src/settings/SettingTab.tsx @@ -163,6 +163,21 @@ export class InfioSettingTab extends PluginSettingTab { }) }), ) + new Setting(containerEl) + .setName(t('settings.FilesSearch.regexBackend')) + .setDesc(t('settings.FilesSearch.regexBackendDescription')) + .addDropdown((dropdown) => + dropdown + .addOption('ripgrep', t('settings.FilesSearch.ripgrep')) + .addOption('omnisearch', t('settings.FilesSearch.omnisearch')) + .setValue(this.plugin.settings.regexSearchBackend) + .onChange(async (value) => { + await this.plugin.setSettings({ + ...this.plugin.settings, + regexSearchBackend: value as 'ripgrep' | 'omnisearch', + }) + }), + ) new Setting(containerEl) .setName(t('settings.FilesSearch.ripgrepPath')) .setDesc(t('settings.FilesSearch.ripgrepPathDescription')) diff --git a/src/types/settings.ts b/src/types/settings.ts index 352de1a..a67ae9f 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -261,6 +261,7 @@ export const InfioSettingsSchema = z.object({ // Files Search filesSearchMethod: z.enum(['regex', 'semantic', 'auto']).catch('auto'), + regexSearchBackend: z.enum(['omnisearch', 'ripgrep']).catch('ripgrep'), ripgrepPath: z.string().catch(''), /// [compatible]