Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

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.

Refresh the repository tab counts while the page is open

a2ec95616f4216c743cb1ae358ef8e9449ad8d1f · Keenan Nemetz · 9d ago · parent 0789f22e

Refresh the repository tab counts while the page is open

The header's open counts were only as fresh as the page load, so a reader
watching a repository had to reload to find out that an issue or a change had
been opened.

The tabs now poll /r/{name}/counts every 30s while the page is visible. The
response is the two badges as out-of-band updates, so the current tab, the
clone menu and anything the reader has typed stay where they are. A badge that
moves keeps one short ring, because a number changing elsewhere on the screen
is easy to miss. The endpoint answers 404 for a repository the viewer cannot
read, like every other page under /r/{name}.

server/issues_changes_test.go +59

@@ -1080,3 +1080,62 @@ func TestMergingRedirectsWithTheCelebrationFlag(t *testing.T) {
1080 1080 t.Fatalf("landing page does not show the merged state:\n%s", page)
1081 1081 }
1082 1082 }
1083
1084 // The header polls the two tab counts so a reader watching a repository sees new
1085 // issues and changes arrive without a reload. The poll must answer with the two
1086 // badges alone: an out-of-band update leaves the page, its current tab and
1087 // anything the reader typed where they were.
1088 func TestRepoTabCountsPollOutOfBand(t *testing.T) {
1089 t.Parallel()
1090 f := newFixture(t)
1091 f.publish(f.repo)
1092 files := map[string]string{
1093 "issues/01M5JR2JK29KKMAA1T9MDNTPD1/issue.md": issueFile("Open thing", "keenan", "open", "2026-10-22T21:00:00Z", []string{"bug"}, "body\n"),
1094 "issues/01M5JR2JK29KKMAA1T9MDNTPD2/issue.md": issueFile("Closed thing", "keenan", "closed", "2026-10-22T21:00:00Z", []string{"bug"}, "body\n"),
1095 }
1096 work := f.issuesRepo(t, files)
1097 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil {
1098 t.Fatalf("push: %s", errs)
1099 }
1100
1101 // The page is what the poll has to keep in step: the badges it names, and the
1102 // request that refreshes them while the page is visible.
1103 code, page := f.get("/r/" + f.repo + "/issues")
1104 if code != http.StatusOK {
1105 t.Fatalf("issue page: %d", code)
1106 }
1107 for _, want := range []string{
1108 `id="repo-issue-count"`,
1109 `id="repo-request-count"`,
1110 `hx-get="/r/` + f.repo + `/counts"`,
1111 "every 30s [document.visibilityState === 'visible']",
1112 } {
1113 if !strings.Contains(page, want) {
1114 t.Fatalf("the repository header does not show %q:\n%s", want, page)
1115 }
1116 }
1117
1118 // The answer is only the badges, each replacing itself out of band. Both are
1119 // present on every poll, so a count that did not move is not left stale.
1120 resp, body := f.htmxGet(t, "/r/"+f.repo+"/counts", "")
1121 if resp.StatusCode != http.StatusOK || strings.Contains(body, "<!doctype html>") {
1122 t.Fatalf("counts poll = %d:\n%s", resp.StatusCode, body)
1123 }
1124 for _, want := range []string{
1125 `id="repo-issue-count" hx-swap-oob="outerHTML">1<`,
1126 `id="repo-request-count" hx-swap-oob="outerHTML">0<`,
1127 } {
1128 if !strings.Contains(body, want) {
1129 t.Fatalf("counts poll does not replace %q:\n%s", want, body)
1130 }
1131 }
1132 if got := strings.Count(body, "hx-swap-oob"); got != 2 {
1133 t.Fatalf("counts poll replaced %d elements, want 2:\n%s", got, body)
1134 }
1135
1136 // A repository the reader cannot see answers like a missing one, so the poll
1137 // cannot be used to discover private repositories.
1138 if resp, _ := f.htmxGet(t, "/r/no-such-repo/counts", ""); resp.StatusCode != http.StatusNotFound {
1139 t.Fatalf("counts for an unknown repository = %d, want 404", resp.StatusCode)
1140 }
1141 }