diff --git a/.air/appview.toml b/.air/appview.toml
index 83f14ca2..172d9313 100644
--- a/.air/appview.toml
+++ b/.air/appview.toml
@@ -6,5 +6,5 @@ cmd = "go build -o out/appview.out cmd/appview/main.go"
bin = "out/appview.out"
include_ext = ["go"]
-exclude_dir = ["avatar", "camo", "sites", "indexes", "nix", "tmp", "node_modules"]
+exclude_dir = ["avatar", "camo", "sites", "indexes", "nix", "tmp", "out", "target", "node_modules"]
stop_on_error = true
diff --git a/appview/pages/pages.go b/appview/pages/pages.go
index 62fbef10..9bcbcdc2 100644
--- a/appview/pages/pages.go
+++ b/appview/pages/pages.go
@@ -930,6 +930,27 @@ func (p *Pages) RepoIndexPage(w io.Writer, params RepoIndexParams) error {
return p.executeRepo("repo/index", w, params)
}
+type RepoSearchParams struct {
+ BaseParams
+ RepoInfo repoinfo.RepoInfo
+ Active string
+ FilterQuery string
+}
+
+func (p *Pages) RepoSearchPage(w io.Writer, params RepoSearchParams) error {
+ params.Active = "overview"
+ return p.executeRepo("repo/search", w, params)
+}
+
+type RepoSearchResultsFragmentParams struct {
+ Results []SearchResult
+ ErrorMsg string
+}
+
+func (p *Pages) RepoSearchResultsFragment(w io.Writer, params RepoSearchResultsFragmentParams) error {
+ return p.executePlain("repo/fragments/searchResults", w, params)
+}
+
type RepoLogParams struct {
BaseParams
RepoInfo repoinfo.RepoInfo
diff --git a/appview/pages/templates/repo/fragments/searchResults.html b/appview/pages/templates/repo/fragments/searchResults.html
new file mode 100644
index 00000000..755fcabd
--- /dev/null
+++ b/appview/pages/templates/repo/fragments/searchResults.html
@@ -0,0 +1,91 @@
+{{ define "repo/fragments/searchResults" }}
+
+ {{ if .ErrorMsg }}
+
+ {{ i "circle-alert" "size-4" }}
+ {{ .ErrorMsg }}
+
+ {{ else }}
+ {{ range .Results }}
+ {{ template "resultCard" . }}
+ {{ else }}
+
+ No results found.
+
+ {{ end }}
+ {{ end }}
+
+{{ end }}
+
+{{ define "resultCard" }}
+ {{ $owner := resolve .Repo.Did }}
+ {{ $slug := .Repo.Slug }}
+ {{ if .File }}
+ {{/* filename match */}}
+
+
+
+ {{ with .Language }}
+ {{ template "repo/fragments/colorBall" (dict "color" (langColor .)) }}
+ {{ . }}
+ {{ end }}
+
+
+ {{ else if .Chunks }}
+ {{/* chunk match */}}
+
+
+
+
+ {{ with .Language }}
+ {{ template "repo/fragments/colorBall" (dict "color" (langColor .)) }}
+ {{ . }}
+ {{ end }}
+
+
+ {{ range $i, $chunk := .Chunks }}
+ {{ if lt $i 3 }}
+ {{ if gt $i 0 }}
+
+ ···
+
+ {{ end }}
+ {{ template "search/fragments/chunkBody" (dict "Owner" $owner "Slug" $slug "Commit" $.Commit "FilePath" $.FilePath "Chunk" $chunk) }}
+ {{ end }}
+ {{ end }}
+ {{ if gt (len .Chunks) 3 }}
+
+
+
+ {{ i "chevron-down" "size-3 m-1" }}
+ Show {{ (slice .Chunks 3).MatchCount }} more matches
+
+
+ {{ i "chevron-up" "size-3 m-1" }}
+ Show less
+
+
+ {{ range $i, $chunk := .Chunks }}
+ {{ if ge $i 3 }}
+
+ ···
+
+ {{ template "search/fragments/chunkBody" (dict "Owner" $owner "Slug" $slug "Commit" $.Commit "FilePath" $.FilePath "Chunk" $chunk) }}
+ {{ end }}
+ {{ end }}
+
+ {{ end }}
+
+ {{ end }}
+{{ end }}
diff --git a/appview/pages/templates/repo/index.html b/appview/pages/templates/repo/index.html
index 2e251b53..3972dd17 100644
--- a/appview/pages/templates/repo/index.html
+++ b/appview/pages/templates/repo/index.html
@@ -24,6 +24,9 @@
{{ i "tags" "w-4" "h-4" }} {{ scaleFmt (len .Tags) }}
+
+ {{ i "search" "size-4" }}
+
{{ template "repo/fragments/cloneDropdown" . }}
diff --git a/appview/pages/templates/repo/search.html b/appview/pages/templates/repo/search.html
new file mode 100644
index 00000000..25fd566e
--- /dev/null
+++ b/appview/pages/templates/repo/search.html
@@ -0,0 +1,31 @@
+{{ define "title" }}Search · {{ .RepoInfo.FullName }} · Tangled{{ end }}
+
+{{ define "extrameta" }}
+ {{ template "repo/fragments/meta" . }}
+
+ {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo) }}
+{{ end }}
+
+{{ define "repoContent" }}
+
+
+
+
+ {{ i "search-code" "size-4" }}
+
+
+
+
+ {{ template "repo/fragments/searchResults" dict }}
+
+{{ end }}
diff --git a/appview/repo/filesearch.go b/appview/repo/filesearch.go
new file mode 100644
index 00000000..dd1bcd1f
--- /dev/null
+++ b/appview/repo/filesearch.go
@@ -0,0 +1,95 @@
+package repo
+
+import (
+ "fmt"
+ "net/http"
+ "slices"
+
+ "github.com/bluesky-social/indigo/atproto/syntax"
+ "tangled.org/core/appview/models"
+ "tangled.org/core/appview/pages"
+ "tangled.org/core/appview/pagination"
+)
+
+func (rp *Repo) Search(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("Hx-Request") == "true" {
+ rp.searchResultsFragment(w, r)
+ return
+ }
+ if err := rp.pages.RepoSearchPage(w, pages.RepoSearchParams{
+ BaseParams: pages.BaseParamsFromContext(r.Context()),
+ RepoInfo: rp.repoResolver.GetRepoInfo(r, rp.oauth.GetMultiAccountUser(r)),
+ FilterQuery: "",
+ }); err != nil {
+ rp.logger.Error("failed to render", "err", err)
+ }
+}
+
+func (rp *Repo) searchResultsFragment(w http.ResponseWriter, r *http.Request) {
+ l := rp.logger.With("handler", "Search")
+ repo, err := rp.repoResolver.Resolve(r)
+ if err != nil {
+ l.Error("failed to get repo", "err", err)
+ return
+ }
+ q := r.URL.Query().Get("q")
+
+ var params pages.RepoSearchResultsFragmentParams
+ defer func() {
+ if err := rp.pages.RepoSearchResultsFragment(w, params); err != nil {
+ l.Error("failed to render", "err", err)
+ }
+ }()
+
+ if q == "" {
+ return
+ }
+
+ q = fmt.Sprintf(`meta.did:%s %q`, repo.RepoDid, q)
+
+ ctx := r.Context()
+
+ res, err := rp.codesearch.Search(ctx, q, pagination.Page{Limit: 50})
+ if err != nil {
+ l.Error("failed to search files", "err", err)
+ params.ErrorMsg = "Failed to perform search. Please try again later."
+ return
+ }
+
+ if len(res.Results) == 0 {
+ return
+ }
+
+ out := make([]pages.SearchResult, 0, len(res.Results))
+ for _, res := range res.Results {
+ if res.RepoDID != syntax.DID(repo.RepoDid) {
+ continue
+ }
+ csr := pages.SearchResult{
+ RepoDID: res.RepoDID,
+ Repo: repo,
+ FilePath: res.FilePath,
+ Branches: res.Branches,
+ Commit: res.Commit,
+ Language: res.Language,
+ }
+ if f := res.File; f != nil {
+ csr.File = &pages.CodeSearchResult_File{
+ NameSpans: pages.FileNameSpans(res.FilePath, f.Ranges),
+ }
+ }
+ slices.SortStableFunc(res.Chunks, func(a, b models.Result_ChunkMatch) int {
+ return a.ContentStartLine - b.ContentStartLine
+ })
+ for _, c := range res.Chunks {
+ csr.Chunks = append(csr.Chunks, pages.CodeSearchResult_Chunk{
+ Lines: pages.ChunkLines(c.Content, c.ContentStartLine, c.Ranges),
+ MatchCount: len(c.Ranges),
+ })
+ }
+ out = append(out, csr)
+ }
+
+ l.Debug("repo file search result", "len", len(out), "duration", res.Stats.Duration)
+ params.Results = out
+}
diff --git a/appview/repo/repo.go b/appview/repo/repo.go
index 771170ea..696578e2 100644
--- a/appview/repo/repo.go
+++ b/appview/repo/repo.go
@@ -13,6 +13,7 @@ import (
"time"
"tangled.org/core/appview/cloudflare"
+ "tangled.org/core/appview/codesearch"
"tangled.org/core/api/tangled"
"tangled.org/core/appview/config"
@@ -61,6 +62,7 @@ type Repo struct {
validator *validator.Validator
cfClient *cloudflare.Client
ogreClient *ogre.Client
+ codesearch *codesearch.CodeSearch
}
func New(
@@ -77,6 +79,7 @@ func New(
logger *slog.Logger,
validator *validator.Validator,
cfClient *cloudflare.Client,
+ codesearch *codesearch.CodeSearch,
) *Repo {
return &Repo{
oauth: oauth,
@@ -93,6 +96,7 @@ func New(
validator: validator,
cfClient: cfClient,
ogreClient: ogre.NewClient(config.Ogre.Host),
+ codesearch: codesearch,
}
}
diff --git a/appview/repo/router.go b/appview/repo/router.go
index 606010d0..ba9a313a 100644
--- a/appview/repo/router.go
+++ b/appview/repo/router.go
@@ -76,6 +76,8 @@ func (rp *Repo) Router(mw *middleware.Middleware) http.Handler {
r.Get("/edit", rp.EditLabelPanel)
})
+ r.Get("/search", rp.Search)
+
// settings routes, needs auth
r.Group(func(r chi.Router) {
r.Use(middleware.AuthMiddleware(rp.oauth))
diff --git a/appview/state/router.go b/appview/state/router.go
index 0f30598b..1b1e5a6a 100644
--- a/appview/state/router.go
+++ b/appview/state/router.go
@@ -412,6 +412,7 @@ func (s *State) RepoRouter(mw *middleware.Middleware) http.Handler {
log.SubLogger(s.logger, "repo"),
s.validator,
s.cfClient,
+ s.codesearch,
)
return repo.Router(mw)
}