diff options
Diffstat (limited to 'vrsite/serve.go')
| -rw-r--r-- | vrsite/serve.go | 253 |
1 files changed, 198 insertions, 55 deletions
diff --git a/vrsite/serve.go b/vrsite/serve.go index c73630c..c6e1ef1 100644 --- a/vrsite/serve.go +++ b/vrsite/serve.go @@ -1,10 +1,24 @@ // 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. +// The server owns nothing. Three rules keep it that way, and app_test.go holds +// each of them: +// +// - The log is the only thing written. Notes typed in the browser are +// appended to it exactly as vr appends them; the repository is only ever +// read (see jj.go), and no file of the app's own is ever created. +// - 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 ( @@ -33,6 +47,7 @@ type server struct { tpl *template.Template hl *highlighter theme, light *Theme + tx *transcripts mu sync.Mutex site *Site @@ -41,14 +56,19 @@ type server struct { 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 + 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(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{}} + tpl: newTemplates(), models: map[string]*Site{}, trees: map[string][]*File{}, + rendered: map[string]*File{}, tx: newTranscripts(defaultTranscriptRoots())} sv.hl = newHighlighter(theme, light) sv.reload(true) return sv @@ -63,8 +83,7 @@ func (sv *server) reload(force bool) { } if err != nil { if sv.site == nil { - sv.site = buildModel(sv.title, nil) - sv.site.Theme, sv.site.Light = sv.theme, sv.light + sv.site = sv.dress(buildModel(sv.title, nil, "")) } return } @@ -84,61 +103,108 @@ func (sv *server) reload(force bool) { 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.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{} } -// 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) 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 == "" { - def := sv.site.Change - if def == "" { - 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 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 + if sv.def == "" { + if id, err := resolveChange(sv.repo, "@"); err == nil { + sv.def = id + } else { + sv.def = sv.site.Change + } } - 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 { +// 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, change) + paths, err := fileList(sv.repo, commit) if err != nil { return nil } f := site.filesAt(paths, change) - sv.trees[change] = f + sv.trees[key] = f return f } -func (sv *server) files(site *Site, change string) []*File { +func (sv *server) files(site *Site, commit, change string) []*File { sv.mu.Lock() defer sv.mu.Unlock() - return sv.filesLocked(site, change) + 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 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) +// 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() @@ -155,7 +221,7 @@ func (sv *server) fileAt(site *Site, gen int, change, path string) *File { if f.Change == "" { f.Change = change } - src, err := fileShow(sv.repo, change, path) + src, err := fileShow(sv.repo, commit, path) switch { case err != nil: f.Stub = "not present at this change" @@ -178,20 +244,56 @@ func (sv *server) fileAt(site *Site, gen int, change, path string) *File { // ── request plumbing ────────────────────────────────────────────────────── -// newPage resolves ?rev= and fills in everything the layout needs. +// 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) { - 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 + full, _, def := sv.snap() + q := r.URL.Query() + p := &page{Site: full, Kind: kind, Title: title, Root: "/", Live: true, + User: sv.user, ChangeID: def, Default: def, Path: r.URL.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.Rev, p.ChangeID = rev, id + 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 } - return p, site, nil + 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) { @@ -233,6 +335,7 @@ func (sv *server) handler() http.Handler { 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) @@ -254,6 +357,7 @@ func (sv *server) index(w http.ResponseWriter, r *http.Request) { if len(p.Notes) > 24 { p.Notes = p.Notes[:24] } + p.Files = sv.files(site, p.Commit, p.ChangeID) sv.render(w, p) } @@ -264,7 +368,7 @@ func (sv *server) filesPage(w http.ResponseWriter, r *http.Request) { sv.render(w, p) return } - p.Tree = template.HTML(renderTree(sv.files(site, p.ChangeID), p.codeLink())) + p.Tree = template.HTML(renderTree(sv.files(site, p.Commit, p.ChangeID), p.codeLink())) sv.render(w, p) } @@ -275,7 +379,7 @@ func (sv *server) treeFrag(w http.ResponseWriter, r *http.Request) { return } w.Header().Set("Content-Type", "text/html; charset=utf-8") - fmt.Fprint(w, renderTree(sv.files(site, p.ChangeID), p.codeLink())) + fmt.Fprint(w, renderTree(sv.files(site, p.Commit, p.ChangeID), p.codeLink())) } func (sv *server) codePage(w http.ResponseWriter, r *http.Request) { @@ -288,7 +392,7 @@ func (sv *server) codePage(w http.ResponseWriter, r *http.Request) { } _, gen, _ := sv.snap() p.Current = path - p.File = sv.fileAt(site, gen, p.ChangeID, path) + p.File = sv.fileAt(site, gen, p.Commit, p.ChangeID, path) sv.render(w, p) } @@ -338,15 +442,25 @@ func (sv *server) addNote(w http.ResponseWriter, r *http.Request) { 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 + _, _, def := sv.snap() rev := strings.TrimSpace(r.FormValue("rev")) - if rev == "" { - rev = "@" + target := strings.TrimSpace(r.FormValue("change")) + for _, fallback := range []string{rev, def, "@"} { + if target != "" { + break + } + target = fallback } - change, err := resolveChange(sv.repo, rev) + change, err := resolveChange(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 { @@ -371,6 +485,12 @@ func (sv *server) addNote(w http.ResponseWriter, r *http.Request) { 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") @@ -406,6 +526,24 @@ func (sv *server) activity(w http.ResponseWriter, r *http.Request) { 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 @@ -479,8 +617,11 @@ func (sv *server) changePage(w http.ResponseWriter, r *http.Request) { return } p.Change = c - p.Rev, p.ChangeID = c.Row.ID, c.Row.ID + 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) } @@ -510,12 +651,14 @@ func (sv *server) asset(w http.ResponseWriter, r *http.Request) { w.Write(b) } -func serve(addr, logPath, repo, title, user string, theme, light *Theme) { +func serve(addr, logPath, repo, title, user string, roots []string, theme, light *Theme) { sv := newServer(logPath, repo, title, user, theme, light) + 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: 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 { fatal("%v", err) } |
