[Help needed for testing] Global Search/Replace - Copy Search Results for Lines, Full File Content of All Results and More

Confirming your method works for me.
Relevant thread with my problem:

I had this made based on yours with AI help:

import { App, Modal, TFile, Notice } from 'obsidian';

class SearchModal extends Modal {
    private searchInput: HTMLInputElement;
    private resultsContainer: HTMLElement;
    private results: TFile[] = [];

    constructor(app: App) {
        super(app);
    }

    onOpen() {
        const {contentEl} = this;
        contentEl.empty();

        // Create title
        contentEl.createEl('h3', {text: 'Custom Search Obsidian'});

        // Create search input
        this.searchInput = contentEl.createEl('input', {
            type: 'text',
            placeholder: 'Enter search text (regex supported without /)...'
        });
        this.searchInput.style.width = '100%';
        this.searchInput.style.marginBottom = '10px';
        this.searchInput.style.padding = '8px';

        // Search button
        const searchButton = contentEl.createEl('button', {
            text: 'Search',
            cls: 'mod-cta'
        });
        searchButton.style.width = '100%';
        searchButton.style.marginBottom = '10px';
        
        // Results container
        this.resultsContainer = contentEl.createEl('div');
        this.resultsContainer.style.maxHeight = '60vh';
        this.resultsContainer.style.overflow = 'auto';
        this.resultsContainer.style.padding = '10px';

        // Handle search
        const performSearch = async () => {
            const searchText = this.searchInput.value;
            if (!searchText) {
                new Notice('Please enter search text');
                return;
            }

            this.resultsContainer.empty();
            this.results = [];

            try {
                let regex: RegExp;
                try {
                    regex = new RegExp(searchText, 'i');
                } catch (error) {
                    new Notice('Invalid regex pattern');
                    return;
                }

                await this.searchVault(regex);
            } catch (error) {
                new Notice('Search failed: ' + error.message);
                console.error('Search error:', error);
            }
        };

        // Add event listeners
        searchButton.onclick = performSearch;
        this.searchInput.addEventListener('keydown', async (e) => {
            if (e.key === 'Enter') {
                await performSearch();
            }
        });

        // Focus input
        this.searchInput.focus();
    }

    private async searchVault(regex: RegExp) {
        const startTime = performance.now();
        const files = this.app.vault.getMarkdownFiles();
        const results: Array<{file: TFile, content: string, matches: Array<{text: string, lineNumber: number, originalLine: string}>}> = [];

        for (const file of files) {
            try {
                const content = await this.app.vault.cachedRead(file);
                const lines = content.split('\n');
                const matches: Array<{text: string, lineNumber: number, originalLine: string}> = [];

                lines.forEach((line, index) => {
                    if (regex.test(line)) {
                        const highlighted = this.highlightMatches(line.trim(), regex);
                        matches.push({
                            text: highlighted,
                            lineNumber: index,
                            originalLine: line
                        });
                    }
                });

                if (matches.length > 0) {
                    results.push({
                        file,
                        content,
                        matches
                    });
                }
            } catch (error) {
                console.error(`Error reading ${file.path}:`, error);
            }
        }

        // Sort results by filename
        results.sort((a, b) => a.file.basename.localeCompare(b.file.basename));

        const endTime = performance.now();
        const searchTime = ((endTime - startTime) / 1000).toFixed(2);

        if (results.length === 0) {
            this.resultsContainer.createEl('div', {
                text: 'No results found'
            });
            return;
        }

        new Notice(`Found matches in ${results.length} files (${searchTime}s)`);

        // Display results
        for (const result of results) {
            const resultDiv = this.resultsContainer.createEl('div', { cls: 'search-result' });
            resultDiv.style.marginBottom = '15px';
            resultDiv.style.padding = '10px';
            resultDiv.style.borderBottom = '1px solid var(--background-modifier-border)';

            // Create file link
            const fileLink = resultDiv.createEl('div', { cls: 'search-result-file' });
            fileLink.style.marginBottom = '5px';
            fileLink.style.fontWeight = 'bold';
            fileLink.style.cursor = 'pointer';
            fileLink.textContent = result.file.basename;

            // Add click handler for file (goes to first match)
            fileLink.addEventListener('click', async () => {
                if (result.matches.length > 0) {
                    await this.navigateToMatch(result.file, result.matches[0].lineNumber);
                } else {
                    await this.app.workspace.activeLeaf.openFile(result.file);
                }
                this.close();
            });

            // Add matches
            const matchesContainer = resultDiv.createEl('div');
            for (const match of result.matches) {
                const matchDiv = matchesContainer.createEl('div');
                matchDiv.style.marginLeft = '10px';
                matchDiv.style.marginTop = '5px';
                matchDiv.style.cursor = 'pointer';
                // Use innerHTML for highlighted text
                matchDiv.innerHTML = match.text;

                // Add click handler for specific match
                matchDiv.addEventListener('click', async () => {
                    await this.navigateToMatch(result.file, match.lineNumber);
                    this.close();
                });
            }

            // Add copy button
            const copyButton = resultDiv.createEl('button', {
                text: 'Copy',
                cls: 'mod-cta'
            });
            copyButton.style.marginTop = '5px';
            copyButton.addEventListener('click', () => {
                const textToCopy = `[[${result.file.basename}]] | ${result.matches.map(m => m.originalLine.trim()).join('\n')}`;
                navigator.clipboard.writeText(textToCopy);
                new Notice('Copied to clipboard');
            });
        }
    }

