OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / lib / web / static / pgv3-search.js

163 lines · javascript ref:
/**
 * 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, '&lt;')
      .replace(/>/g, '&gt;')
      .replace(/"/g, '&quot;')
  }

  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) + ' &middot; ' : '') +
              (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 &rarr;</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 &rarr;</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 }
})()