summaryrefslogtreecommitdiff
path: root/vrsite/serve.go
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-01 16:18:19 -0300
committerGabriel Schneider <[email protected]>2026-08-01 18:21:40 -0300
commit151ea12251ed876aa87bb486da1bc337e25369e9 (patch)
tree0363d90b5df6fc8b17b1737ef2793ba3825ba4d4 /vrsite/serve.go
parent8ec4e135e318c7799b2576a8729493c063da4a97 (diff)
downloadnotevi-151ea12251ed876aa87bb486da1bc337e25369e9.tar.gz
notevi-151ea12251ed876aa87bb486da1bc337e25369e9.zip
Diffstat (limited to 'vrsite/serve.go')
-rw-r--r--vrsite/serve.go118
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 {