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.

Reorganise the web package around one implementation per rule

c717d4eb92b4257b05fa065918b0071f6302f53e · Keenan Nemetz · 14d ago · parent 4506a4d3

Reorganise the web package around one implementation per rule

server/web had grown by feature, so several rules and widgets existed twice: the
comment stack for issues and change requests, the pager, the file tree, the write
preamble, and the ACL predicates. Each pair agreed today and would drift
tomorrow. No route, stored format, or permission rule changes; the few behaviour
differences found along the way are listed at the end.

Files now say what they answer:

  web.go          the Web type, render, open, the error pages
  page.go         Page, pageSection, who, nav
  templatefuncs.go the template function map, with the escaping rules noted
  ui.go           relative time, avatars, breadcrumbs, the pager
  markdown.go     the goldmark and bluemonday boundary
  codebrowser.go  the code browser, out of web.go
  tokens.go       the label/assignee token input
  rolepicker.go   the role control, out of admin.go
  comments.go     the comment view, thread builder, action menus, snippet
  comment_writes.go  add, edit and delete a comment, for both surfaces
  write_context.go   beginWrite/beginPost/beginAdminPost, fail, refuse
  capabilities.go    the ACL rules a page and a handler share
  audit.go        the audit log
  request_view.go the change conversation page, out of requests.go
  admin_access.go the instance-admin access rules, out of settings.go

A comment is one thing, so it has one view and one thread builder: metadata.Comment
is the same type on both surfaces and only a change request can anchor one to a
diff line. Adding, editing and deleting are one implementation each, with a
commentTopic naming what differs per surface. The pager is one control for issues,
changes and the instance feed; the file tree is one widget for the code browser
and a change's Files tab.

Every write starts at beginWrite (session, body bound, CSRF, repository, rules,
form) and asks capabilities.go for the one rule it needs, so the control a page
draws and the check a handler makes cannot disagree. A rejected write answers
through refuse; a write that fails for another reason answers through fail, which
re-renders the section for HTMX and returns text otherwise.

Templates follow the same split: chrome.html, components.html, diff.html and
role-picker.html hold what more than one surface renders, and each page owns its
fragments. The route table moved to routes.go, one function per surface.

Behaviour changes found while unifying:

  - A reply cycle (two comments answering each other, possible through a pushed
    metadata branch) used to drop both comments from the page; anything the walk
    does not reach is now emitted as a root.
  - A rejected anchored comment keeps its "Commenting on path:line" note and
    hidden anchor fields, so the reviewer does not have to pick the line again.
  - A repository whose access rules cannot be read answers 500 rather than 403;
    the account was being blamed for a broken instance.
  - Pager wording is now "Showing a-b of n / page x of y" with Previous/Next
    everywhere, and the instance feed's page links keep their kind filter.
  - The activity feed, issue list and change list share one page control.

Docs: docs/ui-plan.md gains a "Where the code lives" map, the write gate, the
shared comment model, the route table, and rules for rejected writes, shared
template fragments, and the stylesheet's section order. The passkey script is now
an ordinary asset (static/auth.js) under the same CSP as the rest.

server/web/ui.go +150

@@ -0,0 +1,150 @@
1 package web
2
3 // Presentation helpers that no single surface owns: relative time, avatar
4 // initials and colours, breadcrumbs, path validation, and the page control the
5 // list pages share. Nothing here reads a store or decides anything about
6 // access; a value it returns is only ever printed.
7
8 import (
9 "fmt"
10 "strings"
11 "time"
12 )
13
14 func humanSince(t int64) string {
15 if t == 0 {
16 return ""
17 }
18 d := time.Since(time.Unix(t, 0))
19 switch {
20 case d < time.Minute:
21 return "just now"
22 case d < time.Hour:
23 return fmt.Sprintf("%dm ago", int(d.Minutes()))
24 case d < 24*time.Hour:
25 return fmt.Sprintf("%dh ago", int(d.Hours()))
26 default:
27 return fmt.Sprintf("%dd ago", int(d.Hours()/24))
28 }
29 }
30
31 // initial is the one-letter avatar label. Account names are authoritative, so the first
32 // rune is enough; an empty name gets "?" rather than an empty circle.
33 func initial(name string) string {
34 for _, r := range strings.TrimSpace(name) {
35 return strings.ToUpper(string(r))
36 }
37 return "?"
38 }
39
40 // tone picks one of the avatar colour classes deterministically, so a person
41 // keeps the same colour on every page and between visits.
42 func tone(name string) int {
43 var sum uint32
44 for _, r := range name {
45 sum = sum*31 + uint32(r)
46 }
47 return int(sum%7) + 1
48 }
49
50 func safePath(p string) bool {
51 if strings.Contains(p, "\x00") {
52 return false
53 }
54 for _, seg := range strings.Split(p, "/") {
55 if seg == ".." {
56 return false
57 }
58 }
59 return true
60 }
61
62 type crumb struct {
63 Name string
64 Path string
65 }
66
67 func crumbs(p string) []crumb {
68 var out []crumb
69 cur := ""
70 for _, seg := range strings.Split(p, "/") {
71 if seg == "" {
72 continue
73 }
74 if cur == "" {
75 cur = seg
76 } else {
77 cur += "/" + seg
78 }
79 out = append(out, crumb{Name: seg, Path: cur})
80 }
81 return out
82 }
83
84 // listPageSize is how many rows one page of an issue or change-request list
85 // shows. The instance activity feed has its own, larger, page size (home.go).
86 const listPageSize = 25
87
88 // pager is one page of a list. The handler builds the URLs, so it keeps control
89 // of which filters survive; everything a template needs to draw the control is
90 // here, including the label and the fragment a page link swaps.
91 type pager struct {
92 // Label names the nav for a screen reader ("Issue pages").
93 Label string
94 // Target is the HTMX selector a page link replaces. Empty navigates
95 // normally, which is the no-JavaScript behaviour and the fallback.
96 Target string
97 Page int
98 Pages int
99 Total int
100 Per int
101 From int
102 To int
103 PrevURL string
104 NextURL string
105 }
106
107 func newPager(total, per, page int, urlFor func(int) string) pager {
108 if per < 1 {
109 per = 1
110 }
111 pages := (total + per - 1) / per
112 if pages < 1 {
113 pages = 1
114 }
115 if page < 1 {
116 page = 1
117 }
118 if page > pages {
119 page = pages
120 }
121 p := pager{Page: page, Pages: pages, Total: total, Per: per}
122 if total > 0 {
123 p.From = (page-1)*per + 1
124 p.To = min(page*per, total)
125 }
126 if page > 1 {
127 p.PrevURL = urlFor(page - 1)
128 }
129 if page < pages {
130 p.NextURL = urlFor(page + 1)
131 }
132 return p
133 }
134
135 // forSwap names the control and the fragment it replaces, for a list that pages
136 // in place through HTMX. A list without it pages by navigation.
137 func (p pager) forSwap(label, target string) pager {
138 p.Label = label
139 p.Target = target
140 return p
141 }
142
143 // rows returns the half-open range of rows on this page, so a handler that has
144 // already collected and counted them does not repeat the arithmetic.
145 func (p pager) rows() (int, int) {
146 if p.Total == 0 {
147 return 0, 0
148 }
149 return (p.Page - 1) * p.Per, p.To
150 }