devtools.gno
9.96 Kb · 264 lines
1// Package devtools is the worked example of wiring moul's developer packages
2// into one realm: what a new realm should copy.
3//
4// Everything here is a COMBINATION. Any one of these packages has its own demo
5// showing it alone; the thing that is hard to find is what they look like used
6// together, which is the only form a real realm ever meets them in. So this
7// realm imports ten of them and every one earns its place in the output:
8//
9// r/moul/config the notice block, the pause switch, the explorer to link
10// p/moul/mygnoscan the explorer routes themselves
11// p/moul/debug a metadata panel behind ?debug=1
12// p/moul/realmpath reading the query string Render was handed
13// p/moul/kit/ui escaping what a caller typed, and the tables
14// p/moul/md the markdown, without hand-rolled strings
15// p/moul/txlink the "call this function" link
16// p/moul/fifo the bounded note list, so the state cannot grow forever
17// p/moul/kit/tally the note leaderboard, ties ordered rather than arbitrary
18// p/moul/kit/num pricing that list in GNOT rather than raw ugnot
19//
20// # The three lines worth copying
21//
22// A realm that does nothing else should still do these:
23//
24// func Render(path string) string {
25// return config.TopBlockFor(realmPath) + body + config.BottomBlockFor(realmPath)
26// }
27//
28// func Mutate(cur realm, s string) {
29// config.AssertWritableFor(realmPath) // honours the pause switch
30// notes.Append(ui.Inline(s)) // escapes what a caller typed
31// }
32//
33// The first gives every realm a place to put a warning and a pause banner
34// without shipping banner code. The second is two separate guards that both
35// have to be there: the pause one so an incident can stop writes from outside
36// the realm, and the escaping one because a string a caller typed is the
37// oldest way to break a page.
38package devtools
39
40import (
41 "strconv"
42 "strings"
43
44 "chain/markdown"
45 "chain/runtime"
46 "chain/runtime/unsafe"
47
48 "gno.land/p/g1magld5ptgzaktqcexxq0pgq5lmdf0kz6re8hlv/debug/v0"
49 "gno.land/p/g1magld5ptgzaktqcexxq0pgq5lmdf0kz6re8hlv/fifo/v0"
50 "gno.land/p/g1magld5ptgzaktqcexxq0pgq5lmdf0kz6re8hlv/kit/num/v0"
51 "gno.land/p/g1magld5ptgzaktqcexxq0pgq5lmdf0kz6re8hlv/kit/tally/v0"
52 "gno.land/p/g1magld5ptgzaktqcexxq0pgq5lmdf0kz6re8hlv/kit/ui/v0"
53 "gno.land/p/g1magld5ptgzaktqcexxq0pgq5lmdf0kz6re8hlv/md/v0"
54 "gno.land/p/g1magld5ptgzaktqcexxq0pgq5lmdf0kz6re8hlv/mygnoscan/v0"
55 "gno.land/p/g1magld5ptgzaktqcexxq0pgq5lmdf0kz6re8hlv/realmpath/v0"
56 "gno.land/p/g1magld5ptgzaktqcexxq0pgq5lmdf0kz6re8hlv/txlink/v0"
57 "gno.land/r/g1magld5ptgzaktqcexxq0pgq5lmdf0kz6re8hlv/config/v1"
58)
59
60// realmPath is this realm's own path.
61//
62// Passed explicitly to config rather than relying on its zero-argument helpers.
63// Both work (a plain read is borrowed, so config sees this realm as the
64// caller), but a constant cannot be wrong, and spelling it out is what a reader
65// copying this file needs to see.
66const realmPath = "gno.land/r/moul/x/allinone/devtools/v0"
67
68// maxNotes bounds the guest list. fifo drops the oldest rather than growing,
69// because storage on chain is paid for and never refunded: an unbounded list in
70// a demo anyone can write to is a bill with no ceiling.
71const maxNotes = 10
72
73var notes = fifo.New(maxNotes)
74
75// authors counts notes per writer. A tally.Board rather than a map: gno map
76// iteration order is unspecified, so a leaderboard built by ranging one is not
77// reproducible between calls.
78var authors = tally.NewBoard()
79
80// depositPerByte is what the chain charges to keep a byte, in ugnot. Used only
81// to price the note list on the page, never to settle anything.
82const depositPerByte = 100
83
84// Add appends a note. It is the only mutating function here, and it carries
85// both guards a real one should.
86func Add(cur realm, body string) {
87 // 1. The pause switch. Set `pause@r/moul/x/allinone/devtools` on
88 // r/moul/config and this aborts, with the reason, from outside this
89 // realm and without a redeploy.
90 config.AssertWritableFor(realmPath)
91
92 body = strings.TrimSpace(body)
93 if strings.TrimSpace(markdown.StripBidiAndZeroWidth(body)) == "" {
94 panic("empty note")
95 }
96 if len(body) > 200 {
97 panic("note too long: max 200 bytes, got " + strconv.Itoa(len(body)))
98 }
99
100 // 2. Escaping. body is a string a caller typed, and it is about to land in
101 // a markdown list. ui.Inline is the repo's standard for exactly this.
102 notes.Append(ui.Inline(body))
103
104 // 3. The scoreboard. tally.Board keeps one score per author and orders
105 // ties by key, so two authors on the same count always render in the
106 // same order.
107 authors.Add(unsafe.PreviousRealm().Address().String(), 1)
108}
109
110// Reset empties the list, so the realm can be put back to a known state
111// without a redeploy. Not gated by the pause switch: clearing up is the thing
112// you still want to do while paused.
113func Reset(cur realm) {
114 notes = fifo.New(maxNotes)
115 authors = tally.NewBoard()
116}
117
118// Render shows every package at work. The root view is deliberately free of
119// anything that moves on its own, so an example test can pin it; the live
120// numbers live under "chain".
121func Render(path string) string {
122 req := realmpath.Parse(path)
123
124 var b strings.Builder
125 b.WriteString(config.TopBlockFor(realmPath))
126 b.WriteString(md.H1("allinone: devtools"))
127 b.WriteString("\nTen packages, used together rather than one at a time. ")
128 b.WriteString(md.Link("source", config.MygnoscanFor(realmPath)))
129 b.WriteString("\n\n")
130
131 switch req.PathPart(0) {
132 case "chain":
133 b.WriteString(renderChain())
134 default:
135 b.WriteString(renderHome(path))
136 }
137
138 // debug prints its own panel only when ?debug=1 is set, and the empty
139 // string otherwise, so this line is safe to leave in a deployed realm.
140 b.WriteString(debug.Render(path))
141 b.WriteString(config.BottomBlockFor(realmPath))
142 return b.String()
143}
144
145func renderHome(path string) string {
146 var b strings.Builder
147
148 b.WriteString(md.H2("Notes"))
149 b.WriteString("\n")
150 // renderNotes returns no trailing newline of its own: md.BulletList and
151 // ui.Empty already end in one, and adding a second here produced two
152 // consecutive blank lines, which gno collapses inside an // Output: block
153 // and which therefore no example test can ever pin.
154 b.WriteString(renderNotes())
155 b.WriteString("\n")
156
157 // txlink builds the "call this function" URL for the CURRENT realm, so it
158 // keeps working after a version bump without anything here changing.
159 b.WriteString(md.BulletList([]string{
160 md.Link("add a note", txlink.NewLink("Add").AddArgs("body", "hello").URL()),
161 md.Link("clear them", txlink.Call("Reset")),
162 md.Link("live chain values", "/r/moul/x/allinone/devtools/v0:chain"),
163 md.Link("toggle the debug panel", debug.ToggleURL(path)),
164 }))
165 b.WriteString("\n")
166
167 b.WriteString(renderBoard())
168
169 b.WriteString(md.H2("What each package does here"))
170 b.WriteString("\n")
171 t := ui.NewTable("package", "what it contributed")
172 t.Row("`r/moul/config`", "the notice block above, the pause guard on `Add`, and which explorer to link")
173 t.Row("`p/moul/mygnoscan`", "the explorer routes, so a link survives a route change")
174 t.Row("`p/moul/debug`", "the `?debug=1` panel, empty otherwise")
175 t.Row("`p/moul/realmpath`", "splitting the render path into a view and a query")
176 t.Row("`p/moul/kit/ui`", "escaping a caller's note, and this table")
177 t.Row("`p/moul/md`", "every heading, link and list on the page")
178 t.Row("`p/moul/txlink`", "the call links above, relative to this realm")
179 t.Row("`p/moul/fifo`", "the note list, bounded so storage cannot grow forever")
180 t.Row("`p/moul/kit/tally`", "the note leaderboard, with ties ordered by address")
181 t.Row("`p/moul/kit/num`", "pricing the note list in GNOT instead of raw ugnot")
182 b.WriteString(t.String())
183
184 return b.String()
185}
186
187// renderBoard shows who has written the most notes, and what the list costs to
188// keep. Both numbers come from a package rather than from arithmetic here.
189func renderBoard() string {
190 var b strings.Builder
191 b.WriteString(md.H2("Who has written the most"))
192 b.WriteString("\n")
193
194 top := authors.Top(5)
195 if len(top) == 0 {
196 b.WriteString(ui.Empty("No notes yet, so nobody is on the board."))
197 } else {
198 t := ui.NewTable("#", "author", "notes")
199 for i, e := range top {
200 t.Row(ui.Podium(i+1), ui.AddrOf(e.Key), num.Pad(e.Score, 2))
201 }
202 b.WriteString(t.String())
203 }
204
205 // num.GNOTf, not a hand-rolled divide by a million. The storage deposit is
206 // what the chain charges to keep these bytes, so it is the honest price of
207 // the list above.
208 b.WriteString("\nStorage deposit for the list: **")
209 b.WriteString(num.GNOTf(int64(notesBytes()) * depositPerByte))
210 b.WriteString("**\n\n")
211 return b.String()
212}
213
214// notesBytes is the length of every note currently held.
215func notesBytes() int {
216 n := 0
217 for _, v := range notes.Entries() {
218 n += len(v.(string))
219 }
220 return n
221}
222
223// renderChain is everything that moves: the height, and the links built from
224// it. Kept off the root view so the root can be pinned by an example.
225func renderChain() string {
226 s := config.Scanner()
227 h := runtime.ChainHeight()
228
229 var b strings.Builder
230 b.WriteString(md.H2("Live"))
231 b.WriteString("\n")
232
233 t := ui.NewTable("what", "value")
234 t.Row("chain id", ui.Cell(runtime.ChainID()))
235 t.Row("height", strconv.FormatInt(h, 10))
236 t.Row("explorer", ui.Cell(s.Base()))
237 t.Row("network", ui.Cell(s.Network()))
238 b.WriteString(t.String())
239 b.WriteString("\n")
240
241 // mygnoscan.Link used directly, rather than through config, to show that
242 // the builder is an ordinary value: config only decides where it points.
243 b.WriteString(md.BulletList([]string{
244 mygnoscan.Link("this block", s.Block(h)),
245 mygnoscan.Link("this realm's transactions", s.Realm(realmPath, mygnoscan.TabCalls)),
246 mygnoscan.Link("its dependency graph", s.Realm(realmPath, mygnoscan.TabDeps)),
247 mygnoscan.Link("the parked deploy queue", s.Packages(mygnoscan.PackagesInert)),
248 }))
249 b.WriteString("\n")
250 b.WriteString(md.Link("back", "/r/moul/x/allinone/devtools/v0"))
251 b.WriteString("\n")
252 return b.String()
253}
254
255func renderNotes() string {
256 if notes.Size() == 0 {
257 return ui.Empty("no notes yet")
258 }
259 items := []string{}
260 for _, e := range notes.Entries() {
261 items = append(items, e.(string))
262 }
263 return md.BulletList(items)
264}