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