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/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0: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 · 13d 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/templates/components.html +174

@@ -0,0 +1,174 @@
1 {{/* Small components shared by more than one surface: the label/assignee token
2 input, the commit list, the comment card, the file tree, and the pager. All
3 of them are rendered from a page's own data rather than by a call to a
4 handler. */}}
5 {{define "token_input"}}
6 <div class="label-input" data-label-input data-label-item="{{.Item}}">
7 <div class="label-tokens" data-label-tokens>
8 <input type="text" class="label-entry" name="{{.Field}}" value="{{join .Selected ", "}}" data-label-entry autocomplete="off" placeholder="{{.Placeholder}}" aria-label="{{.Placeholder}}">
9 </div>
10 <div class="label-suggest" data-label-suggest hidden>
11 {{range .All}}<button type="button" class="label-option" data-label-option="{{.}}">{{.}}</button>{{end}}
12 </div>
13 </div>
14 {{end}}
15
16 {{define "commitlist"}}
17 {{if .Commits}}
18 <table class="commits">
19 <tbody>
20 {{range .Commits}}
21 <tr>
22 <td class="sha"><a href="/r/{{$.Name}}/commit/{{.SHA}}">{{short .SHA}}</a></td>
23 <td><a href="/r/{{$.Name}}/commit/{{.SHA}}">{{.Subject}}</a></td>
24 <td class="muted">{{.Author}}</td>
25 <td class="muted nowrap">{{since .When}}</td>
26 </tr>
27 {{end}}
28 </tbody>
29 </table>
30 {{else}}
31 <p class="muted">No commits yet.</p>
32 {{end}}
33 {{end}}
34
35 {{/* One tree for both browsers. A code row opens a blob and carries the file
36 icon for its language; a review row opens the file's diff and carries the
37 A/M/D/R mark instead. The context says which is which: Review empty means
38 the code browser. This template lives here because the code browser owns
39 the tree; request_files.html renders it with a Review id. */}}
40 {{define "tree_nodes"}}
41 <ul class="tree-list">
42 {{range .Nodes}}
43 <li>
44 {{if eq .Type "tree"}}
45 <details class="tree-dir{{if .Active}} active{{end}}" data-path="{{.Path}}"{{if .Open}} open{{end}}>
46 <summary class="tree-row">
47 <span class="tree-chevron">{{icon "chevron-right"}}</span>
48 <span class="tree-icon">{{icon "folder"}}</span>
49 <span class="tree-name" title="{{.Path}}">{{.Name}}</span>
50 </summary>
51 <div class="tree-children">{{template "tree_nodes" (treeContext $.Name $.Ref $.Review .Children)}}</div>
52 </details>
53 {{else}}
54 {{$href := treeHref $.Name $.Ref $.Review .Path}}
55 <a class="tree-row tree-file{{if .Active}} active{{end}}" data-path="{{.Path}}" href="{{$href}}" hx-get="{{$href}}" hx-target="{{if $.Review}}#review-main{{else}}#code-main{{end}}" hx-swap="outerHTML" hx-push-url="true" title="{{.Path}}">
56 {{if $.Review}}<span class="review-mark" data-status="{{.Status}}">{{.Status}}</span>{{else}}<span class="tree-icon {{fileclass .Name}}">{{icon (fileicon .Name)}}</span>{{end}}
57 <span class="tree-name">{{.Name}}</span>
58 </a>
59 {{end}}
60 </li>
61 {{end}}
62 </ul>
63 {{end}}
64
65 {{/* One page control for every list that can run past a screen: issues, change
66 requests, and the instance activity feed. The handler owns the URLs and the
67 words ("Issue pages"); this only draws them. */}}
68 {{/* One card for a comment, on either surface. An issue comment has no anchor, so
69 it shows no snippet; a change comment anchored to a line shows the lines it
70 was written about, read from the blob as it stood then, and marks the card as
71 stale when the file has moved on. Edit swaps .Target, so the card does not
72 need to know which thread it is in. */}}
73 {{define "comment_card"}}
74 <article class="comment{{if .Deleted}} deleted{{end}}" id="c-{{.ID}}">
75 {{if .Deleted}}
76 <div class="comment-deleted">{{icon "circle-x"}}Comment deleted</div>
77 {{else}}
78 <header class="comment-head">
79 <span class="avatar c{{tone .Author}}" aria-hidden="true">{{initial (who .Author)}}</span>
80 <span class="comment-author">{{who .Author}}</span>
81 {{if .ReplyTo}}<span class="reply-context">replying to {{if .ReplyToDeleted}}<a href="#c-{{.ReplyTo}}">a deleted comment</a>{{else}}<a href="#c-{{.ReplyTo}}">{{who .ReplyToName}}</a>{{end}}</span>{{end}}
82 <span class="muted">{{since .Created.Unix}}</span>
83 {{if and .Anchor (not .Current)}}<span class="conflict">the file has changed since this comment</span>{{end}}
84 {{if and .Anchor (not .Snippet)}}<span class="muted">on <code>{{.Anchor.Path}}:{{.Anchor.Line}}</code> ({{.Anchor.Side}} side)</span>{{end}}
85 <div class="comment-menu" data-comment-menu>
86 <button type="button" class="comment-menu-toggle" data-comment-menu-toggle aria-haspopup="menu" aria-expanded="false" aria-label="Comment actions">&#8943;</button>
87 <div class="comment-menu-list" data-comment-menu-list role="menu" hidden>
88 {{if .CanReply}}<button type="button" role="menuitem" data-reply-to="{{.ID}}" data-reply-author="{{who .Author}}">Reply</button>{{end}}
89 <button type="button" role="menuitem" data-copy="{{.Source}}">Copy text</button>
90 <button type="button" role="menuitem" data-copy-link="{{.ID}}">Copy link</button>
91 {{if .CanEdit}}<button type="button" role="menuitem" data-comment-edit>Edit</button>{{end}}
92 {{if .CanDelete}}
93 <form method="post" action="{{.DeleteURL}}" data-confirm="Delete this comment?">
94 <input type="hidden" name="csrf" value="{{.CSRF}}">
95 <button type="submit" role="menuitem" class="menu-danger">Delete</button>
96 </form>
97 {{end}}
98 </div>
99 </div>
100 </header>
101 <div class="comment-body md">{{.Body}}</div>
102 {{if .Snippet}}
103 <details class="comment-snippet">
104 <summary>
105 <span class="snippet-chevron">{{icon "chevron-right"}}</span>
106 <code>{{.Snippet.Path}}:{{.Snippet.Line}}</code>
107 <span class="muted">({{.Snippet.Side}} side)</span>
108 {{if not .Current}}<span class="conflict">original line</span>{{end}}
109 </summary>
110 <div class="snippet-code">{{range .Snippet.Lines}}<div class="snippet-line{{if .Anchor}} anchor{{end}}"><span class="snippet-no">{{.No}}</span><span class="snippet-text">{{.Text}}</span></div>{{end}}</div>
111 <div class="snippet-actions"><a href="{{.Snippet.FilesURL}}">View in Files</a></div>
112 </details>
113 {{end}}
114 {{if .CanEdit}}
115 <form class="comment-edit-form" data-comment-edit-form hidden method="post" action="{{.EditURL}}" hx-post="{{.EditURL}}" hx-target="{{.Target}}" hx-swap="outerHTML" hx-disabled-elt="find button[type='submit']">
116 <input type="hidden" name="csrf" value="{{.CSRF}}">
117 <textarea name="body" required>{{.Source}}</textarea>
118 <div class="form-actions">
119 <button class="secondary" type="button" data-comment-edit-cancel>Cancel</button>
120 <button type="submit">Save</button>
121 </div>
122 </form>
123 {{end}}
124 {{end}}
125 </article>
126 {{end}}
127
128 {{/* The discussion section of an issue or a change request: heading, threaded
129 comments, one composer. The two surfaces pass the same view, so the section
130 id, the composer's URL and the heading word come from the handler rather than
131 from the template. */}}
132 {{define "comment_thread"}}
133 <div id="{{.Section}}">
134 <h2>{{icon "message-square"}}{{.Title}} <span class="muted">({{.CommentCount}})</span></h2>
135 {{if .ThreadError}}<p class="error">{{.ThreadError}}</p>{{end}}
136 {{with .Target}}<p class="anchor-note muted">Commenting on <code>{{.Path}}:{{.Line}}</code> ({{.Side}} side) · <a href="{{$.BackURL}}#{{$.Composer}}">cancel</a></p>{{end}}
137 {{range .Comments}}
138 {{template "comment_card" .}}
139 {{if .Replies}}
140 <details class="thread-replies" open>
141 <summary><span class="thread-chevron">{{icon "chevron-right"}}</span>{{len .Replies}} {{if eq (len .Replies) 1}}reply{{else}}replies{{end}}</summary>
142 <div class="comment-replies">{{range .Replies}}{{template "comment_card" .}}{{end}}</div>
143 </details>
144 {{end}}
145 {{else}}
146 <p class="muted">{{.EmptyText}} From a clone of this repository:
147 <code>{{.Hint}}</code></p>
148 {{end}}
149
150 {{if .CanDiscuss}}
151 <form id="{{.Composer}}" method="post" action="{{.PostURL}}" class="form-block discussion-composer" hx-post="{{.PostURL}}" hx-target="#{{.Section}}" hx-swap="outerHTML"{{if .PushURL}} hx-push-url="{{.PushURL}}"{{end}} hx-disabled-elt="find button[type='submit']">
152 <input type="hidden" name="csrf" value="{{.CSRF}}">
153 <input type="hidden" name="reply_to" value="" data-reply-input>
154 {{with .Target}}<input type="hidden" name="path" value="{{.Path}}"><input type="hidden" name="line" value="{{.Line}}"><input type="hidden" name="side" value="{{.Side}}">{{end}}
155 <div class="reply-target" data-reply-target hidden>
156 <span>Replying to <strong data-reply-name></strong></span>
157 <button class="link" type="button" data-reply-clear>Cancel reply</button>
158 </div>
159 <textarea name="body" required placeholder="Leave a comment. Markdown is welcome." aria-label="Comment"{{if .Target}} autofocus{{end}}></textarea>
160 <div class="form-actions"><button type="submit">{{icon "message-square"}}Comment</button></div>
161 </form>
162 {{end}}
163 </div>
164 {{end}}
165
166 {{define "pager"}}
167 {{if gt .Pages 1}}
168 <nav class="pager" aria-label="{{.Label}}">
169 {{if .PrevURL}}<a class="button secondary" href="{{.PrevURL}}"{{if .Target}} hx-get="{{.PrevURL}}" hx-target="{{.Target}}" hx-swap="outerHTML" hx-push-url="true"{{end}}>Previous</a>{{else}}<span></span>{{end}}
170 <span class="muted">Showing {{.From}}–{{.To}} of {{.Total}} · page {{.Page}} of {{.Pages}}</span>
171 {{if .NextURL}}<a class="button secondary" href="{{.NextURL}}"{{if .Target}} hx-get="{{.NextURL}}" hx-target="{{.Target}}" hx-swap="outerHTML" hx-push-url="true"{{end}}>Next</a>{{else}}<span></span>{{end}}
172 </nav>
173 {{end}}
174 {{end}}