From 5ec72f8723d795d0b02d7e9ebb9f555f0c7e6a2e Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sun, 2 Aug 2026 23:43:08 -0300 Subject: 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) --- web/static.go | 247 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 247 insertions(+) create mode 100644 web/static.go (limited to 'web/static.go') diff --git a/web/static.go b/web/static.go new file mode 100644 index 0000000..3f3e766 --- /dev/null +++ b/web/static.go @@ -0,0 +1,247 @@ +// 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 web + +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("", "notevi-") + 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("notevi web: %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", "bemtevi.svg"} { + 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