add search bar for issues and pulls #637

closed
opened by camsmith.dev targeting master from camsmith.dev/core: feat/issue-pull-search

Closes https://tangled.org/@tangled.org/core/issues/61

This is super claude-y right now so I am happy to restructure it.

I also need to get a knot running locally as I was only able to test the /issues portion, not the /pulls portion

Changed files
+838 -9
appview
db
issues
models
pages
templates
repo
fragments
issues
pulls
pulls
search
+259
appview/db/issues.go
···
}
sort.Slice(issues, func(i, j int) bool {
+
if issues[i].Created.Equal(issues[j].Created) {
+
// Tiebreaker: use issue_id for stable sort
+
return issues[i].IssueId > issues[j].IssueId
+
}
return issues[i].Created.After(issues[j].Created)
})
···
return count, nil
}
+
+
func SearchIssues(e Execer, page pagination.Page, text string, labels []string, sortBy string, sortOrder string, filters ...filter) ([]models.Issue, error) {
+
var conditions []string
+
var args []any
+
+
for _, filter := range filters {
+
conditions = append(conditions, filter.Condition())
+
args = append(args, filter.Arg()...)
+
}
+
+
if text != "" {
+
searchPattern := "%" + text + "%"
+
conditions = append(conditions, "(title like ? or body like ?)")
+
args = append(args, searchPattern, searchPattern)
+
}
+
+
whereClause := ""
+
if len(conditions) > 0 {
+
whereClause = " where " + strings.Join(conditions, " and ")
+
}
+
+
pLower := FilterGte("row_num", page.Offset+1)
+
pUpper := FilterLte("row_num", page.Offset+page.Limit)
+
args = append(args, pLower.Arg()...)
+
args = append(args, pUpper.Arg()...)
+
paginationClause := " where " + pLower.Condition() + " and " + pUpper.Condition()
+
+
query := fmt.Sprintf(
+
`
+
select * from (
+
select
+
id,
+
did,
+
rkey,
+
repo_at,
+
issue_id,
+
title,
+
body,
+
open,
+
created,
+
edited,
+
deleted,
+
row_number() over (order by created desc) as row_num
+
from
+
issues
+
%s
+
) ranked_issues
+
%s
+
`,
+
whereClause,
+
paginationClause,
+
)
+
+
rows, err := e.Query(query, args...)
+
if err != nil {
+
return nil, fmt.Errorf("failed to query issues: %w", err)
+
}
+
defer rows.Close()
+
+
issueMap := make(map[string]*models.Issue)
+
for rows.Next() {
+
var issue models.Issue
+
var createdAt string
+
var editedAt, deletedAt sql.Null[string]
+
var rowNum int64
+
+
err := rows.Scan(
+
&issue.Id,
+
&issue.Did,
+
&issue.Rkey,
+
&issue.RepoAt,
+
&issue.IssueId,
+
&issue.Title,
+
&issue.Body,
+
&issue.Open,
+
&createdAt,
+
&editedAt,
+
&deletedAt,
+
&rowNum,
+
)
+
if err != nil {
+
return nil, fmt.Errorf("failed to scan issue: %w", err)
+
}
+
+
if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
+
issue.Created = t
+
}
+
if editedAt.Valid {
+
if t, err := time.Parse(time.RFC3339, editedAt.V); err == nil {
+
issue.Edited = &t
+
}
+
}
+
if deletedAt.Valid {
+
if t, err := time.Parse(time.RFC3339, deletedAt.V); err == nil {
+
issue.Deleted = &t
+
}
+
}
+
+
atUri := issue.AtUri().String()
+
issueMap[atUri] = &issue
+
}
+
+
repoAts := make([]string, 0, len(issueMap))
+
for _, issue := range issueMap {
+
repoAts = append(repoAts, string(issue.RepoAt))
+
}
+
+
repos, err := GetRepos(e, 0, FilterIn("at_uri", repoAts))
+
if err != nil {
+
return nil, fmt.Errorf("failed to build repo mappings: %w", err)
+
}
+
+
repoMap := make(map[string]*models.Repo)
+
for i := range repos {
+
repoMap[string(repos[i].RepoAt())] = &repos[i]
+
}
+
+
for issueAt, i := range issueMap {
+
if r, ok := repoMap[string(i.RepoAt)]; ok {
+
i.Repo = r
+
} else {
+
delete(issueMap, issueAt)
+
}
+
}
+
+
issueAts := slices.Collect(maps.Keys(issueMap))
+
comments, err := GetIssueComments(e, FilterIn("issue_at", issueAts))
+
if err != nil {
+
return nil, fmt.Errorf("failed to query comments: %w", err)
+
}
+
for i := range comments {
+
issueAt := comments[i].IssueAt
+
if issue, ok := issueMap[issueAt]; ok {
+
issue.Comments = append(issue.Comments, comments[i])
+
}
+
}
+
+
allLabels, err := GetLabels(e, FilterIn("subject", issueAts))
+
if err != nil {
+
return nil, fmt.Errorf("failed to query labels: %w", err)
+
}
+
for issueAt, labels := range allLabels {
+
if issue, ok := issueMap[issueAt.String()]; ok {
+
issue.Labels = labels
+
}
+
}
+
+
reactionCounts := make(map[string]int)
+
if len(issueAts) > 0 {
+
reactionArgs := make([]any, len(issueAts))
+
for i, v := range issueAts {
+
reactionArgs[i] = v
+
}
+
rows, err := e.Query(`
+
select thread_at, count(*) as total
+
from reactions
+
where thread_at in (`+strings.Repeat("?,", len(issueAts)-1)+"?"+`)
+
group by thread_at
+
`, reactionArgs...)
+
if err == nil {
+
defer rows.Close()
+
for rows.Next() {
+
var threadAt string
+
var count int
+
if err := rows.Scan(&threadAt, &count); err == nil {
+
reactionCounts[threadAt] = count
+
}
+
}
+
}
+
}
+
+
if len(labels) > 0 {
+
if len(issueMap) > 0 {
+
var repoAt string
+
for _, issue := range issueMap {
+
repoAt = string(issue.RepoAt)
+
break
+
}
+
+
repo, err := GetRepoByAtUri(e, repoAt)
+
if err == nil && len(repo.Labels) > 0 {
+
labelDefs, err := GetLabelDefinitions(e, FilterIn("at_uri", repo.Labels))
+
if err == nil {
+
labelNameToUri := make(map[string]string)
+
for _, def := range labelDefs {
+
labelNameToUri[def.Name] = def.AtUri().String()
+
}
+
+
for issueAt, issue := range issueMap {
+
hasAllLabels := true
+
for _, labelName := range labels {
+
labelUri, found := labelNameToUri[labelName]
+
if !found {
+
hasAllLabels = false
+
break
+
}
+
if !issue.Labels.ContainsLabel(labelUri) {
+
hasAllLabels = false
+
break
+
}
+
}
+
if !hasAllLabels {
+
delete(issueMap, issueAt)
+
}
+
}
+
}
+
}
+
}
+
}
+
+
var issues []models.Issue
+
for _, i := range issueMap {
+
i.ReactionCount = reactionCounts[i.AtUri().String()]
+
issues = append(issues, *i)
+
}
+
+
sort.Slice(issues, func(i, j int) bool {
+
var less bool
+
+
switch sortBy {
+
case "comments":
+
if len(issues[i].Comments) == len(issues[j].Comments) {
+
// Tiebreaker: use issue_id for stable sort
+
less = issues[i].IssueId > issues[j].IssueId
+
} else {
+
less = len(issues[i].Comments) > len(issues[j].Comments)
+
}
+
case "reactions":
+
iCount := reactionCounts[issues[i].AtUri().String()]
+
jCount := reactionCounts[issues[j].AtUri().String()]
+
if iCount == jCount {
+
// Tiebreaker: use issue_id for stable sort
+
less = issues[i].IssueId > issues[j].IssueId
+
} else {
+
less = iCount > jCount
+
}
+
case "created":
+
fallthrough
+
default:
+
if issues[i].Created.Equal(issues[j].Created) {
+
// Tiebreaker: use issue_id for stable sort
+
less = issues[i].IssueId > issues[j].IssueId
+
} else {
+
less = issues[i].Created.After(issues[j].Created)
+
}
+
}
+
+
if sortOrder == "asc" {
+
return !less
+
}
+
return less
+
})
+
+
return issues, nil
+
}
+196
appview/db/pulls.go
···
return pulls, nil
}
+
+
func SearchPulls(e Execer, text string, labels []string, sortBy string, sortOrder string, filters ...filter) ([]*models.Pull, error) {
+
var conditions []string
+
var args []any
+
+
for _, filter := range filters {
+
conditions = append(conditions, filter.Condition())
+
args = append(args, filter.Arg()...)
+
}
+
+
if text != "" {
+
searchPattern := "%" + text + "%"
+
conditions = append(conditions, "title like ?")
+
args = append(args, searchPattern)
+
}
+
+
whereClause := ""
+
if len(conditions) > 0 {
+
whereClause = " where " + strings.Join(conditions, " and ")
+
}
+
+
query := fmt.Sprintf(`
+
select
+
id,
+
owner_did,
+
pull_id,
+
title,
+
body,
+
target_branch,
+
repo_at,
+
rkey,
+
state,
+
source_branch,
+
source_repo_at,
+
stack_id,
+
change_id,
+
parent_change_id,
+
created
+
from pulls
+
%s
+
order by created desc
+
`, whereClause)
+
+
rows, err := e.Query(query, args...)
+
if err != nil {
+
return nil, fmt.Errorf("failed to query pulls: %w", err)
+
}
+
defer rows.Close()
+
+
pullMap := make(map[string]*models.Pull)
+
for rows.Next() {
+
var pull models.Pull
+
var createdAt string
+
var sourceBranch, sourceRepoAt, stackId, changeId, parentChangeId sql.Null[string]
+
+
err := rows.Scan(
+
&pull.ID,
+
&pull.OwnerDid,
+
&pull.PullId,
+
&pull.Title,
+
&pull.Body,
+
&pull.TargetBranch,
+
&pull.RepoAt,
+
&pull.Rkey,
+
&pull.State,
+
&sourceBranch,
+
&sourceRepoAt,
+
&stackId,
+
&changeId,
+
&parentChangeId,
+
&createdAt,
+
)
+
if err != nil {
+
return nil, fmt.Errorf("failed to scan pull: %w", err)
+
}
+
+
if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
+
pull.Created = t
+
}
+
+
if sourceBranch.Valid || sourceRepoAt.Valid {
+
pull.PullSource = &models.PullSource{}
+
if sourceBranch.Valid {
+
pull.PullSource.Branch = sourceBranch.V
+
}
+
if sourceRepoAt.Valid {
+
uri := syntax.ATURI(sourceRepoAt.V)
+
pull.PullSource.RepoAt = &uri
+
}
+
}
+
+
if stackId.Valid {
+
pull.StackId = stackId.V
+
}
+
if changeId.Valid {
+
pull.ChangeId = changeId.V
+
}
+
if parentChangeId.Valid {
+
pull.ParentChangeId = parentChangeId.V
+
}
+
+
pullAt := pull.PullAt().String()
+
pullMap[pullAt] = &pull
+
}
+
+
// Load submissions and labels
+
for _, pull := range pullMap {
+
submissionsMap, err := GetPullSubmissions(e, FilterEq("pull_at", pull.PullAt().String()))
+
if err != nil {
+
return nil, fmt.Errorf("failed to query submissions: %w", err)
+
}
+
if subs, ok := submissionsMap[pull.PullAt()]; ok {
+
pull.Submissions = subs
+
}
+
}
+
+
// Collect labels
+
pullAts := slices.Collect(maps.Keys(pullMap))
+
allLabels, err := GetLabels(e, FilterIn("subject", pullAts))
+
if err != nil {
+
return nil, fmt.Errorf("failed to query labels: %w", err)
+
}
+
for pullAt, labels := range allLabels {
+
if pull, ok := pullMap[pullAt.String()]; ok {
+
pull.Labels = labels
+
}
+
}
+
+
// Filter by labels if specified
+
if len(labels) > 0 {
+
if len(pullMap) > 0 {
+
var repoAt string
+
for _, pull := range pullMap {
+
repoAt = string(pull.RepoAt)
+
break
+
}
+
+
repo, err := GetRepoByAtUri(e, repoAt)
+
if err == nil && len(repo.Labels) > 0 {
+
labelDefs, err := GetLabelDefinitions(e, FilterIn("at_uri", repo.Labels))
+
if err == nil {
+
labelNameToUri := make(map[string]string)
+
for _, def := range labelDefs {
+
labelNameToUri[def.Name] = def.AtUri().String()
+
}
+
+
for pullAt, pull := range pullMap {
+
hasAllLabels := true
+
for _, labelName := range labels {
+
labelUri, found := labelNameToUri[labelName]
+
if !found {
+
hasAllLabels = false
+
break
+
}
+
if !pull.Labels.ContainsLabel(labelUri) {
+
hasAllLabels = false
+
break
+
}
+
}
+
if !hasAllLabels {
+
delete(pullMap, pullAt)
+
}
+
}
+
}
+
}
+
}
+
}
+
+
var pulls []*models.Pull
+
for _, p := range pullMap {
+
pulls = append(pulls, p)
+
}
+
+
sort.Slice(pulls, func(i, j int) bool {
+
var less bool
+
+
switch sortBy {
+
case "created":
+
fallthrough
+
default:
+
if pulls[i].Created.Equal(pulls[j].Created) {
+
// Tiebreaker: use pull_id for stable sort
+
less = pulls[i].PullId > pulls[j].PullId
+
} else {
+
less = pulls[i].Created.After(pulls[j].Created)
+
}
+
}
+
+
if sortOrder == "asc" {
+
return !less
+
}
+
return less
+
})
+
+
return pulls, nil
+
}
+15
appview/db/repos.go
···
repo.Description = ""
}
+
// Load labels for this repo
+
rows, err := e.Query(`select label_at from repo_labels where repo_at = ?`, atUri)
+
if err != nil {
+
return nil, fmt.Errorf("failed to load repo labels: %w", err)
+
}
+
defer rows.Close()
+
+
for rows.Next() {
+
var labelAt string
+
if err := rows.Scan(&labelAt); err != nil {
+
continue
+
}
+
repo.Labels = append(repo.Labels, labelAt)
+
}
+
return &repo, nil
}
+33 -1
appview/issues/issues.go
···
"tangled.org/core/appview/pages"
"tangled.org/core/appview/pagination"
"tangled.org/core/appview/reporesolver"
+
"tangled.org/core/appview/search"
"tangled.org/core/appview/validator"
"tangled.org/core/idresolver"
tlog "tangled.org/core/log"
···
func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) {
params := r.URL.Query()
state := params.Get("state")
+
searchQuery := params.Get("q")
+
sortBy := params.Get("sort_by")
+
sortOrder := params.Get("sort_order")
+
+
// Use for template (preserve empty values)
+
templateSortBy := sortBy
+
templateSortOrder := sortOrder
+
+
// Default sort values for queries
+
if sortBy == "" {
+
sortBy = "created"
+
}
+
if sortOrder == "" {
+
sortOrder = "desc"
+
}
+
isOpen := true
switch state {
case "open":
···
if isOpen {
openVal = 1
}
-
issues, err := db.GetIssuesPaginated(
+
+
var issues []models.Issue
+
+
// Parse the search query (even if empty, to handle label filters)
+
query := search.Parse(searchQuery)
+
+
// Always use search function to handle sorting
+
issues, err = db.SearchIssues(
rp.db,
page,
+
query.Text,
+
query.Labels,
+
sortBy,
+
sortOrder,
db.FilterEq("repo_at", f.RepoAt()),
db.FilterEq("open", openVal),
)
+
if err != nil {
log.Println("failed to get issues", err)
rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
···
LabelDefs: defs,
FilteringByOpen: isOpen,
Page: page,
+
SearchQuery: searchQuery,
+
SortBy: templateSortBy,
+
SortOrder: templateSortOrder,
})
}
+4 -3
appview/models/issue.go
···
// optionally, populate this when querying for reverse mappings
// like comment counts, parent repo etc.
-
Comments []IssueComment
-
Labels LabelState
-
Repo *Repo
+
Comments []IssueComment
+
ReactionCount int
+
Labels LabelState
+
Repo *Repo
}
func (i *Issue) AtUri() syntax.ATURI {
+6
appview/pages/pages.go
···
LabelDefs map[string]*models.LabelDefinition
Page pagination.Page
FilteringByOpen bool
+
SearchQuery string
+
SortBy string
+
SortOrder string
}
func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error {
···
Stacks map[string]models.Stack
Pipelines map[string]models.Pipeline
LabelDefs map[string]*models.LabelDefinition
+
SearchQuery string
+
SortBy string
+
SortOrder string
func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error {
+185
appview/pages/templates/repo/fragments/searchBar.html
···
+
{{ define "repo/fragments/searchBar" }}
+
<div class="flex gap-2 items-center w-full">
+
<form class="flex-grow flex gap-2" method="get" action="">
+
<div class="flex-grow flex items-center border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800">
+
<input
+
type="text"
+
name="q"
+
value="{{ .SearchQuery }}"
+
placeholder="Search {{ .Placeholder }}... (e.g., 'has:enhancement fix bug')"
+
class="flex-grow px-4 py-2 bg-transparent dark:text-white focus:outline-none"
+
/>
+
<button type="submit" class="px-3 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200">
+
{{ i "search" "w-5 h-5" }}
+
</button>
+
</div>
+
+
<!-- Keep state filter in search -->
+
{{ if .State }}
+
<input type="hidden" name="state" value="{{ .State }}" />
+
{{ end }}
+
+
<!-- Sort options -->
+
{{ $sortBy := .SortBy }}
+
{{ $sortOrder := .SortOrder }}
+
{{ $defaultSortBy := "created" }}
+
{{ $defaultSortOrder := "desc" }}
+
{{ if not $sortBy }}
+
{{ $sortBy = $defaultSortBy }}
+
{{ end }}
+
{{ if not $sortOrder }}
+
{{ $sortOrder = $defaultSortOrder }}
+
{{ end }}
+
<input type="hidden" name="sort_by" value="{{ $sortBy }}" id="sortByInput" />
+
<input type="hidden" name="sort_order" value="{{ $sortOrder }}" id="sortOrderInput" />
+
+
<details class="relative dropdown-menu" id="sortDropdown">
+
<summary class="btn cursor-pointer list-none flex items-center gap-2">
+
{{ i "arrow-down-up" "w-4 h-4" }}
+
<span>
+
{{ if .SortBy }}
+
{{ if eq $sortBy "created" }}Created{{ else if eq $sortBy "comments" }}Comments{{ else if eq $sortBy "reactions" }}Reactions{{ end }}
+
{{ else }}
+
Sort
+
{{ end }}
+
</span>
+
{{ i "chevron-down" "w-4 h-4" }}
+
</summary>
+
<div class="absolute right-0 mt-2 w-56 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded shadow-lg z-10">
+
<div class="p-3">
+
<div class="text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Sort by</div>
+
<div class="space-y-1 mb-3">
+
<div class="flex items-center gap-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 p-2 rounded sort-by-option" data-value="created">
+
{{ if eq $sortBy "created" }}{{ i "check" "w-4 h-4" }}{{ else }}<span class="w-4 h-4"></span>{{ end }}
+
<span class="text-sm dark:text-gray-200">Created</span>
+
</div>
+
<div class="flex items-center gap-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 p-2 rounded sort-by-option" data-value="comments">
+
{{ if eq $sortBy "comments" }}{{ i "check" "w-4 h-4" }}{{ else }}<span class="w-4 h-4"></span>{{ end }}
+
<span class="text-sm dark:text-gray-200">Comments</span>
+
</div>
+
<div class="flex items-center gap-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 p-2 rounded sort-by-option" data-value="reactions">
+
{{ if eq $sortBy "reactions" }}{{ i "check" "w-4 h-4" }}{{ else }}<span class="w-4 h-4"></span>{{ end }}
+
<span class="text-sm dark:text-gray-200">Reactions</span>
+
</div>
+
</div>
+
<div class="text-sm font-medium mb-2 text-gray-700 dark:text-gray-300 pt-2 border-t border-gray-200 dark:border-gray-600">Order</div>
+
<div class="space-y-1">
+
<div class="flex items-center gap-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 p-2 rounded sort-order-option" data-value="desc">
+
{{ if eq $sortOrder "desc" }}{{ i "check" "w-4 h-4" }}{{ else }}<span class="w-4 h-4"></span>{{ end }}
+
<span class="text-sm dark:text-gray-200">Descending</span>
+
</div>
+
<div class="flex items-center gap-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 p-2 rounded sort-order-option" data-value="asc">
+
{{ if eq $sortOrder "asc" }}{{ i "check" "w-4 h-4" }}{{ else }}<span class="w-4 h-4"></span>{{ end }}
+
<span class="text-sm dark:text-gray-200">Ascending</span>
+
</div>
+
</div>
+
</div>
+
</div>
+
</details>
+
+
<!-- Label filter dropdown -->
+
<details class="relative dropdown-menu" id="labelDropdown">
+
<summary class="btn cursor-pointer list-none flex items-center gap-2">
+
{{ i "tag" "w-4 h-4" }}
+
<span>label</span>
+
{{ i "chevron-down" "w-4 h-4" }}
+
</summary>
+
<div class="absolute right-0 mt-2 w-64 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded shadow-lg z-10 max-h-96 overflow-y-auto">
+
<div class="p-3">
+
<div class="text-sm font-semibold mb-2 text-gray-700 dark:text-gray-300">Filter by label</div>
+
<div class="space-y-2">
+
{{ range $uri, $def := .LabelDefs }}
+
<div class="flex items-center gap-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 p-2 rounded label-option" data-label-name="{{ $def.Name }}">
+
<span class="label-checkbox-icon w-4 h-4"></span>
+
<span class="flex-grow text-sm dark:text-gray-200">
+
{{ template "labels/fragments/label" (dict "def" $def "val" "" "withPrefix" false) }}
+
</span>
+
</div>
+
{{ end }}
+
</div>
+
</div>
+
</div>
+
</details>
+
</form>
+
</div>
+
+
<script>
+
(function() {
+
// Handle label filter changes
+
const labelOptions = document.querySelectorAll('.label-option');
+
const searchInput = document.querySelector('input[name="q"]');
+
+
// Initialize checkmarks based on current query
+
const currentQuery = searchInput.value;
+
labelOptions.forEach(option => {
+
const labelName = option.getAttribute('data-label-name');
+
const hasFilter = 'has:' + labelName;
+
const iconSpan = option.querySelector('.label-checkbox-icon');
+
+
if (currentQuery.includes(hasFilter)) {
+
iconSpan.innerHTML = '{{ i "check" "w-4 h-4" }}';
+
}
+
});
+
+
labelOptions.forEach(option => {
+
option.addEventListener('click', function() {
+
const labelName = this.getAttribute('data-label-name');
+
let currentQuery = searchInput.value;
+
const hasFilter = 'has:' + labelName;
+
const iconSpan = this.querySelector('.label-checkbox-icon');
+
const isChecked = currentQuery.includes(hasFilter);
+
+
if (isChecked) {
+
// Remove has: filter
+
currentQuery = currentQuery.replace(hasFilter, '').replace(/\s+/g, ' ');
+
searchInput.value = currentQuery.trim();
+
iconSpan.innerHTML = '';
+
} else {
+
// Add has: filter if not already present
+
currentQuery = currentQuery.trim() + ' ' + hasFilter;
+
searchInput.value = currentQuery.trim();
+
iconSpan.innerHTML = '{{ i "check" "w-4 h-4" }}';
+
}
+
+
form.submit();
+
});
+
});
+
+
// Handle sort option changes
+
const sortByOptions = document.querySelectorAll('.sort-by-option');
+
const sortOrderOptions = document.querySelectorAll('.sort-order-option');
+
const sortByInput = document.getElementById('sortByInput');
+
const sortOrderInput = document.getElementById('sortOrderInput');
+
const form = searchInput.closest('form');
+
+
sortByOptions.forEach(option => {
+
option.addEventListener('click', function() {
+
sortByInput.value = this.getAttribute('data-value');
+
form.submit();
+
});
+
});
+
+
sortOrderOptions.forEach(option => {
+
option.addEventListener('click', function() {
+
sortOrderInput.value = this.getAttribute('data-value');
+
form.submit();
+
});
+
});
+
+
// Make dropdowns mutually exclusive - close others when one opens
+
const dropdowns = document.querySelectorAll('.dropdown-menu');
+
dropdowns.forEach(dropdown => {
+
dropdown.addEventListener('toggle', function(e) {
+
if (this.open) {
+
// Close all other dropdowns
+
dropdowns.forEach(other => {
+
if (other !== this && other.open) {
+
other.open = false;
+
}
+
});
+
}
+
});
+
});
+
})();
+
</script>
+
{{ end }}
+10
appview/pages/templates/repo/issues/fragments/issueListing.html
···
<a href="/{{ $.RepoPrefix }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a>
</span>
+
{{ if gt .ReactionCount 0 }}
+
<span class="before:content-['·']">
+
{{ $s := "s" }}
+
{{ if eq .ReactionCount 1 }}
+
{{ $s = "" }}
+
{{ end }}
+
<a href="/{{ $.RepoPrefix }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ .ReactionCount }} reaction{{$s}}</a>
+
</span>
+
{{ end }}
+
{{ $state := .Labels }}
{{ range $k, $d := $.LabelDefs }}
{{ range $v, $s := $state.GetValSet $d.AtUri.String }}
+30 -3
appview/pages/templates/repo/issues/issues.html
···
{{ end }}
{{ define "repoContent" }}
-
<div class="flex justify-between items-center gap-4">
+
<div class="flex justify-between items-center gap-4 mb-4">
<div class="flex gap-4">
<a
href="?state=open"
···
<span>new</span>
</a>
</div>
+
+
{{ $state := "open" }}
+
{{ if not .FilteringByOpen }}
+
{{ $state = "closed" }}
+
{{ end }}
+
+
{{ template "repo/fragments/searchBar" (dict "SearchQuery" .SearchQuery "Placeholder" "issues" "State" $state "LabelDefs" .LabelDefs "SortBy" .SortBy "SortOrder" .SortOrder) }}
<div class="error" id="issues"></div>
{{ end }}
···
{{ if gt .Page.Offset 0 }}
{{ $prev := .Page.Previous }}
+
{{ $prevUrl := printf "/%s/issues?state=%s&offset=%d&limit=%d" $.RepoInfo.FullName $currentState $prev.Offset $prev.Limit }}
+
{{ if .SearchQuery }}
+
{{ $prevUrl = printf "%s&q=%s" $prevUrl .SearchQuery }}
+
{{ end }}
+
{{ if .SortBy }}
+
{{ $prevUrl = printf "%s&sort_by=%s" $prevUrl .SortBy }}
+
{{ end }}
+
{{ if .SortOrder }}
+
{{ $prevUrl = printf "%s&sort_order=%s" $prevUrl .SortOrder }}
+
{{ end }}
<a
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
hx-boost="true"
-
href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&offset={{ $prev.Offset }}&limit={{ $prev.Limit }}"
+
href = "{{ $prevUrl }}"
>
{{ i "chevron-left" "w-4 h-4" }}
previous
···
{{ if eq (len .Issues) .Page.Limit }}
{{ $next := .Page.Next }}
+
{{ $nextUrl := printf "/%s/issues?state=%s&offset=%d&limit=%d" $.RepoInfo.FullName $currentState $next.Offset $next.Limit }}
+
{{ if .SearchQuery }}
+
{{ $nextUrl = printf "%s&q=%s" $nextUrl .SearchQuery }}
+
{{ end }}
+
{{ if .SortBy }}
+
{{ $nextUrl = printf "%s&sort_by=%s" $nextUrl .SortBy }}
+
{{ end }}
+
{{ if .SortOrder }}
+
{{ $nextUrl = printf "%s&sort_order=%s" $nextUrl .SortOrder }}
+
{{ end }}
<a
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
hx-boost="true"
-
href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&offset={{ $next.Offset }}&limit={{ $next.Limit }}"
+
href = "{{ $nextUrl }}"
>
next
{{ i "chevron-right" "w-4 h-4" }}
+10 -1
appview/pages/templates/repo/pulls/pulls.html
···
{{ end }}
{{ define "repoContent" }}
-
<div class="flex justify-between items-center">
+
<div class="flex justify-between items-center mb-4">
<div class="flex gap-4">
<a
href="?state=open"
···
<span>new</span>
</a>
</div>
+
+
{{ $state := "open" }}
+
{{ if .FilteringBy.IsMerged }}
+
{{ $state = "merged" }}
+
{{ else if .FilteringBy.IsClosed }}
+
{{ $state = "closed" }}
+
{{ end }}
+
+
{{ template "repo/fragments/searchBar" (dict "SearchQuery" .SearchQuery "Placeholder" "pulls" "State" $state "LabelDefs" .LabelDefs "SortBy" .SortBy "SortOrder" .SortOrder) }}
<div class="error" id="pulls"></div>
{{ end }}
+27 -1
appview/pulls/pulls.go
···
"tangled.org/core/appview/pages"
"tangled.org/core/appview/pages/markup"
"tangled.org/core/appview/reporesolver"
+
"tangled.org/core/appview/search"
"tangled.org/core/appview/xrpcclient"
"tangled.org/core/idresolver"
"tangled.org/core/patchutil"
···
func (s *Pulls) RepoPulls(w http.ResponseWriter, r *http.Request) {
user := s.oauth.GetUser(r)
params := r.URL.Query()
+
searchQuery := params.Get("q")
+
sortBy := params.Get("sort_by")
+
sortOrder := params.Get("sort_order")
+
+
templateSortBy := sortBy
+
templateSortOrder := sortOrder
+
+
if sortBy == "" {
+
sortBy = "created"
+
}
+
if sortOrder == "" {
+
sortOrder = "desc"
+
}
state := models.PullOpen
switch params.Get("state") {
···
return
}
-
pulls, err := db.GetPulls(
+
var pulls []*models.Pull
+
+
query := search.Parse(searchQuery)
+
+
pulls, err = db.SearchPulls(
s.db,
+
query.Text,
+
query.Labels,
+
sortBy,
+
sortOrder,
db.FilterEq("repo_at", f.RepoAt()),
db.FilterEq("state", state),
)
+
if err != nil {
log.Println("failed to get pulls", err)
s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.")
···
FilteringBy: state,
Stacks: stacks,
Pipelines: m,
+
SearchQuery: searchQuery,
+
SortBy: templateSortBy,
+
SortOrder: templateSortOrder,
})
}
+63
appview/search/query.go
···
+
package search
+
+
import (
+
"strings"
+
)
+
+
// Query represents a parsed search query
+
type Query struct {
+
// Text search terms (anything that's not a has: filter)
+
Text string
+
// Label filters from has:labelname syntax
+
Labels []string
+
}
+
+
// Parse parses a search query string into a Query struct
+
// Syntax:
+
// - "has:enhancement" adds a label filter
+
// - Other text becomes part of the text search
+
func Parse(queryStr string) Query {
+
q := Query{
+
Labels: []string{},
+
}
+
+
// Split query into tokens
+
tokens := strings.Fields(queryStr)
+
var textParts []string
+
+
for _, token := range tokens {
+
// Check if it's a has: filter
+
if strings.HasPrefix(token, "has:") {
+
label := strings.TrimPrefix(token, "has:")
+
if label != "" {
+
q.Labels = append(q.Labels, label)
+
}
+
} else {
+
// It's a text search term
+
textParts = append(textParts, token)
+
}
+
}
+
+
q.Text = strings.Join(textParts, " ")
+
return q
+
}
+
+
// String converts a Query back to a query string
+
func (q Query) String() string {
+
var parts []string
+
+
if q.Text != "" {
+
parts = append(parts, q.Text)
+
}
+
+
for _, label := range q.Labels {
+
parts = append(parts, "has:"+label)
+
}
+
+
return strings.Join(parts, " ")
+
}
+
+
// HasFilters returns true if the query has any search filters
+
func (q Query) HasFilters() bool {
+
return q.Text != "" || len(q.Labels) > 0
+
}