summaryrefslogtreecommitdiff
path: root/web/page.go
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-02 23:43:08 -0300
committerGabriel Schneider <[email protected]>2026-08-03 09:54:39 -0300
commit5ec72f8723d795d0b02d7e9ebb9f555f0c7e6a2e (patch)
treef12f4b700fff4ff6f7e818d2a542672ba0b8d76e /web/page.go
parenta9263daee9413c5eff3c1f1bebcd224442fd8685 (diff)
downloadnotevi-5ec72f8723d795d0b02d7e9ebb9f555f0c7e6a2e.tar.gz
notevi-5ec72f8723d795d0b02d7e9ebb9f555f0c7e6a2e.zip
rebrand to notevi: one CLI over the jj sidecar
vr and vrsite become a single binary. vrsite/ folds into a web package in one module (0x4200.cafe/notevi); "notevi web" serves and exports exactly what vrsite did, and "notevi read/grep/note/query" is unchanged. The sidecar is renamed with it: notevi_log, notevi-log.jsonl, and the description "private: notevi log". The pre-rebrand names are still recognized, so an old repository opens and reads; it is renamed in place on the first write, or up front with "notevi migrate DIR...". That rename cannot be a single mv inside jj run. jj only auto-tracks a *new* file in the run working copy below a size limit it does not take from the command line, so writing a whole log under a name the change has never held is silently dropped while jj reports success. ensureLogFile creates the file empty first and lets every later byte be a modification of a tracked file, which snapshots at any size; that also fixes the same latent bug when importing a large legacy vr-log.jsonl. Adds a bem-te-vi mark (favicon and nav brand) and a README. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Diffstat (limited to 'web/page.go')
-rw-r--r--web/page.go348
1 files changed, 348 insertions, 0 deletions
diff --git a/web/page.go b/web/page.go
new file mode 100644
index 0000000..0425589
--- /dev/null
+++ b/web/page.go
@@ -0,0 +1,348 @@
+// page.go — what a template gets, and how links are formed.
+//
+// The same templates render two ways: as a static export (relative links,
+// ".html" suffixes, no server-side features) and as the live web app
+// (absolute routes, htmx-driven filters, comments and jj queries). Pages carry
+// Live so a template can ask for the difference in one place.
+package web
+
+import (
+ "html/template"
+ "net/url"
+ "strconv"
+ "strings"
+)
+
+type page struct {
+ Site *Site
+ Title string
+ Kind string // index | file | files | notes | note | jj | change | session
+ Root string // static: relative prefix; live: route prefix ending in "/"
+ Live bool
+ Rev string // revision being browsed ("" = the change the log points at)
+ ChangeID string // resolved change id for Rev — what pages name and link
+ Commit string // the commit under it — what content is read and cached at
+ Default string // the change the log points at
+ Path string // this request's path, so the change bar can re-aim it
+ Current string // current file path, for the tree
+ User string // who web comments are attributed to
+
+ // what this view is scoped to. Scope is "change" (only what was recorded
+ // at ChangeID) or "all"; ScopeEmpty means "change" was asked for but
+ // nothing was ever recorded there, so the whole log is showing instead.
+ Scope string
+ ScopeEmpty bool
+ Here int // notes recorded at this change
+ Elsewhere int // notes recorded at any other change
+ ChangeInfo *Row
+
+ Tree template.HTML
+ File *File
+ Files []*File // every file at this change, for counts
+
+ // one note on its own page — the permalink. Excerpt is the code it is
+ // about, already rendered; ExErr says why there is none.
+ Note *Note
+ Thread *ThreadNode
+ Excerpt template.HTML
+ ExFrom, ExTo int
+ ExErr string
+
+ Notes []*Note
+ Filter NoteFilter
+ Facets *Facets
+ Sessions []*Session
+ Compact bool
+ Window string
+
+ JJ *JJView
+ Change *Change
+ Conv *Conv
+ Err string
+}
+
+// JJView is one rendered jj query: what was asked, and what came back.
+type JJView struct {
+ Root string
+ Kind string // log | op
+ Revset string
+ AtOp string
+ Limit int
+ Rows []Row
+ Err string
+}
+
+// params carries the two things every link must not lose: which change you
+// are looking at, and how wide the view is.
+func (p *page) params() string { return p.paramsWith(p.Scope) }
+
+func (p *page) paramsWith(scope string) string {
+ var q []string
+ // "@" is a revset like any other now that the default change is the log's,
+ // so dropping it here would slide the reader off the change they picked
+ if p.Rev != "" {
+ q = append(q, "rev="+url.QueryEscape(p.Rev))
+ }
+ if scope == "all" {
+ q = append(q, "scope=all")
+ }
+ if len(q) == 0 {
+ return ""
+ }
+ return "?" + strings.Join(q, "&")
+}
+
+// ScopeHref is this same page seen with the other scope.
+func (p *page) ScopeHref(scope string) string {
+ path := p.Path
+ if path == "" {
+ path = p.liveRoot()
+ }
+ return path + p.paramsWith(scope)
+}
+
+// BackHref is this page again at the change the log points at.
+func (p *page) BackHref() string {
+ q := ""
+ if p.Scope == "all" {
+ q = "?scope=all"
+ }
+ return p.JumpPath() + q
+}
+
+// JumpPath is where the change picker submits: your own page when it is one
+// that can be read at any change, the file tree otherwise.
+func (p *page) JumpPath() string {
+ switch p.Kind {
+ case "index", "file", "files", "notes", "note":
+ if p.Path != "" {
+ return p.Path
+ }
+ }
+ return p.liveRoot() + "files"
+}
+
+func (p *page) liveRoot() string {
+ root := p.Root
+ if root == "" {
+ return "/"
+ }
+ if !strings.HasPrefix(root, "/") {
+ root = "/" + root
+ }
+ if !strings.HasSuffix(root, "/") {
+ root += "/"
+ }
+ return root
+}
+
+// Href maps a logical destination to a URL for this page's mode. Kinds the
+// static export has no page for return "", and templates guard on .Live.
+func (p *page) Href(kind string, arg ...string) string {
+ a := ""
+ if len(arg) > 0 {
+ a = arg[0]
+ }
+ if p.Live {
+ root := p.liveRoot()
+ switch kind {
+ case "":
+ return root + p.params()
+ case "notes":
+ return root + "notes" + p.params()
+ case "notes-list":
+ return root + "notes/list" + p.params()
+ case "files":
+ return root + "files" + p.params()
+ case "tree":
+ return root + "tree" + p.params()
+ case "jj":
+ return root + "jj"
+ case "jj-log":
+ return root + "jj/log"
+ case "jj-op":
+ return root + "jj/op"
+ case "activity":
+ return root + "activity"
+ case "code":
+ return root + "code/" + escPath(a) + p.params()
+ case "note":
+ return root + "note/" + a
+ case "change":
+ return root + "change/" + a
+ case "session":
+ return root + "session/" + escPath(a)
+ case "asset":
+ return root + a
+ }
+ return root
+ }
+ switch kind {
+ case "":
+ return p.Root + "index.html"
+ case "notes":
+ return p.Root + "notes.html"
+ case "files":
+ return p.Root + "files.html"
+ case "tree":
+ return p.Root + "tree.html"
+ case "code":
+ return p.Root + "code/" + a + ".html"
+ case "note":
+ return p.Root + "note/" + a + ".html"
+ case "asset":
+ return p.Root + a
+ }
+ return ""
+}
+
+// codeLink is the tree's per-file linker for this page's mode.
+func (p *page) codeLink() func(string) string {
+ return func(path string) string { return p.Href("code", path) }
+}
+
+func escPath(p string) string {
+ parts := strings.Split(p, "/")
+ for i, s := range parts {
+ parts[i] = url.PathEscape(s)
+ }
+ return strings.Join(parts, "/")
+}
+
+// ConvHref opens the conversation a note was written in, anchored on it. It
+// needs the transcripts on this machine, so the static export has none — and
+// the conversation itself does not link back to where you already are.
+func (p *page) ConvHref(n *Note) string {
+ if !p.Live || p.Kind == "session" || n.Session == "" || safeSession(n.Session) != nil {
+ return ""
+ }
+ id := strconv.Itoa(n.ID)
+ return p.Href("session", n.Session) + "?note=" + id + "#note-" + id
+}
+
+// PermaHref is the note's own page: the link to hand someone else. It is the
+// one link on the card that names nothing but the note, so it survives the
+// change scope, the filters, and the file having moved on since.
+//
+// It is empty on the note's own page, which is where it already points, and in
+// a static export of a different change, which has no page for it.
+func (p *page) PermaHref(n *Note) string {
+ if p.Kind == "note" && (p.Note == nil || p.Note.ID == n.ID) {
+ return ""
+ }
+ if !p.Live && p.Site != nil && p.Site.NoteByID(n.ID) == nil {
+ return ""
+ }
+ return p.Href("note", strconv.Itoa(n.ID))
+}
+
+// ThreadHref opens the incoming replies and mentions for a note. The focused
+// root only needs an in-page jump; every other card gets its own permalink.
+func (p *page) ThreadHref(n *Note) string {
+ if len(n.Replies) == 0 && len(n.Mentions) == 0 {
+ return ""
+ }
+ if p.Kind == "note" && p.Note != nil && p.Note.ID == n.ID {
+ return "#discussion"
+ }
+ h := p.Href("note", strconv.Itoa(n.ID))
+ if h == "" {
+ return ""
+ }
+ return h + "#discussion"
+}
+
+// ReplyHref is the explicit parent link. A static export may not contain the
+// parent's change, in which case it leaves the relationship as text instead of
+// manufacturing a link to a page the export did not write.
+func (p *page) ReplyHref(n *Note) string {
+ if n.ReplyTo < 1 {
+ return ""
+ }
+ if !p.Live && p.Site != nil && p.Site.NoteByID(n.ReplyTo) == nil {
+ return ""
+ }
+ return p.Href("note", strconv.Itoa(n.ReplyTo))
+}
+
+// HasCode reports whether this view has a page for a path. The live app can
+// render any file at the change it is reading; a static export only wrote the
+// ones present at the change it holds, so a note about a file that is not
+// there must not offer a link into nothing.
+func (p *page) HasCode(path string) bool {
+ if p.Live {
+ return path != ""
+ }
+ for _, f := range p.Files {
+ if f.Path == path {
+ return true
+ }
+ }
+ return false
+}
+
+// NoteHref points at the noted line, or the note list when it is unpinned.
+// The ?note= is what turns a jump into an arrival: the page opens in notes
+// focus with the card flashed and its lines marked (see app.js).
+func (p *page) NoteHref(n *Note) string {
+ if n.File == "" {
+ return p.Href("notes")
+ }
+ id := strconv.Itoa(n.ID)
+ h := p.Href("code", n.File)
+ sep := "?"
+ if strings.Contains(h, "?") {
+ sep = "&"
+ }
+ h += sep + "note=" + id
+ if n.Start > 0 {
+ h += "#L" + strconv.Itoa(n.Start)
+ }
+ return h
+}
+
+// newTemplates parses the page templates. The highlighter comes along because
+// note bodies are markdown, and a fenced code block in a note is painted by
+// the same grammars as the code view.
+func newTemplates(hl *highlighter) *template.Template {
+ md := newMarkdown(hl)
+ funcs := template.FuncMap{
+ "short": short,
+ "md": md.renderAt,
+ "band": func(a, b uint8) bool { return b != 0 && a&b != 0 },
+ "lines": func(n *Note) string {
+ if n.Start <= 0 {
+ return "whole file"
+ }
+ if n.End > n.Start {
+ return ":" + strconv.Itoa(n.Start) + "-" + strconv.Itoa(n.End)
+ }
+ return ":" + strconv.Itoa(n.Start)
+ },
+ "base": func(p string) string {
+ if i := strings.LastIndex(p, "/"); i >= 0 {
+ return p[i+1:]
+ }
+ return p
+ },
+ "dict": func(kv ...any) map[string]any {
+ m := map[string]any{}
+ for i := 0; i+1 < len(kv); i += 2 {
+ m[kv[i].(string)] = kv[i+1]
+ }
+ return m
+ },
+ }
+ tpl, err := template.New("layout.tmpl").Funcs(funcs).ParseFS(tfs, "templates/*.tmpl")
+ if err != nil {
+ fatal("%v", err)
+ }
+ return tpl
+}
+
+func short(s string) string {
+ if len(s) > 8 {
+ return s[:8]
+ }
+ return s
+}