vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
Standardize filter rows and add tag selection to the code page
55615c7a4b8472f8bfccab8048f608978d389821 · Keenan Nemetz · 11d ago · parent 3a068983
Standardize filter rows and add tag selection to the code page The code page kept its branch picker in the header actions, had no tag picker, and left every list page to shape its own filters: search inputs ranged from 10rem to 18rem, some selects had a visible label and some did not, and text searches submitted through a Filter button, a Search button, or Enter alone while selects sometimes waited for a click. - One .filters row on issues, requests, activity, the code page and code search. One --search-w (18rem) for page and global search, a visible label on every filter select, auto-apply on select change, and one icon-and-Search button for text. - The code page puts its search, branch picker and new tag picker on the shared row under the header. The tag picker lists tags newest first and leaves the branch picker unclaimed when a tag is open. - data-autosubmit uses requestSubmit() so htmx swaps the filtered pane instead of a full page load. Checks: ./vole workflow check; ./vole workflow run verify.
server/ui_test.go +118
| @@ -643,6 +643,124 @@ func TestCodeBrowserTreeAndFragments(t *testing.T) { | |||
| 643 | 643 | } | |
| 644 | 644 | } | |
| 645 | 645 | ||
| 646 | func TestCodeBrowserRefPickers(t *testing.T) { | ||
| 647 | t.Parallel() | ||
| 648 | f := newFixture(t) | ||
| 649 | f.publish(f.repo) | ||
| 650 | work := workRepo(t, map[string]string{"README.md": "# refs\n"}) | ||
| 651 | if _, errs, err := runGit(t, work, "tag", "-a", "v1.0.0", "-m", "release notes"); err != nil { | ||
| 652 | t.Fatalf("tag: %s", errs) | ||
| 653 | } | ||
| 654 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main", "refs/tags/v1.0.0"); err != nil { | ||
| 655 | t.Fatalf("push: %s", errs) | ||
| 656 | } | ||
| 657 | |||
| 658 | // Search and the ref pickers share one row under the page header, and the | ||
| 659 | // tag picker lists tags beside the branch picker. | ||
| 660 | code, body := f.get("/r/" + f.repo + "/code") | ||
| 661 | if code != 200 { | ||
| 662 | t.Fatalf("code page = %d", code) | ||
| 663 | } | ||
| 664 | filters := body[strings.Index(body, `class="filters"`):] | ||
| 665 | for _, want := range []string{ | ||
| 666 | `aria-label="Search code"`, | ||
| 667 | `<span class="filter-label">Branch</span>`, | ||
| 668 | `<span class="filter-label">Tag</span>`, | ||
| 669 | `<option value="main" selected>main</option>`, | ||
| 670 | `<option value="v1.0.0">v1.0.0</option>`, | ||
| 671 | } { | ||
| 672 | if !strings.Contains(filters, want) { | ||
| 673 | t.Fatalf("ref picker row does not contain %q:\n%s", want, body) | ||
| 674 | } | ||
| 675 | } | ||
| 676 | |||
| 677 | // Opening a tag selects it in the tag picker and leaves the branch picker | ||
| 678 | // unclaimed rather than pretending the first branch is open. | ||
| 679 | code, body = f.get("/r/" + f.repo + "/code?ref=v1.0.0") | ||
| 680 | if code != 200 { | ||
| 681 | t.Fatalf("tag page = %d", code) | ||
| 682 | } | ||
| 683 | filters = body[strings.Index(body, `class="filters"`):] | ||
| 684 | for _, want := range []string{ | ||
| 685 | `<option value="v1.0.0" selected>v1.0.0</option>`, | ||
| 686 | `<option value="" disabled selected>—</option>`, | ||
| 687 | } { | ||
| 688 | if !strings.Contains(filters, want) { | ||
| 689 | t.Fatalf("tag page ref pickers do not contain %q:\n%s", want, body) | ||
| 690 | } | ||
| 691 | } | ||
| 692 | if strings.Contains(filters, `<option value="main" selected>main</option>`) { | ||
| 693 | t.Fatalf("branch picker claims main while a tag is open:\n%s", body) | ||
| 694 | } | ||
| 695 | } | ||
| 696 | |||
| 697 | // Filter rows are one component: the same class, a visible label on every | ||
| 698 | // select, selects that apply on change, and a Search button for text. A new page | ||
| 699 | // should reuse this row instead of inventing a second shape. | ||
| 700 | func TestFilterRowsShareOneShape(t *testing.T) { | ||
| 701 | t.Parallel() | ||
| 702 | f := newFixture(t) | ||
| 703 | f.publish(f.repo) | ||
| 704 | work := workRepo(t, map[string]string{"README.md": "# filters\n"}) | ||
| 705 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil { | ||
| 706 | t.Fatalf("push: %s", errs) | ||
| 707 | } | ||
| 708 | issues := f.issuesRepo(t, map[string]string{ | ||
| 709 | "issues/01M5JR2JK29KKMAA1T9MDNTPF1/issue.md": issueFile("Filter shape", "keenan", "open", | ||
| 710 | "2026-10-22T21:00:00Z", []string{"bug"}, "body\n", "wilbur"), | ||
| 711 | }) | ||
| 712 | if _, errs, err := f.git(issues, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil { | ||
| 713 | t.Fatalf("issues push: %s", errs) | ||
| 714 | } | ||
| 715 | |||
| 716 | for _, row := range []struct { | ||
| 717 | path string | ||
| 718 | labels []string | ||
| 719 | }{ | ||
| 720 | {"/r/" + f.repo + "/issues", []string{"Label", "Assignee"}}, | ||
| 721 | {"/r/" + f.repo + "/activity", []string{"Actor", "Assignee"}}, | ||
| 722 | {"/r/" + f.repo + "/code", []string{"Branch"}}, | ||
| 723 | } { | ||
| 724 | code, body := f.get(row.path) | ||
| 725 | if code != 200 { | ||
| 726 | t.Fatalf("%s = %d", row.path, code) | ||
| 727 | } | ||
| 728 | at := strings.Index(body, `class="filters"`) | ||
| 729 | if at < 0 { | ||
| 730 | t.Fatalf("%s has no shared filter row", row.path) | ||
| 731 | } | ||
| 732 | filters := body[at:] | ||
| 733 | for _, label := range row.labels { | ||
| 734 | if !strings.Contains(filters, `<span class="filter-label">`+label+`</span>`) { | ||
| 735 | t.Errorf("%s has an unlabeled %s dropdown:\n%s", row.path, label, body) | ||
| 736 | } | ||
| 737 | } | ||
| 738 | if !strings.Contains(filters, "data-autosubmit") { | ||
| 739 | t.Errorf("%s does not apply its selects on change", row.path) | ||
| 740 | } | ||
| 741 | if strings.Contains(filters, ">Filter<") { | ||
| 742 | t.Errorf("%s still has its own Filter button", row.path) | ||
| 743 | } | ||
| 744 | } | ||
| 745 | |||
| 746 | // Text search is the same button on every page: an icon and the word Search. | ||
| 747 | for _, path := range []string{ | ||
| 748 | "/r/" + f.repo + "/issues", | ||
| 749 | "/r/" + f.repo + "/requests", | ||
| 750 | "/r/" + f.repo + "/search", | ||
| 751 | "/r/" + f.repo + "/code", | ||
| 752 | } { | ||
| 753 | code, body := f.get(path) | ||
| 754 | if code != 200 { | ||
| 755 | t.Fatalf("%s = %d", path, code) | ||
| 756 | } | ||
| 757 | at := strings.Index(body, `class="filters"`) | ||
| 758 | if at < 0 || !strings.Contains(body[at:], "Search</button>") { | ||
| 759 | t.Errorf("%s does not end its search field with a Search button:\n%s", path, body) | ||
| 760 | } | ||
| 761 | } | ||
| 762 | } | ||
| 763 | |||
| 646 | 764 | func TestSourceBlobNamesItsLanguageAndEscapes(t *testing.T) { | |
| 647 | 765 | t.Parallel() | |
| 648 | 766 | f := newFixture(t) | |