summaryrefslogtreecommitdiff
path: root/vrsite/static.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/static.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/static.go')
-rw-r--r--vrsite/static.go247
1 files changed, 0 insertions, 247 deletions
diff --git a/vrsite/static.go b/vrsite/static.go
deleted file mode 100644
index 0a8eec0..0000000
--- a/vrsite/static.go
+++ /dev/null
@@ -1,247 +0,0 @@
-// static.go — the offline export: the same pages written out as files.
-//
-// Everything that needs a server (comments, live activity, jj queries) is
-// omitted; the trace/notes toggle, the file finder and the note filters all
-// keep working, since they run in the browser.
-package main
-
-import (
- "bytes"
- "fmt"
- "html/template"
- "os"
- "os/exec"
- "path/filepath"
- "runtime"
- "strconv"
- "strings"
- "sync"
- "time"
-)
-
-func run(dir string, name string, args ...string) []byte {
- cmd := exec.Command(name, args...)
- cmd.Dir = dir
- out, err := cmd.Output()
- if err != nil {
- if ee, ok := err.(*exec.ExitError); ok {
- os.Stderr.Write(ee.Stderr)
- }
- fatal("%s %s: %v", name, strings.Join(args, " "), err)
- }
- return out
-}
-
-func buildSite(log logStore, repoAbs, out, title string, theme, light *Theme, themes *themeSet) {
- start := time.Now()
- entries := readLog(log)
- ranked := buildModel(title, entries, "").Ranked
- if len(ranked) == 0 {
- fatal("log has no jj change ids")
- }
- // the most-logged change this repo actually has: a log can span repos, and
- // the busiest change in it may belong to another one
- var change string
- var list []byte
- for _, c := range ranked {
- out, err := jjRun(repoAbs, "file", "list", "-r", c)
- if err == nil {
- change, list = c, out
- break
- }
- }
- if change == "" {
- fatal("none of the log's %d changes are in %s — logs are per repository; point -repo at the one the trace was recorded in",
- len(ranked), repoAbs)
- }
- // an export is a picture of one change, so it holds only what was
- // recorded there; the notes page says how much of the log that leaves out
- site := buildModel(title, entries, change)
- site.Change = change
- site.Theme, site.Light, site.Repo = theme, light, repoAbs
- site.All = site.filesAt(strings.Split(strings.TrimRight(string(list), "\n"), "\n"), change)
-
- commit := strings.TrimSpace(string(mustJJ(repoAbs, "log", "-r", site.Change, "--no-graph", "-T", "commit_id")))
- tmp, err := os.MkdirTemp("", "vrsite-")
- if err != nil {
- fatal("%v", err)
- }
- defer os.RemoveAll(tmp)
- archive := exec.Command("git", "-C", repoAbs, "archive", commit)
- untar := exec.Command("tar", "-x", "-C", tmp)
- untar.Stdin, _ = archive.StdoutPipe()
- untar.Stderr = os.Stderr
- if err := untar.Start(); err != nil {
- fatal("tar: %v", err)
- }
- if err := archive.Run(); err != nil {
- fatal("git archive %s: %v", short(commit), err)
- }
- if err := untar.Wait(); err != nil {
- fatal("tar: %v", err)
- }
-
- writeSite(site, out, tmp, themes)
- stubs := 0
- for _, f := range site.All {
- if f.Stub != "" {
- stubs++
- }
- }
- fmt.Printf("vrsite: %d files (%d traced, %d not rendered), %d notes, %d agents in %s -> %s\n",
- len(site.All), len(site.Order), stubs, len(site.Notes), len(site.Agents),
- time.Since(start).Round(time.Millisecond), out)
-}
-
-func writeSite(site *Site, out, srcDir string, themes *themeSet) {
- if themes == nil {
- themes = &themeSet{}
- }
- hl := newHighlighter(site.Theme, site.Light)
- tpl := newTemplates(hl)
- if err := os.MkdirAll(out, 0o755); err != nil {
- fatal("%v", err)
- }
-
- each := func(fn func(f *File)) {
- sem := make(chan struct{}, runtime.NumCPU())
- var wg sync.WaitGroup
- for _, f := range site.All {
- wg.Add(1)
- sem <- struct{}{}
- go func(f *File) {
- defer func() { <-sem; wg.Done() }()
- fn(f)
- }(f)
- }
- wg.Wait()
- }
- // phase A: content — coverage percentages must exist before any tree
- // or index below is written
- each(func(f *File) {
- src, err := os.ReadFile(filepath.Join(srcDir, f.Path))
- switch {
- case err != nil:
- f.Stub = "not present in the archived commit"
- 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, hl)
- }
- })
-
- cf := mustCreate(filepath.Join(out, "style.css"))
- if err := renderCSS(cf, site); err != nil {
- fatal("css: %v", err)
- }
- cf.Close()
- for _, name := range []string{"app.js"} {
- b, err := tfs.ReadFile("assets/" + name)
- if err != nil {
- fatal("%v", err)
- }
- if err := os.WriteFile(filepath.Join(out, name), b, 0o644); err != nil {
- fatal("%v", err)
- }
- }
- // the picker travels with the export: a <script src>/<link> pair loads
- // from the filesystem, which is why the menu is a script and not JSON
- for name, b := range map[string][]byte{"themes.css": themes.CSS, "themes.js": themes.JS} {
- if err := os.WriteFile(filepath.Join(out, name), b, 0o644); err != nil {
- fatal("%v", err)
- }
- }
-
- // shared tree fragment: the only nav tree, fetched by every page; "@@" is
- // replaced with the page root by app.js, since the fragment is fetched
- // from any page depth
- treeFrag := renderTree(site.All, func(p string) string { return "@@code/" + p + ".html" })
- if err := os.WriteFile(filepath.Join(out, "tree.html"), []byte(treeFrag), 0o644); err != nil {
- fatal("%v", err)
- }
-
- facets := site.Facets()
- var info *Row
- if rows, err := LogRows(site.Repo, site.Change, "", 1); err == nil {
- for _, row := range rows {
- if !row.Blank() {
- r := row
- info = &r
- break
- }
- }
- }
- render := func(path string, p *page) {
- p.Site, p.Facets = site, facets
- p.ChangeID, p.Default, p.ChangeInfo = site.Change, site.Change, info
- p.Scope, p.Here = "change", site.NotesAt[site.Change]
- p.Elsewhere = site.NotesTotal - p.Here
- p.Files = site.All
- f := mustCreate(path)
- if err := tpl.Execute(f, p); err != nil {
- fatal("render %s: %v", path, err)
- }
- f.Close()
- }
-
- latest := site.FilterNotes(NoteFilter{})
- if len(latest) > 24 {
- latest = latest[:24]
- }
- render(filepath.Join(out, "index.html"), &page{Kind: "index", Title: site.Title, Notes: latest})
-
- fullTree := renderTree(site.All, func(p string) string { return "code/" + p + ".html" })
- render(filepath.Join(out, "files.html"), &page{Kind: "files", Title: "files — " + site.Title,
- Tree: template.HTML(fullTree)})
-
- render(filepath.Join(out, "notes.html"), &page{Kind: "notes", Title: "notes — " + site.Title,
- Notes: site.FilterNotes(NoteFilter{})})
-
- // phase B: every file page, in parallel
- each(func(f *File) {
- dst := filepath.Join(out, "code", f.Path+".html")
- os.MkdirAll(filepath.Dir(dst), 0o755)
- depth := strings.Count(f.Path, "/") + 1
- render(dst, &page{Kind: "file", Title: f.Path + " — " + site.Title,
- Root: strings.Repeat("../", depth), Current: f.Path, File: f})
- })
-
- // phase C: a page per note, so a permalink handed to someone else opens
- // in the export exactly as it does in the live app. The source is read
- // again rather than kept from phase A: an export is a batch job, and the
- // alternative is holding every file in memory for the sake of a few
- // hundred excerpts.
- if err := os.MkdirAll(filepath.Join(out, "note"), 0o755); err != nil {
- fatal("%v", err)
- }
- for _, n := range site.Notes {
- p := &page{Kind: "note", Title: noteTitle(n) + " — " + site.Title,
- Root: "../", Current: n.File, Note: n, Thread: site.NoteThread(n.ID)}
- if n.File != "" && n.Start > 0 {
- src, err := os.ReadFile(filepath.Join(srcDir, n.File))
- switch {
- case err != nil:
- p.ExErr = "not present in the archived commit"
- case bytes.IndexByte(src, 0) >= 0:
- p.ExErr = "binary file — not rendered"
- default:
- var cov []uint8
- if f, ok := site.Files[n.File]; ok {
- cov = f.Cov
- }
- p.Excerpt, p.ExFrom, p.ExTo = excerptFor(n.File, src, cov, hl, n)
- }
- }
- render(filepath.Join(out, "note", strconv.Itoa(n.ID)+".html"), p)
- }
-}
-
-func mustCreate(path string) *os.File {
- f, err := os.Create(path)
- if err != nil {
- fatal("%v", err)
- }
- return f
-}