diff --git a/appview/codesearch/codesearch.go b/appview/codesearch/codesearch.go new file mode 100644 index 00000000..4e6ce993 --- /dev/null +++ b/appview/codesearch/codesearch.go @@ -0,0 +1,201 @@ +package codesearch + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/sourcegraph/zoekt" + "github.com/sourcegraph/zoekt/query" + "tangled.org/core/appview/models" + "tangled.org/core/appview/pagination" +) + +type CodeSearch struct { + Host string // zoekt-webserver host. example: https://zoekt.example.com + Client *http.Client +} + +func (s *CodeSearch) GetClient() *http.Client { + if s.Client != nil { + return s.Client + } + return http.DefaultClient +} + +var ErrRepoOnlyQuery = errors.New("query only search using repo name. use repo-search service instead.") + +// jsonSearchArgs mirrors zoekt's /api/search request body. +type jsonSearchArgs struct { + Q string + Opts *zoekt.SearchOptions +} + +// jsonSearchReply mirrors zoekt's /api/search response body. +type jsonSearchReply struct { + Result *zoekt.SearchResult +} + +// SearchResults is a single page of content-search results plus whether more +// pages follow. +type SearchResults struct { + Results []models.Result + HasMore bool +} + +// Search queries zoekt server for FileNameMatch or ChunkMatch, returning the +// page described by page. +// +// zoekt is top-N only (no offset): we cap MaxDocDisplayCount to one file beyond +// the requested window, then slice the flattened results locally. Each file +// yields at least one card, so fetching offset+limit+1 files guarantees at least +// offset+limit+1 cards when more exist, which is enough to fill the window and +// detect a next page. Top-N is score-ordered and stable across larger caps +// (superset-prefix), so slicing is consistent between pages. +// +// It returns ErrRepoOnlyQuery when the query only matches repository names; such +// queries should be served by the repo-search service rather than content search. +func (s *CodeSearch) Search(ctx context.Context, queryStr string, page pagination.Page) (*SearchResults, error) { + q, err := query.Parse(queryStr) + if err != nil { + return nil, fmt.Errorf("parse query: %w", err) + } + if isRepoOnly(q) { + return nil, ErrRepoOnlyQuery + } + + // ChunkMatches gives rune-based column ranges and multi-line support, which + // is what we want for highlighting. + opts := &zoekt.SearchOptions{ + ChunkMatches: true, + MaxWallTime: 10 * time.Second, + NumContextLines: 2, + } + if page.Limit > 0 { + // +1 file ⇒ at least +1 card, so we can detect a following page. + opts.MaxDocDisplayCount = page.Offset + page.Limit + 1 + } + + body, err := json.Marshal(jsonSearchArgs{ + Q: queryStr, + Opts: opts, + }) + if err != nil { + return nil, fmt.Errorf("marshal request: %w", err) + } + + url := strings.TrimRight(s.Host, "/") + "/api/search" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("build request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := s.GetClient().Do(req) + if err != nil { + return nil, fmt.Errorf("do request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return nil, fmt.Errorf("zoekt search: status %d: %s", resp.StatusCode, strings.TrimSpace(string(b))) + } + + var reply jsonSearchReply + if err := json.NewDecoder(resp.Body).Decode(&reply); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + if reply.Result == nil { + return &SearchResults{}, nil + } + + all := toResults(reply.Result) + end := page.Offset + page.Limit + if page.Limit <= 0 { + // No window requested: return everything. + return &SearchResults{Results: all}, nil + } + + hasMore := len(all) > end // extra card present ⇒ more pages + if page.Offset >= len(all) { + return &SearchResults{HasMore: false}, nil + } + if end > len(all) { + end = len(all) + } + return &SearchResults{Results: all[page.Offset:end], HasMore: hasMore}, nil +} + +// toResults flattens zoekt FileMatches into local Results, emitting one Result +// per ChunkMatch. zoekt returns either filename matches or content matches per +// file, never both, so each chunk maps cleanly to File or Chunk. +func toResults(sr *zoekt.SearchResult) []models.Result { + var out []models.Result + for _, fm := range sr.Files { + // HACK: zoekt use int64 repo.ID as identifier, but we expect DID (string) as an repo identifier. + // as a quick hack without patching zoekt, we extract the DID from RepoURLs + repoDID := extractDID(sr.RepoURLs[fm.Repository]) + for _, cm := range fm.ChunkMatches { + r := models.Result{RepoDID: repoDID} + if cm.FileName { + r.File = &models.Result_FileMatch{ + FilePath: fm.FileName, + Branches: fm.Branches, + Commit: fm.Version, + Language: fm.Language, + Ranges: cm.Ranges, + } + } else { + r.Chunk = &models.Result_ChunkMatch{ + FilePath: fm.FileName, + Branches: fm.Branches, + Commit: fm.Version, + Language: fm.Language, + Content: string(cm.Content), + ContentStartLine: int(cm.ContentStart.LineNumber), + Ranges: cm.Ranges, + } + } + out = append(out, r) + } + } + return out +} + +// extractDID pulls the repo DID out of a zoekt FileURLTemplate of the form +// "{appviewURL}/{repoDID}/blob/{commit}/{path}". +func extractDID(urlTemplate string) syntax.DID { + if urlTemplate == "" { + return "" + } + u, err := url.Parse(urlTemplate) + if err != nil { + return "" + } + seg := strings.SplitN(strings.TrimPrefix(u.Path, "/"), "/", 2)[0] + return syntax.DID(seg) +} + +// isRepoOnly reports whether the query only matches repository names. Mirrors +// zoekt's own logic in web/server.go: every atom is a *query.Repo, or the +// top-level query is a type:repo wrapper. +func isRepoOnly(q query.Q) bool { + if t, ok := q.(*query.Type); ok && t.Type == query.TypeRepo { + return true + } + repoOnly := true + query.VisitAtoms(q, func(a query.Q) { + _, ok := a.(*query.Repo) + repoOnly = repoOnly && ok + }) + return repoOnly +} diff --git a/appview/codesearch/codesearch_test.go b/appview/codesearch/codesearch_test.go new file mode 100644 index 00000000..37fcacba --- /dev/null +++ b/appview/codesearch/codesearch_test.go @@ -0,0 +1,54 @@ +package codesearch + +import ( + "testing" + + "github.com/sourcegraph/zoekt/query" +) + +func TestIsRepoOnly(t *testing.T) { + cases := []struct { + query string + want bool + }{ + {"repo:foo", true}, + {"repo:foo repo:bar", true}, + {"repo:foo bar", false}, + {"repo:foo file:x", false}, + {"file:x", false}, + {"type:repo foo", true}, + {"foo", false}, + {"branch:main", false}, + } + + for _, tc := range cases { + t.Run(tc.query, func(t *testing.T) { + q, err := query.Parse(tc.query) + if err != nil { + t.Fatalf("parse %q: %v", tc.query, err) + } + if got := isRepoOnly(q); got != tc.want { + t.Errorf("isRepoOnly(%q) = %v, want %v", tc.query, got, tc.want) + } + }) + } +} + +func TestExtractDID(t *testing.T) { + cases := []struct { + tmpl string + want string + }{ + {"https://tangled.org/did:plc:abc123/blob/{{.Version}}/{{.Path}}", "did:plc:abc123"}, + {"http://localhost:3000/did:web:example.com/blob/{{.Version}}/{{.Path}}", "did:web:example.com"}, + {"", ""}, + } + + for _, tc := range cases { + t.Run(tc.tmpl, func(t *testing.T) { + if got := extractDID(tc.tmpl); string(got) != tc.want { + t.Errorf("extractDID(%q) = %q, want %q", tc.tmpl, got, tc.want) + } + }) + } +} diff --git a/appview/config/config.go b/appview/config/config.go index 36a66a68..a3c4f650 100644 --- a/appview/config/config.go +++ b/appview/config/config.go @@ -157,6 +157,10 @@ type OgreConfig struct { Host string `env:"HOST, default=https://ogre.tangled.network"` } +type CodeSearchConfig struct { + ZoektUrl string `env:"ZOEKT_URL"` +} + type SSHConfig struct { Enabled bool `env:"ENABLED, default=false"` ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:3333"` @@ -198,6 +202,7 @@ type Config struct { KnotMirror KnotMirrorConfig `env:",prefix=TANGLED_KNOTMIRROR_"` Ogre OgreConfig `env:",prefix=TANGLED_OGRE_"` SSH SSHConfig `env:",prefix=TANGLED_SSH_"` + CodeSearch CodeSearchConfig `env:",prefix=TANGLED_CODESEARCH_"` } func LoadConfig(ctx context.Context) (*Config, error) { diff --git a/appview/models/codesearch.go b/appview/models/codesearch.go new file mode 100644 index 00000000..421fbea1 --- /dev/null +++ b/appview/models/codesearch.go @@ -0,0 +1,47 @@ +package models + +import ( + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/sourcegraph/zoekt" +) + +// Result is union type of file/chunk match +type Result struct { + RepoDID syntax.DID + File *Result_FileMatch + Chunk *Result_ChunkMatch +} + +type Result_FileMatch struct { + FilePath string + Branches []string // branch names + Commit string // commit id + Language string + // Ranges are the matched span(s) within FilePath. LineNumber is always 1 for + // filename matches; Column is 1-based and in runes. + Ranges []zoekt.Range +} + +type Result_ChunkMatch struct { + FilePath string + Branches []string // branch names + Commit string // commit id + Language string + // Content is a contiguous run of complete lines that fully contains Ranges. + Content string + // ContentStartLine is the 1-based line number of Content's first line. + ContentStartLine int + // Ranges are the matched span(s) within the file. LineNumber/Column are + // 1-based, Column is in runes. A Range may span multiple lines. + Ranges []zoekt.Range +} + +// IsFileMatch tells if search result is from file-name match +func (r *Result) IsFileMatch() bool { + return r.File != nil +} + +// IsChunkMatch tells if search result is from chunk match +func (r *Result) IsChunkMatch() bool { + return r.Chunk != nil +} diff --git a/appview/pages/funcmap.go b/appview/pages/funcmap.go index 8232deb4..496f88a7 100644 --- a/appview/pages/funcmap.go +++ b/appview/pages/funcmap.go @@ -16,6 +16,7 @@ import ( "net/url" "path/filepath" "reflect" + "sort" "strings" "time" @@ -688,3 +689,47 @@ func durationFmt(duration time.Duration, names [4]string) string { return strings.Join(parts, " ") } + +// mergeIntervals sorts half-open rune intervals and merges overlapping/adjacent ones. +func mergeIntervals(in [][2]int) [][2]int { + if len(in) < 2 { + return in + } + sort.Slice(in, func(i, j int) bool { return in[i][0] < in[j][0] }) + out := in[:1] + for _, iv := range in[1:] { + last := &out[len(out)-1] + if iv[0] <= last[1] { + if iv[1] > last[1] { + last[1] = iv[1] + } + continue + } + out = append(out, iv) + } + return out +} + +// spanRunes splits runes into alternating unmatched/matched ChunkSpans using the +// (sorted, merged) match intervals. Returns nil for an empty line. +func spanRunes(runes []rune, intervals [][2]int) []ChunkSpan { + if len(runes) == 0 { + return nil + } + if len(intervals) == 0 { + return []ChunkSpan{{Text: string(runes)}} + } + var spans []ChunkSpan + pos := 0 + for _, iv := range intervals { + if iv[0] > pos { + spans = append(spans, ChunkSpan{Text: string(runes[pos:iv[0]])}) + } + spans = append(spans, ChunkSpan{Text: string(runes[iv[0]:iv[1]]), Match: true}) + pos = iv[1] + } + if pos < len(runes) { + spans = append(spans, ChunkSpan{Text: string(runes[pos:])}) + } + return spans +} diff --git a/appview/pages/pages.go b/appview/pages/pages.go index 5d8aafa7..f89d3b9b 100644 --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -33,6 +33,7 @@ import ( "github.com/bluesky-social/indigo/atproto/identity" "github.com/bluesky-social/indigo/atproto/syntax" "github.com/go-git/go-git/v5/plumbing" + "github.com/sourcegraph/zoekt" ) //go:embed templates/* static legal @@ -1751,6 +1752,149 @@ func (p *Pages) SearchQuickMobile(w io.Writer, params SearchQuickParams) error { return tpl.ExecuteTemplate(w, "search/fragments/quickMobile", params) } +// CodeSearchResult pairs a code-search match with the resolved repository +// metadata. Repo may be nil when the matched repo is not (or no longer) known +// to the appview DB; templates guard against that. Chunk/File form a union +// mirroring models.Result, with match highlighting precomputed during the +// models.Result -> CodeSearchResult conversion in the handler. +type CodeSearchResult struct { + RepoDID syntax.DID + Repo *models.Repo + Chunk *CodeSearchResult_Chunk + File *CodeSearchResult_File +} + +// CodeSearchResult_Chunk is a content match with its lines pre-rendered. +type CodeSearchResult_Chunk struct { + FilePath string + Branches []string + Commit string + Language string + Lines []ChunkLine // precomputed from Content/ContentStartLine/Ranges +} + +// CodeSearchResult_File is a filename match with its name spans pre-rendered. +type CodeSearchResult_File struct { + FilePath string + Branches []string + Commit string + Language string + NameSpans []ChunkSpan // precomputed from FilePath/Ranges +} + +// Commit/FilePath dispatch for the top-level "View file" link, rendered outside +// the with .Chunk / with .File blocks. +func (r *CodeSearchResult) Commit() string { + if r.Chunk != nil { + return r.Chunk.Commit + } + if r.File != nil { + return r.File.Commit + } + return "" +} + +func (r *CodeSearchResult) FilePath() string { + if r.Chunk != nil { + return r.Chunk.FilePath + } + if r.File != nil { + return r.File.FilePath + } + return "" +} + +// ChunkSpan is a contiguous run of a chunk line; Match marks the runs that fall +// within a zoekt match range (rendered with a stronger highlight). +type ChunkSpan struct { + Text string + Match bool +} + +// ChunkLine is a single rendered line of a chunk match, produced by the +// chunkLines template helper. Spans split the line into matched/unmatched runs; +// Highlight marks lines that overlap any match range (subtle full-line tint). +type ChunkLine struct { + Num int + Spans []ChunkSpan + Highlight bool +} + +// ChunkLines renders a chunk's Content into per-line ChunkLines, splitting each +// line into matched/unmatched spans using ranges. startLine is the 1-based line +// number of the first line. +func ChunkLines(content string, startLine int, ranges []zoekt.Range) []ChunkLine { + if startLine < 1 { + startLine = 1 + } + // trim a single trailing newline so we don't emit a spurious empty line + content = strings.TrimSuffix(content, "\n") + lines := strings.Split(content, "\n") + out := make([]ChunkLine, len(lines)) + for i, text := range lines { + num := startLine + i + runes := []rune(text) + + // collect matched rune intervals [c0,c1) for this line + var intervals [][2]int + for _, rg := range ranges { + if num < int(rg.Start.LineNumber) || num > int(rg.End.LineNumber) { + continue + } + c0, c1 := 0, len(runes) + if num == int(rg.Start.LineNumber) { + c0 = int(rg.Start.Column) - 1 + } + if num == int(rg.End.LineNumber) { + c1 = int(rg.End.Column) - 1 + } + c0 = max(0, min(c0, len(runes))) + c1 = max(0, min(c1, len(runes))) + if c0 < c1 { + intervals = append(intervals, [2]int{c0, c1}) + } + } + intervals = mergeIntervals(intervals) + + out[i] = ChunkLine{ + Num: num, + Spans: spanRunes(runes, intervals), + Highlight: len(intervals) > 0, + } + } + return out +} + +// FileNameSpans splits a filename into matched/unmatched spans using ranges. +// Filename ranges live on line 1; columns are clamped to rune bounds. +func FileNameSpans(name string, ranges []zoekt.Range) []ChunkSpan { + runes := []rune(name) + var intervals [][2]int + for _, rg := range ranges { + if rg.Start.LineNumber > 1 || rg.End.LineNumber < 1 { + continue + } + c0 := max(0, min(int(rg.Start.Column)-1, len(runes))) + c1 := max(0, min(int(rg.End.Column)-1, len(runes))) + if c0 < c1 { + intervals = append(intervals, [2]int{c0, c1}) + } + } + return spanRunes(runes, mergeIntervals(intervals)) +} + +type CodeSearchParams struct { + BaseParams + FilterQuery string + Results []CodeSearchResult + Page pagination.Page + HasMore bool +} + +func (p *Pages) CodeSearch(w io.Writer, params CodeSearchParams) error { + return p.execute("codesearch/index", w, params) +} + func (p *Pages) Home(w io.Writer, params TimelineParams) error { return p.execute("timeline/home", w, params) } diff --git a/appview/pages/templates/codesearch/fragments/chunkCard.html b/appview/pages/templates/codesearch/fragments/chunkCard.html new file mode 100644 index 00000000..4c55d8a3 --- /dev/null +++ b/appview/pages/templates/codesearch/fragments/chunkCard.html @@ -0,0 +1,90 @@ +{{ define "codesearch/fragments/chunkCard" }} + {{ $owner := "" }} + {{ $slug := "" }} + {{ if .Repo }} + {{ $owner = resolve .Repo.Did }} + {{ $slug = .Repo.Slug }} + {{ end }} + +
+
+
+ {{ if .Repo }} + {{ $owner }}/{{ $slug }} + {{ else }} + {{ .RepoDID }} + {{ end }} +
+ {{ if .Repo }} + + View file + {{ i "arrow-up-right" "size-2" }} + + {{ end }} +
+ + {{ with .Chunk }} +
+ +
+ {{ range .Lines }} +
+
{{ .Num }}
+
+ {{- range .Spans -}} + {{- if .Match -}} + {{ .Text }} + {{- else -}} + {{ .Text }} + {{- end -}} + {{- end -}} + {{- if not .Spans }}​{{ end -}} +
+
+ {{ end }} +
+ +
+
+ {{ if .Language }} + {{ template "repo/fragments/colorBall" (dict "color" (langColor .Language)) }} + {{ .Language }} + {{ end }} +
+
+ {{ .FilePath }} + {{ if .Branches }} + · + {{ index .Branches 0 }} + {{ end }} +
+
+
+ {{ end }} + + {{ with .File }} + +
+
+ {{ i "file" "w-4 h-4 flex-shrink-0" }} + + {{- range .NameSpans -}} + {{- if .Match -}}{{ .Text }} + {{- else -}}{{ .Text }}{{- end -}} + {{- end -}} + +
+
+ {{ if .Language }} + {{ template "repo/fragments/colorBall" (dict "color" (langColor .Language)) }} + {{ .Language }} + {{ end }} + {{ if .Branches }} + · + {{ index .Branches 0 }} + {{ end }} +
+
+ {{ end }} +
+{{ end }} diff --git a/appview/pages/templates/codesearch/fragments/repoCard.html b/appview/pages/templates/codesearch/fragments/repoCard.html new file mode 100644 index 00000000..c33423fa --- /dev/null +++ b/appview/pages/templates/codesearch/fragments/repoCard.html @@ -0,0 +1,5 @@ +{{ define "codesearch/fragments/repoCard" }} +
+
+{{ end }} + diff --git a/appview/pages/templates/codesearch/index.html b/appview/pages/templates/codesearch/index.html new file mode 100644 index 00000000..13f70262 --- /dev/null +++ b/appview/pages/templates/codesearch/index.html @@ -0,0 +1,172 @@ +{{ define "title" }}Code Search · Tangled{{ end }} + +{{ define "content" }} +

Code Search

+ +
+
+ {{ template "searchBar" . }} + {{ template "searchResults" . }} +
+ + +
+{{ end }} + +{{ define "searchBar" }} +
+
+ + +
+
+ {{ template "sortOptionsList" . }} +
+
+ +
+
+ {{ template "languageFilters" . }} +
+
+{{ end }} + +{{ define "sortOptionsList" }} + +{{ end }} + +{{ define "searchResults" }} +
+ {{ range .Results }} + {{ template "codesearch/fragments/chunkCard" . }} + {{ else }} +
+ No results found. +
+ {{ end }} +
+ +
+ {{ template "searchStatistics" . }} +
+ + {{ template "codeSearchPager" . }} +{{ end }} + +{{ define "codeSearchPager" }} + {{ if or (gt .Page.Offset 0) .HasMore }} + {{ $page := .Page }} + {{ $query := safeUrl (queryParams "q" .FilterQuery).Encode }} +
+ + {{ i "chevron-left" "w-4 h-4" }} + Prev + + + + Next + {{ i "chevron-right" "w-4 h-4" }} + +
+ {{ end }} +{{ end }} + +{{ define "searchOptionsPanel" }} +
+ + + +
+

+ Languages +

+ {{ template "languageFilters" . }} +
+ + {{ template "searchStatistics" . }} +
+{{ end }} + +{{ define "languageFilters" }} + {{ $commonLanguages := list "Go" "JavaScript" "TypeScript" "Python" "Rust" "OCaml" "Haskell" "C" "C++" "Ruby" "Swift" }} + +
+ {{ range $commonLanguages }} + {{ $lang := . }} + {{ template "languageFilterChip" (dict "Language" $lang "CurrentQuery" $.FilterQuery) }} + {{ end }} +
+ + +{{ end }} + +{{ define "languageFilterChip" }} + {{ $lang := .Language }} + {{ $currentQuery := .CurrentQuery }} + {{ $langColor := langColor $lang }} + {{ $newQuery := queryParams "q" (printf "lang:%s %s" $lang $currentQuery) }} + + + {{ template "repo/fragments/colorBall" (dict "color" $langColor) }} + {{ $lang }} + +{{ end }} + +{{ define "searchStatistics" }} + +{{ end }} diff --git a/appview/state/codesearch.go b/appview/state/codesearch.go new file mode 100644 index 00000000..b29de785 --- /dev/null +++ b/appview/state/codesearch.go @@ -0,0 +1,105 @@ +package state + +import ( + "errors" + "net/http" + "net/url" + + "tangled.org/core/appview/codesearch" + "tangled.org/core/appview/db" + "tangled.org/core/appview/models" + "tangled.org/core/appview/pages" + "tangled.org/core/appview/pagination" + "tangled.org/core/orm" +) + +func (s *State) CodeSearch(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query().Get("q") + ctx := r.Context() + page := pagination.FromContext(ctx) + + render := func(results []pages.CodeSearchResult, hasMore bool) { + if err := s.pages.CodeSearch(w, pages.CodeSearchParams{ + BaseParams: pages.BaseParamsFromContext(ctx), + FilterQuery: q, + Results: results, + Page: page, + HasMore: hasMore, + }); err != nil { + s.logger.Error("failed to render code search", "err", err) + } + } + + if q == "" { + render(nil, false) + return + } + + // 1. perform content search + res, err := s.codesearch.Search(ctx, q, page) + if err != nil { + // repo-name-only queries belong to the repo search page. + if errors.Is(err, codesearch.ErrRepoOnlyQuery) { + http.Redirect(w, r, "/search?q="+url.QueryEscape(q), http.StatusFound) + return + } + s.logger.Error("code search failed", "err", err, "query", q) + render(nil, false) + return + } + results := res.Results + + // 2. resolve matched repos by their DID + byDID := map[string]*models.Repo{} + var dids []string + for _, res := range results { + did := res.RepoDID.String() + if did == "" { + continue + } + if _, ok := byDID[did]; !ok { + byDID[did] = nil + dids = append(dids, did) + } + } + if len(dids) > 0 { + repos, err := db.GetRepos(s.db, orm.FilterIn("repo_did", dids)) + if err != nil { + s.logger.Error("failed to load repos for code search", "err", err) + } else { + for i := range repos { + byDID[repos[i].RepoDid] = &repos[i] + } + } + } + + // 3. convert to page results + out := make([]pages.CodeSearchResult, len(results)) + for i, res := range results { + csr := pages.CodeSearchResult{ + RepoDID: res.RepoDID, + Repo: byDID[res.RepoDID.String()], + } + if c := res.Chunk; c != nil { + csr.Chunk = &pages.CodeSearchResult_Chunk{ + FilePath: c.FilePath, + Branches: c.Branches, + Commit: c.Commit, + Language: c.Language, + Lines: pages.ChunkLines(c.Content, c.ContentStartLine, c.Ranges), + } + } + if f := res.File; f != nil { + csr.File = &pages.CodeSearchResult_File{ + FilePath: f.FilePath, + Branches: f.Branches, + Commit: f.Commit, + Language: f.Language, + NameSpans: pages.FileNameSpans(f.FilePath, f.Ranges), + } + } + out[i] = csr + } + + render(out, res.HasMore) +} diff --git a/appview/state/router.go b/appview/state/router.go index c467acb3..2774c813 100644 --- a/appview/state/router.go +++ b/appview/state/router.go @@ -187,6 +187,8 @@ func (s *State) StandardRouter(mw *middleware.Middleware) http.Handler { r.With(middleware.AuthMiddleware(s.oauth)).Get("/search/quick", s.SearchQuick) r.With(middleware.AuthMiddleware(s.oauth)).Get("/search/quick/mobile", s.SearchQuickMobile) + r.With(middleware.Paginate).Get("/codesearch", s.CodeSearch) + r.Post("/account/switch", s.SwitchAccount) r.With(middleware.AuthMiddleware(s.oauth)).Delete("/account/{did}", s.RemoveAccount) diff --git a/appview/state/state.go b/appview/state/state.go index e73ff28d..a4fe0a4f 100644 --- a/appview/state/state.go +++ b/appview/state/state.go @@ -15,6 +15,7 @@ import ( "tangled.org/core/appview/bsky" "tangled.org/core/appview/cache" "tangled.org/core/appview/cloudflare" + "tangled.org/core/appview/codesearch" "tangled.org/core/appview/config" "tangled.org/core/appview/db" "tangled.org/core/appview/email" @@ -76,6 +77,7 @@ type State struct { logger *slog.Logger validator *validator.Validator cfClient *cloudflare.Client + codesearch *codesearch.CodeSearch } func Make(ctx context.Context, config *config.Config) (*State, error) { @@ -250,6 +252,7 @@ func Make(ctx context.Context, config *config.Config) (*State, error) { logger: logger, validator: validator, cfClient: cfClient, + codesearch: &codesearch.CodeSearch{Host: config.CodeSearch.ZoektUrl}, } // fetch initial bluesky posts if configured diff --git a/docker-compose.yml b/docker-compose.yml index 8373b756..766ea214 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -242,6 +242,7 @@ services: TANGLED_JETSTREAM_ENDPOINT: wss://jetstream.tngl.boltless.dev/subscribe TANGLED_REDIS_ADDR: redis:6379 TANGLED_KNOTMIRROR_URL: https://mirror.tngl.boltless.dev + TANGLED_CODESEARCH_ZOEKT_URL: https://zoekt.tngl.boltless.dev ports: - "3000:3000" volumes: diff --git a/input.css b/input.css index af00177c..00e8a29d 100644 --- a/input.css +++ b/input.css @@ -554,6 +554,10 @@ @apply !bg-yellow-200/30 dark:!bg-yellow-700/30; } +.chunk-match-hl { + @apply rounded-sm !bg-yellow-300/70 dark:!bg-yellow-600/60; +} + :is(.line-quote-hl, .line-range-hl) > .min-w-\[3\.5rem\] { @apply !bg-yellow-200/30 dark:!bg-yellow-700/30; }