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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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 + }