    private highlightMatches(text: string, regex: RegExp): string {
        try {
            const escaped = text.replace(/[&<>'"]/g, char => ({
                '&': '&amp;',
                '<': '&lt;',
                '>': '&gt;',
                "'": '&#39;',
                '"': '&quot;'
            }[char] || char));

            return escaped.replace(regex, match => 
                `<span style="background-color: rgba(255, 165, 0, 0.5);">${match}</span>`
            );
        } catch (error) {
            console.error('Error highlighting matches:', error);
            return text;
        }
    }

    private async navigateToMatch(file: TFile, lineNumber: number) {
        try {
            // Open the file
            const leaf = this.app.workspace.getLeaf(false);
            await leaf.openFile(file);

            // Wait for the file to fully load and the editor to be ready
            await new Promise(resolve => setTimeout(resolve, 150));

            // Get the editor from the active view
            const activeView = this.app.workspace.getActiveViewOfType(require('obsidian').MarkdownView);
            
            if (activeView && activeView.editor) {
                const editor = activeView.editor;
                
                // Wait longer to override Remember Cursor Position plugin
                await new Promise(resolve => setTimeout(resolve, 400));
                
                // Set cursor position to the specific line multiple times to ensure it sticks
                const pos = { line: lineNumber, ch: 0 };
                editor.setCursor(pos);
                
                // Additional delay and re-set cursor to make sure it overrides other plugins
                await new Promise(resolve => setTimeout(resolve, 50));
                editor.setCursor(pos);
                
                // Scroll to make the line visible
                editor.scrollIntoView({ from: pos, to: pos }, true);
                
                // Focus the editor and set cursor one more time
                editor.focus();
                editor.setCursor(pos);
                
                console.log(`Navigated to line ${lineNumber + 1} in ${file.basename}`);
            } else {
                console.error('Could not get editor instance');
                new Notice('Could not navigate to line - editor not available');
            }
        } catch (error) {
            console.error('Error navigating to match:', error);
            new Notice('Error navigating to match: ' + error.message);
        }
    }

    onClose() {
        const {contentEl} = this;
        contentEl.empty();
    }
}

export class CustomVaultSearch {
    constructor(private app: App) {}

    async onload() {
        this.addCommand({
            id: 'custom-vault-search',
            name: 'Custom Vault Search',
            callback: () => this.openSearchModal()
        });
    }

    private async openSearchModal() {
        new SearchModal(this.app).open();
    }
}

export async function invoke(app: App): Promise<void> {
    new SearchModal(app).open();
}

This script has results in 4-5 seconds like Backlinks’ Unlinked Mentions I was talking about in the other thread. (First search is slow but any consecutive searcher are fast.)
So obviously something is wrong with the mobile app’s built in search element…

Thanks for the heads-up @Yurcee!

1 Like