diff options
| author | Gabriel Schneider <[email protected]> | 2026-07-24 11:39:36 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-07-30 15:18:55 -0300 |
| commit | cb05c363045bf0e7af5858f6d7bc26f026fa9d70 (patch) | |
| tree | c3e89426ff044ffe339dd3a77e7f3b7115cba636 /vrsite/serve.go | |
| download | notevi-cb05c363045bf0e7af5858f6d7bc26f026fa9d70.tar.gz notevi-cb05c363045bf0e7af5858f6d7bc26f026fa9d70.zip | |
Diffstat (limited to 'vrsite/serve.go')
| -rw-r--r-- | vrsite/serve.go | 522 |
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) + } +} |
