summaryrefslogtreecommitdiff
path: root/vrsite/main.go
diff options
context:
space:
mode:
Diffstat (limited to 'vrsite/main.go')
-rw-r--r--vrsite/main.go169
1 files changed, 169 insertions, 0 deletions
diff --git a/vrsite/main.go b/vrsite/main.go
new file mode 100644
index 0000000..0a525be
--- /dev/null
+++ b/vrsite/main.go
@@ -0,0 +1,169 @@
+// vrsite — the reading room for vr trace logs.
+//
+// A vr log records who read what, where, and what they thought about it. This
+// program projects that log onto the jj change the code was read at: the repo
+// tree with per-agent coverage colors, tree-sitter highlighted sources with
+// coverage-tinted line numbers (colors mix where agents overlap), and notes —
+// both the ones agents left and the ones you write in the browser.
+//
+// It runs as a live web app (default) or writes a static export (-out).
+// Either way the log is the only state: notes are appended to it, and the
+// repository is only ever read, never modified.
+//
+// Theme comes from a Zed theme JSON; per-agent colors come from the theme's
+// player (collaborator) palette.
+package main
+
+import (
+ "embed"
+ "flag"
+ "fmt"
+ "os"
+ "os/user"
+ "path/filepath"
+ "time"
+)
+
+//go:embed templates assets
+var tfs embed.FS
+
+func fatal(format string, a ...any) {
+ fmt.Fprintf(os.Stderr, "vrsite: "+format+"\n", a...)
+ os.Exit(1)
+}
+
+func main() {
+ logPath := flag.String("log", "", "vr trace log (jsonl)")
+ repo := flag.String("repo", "", "jj repo the trace was recorded in")
+ addr := flag.String("addr", "127.0.0.1:4200", "address to serve on")
+ who := flag.String("user", "", "name your browser notes are logged under (default: $USER)")
+ out := flag.String("out", "", "write a static site here instead of serving")
+ themePath := flag.String("theme", defaultTheme("dark"), "zed theme json for the dark scheme (default: discovered, else embedded One Dark)")
+ themeName := flag.String("theme-name", "", "theme name inside the family")
+ lightPath := flag.String("light-theme", defaultTheme("light"), "zed theme json for the light scheme (default: discovered, else embedded One Light)")
+ lightName := flag.String("light-theme-name", "", "theme name inside the light family")
+ title := flag.String("title", "vr trace", "site title")
+ watch := flag.Bool("watch", false, "with -out: watch the trace log and rebuild after changes settle")
+ debounce := flag.Duration("debounce", 10*time.Minute, "quiet period before a watched rebuild")
+ flag.Parse()
+ if *logPath == "" || *repo == "" {
+ fatal("usage: vrsite -log FILE -repo DIR [-addr HOST:PORT] [-user NAME]\n" +
+ " vrsite -log FILE -repo DIR -out DIR [-watch] [-debounce 10m]\n" +
+ " shared: [-theme zed.json] [-theme-name N] [-light-theme f] [-title T]")
+ }
+ repoAbs, err := filepath.Abs(*repo)
+ if err != nil {
+ fatal("%v", err)
+ }
+ if *debounce < 0 {
+ fatal("debounce must not be negative")
+ }
+ theme := loadTheme(*themePath, *themeName, "dark")
+ light := loadTheme(*lightPath, *lightName, "light")
+
+ if *out == "" {
+ if *watch {
+ fatal("-watch is for -out builds; the served app already follows the log")
+ }
+ serve(*addr, *logPath, repoAbs, *title, username(*who), theme, light)
+ return
+ }
+ build := func() { buildSite(*logPath, repoAbs, *out, *title, theme, light) }
+ build()
+ if !*watch {
+ return
+ }
+ fmt.Printf("vrsite: watching %s (rebuild after %s without changes)\n", *logPath, *debounce)
+ watchLog(*logPath, *debounce, build)
+}
+
+func username(flagValue string) string {
+ if flagValue != "" {
+ return flagValue
+ }
+ if u := os.Getenv("USER"); u != "" {
+ return u
+ }
+ if u, err := user.Current(); err == nil && u.Username != "" {
+ return u.Username
+ }
+ return "web"
+}
+
+// ── watching the log (static export only) ─────────────────────────────────
+
+type logState struct {
+ size int64
+ modTime time.Time
+}
+
+func statLog(path string) (logState, error) {
+ info, err := os.Stat(path)
+ if err != nil {
+ return logState{}, err
+ }
+ return logState{size: info.Size(), modTime: info.ModTime()}, nil
+}
+
+func watchLog(path string, debounce time.Duration, rebuild func()) {
+ watchLogUntil(path, debounce, time.Second, nil, rebuild)
+}
+
+func watchLogUntil(path string, debounce, poll time.Duration, done <-chan struct{}, rebuild func()) {
+ state, err := statLog(path)
+ if err != nil {
+ fatal("watch %s: %v", path, err)
+ }
+
+ ticker := time.NewTicker(poll)
+ defer ticker.Stop()
+ var timer *time.Timer
+ var timerC <-chan time.Time
+ defer func() {
+ if timer != nil {
+ timer.Stop()
+ }
+ }()
+ lastStatErr := ""
+
+ for {
+ select {
+ case <-done:
+ return
+
+ case <-ticker.C:
+ next, err := statLog(path)
+ if err != nil {
+ if msg := err.Error(); msg != lastStatErr {
+ fmt.Fprintf(os.Stderr, "vrsite: watch %s: %v\n", path, err)
+ lastStatErr = msg
+ }
+ continue
+ }
+ lastStatErr = ""
+ if next == state {
+ continue
+ }
+ state = next
+ if timer == nil {
+ timer = time.NewTimer(debounce)
+ timerC = timer.C
+ } else {
+ if !timer.Stop() {
+ select {
+ case <-timer.C:
+ default:
+ }
+ }
+ timer.Reset(debounce)
+ }
+ fmt.Printf("vrsite: change detected; rebuild scheduled after %s without changes\n", debounce)
+
+ case <-timerC:
+ fmt.Println("vrsite: rebuilding after trace changes settled")
+ timer = nil
+ timerC = nil
+ rebuild()
+ }
+ }
+}