···11+package models
22+33+import (
44+ "github.com/bluesky-social/indigo/atproto/syntax"
55+ "github.com/sourcegraph/zoekt"
66+)
77+88+// Result is a single matched file. A zoekt FileMatch is either a filename
99+// match or a set of content matches, never both: File is set for the former,
1010+// Chunks for the latter.
1111+type Result struct {
1212+ RepoDID syntax.DID
1313+ FilePath string
1414+ Branches []string // branch names
1515+ Commit string // commit id
1616+ Language string
1717+1818+ File *Result_FileMatch // set for a filename match
1919+ Chunks []Result_ChunkMatch // set for content matches
2020+}
2121+2222+type Result_FileMatch struct {
2323+ // Ranges are the matched span(s) within FilePath. LineNumber is always 1 for
2424+ // filename matches; Column is 1-based and in runes.
2525+ Ranges []zoekt.Range
2626+}
2727+2828+type Result_ChunkMatch struct {
2929+ // Content is a contiguous run of complete lines that fully contains Ranges.
3030+ Content string
3131+ // ContentStartLine is the 1-based line number of Content's first line.
3232+ ContentStartLine int
3333+ // Ranges are the matched span(s) within the file. LineNumber/Column are
3434+ // 1-based, Column is in runes. A Range may span multiple lines.
3535+ Ranges []zoekt.Range
3636+}
3737+3838+// IsFileMatch tells if search result is from file-name match
3939+func (r *Result) IsFileMatch() bool {
4040+ return r.File != nil
4141+}
4242+4343+// IsChunkMatch tells if search result is from chunk match
4444+func (r *Result) IsChunkMatch() bool {
4545+ return len(r.Chunks) > 0
4646+}
+49
appview/pages/codesearch.go
···11+package pages
22+33+import "sort"
44+55+// helper functions to render search match highlights
66+77+// mergeIntervals sorts half-open rune intervals and merges overlapping/adjacent ones.
88+func mergeIntervals(in [][2]int) [][2]int {
99+ if len(in) < 2 {
1010+ return in
1111+ }
1212+ sort.Slice(in, func(i, j int) bool { return in[i][0] < in[j][0] })
1313+ out := in[:1]
1414+ for _, iv := range in[1:] {
1515+ last := &out[len(out)-1]
1616+ if iv[0] <= last[1] {
1717+ if iv[1] > last[1] {
1818+ last[1] = iv[1]
1919+ }
2020+ continue
2121+ }
2222+ out = append(out, iv)
2323+ }
2424+ return out
2525+}
2626+2727+// spanRunes splits runes into alternating unmatched/matched ChunkSpans using the
2828+// (sorted, merged) match intervals. Returns nil for an empty line.
2929+func spanRunes(runes []rune, intervals [][2]int) []ChunkSpan {
3030+ if len(runes) == 0 {
3131+ return nil
3232+ }
3333+ if len(intervals) == 0 {
3434+ return []ChunkSpan{{Text: string(runes)}}
3535+ }
3636+ var spans []ChunkSpan
3737+ pos := 0
3838+ for _, iv := range intervals {
3939+ if iv[0] > pos {
4040+ spans = append(spans, ChunkSpan{Text: string(runes[pos:iv[0]])})
4141+ }
4242+ spans = append(spans, ChunkSpan{Text: string(runes[iv[0]:iv[1]]), Match: true})
4343+ pos = iv[1]
4444+ }
4545+ if pos < len(runes) {
4646+ spans = append(spans, ChunkSpan{Text: string(runes[pos:])})
4747+ }
4848+ return spans
4949+}
+130-1
appview/pages/pages.go
···3333 "github.com/bluesky-social/indigo/atproto/identity"
3434 "github.com/bluesky-social/indigo/atproto/syntax"
3535 "github.com/go-git/go-git/v5/plumbing"
3636+ "github.com/sourcegraph/zoekt"
3637)
37383839//go:embed templates/* static legal
···1682168316831684type SearchReposParams struct {
16841685 BaseParams
16851685- Repos []models.Repo
16861686+ FilterType string // "repo" | "code"
16871687+ Repos []SearchResult
16861688 Page pagination.Page
16871689 ResultCount int
16881690 FilterQuery string
16891691 SortParam string
16901692 TimeTaken time.Duration
16911693 DocCount int64
16941694+ ErrorMsg string
16921695}
1693169616941697func (p *Pages) SearchRepos(w io.Writer, params SearchReposParams) error {
16981698+ params.FilterType = "repo"
16951699 return p.execute("search/search", w, params)
16961700}
16971701···17111715 return err
17121716 }
17131717 return tpl.ExecuteTemplate(w, "search/fragments/quickMobile", params)
17181718+}
17191719+17201720+type SearchResult struct {
17211721+ RepoDID syntax.DID
17221722+ Repo *models.Repo
17231723+ FilePath string
17241724+ Branches []string
17251725+ Commit string
17261726+ Language string
17271727+17281728+ File *CodeSearchResult_File // filename match
17291729+ Chunks CodeSearchResult_Chunks // content matches
17301730+}
17311731+17321732+// CodeSearchResult_Chunk is a content match with its lines pre-rendered.
17331733+type CodeSearchResult_Chunk struct {
17341734+ Lines []ChunkLine // precomputed from Content/ContentStartLine/Ranges
17351735+ MatchCount int // number of match ranges in this chunk
17361736+}
17371737+17381738+type CodeSearchResult_Chunks []CodeSearchResult_Chunk
17391739+17401740+func (cs CodeSearchResult_Chunks) MatchCount() int {
17411741+ count := 0
17421742+ for _, c := range cs {
17431743+ count += c.MatchCount
17441744+ }
17451745+ return count
17461746+}
17471747+17481748+type CodeSearchResult_File struct {
17491749+ NameSpans []ChunkSpan // precomputed from FilePath/Ranges
17501750+}
17511751+17521752+type ChunkSpan struct {
17531753+ Text string
17541754+ Match bool
17551755+}
17561756+17571757+type ChunkLine struct {
17581758+ Num int
17591759+ Spans []ChunkSpan
17601760+ Highlight bool
17611761+}
17621762+17631763+// ChunkLines renders a chunk's Content into per-line ChunkLines, splitting each
17641764+// line into matched/unmatched spans using ranges. startLine is the 1-based line
17651765+// number of the first line.
17661766+func ChunkLines(content string, startLine int, ranges []zoekt.Range) []ChunkLine {
17671767+ if startLine < 1 {
17681768+ startLine = 1
17691769+ }
17701770+ // trim a single trailing newline so we don't emit a spurious empty line
17711771+ content = strings.TrimSuffix(content, "\n")
17721772+ lines := strings.Split(content, "\n")
17731773+ out := make([]ChunkLine, len(lines))
17741774+ for i, text := range lines {
17751775+ num := startLine + i
17761776+ runes := []rune(text)
17771777+17781778+ // collect matched rune intervals [c0,c1) for this line
17791779+ var intervals [][2]int
17801780+ for _, rg := range ranges {
17811781+ if num < int(rg.Start.LineNumber) || num > int(rg.End.LineNumber) {
17821782+ continue
17831783+ }
17841784+ c0, c1 := 0, len(runes)
17851785+ if num == int(rg.Start.LineNumber) {
17861786+ c0 = int(rg.Start.Column) - 1
17871787+ }
17881788+ if num == int(rg.End.LineNumber) {
17891789+ c1 = int(rg.End.Column) - 1
17901790+ }
17911791+ c0 = max(0, min(c0, len(runes)))
17921792+ c1 = max(0, min(c1, len(runes)))
17931793+ if c0 < c1 {
17941794+ intervals = append(intervals, [2]int{c0, c1})
17951795+ }
17961796+ }
17971797+ intervals = mergeIntervals(intervals)
17981798+17991799+ out[i] = ChunkLine{
18001800+ Num: num,
18011801+ Spans: spanRunes(runes, intervals),
18021802+ Highlight: len(intervals) > 0,
18031803+ }
18041804+ }
18051805+ return out
18061806+}
18071807+18081808+// FileNameSpans splits a filename into matched/unmatched spans using ranges.
18091809+// Filename ranges live on line 1; columns are clamped to rune bounds.
18101810+func FileNameSpans(name string, ranges []zoekt.Range) []ChunkSpan {
18111811+ runes := []rune(name)
18121812+ var intervals [][2]int
18131813+ for _, rg := range ranges {
18141814+ if rg.Start.LineNumber > 1 || rg.End.LineNumber < 1 {
18151815+ continue
18161816+ }
18171817+ c0 := max(0, min(int(rg.Start.Column)-1, len(runes)))
18181818+ c1 := max(0, min(int(rg.End.Column)-1, len(runes)))
18191819+ if c0 < c1 {
18201820+ intervals = append(intervals, [2]int{c0, c1})
18211821+ }
18221822+ }
18231823+ return spanRunes(runes, mergeIntervals(intervals))
18241824+}
18251825+18261826+type CodeSearchParams struct {
18271827+ BaseParams
18281828+ FilterType string // "code"
18291829+ FilterQuery string
18301830+ Results []SearchResult
18311831+ Page pagination.Page
18321832+ HasMore bool
18331833+ ErrorMsg string
18341834+18351835+ MatchCount int
18361836+ FileCount int
18371837+ TimeTaken time.Duration
18381838+}
18391839+18401840+func (p *Pages) CodeSearch(w io.Writer, params CodeSearchParams) error {
18411841+ params.FilterType = "code"
18421842+ return p.execute("search/search", w, params)
17141843}
1715184417161845func (p *Pages) Home(w io.Writer, params TimelineParams) error {