+259
appview/db/issues.go
+259
appview/db/issues.go
···
237
237
}
238
238
239
239
sort.Slice(issues, func(i, j int) bool {
240
+
if issues[i].Created.Equal(issues[j].Created) {
241
+
// Tiebreaker: use issue_id for stable sort
242
+
return issues[i].IssueId > issues[j].IssueId
243
+
}
240
244
return issues[i].Created.After(issues[j].Created)
241
245
})
242
246
···
490
494
491
495
return count, nil
492
496
}
497
+
498
+
func SearchIssues(e Execer, page pagination.Page, text string, labels []string, sortBy string, sortOrder string, filters ...filter) ([]models.Issue, error) {
499
+
var conditions []string
500
+
var args []any
501
+
502
+
for _, filter := range filters {
503
+
conditions = append(conditions, filter.Condition())
504
+
args = append(args, filter.Arg()...)
505
+
}
506
+
507
+
if text != "" {
508
+
searchPattern := "%" + text + "%"
509
+
conditions = append(conditions, "(title like ? or body like ?)")
510
+
args = append(args, searchPattern, searchPattern)
511
+
}
512
+
513
+
whereClause := ""
514
+
if len(conditions) > 0 {
515
+
whereClause = " where " + strings.Join(conditions, " and ")
516
+
}
517
+
518
+
pLower := FilterGte("row_num", page.Offset+1)
519
+
pUpper := FilterLte("row_num", page.Offset+page.Limit)
520
+
args = append(args, pLower.Arg()...)
521
+
args = append(args, pUpper.Arg()...)
522
+
paginationClause := " where " + pLower.Condition() + " and " + pUpper.Condition()
523
+
524
+
query := fmt.Sprintf(
525
+
`
526
+
select * from (
527
+
select
528
+
id,
529
+
did,
530
+
rkey,
531
+
repo_at,
532
+
issue_id,
533
+
title,
534
+
body,
535
+
open,
536
+
created,
537
+
edited,
538
+
deleted,
539
+
row_number() over (order by created desc) as row_num
540
+
from
541
+
issues
542
+
%s
543
+
) ranked_issues
544
+
%s
545
+
`,
546
+
whereClause,
547
+
paginationClause,
548
+
)
549
+
550
+
rows, err := e.Query(query, args...)
551
+
if err != nil {
552
+
return nil, fmt.Errorf("failed to query issues: %w", err)
553
+
}
554
+
defer rows.Close()
555
+
556
+
issueMap := make(map[string]*models.Issue)
557
+
for rows.Next() {
558
+
var issue models.Issue
559
+
var createdAt string
560
+
var editedAt, deletedAt sql.Null[string]
561
+
var rowNum int64
562
+
563
+
err := rows.Scan(
564
+
&issue.Id,
565
+
&issue.Did,
566
+
&issue.Rkey,
567
+
&issue.RepoAt,
568
+
&issue.IssueId,
569
+
&issue.Title,
570
+
&issue.Body,
571
+
&issue.Open,
572
+
&createdAt,
573
+
&editedAt,
574
+
&deletedAt,
575
+
&rowNum,
576
+
)
577
+
if err != nil {
578
+
return nil, fmt.Errorf("failed to scan issue: %w", err)
579
+
}
580
+
581
+
if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
582
+
issue.Created = t
583
+
}
584
+
if editedAt.Valid {
585
+
if t, err := time.Parse(time.RFC3339, editedAt.V); err == nil {
586
+
issue.Edited = &t
587
+
}
588
+
}
589
+
if deletedAt.Valid {
590
+
if t, err := time.Parse(time.RFC3339, deletedAt.V); err == nil {
591
+
issue.Deleted = &t
592
+
}
593
+
}
594
+
595
+
atUri := issue.AtUri().String()
596
+
issueMap[atUri] = &issue
597
+
}
598
+
599
+
repoAts := make([]string, 0, len(issueMap))
600
+
for _, issue := range issueMap {
601
+
repoAts = append(repoAts, string(issue.RepoAt))
602
+
}
603
+
604
+
repos, err := GetRepos(e, 0, FilterIn("at_uri", repoAts))
605
+
if err != nil {
606
+
return nil, fmt.Errorf("failed to build repo mappings: %w", err)
607
+
}
608
+
609
+
repoMap := make(map[string]*models.Repo)
610
+
for i := range repos {
611
+
repoMap[string(repos[i].RepoAt())] = &repos[i]
612
+
}
613
+
614
+
for issueAt, i := range issueMap {
615
+
if r, ok := repoMap[string(i.RepoAt)]; ok {
616
+
i.Repo = r
617
+
} else {
618
+
delete(issueMap, issueAt)
619
+
}
620
+
}
621
+
622
+
issueAts := slices.Collect(maps.Keys(issueMap))
623
+
comments, err := GetIssueComments(e, FilterIn("issue_at", issueAts))
624
+
if err != nil {
625
+
return nil, fmt.Errorf("failed to query comments: %w", err)
626
+
}
627
+
for i := range comments {
628
+
issueAt := comments[i].IssueAt
629
+
if issue, ok := issueMap[issueAt]; ok {
630
+
issue.Comments = append(issue.Comments, comments[i])
631
+
}
632
+
}
633
+
634
+
allLabels, err := GetLabels(e, FilterIn("subject", issueAts))
635
+
if err != nil {
636
+
return nil, fmt.Errorf("failed to query labels: %w", err)
637
+
}
638
+
for issueAt, labels := range allLabels {
639
+
if issue, ok := issueMap[issueAt.String()]; ok {
640
+
issue.Labels = labels
641
+
}
642
+
}
643
+
644
+
reactionCounts := make(map[string]int)
645
+
if len(issueAts) > 0 {
646
+
reactionArgs := make([]any, len(issueAts))
647
+
for i, v := range issueAts {
648
+
reactionArgs[i] = v
649
+
}
650
+
rows, err := e.Query(`
651
+
select thread_at, count(*) as total
652
+
from reactions
653
+
where thread_at in (`+strings.Repeat("?,", len(issueAts)-1)+"?"+`)
654
+
group by thread_at
655
+
`, reactionArgs...)
656
+
if err == nil {
657
+
defer rows.Close()
658
+
for rows.Next() {
659
+
var threadAt string
660
+
var count int
661
+
if err := rows.Scan(&threadAt, &count); err == nil {
662
+
reactionCounts[threadAt] = count
663
+
}
664
+
}
665
+
}
666
+
}
667
+
668
+
if len(labels) > 0 {
669
+
if len(issueMap) > 0 {
670
+
var repoAt string
671
+
for _, issue := range issueMap {
672
+
repoAt = string(issue.RepoAt)
673
+
break
674
+
}
675
+
676
+
repo, err := GetRepoByAtUri(e, repoAt)
677
+
if err == nil && len(repo.Labels) > 0 {
678
+
labelDefs, err := GetLabelDefinitions(e, FilterIn("at_uri", repo.Labels))
679
+
if err == nil {
680
+
labelNameToUri := make(map[string]string)
681
+
for _, def := range labelDefs {
682
+
labelNameToUri[def.Name] = def.AtUri().String()
683
+
}
684
+
685
+
for issueAt, issue := range issueMap {
686
+
hasAllLabels := true
687
+
for _, labelName := range labels {
688
+
labelUri, found := labelNameToUri[labelName]
689
+
if !found {
690
+
hasAllLabels = false
691
+
break
692
+
}
693
+
if !issue.Labels.ContainsLabel(labelUri) {
694
+
hasAllLabels = false
695
+
break
696
+
}
697
+
}
698
+
if !hasAllLabels {
699
+
delete(issueMap, issueAt)
700
+
}
701
+
}
702
+
}
703
+
}
704
+
}
705
+
}
706
+
707
+
var issues []models.Issue
708
+
for _, i := range issueMap {
709
+
i.ReactionCount = reactionCounts[i.AtUri().String()]
710
+
issues = append(issues, *i)
711
+
}
712
+
713
+
sort.Slice(issues, func(i, j int) bool {
714
+
var less bool
715
+
716
+
switch sortBy {
717
+
case "comments":
718
+
if len(issues[i].Comments) == len(issues[j].Comments) {
719
+
// Tiebreaker: use issue_id for stable sort
720
+
less = issues[i].IssueId > issues[j].IssueId
721
+
} else {
722
+
less = len(issues[i].Comments) > len(issues[j].Comments)
723
+
}
724
+
case "reactions":
725
+
iCount := reactionCounts[issues[i].AtUri().String()]
726
+
jCount := reactionCounts[issues[j].AtUri().String()]
727
+
if iCount == jCount {
728
+
// Tiebreaker: use issue_id for stable sort
729
+
less = issues[i].IssueId > issues[j].IssueId
730
+
} else {
731
+
less = iCount > jCount
732
+
}
733
+
case "created":
734
+
fallthrough
735
+
default:
736
+
if issues[i].Created.Equal(issues[j].Created) {
737
+
// Tiebreaker: use issue_id for stable sort
738
+
less = issues[i].IssueId > issues[j].IssueId
739
+
} else {
740
+
less = issues[i].Created.After(issues[j].Created)
741
+
}
742
+
}
743
+
744
+
if sortOrder == "asc" {
745
+
return !less
746
+
}
747
+
return less
748
+
})
749
+
750
+
return issues, nil
751
+
}
+196
appview/db/pulls.go
+196
appview/db/pulls.go
···
736
736
737
737
return pulls, nil
738
738
}
739
+
740
+
func SearchPulls(e Execer, text string, labels []string, sortBy string, sortOrder string, filters ...filter) ([]*models.Pull, error) {
741
+
var conditions []string
742
+
var args []any
743
+
744
+
for _, filter := range filters {
745
+
conditions = append(conditions, filter.Condition())
746
+
args = append(args, filter.Arg()...)
747
+
}
748
+
749
+
if text != "" {
750
+
searchPattern := "%" + text + "%"
751
+
conditions = append(conditions, "title like ?")
752
+
args = append(args, searchPattern)
753
+
}
754
+
755
+
whereClause := ""
756
+
if len(conditions) > 0 {
757
+
whereClause = " where " + strings.Join(conditions, " and ")
758
+
}
759
+
760
+
query := fmt.Sprintf(`
761
+
select
762
+
id,
763
+
owner_did,
764
+
pull_id,
765
+
title,
766
+
body,
767
+
target_branch,
768
+
repo_at,
769
+
rkey,
770
+
state,
771
+
source_branch,
772
+
source_repo_at,
773
+
stack_id,
774
+
change_id,
775
+
parent_change_id,
776
+
created
777
+
from pulls
778
+
%s
779
+
order by created desc
780
+
`, whereClause)
781
+
782
+
rows, err := e.Query(query, args...)
783
+
if err != nil {
784
+
return nil, fmt.Errorf("failed to query pulls: %w", err)
785
+
}
786
+
defer rows.Close()
787
+
788
+
pullMap := make(map[string]*models.Pull)
789
+
for rows.Next() {
790
+
var pull models.Pull
791
+
var createdAt string
792
+
var sourceBranch, sourceRepoAt, stackId, changeId, parentChangeId sql.Null[string]
793
+
794
+
err := rows.Scan(
795
+
&pull.ID,
796
+
&pull.OwnerDid,
797
+
&pull.PullId,
798
+
&pull.Title,
799
+
&pull.Body,
800
+
&pull.TargetBranch,
801
+
&pull.RepoAt,
802
+
&pull.Rkey,
803
+
&pull.State,
804
+
&sourceBranch,
805
+
&sourceRepoAt,
806
+
&stackId,
807
+
&changeId,
808
+
&parentChangeId,
809
+
&createdAt,
810
+
)
811
+
if err != nil {
812
+
return nil, fmt.Errorf("failed to scan pull: %w", err)
813
+
}
814
+
815
+
if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
816
+
pull.Created = t
817
+
}
818
+
819
+
if sourceBranch.Valid || sourceRepoAt.Valid {
820
+
pull.PullSource = &models.PullSource{}
821
+
if sourceBranch.Valid {
822
+
pull.PullSource.Branch = sourceBranch.V
823
+
}
824
+
if sourceRepoAt.Valid {
825
+
uri := syntax.ATURI(sourceRepoAt.V)
826
+
pull.PullSource.RepoAt = &uri
827
+
}
828
+
}
829
+
830
+
if stackId.Valid {
831
+
pull.StackId = stackId.V
832
+
}
833
+
if changeId.Valid {
834
+
pull.ChangeId = changeId.V
835
+
}
836
+
if parentChangeId.Valid {
837
+
pull.ParentChangeId = parentChangeId.V
838
+
}
839
+
840
+
pullAt := pull.PullAt().String()
841
+
pullMap[pullAt] = &pull
842
+
}
843
+
844
+
// Load submissions and labels
845
+
for _, pull := range pullMap {
846
+
submissionsMap, err := GetPullSubmissions(e, FilterEq("pull_at", pull.PullAt().String()))
847
+
if err != nil {
848
+
return nil, fmt.Errorf("failed to query submissions: %w", err)
849
+
}
850
+
if subs, ok := submissionsMap[pull.PullAt()]; ok {
851
+
pull.Submissions = subs
852
+
}
853
+
}
854
+
855
+
// Collect labels
856
+
pullAts := slices.Collect(maps.Keys(pullMap))
857
+
allLabels, err := GetLabels(e, FilterIn("subject", pullAts))
858
+
if err != nil {
859
+
return nil, fmt.Errorf("failed to query labels: %w", err)
860
+
}
861
+
for pullAt, labels := range allLabels {
862
+
if pull, ok := pullMap[pullAt.String()]; ok {
863
+
pull.Labels = labels
864
+
}
865
+
}
866
+
867
+
// Filter by labels if specified
868
+
if len(labels) > 0 {
869
+
if len(pullMap) > 0 {
870
+
var repoAt string
871
+
for _, pull := range pullMap {
872
+
repoAt = string(pull.RepoAt)
873
+
break
874
+
}
875
+
876
+
repo, err := GetRepoByAtUri(e, repoAt)
877
+
if err == nil && len(repo.Labels) > 0 {
878
+
labelDefs, err := GetLabelDefinitions(e, FilterIn("at_uri", repo.Labels))
879
+
if err == nil {
880
+
labelNameToUri := make(map[string]string)
881
+
for _, def := range labelDefs {
882
+
labelNameToUri[def.Name] = def.AtUri().String()
883
+
}
884
+
885
+
for pullAt, pull := range pullMap {
886
+
hasAllLabels := true
887
+
for _, labelName := range labels {
888
+
labelUri, found := labelNameToUri[labelName]
889
+
if !found {
890
+
hasAllLabels = false
891
+
break
892
+
}
893
+
if !pull.Labels.ContainsLabel(labelUri) {
894
+
hasAllLabels = false
895
+
break
896
+
}
897
+
}
898
+
if !hasAllLabels {
899
+
delete(pullMap, pullAt)
900
+
}
901
+
}
902
+
}
903
+
}
904
+
}
905
+
}
906
+
907
+
var pulls []*models.Pull
908
+
for _, p := range pullMap {
909
+
pulls = append(pulls, p)
910
+
}
911
+
912
+
sort.Slice(pulls, func(i, j int) bool {
913
+
var less bool
914
+
915
+
switch sortBy {
916
+
case "created":
917
+
fallthrough
918
+
default:
919
+
if pulls[i].Created.Equal(pulls[j].Created) {
920
+
// Tiebreaker: use pull_id for stable sort
921
+
less = pulls[i].PullId > pulls[j].PullId
922
+
} else {
923
+
less = pulls[i].Created.After(pulls[j].Created)
924
+
}
925
+
}
926
+
927
+
if sortOrder == "asc" {
928
+
return !less
929
+
}
930
+
return less
931
+
})
932
+
933
+
return pulls, nil
934
+
}
+15
appview/db/repos.go
+15
appview/db/repos.go
···
372
372
repo.Description = ""
373
373
}
374
374
375
+
// Load labels for this repo
376
+
rows, err := e.Query(`select label_at from repo_labels where repo_at = ?`, atUri)
377
+
if err != nil {
378
+
return nil, fmt.Errorf("failed to load repo labels: %w", err)
379
+
}
380
+
defer rows.Close()
381
+
382
+
for rows.Next() {
383
+
var labelAt string
384
+
if err := rows.Scan(&labelAt); err != nil {
385
+
continue
386
+
}
387
+
repo.Labels = append(repo.Labels, labelAt)
388
+
}
389
+
375
390
return &repo, nil
376
391
}
377
392
+33
-1
appview/issues/issues.go
+33
-1
appview/issues/issues.go
···
26
26
"tangled.org/core/appview/pages"
27
27
"tangled.org/core/appview/pagination"
28
28
"tangled.org/core/appview/reporesolver"
29
+
"tangled.org/core/appview/search"
29
30
"tangled.org/core/appview/validator"
30
31
"tangled.org/core/idresolver"
31
32
tlog "tangled.org/core/log"
···
759
760
func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) {
760
761
params := r.URL.Query()
761
762
state := params.Get("state")
763
+
searchQuery := params.Get("q")
764
+
sortBy := params.Get("sort_by")
765
+
sortOrder := params.Get("sort_order")
766
+
767
+
// Use for template (preserve empty values)
768
+
templateSortBy := sortBy
769
+
templateSortOrder := sortOrder
770
+
771
+
// Default sort values for queries
772
+
if sortBy == "" {
773
+
sortBy = "created"
774
+
}
775
+
if sortOrder == "" {
776
+
sortOrder = "desc"
777
+
}
778
+
762
779
isOpen := true
763
780
switch state {
764
781
case "open":
···
786
803
if isOpen {
787
804
openVal = 1
788
805
}
789
-
issues, err := db.GetIssuesPaginated(
806
+
807
+
var issues []models.Issue
808
+
809
+
// Parse the search query (even if empty, to handle label filters)
810
+
query := search.Parse(searchQuery)
811
+
812
+
// Always use search function to handle sorting
813
+
issues, err = db.SearchIssues(
790
814
rp.db,
791
815
page,
816
+
query.Text,
817
+
query.Labels,
818
+
sortBy,
819
+
sortOrder,
792
820
db.FilterEq("repo_at", f.RepoAt()),
793
821
db.FilterEq("open", openVal),
794
822
)
823
+
795
824
if err != nil {
796
825
log.Println("failed to get issues", err)
797
826
rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
···
821
850
LabelDefs: defs,
822
851
FilteringByOpen: isOpen,
823
852
Page: page,
853
+
SearchQuery: searchQuery,
854
+
SortBy: templateSortBy,
855
+
SortOrder: templateSortOrder,
824
856
})
825
857
}
826
858
+4
-3
appview/models/issue.go
+4
-3
appview/models/issue.go
···
24
24
25
25
// optionally, populate this when querying for reverse mappings
26
26
// like comment counts, parent repo etc.
27
-
Comments []IssueComment
28
-
Labels LabelState
29
-
Repo *Repo
27
+
Comments []IssueComment
28
+
ReactionCount int
29
+
Labels LabelState
30
+
Repo *Repo
30
31
}
31
32
32
33
func (i *Issue) AtUri() syntax.ATURI {
+6
appview/pages/pages.go
+6
appview/pages/pages.go
···
969
969
LabelDefs map[string]*models.LabelDefinition
970
970
Page pagination.Page
971
971
FilteringByOpen bool
972
+
SearchQuery string
973
+
SortBy string
974
+
SortOrder string
972
975
}
973
976
974
977
func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error {
···
1102
1105
Stacks map[string]models.Stack
1103
1106
Pipelines map[string]models.Pipeline
1104
1107
LabelDefs map[string]*models.LabelDefinition
1108
+
SearchQuery string
1109
+
SortBy string
1110
+
SortOrder string
1105
1111
}
1106
1112
1107
1113
func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error {
+185
appview/pages/templates/repo/fragments/searchBar.html
+185
appview/pages/templates/repo/fragments/searchBar.html
···
1
+
{{ define "repo/fragments/searchBar" }}
2
+
<div class="flex gap-2 items-center w-full">
3
+
<form class="flex-grow flex gap-2" method="get" action="">
4
+
<div class="flex-grow flex items-center border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800">
5
+
<input
6
+
type="text"
7
+
name="q"
8
+
value="{{ .SearchQuery }}"
9
+
placeholder="Search {{ .Placeholder }}... (e.g., 'has:enhancement fix bug')"
10
+
class="flex-grow px-4 py-2 bg-transparent dark:text-white focus:outline-none"
11
+
/>
12
+
<button type="submit" class="px-3 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200">
13
+
{{ i "search" "w-5 h-5" }}
14
+
</button>
15
+
</div>
16
+
17
+
<!-- Keep state filter in search -->
18
+
{{ if .State }}
19
+
<input type="hidden" name="state" value="{{ .State }}" />
20
+
{{ end }}
21
+
22
+
<!-- Sort options -->
23
+
{{ $sortBy := .SortBy }}
24
+
{{ $sortOrder := .SortOrder }}
25
+
{{ $defaultSortBy := "created" }}
26
+
{{ $defaultSortOrder := "desc" }}
27
+
{{ if not $sortBy }}
28
+
{{ $sortBy = $defaultSortBy }}
29
+
{{ end }}
30
+
{{ if not $sortOrder }}
31
+
{{ $sortOrder = $defaultSortOrder }}
32
+
{{ end }}
33
+
<input type="hidden" name="sort_by" value="{{ $sortBy }}" id="sortByInput" />
34
+
<input type="hidden" name="sort_order" value="{{ $sortOrder }}" id="sortOrderInput" />
35
+
36
+
<details class="relative dropdown-menu" id="sortDropdown">
37
+
<summary class="btn cursor-pointer list-none flex items-center gap-2">
38
+
{{ i "arrow-down-up" "w-4 h-4" }}
39
+
<span>
40
+
{{ if .SortBy }}
41
+
{{ if eq $sortBy "created" }}Created{{ else if eq $sortBy "comments" }}Comments{{ else if eq $sortBy "reactions" }}Reactions{{ end }}
42
+
{{ else }}
43
+
Sort
44
+
{{ end }}
45
+
</span>
46
+
{{ i "chevron-down" "w-4 h-4" }}
47
+
</summary>
48
+
<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">
49
+
<div class="p-3">
50
+
<div class="text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Sort by</div>
51
+
<div class="space-y-1 mb-3">
52
+
<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">
53
+
{{ if eq $sortBy "created" }}{{ i "check" "w-4 h-4" }}{{ else }}<span class="w-4 h-4"></span>{{ end }}
54
+
<span class="text-sm dark:text-gray-200">Created</span>
55
+
</div>
56
+
<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">
57
+
{{ if eq $sortBy "comments" }}{{ i "check" "w-4 h-4" }}{{ else }}<span class="w-4 h-4"></span>{{ end }}
58
+
<span class="text-sm dark:text-gray-200">Comments</span>
59
+
</div>
60
+
<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">
61
+
{{ if eq $sortBy "reactions" }}{{ i "check" "w-4 h-4" }}{{ else }}<span class="w-4 h-4"></span>{{ end }}
62
+
<span class="text-sm dark:text-gray-200">Reactions</span>
63
+
</div>
64
+
</div>
65
+
<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>
66
+
<div class="space-y-1">
67
+
<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">
68
+
{{ if eq $sortOrder "desc" }}{{ i "check" "w-4 h-4" }}{{ else }}<span class="w-4 h-4"></span>{{ end }}
69
+
<span class="text-sm dark:text-gray-200">Descending</span>
70
+
</div>
71
+
<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">
72
+
{{ if eq $sortOrder "asc" }}{{ i "check" "w-4 h-4" }}{{ else }}<span class="w-4 h-4"></span>{{ end }}
73
+
<span class="text-sm dark:text-gray-200">Ascending</span>
74
+
</div>
75
+
</div>
76
+
</div>
77
+
</div>
78
+
</details>
79
+
80
+
<!-- Label filter dropdown -->
81
+
<details class="relative dropdown-menu" id="labelDropdown">
82
+
<summary class="btn cursor-pointer list-none flex items-center gap-2">
83
+
{{ i "tag" "w-4 h-4" }}
84
+
<span>label</span>
85
+
{{ i "chevron-down" "w-4 h-4" }}
86
+
</summary>
87
+
<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">
88
+
<div class="p-3">
89
+
<div class="text-sm font-semibold mb-2 text-gray-700 dark:text-gray-300">Filter by label</div>
90
+
<div class="space-y-2">
91
+
{{ range $uri, $def := .LabelDefs }}
92
+
<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 }}">
93
+
<span class="label-checkbox-icon w-4 h-4"></span>
94
+
<span class="flex-grow text-sm dark:text-gray-200">
95
+
{{ template "labels/fragments/label" (dict "def" $def "val" "" "withPrefix" false) }}
96
+
</span>
97
+
</div>
98
+
{{ end }}
99
+
</div>
100
+
</div>
101
+
</div>
102
+
</details>
103
+
</form>
104
+
</div>
105
+
106
+
<script>
107
+
(function() {
108
+
// Handle label filter changes
109
+
const labelOptions = document.querySelectorAll('.label-option');
110
+
const searchInput = document.querySelector('input[name="q"]');
111
+
112
+
// Initialize checkmarks based on current query
113
+
const currentQuery = searchInput.value;
114
+
labelOptions.forEach(option => {
115
+
const labelName = option.getAttribute('data-label-name');
116
+
const hasFilter = 'has:' + labelName;
117
+
const iconSpan = option.querySelector('.label-checkbox-icon');
118
+
119
+
if (currentQuery.includes(hasFilter)) {
120
+
iconSpan.innerHTML = '{{ i "check" "w-4 h-4" }}';
121
+
}
122
+
});
123
+
124
+
labelOptions.forEach(option => {
125
+
option.addEventListener('click', function() {
126
+
const labelName = this.getAttribute('data-label-name');
127
+
let currentQuery = searchInput.value;
128
+
const hasFilter = 'has:' + labelName;
129
+
const iconSpan = this.querySelector('.label-checkbox-icon');
130
+
const isChecked = currentQuery.includes(hasFilter);
131
+
132
+
if (isChecked) {
133
+
// Remove has: filter
134
+
currentQuery = currentQuery.replace(hasFilter, '').replace(/\s+/g, ' ');
135
+
searchInput.value = currentQuery.trim();
136
+
iconSpan.innerHTML = '';
137
+
} else {
138
+
// Add has: filter if not already present
139
+
currentQuery = currentQuery.trim() + ' ' + hasFilter;
140
+
searchInput.value = currentQuery.trim();
141
+
iconSpan.innerHTML = '{{ i "check" "w-4 h-4" }}';
142
+
}
143
+
144
+
form.submit();
145
+
});
146
+
});
147
+
148
+
// Handle sort option changes
149
+
const sortByOptions = document.querySelectorAll('.sort-by-option');
150
+
const sortOrderOptions = document.querySelectorAll('.sort-order-option');
151
+
const sortByInput = document.getElementById('sortByInput');
152
+
const sortOrderInput = document.getElementById('sortOrderInput');
153
+
const form = searchInput.closest('form');
154
+
155
+
sortByOptions.forEach(option => {
156
+
option.addEventListener('click', function() {
157
+
sortByInput.value = this.getAttribute('data-value');
158
+
form.submit();
159
+
});
160
+
});
161
+
162
+
sortOrderOptions.forEach(option => {
163
+
option.addEventListener('click', function() {
164
+
sortOrderInput.value = this.getAttribute('data-value');
165
+
form.submit();
166
+
});
167
+
});
168
+
169
+
// Make dropdowns mutually exclusive - close others when one opens
170
+
const dropdowns = document.querySelectorAll('.dropdown-menu');
171
+
dropdowns.forEach(dropdown => {
172
+
dropdown.addEventListener('toggle', function(e) {
173
+
if (this.open) {
174
+
// Close all other dropdowns
175
+
dropdowns.forEach(other => {
176
+
if (other !== this && other.open) {
177
+
other.open = false;
178
+
}
179
+
});
180
+
}
181
+
});
182
+
});
183
+
})();
184
+
</script>
185
+
{{ end }}
+10
appview/pages/templates/repo/issues/fragments/issueListing.html
+10
appview/pages/templates/repo/issues/fragments/issueListing.html
···
42
42
<a href="/{{ $.RepoPrefix }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a>
43
43
</span>
44
44
45
+
{{ if gt .ReactionCount 0 }}
46
+
<span class="before:content-['·']">
47
+
{{ $s := "s" }}
48
+
{{ if eq .ReactionCount 1 }}
49
+
{{ $s = "" }}
50
+
{{ end }}
51
+
<a href="/{{ $.RepoPrefix }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ .ReactionCount }} reaction{{$s}}</a>
52
+
</span>
53
+
{{ end }}
54
+
45
55
{{ $state := .Labels }}
46
56
{{ range $k, $d := $.LabelDefs }}
47
57
{{ range $v, $s := $state.GetValSet $d.AtUri.String }}
+30
-3
appview/pages/templates/repo/issues/issues.html
+30
-3
appview/pages/templates/repo/issues/issues.html
···
8
8
{{ end }}
9
9
10
10
{{ define "repoContent" }}
11
-
<div class="flex justify-between items-center gap-4">
11
+
<div class="flex justify-between items-center gap-4 mb-4">
12
12
<div class="flex gap-4">
13
13
<a
14
14
href="?state=open"
···
33
33
<span>new</span>
34
34
</a>
35
35
</div>
36
+
37
+
{{ $state := "open" }}
38
+
{{ if not .FilteringByOpen }}
39
+
{{ $state = "closed" }}
40
+
{{ end }}
41
+
42
+
{{ template "repo/fragments/searchBar" (dict "SearchQuery" .SearchQuery "Placeholder" "issues" "State" $state "LabelDefs" .LabelDefs "SortBy" .SortBy "SortOrder" .SortOrder) }}
36
43
<div class="error" id="issues"></div>
37
44
{{ end }}
38
45
···
52
59
53
60
{{ if gt .Page.Offset 0 }}
54
61
{{ $prev := .Page.Previous }}
62
+
{{ $prevUrl := printf "/%s/issues?state=%s&offset=%d&limit=%d" $.RepoInfo.FullName $currentState $prev.Offset $prev.Limit }}
63
+
{{ if .SearchQuery }}
64
+
{{ $prevUrl = printf "%s&q=%s" $prevUrl .SearchQuery }}
65
+
{{ end }}
66
+
{{ if .SortBy }}
67
+
{{ $prevUrl = printf "%s&sort_by=%s" $prevUrl .SortBy }}
68
+
{{ end }}
69
+
{{ if .SortOrder }}
70
+
{{ $prevUrl = printf "%s&sort_order=%s" $prevUrl .SortOrder }}
71
+
{{ end }}
55
72
<a
56
73
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
57
74
hx-boost="true"
58
-
href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&offset={{ $prev.Offset }}&limit={{ $prev.Limit }}"
75
+
href = "{{ $prevUrl }}"
59
76
>
60
77
{{ i "chevron-left" "w-4 h-4" }}
61
78
previous
···
66
83
67
84
{{ if eq (len .Issues) .Page.Limit }}
68
85
{{ $next := .Page.Next }}
86
+
{{ $nextUrl := printf "/%s/issues?state=%s&offset=%d&limit=%d" $.RepoInfo.FullName $currentState $next.Offset $next.Limit }}
87
+
{{ if .SearchQuery }}
88
+
{{ $nextUrl = printf "%s&q=%s" $nextUrl .SearchQuery }}
89
+
{{ end }}
90
+
{{ if .SortBy }}
91
+
{{ $nextUrl = printf "%s&sort_by=%s" $nextUrl .SortBy }}
92
+
{{ end }}
93
+
{{ if .SortOrder }}
94
+
{{ $nextUrl = printf "%s&sort_order=%s" $nextUrl .SortOrder }}
95
+
{{ end }}
69
96
<a
70
97
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
71
98
hx-boost="true"
72
-
href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&offset={{ $next.Offset }}&limit={{ $next.Limit }}"
99
+
href = "{{ $nextUrl }}"
73
100
>
74
101
next
75
102
{{ i "chevron-right" "w-4 h-4" }}
+10
-1
appview/pages/templates/repo/pulls/pulls.html
+10
-1
appview/pages/templates/repo/pulls/pulls.html
···
8
8
{{ end }}
9
9
10
10
{{ define "repoContent" }}
11
-
<div class="flex justify-between items-center">
11
+
<div class="flex justify-between items-center mb-4">
12
12
<div class="flex gap-4">
13
13
<a
14
14
href="?state=open"
···
40
40
<span>new</span>
41
41
</a>
42
42
</div>
43
+
44
+
{{ $state := "open" }}
45
+
{{ if .FilteringBy.IsMerged }}
46
+
{{ $state = "merged" }}
47
+
{{ else if .FilteringBy.IsClosed }}
48
+
{{ $state = "closed" }}
49
+
{{ end }}
50
+
51
+
{{ template "repo/fragments/searchBar" (dict "SearchQuery" .SearchQuery "Placeholder" "pulls" "State" $state "LabelDefs" .LabelDefs "SortBy" .SortBy "SortOrder" .SortOrder) }}
43
52
<div class="error" id="pulls"></div>
44
53
{{ end }}
45
54
+27
-1
appview/pulls/pulls.go
+27
-1
appview/pulls/pulls.go
···
21
21
"tangled.org/core/appview/pages"
22
22
"tangled.org/core/appview/pages/markup"
23
23
"tangled.org/core/appview/reporesolver"
24
+
"tangled.org/core/appview/search"
24
25
"tangled.org/core/appview/xrpcclient"
25
26
"tangled.org/core/idresolver"
26
27
"tangled.org/core/patchutil"
···
492
493
func (s *Pulls) RepoPulls(w http.ResponseWriter, r *http.Request) {
493
494
user := s.oauth.GetUser(r)
494
495
params := r.URL.Query()
496
+
searchQuery := params.Get("q")
497
+
sortBy := params.Get("sort_by")
498
+
sortOrder := params.Get("sort_order")
499
+
500
+
templateSortBy := sortBy
501
+
templateSortOrder := sortOrder
502
+
503
+
if sortBy == "" {
504
+
sortBy = "created"
505
+
}
506
+
if sortOrder == "" {
507
+
sortOrder = "desc"
508
+
}
495
509
496
510
state := models.PullOpen
497
511
switch params.Get("state") {
···
507
521
return
508
522
}
509
523
510
-
pulls, err := db.GetPulls(
524
+
var pulls []*models.Pull
525
+
526
+
query := search.Parse(searchQuery)
527
+
528
+
pulls, err = db.SearchPulls(
511
529
s.db,
530
+
query.Text,
531
+
query.Labels,
532
+
sortBy,
533
+
sortOrder,
512
534
db.FilterEq("repo_at", f.RepoAt()),
513
535
db.FilterEq("state", state),
514
536
)
537
+
515
538
if err != nil {
516
539
log.Println("failed to get pulls", err)
517
540
s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.")
···
599
622
FilteringBy: state,
600
623
Stacks: stacks,
601
624
Pipelines: m,
625
+
SearchQuery: searchQuery,
626
+
SortBy: templateSortBy,
627
+
SortOrder: templateSortOrder,
602
628
})
603
629
}
604
630
+63
appview/search/query.go
+63
appview/search/query.go
···
1
+
package search
2
+
3
+
import (
4
+
"strings"
5
+
)
6
+
7
+
// Query represents a parsed search query
8
+
type Query struct {
9
+
// Text search terms (anything that's not a has: filter)
10
+
Text string
11
+
// Label filters from has:labelname syntax
12
+
Labels []string
13
+
}
14
+
15
+
// Parse parses a search query string into a Query struct
16
+
// Syntax:
17
+
// - "has:enhancement" adds a label filter
18
+
// - Other text becomes part of the text search
19
+
func Parse(queryStr string) Query {
20
+
q := Query{
21
+
Labels: []string{},
22
+
}
23
+
24
+
// Split query into tokens
25
+
tokens := strings.Fields(queryStr)
26
+
var textParts []string
27
+
28
+
for _, token := range tokens {
29
+
// Check if it's a has: filter
30
+
if strings.HasPrefix(token, "has:") {
31
+
label := strings.TrimPrefix(token, "has:")
32
+
if label != "" {
33
+
q.Labels = append(q.Labels, label)
34
+
}
35
+
} else {
36
+
// It's a text search term
37
+
textParts = append(textParts, token)
38
+
}
39
+
}
40
+
41
+
q.Text = strings.Join(textParts, " ")
42
+
return q
43
+
}
44
+
45
+
// String converts a Query back to a query string
46
+
func (q Query) String() string {
47
+
var parts []string
48
+
49
+
if q.Text != "" {
50
+
parts = append(parts, q.Text)
51
+
}
52
+
53
+
for _, label := range q.Labels {
54
+
parts = append(parts, "has:"+label)
55
+
}
56
+
57
+
return strings.Join(parts, " ")
58
+
}
59
+
60
+
// HasFilters returns true if the query has any search filters
61
+
func (q Query) HasFilters() bool {
62
+
return q.Text != "" || len(q.Labels) > 0
63
+
}