diff options
Diffstat (limited to 'web/page.go')
| -rw-r--r-- | web/page.go | 348 |
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 +} |
