summaryrefslogtreecommitdiff
path: root/vrsite/serve.go
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-02 23:43:08 -0300
committerGabriel Schneider <[email protected]>2026-08-03 09:54:39 -0300
commit5ec72f8723d795d0b02d7e9ebb9f555f0c7e6a2e (patch)
treef12f4b700fff4ff6f7e818d2a542672ba0b8d76e /vrsite/serve.go
parenta9263daee9413c5eff3c1f1bebcd224442fd8685 (diff)
downloadnotevi-5ec72f8723d795d0b02d7e9ebb9f555f0c7e6a2e.tar.gz
notevi-5ec72f8723d795d0b02d7e9ebb9f555f0c7e6a2e.zip
rebrand to notevi: one CLI over the jj sidecar
vr and vrsite become a single binary. vrsite/ folds into a web package in one module (0x4200.cafe/notevi); "notevi web" serves and exports exactly what vrsite did, and "notevi read/grep/note/query" is unchanged. The sidecar is renamed with it: notevi_log, notevi-log.jsonl, and the description "private: notevi log". The pre-rebrand names are still recognized, so an old repository opens and reads; it is renamed in place on the first write, or up front with "notevi migrate DIR...". That rename cannot be a single mv inside jj run. jj only auto-tracks a *new* file in the run working copy below a size limit it does not take from the command line, so writing a whole log under a name the change has never held is silently dropped while jj reports success. ensureLogFile creates the file empty first and lets every later byte be a modification of a tracked file, which snapshots at any size; that also fixes the same latent bug when importing a large legacy vr-log.jsonl. Adds a bem-te-vi mark (favicon and nav brand) and a README. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Diffstat (limited to 'vrsite/serve.go')
-rw-r--r--vrsite/serve.go765
1 files changed, 0 insertions, 765 deletions
diff --git a/vrsite/serve.go b/vrsite/serve.go
deleted file mode 100644
index 746abac..0000000
--- a/vrsite/serve.go
+++ /dev/null
@@ -1,765 +0,0 @@
-// serve.go — vrsite 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 vr 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 main
-
-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)
- 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", "text/javascript; charset=utf-8")
- w.Header().Set("Cache-Control", "max-age=300")
- w.Write(b)
-}
-
-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("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: 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("vrsite: comments are appended to %s; the project working copy is never written to\n", log)
- 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)
- }
-}