bookwiz.io / components / UnifiedSearchTab.tsx
UnifiedSearchTab.tsx
Raw
import { useState, useEffect, useMemo } from 'react'
import { FileOperationsService } from '@/lib/services/file-operations'
import { FileSystemItem } from '@/lib/types/database'
import { supabase } from '@/lib/supabase'
import SearchInput, { SearchMode } from './search/SearchInput'
import SearchOptionsComponent, { SearchOptions } from './search/SearchOptions'
import IndexingStatus from './search/IndexingStatus'
import SearchResults from './search/SearchResults'

interface TextSearchResult {
  id: string
  name: string
  file_extension: string
  matches: {
    line_number: number
    content: string
    context_before?: string
    context_after?: string
  }[]
  total_matches: number
}

interface SemanticSearchResult {
  chunk_id: string
  file_id: string
  file_name: string
  content: string
  similarity: number
  line_start: number
  line_end: number
}

interface UnifiedSearchTabProps {
  bookId?: string
  onFileSelect?: (file: FileSystemItem) => void
}

export default function UnifiedSearchTab({ bookId, onFileSelect }: UnifiedSearchTabProps) {
  // Search state
  const [searchQuery, setSearchQuery] = useState('')
  const [searchMode, setSearchMode] = useState<SearchMode>('text')
  const [isSearching, setIsSearching] = useState(false)

  // Text search state
  const [searchOptions, setSearchOptions] = useState<SearchOptions>({
    matchCase: false,
    matchWholeWord: false,
    useRegex: false,
    includeIgnored: false
  })
  const [includeFilter, setIncludeFilter] = useState('')
  const [excludeFilter, setExcludeFilter] = useState('')
  const [textResults, setTextResults] = useState<TextSearchResult[]>([])

  // Semantic search state
  const [semanticResults, setSemanticResults] = useState<SemanticSearchResult[]>([])
  const [indexingStatus, setIndexingStatus] = useState<any>(null)
  const [isIndexing, setIsIndexing] = useState(false)

  const fileOperations = useMemo(() => new FileOperationsService(), [])

  // Fetch indexing status on mount and mode change
  useEffect(() => {
    if (bookId && searchMode === 'semantic') {
      fetchIndexingStatus()
    }
  }, [bookId, searchMode])

  // Debounced search
  useEffect(() => {
    if (!searchQuery.trim() || !bookId) {
      setTextResults([])
      setSemanticResults([])
      return
    }

    const timeoutId = setTimeout(async () => {
      if (searchMode === 'text') {
        await performTextSearch()
      } else {
        await performSemanticSearch()
      }
    }, 300)

    return () => clearTimeout(timeoutId)
  }, [searchQuery, searchMode, searchOptions, includeFilter, excludeFilter, bookId])

  const fetchIndexingStatus = async () => {
    if (!bookId) return

    try {
      const { data: { session } } = await supabase.auth.getSession()
      const headers: Record<string, string> = { 'Content-Type': 'application/json' }
      
      if (session?.access_token) {
        headers['Authorization'] = `Bearer ${session.access_token}`
      }

      const response = await fetch(`/api/books/${bookId}/semantic-search`, { headers })
      if (response.ok) {
        const data = await response.json()
        setIndexingStatus(data.stats)
      }
    } catch (error) {
      console.error('Error fetching indexing status:', error)
    }
  }

  const performTextSearch = async () => {
    if (!bookId || !searchQuery.trim()) return

    setIsSearching(true)
    try {
      let searchPattern = searchQuery

      if (searchOptions.useRegex) {
        searchPattern = searchQuery
      } else if (searchOptions.matchWholeWord) {
        searchPattern = `\\b${searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`
      } else {
        searchPattern = searchQuery
      }

      const results = await fileOperations.grepFiles(
        bookId,
        searchPattern,
        includeFilter || undefined,
        searchOptions.matchCase,
        searchOptions.useRegex
      )

      setTextResults(results)
    } catch (error) {
      console.error('Text search error:', error)
      setTextResults([])
    } finally {
      setIsSearching(false)
    }
  }

  const performSemanticSearch = async () => {
    if (!bookId || !searchQuery.trim()) return

    setIsSearching(true)
    try {
      const { data: { session } } = await supabase.auth.getSession()
      const headers: Record<string, string> = { 'Content-Type': 'application/json' }
      
      if (session?.access_token) {
        headers['Authorization'] = `Bearer ${session.access_token}`
      }

      const response = await fetch(`/api/books/${bookId}/semantic-search`, {
        method: 'POST',
        headers,
        body: JSON.stringify({
          query: searchQuery,
          maxResults: 10,
          similarityThreshold: 0.01
        })
      })

      if (response.ok) {
        const data = await response.json()
        setSemanticResults(data.results || [])
      } else {
        console.error('Semantic search failed:', response.statusText)
        setSemanticResults([])
      }
    } catch (error) {
      console.error('Semantic search error:', error)
      setSemanticResults([])
    } finally {
      setIsSearching(false)
    }
  }

  const startIndexing = async () => {
    if (!bookId || isIndexing) return

    setIsIndexing(true)
    try {
      const { data: { session } } = await supabase.auth.getSession()
      const headers: Record<string, string> = { 'Content-Type': 'application/json' }
      
      if (session?.access_token) {
        headers['Authorization'] = `Bearer ${session.access_token}`
      }

      const response = await fetch(`/api/books/${bookId}/index`, {
        method: 'POST',
        headers,
      })

      if (response.ok) {
        const interval = setInterval(() => {
          fetchIndexingStatus()
        }, 2000)

        setTimeout(() => {
          clearInterval(interval)
          setIsIndexing(false)
        }, 30000)
      }
    } catch (error) {
      console.error('Error starting indexing:', error)
      setIsIndexing(false)
    }
  }

  const handleResultClick = async (result: any, lineNumber?: number) => {
    if (!onFileSelect || !bookId) return

    try {
      let fileData, fileItem: FileSystemItem

      if (searchMode === 'text') {
        // Text search result
        fileData = await fileOperations.readFile(bookId, result.id)
        
        fileItem = {
          id: result.id,
          name: result.name,
          type: 'file',
          file_extension: result.file_extension,
          content: fileData.content,
          book_id: bookId,
          created_at: new Date().toISOString(),
          updated_at: new Date().toISOString(),
          parent_id: null,
          mime_type: null,
          file_size: null,
          file_url: null,
          expanded: false,
          sort_order: 0,
          metadata: {}
        }
      } else {
        // Semantic search result
        const { data: { session } } = await supabase.auth.getSession()
        const headers: Record<string, string> = { 'Content-Type': 'application/json' }
        
        if (session?.access_token) {
          headers['Authorization'] = `Bearer ${session.access_token}`
        }

        const response = await fetch(`/api/books/${bookId}/files/${result.file_id}`, { headers })
        
        if (!response.ok) {
          console.error('Failed to fetch file:', response.status, response.statusText)
          return
        }

        fileData = await response.json()
        
        fileItem = {
          id: result.file_id,
          name: result.file_name,
          type: 'file',
          file_extension: fileData.file_extension || 'md',
          content: fileData.content,
          book_id: bookId,
          created_at: new Date().toISOString(),
          updated_at: new Date().toISOString(),
          parent_id: null,
          mime_type: null,
          file_size: null,
          file_url: null,
          expanded: false,
          sort_order: 0,
          metadata: {}
        }
      }

      onFileSelect(fileItem)

      // Small delay to ensure file is loaded in editor before highlighting
      setTimeout(() => {
        if (searchMode === 'text') {
          // Text search: navigate to specific line and highlight search terms
          if (lineNumber) {
            window.dispatchEvent(new CustomEvent('search-goto-line', {
              detail: { 
                lineNumber: lineNumber,
                searchQuery: searchQuery,
                caseSensitive: searchOptions.matchCase,
                useRegex: searchOptions.useRegex,
                matchWholeWord: searchOptions.matchWholeWord,
                isSemanticSearch: false
              }
            }))
          } else {
            // Highlight search terms throughout the file
            window.dispatchEvent(new CustomEvent('search-highlight', {
              detail: { 
                searchQuery: searchQuery,
                caseSensitive: searchOptions.matchCase,
                useRegex: searchOptions.useRegex,
                matchWholeWord: searchOptions.matchWholeWord,
                isSemanticSearch: false
              }
            }))
          }
        } else {
          // Semantic search: navigate to the range and highlight the specific content
          window.dispatchEvent(new CustomEvent('search-goto-line', {
            detail: { 
              lineNumber: result.line_start,
              endLineNumber: result.line_end,
              searchQuery: searchQuery,
              semanticContent: result.content,
              isSemanticSearch: true,
              highlightRange: {
                startLine: result.line_start,
                endLine: result.line_end,
                content: result.content
              }
            }
          }))
        }
      }, 100)
    } catch (error) {
      console.error('Error opening file from search:', error)
    }
  }

  return (
    <div className="h-full flex flex-col relative overflow-hidden">
      
      {/* Search Input */}
      <SearchInput
        value={searchQuery}
        onChange={setSearchQuery}
        mode={searchMode}
        onModeChange={setSearchMode}
        isSearching={isSearching}
      />

      {/* Search Options (Text Mode Only) */}
      <SearchOptionsComponent
        mode={searchMode}
        options={searchOptions}
        onOptionsChange={setSearchOptions}
        includeFilter={includeFilter}
        excludeFilter={excludeFilter}
        onIncludeFilterChange={setIncludeFilter}
        onExcludeFilterChange={setExcludeFilter}
      />

      {/* Indexing Status (Semantic Mode Only) */}
      <IndexingStatus
        mode={searchMode}
        indexingStatus={indexingStatus}
        isIndexing={isIndexing}
        onStartIndexing={startIndexing}
      />

      {/* Search Results */}
      <SearchResults
        mode={searchMode}
        searchQuery={searchQuery}
        textResults={textResults}
        semanticResults={semanticResults}
        isSearching={isSearching}
        searchOptions={searchOptions}
        onResultClick={handleResultClick}
      />
    </div>
  )
}