Monorepo for Tangled tangled.org
5

Configure Feed

Select the types of activity you want to include in your feed.

appview: code search

Signed-off-by: Seongmin Lee <git@boltless.me>

author
Seongmin Lee
date (Jun 26, 2026, 2:31 AM +0900) commit 325f3d6a parent 84991abb change-id psulntsy
+1061 -115
+267
appview/codesearch/codesearch.go
··· 1 + package codesearch 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "fmt" 8 + "io" 9 + "net/http" 10 + "net/url" 11 + "strings" 12 + "time" 13 + 14 + "github.com/bluesky-social/indigo/atproto/syntax" 15 + "github.com/sourcegraph/zoekt" 16 + "github.com/sourcegraph/zoekt/query" 17 + "tangled.org/core/appview/models" 18 + "tangled.org/core/appview/pagination" 19 + ) 20 + 21 + type CodeSearch struct { 22 + Host string // zoekt-webserver host. example: https://zoekt.example.com 23 + Client *http.Client 24 + } 25 + 26 + func (s *CodeSearch) GetClient() *http.Client { 27 + if s.Client != nil { 28 + return s.Client 29 + } 30 + return http.DefaultClient 31 + } 32 + 33 + type RepoOnlyError struct{ Query string } 34 + 35 + func (e *RepoOnlyError) Error() string { 36 + return "query only filters by repo name; use repo search instead" 37 + } 38 + 39 + // jsonSearchArgs mirrors zoekt's /api/search request body. 40 + type jsonSearchArgs struct { 41 + Q string 42 + Opts *zoekt.SearchOptions 43 + } 44 + 45 + // jsonSearchReply mirrors zoekt's /api/search response body. 46 + type jsonSearchReply struct { 47 + Result *zoekt.SearchResult 48 + } 49 + 50 + // jsonListArgs mirrors zoekt's /api/list request body. 51 + type jsonListArgs struct { 52 + Q string 53 + Opts *zoekt.ListOptions 54 + } 55 + 56 + // jsonListReply mirrors zoekt's /api/list response body. 57 + type jsonListReply struct { 58 + List *zoekt.RepoList 59 + } 60 + 61 + // SearchResults is a single page of content-search results plus whether more 62 + // pages follow. 63 + type SearchResults struct { 64 + Results []models.Result 65 + HasMore bool 66 + Stats zoekt.Stats // zoekt search stats (MatchCount, FileCount, Duration, …) 67 + } 68 + 69 + // Search queries zoekt server for FileNameMatch or ChunkMatch. 70 + // It returns *RepoOnlyError when the query only filters by repo name 71 + // (optionally with `lang:` filter.) 72 + func (s *CodeSearch) Search(ctx context.Context, queryStr string, page pagination.Page) (*SearchResults, error) { 73 + q, err := query.Parse(queryStr) 74 + if err != nil { 75 + return nil, fmt.Errorf("parse query: %w", err) 76 + } 77 + if rs, ok := asRepoSearch(q); ok { 78 + return nil, &RepoOnlyError{Query: rs.Query()} 79 + } 80 + 81 + opts := &zoekt.SearchOptions{ 82 + ChunkMatches: true, 83 + MaxWallTime: 10 * time.Second, 84 + NumContextLines: 2, 85 + } 86 + if page.Limit > 0 { 87 + // +1 so we can detect a following page. 88 + opts.MaxDocDisplayCount = page.Offset + page.Limit + 1 89 + } 90 + 91 + body, err := json.Marshal(jsonSearchArgs{ 92 + Q: queryStr, 93 + Opts: opts, 94 + }) 95 + if err != nil { 96 + return nil, fmt.Errorf("marshal request: %w", err) 97 + } 98 + 99 + url := strings.TrimRight(s.Host, "/") + "/api/search" 100 + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) 101 + if err != nil { 102 + return nil, fmt.Errorf("build request: %w", err) 103 + } 104 + req.Header.Set("Content-Type", "application/json") 105 + 106 + resp, err := s.GetClient().Do(req) 107 + if err != nil { 108 + return nil, fmt.Errorf("do request: %w", err) 109 + } 110 + defer resp.Body.Close() 111 + 112 + if resp.StatusCode != http.StatusOK { 113 + b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) 114 + return nil, fmt.Errorf("zoekt search: status %d: %s", resp.StatusCode, strings.TrimSpace(string(b))) 115 + } 116 + 117 + var reply jsonSearchReply 118 + if err := json.NewDecoder(resp.Body).Decode(&reply); err != nil { 119 + return nil, fmt.Errorf("decode response: %w", err) 120 + } 121 + if reply.Result == nil { 122 + return &SearchResults{}, nil 123 + } 124 + 125 + stats := reply.Result.Stats 126 + all := toResults(reply.Result) 127 + end := page.Offset + page.Limit 128 + if page.Limit <= 0 { 129 + // No window requested: return everything. 130 + return &SearchResults{Results: all, Stats: stats}, nil 131 + } 132 + 133 + hasMore := len(all) > end // extra card present ⇒ more pages 134 + if page.Offset >= len(all) { 135 + return &SearchResults{HasMore: false, Stats: stats}, nil 136 + } 137 + if end > len(all) { 138 + end = len(all) 139 + } 140 + return &SearchResults{Results: all[page.Offset:end], HasMore: hasMore, Stats: stats}, nil 141 + } 142 + 143 + // RepoCount returns the total number of repositories in the zoekt index. 144 + func (s *CodeSearch) RepoCount(ctx context.Context) (int, error) { 145 + body, err := json.Marshal(jsonListArgs{ 146 + Q: "", // empty query ⇒ match all repos 147 + Opts: &zoekt.ListOptions{Field: zoekt.RepoListFieldRepos}, 148 + }) 149 + if err != nil { 150 + return 0, fmt.Errorf("marshal request: %w", err) 151 + } 152 + 153 + url := strings.TrimRight(s.Host, "/") + "/api/list" 154 + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) 155 + if err != nil { 156 + return 0, fmt.Errorf("build request: %w", err) 157 + } 158 + req.Header.Set("Content-Type", "application/json") 159 + 160 + resp, err := s.GetClient().Do(req) 161 + if err != nil { 162 + return 0, fmt.Errorf("do request: %w", err) 163 + } 164 + defer resp.Body.Close() 165 + 166 + if resp.StatusCode != http.StatusOK { 167 + b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) 168 + return 0, fmt.Errorf("zoekt list: status %d: %s", resp.StatusCode, strings.TrimSpace(string(b))) 169 + } 170 + 171 + var reply jsonListReply 172 + if err := json.NewDecoder(resp.Body).Decode(&reply); err != nil { 173 + return 0, fmt.Errorf("decode response: %w", err) 174 + } 175 + if reply.List == nil { 176 + return 0, nil 177 + } 178 + return reply.List.Stats.Repos, nil 179 + } 180 + 181 + // toResults maps zoekt FileMatches into local Results 182 + func toResults(sr *zoekt.SearchResult) []models.Result { 183 + var out []models.Result 184 + for _, fm := range sr.Files { 185 + // HACK: zoekt use int64 repo.ID as identifier, but we expect DID (string) as an repo identifier. 186 + // as a quick hack without patching zoekt, we extract the DID from RepoURLs 187 + repoDID := extractDID(sr.RepoURLs[fm.Repository]) 188 + res := models.Result{ 189 + RepoDID: repoDID, 190 + FilePath: fm.FileName, 191 + Branches: fm.Branches, 192 + Commit: fm.Version, 193 + Language: fm.Language, 194 + } 195 + for _, cm := range fm.ChunkMatches { 196 + if cm.FileName { 197 + res.File = &models.Result_FileMatch{Ranges: cm.Ranges} 198 + break 199 + } else { 200 + res.Chunks = append(res.Chunks, models.Result_ChunkMatch{ 201 + Content: string(cm.Content), 202 + ContentStartLine: int(cm.ContentStart.LineNumber), 203 + Ranges: cm.Ranges, 204 + }) 205 + } 206 + } 207 + out = append(out, res) 208 + } 209 + return out 210 + } 211 + 212 + // extractDID pulls the repo DID out of a zoekt FileURLTemplate of the form 213 + // "{appviewURL}/{repoDID}/blob/{commit}/{path}". 214 + func extractDID(urlTemplate string) syntax.DID { 215 + if urlTemplate == "" { 216 + return "" 217 + } 218 + u, err := url.Parse(urlTemplate) 219 + if err != nil { 220 + return "" 221 + } 222 + seg := strings.SplitN(strings.TrimPrefix(u.Path, "/"), "/", 2)[0] 223 + return syntax.DID(seg) 224 + } 225 + 226 + type repoSearchQuery struct { 227 + RepoNames []string 228 + Language string 229 + } 230 + 231 + func (r repoSearchQuery) Query() string { 232 + parts := append([]string{}, r.RepoNames...) 233 + if r.Language != "" { 234 + parts = append(parts, "lang:"+r.Language) 235 + } 236 + return strings.Join(parts, " ") 237 + } 238 + 239 + func asRepoSearch(q query.Q) (repoSearchQuery, bool) { 240 + var rs repoSearchQuery 241 + if t, ok := q.(*query.Type); ok && t.Type == query.TypeRepo { 242 + query.VisitAtoms(t.Child, func(a query.Q) { 243 + switch v := a.(type) { 244 + case *query.Repo: 245 + rs.RepoNames = append(rs.RepoNames, v.Regexp.String()) 246 + case *query.Substring: 247 + rs.RepoNames = append(rs.RepoNames, v.Pattern) 248 + case *query.Language: 249 + rs.Language = v.Language 250 + } 251 + }) 252 + return rs, true 253 + } 254 + hasRepo, only := false, true 255 + query.VisitAtoms(q, func(a query.Q) { 256 + switch v := a.(type) { 257 + case *query.Repo: 258 + hasRepo = true 259 + rs.RepoNames = append(rs.RepoNames, v.Regexp.String()) 260 + case *query.Language: 261 + rs.Language = v.Language 262 + default: 263 + only = false 264 + } 265 + }) 266 + return rs, hasRepo && only 267 + }
+61
appview/codesearch/codesearch_test.go
··· 1 + package codesearch 2 + 3 + import ( 4 + "testing" 5 + 6 + "github.com/sourcegraph/zoekt/query" 7 + ) 8 + 9 + func TestAsRepoSearch(t *testing.T) { 10 + cases := []struct { 11 + query string 12 + wantOK bool 13 + wantStr string // rewritten repo-search query, only checked when wantOK 14 + }{ 15 + {"repo:foo", true, "foo"}, 16 + {"repo:foo repo:bar", true, "foo bar"}, 17 + {"repo:foo lang:go", true, "foo lang:Go"}, 18 + {"lang:go", false, ""}, 19 + {"repo:foo bar", false, ""}, 20 + {"repo:foo file:x", false, ""}, 21 + {"file:x", false, ""}, 22 + {"type:repo foo", true, "foo"}, 23 + {"foo", false, ""}, 24 + {"branch:main", false, ""}, 25 + } 26 + 27 + for _, tc := range cases { 28 + t.Run(tc.query, func(t *testing.T) { 29 + q, err := query.Parse(tc.query) 30 + if err != nil { 31 + t.Fatalf("parse %q: %v", tc.query, err) 32 + } 33 + rs, ok := asRepoSearch(q) 34 + if ok != tc.wantOK { 35 + t.Fatalf("asRepoSearch(%q) ok = %v, want %v", tc.query, ok, tc.wantOK) 36 + } 37 + if ok && rs.Query() != tc.wantStr { 38 + t.Errorf("asRepoSearch(%q).Query() = %q, want %q", tc.query, rs.Query(), tc.wantStr) 39 + } 40 + }) 41 + } 42 + } 43 + 44 + func TestExtractDID(t *testing.T) { 45 + cases := []struct { 46 + tmpl string 47 + want string 48 + }{ 49 + {"https://tangled.org/did:plc:abc123/blob/{{.Version}}/{{.Path}}", "did:plc:abc123"}, 50 + {"http://localhost:3000/did:web:example.com/blob/{{.Version}}/{{.Path}}", "did:web:example.com"}, 51 + {"", ""}, 52 + } 53 + 54 + for _, tc := range cases { 55 + t.Run(tc.tmpl, func(t *testing.T) { 56 + if got := extractDID(tc.tmpl); string(got) != tc.want { 57 + t.Errorf("extractDID(%q) = %q, want %q", tc.tmpl, got, tc.want) 58 + } 59 + }) 60 + } 61 + }
+5
appview/config/config.go
··· 157 157 Host string `env:"HOST, default=https://ogre.tangled.network"` 158 158 } 159 159 160 + type CodeSearchConfig struct { 161 + ZoektUrl string `env:"ZOEKT_URL"` 162 + } 163 + 160 164 type SSHConfig struct { 161 165 Enabled bool `env:"ENABLED, default=false"` 162 166 ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:3333"` ··· 198 202 KnotMirror KnotMirrorConfig `env:",prefix=TANGLED_KNOTMIRROR_"` 199 203 Ogre OgreConfig `env:",prefix=TANGLED_OGRE_"` 200 204 SSH SSHConfig `env:",prefix=TANGLED_SSH_"` 205 + CodeSearch CodeSearchConfig `env:",prefix=TANGLED_CODESEARCH_"` 201 206 } 202 207 203 208 func LoadConfig(ctx context.Context) (*Config, error) {
+46
appview/models/codesearch.go
··· 1 + package models 2 + 3 + import ( 4 + "github.com/bluesky-social/indigo/atproto/syntax" 5 + "github.com/sourcegraph/zoekt" 6 + ) 7 + 8 + // Result is a single matched file. A zoekt FileMatch is either a filename 9 + // match or a set of content matches, never both: File is set for the former, 10 + // Chunks for the latter. 11 + type Result struct { 12 + RepoDID syntax.DID 13 + FilePath string 14 + Branches []string // branch names 15 + Commit string // commit id 16 + Language string 17 + 18 + File *Result_FileMatch // set for a filename match 19 + Chunks []Result_ChunkMatch // set for content matches 20 + } 21 + 22 + type Result_FileMatch struct { 23 + // Ranges are the matched span(s) within FilePath. LineNumber is always 1 for 24 + // filename matches; Column is 1-based and in runes. 25 + Ranges []zoekt.Range 26 + } 27 + 28 + type Result_ChunkMatch struct { 29 + // Content is a contiguous run of complete lines that fully contains Ranges. 30 + Content string 31 + // ContentStartLine is the 1-based line number of Content's first line. 32 + ContentStartLine int 33 + // Ranges are the matched span(s) within the file. LineNumber/Column are 34 + // 1-based, Column is in runes. A Range may span multiple lines. 35 + Ranges []zoekt.Range 36 + } 37 + 38 + // IsFileMatch tells if search result is from file-name match 39 + func (r *Result) IsFileMatch() bool { 40 + return r.File != nil 41 + } 42 + 43 + // IsChunkMatch tells if search result is from chunk match 44 + func (r *Result) IsChunkMatch() bool { 45 + return len(r.Chunks) > 0 46 + }
+49
appview/pages/codesearch.go
··· 1 + package pages 2 + 3 + import "sort" 4 + 5 + // helper functions to render search match highlights 6 + 7 + // mergeIntervals sorts half-open rune intervals and merges overlapping/adjacent ones. 8 + func mergeIntervals(in [][2]int) [][2]int { 9 + if len(in) < 2 { 10 + return in 11 + } 12 + sort.Slice(in, func(i, j int) bool { return in[i][0] < in[j][0] }) 13 + out := in[:1] 14 + for _, iv := range in[1:] { 15 + last := &out[len(out)-1] 16 + if iv[0] <= last[1] { 17 + if iv[1] > last[1] { 18 + last[1] = iv[1] 19 + } 20 + continue 21 + } 22 + out = append(out, iv) 23 + } 24 + return out 25 + } 26 + 27 + // spanRunes splits runes into alternating unmatched/matched ChunkSpans using the 28 + // (sorted, merged) match intervals. Returns nil for an empty line. 29 + func spanRunes(runes []rune, intervals [][2]int) []ChunkSpan { 30 + if len(runes) == 0 { 31 + return nil 32 + } 33 + if len(intervals) == 0 { 34 + return []ChunkSpan{{Text: string(runes)}} 35 + } 36 + var spans []ChunkSpan 37 + pos := 0 38 + for _, iv := range intervals { 39 + if iv[0] > pos { 40 + spans = append(spans, ChunkSpan{Text: string(runes[pos:iv[0]])}) 41 + } 42 + spans = append(spans, ChunkSpan{Text: string(runes[iv[0]:iv[1]]), Match: true}) 43 + pos = iv[1] 44 + } 45 + if pos < len(runes) { 46 + spans = append(spans, ChunkSpan{Text: string(runes[pos:])}) 47 + } 48 + return spans 49 + }
+130 -1
appview/pages/pages.go
··· 33 33 "github.com/bluesky-social/indigo/atproto/identity" 34 34 "github.com/bluesky-social/indigo/atproto/syntax" 35 35 "github.com/go-git/go-git/v5/plumbing" 36 + "github.com/sourcegraph/zoekt" 36 37 ) 37 38 38 39 //go:embed templates/* static legal ··· 1682 1683 1683 1684 type SearchReposParams struct { 1684 1685 BaseParams 1685 - Repos []models.Repo 1686 + FilterType string // "repo" | "code" 1687 + Repos []SearchResult 1686 1688 Page pagination.Page 1687 1689 ResultCount int 1688 1690 FilterQuery string 1689 1691 SortParam string 1690 1692 TimeTaken time.Duration 1691 1693 DocCount int64 1694 + ErrorMsg string 1692 1695 } 1693 1696 1694 1697 func (p *Pages) SearchRepos(w io.Writer, params SearchReposParams) error { 1698 + params.FilterType = "repo" 1695 1699 return p.execute("search/search", w, params) 1696 1700 } 1697 1701 ··· 1711 1715 return err 1712 1716 } 1713 1717 return tpl.ExecuteTemplate(w, "search/fragments/quickMobile", params) 1718 + } 1719 + 1720 + type SearchResult struct { 1721 + RepoDID syntax.DID 1722 + Repo *models.Repo 1723 + FilePath string 1724 + Branches []string 1725 + Commit string 1726 + Language string 1727 + 1728 + File *CodeSearchResult_File // filename match 1729 + Chunks CodeSearchResult_Chunks // content matches 1730 + } 1731 + 1732 + // CodeSearchResult_Chunk is a content match with its lines pre-rendered. 1733 + type CodeSearchResult_Chunk struct { 1734 + Lines []ChunkLine // precomputed from Content/ContentStartLine/Ranges 1735 + MatchCount int // number of match ranges in this chunk 1736 + } 1737 + 1738 + type CodeSearchResult_Chunks []CodeSearchResult_Chunk 1739 + 1740 + func (cs CodeSearchResult_Chunks) MatchCount() int { 1741 + count := 0 1742 + for _, c := range cs { 1743 + count += c.MatchCount 1744 + } 1745 + return count 1746 + } 1747 + 1748 + type CodeSearchResult_File struct { 1749 + NameSpans []ChunkSpan // precomputed from FilePath/Ranges 1750 + } 1751 + 1752 + type ChunkSpan struct { 1753 + Text string 1754 + Match bool 1755 + } 1756 + 1757 + type ChunkLine struct { 1758 + Num int 1759 + Spans []ChunkSpan 1760 + Highlight bool 1761 + } 1762 + 1763 + // ChunkLines renders a chunk's Content into per-line ChunkLines, splitting each 1764 + // line into matched/unmatched spans using ranges. startLine is the 1-based line 1765 + // number of the first line. 1766 + func ChunkLines(content string, startLine int, ranges []zoekt.Range) []ChunkLine { 1767 + if startLine < 1 { 1768 + startLine = 1 1769 + } 1770 + // trim a single trailing newline so we don't emit a spurious empty line 1771 + content = strings.TrimSuffix(content, "\n") 1772 + lines := strings.Split(content, "\n") 1773 + out := make([]ChunkLine, len(lines)) 1774 + for i, text := range lines { 1775 + num := startLine + i 1776 + runes := []rune(text) 1777 + 1778 + // collect matched rune intervals [c0,c1) for this line 1779 + var intervals [][2]int 1780 + for _, rg := range ranges { 1781 + if num < int(rg.Start.LineNumber) || num > int(rg.End.LineNumber) { 1782 + continue 1783 + } 1784 + c0, c1 := 0, len(runes) 1785 + if num == int(rg.Start.LineNumber) { 1786 + c0 = int(rg.Start.Column) - 1 1787 + } 1788 + if num == int(rg.End.LineNumber) { 1789 + c1 = int(rg.End.Column) - 1 1790 + } 1791 + c0 = max(0, min(c0, len(runes))) 1792 + c1 = max(0, min(c1, len(runes))) 1793 + if c0 < c1 { 1794 + intervals = append(intervals, [2]int{c0, c1}) 1795 + } 1796 + } 1797 + intervals = mergeIntervals(intervals) 1798 + 1799 + out[i] = ChunkLine{ 1800 + Num: num, 1801 + Spans: spanRunes(runes, intervals), 1802 + Highlight: len(intervals) > 0, 1803 + } 1804 + } 1805 + return out 1806 + } 1807 + 1808 + // FileNameSpans splits a filename into matched/unmatched spans using ranges. 1809 + // Filename ranges live on line 1; columns are clamped to rune bounds. 1810 + func FileNameSpans(name string, ranges []zoekt.Range) []ChunkSpan { 1811 + runes := []rune(name) 1812 + var intervals [][2]int 1813 + for _, rg := range ranges { 1814 + if rg.Start.LineNumber > 1 || rg.End.LineNumber < 1 { 1815 + continue 1816 + } 1817 + c0 := max(0, min(int(rg.Start.Column)-1, len(runes))) 1818 + c1 := max(0, min(int(rg.End.Column)-1, len(runes))) 1819 + if c0 < c1 { 1820 + intervals = append(intervals, [2]int{c0, c1}) 1821 + } 1822 + } 1823 + return spanRunes(runes, mergeIntervals(intervals)) 1824 + } 1825 + 1826 + type CodeSearchParams struct { 1827 + BaseParams 1828 + FilterType string // "code" 1829 + FilterQuery string 1830 + Results []SearchResult 1831 + Page pagination.Page 1832 + HasMore bool 1833 + ErrorMsg string 1834 + 1835 + MatchCount int 1836 + FileCount int 1837 + TimeTaken time.Duration 1838 + } 1839 + 1840 + func (p *Pages) CodeSearch(w io.Writer, params CodeSearchParams) error { 1841 + params.FilterType = "code" 1842 + return p.execute("search/search", w, params) 1714 1843 } 1715 1844 1716 1845 func (p *Pages) Home(w io.Writer, params TimelineParams) error {
+45 -32
appview/pages/templates/fragments/pagination.html
··· 1 1 {{ define "fragments/pagination" }} 2 - {{/* Params: Page (pagination.Page), TotalCount (int), BasePath (string), QueryParams (url.Values) */}} 2 + {{/* Params: Page (pagination.Page), BasePath (string), QueryParams (url.Values), TotalCount (int)|HasMore (bool) */}} 3 + {{/* Cursor mode when HasMore is provided */}} 4 + 3 5 {{ $page := .Page }} 4 6 {{ $totalCount := .TotalCount }} 5 7 {{ $basePath := .BasePath }} 6 8 {{ $queryParams := safeUrl .QueryParams.Encode }} 9 + {{ $cursor := mapContains . "HasMore" }} 7 10 8 11 {{ $prev := $page.Previous.Offset }} 9 12 {{ $next := $page.Next.Offset }} 10 - {{ $lastPage := sub $totalCount (mod $totalCount $page.Limit) }} 13 + 14 + {{ $hasNext := false }} 15 + {{ if $cursor }} 16 + {{ $hasNext = .HasMore }} 17 + {{ else }} 18 + {{ $hasNext = lt $next $totalCount }} 19 + {{ end }} 11 20 12 21 <div class="flex justify-center items-center mt-4 gap-5"> 13 22 <a ··· 26 35 Prev 27 36 </a> 28 37 29 - {{ if gt $page.Offset 0 }} 30 - <a hx-boost="true" href="{{ $basePath }}?{{ $queryParams }}&offset=0&limit={{ $page.Limit }}"> 31 - 1 32 - </a> 33 - {{ end }} 38 + {{ if not $cursor }} 39 + {{ $lastPage := sub $totalCount (mod $totalCount $page.Limit) }} 40 + 41 + {{ if gt $page.Offset 0 }} 42 + <a hx-boost="true" href="{{ $basePath }}?{{ $queryParams }}&offset=0&limit={{ $page.Limit }}"> 43 + 1 44 + </a> 45 + {{ end }} 34 46 35 - {{ if gt $prev $page.Limit }} 36 - <span class="text-gray-400 dark:text-gray-500">—</span> 37 - {{ end }} 47 + {{ if gt $prev $page.Limit }} 48 + <span class="text-gray-400 dark:text-gray-500">—</span> 49 + {{ end }} 38 50 39 - {{ if gt $prev 0 }} 40 - <a hx-boost="true" href="{{ $basePath }}?{{ $queryParams }}&offset={{ $prev }}&limit={{ $page.Limit }}"> 41 - {{ add (div $prev $page.Limit) 1 }} 42 - </a> 43 - {{ end }} 51 + {{ if gt $prev 0 }} 52 + <a hx-boost="true" href="{{ $basePath }}?{{ $queryParams }}&offset={{ $prev }}&limit={{ $page.Limit }}"> 53 + {{ add (div $prev $page.Limit) 1 }} 54 + </a> 55 + {{ end }} 44 56 45 - <span class="px-2 py-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-sm"> 46 - {{ add (div $page.Offset $page.Limit) 1 }} 47 - </span> 57 + <span class="px-2 py-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-sm"> 58 + {{ add (div $page.Offset $page.Limit) 1 }} 59 + </span> 48 60 49 - {{ if lt $next $lastPage }} 50 - <a hx-boost="true" href="{{ $basePath }}?{{ $queryParams }}&offset={{ $next }}&limit={{ $page.Limit }}"> 51 - {{ add (div $next $page.Limit) 1 }} 52 - </a> 53 - {{ end }} 61 + {{ if lt $next $lastPage }} 62 + <a hx-boost="true" href="{{ $basePath }}?{{ $queryParams }}&offset={{ $next }}&limit={{ $page.Limit }}"> 63 + {{ add (div $next $page.Limit) 1 }} 64 + </a> 65 + {{ end }} 54 66 55 - {{ if lt $next (sub $totalCount (mul 2 $page.Limit)) }} 56 - <span class="text-gray-400 dark:text-gray-500">—</span> 57 - {{ end }} 67 + {{ if lt $next (sub $totalCount (mul 2 $page.Limit)) }} 68 + <span class="text-gray-400 dark:text-gray-500">—</span> 69 + {{ end }} 58 70 59 - {{ if lt $page.Offset $lastPage }} 60 - <a hx-boost="true" href="{{ $basePath }}?{{ $queryParams }}&offset={{ $lastPage }}&limit={{ $page.Limit }}"> 61 - {{ add (div $lastPage $page.Limit) 1 }} 62 - </a> 71 + {{ if lt $page.Offset $lastPage }} 72 + <a hx-boost="true" href="{{ $basePath }}?{{ $queryParams }}&offset={{ $lastPage }}&limit={{ $page.Limit }}"> 73 + {{ add (div $lastPage $page.Limit) 1 }} 74 + </a> 75 + {{ end }} 63 76 {{ end }} 64 77 65 78 <a 66 79 class=" 67 80 flex items-center gap-1 no-underline hover:no-underline dark:text-white text-sm 68 - {{ if lt $next $totalCount | not }} 81 + {{ if not $hasNext }} 69 82 cursor-not-allowed opacity-50 70 83 {{ end }} 71 84 " 72 - {{ if lt $next $totalCount }} 85 + {{ if $hasNext }} 73 86 hx-boost="true" 74 87 href="{{ $basePath }}?{{ $queryParams }}&offset={{ $next }}&limit={{ $page.Limit }}" 75 88 {{ end }}
+24
appview/pages/templates/search/fragments/chunkBody.html
··· 1 + {{ define "search/fragments/chunkBody" }} 2 + {{ $owner := .Owner }} 3 + {{ $slug := .Slug }} 4 + {{ $commit := .Commit }} 5 + {{ $filePath := .FilePath }} 6 + {{ $chunk := .Chunk }} 7 + <div class="overflow-x-auto font-mono text-sm"> 8 + {{ range $chunk.Lines }} 9 + <div class="px-3 flex gap-3 w-max min-w-full{{ if .Highlight }} line-range-hl{{ end }}"> 10 + <a href="/{{ $owner }}/{{ $slug }}/blob/{{ $commit }}/{{ $filePath }}#L{{ .Num }}" class="block select-none text-right text-gray-300 dark:text-gray-600 hover:text-gray-500 hover:underline" style="min-width: 2.5rem">{{ .Num }}</a> 11 + <div class="whitespace-pre text-gray-700 dark:text-gray-300"> 12 + {{- range .Spans -}} 13 + {{- if .Match -}} 14 + <span class="chunk-match-hl">{{ .Text }}</span> 15 + {{- else -}} 16 + {{ .Text }} 17 + {{- end -}} 18 + {{- end -}} 19 + {{- if not .Spans }}&#8203;{{ end -}} 20 + </div> 21 + </div> 22 + {{ end }} 23 + </div> 24 + {{ end }}
+110
appview/pages/templates/search/fragments/resultCard.html
··· 1 + {{ define "search/fragments/resultCard" }} 2 + {{ $owner := resolve .Repo.Did }} 3 + {{ $slug := .Repo.Slug }} 4 + 5 + <div class="bg-white dark:bg-gray-800 p-3 flex flex-col gap-2 border border-gray-200 dark:border-gray-700 rounded"> 6 + <div class="flex items-center gap-x-1 gap-y-1"> 7 + <div class="px-1 flex items-center gap-1 min-w-0"> 8 + {{ template "user/fragments/pic" (list .Repo.Did "size-5") }} 9 + <a href="/{{ $owner }}/{{ $slug }}" class="truncate">{{ $owner }}/{{ $slug }}</a> 10 + </div> 11 + <div class="flex gap-1"> 12 + {{ range .Branches }} 13 + <span class="text-xs px-2 py-1 bg-gray-100 dark:bg-gray-900 rounded shrink-0">{{ . }}</span> 14 + {{ end }} 15 + </div> 16 + {{ with .Repo.RepoStats }} 17 + <div class="shrink-0 ml-auto order-last flex gap-1 items-center text-gray-500"> 18 + {{ i "star" "size-4" }} 19 + <span>{{ scaleFmt .StarCount }}</span> 20 + </div> 21 + {{ end }} 22 + </div> 23 + 24 + {{ if .File }} 25 + {{/* filename match */}} 26 + <div class="px-3.5 py-2 text-sm border border-gray-200 dark:border-gray-700 rounded-sm flex items-center justify-between"> 27 + <div class="flex items-center gap-2 min-w-0"> 28 + {{ i "file" "size-4 flex-shrink-0" }} 29 + <a href="/{{ $owner }}/{{ $slug }}/blob/{{ .Commit }}/{{ .FilePath }}" class="truncate font-mono"> 30 + {{- range .File.NameSpans -}} 31 + {{- if .Match -}}<span class="chunk-match-hl">{{ .Text }}</span> 32 + {{- else -}}{{ .Text }}{{- end -}} 33 + {{- end -}} 34 + </a> 35 + </div> 36 + <div class="flex items-center gap-3.5"> 37 + {{ with .Language }} 38 + {{ template "repo/fragments/colorBall" (dict "color" (langColor .)) }} 39 + <span>{{ . }}</span> 40 + {{ end }} 41 + </div> 42 + </div> 43 + {{ else if .Chunks }} 44 + {{/* chunk match */}} 45 + <div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-sm divide-y divide-gray-200 dark:divide-gray-700"> 46 + <div class="px-3.5 py-2 text-sm flex items-center justify-between"> 47 + <div class="flex items-center gap-2 min-w-0"> 48 + {{ i "file" "size-4 flex-shrink-0" }} 49 + <a href="/{{ $owner }}/{{ $slug }}/blob/{{ .Commit }}/{{ .FilePath }}" class="truncate font-mono">{{ .FilePath }}</a> 50 + </div> 51 + <div class="flex items-center gap-3.5"> 52 + {{ with .Language }} 53 + {{ template "repo/fragments/colorBall" (dict "color" (langColor .)) }} 54 + <span>{{ . }}</span> 55 + {{ end }} 56 + </div> 57 + </div> 58 + {{ range $i, $chunk := .Chunks }} 59 + {{ if lt $i 3 }} 60 + {{ if gt $i 0 }} 61 + <div class="bg-gray-50 dark:bg-gray-700 text-center"> 62 + <span class="text-sm text-gray-500 dark:text-gray-400 select-none">···</span> 63 + </div> 64 + {{ end }} 65 + {{ template "search/fragments/chunkBody" (dict "Owner" $owner "Slug" $slug "Commit" $.Commit "FilePath" $.FilePath "Chunk" $chunk) }} 66 + {{ end }} 67 + {{ end }} 68 + {{ if gt (len .Chunks) 3 }} 69 + <details class="group flex flex-col divide-y divide-gray-200 dark:divide-gray-700"> 70 + <summary class="bg-gray-50 dark:bg-gray-700 text-gray-500 dark:text-gray-400 px-1 py-0.5 cursor-pointer list-none group-open:order-last group-open:border-t border-gray-200 dark:border-gray-700"> 71 + <div class="flex group-open:hidden"> 72 + {{ i "chevron-down" "size-3 m-1" }} 73 + <span class="text-sm select-none">Show {{ (slice .Chunks 3).MatchCount }} more matches</span> 74 + </div> 75 + <div class="hidden group-open:flex"> 76 + {{ i "chevron-up" "size-3 m-1" }} 77 + <span class="text-sm select-none">Show less</span> 78 + </div> 79 + </summary> 80 + {{ range $i, $chunk := .Chunks }} 81 + {{ if ge $i 3 }} 82 + <div class="bg-gray-50 dark:bg-gray-700 text-center"> 83 + <span class="text-sm text-gray-500 dark:text-gray-400 select-none">···</span> 84 + </div> 85 + {{ template "search/fragments/chunkBody" (dict "Owner" $owner "Slug" $slug "Commit" $.Commit "FilePath" $.FilePath "Chunk" $chunk) }} 86 + {{ end }} 87 + {{ end }} 88 + </details> 89 + {{ end }} 90 + </div> 91 + {{ else }} 92 + {{/* repo match */}} 93 + <div class="flex flex-col gap-1 flex-1 min-h-16"> 94 + {{ with .Repo.Description }} 95 + <div class="px-1 text-gray-600 dark:text-gray-300 text-sm line-clamp-2"> 96 + {{ . | description }} 97 + </div> 98 + {{ end }} 99 + {{ with .Repo.RepoStats }} 100 + {{ with .Language }} 101 + <div class="px-1 flex gap-2 items-center text-sm text-gray-400 mt-auto"> 102 + {{ template "repo/fragments/colorBall" (dict "color" (langColor .)) }} 103 + <span>{{ . }}</span> 104 + </div> 105 + {{ end }} 106 + {{ end }} 107 + </div> 108 + {{ end }} 109 + </div> 110 + {{ end }}
+159 -56
appview/pages/templates/search/search.html
··· 1 - {{ define "title" }}Search &middot; Tangled{{ end }} 1 + {{ define "title" }}{{ if eq .FilterType "code" }}Code Search{{ else }}Search{{ end }} &middot; Tangled{{ end }} 2 2 3 3 {{ define "content" }} 4 - <h1 class="text-2xl font-bold mb-4 px-2">Search</h1> 4 + <h1 class="text-2xl font-bold mb-4 px-2">{{ if eq .FilterType "code" }}Code Search{{ else }}Search{{ end }}</h1> 5 5 6 6 <div class="grid grid-cols-1 md:grid-cols-4 gap-4 px-2"> 7 7 <div class="col-span-1 md:col-span-3 space-y-4"> ··· 17 17 18 18 {{ define "searchBar" }} 19 19 <form id="search-form" method="GET" class="flex items-center gap-2"> 20 + <input type="hidden" name="type" value="{{ .FilterType }}"> 20 21 <div class="flex relative w-full"> 21 22 <div class="flex-1 flex relative"> 22 23 <input 23 24 id="search-q" 24 - class="flex-1 py-1 pl-2 pr-10 mr-[-1px] rounded-r-none peer" 25 + class='{{ if eq .FilterType "code" }}font-mono{{ end }} flex-1 py-1 pl-2 pr-10 mr-[-1px] rounded-r-none peer' 25 26 type="text" 26 27 name="q" 27 28 value="{{ .FilterQuery }}" 28 - placeholder="Search repositories..." 29 + placeholder='{{ if eq .FilterType "code" }}Search code...{{ else }}Search repositories...{{ end }}' 29 30 > 30 31 <a 31 - href="/search" 32 + href="/search?type={{ .FilterType }}" 32 33 class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 {{ if not .FilterQuery }}hidden{{ end }} peer-[:not(:placeholder-shown)]:block" 33 34 > 34 - {{ i "x" "w-4 h-4" }} 35 + {{ i "x" "size-4" }} 35 36 </a> 36 37 </div> 37 38 <button 38 39 type="submit" 39 40 class="p-2 text-gray-400 border rounded-r border-gray-300 dark:border-gray-600" 40 41 > 41 - {{ i "search" "w-4 h-4" }} 42 + {{ i "search" "size-4" }} 42 43 </button> 43 44 </div> 44 - <div class="md:hidden"> 45 - {{ template "sortOptionsList" . }} 46 - </div> 45 + <button 46 + type="button" 47 + popovertarget="search-filters-sheet" 48 + popovertargetaction="toggle" 49 + class="md:hidden btn flex items-center gap-2 shrink-0"> 50 + {{ i "list-filter" "size-4" }} 51 + <span>Filters</span> 52 + </button> 47 53 </form> 48 54 49 - <div class="md:hidden mt-3"> 50 - <div class="flex gap-2 overflow-x-auto scrollbar-hide pb-2"> 51 - {{ template "languageFilters" . }} 55 + <div 56 + popover 57 + id="search-filters-sheet" 58 + class="md:hidden m-0 fixed inset-x-0 bottom-0 top-auto w-full max-w-none 59 + rounded-t-xl rounded-b-none 60 + bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 61 + dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50 62 + p-4 max-h-[80dvh] overflow-y-auto"> 63 + <div class="flex items-center justify-between mb-4"> 64 + <h2 class="text-base font-semibold">Filters</h2> 65 + <button type="button" popovertarget="search-filters-sheet" popovertargetaction="hide" 66 + class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"> 67 + {{ i "x" "size-5" }} 68 + </button> 69 + </div> 70 + <div class="flex flex-col gap-6"> 71 + {{ template "searchTypeSwitcher" . }} 72 + {{ if eq .FilterType "repo" }} 73 + <div> 74 + <h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Sort by</h3> 75 + {{ template "sortOptionsList" . }} 76 + </div> 77 + {{ end }} 78 + <div> 79 + <h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Languages</h3> 80 + {{ template "languageFilters" . }} 81 + </div> 52 82 </div> 53 83 </div> 54 84 {{ end }} 55 85 56 86 {{ define "searchResults" }} 87 + {{ if .ErrorMsg }} 88 + <div class="flex items-center gap-2 my-2 bg-red-50 dark:bg-red-900/30 border border-red-300 dark:border-red-700 rounded px-3 py-2 text-red-600 dark:text-red-300 text-sm"> 89 + {{ i "circle-alert" "size-4" }} 90 + <span>{{ .ErrorMsg }}</span> 91 + </div> 92 + {{ else if eq .FilterType "code" }} 93 + {{ template "codeSearchResults" . }} 94 + {{ else }} 95 + {{ template "repoSearchResults" . }} 96 + {{ end }} 97 + {{ end }} 98 + 99 + {{ define "repoSearchResults" }} 57 100 <div id="repos" class="grid grid-cols-1 gap-4 mb-6"> 58 101 {{ range .Repos }} 59 - <div class="border border-gray-200 dark:border-gray-700 rounded-sm"> 60 - {{ template "user/fragments/repoCard" (list $ . true) }} 61 - </div> 102 + {{ template "search/fragments/resultCard" . }} 62 103 {{ else }} 63 104 <div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded"> 64 105 <span>No repositories found.</span> ··· 75 116 "Page" .Page 76 117 "TotalCount" .ResultCount 77 118 "BasePath" "search" 78 - "QueryParams" (queryParams "q" .FilterQuery "sort" .SortParam) 119 + "QueryParams" (queryParams "q" .FilterQuery "sort" .SortParam "type" .FilterType) 120 + ) }} 121 + {{ end }} 122 + {{ end }} 123 + 124 + {{ define "codeSearchResults" }} 125 + <div id="repos" class="grid grid-cols-1 gap-4 mb-6"> 126 + {{ range .Results }} 127 + {{ template "search/fragments/resultCard" . }} 128 + {{ else }} 129 + <div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded"> 130 + <span>No results found.</span> 131 + </div> 132 + {{ end }} 133 + </div> 134 + 135 + <div class="md:hidden mb-4"> 136 + {{ template "searchStatistics" . }} 137 + </div> 138 + 139 + {{ if or (gt .Page.Offset 0) .HasMore }} 140 + {{ template "fragments/pagination" (dict 141 + "Page" .Page 142 + "BasePath" "search" 143 + "QueryParams" (queryParams "q" .FilterQuery "type" .FilterType) 144 + "HasMore" .HasMore 79 145 ) }} 80 146 {{ end }} 81 147 {{ end }} 82 148 83 149 {{ define "searchOptionsPanel" }} 84 150 <div class="flex flex-col gap-6 px-2 md:px-0"> 151 + {{ template "searchTypeSwitcher" . }} 152 + 153 + {{ if eq .FilterType "repo" }} 85 154 <div> 86 155 <h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3"> 87 156 Sort by 88 157 </h3> 89 158 {{ template "sortOptionsList" . }} 90 159 </div> 160 + {{ end }} 91 161 92 162 <div> 93 163 <h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3"> ··· 100 170 </div> 101 171 {{ end }} 102 172 173 + {{ define "searchTypeSwitcher" }} 174 + <div> 175 + <h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3"> 176 + Filter by 177 + </h3> 178 + <div> 179 + {{ $options := list 180 + (dict "value" "repo" "name" "Repositories") 181 + (dict "value" "code" "name" "Code") 182 + }} 183 + {{ range $options }} 184 + <label class="flex items-center gap-2 text-sm cursor-pointer"> 185 + <input 186 + type="radio" 187 + value="{{ .value }}" 188 + {{ if eq $.FilterType .value }}checked{{ end }} 189 + onchange="window.location.href='/search?type={{ .value }}&q={{ $.FilterQuery }}'" 190 + > 191 + <span>{{ .name }}</span> 192 + </label> 193 + {{ end }} 194 + </div> 195 + </div> 196 + {{ end }} 197 + 103 198 {{ define "sortOptionsList" }} 104 - {{ $currentQuery := .FilterQuery }} 105 - <select 106 - name="sort" 107 - class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 dark:text-white text-sm" 108 - onchange="window.location.href='/search?q={{ .FilterQuery }}&sort=' + this.value" 109 - > 110 - {{ $currentSort := .SortParam }} 111 - {{ if eq $currentSort "" }} 112 - {{ $currentSort = "relevance" }} 113 - {{ end }} 199 + {{ if eq .FilterType "repo" }} 200 + <select 201 + name="sort" 202 + class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 dark:text-white text-sm" 203 + onchange="window.location.href='/search?type=repo&q={{ .FilterQuery }}&sort=' + this.value" 204 + > 205 + {{ $currentSort := .SortParam }} 206 + {{ if eq $currentSort "" }} 207 + {{ $currentSort = "relevance" }} 208 + {{ end }} 114 209 115 - {{ $options := list 116 - (dict "value" "relevance" "name" "Relevance") 117 - (dict "value" "created-desc" "name" "Newest") 118 - (dict "value" "created-asc" "name" "Oldest") 119 - (dict "value" "stars-desc" "name" "Most Stars") 120 - (dict "value" "stars-asc" "name" "Fewest Stars") 121 - (dict "value" "issues-desc" "name" "Most Issues") 122 - (dict "value" "issues-asc" "name" "Fewest Issues") 123 - (dict "value" "pulls-desc" "name" "Most Pulls") 124 - (dict "value" "pulls-asc" "name" "Fewest Pulls") 125 - }} 210 + {{ $options := list 211 + (dict "value" "relevance" "name" "Relevance") 212 + (dict "value" "created-desc" "name" "Newest") 213 + (dict "value" "created-asc" "name" "Oldest") 214 + (dict "value" "stars-desc" "name" "Most Stars") 215 + (dict "value" "stars-asc" "name" "Fewest Stars") 216 + (dict "value" "issues-desc" "name" "Most Issues") 217 + (dict "value" "issues-asc" "name" "Fewest Issues") 218 + (dict "value" "pulls-desc" "name" "Most Pulls") 219 + (dict "value" "pulls-asc" "name" "Fewest Pulls") 220 + }} 126 221 127 - {{ range $options }} 128 - <option value="{{ .value }}" {{ if eq $currentSort .value }}selected{{ end }}> 129 - {{ .name }} 130 - </option> 131 - {{ end }} 132 - </select> 222 + {{ range $options }} 223 + <option value="{{ .value }}" {{ if eq $currentSort .value }}selected{{ end }}> 224 + {{ .name }} 225 + </option> 226 + {{ end }} 227 + </select> 228 + {{ end }} 133 229 {{ end }} 134 230 135 231 {{ define "languageFilters" }} 136 232 {{ $commonLanguages := list "Go" "JavaScript" "TypeScript" "Python" "Rust" "OCaml" "Haskell" "C" "C++" "Ruby" "Swift" }} 137 233 138 - <div class="flex gap-2 md:flex-wrap"> 234 + <div class="flex flex-wrap gap-2"> 139 235 {{ range $commonLanguages }} 140 236 {{ $lang := . }} 141 - {{ template "languageFilterChip" (dict "Language" $lang "CurrentQuery" $.FilterQuery) }} 237 + {{ template "languageFilterChip" (dict "Language" $lang "CurrentQuery" $.FilterQuery "FilterType" $.FilterType) }} 142 238 {{ end }} 143 239 </div> 144 240 145 241 <p class="hidden md:block text-gray-500 dark:text-gray-400 text-xs mt-3"> 146 242 Click a language to filter results. You can also filter by language by 147 243 adding <code class="px-1 py-0.5 bg-gray-100 dark:bg-gray-700 rounded 148 - text-xs">language:name</code> to the search bar. 244 + text-xs">lang:name</code> to the search bar. 149 245 </p> 150 246 {{ end }} 151 247 152 248 {{ define "languageFilterChip" }} 153 - {{ $lang := .Language }} 249 + {{ $lang := .Language }} 154 250 {{ $currentQuery := .CurrentQuery }} 155 - {{ $langColor := langColor $lang }} 156 - {{ $newQuery := queryParams "q" (printf "language:%s %s" $lang $currentQuery) }} 251 + {{ $filterType := .FilterType }} 252 + {{ $langColor := langColor $lang }} 253 + {{ $newQuery := queryParams "q" (printf "lang:%s %s" $lang $currentQuery) "type" $filterType }} 157 254 158 255 <a 159 256 href="/search?{{ safeUrl $newQuery.Encode }}" ··· 166 263 {{ end }} 167 264 168 265 {{ define "searchStatistics" }} 169 - {{ if gt .ResultCount 0 }} 170 - <div class="text-xs text-gray-600 dark:text-gray-400 border-t border-gray-200 dark:border-gray-700 py-2"> 171 - Returned {{ .ResultCount }} of {{ .DocCount }} repos in {{ .TimeTaken }} 172 - </div> 266 + {{ if eq .FilterType "code" }} 267 + {{ if gt .MatchCount 0 }} 268 + <div class="text-xs text-gray-600 dark:text-gray-400 border-t border-gray-200 dark:border-gray-700 py-2"> 269 + Found {{ .MatchCount }} results in {{ .FileCount }} files in {{ .TimeTaken }} 270 + </div> 271 + {{ end }} 272 + {{ else }} 273 + {{ if gt .ResultCount 0 }} 274 + <div class="text-xs text-gray-600 dark:text-gray-400 border-t border-gray-200 dark:border-gray-700 py-2"> 275 + Returned {{ .ResultCount }} of {{ .DocCount }} repos in {{ .TimeTaken }} 276 + </div> 277 + {{ end }} 173 278 {{ end }} 174 279 {{ end }} 175 - 176 -
+113
appview/state/codesearch.go
··· 1 + package state 2 + 3 + import ( 4 + "errors" 5 + "net/http" 6 + "net/url" 7 + "slices" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "tangled.org/core/appview/codesearch" 11 + "tangled.org/core/appview/db" 12 + "tangled.org/core/appview/models" 13 + "tangled.org/core/appview/pages" 14 + "tangled.org/core/appview/pagination" 15 + "tangled.org/core/orm" 16 + ) 17 + 18 + func (s *State) handleCodeSearch(w http.ResponseWriter, r *http.Request) { 19 + l := s.logger.With("handler", "CodeSearch") 20 + ctx := r.Context() 21 + page := pagination.FromContext(ctx) 22 + q := r.URL.Query().Get("q") 23 + 24 + var redirected bool 25 + var params pages.CodeSearchParams 26 + params.BaseParams = pages.BaseParamsFromContext(ctx) 27 + params.FilterQuery = q 28 + params.Page = page 29 + defer func() { 30 + if redirected { 31 + return 32 + } 33 + if err := s.pages.CodeSearch(w, params); err != nil { 34 + l.Error("failed to render code search", "err", err) 35 + } 36 + }() 37 + 38 + if q == "" { 39 + return 40 + } 41 + 42 + res, err := s.codesearch.Search(ctx, q, page) 43 + if err != nil { 44 + // repo-name-only queries belong to the repo search page; redirect with 45 + // the rewritten query (repo: prefix dropped, lang: kept). 46 + var repoErr *codesearch.RepoOnlyError 47 + if errors.As(err, &repoErr) { 48 + redirected = true 49 + http.Redirect(w, r, "/search?q="+url.QueryEscape(repoErr.Query), http.StatusFound) 50 + return 51 + } 52 + l.Error("code search failed", "err", err, "query", q) 53 + params.ErrorMsg = "Failed to perform search. Please try again later." 54 + return 55 + } 56 + results := res.Results 57 + 58 + repoMap := map[syntax.DID]*models.Repo{} 59 + var repoDids []string 60 + for _, res := range results { 61 + if res.RepoDID == "" { 62 + continue 63 + } 64 + if _, ok := repoMap[res.RepoDID]; !ok { 65 + repoMap[res.RepoDID] = nil 66 + repoDids = append(repoDids, res.RepoDID.String()) 67 + } 68 + } 69 + if len(repoDids) > 0 { 70 + repos, err := db.GetRepos(s.db, orm.FilterIn("repo_did", repoDids)) 71 + if err != nil { 72 + l.Error("failed to load repos for code search", "err", err) 73 + params.ErrorMsg = "Failed to load repos for code search. Please try again later." 74 + return 75 + } 76 + for i := range repos { 77 + repoMap[syntax.DID(repos[i].RepoDid)] = &repos[i] 78 + } 79 + } 80 + 81 + out := make([]pages.SearchResult, 0, len(results)) 82 + for _, res := range results { 83 + csr := pages.SearchResult{ 84 + RepoDID: res.RepoDID, 85 + Repo: repoMap[res.RepoDID], 86 + FilePath: res.FilePath, 87 + Branches: res.Branches, 88 + Commit: res.Commit, 89 + Language: res.Language, 90 + } 91 + if f := res.File; f != nil { 92 + csr.File = &pages.CodeSearchResult_File{ 93 + NameSpans: pages.FileNameSpans(res.FilePath, f.Ranges), 94 + } 95 + } 96 + slices.SortStableFunc(res.Chunks, func(a, b models.Result_ChunkMatch) int { 97 + return a.ContentStartLine - b.ContentStartLine 98 + }) 99 + for _, c := range res.Chunks { 100 + csr.Chunks = append(csr.Chunks, pages.CodeSearchResult_Chunk{ 101 + Lines: pages.ChunkLines(c.Content, c.ContentStartLine, c.Ranges), 102 + MatchCount: len(c.Ranges), 103 + }) 104 + } 105 + out = append(out, csr) 106 + } 107 + 108 + params.Results = out 109 + params.HasMore = res.HasMore 110 + params.MatchCount = res.Stats.MatchCount 111 + params.FileCount = res.Stats.FileCount 112 + params.TimeTaken = res.Stats.Duration 113 + }
+44 -26
appview/state/search.go
··· 17 17 ) 18 18 19 19 func (s *State) Search(w http.ResponseWriter, r *http.Request) { 20 - l := s.logger.With("handler", "Search") 20 + switch r.URL.Query().Get("type") { 21 + case "code": 22 + s.handleCodeSearch(w, r) 23 + case "repo": 24 + s.handleRepoSearch(w, r) 25 + default: 26 + query := r.URL.Query() 27 + query.Set("type", "repo") 28 + http.Redirect(w, r, "/search?"+query.Encode(), http.StatusFound) 29 + } 30 + } 21 31 22 - params := r.URL.Query() 32 + func (s *State) handleRepoSearch(w http.ResponseWriter, r *http.Request) { 33 + l := s.logger.With("handler", "Search") 34 + query := r.URL.Query() 23 35 page := pagination.FromContext(r.Context()) 36 + q := searchquery.Parse(query.Get("q")) 24 37 25 - query := searchquery.Parse(params.Get("q")) 38 + sortParam := query.Get("sort") 39 + sortField, sortDesc := parseSortParam(sortParam) 26 40 27 - sortParam := params.Get("sort") 28 - sortField, sortDesc := parseSortParam(sortParam) 41 + var params pages.SearchReposParams 42 + params.BaseParams = pages.BaseParamsFromContext(r.Context()) 43 + params.FilterQuery = q.String() 44 + params.SortParam = sortParam 45 + params.Page = page 46 + defer func() { 47 + if err := s.pages.SearchRepos(w, params); err != nil { 48 + l.Error("failed to render page", "err", err) 49 + } 50 + }() 29 51 30 52 var language string 31 - if lang := cmp.Or(query.Get("language"), query.Get("lang")); lang != nil { 53 + if lang := cmp.Or(q.Get("language"), q.Get("lang")); lang != nil { 32 54 language = *lang 33 55 } 34 56 35 - tf := searchquery.ExtractTextFilters(query) 57 + tf := searchquery.ExtractTextFilters(q) 36 58 37 59 searchOpts := models.RepoSearchOptions{ 38 60 Keywords: tf.Keywords, ··· 56 78 res, err := s.indexer.Repos.Search(r.Context(), searchOpts) 57 79 if err != nil { 58 80 l.Error("failed to search repos", "err", err) 59 - s.pages.Error500(w) 81 + params.ErrorMsg = "Failed to perform search. Please try again later." 60 82 return 61 83 } 62 84 ··· 66 88 repos, err = db.GetRepos(s.db, orm.FilterIn("id", res.Hits)) 67 89 if err != nil { 68 90 l.Error("failed to get repos by IDs", "err", err) 69 - s.pages.Error500(w) 91 + params.ErrorMsg = "Failed to query repos. Please try again later." 70 92 return 71 93 } 72 94 ··· 94 116 ) 95 117 if err != nil { 96 118 l.Error("failed to get repos", "err", err) 97 - s.pages.Error500(w) 119 + params.ErrorMsg = "Failed to query repos. Please try again later." 98 120 return 99 121 } 100 122 ··· 103 125 ) 104 126 if err != nil { 105 127 l.Error("failed to count repos", "err", err) 106 - s.pages.Error500(w) 128 + params.ErrorMsg = "Failed to count repos. Please try again later." 107 129 return 108 130 } 109 131 ··· 117 139 "resultCount", resultCount, 118 140 "docCount", docCount, 119 141 "time", searchDuration, 120 - "filterQuery", query.String(), 142 + "filterQuery", q.String(), 121 143 "sortParam", sortParam, 122 144 ) 123 145 124 - if !s.config.Core.Dev && query.String() != "" { 146 + if !s.config.Core.Dev && q.String() != "" { 125 147 distinctId := s.oauth.GetDid(r) 126 148 if distinctId == "" { 127 149 distinctId = "anonymous" ··· 131 153 DistinctId: distinctId, 132 154 Event: "search", 133 155 Properties: posthog.Properties{ 134 - "query": query.String(), 156 + "query": q.String(), 135 157 "result_count": resultCount, 136 158 "method": method, 137 159 }, ··· 141 163 }() 142 164 } 143 165 144 - err = s.pages.SearchRepos(w, pages.SearchReposParams{ 145 - BaseParams: pages.BaseParamsFromContext(r.Context()), 146 - Repos: repos, 147 - Page: page, 148 - FilterQuery: query.String(), 149 - SortParam: sortParam, 150 - TimeTaken: searchDuration, 151 - ResultCount: resultCount, 152 - DocCount: docCount, 153 - }) 154 - if err != nil { 155 - l.Error("failed to render page", "err", err) 166 + repoResults := make([]pages.SearchResult, len(repos)) 167 + for i := range repos { 168 + repoResults[i] = pages.SearchResult{Repo: &repos[i]} 156 169 } 170 + 171 + params.Repos = repoResults 172 + params.TimeTaken = searchDuration 173 + params.ResultCount = resultCount 174 + params.DocCount = docCount 157 175 } 158 176 159 177 func (s *State) SearchQuick(w http.ResponseWriter, r *http.Request) {
+3
appview/state/state.go
··· 15 15 "tangled.org/core/appview/bsky" 16 16 "tangled.org/core/appview/cache" 17 17 "tangled.org/core/appview/cloudflare" 18 + "tangled.org/core/appview/codesearch" 18 19 "tangled.org/core/appview/config" 19 20 "tangled.org/core/appview/db" 20 21 "tangled.org/core/appview/email" ··· 76 77 logger *slog.Logger 77 78 validator *validator.Validator 78 79 cfClient *cloudflare.Client 80 + codesearch *codesearch.CodeSearch 79 81 } 80 82 81 83 func Make(ctx context.Context, config *config.Config) (*State, error) { ··· 250 252 logger: logger, 251 253 validator: validator, 252 254 cfClient: cfClient, 255 + codesearch: &codesearch.CodeSearch{Host: config.CodeSearch.ZoektUrl}, 253 256 } 254 257 255 258 // fetch initial bluesky posts if configured
+1
docker-compose.yml
··· 300 300 TANGLED_JETSTREAM_ENDPOINT: wss://jetstream.tngl.boltless.dev/subscribe 301 301 TANGLED_REDIS_ADDR: redis:6379 302 302 TANGLED_KNOTMIRROR_URL: https://mirror.tngl.boltless.dev 303 + TANGLED_CODESEARCH_ZOEKT_URL: https://zoekt.tngl.boltless.dev 303 304 ports: 304 305 - "3000:3000" 305 306 volumes:
+4
input.css
··· 554 554 @apply !bg-yellow-200/30 dark:!bg-yellow-700/30; 555 555 } 556 556 557 + .chunk-match-hl { 558 + @apply rounded-sm !bg-yellow-300/70 dark:!bg-yellow-600/60; 559 + } 560 + 557 561 :is(.line-quote-hl, .line-range-hl) > .min-w-\[3\.5rem\] { 558 562 @apply !bg-yellow-200/30 dark:!bg-yellow-700/30; 559 563 }