/**
* 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)
}