diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-01 16:18:19 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-01 18:21:40 -0300 |
| commit | 151ea12251ed876aa87bb486da1bc337e25369e9 (patch) | |
| tree | 0363d90b5df6fc8b17b1737ef2793ba3825ba4d4 /vrsite/serve.go | |
| parent | 8ec4e135e318c7799b2576a8729493c063da4a97 (diff) | |
| download | notevi-151ea12251ed876aa87bb486da1bc337e25369e9.tar.gz notevi-151ea12251ed876aa87bb486da1bc337e25369e9.zip | |
Diffstat (limited to 'vrsite/serve.go')
| -rw-r--r-- | vrsite/serve.go | 118 |
1 files changed, 110 insertions, 8 deletions
diff --git a/vrsite/serve.go b/vrsite/serve.go index c6e1ef1..31217af 100644 --- a/vrsite/serve.go +++ b/vrsite/serve.go @@ -47,6 +47,7 @@ type server struct { tpl *template.Template hl *highlighter theme, light *Theme + themes *themeSet tx *transcripts mu sync.Mutex @@ -64,12 +65,16 @@ type server struct { // 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 { +func newServer(logPath, repo, title, user string, theme, light *Theme, themes *themeSet) *server { + if themes == nil { + themes = &themeSet{} + } + hl := newHighlighter(theme, light) sv := &server{logPath: logPath, repo: repo, title: title, user: user, - session: "web:" + user, theme: theme, light: light, - tpl: newTemplates(), models: map[string]*Site{}, trees: map[string][]*File{}, + 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 = newHighlighter(theme, light) + sv.hl = hl sv.reload(true) return sv } @@ -332,6 +337,7 @@ func (sv *server) handler() http.Handler { 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) @@ -341,6 +347,8 @@ func (sv *server) handler() http.Handler { 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 @@ -406,6 +414,75 @@ func (sv *server) notesPage(w http.ResponseWriter, r *http.Request) { 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) @@ -444,7 +521,17 @@ func (sv *server) addNote(w http.ResponseWriter, r *http.Request) { } // 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() + 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, "@"} { @@ -470,7 +557,8 @@ func (sv *server) addNote(w http.ResponseWriter, r *http.Request) { 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, + Start: start, End: end, ReplyTo: replyTo, + Type: strings.TrimSpace(r.FormValue("kind")), Text: text, } sv.mu.Lock() @@ -639,6 +727,18 @@ func (sv *server) styleCSS(w http.ResponseWriter, r *http.Request) { 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) @@ -651,12 +751,14 @@ func (sv *server) asset(w http.ResponseWriter, r *http.Request) { w.Write(b) } -func serve(addr, logPath, repo, title, user string, roots []string, theme, light *Theme) { - sv := newServer(logPath, repo, title, user, theme, light) +func serve(addr, logPath, repo, title, user string, roots []string, theme, light *Theme, themes *themeSet) { + sv := newServer(logPath, 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 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 { |
