summaryrefslogtreecommitdiff
path: root/vrsite/serve.go
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-07-24 11:39:36 -0300
committerGabriel Schneider <[email protected]>2026-07-30 15:18:55 -0300
commitcb05c363045bf0e7af5858f6d7bc26f026fa9d70 (patch)
treec3e89426ff044ffe339dd3a77e7f3b7115cba636 /vrsite/serve.go
downloadnotevi-cb05c363045bf0e7af5858f6d7bc26f026fa9d70.tar.gz
notevi-cb05c363045bf0e7af5858f6d7bc26f026fa9d70.zip
Diffstat (limited to 'vrsite/serve.go')
-rw-r--r--vrsite/serve.go522
1 files changed, 522 insertions, 0 deletions
diff --git a/vrsite/serve.go b/vrsite/serve.go
new file mode 100644
index 0000000..c73630c
--- /dev/null
+++ b/vrsite/serve.go
@@ -0,0 +1,522 @@
+// serve.go — vrsite as a live web application.
+//
+// The log is the only state: it is re-read whenever it grows, notes written
+// from the browser are appended to it, and the repository is only ever read
+// (see jj.go). Pages are rendered per request from the same templates the
+// static export uses; htmx swaps the parts that change on their own —
+// filtered note lists, the activity of agents currently reading, jj queries.
+package main
+
+import (
+ "bytes"
+ "fmt"
+ "html/template"
+ "net/http"
+ "net/url"
+ "os"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+)
+
+// liveWindow is how recently a session must have logged something to count as
+// still running.
+const liveWindow = 5 * time.Minute
+
+// renderCacheMax bounds the rendered-file cache; it is dropped wholesale
+// whenever the log changes, since coverage is baked into the HTML.
+const renderCacheMax = 64
+
+type server struct {
+ logPath, repo, title, user, session string
+ tpl *template.Template
+ hl *highlighter
+ theme, light *Theme
+
+ mu sync.Mutex
+ site *Site
+ entries []Entry
+ off int64
+ state logState
+ gen int
+ def string // resolved change the log points at
+ trees map[string][]*File // change -> file list
+ rendered map[string]*File // gen|change|path -> rendered file
+}
+
+func newServer(logPath, repo, title, user string, theme, light *Theme) *server {
+ sv := &server{logPath: logPath, repo: repo, title: title, user: user,
+ session: "web:" + user, theme: theme, light: light,
+ tpl: newTemplates(), trees: map[string][]*File{}, rendered: map[string]*File{}}
+ sv.hl = newHighlighter(theme, light)
+ sv.reload(true)
+ return sv
+}
+
+// reload re-reads the log if it changed. It appends from the last offset when
+// the file only grew — the common case, since vr and the web app both append.
+func (sv *server) reload(force bool) {
+ st, err := statLog(sv.logPath)
+ if err == nil && st == sv.state && !force {
+ return
+ }
+ if err != nil {
+ if sv.site == nil {
+ sv.site = buildModel(sv.title, nil)
+ sv.site.Theme, sv.site.Light = sv.theme, sv.light
+ }
+ return
+ }
+ if st.size < sv.off {
+ sv.off, sv.entries = 0, nil // truncated or rewritten
+ }
+ f, err := os.Open(sv.logPath)
+ if err != nil {
+ return
+ }
+ defer f.Close()
+ buf := make([]byte, st.size-sv.off)
+ n, _ := f.ReadAt(buf, sv.off)
+ buf = buf[:n]
+ if i := bytes.LastIndexByte(buf, '\n'); i >= 0 { // ignore a partial last line
+ sv.off += int64(i + 1)
+ sv.entries = append(sv.entries, parseLog(buf[:i+1])...)
+ }
+ sv.state = st
+ sv.site = buildModel(sv.title, sv.entries)
+ sv.site.Theme, sv.site.Light, sv.site.Repo = sv.theme, sv.light, sv.repo
+ sv.gen++
+ sv.def = ""
+ sv.trees = map[string][]*File{}
+ sv.rendered = map[string]*File{}
+}
+
+// snap returns the current model, its generation, and the change the log
+// points at (falling back to the working copy when that change is unknown to
+// this repo).
+func (sv *server) snap() (*Site, int, string) {
+ sv.mu.Lock()
+ defer sv.mu.Unlock()
+ sv.reload(false)
+ if sv.def == "" {
+ def := sv.site.Change
+ if def == "" {
+ def = "@"
+ }
+ if id, err := resolveChange(sv.repo, def); err == nil {
+ sv.def = id
+ } else if id, err := resolveChange(sv.repo, "@"); err == nil {
+ sv.def = id
+ } else {
+ sv.def = def
+ }
+ sv.site.All = sv.filesLocked(sv.site, sv.def)
+ }
+ return sv.site, sv.gen, sv.def
+}
+
+func (sv *server) filesLocked(site *Site, change string) []*File {
+ if f, ok := sv.trees[change]; ok {
+ return f
+ }
+ paths, err := fileList(sv.repo, change)
+ if err != nil {
+ return nil
+ }
+ f := site.filesAt(paths, change)
+ sv.trees[change] = f
+ return f
+}
+
+func (sv *server) files(site *Site, change string) []*File {
+ sv.mu.Lock()
+ defer sv.mu.Unlock()
+ return sv.filesLocked(site, change)
+}
+
+// fileAt renders one file at a change: model coverage and notes, source from
+// jj, highlighted. Results are cached per log generation.
+func (sv *server) fileAt(site *Site, gen int, change, path string) *File {
+ key := fmt.Sprintf("%d|%s|%s", gen, change, path)
+ sv.mu.Lock()
+ if f, ok := sv.rendered[key]; ok {
+ sv.mu.Unlock()
+ return f
+ }
+ sv.mu.Unlock()
+
+ var f *File
+ if base, ok := site.Files[path]; ok {
+ f = base.clone()
+ } else {
+ f = &File{Path: path, Change: change, NoteAt: map[int]int{}}
+ }
+ if f.Change == "" {
+ f.Change = change
+ }
+ src, err := fileShow(sv.repo, change, path)
+ switch {
+ case err != nil:
+ f.Stub = "not present at this change"
+ case bytes.IndexByte(src, 0) >= 0:
+ f.Stub = "binary file — not rendered"
+ case len(src) > 2<<20:
+ f.Stub = fmt.Sprintf("file too large to render (%d KB)", len(src)/1024)
+ default:
+ f.render(src, sv.hl)
+ }
+
+ sv.mu.Lock()
+ if len(sv.rendered) > renderCacheMax {
+ sv.rendered = map[string]*File{}
+ }
+ sv.rendered[key] = f
+ sv.mu.Unlock()
+ return f
+}
+
+// ── request plumbing ──────────────────────────────────────────────────────
+
+// newPage resolves ?rev= and fills in everything the layout needs.
+func (sv *server) newPage(r *http.Request, kind, title string) (*page, *Site, error) {
+ site, _, def := sv.snap()
+ p := &page{Site: site, Kind: kind, Title: title, Root: "/", Live: true,
+ User: sv.user, ChangeID: def, Facets: site.Facets()}
+ rev := strings.TrimSpace(r.URL.Query().Get("rev"))
+ if rev != "" && rev != def {
+ id, err := resolveChange(sv.repo, rev)
+ if err != nil {
+ return p, site, err
+ }
+ p.Rev, p.ChangeID = rev, id
+ }
+ return p, site, nil
+}
+
+func (sv *server) render(w http.ResponseWriter, p *page) {
+ var b bytes.Buffer
+ if err := sv.tpl.Execute(&b, p); err != nil {
+ http.Error(w, "render: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ b.WriteTo(w)
+}
+
+func (sv *server) frag(w http.ResponseWriter, name string, data any) {
+ var b bytes.Buffer
+ if err := sv.tpl.ExecuteTemplate(&b, name, data); err != nil {
+ http.Error(w, "render: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ b.WriteTo(w)
+}
+
+func htmxReq(r *http.Request) bool { return r.Header.Get("HX-Request") != "" }
+
+func atoiDefault(s string, def int) int {
+ if n, err := strconv.Atoi(strings.TrimSpace(s)); err == nil {
+ return n
+ }
+ return def
+}
+
+func (sv *server) handler() http.Handler {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /{$}", sv.index)
+ mux.HandleFunc("GET /files", sv.filesPage)
+ mux.HandleFunc("GET /tree", sv.treeFrag)
+ mux.HandleFunc("GET /code/{path...}", sv.codePage)
+ mux.HandleFunc("GET /notes", sv.notesPage)
+ mux.HandleFunc("GET /notes/list", sv.notesList)
+ mux.HandleFunc("POST /notes", sv.addNote)
+ mux.HandleFunc("GET /activity", sv.activity)
+ mux.HandleFunc("GET /jj", sv.jjPage)
+ mux.HandleFunc("GET /jj/log", sv.jjLog)
+ mux.HandleFunc("GET /jj/op", sv.jjOp)
+ mux.HandleFunc("GET /change/{id}", sv.changePage)
+ mux.HandleFunc("GET /style.css", sv.styleCSS)
+ mux.HandleFunc("GET /app.js", sv.asset)
+ mux.HandleFunc("GET /htmx.min.js", sv.asset)
+ return mux
+}
+
+// ── pages ─────────────────────────────────────────────────────────────────
+
+func (sv *server) index(w http.ResponseWriter, r *http.Request) {
+ p, site, err := sv.newPage(r, "index", sv.title)
+ if err != nil {
+ p.Err = err.Error()
+ }
+ p.Notes = site.FilterNotes(NoteFilter{})
+ if len(p.Notes) > 24 {
+ p.Notes = p.Notes[:24]
+ }
+ sv.render(w, p)
+}
+
+func (sv *server) filesPage(w http.ResponseWriter, r *http.Request) {
+ p, site, err := sv.newPage(r, "files", "files — "+sv.title)
+ if err != nil {
+ p.Err = err.Error()
+ sv.render(w, p)
+ return
+ }
+ p.Tree = template.HTML(renderTree(sv.files(site, p.ChangeID), p.codeLink()))
+ sv.render(w, p)
+}
+
+func (sv *server) treeFrag(w http.ResponseWriter, r *http.Request) {
+ p, site, err := sv.newPage(r, "files", "")
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ fmt.Fprint(w, renderTree(sv.files(site, p.ChangeID), p.codeLink()))
+}
+
+func (sv *server) codePage(w http.ResponseWriter, r *http.Request) {
+ path := strings.TrimPrefix(r.PathValue("path"), "/")
+ p, site, err := sv.newPage(r, "file", path+" — "+sv.title)
+ if err != nil {
+ p.Err = err.Error()
+ sv.render(w, p)
+ return
+ }
+ _, gen, _ := sv.snap()
+ p.Current = path
+ p.File = sv.fileAt(site, gen, p.ChangeID, path)
+ sv.render(w, p)
+}
+
+func (sv *server) notesPage(w http.ResponseWriter, r *http.Request) {
+ p, site, err := sv.newPage(r, "notes", "notes — "+sv.title)
+ if err != nil {
+ p.Err = err.Error()
+ }
+ p.Filter = filterFrom(r)
+ p.Notes = site.FilterNotes(p.Filter)
+ sv.render(w, p)
+}
+
+func (sv *server) notesList(w http.ResponseWriter, r *http.Request) {
+ p, site, _ := sv.newPage(r, "notes", "")
+ p.Filter = filterFrom(r)
+ p.Notes = site.FilterNotes(p.Filter)
+ sv.frag(w, "notelist-oob", p)
+}
+
+func filterFrom(r *http.Request) NoteFilter {
+ q := r.URL.Query()
+ return NoteFilter{
+ Text: q.Get("text"), File: q.Get("file"), Agent: q.Get("agent"),
+ Model: q.Get("model"), Session: q.Get("session"), Kind: q.Get("kind"),
+ Change: q.Get("change"), From: q.Get("from"), To: q.Get("to"),
+ }
+}
+
+// addNote appends a comment written in the browser to the log — the only
+// write this program does, and it never touches the repository.
+func (sv *server) addNote(w http.ResponseWriter, r *http.Request) {
+ // the app writes to a file on your disk, so refuse posts a page on
+ // another origin sent here
+ if o := r.Header.Get("Origin"); o != "" {
+ if u, err := url.Parse(o); err != nil || u.Host != r.Host {
+ http.Error(w, "cross-origin post refused", http.StatusForbidden)
+ return
+ }
+ }
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "bad form", http.StatusBadRequest)
+ return
+ }
+ text := strings.TrimSpace(r.FormValue("text"))
+ if text == "" {
+ http.Error(w, "a note needs text", http.StatusBadRequest)
+ return
+ }
+ rev := strings.TrimSpace(r.FormValue("rev"))
+ if rev == "" {
+ rev = "@"
+ }
+ change, err := resolveChange(sv.repo, rev)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ start := atoiDefault(r.FormValue("start"), 0)
+ end := atoiDefault(r.FormValue("end"), 0)
+ if end < start {
+ end = start
+ }
+ e := Entry{
+ Time: time.Now().Format(time.RFC3339), Op: "note",
+ Session: sv.session, Agent: sv.user, Dir: sv.repo,
+ Rev: rev, Change: change, File: strings.TrimSpace(r.FormValue("file")),
+ Start: start, End: end, Type: strings.TrimSpace(r.FormValue("kind")), Text: text,
+ }
+
+ sv.mu.Lock()
+ e.NoteID = nextNoteID(sv.entries, sv.session)
+ err = appendEntry(sv.logPath, e)
+ if err == nil {
+ sv.reload(true)
+ }
+ site := sv.site
+ sv.mu.Unlock()
+ if err != nil {
+ http.Error(w, "write log: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ if !htmxReq(r) {
+ back := r.FormValue("back")
+ if back == "" {
+ back = "/notes"
+ }
+ http.Redirect(w, r, back, http.StatusSeeOther)
+ return
+ }
+ p := &page{Site: site, Kind: "file", Root: "/", Live: true, User: sv.user, Facets: site.Facets()}
+ if e.File != "" {
+ p.File = site.Files[e.File]
+ }
+ // the note just written is the last one carrying our session and id
+ for i := len(site.Notes) - 1; i >= 0; i-- {
+ if n := site.Notes[i]; n.Session == sv.session && n.NoteID == e.NoteID {
+ p.Notes = []*Note{n}
+ break
+ }
+ }
+ sv.frag(w, "note-added", p)
+}
+
+func (sv *server) activity(w http.ResponseWriter, r *http.Request) {
+ site, _, _ := sv.snap()
+ limit := 6
+ compact := r.URL.Query().Get("compact") != ""
+ if !compact {
+ limit = 20
+ }
+ p := &page{Site: site, Live: true, Compact: compact, Window: "5 min",
+ Sessions: site.Sessions(time.Now(), liveWindow, limit)}
+ sv.frag(w, "activity", p)
+}
+
+// ── jj ────────────────────────────────────────────────────────────────────
+
+const defaultLimit = 50
+
+func (sv *server) jjView(r *http.Request) *JJView {
+ q := r.URL.Query()
+ v := &JJView{Kind: q.Get("kind"), Revset: strings.TrimSpace(q.Get("revset")),
+ AtOp: strings.TrimSpace(q.Get("at_op")), Limit: atoiDefault(q.Get("limit"), defaultLimit)}
+ if v.Limit < 1 || v.Limit > 1000 {
+ v.Limit = defaultLimit
+ }
+ if v.Kind != "op" {
+ v.Kind = "log"
+ }
+ var err error
+ if v.Kind == "op" {
+ v.Rows, err = OpRows(sv.repo, v.Limit)
+ } else {
+ v.Rows, err = LogRows(sv.repo, v.Revset, v.AtOp, v.Limit)
+ }
+ if err != nil {
+ v.Err = err.Error()
+ }
+ return v
+}
+
+func (sv *server) jjPage(w http.ResponseWriter, r *http.Request) {
+ p, _, err := sv.newPage(r, "jj", "jj — "+sv.title)
+ if err != nil {
+ p.Err = err.Error()
+ }
+ p.JJ = sv.jjView(r)
+ sv.render(w, p)
+}
+
+func (sv *server) jjLog(w http.ResponseWriter, r *http.Request) {
+ v := sv.jjView(r)
+ if !htmxReq(r) {
+ sv.jjPage(w, r)
+ return
+ }
+ sv.frag(w, "jjrows", v)
+}
+
+func (sv *server) jjOp(w http.ResponseWriter, r *http.Request) {
+ q := r.URL.Query()
+ v := &JJView{Kind: "op", Limit: atoiDefault(q.Get("limit"), defaultLimit)}
+ if v.Limit < 1 || v.Limit > 1000 {
+ v.Limit = defaultLimit
+ }
+ var err error
+ if v.Rows, err = OpRows(sv.repo, v.Limit); err != nil {
+ v.Err = err.Error()
+ }
+ if !htmxReq(r) {
+ p, _, _ := sv.newPage(r, "jj", "jj op log — "+sv.title)
+ p.JJ = v
+ sv.render(w, p)
+ return
+ }
+ sv.frag(w, "jjrows", v)
+}
+
+func (sv *server) changePage(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ p, site, _ := sv.newPage(r, "change", id+" — "+sv.title)
+ c, err := LoadChange(sv.repo, id)
+ if err != nil {
+ p.Err = err.Error()
+ sv.render(w, p)
+ return
+ }
+ p.Change = c
+ p.Rev, p.ChangeID = c.Row.ID, c.Row.ID
+ p.Title = c.Row.Short + " — " + sv.title
+ p.Notes = site.FilterNotes(NoteFilter{Change: c.Row.ID})
+ sv.render(w, p)
+}
+
+// ── assets ────────────────────────────────────────────────────────────────
+
+func (sv *server) styleCSS(w http.ResponseWriter, r *http.Request) {
+ site, _, _ := sv.snap()
+ var b bytes.Buffer
+ if err := renderCSS(&b, site); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "text/css; charset=utf-8")
+ b.WriteTo(w)
+}
+
+func (sv *server) asset(w http.ResponseWriter, r *http.Request) {
+ name := strings.TrimPrefix(r.URL.Path, "/")
+ b, err := tfs.ReadFile("assets/" + name)
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
+ w.Header().Set("Cache-Control", "max-age=300")
+ w.Write(b)
+}
+
+func serve(addr, logPath, repo, title, user string, theme, light *Theme) {
+ sv := newServer(logPath, repo, title, user, theme, light)
+ site, _, def := sv.snap()
+ fmt.Printf("vrsite: http://%s — %d notes, %d agents, repo %s at %s\n",
+ addr, len(site.Notes), len(site.Agents), repo, short(def))
+ fmt.Printf("vrsite: comments are appended to %s; the repo is never written to\n", logPath)
+ if err := http.ListenAndServe(addr, sv.handler()); err != nil {
+ fatal("%v", err)
+ }
+}