Render search suggestions on the server
The typeahead requests /_search via htmx (debounced, newer requests replace in-flight ones) and gets rendered rows with the match highlighting done in Go. Rows are links, so picking in the header is plain navigation; the editor's link dialog reads data-name instead. The editor's [[ autocomplete keeps the JSON response. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -293,20 +293,23 @@ func scoreName(nameLower string, nameTokens []string, qLower string, qTokens []s
|
||||
return score
|
||||
}
|
||||
|
||||
// handleSearchSuggest serves the JSON typeahead for the header dropdown and
|
||||
// the editor's link picker. Caps results at 5; reports total so the UI can
|
||||
// surface a "show all" footer when more matches exist. Empty/whitespace query
|
||||
// is a no-op (200 with empty results), not a 400 — every keystroke fires this.
|
||||
type suggestResult struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// handleSearchSuggest serves the typeahead: rendered rows for the dropdown
|
||||
// (header search, editor link dialog; htmx requests) and JSON for the
|
||||
// editor's [[ autocomplete. Caps results at 5; reports total so the dropdown
|
||||
// can offer a "show all" footer (?footer) when more matches exist. An
|
||||
// empty/whitespace query is a no-op (200, no results), not a 400 — every
|
||||
// keystroke fires this.
|
||||
func (h *handler) handleSearchSuggest(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.checkAuth(w, r) {
|
||||
return
|
||||
}
|
||||
query := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
type suggestResult struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
type suggestResp struct {
|
||||
Query string `json:"query"`
|
||||
Results []suggestResult `json:"results"`
|
||||
@@ -328,12 +331,92 @@ func (h *handler) handleSearchSuggest(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
}
|
||||
// htmx (the typeahead dropdown) gets rendered rows; other callers (the
|
||||
// editor's [[ autocomplete) keep the JSON.
|
||||
w.Header().Set("Vary", "HX-Request")
|
||||
if isHX(r) {
|
||||
renderSuggest(w, resp.Query, resp.Results, resp.Total, r.URL.Query().Has("footer"))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
log.Printf("search suggest encode error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
var suggestTmpl = template.Must(template.ParseFS(assets, "assets/search/suggest.html"))
|
||||
|
||||
// suggestMinLen is the shortest query (in characters) the typeahead answers;
|
||||
// shorter ones render nothing, which closes the dropdown.
|
||||
const suggestMinLen = 2
|
||||
|
||||
type suggestRow struct {
|
||||
Name, Path, URL string
|
||||
Highlighted template.HTML
|
||||
}
|
||||
|
||||
// renderSuggest writes the typeahead dropdown for query. footer adds a
|
||||
// "Show all" row linking to the full results page when some were cut off.
|
||||
func renderSuggest(w http.ResponseWriter, query string, results []suggestResult, total int, footer bool) {
|
||||
data := struct {
|
||||
Show bool
|
||||
Results []suggestRow
|
||||
Total int
|
||||
AllURL string
|
||||
}{Show: utf8.RuneCountInString(query) >= suggestMinLen, Total: total}
|
||||
tokens := suggestTokens(query)
|
||||
for _, res := range results {
|
||||
data.Results = append(data.Results, suggestRow{res.Name, res.Path, res.URL, highlightMatches(res.Name, tokens)})
|
||||
}
|
||||
if footer && total > len(results) {
|
||||
data.AllURL = "/?q=" + url.QueryEscape(query)
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := suggestTmpl.ExecuteTemplate(w, "suggest", data); err != nil {
|
||||
log.Printf("suggest template error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// suggestTokens lowercases query and splits it on anything that is not a
|
||||
// letter or digit.
|
||||
func suggestTokens(query string) []string {
|
||||
return strings.FieldsFunc(strings.ToLower(query), func(r rune) bool {
|
||||
return !unicode.IsLetter(r) && !unicode.IsNumber(r)
|
||||
})
|
||||
}
|
||||
|
||||
// highlightMatches bolds the first occurrence of each token in name
|
||||
// (case-insensitive), merging overlapping spans, and escapes the rest.
|
||||
func highlightMatches(name string, tokens []string) template.HTML {
|
||||
lower := strings.ToLower(name)
|
||||
if len(lower) != len(name) {
|
||||
// Lowercasing changed byte lengths, so offsets would not line up.
|
||||
return template.HTML(template.HTMLEscapeString(name))
|
||||
}
|
||||
type span struct{ from, to int }
|
||||
var spans []span
|
||||
for _, t := range tokens {
|
||||
if i := strings.Index(lower, t); i >= 0 {
|
||||
spans = append(spans, span{i, i + len(t)})
|
||||
}
|
||||
}
|
||||
sort.Slice(spans, func(i, j int) bool { return spans[i].from < spans[j].from })
|
||||
var b strings.Builder
|
||||
cursor := 0
|
||||
for i := 0; i < len(spans); i++ {
|
||||
sp := spans[i]
|
||||
for i+1 < len(spans) && spans[i+1].from <= sp.to {
|
||||
i++
|
||||
sp.to = max(sp.to, spans[i].to)
|
||||
}
|
||||
b.WriteString(template.HTMLEscapeString(name[cursor:sp.from]))
|
||||
b.WriteString("<strong>" + template.HTMLEscapeString(name[sp.from:sp.to]) + "</strong>")
|
||||
cursor = sp.to
|
||||
}
|
||||
b.WriteString(template.HTMLEscapeString(name[cursor:]))
|
||||
return template.HTML(b.String())
|
||||
}
|
||||
|
||||
// handleReindex rebuilds the folder index synchronously and returns 204.
|
||||
// The frontend reloads the page on success. Serialized via buildMu so a
|
||||
// double-click waits rather than running two walks in parallel.
|
||||
|
||||
Reference in New Issue
Block a user