diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-02 23:43:08 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-03 09:54:39 -0300 |
| commit | 5ec72f8723d795d0b02d7e9ebb9f555f0c7e6a2e (patch) | |
| tree | f12f4b700fff4ff6f7e818d2a542672ba0b8d76e /vrsite/serve.go | |
| parent | a9263daee9413c5eff3c1f1bebcd224442fd8685 (diff) | |
| download | notevi-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.go | 765 |
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) - } -} |
