summaryrefslogtreecommitdiff
path: root/vrsite/serve.go
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-07-30 17:05:12 -0300
committerGabriel Schneider <[email protected]>2026-07-30 17:56:23 -0300
commit8ec4e135e318c7799b2576a8729493c063da4a97 (patch)
treeab37cdbb7f478eb996264cf941891f127f303c85 /vrsite/serve.go
parentcb05c363045bf0e7af5858f6d7bc26f026fa9d70 (diff)
downloadnotevi-8ec4e135e318c7799b2576a8729493c063da4a97.tar.gz
notevi-8ec4e135e318c7799b2576a8729493c063da4a97.zip
Diffstat (limited to 'vrsite/serve.go')
-rw-r--r--vrsite/serve.go253
1 files changed, 198 insertions, 55 deletions
diff --git a/vrsite/serve.go b/vrsite/serve.go
index c73630c..c6e1ef1 100644
--- a/vrsite/serve.go
+++ b/vrsite/serve.go
@@ -1,10 +1,24 @@
// 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.
+// The server owns nothing. Three rules keep it that way, and app_test.go holds
+// each of them:
+//
+// - The log is the only thing written. Notes typed in the browser are
+// appended to it exactly as vr appends them; the repository is only ever
+// read (see jj.go), and no file of the app's own is ever created.
+// - Memory is a cache, never a source. The model, the per-change
+// projections, file lists and rendered pages are all derived from the log
+// and the repo, so a restart changes nothing anyone can see.
+// - What is cached is keyed by what it was derived from: the log's
+// generation, and the commit id — not the change id, which is a pointer
+// that moves whenever the change is edited.
+//
+// What a reader chose lives in the URL (which change, how wide the view) or in
+// their own browser (theme, focus, which folders are open). None of it is here.
+//
+// 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 (
@@ -33,6 +47,7 @@ type server struct {
tpl *template.Template
hl *highlighter
theme, light *Theme
+ tx *transcripts
mu sync.Mutex
site *Site
@@ -41,14 +56,19 @@ type server struct {
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
+ models map[string]*Site // scope key -> the log projected onto it
+ trees map[string][]*File // scope key|change -> file list
+ rendered map[string]*File // gen|scope|change|path -> rendered file
}
+// scopeAll is the key of the unscoped model — the whole log, every change.
+const scopeAll = "*"
+
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{}}
+ tpl: newTemplates(), models: map[string]*Site{}, trees: map[string][]*File{},
+ rendered: map[string]*File{}, tx: newTranscripts(defaultTranscriptRoots())}
sv.hl = newHighlighter(theme, light)
sv.reload(true)
return sv
@@ -63,8 +83,7 @@ func (sv *server) reload(force bool) {
}
if err != nil {
if sv.site == nil {
- sv.site = buildModel(sv.title, nil)
- sv.site.Theme, sv.site.Light = sv.theme, sv.light
+ sv.site = sv.dress(buildModel(sv.title, nil, ""))
}
return
}
@@ -84,61 +103,108 @@ func (sv *server) reload(force bool) {
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.site = sv.dress(buildModel(sv.title, sv.entries, ""))
sv.gen++
sv.def = ""
+ sv.models = map[string]*Site{}
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) dress(s *Site) *Site {
+ s.Theme, s.Light, s.Repo = sv.theme, sv.light, sv.repo
+ return s
+}
+
+// snap returns the whole-log model, its generation, and the change the log
+// points at: the most-logged change this repo actually has, since a log can
+// carry entries from several repos, falling back to the working copy.
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 = "@"
+ for i, c := range sv.site.Ranked {
+ if i == 5 {
+ break // a log with many foreign changes is not worth probing
+ }
+ if id, err := resolveChange(sv.repo, c); err == nil {
+ sv.def = id
+ break
+ }
}
- 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
+ if sv.def == "" {
+ if id, err := resolveChange(sv.repo, "@"); err == nil {
+ sv.def = id
+ } else {
+ sv.def = sv.site.Change
+ }
}
- 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 {
+// modelFor returns the log projected onto one change, or the whole log when
+// change is "". Built once per generation, since the projection is pure.
+func (sv *server) modelFor(change string) *Site {
+ key := change
+ if key == "" {
+ key = scopeAll
+ }
+ sv.mu.Lock()
+ defer sv.mu.Unlock()
+ if s, ok := sv.models[key]; ok {
+ return s
+ }
+ s := sv.site
+ if change != "" {
+ s = sv.dress(buildModel(sv.title, sv.entries, change))
+ }
+ sv.models[key] = s
+ return s
+}
+
+func scopeKey(site *Site) string {
+ if site.Only == "" {
+ return scopeAll
+ }
+ return site.Only
+}
+
+func (sv *server) filesLocked(site *Site, commit, change string) []*File {
+ key := scopeKey(site) + "|" + commit
+ if f, ok := sv.trees[key]; ok {
return f
}
- paths, err := fileList(sv.repo, change)
+ paths, err := fileList(sv.repo, commit)
if err != nil {
return nil
}
f := site.filesAt(paths, change)
- sv.trees[change] = f
+ sv.trees[key] = f
return f
}
-func (sv *server) files(site *Site, change string) []*File {
+func (sv *server) files(site *Site, commit, change string) []*File {
sv.mu.Lock()
defer sv.mu.Unlock()
- return sv.filesLocked(site, change)
+ return sv.filesLocked(site, commit, change)
+}
+
+// forgetDefault drops the chosen change so the next request picks again —
+// the repo moved under us, which is the repo's business, not ours to remember.
+func (sv *server) forgetDefault() {
+ sv.mu.Lock()
+ sv.def = ""
+ sv.mu.Unlock()
}
-// 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)
+// fileAt renders one file: model coverage and notes, source from jj,
+// highlighted. commit is the content it is read at and what the cache is keyed
+// on — change only names it on the page — so an edit under the same change id
+// is a different key, never a stale hit.
+func (sv *server) fileAt(site *Site, gen int, commit, change, path string) *File {
+ key := fmt.Sprintf("%d|%s|%s|%s", gen, scopeKey(site), commit, path)
sv.mu.Lock()
if f, ok := sv.rendered[key]; ok {
sv.mu.Unlock()
@@ -155,7 +221,7 @@ func (sv *server) fileAt(site *Site, gen int, change, path string) *File {
if f.Change == "" {
f.Change = change
}
- src, err := fileShow(sv.repo, change, path)
+ src, err := fileShow(sv.repo, commit, path)
switch {
case err != nil:
f.Stub = "not present at this change"
@@ -178,20 +244,56 @@ func (sv *server) fileAt(site *Site, gen int, change, path string) *File {
// ── request plumbing ──────────────────────────────────────────────────────
-// newPage resolves ?rev= and fills in everything the layout needs.
+// newPage answers the two questions every page starts from: which change am I
+// looking at, and am I seeing only what was recorded there. ?rev= picks the
+// change (default: the one the log points at); ?scope=all widens the view to
+// the whole log, and a change nothing was ever recorded at widens by itself,
+// so the site never looks mysteriously empty.
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
+ full, _, def := sv.snap()
+ q := r.URL.Query()
+ p := &page{Site: full, Kind: kind, Title: title, Root: "/", Live: true,
+ User: sv.user, ChangeID: def, Default: def, Path: r.URL.Path, Scope: "change"}
+ rev := strings.TrimSpace(q.Get("rev"))
+ target := rev
+ if target == "" {
+ target = def
+ }
+ row, err := changeBrief(sv.repo, target)
+ if err != nil {
+ if rev == "" {
+ sv.forgetDefault() // the log's change left the repo; pick again
}
- p.Rev, p.ChangeID = rev, id
+ p.Facets = full.Facets()
+ return p, full, err
+ }
+ p.ChangeID, p.Commit, p.ChangeInfo = row.ID, row.Commit, &row
+ if rev != "" && row.ID != def {
+ p.Rev = rev
}
- return p, site, nil
+ if q.Get("scope") == "all" {
+ p.Scope = "all"
+ }
+ return p, sv.rescope(p), nil
+}
+
+// rescope picks the model for the change the page settled on. Handlers that
+// discover their change from the path (a change page) call it again.
+func (sv *server) rescope(p *page) *Site {
+ full, _, _ := sv.snap()
+ site := full
+ p.ScopeEmpty = false
+ if p.Scope == "change" {
+ if full.Votes[p.ChangeID] > 0 {
+ site = sv.modelFor(p.ChangeID)
+ } else {
+ p.ScopeEmpty = true // asked for this change, nothing was recorded here
+ }
+ }
+ p.Site, p.Facets = site, site.Facets()
+ p.Here = full.NotesAt[p.ChangeID]
+ p.Elsewhere = full.NotesTotal - p.Here
+ return site
}
func (sv *server) render(w http.ResponseWriter, p *page) {
@@ -233,6 +335,7 @@ func (sv *server) handler() http.Handler {
mux.HandleFunc("GET /notes/list", sv.notesList)
mux.HandleFunc("POST /notes", sv.addNote)
mux.HandleFunc("GET /activity", sv.activity)
+ mux.HandleFunc("GET /session/{id}", sv.sessionPage)
mux.HandleFunc("GET /jj", sv.jjPage)
mux.HandleFunc("GET /jj/log", sv.jjLog)
mux.HandleFunc("GET /jj/op", sv.jjOp)
@@ -254,6 +357,7 @@ func (sv *server) index(w http.ResponseWriter, r *http.Request) {
if len(p.Notes) > 24 {
p.Notes = p.Notes[:24]
}
+ p.Files = sv.files(site, p.Commit, p.ChangeID)
sv.render(w, p)
}
@@ -264,7 +368,7 @@ func (sv *server) filesPage(w http.ResponseWriter, r *http.Request) {
sv.render(w, p)
return
}
- p.Tree = template.HTML(renderTree(sv.files(site, p.ChangeID), p.codeLink()))
+ p.Tree = template.HTML(renderTree(sv.files(site, p.Commit, p.ChangeID), p.codeLink()))
sv.render(w, p)
}
@@ -275,7 +379,7 @@ func (sv *server) treeFrag(w http.ResponseWriter, r *http.Request) {
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
- fmt.Fprint(w, renderTree(sv.files(site, p.ChangeID), p.codeLink()))
+ fmt.Fprint(w, renderTree(sv.files(site, p.Commit, p.ChangeID), p.codeLink()))
}
func (sv *server) codePage(w http.ResponseWriter, r *http.Request) {
@@ -288,7 +392,7 @@ func (sv *server) codePage(w http.ResponseWriter, r *http.Request) {
}
_, gen, _ := sv.snap()
p.Current = path
- p.File = sv.fileAt(site, gen, p.ChangeID, path)
+ p.File = sv.fileAt(site, gen, p.Commit, p.ChangeID, path)
sv.render(w, p)
}
@@ -338,15 +442,25 @@ func (sv *server) addNote(w http.ResponseWriter, r *http.Request) {
http.Error(w, "a note needs text", http.StatusBadRequest)
return
}
+ // a note belongs to the change its writer was reading, which is the one
+ // the form carries — not the working copy, which may have moved on since
+ _, _, def := sv.snap()
rev := strings.TrimSpace(r.FormValue("rev"))
- if rev == "" {
- rev = "@"
+ target := strings.TrimSpace(r.FormValue("change"))
+ for _, fallback := range []string{rev, def, "@"} {
+ if target != "" {
+ break
+ }
+ target = fallback
}
- change, err := resolveChange(sv.repo, rev)
+ change, err := resolveChange(sv.repo, target)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
+ if rev == "" {
+ rev = target
+ }
start := atoiDefault(r.FormValue("start"), 0)
end := atoiDefault(r.FormValue("end"), 0)
if end < start {
@@ -371,6 +485,12 @@ func (sv *server) addNote(w http.ResponseWriter, r *http.Request) {
http.Error(w, "write log: "+err.Error(), http.StatusInternalServerError)
return
}
+ // answer in the same view the writer is looking at, or the note count
+ // coming back would be the whole log's while their page shows one change
+ scope := r.FormValue("scope")
+ if scope != "all" {
+ site = sv.modelFor(change)
+ }
if !htmxReq(r) {
back := r.FormValue("back")
@@ -406,6 +526,24 @@ func (sv *server) activity(w http.ResponseWriter, r *http.Request) {
sv.frag(w, "activity", p)
}
+// sessionPage shows one session's conversation, with ?note=N anchoring the
+// moment a note was written. It renders from the log alone when the harness
+// transcript cannot be found — the reading timeline is still worth having.
+func (sv *server) sessionPage(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ if err := safeSession(id); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ p, site, err := sv.newPage(r, "session", "session "+short(id)+" — "+sv.title)
+ if err != nil {
+ p.Err = err.Error()
+ }
+ q := r.URL.Query()
+ p.Conv = sv.conversation(site, id, atoiDefault(q.Get("note"), 0), q.Get("all") != "")
+ sv.render(w, p)
+}
+
// ── jj ────────────────────────────────────────────────────────────────────
const defaultLimit = 50
@@ -479,8 +617,11 @@ func (sv *server) changePage(w http.ResponseWriter, r *http.Request) {
return
}
p.Change = c
- p.Rev, p.ChangeID = c.Row.ID, c.Row.ID
+ p.Rev, p.ChangeID, p.Commit = c.Row.ID, c.Row.ID, c.Row.Commit
+ p.ChangeInfo = &c.Row
p.Title = c.Row.Short + " — " + sv.title
+ // the page named a change the query string did not, so the view follows it
+ site = sv.rescope(p)
p.Notes = site.FilterNotes(NoteFilter{Change: c.Row.ID})
sv.render(w, p)
}
@@ -510,12 +651,14 @@ func (sv *server) asset(w http.ResponseWriter, r *http.Request) {
w.Write(b)
}
-func serve(addr, logPath, repo, title, user string, theme, light *Theme) {
+func serve(addr, logPath, repo, title, user string, roots []string, theme, light *Theme) {
sv := newServer(logPath, repo, title, user, theme, light)
+ sv.tx = newTranscripts(roots)
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)
+ fmt.Printf("vrsite: session transcripts read from %s\n", strings.Join(sv.tx.roots, ", "))
if err := http.ListenAndServe(addr, sv.handler()); err != nil {
fatal("%v", err)
}