// serve.go — notevi web as a live web application. // // The server owns nothing. Three rules keep it that way, and app_test.go holds // each of them: // // - The log sidecar is the only thing written. Notes typed in the browser are // appended to it exactly as notevi appends them; project history and the main // working copy are only ever read (see jj.go). // - 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 web import ( "bytes" "fmt" "html/template" "net/http" "net/url" "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 { log logStore repo, title, user, session, base string tpl *template.Template hl *highlighter theme, light *Theme themes *themeSet tx *transcripts mu sync.Mutex site *Site entries []Entry state string gen int def string // resolved change the log points at 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(log logStore, repo, title, user string, theme, light *Theme, themes *themeSet) *server { return newServerAt(log, repo, title, user, "/", theme, light, themes) } func newServerAt(log logStore, repo, title, user, base string, theme, light *Theme, themes *themeSet) *server { if themes == nil { themes = &themeSet{} } base = (&page{Root: base}).liveRoot() hl := newHighlighter(theme, light) sv := &server{log: log, repo: repo, title: title, user: user, base: base, session: "web:" + user, theme: theme, light: light, themes: themes, tpl: newTemplates(hl), models: map[string]*Site{}, trees: map[string][]*File{}, rendered: map[string]*File{}, tx: newTranscripts(defaultTranscriptRoots())} sv.hl = hl sv.reload(true) return sv } // reload re-reads the log when the sidecar commit id changes. func (sv *server) reload(force bool) { state, err := sv.log.Generation() if err == nil && state == sv.state && !force { return } if err != nil { if sv.site == nil { sv.site = sv.dress(buildModel(sv.title, nil, "")) } return } b, err := sv.log.Read() if err != nil { if sv.site == nil { sv.state = state sv.site = sv.dress(buildModel(sv.title, nil, "")) sv.gen++ } return } sv.entries = parseLog(b) sv.state = state 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{} } 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 == "" { 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 sv.def == "" { if id, err := resolveChange(sv.repo, "@"); err == nil { sv.def = id } else { sv.def = sv.site.Change } } } return sv.site, sv.gen, sv.def } // 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, commit) if err != nil { return nil } f := site.filesAt(paths, change) sv.trees[key] = f return f } func (sv *server) files(site *Site, commit, change string) []*File { sv.mu.Lock() defer sv.mu.Unlock() 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: 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() 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, commit, 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 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) { full, _, def := sv.snap() q := r.URL.Query() path := sv.base + strings.TrimPrefix(r.URL.Path, "/") p := &page{Site: full, Kind: kind, Title: title, Root: sv.base, Live: true, User: sv.user, ChangeID: def, Default: def, Path: 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.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 } 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) { 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 /note/{id}", sv.notePage) 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) mux.HandleFunc("GET /change/{id}", sv.changePage) mux.HandleFunc("GET /style.css", sv.styleCSS) mux.HandleFunc("GET /themes.css", sv.themeAsset) mux.HandleFunc("GET /themes.js", sv.themeAsset) mux.HandleFunc("GET /app.js", sv.asset) mux.HandleFunc("GET /htmx.min.js", sv.asset) mux.HandleFunc("GET /bemtevi.svg", 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] } p.Files = sv.files(site, p.Commit, p.ChangeID) 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.Commit, 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.Commit, 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.Commit, 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) } // notePage is one note on its own — the permalink. It is looked up in the // whole-log model, never the scoped one, so a link handed to someone else // opens whatever change they arrive on. // // The page then reads at the change the note was written at, because that is // where its line numbers mean anything; ?rev= still overrides, for a reader // asking what those lines say now. func (sv *server) notePage(w http.ResponseWriter, r *http.Request) { full, _, _ := sv.snap() n := full.NoteByID(atoiDefault(r.PathValue("id"), 0)) if n == nil { http.Error(w, "no note with that id in this log", http.StatusNotFound) return } p, site, err := sv.newPage(r, "note", noteTitle(n)+" — "+sv.title) if err != nil { p.Err = err.Error() sv.render(w, p) return } p.Note, p.Thread, p.Current = n, full.NoteThread(n.ID), n.File if p.Rev == "" && n.Change != "" && n.Change != p.ChangeID { if row, rerr := changeBrief(sv.repo, n.Change); rerr == nil { p.ChangeID, p.Commit, p.ChangeInfo = row.ID, row.Commit, &row if row.ID != p.Default { p.Rev = n.Change } site = sv.rescope(p) } else { p.ExErr = "the change this note was written at is no longer in the repository" } } if n.File != "" && n.Start > 0 && p.ExErr == "" { var cov []uint8 if bf, ok := site.Files[n.File]; ok { cov = bf.Cov } src, ferr := fileShow(sv.repo, p.Commit, n.File) switch { case ferr != nil: p.ExErr = "not present at this change" case bytes.IndexByte(src, 0) >= 0: p.ExErr = "binary file — not rendered" default: p.Excerpt, p.ExFrom, p.ExTo = excerptFor(n.File, src, cov, sv.hl, n) } } sv.render(w, p) } // noteTitle names a note the way a browser tab and a link preview should: the // lines it is about, or its id when it is pinned to nothing. func noteTitle(n *Note) string { if n.File == "" { return "note #" + strconv.Itoa(n.ID) } name := n.File if i := strings.LastIndex(name, "/"); i >= 0 { name = name[i+1:] } if n.Start > 0 { name += ":" + strconv.Itoa(n.Start) if n.End > n.Start { name += "-" + strconv.Itoa(n.End) } } return name } 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 } // 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 full, _, def := sv.snap() replyText := strings.TrimSpace(r.FormValue("reply_to")) replyTo := 0 if replyText != "" { var rerr error replyTo, rerr = strconv.Atoi(replyText) if rerr != nil || replyTo < 1 || full.NoteByID(replyTo) == nil { http.Error(w, "reply target is not a note in this log", http.StatusBadRequest) return } } rev := strings.TrimSpace(r.FormValue("rev")) target := strings.TrimSpace(r.FormValue("change")) for _, fallback := range []string{rev, def, "@"} { if target != "" { break } target = fallback } change, commit, err := resolveRevision(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 { 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, Commit: commit, File: strings.TrimSpace(r.FormValue("file")), Start: start, End: end, ReplyTo: replyTo, Type: strings.TrimSpace(r.FormValue("kind")), Text: text, } sv.mu.Lock() err = appendEntry(sv.log, &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 } // 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") if back == "" { back = sv.base + "notes" } http.Redirect(w, r, back, http.StatusSeeOther) return } p := &page{Site: site, Kind: "file", Root: sv.base, 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, Root: sv.base, Live: true, Compact: compact, Window: "5 min", Sessions: site.Sessions(time.Now(), liveWindow, limit)} 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 func (sv *server) jjView(r *http.Request) *JJView { q := r.URL.Query() v := &JJView{Root: sv.base, 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{Root: sv.base, 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, 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) } // ── 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) } // themeAsset serves the two files the theme picker needs. Both are written // once at startup — the machine's themes do not change while we watch a log. func (sv *server) themeAsset(w http.ResponseWriter, r *http.Request) { body, ctype := sv.themes.JS, "text/javascript; charset=utf-8" if strings.HasSuffix(r.URL.Path, ".css") { body, ctype = sv.themes.CSS, "text/css; charset=utf-8" } w.Header().Set("Content-Type", ctype) w.Header().Set("Cache-Control", "max-age=300") w.Write(body) } 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", assetType(name)) w.Header().Set("Cache-Control", "max-age=300") w.Write(b) } // assetType covers what embedded assets/ actually holds: scripts and the mark. func assetType(name string) string { if strings.HasSuffix(name, ".svg") { return "image/svg+xml" } return "text/javascript; charset=utf-8" } func serve(addr string, log logStore, repo, title, user string, roots []string, theme, light *Theme, themes *themeSet) { sv := newServer(log, repo, title, user, theme, light, themes) sv.tx = newTranscripts(roots) site, _, def := sv.snap() fmt.Printf("notevi web: http://%s — %d notes, %d agents, repo %s at %s\n", addr, len(site.Notes), len(site.Agents), repo, short(def)) fmt.Printf("notevi web: themes — %s dark, %s light, %d more to pick from (%d zed, %d helix)\n", theme.Name, light.Name, len(sv.themes.List), sv.themes.count("zed"), sv.themes.count("helix")) fmt.Printf("notevi web: comments are appended to %s; the project working copy is never written to\n", log) fmt.Printf("notevi web: session transcripts read from %s\n", strings.Join(sv.tx.roots, ", ")) if err := http.ListenAndServe(addr, sv.handler()); err != nil { fatal("%v", err) } }