OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / routes / search.js

200 lines · javascript ref:
/**
 * Search Routes — repo search and code search
 */

import path from 'bare-path'
import { html, json } from '../response.js'
import { renderSearchPage } from '../repo-pages-ui.js'

export function registerSearchRoutes(router) {
  router.get('/api/repos/search', handleSearchRepos)
  router.get('/api/search/code', handleSearchCode)
  router.get('/search', handleSearchPage)
}

async function handleSearchRepos(req, res, server) {
  const { repos } = server.repoData
  try {
    const url = new URL(req.url, `http://${req.headers.host}`)
    const query = url.searchParams.get('q') || ''
    const limit = parseInt(url.searchParams.get('limit')) || 20

    if (!query || query.length < 2) {
      json(res, { repos: [], query, message: 'Query must be at least 2 characters' })
      return
    }

    const q = query.toLowerCase()
    const results = repos
      .filter(repo => {
        const nameMatch = repo.name.toLowerCase().includes(q)
        const descMatch = repo.description?.toLowerCase().includes(q)
        const categoryMatch = repo.category?.toLowerCase().includes(q)
        const namespaceMatch = repo.namespace?.toLowerCase().includes(q)
        return nameMatch || descMatch || categoryMatch || namespaceMatch
      })
      .map(repo => ({
        name: repo.name,
        description: repo.description || null,
        category: repo.category || null,
        namespace: repo.namespace || null,
        commits: repo.commits || 0,
        branches: repo.branches || 0,
        // Calculate relevance score (name match is higher)
        relevance: repo.name.toLowerCase().includes(q) ? 2 : 1
      }))
      .sort((a, b) => {
        // Sort by relevance, then alphabetically
        if (b.relevance !== a.relevance) return b.relevance - a.relevance
        return a.name.localeCompare(b.name)
      })
      .slice(0, limit)

    json(res, {
      repos: results,
      query,
      total: results.length
    })
  } catch (err) {
    console.error('[Search] Repo search error:', err)
    json(res, { error: err.message }, 500)
  }
}

/**
 * Search code across repositories using git grep
 * GET /api/search/code?q=query&repo=optional&limit=100
 */
async function handleSearchCode(req, res, server) {
  const { repos: allRepos, backendManager } = server.repoData
  try {
    const url = new URL(req.url, `http://${req.headers.host}`)
    const query = url.searchParams.get('q') || ''
    const repoFilter = url.searchParams.get('repo') || null
    const limit = parseInt(url.searchParams.get('limit')) || 100
    const maxRepos = parseInt(url.searchParams.get('maxRepos')) || 10

    if (!query || query.length < 2) {
      json(res, { results: [], query, message: 'Query must be at least 2 characters' })
      return
    }

    // Get repos to search
    let repos = allRepos
    if (repoFilter) {
      repos = repos.filter(r => r.name === repoFilter)
      if (repos.length === 0) {
        json(res, { results: [], query, message: `Repository "${repoFilter}" not found` })
        return
      }
    } else {
      repos = repos.slice(0, maxRepos)
    }

    const results = []
    let totalMatches = 0

    for (const repo of repos) {
      if (totalMatches >= limit) break

      try {
        const repoPath = backendManager.gitBrowser.getRepoPath(repo.name)

        // Use git grep for efficient searching
        // -n: line numbers, -I: ignore binary, --heading: group by file
        const output = await backendManager.gitBrowser.execGit(repoPath, [
          'grep', '-n', '-I', '-i', '--heading', '--break',
          '-e', query, 'HEAD'
        ]).catch(() => null) // grep returns non-zero for no matches — expected

        if (output && output.trim()) {
          const matches = parseGrepOutput(output, repo.name, limit - totalMatches)
          if (matches.length > 0) {
            results.push({
              repo: repo.name,
              matches
            })
            totalMatches += matches.length
          }
        }
      } catch (err) {
        // Skip repos that fail (might be empty or have issues)
        console.log(`[Search] Skipping ${repo.name}: ${err.message}`)
      }
    }

    json(res, {
      results,
      query,
      totalMatches,
      reposSearched: repos.length
    })
  } catch (err) {
    console.error('[Search] Code search error:', err)
    json(res, { error: err.message }, 500)
  }
}

/**
 * Parse git grep output into structured results
 */
function parseGrepOutput(output, repoName, limit) {
  const matches = []
  let currentFile = null

  const lines = output.split('\n')

  for (const line of lines) {
    if (matches.length >= limit) break

    // Empty line or separator
    if (!line.trim() || line === '--') {
      continue
    }

    // File heading (no line number)
    if (!line.includes(':') || line.match(/^[a-f0-9]{40}:/)) {
      // This is a new file path (git grep --heading format)
      // Format: HEAD:path/to/file.js
      const match = line.match(/^HEAD:(.+)$/)
      if (match) {
        currentFile = match[1]
      } else if (!line.includes(':')) {
        currentFile = line
      }
      continue
    }

    // Match line with line number
    // Format: lineNumber:content or lineNumber-content (context)
    const lineMatch = line.match(/^(\d+)([:-])(.*)$/)
    if (lineMatch && currentFile) {
      const [, lineNum, separator, content] = lineMatch
      matches.push({
        file: currentFile,
        line: parseInt(lineNum),
        content: content.trim(),
        isMatch: separator === ':'
      })
    }
  }

  return matches
}

/**
 * Search results page
 * GET /search?q=query&type=code|repo
 */
async function handleSearchPage(req, res, server) {
  const { repos } = server.repoData
  const { pathPrefix } = server.config
  const url = new URL(req.url, `http://${req.headers.host}`)
  const query = url.searchParams.get('q') || ''
  const searchType = url.searchParams.get('type') || 'repo'
  const repoFilter = url.searchParams.get('repo') || ''

  const content = renderSearchPage(query, searchType, repoFilter, repos, pathPrefix)
  html(res, content)
}