/**
* Nav search autocomplete.
*
* Ported from pear-git v1 (lib/nav-ui.js). Two modes:
* - Names: GET /api/search/repos?q=...&limit=8
* - Content: GET /api/search/code?q=...&limit=20&maxRepos=5
*
* 200ms debounce, minimum 2 chars. Escape closes the dropdown, Enter
* navigates to the full search page (/search?q=&type=repo|code).
*/
(function () {
const input = document.getElementById('nav-search-input')
const dropdown = document.getElementById('nav-search-dropdown')
const results = document.getElementById('nav-search-results')
const loading = document.getElementById('nav-search-loading')
const wrap = document.getElementById('nav-search-wrap')
const tabs = document.querySelectorAll('.nav-search-tab')
if (!input || !dropdown || !results || !loading || !wrap) return
const pfx = (document.body && document.body.dataset.prefix) || ''
let searchType = 'repo'
let debounce = null
function esc (text) {
if (text == null) return ''
return String(text)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
}
function hl (text, q) {
if (!q) return text
const safe = q.replace(/[-\\/^$*+?.()|[\]{}]/g, '\\$&')
const r = new RegExp('(' + safe + ')', 'gi')
return text.replace(r, '<span class="hl">$1</span>')
}
function hide () {
dropdown.classList.remove('visible')
}
function show () {
dropdown.classList.add('visible')
}
function setType (type) {
searchType = type
tabs.forEach((t) => t.classList.toggle('active', t.dataset.type === type))
const q = input.value.trim()
if (q.length >= 2) run(q)
}
async function run (q) {
show()
loading.style.display = 'block'
results.innerHTML = ''
try {
if (searchType === 'repo') {
const r = await fetch(pfx + '/api/search/repos?q=' + encodeURIComponent(q) + '&limit=8')
const d = await r.json()
const rows = d.repos || []
if (!rows.length) {
results.innerHTML = '<div class="nav-search-empty">No repos found</div>'
return
}
results.innerHTML = rows.map((repo) =>
'<a class="nav-search-result" href="' + pfx + '/repos/' + encodeURIComponent(repo.name) + '" style="display:block;text-decoration:none">' +
'<div class="nav-search-result-name">' + hl(esc(repo.name), q) + '</div>' +
'<div class="nav-search-result-meta">' +
(repo.description ? esc(repo.description) + ' · ' : '') +
(repo.commits || 0) + ' commit' + (repo.commits === 1 ? '' : 's') +
'</div>' +
'</a>'
).join('') +
'<div class="nav-search-footer"><a href="' + pfx + '/search?q=' + encodeURIComponent(q) + '&type=repo">View all →</a></div>'
} else {
const r = await fetch(pfx + '/api/search/code?q=' + encodeURIComponent(q) + '&limit=20&maxRepos=5')
const d = await r.json()
const rows = d.results || []
if (!rows.length) {
results.innerHTML = '<div class="nav-search-empty">No code matches</div>'
return
}
// Flatten up to 8 first-matches per repo
const flat = []
for (const result of rows) {
for (const m of (result.matches || []).slice(0, 3)) {
flat.push({ repo: result.repo, ...m })
if (flat.length >= 8) break
}
if (flat.length >= 8) break
}
results.innerHTML = flat.map((m) =>
'<a class="nav-search-result" href="' + pfx + '/repos/' + encodeURIComponent(m.repo) + '/blob/' + encodeURIComponent(m.file) + '#L' + m.line + '" style="display:block;text-decoration:none">' +
'<div class="nav-search-result-name">' + esc(m.repo) + ' / ' + esc(m.file) + '</div>' +
'<div class="nav-search-result-match">' + m.line + ': ' + hl(esc(m.content), q) + '</div>' +
'</a>'
).join('') +
'<div class="nav-search-footer"><a href="' + pfx + '/search?q=' + encodeURIComponent(q) + '&type=code">View all ' + (d.totalMatches || '') + ' matches →</a></div>'
}
} catch {
results.innerHTML = '<div class="nav-search-empty">Search failed</div>'
} finally {
loading.style.display = 'none'
}
}
// Wire up the debounced input
input.addEventListener('input', () => {
const val = input.value
clearTimeout(debounce)
if (val.trim().length < 2) {
hide()
return
}
debounce = setTimeout(() => run(val.trim()), 150)
})
input.addEventListener('focus', () => {
const q = input.value.trim()
if (q.length >= 2) show()
})
input.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
hide()
} else if (e.key === 'Enter') {
const q = input.value.trim()
if (q.length >= 2) {
window.location.href = pfx + '/search?q=' + encodeURIComponent(q) + '&type=' + searchType
}
}
})
// Tabs
tabs.forEach((t) => {
t.addEventListener('click', () => setType(t.dataset.type))
})
// Close on outside click
document.addEventListener('click', (e) => {
if (!e.target.closest('#nav-search-wrap')) hide()
})
// "/" shortcut to focus nav search (U17) — only fires when no input,
// textarea, select, or contenteditable element is focused.
document.addEventListener('keydown', function (e) {
if (e.key !== '/') return
var tag = document.activeElement && document.activeElement.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return
if (document.activeElement && document.activeElement.isContentEditable) return
e.preventDefault()
input.focus()
})
// Expose for debugging / tests
window._pgv3Search = { run, setType, hide }
})()